从零手写服务端框架

服务端开发是一个很笼统的概念,狭义讲,服务端开发只是后台程序员的逻辑开发,比如一些新功能(针对新数据的增删改查),或者游戏的新玩法等等;而广义上讲,服务端开发的工作会涉及除了:
web/移动客户端/游戏客户端等前端部分;
运维/工具等支持设施;
这两部分之外的所有开发工作。
个人认为,不论是做什么服务端开发,都大同小异,区别可能在于:
写web,解一下json,做一下增删改查。
写游戏,解一下私有协议,改进程状态。
当然,小说君只做过游戏,所以对web后端开发也不太了解,如有认识错误欢迎指正。

当讨论到游戏服务端的时候,我们首先想到的会是什么?要回答这个问题,我们需要从游戏服务端的需求起源说起。
定义问题
游戏对服务端的需求起源有两种:
第一种是单机游戏联网版。这种通常实现为主客机模式,主机部分可以看做服务端。
第二种是所有mmo(multiple massive online,大型多人在线,也就是我们常见的PC端网游,下面都简称mmo了)的雏形mud。这种跟webserver比较类似,一个host服务多clients,表现为cs(client server)架构。

第一种需求长盛不衰,一方面是console游戏一直以来都特别适合这一套,成本低,不需要大规模的房间服务器;另一方面是手游的发展现状,即使霸榜的还是那么几款MMO,但是碎片化的PVE玩法+开房间式同步PVP玩法的手游是大多数,毕竟MMO手游再怎么火也不可能改变手游时间碎片化的事实的,手游不会再重走端游老路了。
第二种需求就不用说了,网上大把例子可以参考。最典型的是假设有一个场景,场景中有很多玩家和怪,玩家和怪的位置、逻辑都在服务端驱动。

解决方案毕竟是不断发展的,即使速度很慢。

说不断发展是特指第一种需求的解决方案,发展原因就是国情,外挂太多。像war3这种都还是纯正的主客机,但是后来对战平台出现、发展,逐渐过渡成了cs架构。真正的主机其实是建在服务器的。
因此,服务器这边也维护了房间状态。后来的一系列ARPG端游也都是这个趋势,服务端越来越重,逐渐变得与第二种需求的解决方案没什么区别。 现在的各种ARPG手游也是一样。

说发展速度很慢特指针对第二种需求的解决方案,慢的原因也比较有意思,那就是魔兽世界成了不可逾越的鸿沟。bigworld在魔兽世界用之前名不见经传,魔兽世界用了之后国内厂商也跟进。发展了这么多年,现在的无缝世界服务端跟当年的无缝世界服务端并无二致。
发展慢的原因就观察来说可能需求本身就不是特别明确,MMO核心用户是重社交的,无缝世界核心用户是重体验的。前者跑去玩了天龙八部和倩女不干了,说这俩既轻松又妹子多;后者玩了console游戏也不干了,搞了半天MMO无缝世界是让我更好地刷刷刷的。
所以仔细想想,这么多年了,能说大成的无缝世界游戏除了天下就是剑3,但是收入却跟重社交的那几款(比如天龙八部)完全不在一个量级。许多引进了之后过段时间就销声匿迹的海外游戏就不说了,国内厂商的天刀和天谕其实相比剑3收入还是差挺多的。

接下来进入技术话题。
两种需求起源,最终其实导向了同一种业务需求。传统MMO架构(就是之前说的天龙、倩女类架构),一个进程维护多个场景,每个场景里多个玩家,额外的中心进程负责帮玩家从一个场景/进程切到另一个场景/进程。bigworld架构,如果剥离开其围绕切进程所做的一些外围设施,核心工作流程基本也能用这一段话描述。

再对问题做下抽象,我们谈到游戏服务端首先想到的就应该是多玩家客户端面对同一场景的视图同步,下面简称为场景服务。
如何实现场景服务?

首先,我们看手边工具,socket。

之所以不提TCP或UDP是因为要不要用UDP自己实现一套TCP是另一个待撕话题,本文不做讨论。
因此,我们假设,后续实现是建立在对传输层协议一无所知的前提之上的。这样,我们设计的时候不需要考虑各种协议的适配,只需要关注socket这一普适抽象。

socket大家都很熟悉,优点就是各操作系统上抽象统一。
因此,之前的问题可以规约为:如何用socket实现场景同步?

拓扑结构是这样的(之后的所有图片连接箭头的意思表示箭头指向的对于箭头起源的来说是静态的):

从零手写服务端框架_第1张图片
场景服务有两个核心需求:
低网络时延
富交互

要做到前者,最理想的情况就是由程序员把控完整的消息流,换句话说,就是不借助第三方的消息库/连接库。当然,例外是你对某些第三方连接库特别熟悉,比如很多C++服务端库喜欢用的libevent,或者mono中的IO模块。
要做到后者,就需要保持场景同步逻辑的简化,也就是说,场景逻辑最好是单线程的,并且跟IO无关,因为单位与单位之间、单位与玩家之间、玩家与玩家之间的会发生非常频繁的数据交互。其核心入口就是一个主循环,依次更新场景中的所有单位,刷新状态,并通知client。

正是由于这两个需求的存在,网络库的概念就出现了。网络库由于易于实现,概念简单,而且笼罩着「底层」光环,所以如果除去玩具性质的项目之外,网络库应该是程序员造过最多的轮子之一。

那么,网络库需要解决什么问题?

抛开多项目代码复用不谈,网络库首先解决的一点就是,将传输层的协议(stream-based的TCP协议或packet-based的UDP协议)转换为应用层的消息协议(通常是packet-based)。
对于业务层来说,接收到流和包的处理模型是完全不同的。对于业务逻辑程序员来说,包显然是处理起来更直观的。

流转包,基本都是借助一个缓冲区的概念来实现的。缓冲区的实现有很多,简单列几种:
最简单的可伸缩的non-trivial buffer,可以简单认为就是一个字节数组,快满了就扩容。
ringbuffer,固定大小的环形字节数组,可以构建无锁情景。
bufferlist,跟名字一样,就是buffer的list,扩容成本可以忽略不计。

不同的结构适用于不同的需求,有的方便做zero-copy,有的方便做无锁,有的纯粹图个省事。如果脱离了具体的应用情景跑分,谁一定比谁好都说不准。

buffer需要提供的语义也很简单,无非就是add、remove。buffer是只服务于网络库的。

网络库要解决的第二个问题是,为应用层建立IO模型。
之前提到过,场景服务具有富交互的特点,而poll模型可以避免大量共享状态的存在,理论上应该是最合适场景服务的。所谓poll,就是IO线程准备好数据放在消息队列中,用户线程负责轮询poll,这样,应用层的回调就是由用户线程进入的,保证模型简单。

而至于IO线程是如何准备数据的,平台不同做法不同。linux上最合适的做法是reactor,win最合适的做法就是proactor,一个例外是mono,mono跑在linux平台上的时候虽然IO库是reactor模型,但是在C#层面还是表现为proactor模型。reactor还是proactor是一个比较古老的话题了,最简单的理解可以认为前者是借助一个poll轮询在同一个线程接收数据,后者是不需要借助任何轮询而在多个IO线程接收数据。
提供统一poll语义的网络库可以隐藏这种平台差异,让应用层看起来就是统一的本线程poll,本线程回调。由于socket是全双工的,因此IO模型对于任意一侧都是适用的。

网络库要解决的第三个问题是,封装具体的连接细节。cs架构中一方是client一方是server,因此连接细节在两侧是不一样的。
连接细节的不同就体现在,client侧,核心需求是发起建立连接,外围需求是重连;server侧,核心需求是接受连接,外围需求是主动断开连接。而两边等到连接建立好,都可以基于这个连接构建同样的IO模型就可以了。

接下来简单介绍一种网络库实现。

一个连接好的socket对应一个connector。结构如下:

public interface IRemote
{
    string RemoteIp { get; }
    int RemotePort { get; }
    int Id { get; }    
    int Push(byte[] buffer, int len, int offset);
    int PushBegin(int len);
    int PushMore(byte[] buffer, int len, int offset);
}
public interface ILocal
{
    string RemoteIp { get; }    
    int RemotePort { get; }
}

internal class Connector : IRemote, ILocal
{
    private const int HeadLen = 4;    
    // system socket
    private Socket sysSocket;    

    // todo change to bufferlist
    // todo not ensure thread-safe yet
    private ConnectorBuffer receiveBuffer = new ConnectorBuffer();    
    private ConnectorBuffer sendBuffer = new ConnectorBuffer();    
    private readonly SwapContainer> msgQueue = new SwapContainer>();    

    // todo not implemented yet 
    private RC4 rc4Read;    
    private RC4 rc4Write;    

    // will be set to true when exception or ServerNetwork.Dispose,
    // after which Network will close this connection
    internal bool DefferedClose { get; private set; }    
    internal bool Connected { get; set; }    

    public int Id { get; private set; }    
    ///...
}

connector负责向上提供IO模型抽象(poll语义)。同时,其借助维护的两个buffer,来实现流转包和包转流。

网络库中的server部分主要组件是ServerNetwork,维护接受连接(与主动断开)与N条connector。

// context for socket listener
// manage all clients accepted
public class ServerNetwork
{    
    public int ClientCount { get { return clientConnectorsDict.Count; } }    

    // io thread pushes while user thread pops
    private readonly Dictionary<int, Connector> clientConnectorsDict = new Dictionary<int, Connector>();    
    // io thread pushes while user thread pops
    private readonly SwapContainer> toAddClientConnectors = new SwapContainer>();    

    // io or user thread pushes while user thread pops
    // currently, only user thread pushes
    private readonly SwapContainer> toRemoveClientConnectors = new SwapContainer>();    

    // connectorId for next accepted client
    private int nextClientConnectorId = 1;    

    //system socket
    private Socket listenSocket;    

    public void BeginAccept()
    {
        // ...
    }    
    public void Poll()
    {        
        // ...
    }    
    //...
}

网络库中的client部分主要组件是ClientNetwork,维护连接(与重连)与一条connector。从代码中可以看出来跟ServerNetwork稍有不同。

// context for connect socket
// manage just one connector, which means local client 
public class ClientNetwork
{
    class ConnectAsyncResult
    {
        public Exception Ex;
        public Connector Conn;
    }

    // compared to serverNetwork
    // clientNetwork hold one connector for connect socket only
    private Connector connector;

    private ConnectAsyncResult defferedConnected = null;

    // ip:port for host
    private readonly string hostIp;
    private readonly int hostPort;

    // system socket
    private readonly Socket sysSocket;

    // block api
    public void Connect()
    {
        //...
    }

    public void SendData(byte[] buffer)
    {
        //...
    }

    public void SendDatav(params byte[][] buffers)
    {
        //...
    }

    public void Poll()
    {
        //...
    }

    // user thread
    public void Close()
    {
        //...
    }

    // todo client only
    public void SetClientRc4Key(string key)
    {
        //...
    }
}

Network层面的协议非常简单,就是数据长度+数据。
有了网络库这个基础设施,我们其实就已经搞定了一个原型服务端「框架」了。
服务器可以借助网络库监听端口,提供服务。
客户端可以借助网络库与服务端建立连接,发送消息。
服务器接收到消息可以做处理,然后返回给客户端。

虽然很简陋,但是消息流已经基本完成了。

但是,如果是游戏服务端的话,显然还需要存档功能。
这点也比较简单,我们可以直接在服务器上集成一个数据库的客户端库,服务器启动时借助这个客户端库跟数据库建立连接。然后客户端发过来消息,服务器更新完玩家状态,把状态直接存回数据库即可。

这样一个简单的游戏服务端框架就搞定了。

这样一个东西,其实完全是可以支撑一个联网小游戏的,但是我们肯定不能止步于此。很随意就能对这个设计提出两个致命问题:
服务器存档直接调用数据库的同步API;
单机的处理连接数到达瓶颈。

你可能感兴趣的:(框架)