记录link包的重构(1)

Go设计分享 by 达达 at 2014-08-07

link包的代码虽少,但经过了很多次API调整,我觉得其中的思考过程和设计思路是很值得分享的。

首先我从Server.Start()说起,从当前版本的API你找不到Server.Start()这个方法了,但是最初设计Server类型时很自热而然的就联想到Server需要有一个Start()方法用来启动,于是最初的Server就有了Start()这么一个方法。这个方法执行是不阻塞的,会创建一个goroutine持续的accept新连接。当时所希望的用法是像这样:

// 设置Session Start的事件回调
server.OnSessionStart(func(session *Session) {
    // 初始化Session
})

// 设置Session Close的事件回调
server.OnSessionClose(func(session *Session) {
    // 清理Session相关资源
})

server.OnServerStop(func(server *Server) {
    // 清理Server相关资源
})

// 设置server的其他参数
......

// 开门迎宾
server.Start()

后来Server做了一次比较大的API重构,不会阻塞的Start()被会阻塞的Server.Handle()替代,OnSessionStart、OnSessionClose以及OnServerStop被去掉,用法变成像这样:

// 开门迎宾
server.Handle(func(session *Session) {
    // 初始化Session

    session.OnClose(func(session *Session) {
        // 清理Session相关资源
    })
})

这两组API的最大区别在于Handle的阻塞模式,原有的Strart是自己创建goroutine的,看似方便,但实际上对于一个通用的库来说,制约了调用者的用法,这个内部创建出来的goroutine没有任何的recover机制或者错误日志机制,也没有机会让调用者做这方面的事情,而阻塞的Handle虽然更原始,但是不过度包办,调用者可以决定是否要用goroutine以及怎么用。

比如link包的server_test.go里面,为了不让Handle阻塞程序的顺序执行,于是就用一个goroutine包裹起来了,在这个地方我知道我不需要任何recover和日志,出错就让程序崩溃好了,于是就只需要很简单的一个goroutine。而examples目录下的程序,Server启动会我不需要再做任何事情了,于是我可以任由程序最后阻塞在Handle上。

由于Handle是阻塞的,所以OnServerStop事件回调也不再需要了,只要Handle返回,就说明Server停掉了,该回收的回收该处理的处理,不再需要一个移步的事件回调。

OnSessionStart则直接被并入Handle的参数中,因为“开门迎宾”是Server最主要的工作,它只需要做好这件事情,OnSessionClose则是过度包办了,Session关闭的事件属于Session显然更自然合理,Session关闭的回调事件设置放到Session的初始化过程也很自然合理。

以上就是Server的重构思路,最后我们得到的是代码更少但更灵活的API。

而今天晚上我对Session的API做了一次调整,这次调的是Session.Start()的用法。

最初Session和Server是一起实现的,当时设计的时候完全是只考虑到服务端的需要,后来在做单元测试的时候需要一个对应的客户端,我发现Session其实完全是中立的,可以在服务端用也可以在客户端用,于是就做了一组工具函数,连接到服务端并返回一个Session,这个Session就可以用来当客户端用。

当时做完以后有一个比较别扭的地方一直没找到好的解决办法,就是服务端在收到Session后只需要做初始化工作,最后Handle会自己执行Start,而客户端则需要自己手工调用Session.Start()才能用。

我不想把Start放到Session实例化之后自动调用,因为调用者经常会需要在Session开始收发消息前做一些初始化工作,如果自动Start了,初始化的顺序就不可控了。

后来在review代码的时候,我想到使用者很有可能在Session初始化的时候关闭Session,比如经过IP白名单过滤之后决定不接受这个Session。于是改了一版代码,让Session可以在初始化时关闭,然后Server通过判断Session是否在初始化时就关闭,来决定是否调用Start()。

但是这么改还是有问题的,因为Session的关闭函数在Start被调用后才会起作用,所以我那一版的设计等于是永远不可能在初始化时关闭Session。

最后我决定,Server不自动调用Session的Start,在服务端初始化Session之后必须显式调用Start()来启用Session,如果没有显式Start,Server就把连接回收了。

这个逻辑一改,Session.Start()的用法也就都一致了,调用者只需要记住一件事,Session.Start()是Session初始化的句号,是用来让一个Session跑起来的。

写到这边我又想起一次比较值得分享的设计调整,继续多啰嗦几句。

最初Server有一个Broadcast方法,这个方法的设计目的避免广播的重复封包,可以提高效率。当时抽象了一个接口叫SessionList,然后Channel实现了SessionList所需的方法,这么设计的目的是让自定义的Session集合也可以用于广播,而不局限于Channel类型,你只需要实现SessionList接口即可。

但是一次review代码的时候我想到一个问题,Server.Broadcast内部为了避免重复申请内存,重用了一个缓冲区,于是要并发的广播就要上锁,然而像游戏这类型的应用,每时每刻都在广播,并发的量是比较大的,这个锁就成了一个瓶颈,而Channel是当初设计用区分不同的广播用途的,显然把Broadcast放到各个Channel更合适。于是我就把Broadcast逻辑搬到了Channel类型。

现在的API设计还有没有优化空间呢?还有很多。

比如我对Session的SendChanSize这个东西就觉得有些别扭,到底多大的SendChanSize才合适,使用者没有一个直观的参考标准,并且SendChan阻塞时自动关闭Session,会不会对于一些使用者来说是不可接受的呢?

再比如Broadcast被移到了Channel,解决了我说的并发效率问题,但是之前可以允许自定义的Session集合用于广播发送的特性就失去了,也许我应该把广播抽象成一个广播发送者类型,然后Channel继承自这个类型,同时实现SessionList接口,这样link包的使用者也可以把广播优化用到自己特定的Session管理场景里。(刚花了几分钟做掉了)

今天就分享到这里,希望啰嗦这么多对大家有所启发 :)