一款RPC框架的自我学习

(初学者见解,有错望指正)


RPC

RPC(Remote Procedure Call) —— 远程过程调用,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术。RPC协议假定某些传输协议的存在,如TCP或者UDP,为通信程序之间携带信息数据。在OSI网络通信模型中,RPC跨越了传输层和应用层。RPC使得开发包括网络分布式多程序在内的应用程序更加容易。
(源于:百度百科)


先举个通俗的例子:

  • 杨小胖想给自己暗恋的女神小芳表白,于是他白纸黑字写了一封信,让小夏转交给她。但是杨小胖只知道表白信应该交给了小夏,他并不知道小夏是怎样转交给小芳的。于是他静等小芳的回复。时间过得很快,久久没收收到回复,心急如焚的杨小胖,又写了一份问候信,又转交给小夏。时间过去了,杨小胖得知了答复。然而知道的竟然是问候信的回复,并不是期待的表白信答复。就当杨小胖垂头丧气的时候,不久又得知了表白信的答复。从此杨小胖再也没写过信。
  • 显而易见,信就是封装好的协议,里面有杨小胖的个人信息,以及函数名,参数等信息,明确杨小胖这封信要干什么。但是为什么杨小胖收到第一封回信就知道是问候信而不是表白信呢,因为聪明的他,在每次递交给小夏信的时候,就会让小夏注册一个回调,即回调函数。当收到回复的时候,就委托小夏执行回调函数。从而自己就得知了结果。
  • 此过程小夏就充当了代理的角色。

代理

这里简单介绍一下代理,这也是RPC框架不可或缺的部分。
那么什么是代理呢?小夏就是一个代理,直接上代码吧。

  • RPC代理类的基类
public abstract class RPCProxyBase {
	/**
	 * 异步注册监听RPC返回
	 * @param method
	 * @param context
	 */
	public abstract void listenResult(JowFunction2<Param, Param> method, Object...context);
	public abstract void listenResult(JowFunction3<Boolean, Param, Param> method, Object...context);
}

//---------------------------------------我是分界线-------------------------------------------------------------------

public abstract class RPCImpIBase {
	/**
	 * 根据函数Id 获取Service上的RPC函数
	 * 
	 * @param serv
	 * @param methOdKey
	 * @return
	 */
	public abstract <T> T getMethodFunction(Service serv, int methOdKey);
}
  • 小夏代理类(大致的代码结构)

import XXXX;

public static final class XiaoXiaServiceProxy extends RPCProxyBase {
	
	public fianl class EnumCall{
		public static final int CALL_WENHOU = 1;
		public static final int CALL_BIAOBAI = 2;
	}
	
	private String remote;//自己的简要信息
	private Port localPort;//任务线程

	/**
	 * 获取实例
	 * @param selfData 自己的简要信息
	 * @return
	 */
	private static XiaoXiaServiceProxy newInstance(String selfData){
		XiaoXiaServiceProxy inst = new XiaoXiaServiceProxy ();
		inst.remote = selfData;
		inst.localPort = Port.getCurrent();
		return inst ; 
	}


	//监听返回值
	public void listenResult(JowFunction1<Param> method, Object...context) {
		context.put("_callerInfo", remote)
		localPort.listenResult(method, context);//内部是一个Map存放函数指针,与上下文参数
	}

	public void sayHello(String content) {
			remote =  "sayHello";
			localPort.call(false, remote, EnumCall.CALL_WENHOU , new Object[]{ content});
	}
		
	public void sayLove(String content) {
			remote =  "sayLove";
			localPort.call(false, remote, EnumCall.CALL_BIAOBAI , new Object[]{ content});
	}
		
}

//---------------------------------------我是分界线-------------------------------------------------------------------


public final class XiaoXiaServiceImpl extends RPCImpIBase {
	/**
	 * 获取函数指针
	 */
	@Override	
	public Object getMethodFunction(Service service, int methodKey) {
		XiaoFangService serv = (XiaoFangService )service;
		switch (methodKey) {
			case EnumCall.CALL_WENHOU : {
				return (JowFunction1<String>)serv::isHello;
			}
			case EnumCall.CALL_BIAOBAI : {
				return (JowFunction1<Striong>)serv::isLove;
			}
			default: break;
		}
		return null;
	}
	
}

  • 服务基类
public abstract class Service {
	/** 所属Port */
	public final Port port;

	/** 缓存服务对应的代理类 */
	private RPCImpIBase methodFunctionProxy;

	public Service(Port port) {
		this.port = port;
	}

/**
	 * 获取RPC函数调用
	 * 
	 * @param funcKey
	 * @return
	 */
	public <T> T getMethodFunction(int funcKey) {
		try {
			// 获取对应的代理类
			if (methodFunctionProxy == null) {
				Class<?> cls = Class.forName(getClass().getName() + "Impl");
				Constructor<?> c = cls.getDeclaredConstructor();
				c.setAccessible(true);
				methodFunctionProxy = (RPCImpIBase) c.newInstance();
			}

			// 通过代理类 获取函数引用
			return methodFunctionProxy.getMethodFunction(this, funcKey);
		} catch (Exception e) {
			throw new SysException(e);
		}
	}

}
  • 小芳服务类
public final class XiaoFangService extends Service {

	@DistrMethod
	public void isHello(String content) {
		if (content == "HELLO"){
			port.returns("Hi");
		}
	}
	
	@DistrMethod
	public void isLove(String content) {
		port.returns("Sorry, No!");
	}
}

  • 杨小胖
public class YangXiaoPang {

private String selfData = "YangXiaoPang";

public static void sayHello() {
	XiaoXiaServiceProxy  proxy = new XiaoXiaServiceProxy(selfData );
	proxy.sayHello("HELLO");
	prx.listenResult(YangXiaoPang ::_result_isHello);
}

private static void _result_isHello(Parms results) {
	System.out.println(results.get());
}

public static void sayLove() {
	XiaoXiaServiceProxy  proxy = new XiaoXiaServiceProxy(selfData );
	proxy.sayHello("Like");
	prx.listenResult(YangXiaoPang ::_result_isLove);
}

private static void _result_isLove(Parms results) {
	System.out.println(results.get());
}
}
  • 静态代理与动态代理有什么区别呢?
    静态:由程序员创建代理类,在对其编译。在程序运行前代理类的 .class文件就已经存在了。
    动态:在程序运行时用反射机制动态创建而成。动态代理类只能代理接口(不支持抽象类),代理类都需要实现InvocationHandler类,实现invoke方法。该invoke方法就是调用被代理接口的所有方法时需要调用的,该invoke方法返回的值是被代理接口的一个实现类。
    详解:[link]http://www.cnblogs.com/baizhanshi/p/6611164.html

框架

服务器采用的是分布式框架,会开启多个进程来提供业务服务。譬如会有登陆服Loginsrv,连接服Connsrv,中心服Centralsrv,游戏服Gamesrv,当然还会有平台服。至于开启几个服务完全是根据业务去区分的。
在这里插入图片描述
上图大致描述了这个框架。

玩家登陆,会在连接服(ConnSrv)创建一个连接点——服务器Id,线程Id,连接Id,把他们都抽象成类就是,NodeId,PortId,ServiceId。连接点根据负载会被分配到连接服的某一个线程下。这个连接点也会绑定在玩家身上,便于随时通过连接服把数据发送给前端。连接服就是与客户端(Client)进行交互。登陆服会记录游戏服(GameSrv)当前已经注册的玩家,游戏服玩家数据加载之后会注册到中心服(CentralSrv),中心服有一个玩家管理的类(HumanGlobalService),玩家登陆之后更新自己的简要信息,名字头像等,还有自己处于游戏服(Node)的哪个线程(Port)下。

  • 那么不同的服务之间是怎么通信的呢?

在这里插入图片描述

上图Node代表一个进程即所属服务,下属Port是一条线程,Port管理着它的下属服务Service,所有的业务会在Service处理,Port负责任务调度和消息的分发,Node负责接收远程Node发来的消息,和转发自己发出的消息。每一个Service都可以通过ServiceId,PortId,NodeId,去定位。当LoginSrv想发起一个心跳的消息,检测ConnSrv与客户端连接通道是否存在时,LoginSev,会封装一个类——Call,该类记录了自己的NodeId,PortId,ServiceId;以及所需参数信息等。所属Port会判断,如果此Call是发给自己本属Node,那么会直接调用上级Node发送给所属Port,若不是,会调用上级发送给远程Node。当Node接收到一个远程的Call时,自己本地也会判断是否发送给自己本地Port,或者其他远程Node。当ConnSrv收到LoginSrv发来的心跳监测时,假如是第一次收到消息,ConnSrv会反向于LoginSrv关联起来,同时自己也会发送心跳监测到LoginSrv。由此在启动服务时并不是直接绑定远程的服务,可以通过相互发送消息,来灵活控制相互关系。那么这么多Call发送,怎么保证井然有序呢?正如开篇的例子,用到了代理。对于不同的服务,会生成不同的代理类。

整个框架是一个异步的过程,无需等待。当然也有需要等待的特殊的Call,如去DB取Id段,这个过程一定要同步等待。

  • 可见通信是用到了序列化与反射。大家都知道该过程很慢,这也是该框架的一个瓶颈。
    随着社会的发展,如今物理机有很强的计算能力,因此,假如把所有的业务放一个Node下,这样可以减少部分序列化过程,从而提高了一定的效率。当然,这是在物理机能承受的范围之内。

在对框架进行探讨:
假如我们需要做一个类似王者荣耀那种类型的游戏服务器,不同服的玩家可以在一起游戏,那么就需要再增加两个节点(如下图),即匹配服(MatchSrv),房间服(RoomSrv),这两个服要与中心服关联,与游戏服关联,与连接服关联,同时匹配服与房间服也互相关联。匹配服维护一个队列,每到一定人数,去RoomSrv创建场景(房间),把玩家放入里面。同时中心服,就需要记录更多的状态,玩家是处于匹配中还是游戏中。战斗之间的广播可以直接发给ConnSrv, 假如在战斗可能有数据的储存,可以把数据发回GameSrv处理。当然王者荣耀,战斗结算存储数据并不多,只有简单结算。

图2

假如需要做一个MMORPG类型的游戏,那么就需要引入场景的概念。这样就需要一个场景的管理类(StageGlobalService)。场景也是该类型游戏的比较复杂的地方,主要是负责消息的广播,和对场景中模型管理。当一个场景被new出来以后,会在StageGlobalService中注册简要的信息,即此场景是属于哪一个Node下,哪个Port下,哪个Service。当然至于放在哪个Port下,会根据负载分配的。哪个Port有几个场景服务,场景里有多少个玩家,StageGlobalService也是会有记录。同时还会检测销毁一些无用的场景。

  • 场景中会有什么呢?
    Human,Pet,Monster等。不同的场景有不同的Monster,这个是怎样建立联系呢,直接上图吧。
    在这里插入图片描述

在WordObject中会引用StageObject类,StageObject是所有地图的父类,StageObject也会有一个Map集合,存放着所有的WordObject,这样将角色与场景分开降低耦合性,易于管理。上图中也只是场景中的一部分,根据不同的需求可以拓展出很多子类。

数据安全问题

既然是多线程,那么是怎样保证数据安全呢?

举一个简单的例子,假如玩家张小胖处于某一个线程A下,通过小夏介绍他知道了小美的ID,于是张小胖想申请加个好友,通过小美的ID,在HumanGlobalService查询得知了她正在GameSrv2游戏服的线程B下。于是他向小美所在的线程发起一个封装好自己的ID等信息的Call,小美收到此Call时,若心情好就同意,把Call中携带的ID加到自己的好友列表里面,同时也发出一个Call回复杨小胖,“我同意了”。当杨小胖收到回复时,才把小美的ID加入自己的好友列表里面。反之若不同意,也要回复一个Call,“对方拒绝了你!”。

Port下的一个集合是ConcurrentLinkedQueue,它会存放接收的Call,这样形成一个队列。每一帧会处理一定数量的Call。当然Port的下属服务Service也会存放在ConcurrentHashMap集合中,而这两个集合是线程安全的。 由此可见,玩家数据交互都是自己在修改自己的数据,自己修改数据是在单线程下修改的,这样就不会出现线程安全问题,整个框架是不加锁的,可以支撑很高的并发量。

假如,做一个全服的活动,所有的玩家达到一定条件之后,可以得到一个iPad,限制数量只有十个。需要怎么做呢?可以在中心服开启一条线程,这个线程存放着iPad数量,玩家在游戏服达到条件后,只需要发送一个消息,到本线程来处理,这样单线程环境下就是先到先得,保证了数据的安全。当然是否开启线程,这个需要考虑物理机的CPU能力。实在不行的话,也可以开启另一台物理机,把该活动单独建立一个服。用一条线程去维护即可。

关于DB

数据都是自己封装好的一套,业务开发者无需关心内部的实现,调用即可。原理很简单,服务器其实就是对数据的增删改查,那么当加载DB时,会记录最初的状态,当我有数据改变时(get,set),会给数据做一个状态标记,是新增的,还是修改的,或者删除的。这样每过一段时间就会把数据向数据库同步一次,然后更新初始状态。

public class Record implements ISerilizable, IRecord {
	/** 主键名称 */
	public static final String PRIMARY_KEY_NAME = "id";
	
	/** 表名 */
	private String tableName;
	/** 字段当前实际数据 */
	private final Map<String, Object> values = new HashMap<>();
	/** 修改过的字段 */
	private final Set<String> fieldModified = new HashSet<>();
	
	/** 数据当前状态 1新增 2未修改 3有修改 4已删除 默认为新增 */
	private int status = DBConsts.RECORD_STATUS_NEW;
	/** !此属性不参与串行化 是否为一个刚刚生成的数据(新建或串行化) 用来做自动提交逻辑的一种特殊情况判断 */
	private boolean newness = true;
	
	/** 写缓存创建时间 */
	private long writeCacheCreateTime; 
	
	public Record() {
		
	}

	public Record(String tableName, ResultSet rs) {
		try {
			//设置状态
			status = DBConsts.RECORD_STATUS_NONE;
			
			//表名称
			this.tableName = tableName;
			
			//数据集信息
			ResultSetMetaData meta = rs.getMetaData();
			//字段定义信息
			FieldTable fs = getFieldTable();
			
			//初始化字段
			for (int i = 1; i <= meta.getColumnCount(); i++) {
				String name = meta.getColumnName(i);
				Field field = fs.getField(name);
				
				if (field.entityType == DBConsts.ENTITY_TYPE_INT) {
					values.put(name, rs.getInt(name));
				} else if (field.entityType == DBConsts.ENTITY_TYPE_LONG) {
					values.put(name, rs.getLong(name));
				} else if (field.entityType == DBConsts.ENTITY_TYPE_DOUBLE) {
					values.put(name, rs.getDouble(name));
				} else if (field.entityType == DBConsts.ENTITY_TYPE_STR) {
					values.put(name, rs.getString(name));
				} else if (field.entityType == DBConsts.ENTITY_TYPE_BYTES) {
					values.put(name, rs.getBytes(name));
				}
			}
		} catch (SQLException e) {
			throw new SysException(e);
		}
	}

	/**
	 * 创建一个新数据项
	 * @param tableName
	 */
	public static Record newInstance(String tableName) {
		Record r = new Record();
		
		//设置为新增状态
		r.status = DBConsts.RECORD_STATUS_NEW;
		
		//表名称
		r.tableName = tableName;
		
		//字段定义信息
		FieldTable fs = r.getFieldTable();
		
		//初始化字段及默认值
		for (Entry<String, Field> e : fs.entrySet()) {
			String name = e.getKey();
			Field field = e.getValue();
			
			if (field.entityType == DBConsts.ENTITY_TYPE_INT) {
				r.values.put(name, 0);
			} else if (field.entityType == DBConsts.ENTITY_TYPE_LONG) {
				r.values.put(name, 0L);
			} else if (field.entityType == DBConsts.ENTITY_TYPE_DOUBLE) {
				r.values.put(name, 0.0);
			} else if (field.entityType == DBConsts.ENTITY_TYPE_STR) {
				r.values.put(name, null);
			} else if (field.entityType == DBConsts.ENTITY_TYPE_BYTES) {
				r.values.put(name, null);
			}
		}
		
		return r;
	}


	/**
	 * 设置新数据
	 * @param name
	 * @param value
	 */
	@Override
	public void set(String name, Object value) {
		//记录新旧值
		Object valOld = values.get(name);
		Object valNew = value;
		
		//未修改 无需改动
		if (Utils.isEquals(valOld, valNew)) return;
		
		//记录新值
		values.put(name, valNew);
		
		//将数据设置为已修改
		setStatusToModified(name);
	}

	/**
	 * 修改数据状态
	 * 1.将数据状态设置为已修改
	 * 2.记录修改的字段
	 * @param name
	 */
	private void setStatusToModified(String name) {
		//修改数据状态为已修改
		if (status == DBConsts.RECORD_STATUS_NONE) {
			status = DBConsts.RECORD_STATUS_MODIFIED;
		}
		
		//只有当修改状态下 设置修改字段才有意义
		if (status == DBConsts.RECORD_STATUS_MODIFIED) {
			fieldModified.add(name);
		}
	}

	/**
	 * 获取数据信息
	 * @return
	 */
	public FieldTable getFieldTable() {
		return FieldTable.get(tableName);
	}

}

(上述只是展示了部分代码。)

ID池的分配

生成ID有很多种,常用的有通过数据库的递增,java的UUID利用时间戳,基于Redis的incr方法等,但是你会发现一个基于分布式的框架,要想做到高效而且能得到全服唯一的ID,以上都不能满足需求,所以我们对ID的分配池做了部分改进。

UUID用的是long型,约定UUID是非负数,去掉符号位还有63bit,2^63 = 92 2337 2036854775808 10进制一共是19位。将这19位做一个合理的划分:
18-19:平台Id,有效值0-91
14-17:服务器Id,有效值0-9999
1-13:逻辑Id
这样每一个服务器都有一个自己的ID池,但也能保证不出现重复的ID。

大家可能会想,为什么不直接用一个ID分配池呢,要这么麻烦。假如一个分配池,所有的请求都会去一个线程取,假如游戏开服,吸引了万级用户,都去一个线程去取数据,就像千军万马过独木桥,出现登陆队列长时间的等待,影响游戏体验。
线程去ID池申请ID时,是按段取,一次性取多少可以根据需求决定。

ZMQ

ZMQ是什么?
这个类似于Socket的一系列接口,他跟Socket的区别在于:Socket是端到端的(1 : 1)关系,而ZMQ却可以N:M的关系,人们对BSD套接字的了解较多的是点对点的连接,点对点连接需要显示地建立连接,销毁连接,选择协议(TCP/UDP)和处理错误等,而ZMQ屏蔽了这些细节,让网络编程更为简单。ZMQ用于Node与Node间的通信,Node可以使主机或者进程。

Netty

关于netty有很多博客,就不介绍了。需要指出的是,Netty优点在于快,而且它还能很好的解决消息的拆包和粘包问题,在前后端定义协议的时候,可以约定一下每个消息的长度,用前四位表示,中间四位代表消息的ID,这样可以很好的解决拆包问题。
附上推荐的连接[link]https://blog.csdn.net/TheLudlows/article/details/84993154


以上算是对该框架的一个总结吧,期中部分代码摘自原游戏框架。如有错误,望留言指正。谢谢!

猜你喜欢

转载自blog.csdn.net/qq_39742510/article/details/89150552
今日推荐