解决网易云音乐Windows客户端音质差的一个方式

我几乎不在电脑上听歌,主要是因为Windows版本的网易云音乐不知道为何,我总觉得音质特别差,不管用什么耳机听,都是一团糊。不管开会员用高音质,还是选Wasapi换输出,甚至外接声卡,都没有任何改变。

同样的音乐,用foobar就完全不一样,但是习惯了在线听歌了,本地下载肯定没法接受。

说一下foobar的环境,输出是走ASIO用外接DSP,而网易云完全不支持ASIO,那会不会是这个原因呢?

然而不支持就是不支持,难道有什么办法可以解决吗?

还真给我找到了一个。

VB-Audio Hi-Fi Cable
https://vb-audio.com/Cable/

这个软件原理很简单,生成了一个虚拟设备,可以将输出到这个设备里的音频传输到ASIO设备上。

具体实现,只要安装驱动,打开ASIO Bridge软件,输出选为你需要的ASIO设备(一般为独立声卡)。

然后在网易云的播放选项里,输出选择WaveOut: Hi-Fi Cable Input。

现在你可以享受ASIO的输出了。

当然我也怀疑过,明明还是靠WaveOut输出,这样“只换末端一截线”真的有意义吗?

我只能说,我听上去确实还是很明显的,至少网易云Windows客户端的声音现在能听了。

这也算是困扰了我很多年的问题,值得记录一下。

钉钉的提示音太TM吵了

做梦也没想到,我也有在公司推行钉钉的一天……

主要的点还是,单纯的软件设计上看,很多点还是做得比企业微信好的……企业微信的槽我自己用了两天都已经吐不完了。

另外,能够有效区分工作和生活,这点上还是很能减轻心智负担的。之前每次想找一个人,都得考虑我们一般是用微信还是QQ,想查找一段记录的时候,也得考虑一下是在哪儿聊的。

最后只有一个问题了

钉钉的提示音实在是太!吵!了!

这个吵,一方面是波形本身的增益估计设得比较高,另一方面是估计是有意设置的令人不适,这样能有效注意。

今天实在是忍不了了,直接开AU给改了,降低了10db的响度,并且把起音删除了。

试了一下,感觉好多了。

音频文件在钉钉的安装目录的这个位置:

DingDing\main\current\uiresources\new\common\sound

message

欢迎下载替换: )

游戏服务器的集群问题

(这篇可能希望可以长期更新,记录一下这个问题的解决过程,最终可以产生一个方案)

遗留的两个问题

目前游戏服务器框架是使用的Skynet,之前的所有开发设计都是基于单机情况下的,在配合了Gitlab-CI之后,已经可以非常爽快的使用了。代码合并进master分支之后,会被pipeline自动构建并部署到阿里云的测试服务器上。在年底的时候,因为终于有了一位正式的服务器程序( 雷迪 )入职,在做完框架交接之后,基本就是他直接和产品那边配合开发逻辑了,我也把精力放在了其他方面。

在当时我这边暂时脱离服务器开发的时候,实际上还有两个点不放心,或者说在正式上线前必须解决。一个是数据库,一个是集群。

目前的数据库连接方案是我基于Redis和MySQL,写了一层薄封装,称为Unistore。这层封装本质是是给MySQL实现了一套Redis接口,这样一来,Redis就完全变成了一个缓存,数据实际上都是存在MySQL里面的。做这件事主要还是上一篇日志描述的事情给我带来很大的鸭梨,觉得数据这种东西还是小心点好。不过因为实现得比较粗暴,写入的时候是直接往Redis和MySQL里同时阻塞写入,这样一来,MySQL的写入是大概率撑不住的。这一块我跟雷迪聊了想法,在年前的阶段他尝试了一下改进,目前应该已经是在使用新的方案了,这个下周抽空去找他聊下,作为评审和确定。

至于集群,虽然我很赞同云风的建议,如果靠单机加核数和内存就能解决的问题,是没有必要上集群的。在年前我也对现有的单机部门做了一些压测,情况总体看来还是很不错的,CPU和内存基本压力没有那么大,也很明显只要往上加,性能是可以对应堆上去的。遇到稍微麻烦的问题在于并发连接数,我们的连接用的是TCP,所以用户数和并发连接数成正比,这就是著名的C10K问题了,虽然说,目前应该C10K问题依旧被研究得比较清楚了,但是对于我这样的运维萌新来说还是挺一头雾水的,在研究了半天的Linux kernel参数之后,应该勉强是在两台PC的轰击之下保持了10000+的连接(使用ss -s查看),但是离预期的目标来说还是差挺大的,并且总归是要为未来的拓展做好准备,这样集群方案就是绕不开的问题了。

Docker 与 Kubernetes

最近一段时间,林峰的平台那边因为基本的需求都完成得差不多了,所以开始研究了一下Docker。说来惭愧,我一直觉得Docker应该是很确定的方向,但是几次尝试,都看得云里雾里。Docker本身还是挺好理解的,但是一涉及到实际生产方案,资料就十分匮乏,最终唯一的用途就是在Mac mini上面起了一个Container取代原本原生部署的Unity cache server,这种无状态缓存用Docker还是很爽的。这次林峰的成果大大超出我的预期,在本地使用Minikube进行尝试后,在内网服务器部署了一个IBM cloud private集群,算是对kubernetes的使用有了一个大概的了解,我看差不多可以用了,就开始怂恿他开始往生产环境上面试,于是在阿里云上面尝试了一番容器服务。

阿里云的容器服务Kubernetes版总共有四种,Kubernetes、Kubernetes托管版,多可用区Kubernetes、Serverless Kubernetes。Kubernets和多可用区Kubernetes基本一样,最正常的方案,就是用至少3台ECS作为Master节点,然后加入Worker。托管版就是不需要自己买Master,只需要配置Worker。而Serverless Kubernetes就更厉害啦,连Worker都不用买,直接也被托管了。我们兴趣最大也最先尝试的就是Serverless Kubernetes,完全无视底层虚拟机,完全面向容器,多么令人开心的方案。结果,试了半天,helloworld都跑不通,直接报错无法拉取镜像。无奈只能工单咨询,结果答曰:Serverless Kubernetes的Worker没有公网访问权限,所以无法访问 Docker hub……这我可是头一回听说,我买台云服务器还有不能上网的吗?我又不需要公网访问……仔细研究了一番才明白,VPC还真不能上网,之前我买的几台可以上网是因为我分配了公网IP,如果只是内网机的话,不仅仅是外面访问不进来,里面也访问不出去。这下开心了,Serverless Kubernetes的Worker又不是我的,我也无权分配公网IP,要想上网只能买个NAT服务,价格嘛,12块钱一天……我说就为了拉个镜像至于么,于是开始找寻方法,找了半天还真给我找到了,原来只需要配置一下路由表,把0.0.0.0/0的下一跳指向一台可以联网的ECS,然后在这台ECS上开启ipv4 redirect转发即可,啊,第一次感受到了SNI的强大。配置好之后终于可以拉取镜像部署了。经过一番试用,最终还是放弃了,Serverless Kubernetes的运行本质上还是依赖了弹性容器实例 ECI,但是不知道为什么,阿里云设定每个pod至少分配2核4G内存,这对我们现在的测试实在是太不友好啦(穷),再考虑到方案强依赖云服务商的特定服务也是我一直很不喜欢的,于是出门右转开始尝试Kubernetes托管版。中途解决了各种奇奇怪怪的问题,最终效果非常令人满意,搭配上完整的弹性伸缩服务,现在整个集群都可以根据负载自动伸缩,负载高了,自动加入一台新的ECS作为节点,而ECS我也发现了抢占式实例这个性价比神器,只要不限制最高价格,也不用担心会被回收,平均只需要正常按量计费的1/6价格,目前是用了两台2核8G的ECS测试,每小时只需要2毛多。简直让我产生了在玩异星工厂的爽感。

在这个过程中,我也大概了解了Kubernetes集群和服务架构的一些逻辑,真的是非常方便,怪不得大家都在容器化,这才是真正的DevOps。既然容器这么好,那游戏服务器这边,怎么搞也得上一下吧?于是就产生了这篇日志要讨论的问题。

Web 项目与游戏服务器集群的差异

目前在Web项目中,使用微服务架构已经成为标准做法,简单来说,就是让所有的接口都成为无状态的,那么服务就可以近乎无限的横向拓展,而微服务所依赖的服务发现与负载均衡网关,则为快速拓展带来了非常大的方便。游戏服务器从本质上来说和Web服务器其实并没有非常大的区别,主要的差异大概列一下是以下几点:

  • 游戏不可能做到完全无状态,主要是性能方面考虑。所有数据都只存在数据库或者某个中心节点是几乎不可能的。一个网页,点击一下链接,花费一秒钟打开你可能还觉得挺快的,而游戏你滑动一下摇杆,过一秒角色才动,可能你就想砸电脑了。
  • 游戏的通讯协议一般是直接走TCP或者封装一下TCP on UDP,不太会(也不是没有)走HTTP协议,在频繁通讯的情况下每个HTTP请求都要重新建立TCP连接,每个HTTP包还要带上那么一大坨额外的数据,时间和带宽开销有相对较大,就算考虑到keep-alive可以复用连接,二者的性能还是有明显差距的。(最近看到从Google的QUIC项目升级而来的HTTP/3,是基于UDP的,非常有意思)
  • 游戏服务器没有类似Spring Cloud这样的成熟方案……

这两(三)个问题合并在一起,就让我犯难了。理论上说,我当然希望所有的服务是无状态的,那么TCP也就没关系了,我随便连谁都行。如果有状态的话,我就必须保证同一个用户每次链接的服务都是同一个,这样势必导致需要给每个服务都开放公网连接端口,这些端口还必须有办法让用户知道。如果考虑Kubernetes集群就更完了,每个Container都需要对外暴露端口,这简直没法做了。

Frontd

想了两天(开始写文章的时候是第一天晚上),偶然看到了很久以前其实就看到过的东西……结果豁然开朗

https://github.com/xindong/frontd

感谢沈老师,感谢达达,没想到竟然是这么这么熟悉的大佬们帮我解惑了,解决方案也很容易,只是在出口架设一个网关,每次连接上去以后,先跟他说你要找谁,网关就帮你转发了……理论上和HTTP是一个逻辑。

有些时候,解决方案不是你想不到,是不敢想。

于是周一很开心的写了一天的Go……把这个三年前的项目重新整理了一下,替换了一些依赖,并基于Alpine重新build了镜像,让镜像的体积从257M减小到了7M(多阶段构建大法好!!!)

今天呢,则是build了一天的项目镜像……前面花了一半的时间企图继续使用Alpine打包,结果最后发现Jemalloc不兼容……GG

然后又转而使用Ubuntu,也遇到很多问题,一一解决。镜像传参问题也想到了一个比较好的办法,算是搞定了基本的镜像问题,standalone模式也可以顺利运行了。

明天开始,来解决服务发现问题,并且需要重新改写客户端那边基于frontd网关的连接。

服务发现

服务发现问题解决的比较顺利,除了中途差点操作失误丢失了全部的工作成果,最终竟然是从之前打好的docker image里面把脚本都找回来了……简直万幸,这个故事告诉我们,找回 git 中 staged 的变更只存在理论可能,实际操作中实在太麻烦了……

服务发现本质上要解决的问题是在所有节点共享一份地址簿,当有节点加入或退出的时候,需要根据实际情况实时更新。所有无状态节点都是走Kubernetes服务的,直接通过服务名称即可访问,也是保证高可用的,这部分节点的伸缩,对于Pod外都是完全透明的,不需要动态注册与反注册,只需要订阅地址簿的更新即可。而有状态的节点(比如游戏服务器),在集群中应该是在无头服务的后面,必须要指定到具体的Pod才可以访问,所以这类节点不仅需要订阅更新,也需要动态注册自己。

具体的实现方式是这样的,首先有一个单独的节点作为服务发现的管理节点,称为cluster_manager(暂时这就是一个节点,如果做成高可用就需要解决多节点的数据一致性问题,比较麻烦,这个节点的功能也比较单一,应该不会出现严重的问题,后续有机会可以加强这里的健壮性),其他每个节点都有一个对应的服务发现服务,称为cluster_client。所有节点在启动的时候,都必须先启动cluster_client服务,这个服务会自动向cluster_manager订阅更新。需要注册的节点在订阅之后可以调用接口进行注册,注册成功,则会获得一份地址簿用于更新。后续的更新都会通过之前的订阅自动完成。

成功注册之后那就有个节点下线的时候的反注册问题。正常的下线反注册很简单,就是一个接口调用的问题,但是异常状态下,比如崩溃或者网络连接中断的情况,就必须及时的把这个节点下线。要解决这个问题,就需要一个健康检查的功能,一般的HTTP服务需要维持心跳来刷“存在感”,Skynet使用的是TCP,云风大佬在一个Issue里面给出了一个很好的办法,套用过来就是说,cluster_manager服务在接收到注册后,就会给cluster_client发送一个请求,正常情况下cluster_client不需要回应,这样,当这个节点中断的时候,cluster_manager就会收到一个error,从而得知节点状态异常。顺带着,我把login service到game service的健康检查也顺便做了一下,这样当game service断开的时候,login service就可以及时了解,从而防止推荐不存在的节点给用户。

网关模式的实现

frontd网关模式的实现……就是一把辛酸泪了,理论上是很简单清晰的,泪点都在具体实现上。首先,我研究了很久都没想通为什么frontd连接成功之后不会回应一个状态码,这样的话错误码就无从接收了呀,你接收4个字节,一看不是错误码,哦,那说明连上了,那收到的4个字节怎么办!你还能还给socket不成……或者万一正确的消息恰好就是状态码的一种呢?最后实在无解了,就还是给网关改写了这块功能,也修改了一下相应的协议。

然后呢,frontd的加密使用的是AES算法,具体的应用标准则是基于openssl,这里并没有详细的文档说明到底是怎样得到的最后的base64编码的结果,我所能依赖的只有1、源码中引用的一个叫做go-oepnssl的库;2、文档中给出的一个在线加密网站;3、openssl的官方实现。第一个库基本就不用看了,整个一挂羊头卖狗肉,就单单实现了一个加密也好意思叫go-openssl?而在线加密网站完全只说用了Crypto-js,其他的一概不知,最终我能依靠的就只有openssl了。以下省略N个小时(包括一通宵),之后,终于搞出一个Lua绑定的C库用来生成加密结果了。后续的工作主要就是客户端的对接,顺便解决了一下最近发现的,shell被强制结束后,Skynet进程会成为孤儿进程的问题。一开始我假设了一堆原因,顺便吧UNIX的signal机制好好学习了一番,最后怎么看怎么不对,这为什么会结束不掉呢?苦思许久后灵光一闪,日志服务的logrotate信号好像就是SIGHUP,看了一下果然是的。为了不改Skynet库代码,我在自己实现的日志模块中把Skynet注册的SIGHUP信号恢复为默认了,并重新注册了SIGUSR1信号作为代替。

至此,集群方案的开发部分就已经差不多了,后续的工作主要是尝试在Kubernetes中部署,并合并代码,将现有的客户端通讯替换为集群方案。

想想觉得很有意思,不懂的时候遇到觉得无比巨难的问题,在解决了以后就好像是理所当然的了。人的进步不正式基于此吗:)

最后

提两个小点:

  1. 开始没发现网关方法的时候,为了对外暴露的接口有限,我尝试找寻了一些方法,其中一个叫做LO_REUSEPORT的套接字选项成功吸引了我的注意,网络上所有的文章都把我说的云里雾里,实际尝试确实可以让端口被重复监听,但是会导致收到的消息完全混乱。最终,我还是老老实实去翻了《UNIX 网络编程》,解释得清清楚楚,希望后面有对这个选项感兴趣的同学可以直接去看书……简单来说就是这个选项只有UDP通讯的时候需要启用,TCP绝对不要开就对了。
  2. 看书的时候顺便吧IPv4协议部分又复习了一遍,发现了一个有趣的点,原来127.0.0.1/24(即 127.0.0.0 到 127.255.255.255 )全都是本地环回接口,我实际试了一下,在Windows上,除了127.255.255.255(广播地址)以外,确实都是可以ping通的。这个小知识在当天就派上了用场,我在本地单机尝试的时候,希望和集群保持一致的端口,但是端口不能重复监听,怎么办呢?好办,通常我们都是会开启LO_REUSEADDR的,这样我们在本地只要监听不同的本地环回地址,就可以使用相同的端口的,我们还能在hosts中配置对应的hostname,这样就非常开心了,本地和集群配置完全一致。

一次Redis数据完全丢失的事故

虽然不是我删库,但是还是想跑路

之前可能说过,虽然现在的项目明确最终应该还是要靠MySQL,但是开发期间为了方便,使用Redis做数据落地。

很长一段时间都没有问题,心中隐隐产生了点好像直接使用Redis也没什么问题的感觉。

于是今天就瞬间炸裂了。

早上机房因为一个插线板烧毁,导致断电,不过当时我并不知道具体情况,以为只是普通断网,网好了以后试了试NAS连接没问题,就没有再管了。下午偶然想要更新一下Skynet,结果发现开发服连不上去,仔细一查发现VM宿主机都连不上了,赶紧联系运维去机房看了看,一聊才知道原来是断电了。奇怪的是NAS为什么没问题呢?隐约记得NAS我是设置了断电恢复后自动开机,现在是体会到这个功能的必要性了……下次再出现这种情况,得去把虚拟机宿主服务器也设置一下。

上来以后连上VM看,所有虚拟机都没开,原来VMware ESXi在默认设置下,开机是不会自动启动虚拟机的,查询了一下,把都设置自动启动了。确认虚拟机都启动起来了,也就没再管了。结果过了一会儿,徐哥问游戏服务器的连接问题,我有些纳闷,一看,果然都挂了。这时候正好要去参加一场面试,粗略看了眼好像是Redis问题,systemctl查看果然是Redis没有启动,restart也无效,只好嘱咐徐哥帮忙看一下,准备好材料先去面试。

回来以后问题还没解决,这个问题非常奇怪,系统日志里看Redis只是单纯的启动不了,徐哥查看了Redis日志说是没有权限读取dump.rdb,查看权限竟然是600,所有者和群组都是root。权限设为755后,读取报错,确认应该是文件损毁,改名之后,Redis顺利启动。BGSAVE生成新的dump文件,对比发现,这次生成的文件所有者是正确的Redis,大小也远远超过损毁的文件,看来原来的资料是没救了。损毁的原因猜测应该是断电的时候正好在dump,不过怎么会这么不靠谱呢?仔细去看了一遍Redis有关持久化的文档,以及conf文件,终于搞清楚了,原来是自己的用法不对。

Redis 有两种持久化方案,RDB (Redis DataBase)和 AOF (Append Only File)默认开启的只有RDB,定期全量备份全库,这个从文件名dump也能清楚的看出来,平时非常好用,但是致命的问题就在于……RDB并不保证完整性和一致性。万一在dump的时候断电,那么就是直接的结果……文件损毁。

而AOF就是为了弥补这个缺点而存在的,直接的理解就是记录日志,根据配置可以对每一条记录写一次,或者每秒写一次,在超过指定大小后,还会重写整个文件,防持续追加记录导致文件过大。

找到设置,开启AOF,以后就不怕再断电了。正高兴呢,结果查询了一下,发现Redis竟然数据又消失了……仔细考虑了一下,开启AOF之后,Redis启动时就不再读取RDB,而是根据AOF日志恢复,而现在AOF是空的,那么当然恢复不出任何数据了。这个问题的解决方法也很简单,要么从一开始就开启AOF,要么配置文件中先不开启AOF,而是通过RDB启动起来以后,使用client调用命令手动开启AOF。我这样操作以后发现依然没有数据,这可就奇怪了。再仔细思索一下,心中一凉,完了……又把数据搞丢了。我在上一次发现数据库是空的,然后stop数据库准备不开AOF启动那一次,直接就把空数据库dump了出来,覆盖了有数据的那份……唉……太傻了。

删库有一个好处,我趁机把之前想改的一个key结构给改了,反正都全部重来了,也算苦中作乐吧。好在反正就我们这几个程序策划在里面,给每人发点金币补偿一下吧,哈哈哈。

为什么我不用单例

突然发现,其实自己倒是很少写技术相关的内容,仔细想想,还是有些内容是希望聊聊,记录下来的。这是一些经过长期思考、验证,至今依然觉得有其道理的内容。

今天先从一个简单的问题着手吧。

编程学到一定程度,从大学开始接触的话大概在大二大三的样子,基本都会接触一个新鲜事物——设计模式。回想一下我看的第一本设计模式相关的书好像是《设计模式之禅》。设计模式给我的最大震撼是终于理解了之前看到的很多写法到底为什么这么写,这也是自己从把代码作为一个固定模式使用,转变为开始自行思考写法。换言之,从“编写”代码,到“设计”代码的一个转折吧。

初学设计模式,我也和很多人一样,印象最深的,就是“单例模式”了。对于自己已经熟知的写法进行组合,竟然可以产生这样的效用。而真正写好、写正确一个单例,又没这么简单。我至今都很痴迷这种简单与复杂的交织。

大学后来写Web写App,单例倒是确实没怎么用到过,所以渐渐也没那么在意了,再到后来,幻刃成立之后,开始写游戏项目,这个问题很显然地就出现了。

Unity 是脚本驱动的,每个 GameObject 上都可以挂载不同的脚本,每个脚本负责自己的数据和逻辑,这个思路是很清晰的。那么我们来设想一下,一个初次开发游戏的开发者,会怎样组织代码呢?

一开始,肯定是每个脚本自己管自己的事情,需要相互引用就“拖一下”,逻辑简单的时候这样是不会出现什么大问题的。

然后呢,他就会发现,自己有挺多地方有着差不多的代码,这时候呢,挺有可能他也听说了一个新鲜词,叫做“DRY原则”,自己也深以为然,于是开始思考怎么解决这个问题。

首先,可能最容易想到的代码复用方式就是“工具类”。这个平时调用过一些库的话,很容易就能看到并理解这种方式。为什么不是类继承呢?因为这在设计上实在要求太高了,大概在这个阶段我自己对于继承实在还停留在 Rectangle 可以继承 Shape,甚至刚刚搞明白如果我再想要一个 Square 类那该是怎样的继承关系(这个问题确实还挺有意思)。什么接口呀,抽象类呀,还都没在工程上用过呢。后面我也会具体谈到继承的方式在工程上面使用的一些具体情况。

说回工具类,工具类嘛,在面向对象语言中的实现基本就是一个名称以Tools或者Utils什么的结尾,只包含静态方法的一个特殊类,这样,需要复用的方法,就可以复制粘贴过去,然后在任何位置直接调用了。

这种方式好用吗?太好用了,于是你渐渐的就会有自己的MathTools、LogTools、NetTools、FileTools等等以及更多业务相关的类。

再然后,你会发现这种方式好像可以解决一个新遇到的问题,那就是怎样进行脚本间通讯的问题。

比方说吧,大部分游戏,肯定有个概念是主角嘛,Unity中的话,这就是一个GameObject 了。主角对象上可能还挂了一个脚本,负责一些基本逻辑,比如叫做Player。那么,在其他脚本中,如何获取这个脚本呢?

一个比较Unity的做法是直接 GameObject.FindWithTag(“Player”) 拿到GameObject,然后 GetComponent<Player>(),或者是 Object.FindObjectOfType()。这类方法,即使是初学者也会很快明白是不对的。

  1. 需要遍历查找,性能非常差;
  2. 通过 Tag 的形式依赖字符串,所有需要手动保证不输入错误的地方,我觉得都是有问题的;
  3. 如果有大量目标需要查找时,非常难以区分与管理。

那么,很自然的,解决方法就有了,写一个叫做 PlayerManager 的静态类,声明一个公开的 Player 字段,在 Player 初始化的时候,把自己赋值上去就好了。虽然这种初始化时把自己的引用传递出去的方式感觉稍微有些违和,但是至少问题是解决了的。

这个问题本质上其实是任何时候,获取一个特定的引用,在面向对象语言中,基本需要依靠“静态”这个概念,这点是跑不掉的,那么除了直接使用静态字段,还有没有别的方法呢?

很自然,单例天然的解决了这个问题。更因为C#的CLR级别泛型支持,有一个特别方便的单例写法。(本篇不讨论单例正确写法问题,我们先用最朴素的单例实现方法,不考虑懒加载与线程安全问题)

public class Singleton<T> where T : class, new()
{
    private static readonly T _instance = new T();

    // Explicit static constructor to tell C# compiler
    // not to mark type as beforefieldinit
    static Singleton()
    {
    }

    public static T Instance
    {
        get { return _instance; }
    }
}

public class SingletonExample: Singleton<SingletonExample>
{
    // Explicit static constructor to tell C# compiler
    // not to mark type as beforefieldinit
    static Singleton()
    {
    }
}

直接一个继承就搞定了。

然后你就可以在任何地方,使用 SingletonExample.Instance 访问这个对象。

咋一看,这和使用静态类没什么区别啊,只是静态类的字段需要多个static,单例访问的时候需要多写个Instance。

再深入想一层呢?有一个很大的区别就是在单例是可以懒加载的,因为其实本质是是个对象,在需要的时候,还可以解除引用进行GC;还要考虑到序列化方面的问题;再有,比如你写了一个FileSystem的静态类,然后发现其实你需要对不同平台编写不同的实现,使用单例的话只需要写多个子类继承FileSystem然后分别实现,最后使用编译选项控制FileSystem的单例实例化的具体对象就可以了,这些情况静态类就都别想了。

这么说来岂不是使用单例还是挺好的,除了每次需要多写个Instance比较繁琐,但是也是可以依靠IDE代码补全,或者干脆命名为I来省事。

我们回过头来想想,单例本质上是解决什么问题的呢?对了,是解决在同一Runtime下确保只存在一份该类的实例这一问题。只有一份实例之后,可以全局便捷访问到,只是 一个附带的作用而已。所以实际上来说,我们是利用了一个副作用,而忽略了本来使用单例的目的(其实我们确实不管理是不是唯一,甚至在Unity中我们也并不能保证,一个继承了Monobehaviour的单例,因为并不是也不能通过new的方式实例化,所以生成了多个是很容易也常见的错误)。实际上,如果想解决“只有一个实例”和“在全局便捷的访问”其实分别会有更加合适的方法。

再说到全局访问,一个人写小规模代码的时候这是个很方便的做法,但是项目一旦开始扩大,人多了以后,你会发现这几个问题。

  1. 滥用,你可能花了很大心思保证两个系统的解耦合,但是因为他们都可以很便捷的被访问到,那么接下去的事情就不是你能控制的了。此外,如果单例成为了给实例提供方便的访问方法的项目标准做法,那么势必会出现大量单例,虽然很明显,不是每个单例的创建者都希望别人在任何位置使用它,但是你却不能用除了约定以外的方式来确保这一点。
  2. 你无法控制启动顺序,单例的懒加载作为一大优势,同时也可能成为一个难以解决的问题。懒加载单例的加载顺序,是由调用方决定的,大多数情况下,单例们都可以自动根据依赖关系顺利启动,但是这种自动,很多情况下其实都不是你希望的顺序。甚至,很容易造成循环依赖
  3. 别人很难通过代码本身了解到框架的设计者提供了那些功能,文档的编写和维护对于小团队来说成本是挺高的,大家协作开发时,也不可能每写一个功能前都问问大家,是否曾经在框架中已经实现了。我在早起开发中,就经常遇到代码库中,同样功能的函数被不同人实现多次的情况,甚至有时候自己都不记得了,又再写了一遍。

说到这里,我们会发现,对于一个游戏运行时脚本系统来说,单例完全就是个不需要的设计,我们想解决问题,其实很好解决。

所有这些需要便捷访问的模块,我一般称作“服务”,他们全部作为普通的类来实现就可以了,然后写一个静态类,把他们都放进去。服务也可以有子服务,子服务的生成销毁与访问都交给父服务即可,逐级推到最上层,会有一个主服务,负责按顺序实例化所有服务,并注入到这个静态类中。这个静态类,一般被称为“服务定位器”

因为有了“服务”这个概念,就隐含了服务定位器中的对象,一定就是希望可以全局便捷访问的,而服务与服务之间,则是尽量解耦合。当你遇到需要和脚本外部交互的情况,第一反应就应该是敲一个L.(我喜欢把服务定位器类命名为L,表示ServiceLocator),IDE的自动补全即会自动提示我可以使用的服务。即使没有IDE,打开看一眼L类,有哪些功能也都清清楚楚。服务相关的脚本也都可以放在一起。静态工具类也都可以放在一起,并且明确只会包含状态无关的纯函数,比如拓展数学库等。按照这样的方式,单例就可以明确得禁止在项目中使用了,即使可能有人把不应该的模块当做了服务,也非常容易被发现并得到修正。

终究我们会发现,我们大多数使用单例解决的,都不是单例应该解决的问题,反而会带来大量的副作用,所以,用正确的方法解决问题,而不是解决问题。


对于有一些经验的游戏开发初入门者,我一直很推荐三本书:《游戏引擎架构》、《网络游戏核心技术与实战》以及游戏编程模式。最后一本篇幅不长,但是就游戏中使用的设计模式问题进行了大量的有针对性的讨论,关于单例与服务定位器的部分,都有专门的章节描述,比我写的不知道高到哪里去了。

每次看到都想吐槽《网络游戏核心技术与实战(オンラインゲームを支える技術)》这本书为什么翻译了个这么《21天学通C++》的名字,导致我一直以为是本垃圾书没有看……

首个里程碑顺利结束

到今天为止,第一个里程碑算是顺利结束了。

其实周三就已经完成所有功能提交验收了,周四把GameLogic的执行顺序修正了一下,保证Component的开关是可用的。之前的做法保证勉强可用,这下应该是确保正确了。只是还遗留了两个小问题:1. 不能保证Update一定在Initialize、Enable、Start之后调用,因为Initialize和Start都有异步方法;2. 第一个问题的衍生问题,我在纠结要不要让两个异步方法不再等待,一个脚本在异步加载的时候,所有脚本都要等待加载完成,虽然并不会卡住主线程,但是总归是有点奇怪的。一时半会还没想通,先放着吧。

这两周代码总归还是写得挺开心的,状态很好以至于直接通宵了几天,上周心动48小时也是玩心大起也跟玩了两天,跟叶斌前辈以及邢大哥聊的收获颇丰,真的很感谢前辈们的分享。现在来看基本上框架算是完善一些了,也算是可以缓一口气。

网络那边交给诸谦去研究了,最后还真是觉得Skynet是比较靠谱的解决方案,现在已经(排除万坑XD)走通了基本的连接和加密方面,下周就是协议方面了,最后整个流程走通了,就可以准备进行整理和并入框架了。诸谦真的还是厉害!

放松了两天,可以过个闲适一些的周末。下周开始进入里程碑 2 的开发。

.Net的Task写起来太TM爽了

上周二拿到PvE的需求,开始搞起来了。

地形用新的TileMap搞了一下,先用着再管美术问题。

摇杆就用EasyTouch的,结果还是发现确实没法满足右摇杆的需求,于是就开始纠结怎么办了。

结果纠结了一下午之后,完美解决了。

RectTransformUtility.ScreenPointToLocalPointInRectangle(RectTransform, screenPosition, null, out localPosition);

获取实际点击位置,分辨率无关。顺便再重构了一下InputController,逻辑非常清晰,很满意。

主角状态有意没有用很复杂的结构,先简单写了一些行为,然后对输入做绑定。前期需求还不明确的情况下设计逻辑架构感觉其实还是挺不合算的。子弹也是一样,简单写了个实现。

上周基本就是这样。

其实整个框架一直都有一个严重的问题,就是基于协程的异步接口一直令人非常难受。主要的点在于,协程不能返回值啊!!!一溜烟写一串那确实是挺爽的,但是一旦需要返回值那就直接火葬场。框架服务启动的时候会涉及AssetBundle加载,肯定涉及异步,代码写起来的感觉啊,真的是拆东墙补西墙,一个地方改改一堆地方都要跪。今天一早来,改到中午,好不容易把View的加载改好了(本来是Resources.Load),结果好了,这里不阻塞了,直接服务启动速度快了很多,导致整个服务都启动好了,主角还没被加载出来,然后一加载敌人,找不到主角,跪了。

虽然这个问题可以简单的靠敌人做一下null判断就可以解决的事情,但是总是心里非常的不爽。明明弄了这么一套服务框架,出发点就是让执行顺序可以得到保证,而且明明大部分情况都是可以的,但是最后的一点问题总是不好解决,非常的难受。

而主要的问题,就是协程不能返回值啊!!!要让我把所有代码都写成一个Request形式的,也太难受了,而且这样,其实说到底还是得在一个协程里完成,只是可以把这个协程往上推,根本不解决实际问题。

崩溃,这个问题想了很久很久,感觉实在没法想出什么写法可以完美解决这个问题的。

最后,我也不记得是怎么想起来的,用.Net 4.6 Framework试一下Task。

随便Google了一下,找到了个以及作者的博客。Asset Store也有,但是不是最新提交,于是git clone了一份下来。在经历了导进去删掉再导进去再删掉最后还是导了进去之后,终于搞清楚是怎么回事了。

于是我在办公室里喊了一下午:“太爽了!!!”

所以就不能怪大家今天一直用奇怪的眼神看我了……

大家直接理解为,新的语法可以把任何一个函数变成异步的(只需要加上async关键字),然后,可以随意返回值!!!

写出来类似这样:

public class AsyncExample : MonoBehaviour
{
    public async void Start()
    {
        // Wait one second
        await new WaitForSeconds(1.0f);
 
        // Wait for IEnumerator to complete
        await CustomCoroutineAsync();
 
        await LoadModelAsync();
 
        // You can also get the final yielded value from the coroutine
        var value = (string)(await CustomCoroutineWithReturnValue());
        // value is equal to "asdf" here
 
        // Open notepad and wait for the user to exit
        var returnCode = await Process.Start("notepad.exe");
 
        // Load another scene and wait for it to finish loading
        await SceneManager.LoadSceneAsync("scene2");
    }
 
    async Task LoadModelAsync()
    {
        var assetBundle = await GetAssetBundle("www.my-server.com/myfile");
        var prefab = await assetBundle.LoadAssetAsync<GameObject>("myasset");
        GameObject.Instantiate(prefab);
        assetBundle.Unload(false);
    }
 
    async Task<AssetBundle> GetAssetBundle(string url)
    {
        return (await new WWW(url)).assetBundle
    }
 
    IEnumerator CustomCoroutineAsync()
    {
        yield return new WaitForSeconds(1.0f);
    }
 
    IEnumerator CustomCoroutineWithReturnValue()
    {
        yield return new WaitForSeconds(1.0f);
        yield return "asdf";
    }
}

这是这个库的官方样例,再次感叹拓展方法真的太神奇,可以和协程完美互相结合。

我只能说,谁用谁知道。

很快把项目所有异步接口都改写成Task,服务和游戏逻辑完全保证执行顺序,完美达成目的不说,关键写起来是真的优雅,真的爽。

开心,晚饭汉堡王30-20都没能令我更开心。

卓越的企业,伟大的公司

在关电脑前,随意整理了一下收藏夹,结果看到了这个很早以前肖哥分享的视频。

http://v.youku.com/v_show/id_XNjkwMDg4Mzg0.html

说的是The Golden Circle理论。核心是下面这句:

“People don’t buy what you do; they buy why you do it. And what you do simply proves what you believe”

― Simon Sinek, Start with Why: How Great Leaders Inspire Everyone to Take Action

记得当时肖哥是非常兴奋地向我们推荐这个视频,我当时看了应该也是有些感触。多年之后,重看一遍,依然还是激发了我一些思考。

很明显,这个视频的理论并不是我构建世界观的一部分,至少不属于根本的那部分。而对肖哥,我相信他是Native得实践这个理论,而且成功了,“It do work”。

因为视频中举了Martin Luther King的例子,我也去找除了Dr. King的演讲视频。这个著名的演讲——感谢语文教材编委会——我看过中文版,实际看视频,感觉比翻译后的文字更加振奋人心,稿子写得确实好。

然后就是很自然的功利主义思想了——思考一下,对现状有什么帮助吗?首先就是意识到当前的直接方向并不符合这个理论,现在还真的是利益,或者更加直接的,利润导向嘛。现在大家热炒所谓“独立游戏”,反倒是挺符合理论的。再从根本上来说,我,以及我所在的组织,这个终极的Why是什么呢?这个问题倒是很好回答,我和肖哥各给出了一个答案:

成为一家卓越的企业(我)
成为一家伟大的游戏公司(肖哥)

偏向重点不同,但是目标一致。

卓越这个词的来源是吉姆·柯林斯那本著名的《从优秀到卓越》,这两本书(另一本是指《基业长青》)的内容对我倒没有多大影响,现在也几乎记不清了,只是一直以来都模糊得感觉“卓越”是一家企业所应该达到的终极。

企,踮着脚看的意思,引申为期盼、期待的意思。所以企鹅这个名字起得真的是绝了,掂着脚走路的鹅,萌萌哒。所谓企业,所期盼的事业也,相对于公司,看上去好像用语更加正式,实际上又更有人味儿一些。这种“看上去”又“实际上”的事情是我所喜欢的,故用是词。

对这两个答案简单的取个最大公约数吧。

成为一家伟大的企业。

卓越和伟大,这两个词粗看之下意思差不多,但是细细品味好像又不太一样。

百度阿里腾讯,说他们是卓越的公司,我想不会有人反对,但说他们是伟大的企业,那就可能会迟疑一下。

什么是我心中伟大的企业呢?脱口而出的,Google、Apple、Microsoft、Elon Musk的三家公司。

果然是

People don’t buy what you do; they buy why you do it

I have a dream,然后有一家公司正在替我实现,所以他是伟大的。

Google的收入主要来自广告,导致它大部分的产品和项目都可以不那么看中钱(有些情况下这也是个问题……),而Google又网罗了大量的优秀人才,最终导致Google一直在做“酷”的事情,而酷的事情,很多时候就是伟大的。

Apple的伟大那么的理所当然,反倒是感觉不需要说了。顺便吐一槽,现在看屁苹果的发布会,每次发布会的每一个点都还是挺兴奋的,确实是“why you do it”,但是看完之后,又回忆不起什么重要的内容。这已经跟老罗的发布会相声一个水平了嘛……

Microsoft现在每次打动我的,竟然是硬件产品,像是Surface系列,以及,当然,HoloLens。

Elon Musk的三家公司就更不用说了,反倒是说这些公司是否“卓越”我还得谨慎一些,说“伟大”,毫无问题。

所以,在我的感觉上,卓越是一个更加理性的标准,可以量化,基本来说,就直接反应在了股票上了。而伟大,则是一件更有想象力的事情。做到卓越,更像是拼命想考高分的好学生,按照一个大家公认的标准而做到最好。成为伟大,听上去才更像是人生的意义。

虽然,我依然还是选择“卓越”,因为这已经是一件非常非常非常非常非常困难的事情了,而“伟大”,还是作为行事的最终标杆吧。

作为一个终极的意义,成为一家伟大的企业,没毛病。

在这条路上,想要做出一款真正的3A游戏,也是一个明确的想象。

剩下的,还没想清楚,现在也没能力想清楚,就算想清楚了也没有用。

时常,确认一下方向是对的,然后的精力就继续回到“解决问题”上了,解决眼前的问题们,就已经是足以令人兴奋激动得要疯掉的事情了。


最后说一个个人想法,确实感觉,现在中国还没有一家可以称为“伟大”的企业。当然这有很多原因,这里还是不要多嘴了。但是呢,很明显,终于他们开始向着伟大迈步了。不管是百度对AI的All in,还是阿里的达摩院,都是明显的标志。而腾讯现在还稍稍有些停留在“有钱是可以为所欲为”的阶段。确实,我相信现在它的力量已经达到十分可怕的程度了,在BAT中,也已经是相对最不作恶,最“卓越”的一个了。所以,这也是我最期待的——当它开始变得“伟大”,将会是怎样一番景象呢?

回到前端

花了几天时间对“网络游戏”有了些许概念性的了解后,就还是开始攻克前端这个真正摆在眼前的问题了。

插句题外话,前些日子脂评版石头记看完后,这些日子公车途中在看汪曾祺先生的散文。特别是偶得一本说吃的合集,特长,特爽。心得有二。其一,终于意识到散文是什么了,从小一直说散文散文啊,都没想通到底什么是散文,原来这种便是;其二,我真是妄称热爱文学,博客都写得烂如这般,文字洗练还是要下功夫。一名作家,看小说会让你喜欢上他,于是想了解他。故此,散文便是最好的途径了。如汪老一般写到这般“信手拈来无不是”的境界,也就“字如其人”了(搜狗给我打了个信手拈来无不适……我很震惊自己一直理解错了吗?一查发现用“是”是对的,而且这句还真出自脂批)。

前两天状态很不错,一直有点担心是不是打折能量饮料喝多了,后面就“萎了”。至少现在看来并不是。我也不完全信是褪黑素的功劳,但是解决了“贪睡”这个老大难问题,还是挺好的,印证了一句老话:办法总比困难多。

正题

回到前端的问题上。

升了 Unity 2017.2,一是新项目用最新版的习惯(长痛不如短痛),二是有些新功能(比如原生的Tile Map)想试试看情况。

改了下.gitignore。排除了/.vs、Resharper相关路径,还有AssetBundles的导出路径

重新整理了一下Directory,从之前几个项目中,把混乱的“Framework”(自己写的框架,仗着重构namespace方便就先乱起了个名字)切出来,导入插件。

框架和插件选择这两点,等以后稳定下来,肯定需要专门系统的写几篇说明。

然后就是解决了几个问题。首先是AssetBundle,AssetBundle Manager这个插件本身没问题,不过2015年之后在Asset Store上就没更新了。按照我对Unity的理解,这么重要的东西肯定还是在做的,不说别的,就看这几年光官方就出了那么多AssetBundle插件就可能看出官方还是挺重视的,而AssetBundle Manager在这一堆插件中还算是很不错的了。于是Google一下就发现BitBucket上官方有个项目叫AssetBundleDemo,点进去一看果然最近还在更新。Clone下来导入项目一款,嗯不错,Asset Store版本中注释掉的Inspector面板,这个版本是实现了的,应该就是同一个项目的演化版本无疑了,就用它了!

某次还看到了Asset Bundle Browser Tools,Asset Store就有,也是官方出品,看上去比较靠谱,也是果断Github之(比较新),果然,很好用。

接下里的问题就花了几个通宵了,AssetBundle Manager有一些bug和一些兼容性问题,这个好修,修好以后想给提个Pull Request。随后没提真不是我懒,是我一看,好家伙,已经一堆人提过了,官方目前还没管呢。然后呢,这接口是异步的,按照Node.js的经验,异步接口无外乎写成回调和Task(或者Promise,这个我还没完全了解一堆这种名字到底是不是同一个概念的东西,哪天得好好研究研究),对于Task形式的接口,我希望是可以写成

var task = new Task<TResult>(DoSomeJob());
yield return task;
var result = task.Result;

其中,DoSomeJob函数返回值应该是个IEnumerator。

一开始我可犯了难,yield return 一个对象。 这是什么东西?这时才发现原来其实Unity用迭代器实现的这个协程自己理解的还不是那么透彻的。憋了很久算是憋出来了,结果发现嗨,不是和AssetBundle Manager原生接口返回的东西基本一样嘛。那么这个实际上就只是个适配器而已了,充其量可以打两个Log。在使用var的情况下,两种方式的代码甚至可以做到基本无差别。于是……这代码就被我怒删了。

当然……现在后悔了,因为发现其实后续一些框架接口也是要写成异步形式,所以这个类还是有用的,用到了再补回来,知道怎么写了就快了,代码很短,就是个IEnumerator的实现而已。代码后续补上。

研究了AssetBundle Manager与AssetBundle Browser的搭配工作流程,在Editor下和手机上都跑通了,我也困得不行,就拿衣服边上先睡了。

应该淘宝一床被子放公司里。

第二天一早精神依然挺好,继续整框架,发现一个新需求,我必须要让所有逻辑代码在框架启动完毕之后启动。

插播一下,现在框架启动已经是通过[RuntimeInitializeOnLoadMethod]了,真的爽飞。

于是开始改写之前临时写的GameLogic类,开始很顺利,后面发现自己想错了一大堆东西,其实并不可能写成自己想象中优雅的方式,结果还是大改特改,目前算是能跑了,先用着,在自己使用中越来越完善嘛。

这种方式,让服务启动也是使用协程顺序启动成为可能,也算给服务启动时异步加载提供了可行方案(不然脚本肯定比服务先启动的),这个根据需求后期修改。

具体实现依然等稳定了再慢慢写说明……

然后看了新的TileMap,这个很难说,因为我至今还没搞清楚怎么用,所以还是不说了吧,继续研究研究……

所以就去了。

对“网络游戏”的理解

这个周末很神奇的状态很好,只睡了10个小时,完全沉迷学习无法自拔。

深刻的自我检讨了一番,技术方面的能力还是太浅了,之前的一些想法和行为都很羞耻,啼笑大方。

一天多看完了《网络游戏核心技术与实战》 (以下简称《网》),大为叹服。清晰说明了网络游戏的主要架构不说,关键是这是第一次看到有完整的网络游戏整个研发运营过程的完整介绍,甚至包括商业部分,而且说得还很对……

6个月前就在考虑,万一最后还是决定需要强联网的话,至少应该先把这本看完,现在看完也不晚,感觉正是时候。

网游开发其实最主要的问题感觉是难入门。市面上能找到的资料其实我也看了,但总有种盲人摸象之感,难以串起来。偶有几篇讲述“帧同步”与“状态同步”的文章,都已经是极好的了。这点上,总有点感觉只要是国外不用的技术,就别想指望在互联网上能学到了,至少是要花费大量精力。如此,就更令我敬佩那些具有分享精神的前辈们了,这些经验的分享,真的是无价之宝。

下面还是具体的转述一下书中的一小部分概念,至少可以系统性的归纳一下“网络游戏”的架构方案。

当前国内的说法

我在阅读这本书之前,综合之前看的一些分享,是把网络游戏的架构分成这么几种情况:

  • 状态同步
    • 服务器计算
    • 客户端计算
  •  帧同步
    • 客户端计算

先说帧同步,原理很简单,主要是基于一个确定性假设:初始状态一致,帧率一致、每帧输入一致的情况下,理论上可以保证一致。也就是说所谓帧“同步”,其实根本没有保证什么同步,只是“碰巧”罢了。用象棋举个例子好了,帧同步就是说,假设我们有两张棋盘,两个人下棋,但是你们俩都不许碰棋子,只能由第三个人(充当网络)来进行。来我们开始,只要遵守规则,那么初始的状态肯定是一致的。你先开始,想来一个当头炮,所以吼了一句——炮二平五,这时候,第三个人听到了,就分别给你们两摆上个当头炮。轮到对方了,对方也不甘示弱,也来了个——当头炮——开玩笑怎么可能,当然是要来个马来跳,于是他说:马8进7,第三个人也负责给摆上了,就这么下去,就是个“帧同步网络游戏”啦。

听上去挺简单,但是感觉有点悬么不是?对方万一瞎走直接将军那怎么办?第三个人你是要假设他可不会下棋的(网络部分只负责同步,没有逻辑判断),直接给你老车贴脸。再来,那万一那个人忙中出错,给你和对方棋盘上摆得不一样,那可糟了,后面就全乱了。现代游戏基本都很依赖物理引擎,受限于精度,这每次运算的结果可都不一样。虽然可能只是差了0.000000000000001,但是根据Chaos(混沌,好像有人会读作【敲死】,应该是读【K奥斯】,我也觉得有点奇怪)原理,或者你喜欢说“蝴蝶效应”,一点点的误差慢慢积累,都会导致结果的巨大不同。最后的一击打没打中,很可能直接导致了生死只差呀,一次团灭与否,很可能就是胜负之差,这可怎么玩呢。具体当然是有做法,主要就是随机数全部在开始约定种子,自己实现定点数库,消灭一切不确定因素,理论上,就可以保证一致的同步了。现实中,很多RTS都是使用这种方案,像魔兽争霸、星际争霸,当然还是大家喜闻乐见的——王者荣耀。

状态同步呢,实际上是要分两种的,当然这是我自己这么分的,大多数资料上面好像一般都只说了一种情况,我也是因为用了Photon以及UNET后,感觉他们的方案明显是状态同步,但是又明显和一般说的状态同步不太一样,所以还是分开表述。

状态同步嘛,同步的当然就是状态。我觉得最好的理解方式其实就是,其实就是无线手柄分屏本地多人嘛,想象一下,用四块屏幕,大家在房间的四个角玩。游戏主机其实就是服务器,手柄无线连接上去可以当做网络,显示器也假设是网络连接上游戏机的(或者直接理解为远程桌面)。好了,搞定。现实中的MMORPG也大多确实就是这样的原理,整个游戏实际上都是在服务器上跑的,只是不需要渲染,所以可以节省大量的资源。然后你的客户端其实就是个显示器,只是从服务器获取你需要看到的内容,并渲染出来给你看。你的一切操作,都是直接被转发到服务器作为输入的。当然这也就会有很大的延迟,所以一般客户端都会做一定的“预测”,预测对了皆大欢喜,如果不对,那还是会被强制和服务器的情况保持同步,也就是一般大家经常在网络不好的时候见到的“鬼跳”了。

另一种呢,其实说白了,就是有一名玩家作为服务器,其他人都同步他的结果。这种情况,其实可以简单的当做在一台电脑上同时运行了服务端和客户端两个软件,本机的客户端自己实际上还是使用和其他客户端一样的方式(比如Socket通信)与本地的服务端连接的。这么来看,实际上也就把这种情况和上面说的服务端计算类型划了等号了。又或者每个客户端都控制由自己创造的物体,比如自己的角色等等,自己控制的物体,就是自己控制状态并同步给其他人,这样来分担运算压力,以及保证每个人自己主观的流畅度(当然还有其他原因,就不在此赘述了)。

好了,这就是目前国内能找到的资料对于同步问题的表述。大体上这么分类我觉得是没有问题的,《网》这本书,则是更加完整得给了一个回答。

《网》中对同步问题的分类

书中首先先介绍了一下基本网络拓扑结构。而具体的架构分类,则从两个角度描述,物理架构和逻辑架构。

基本网络拓扑结构

首先基本的网络拓扑结构,计算机网络基础我就不赘述了,结果上来说,网络游戏中使用一般只有:

  • 星形(和作为应用的总线型)
  • 全网状

解释一下,因为总线型可以理解为不存在中央节点的星形,所以被包含在了星形的应用中。

物理架构

  • C/S架构
    • 纯服务器型
    • 反射型
  • P2P架构
    • 同步方式
    • 异步方式
  • C/S + P2P混合架构
  • ad-hoc模式

我现在了解的情况,似乎国内是有人用C/S架构代指状态同步,不准确,而且误导性极强。

一般的时候提到C/S架构(客户端 / 服务器架构),主要是对比B/S架构(浏览器/服务器架构),强调用户端是浏览器还是一个Native的客户端。而到了现代的网页前端,基本都接受了RESTful的API规范,实际上完全是把前端项目完全当做一个独立端了,与服务器也是通过Ajax传递数据的形式更新。所以实际上,我也已经很多年没听过这个词了。而在这里用到,是用来和P2P的客户端到客户端区分,强调的是“服务器”的有无。

纯服务器型和反射型的区别,其实也只是是否对数据包的合法性进行校验(反射型不校验)。至于为什么要专门强调乃至于可以据此分类,我目前还理解不能。

P2P,顾名思义,就是没有服务器,数据完全是客户端之间自己交换的。这里不禁怀念一下童年,红警、帝国时代、CS。忽然想起,大学刚报到的时候,大家还没办网,于是几个已经非法带电脑来了的同学(没错,我校规定大一不许带电脑)就把电脑搬到同一个寝室,建了个无线热点,痛快地打了个通宵CS。当然现在看来,这种“局域网游戏”是不能称作“网络游戏”的(各种对战平台实际上是自己做了一个大局域网,现在想想还真的挺有想法的),真正想通过P2P的方式做一款网络游戏,就得考虑蛋疼的NAT穿透问题了……暂且不论。

同步和异步方式的问题,暂且留到逻辑架构中描述(因为书中就是这个奇怪的顺序啊,所以也是我觉得有必要自己总结一下的原因……也确实是网络同步方案之间关系密切,很难有一个方法清晰的分类)。简单理解,P2P同步基本等同于帧同步,P2P异步,则可以理解为客户端计算的状态同步。

至于C/S + P2P混合架构,书中没有详细介绍,我印象里好像确实有这样用的,在有中心服务器保证可用性的前提下,在可行的情况下(可以NAT穿透,并且玩家物理网络连接良好),使用P2P直传数据降低服务器压力,提升玩家体验。

ad-hoc 我看书中的意思也没有很清楚,大概是说所有设备都是无线连接的移动设备。感觉上面我说的无线热点打CS就算,当然明显的例子应该是用3DS面连打怪物猎人。因为是通过热点直连。而因为某台设备掉线的概率很大,所以比较适合异步方式的P2P架构,都不是主机,大家随便掉(当然想连回来也相对不容易……)

 

逻辑架构

好了逻辑架构书中的分类就更迷了,竟然分为了MMO和MO架构……区别还真的就是是否Massively……按照规模分的……虽然我很能理解不同规模会直接导致方案的决定,但是规模本身不该是核心分类依据吧……

吐槽归吐槽,这章其实主要还是在论述

  • 星形结构
  • 全网状结构

  • 同步方式
  • 异步方式

的各种搭配情况。

前面有提到,同步方式就可以理解为帧同步,异步方式就可以理解为状态同步。但是不论怎么同步,总归大家还是要交换数据的吧,这个肯定跑不了。那么怎么交换呢?我们来想象大家上课穿小纸条吧。一般都是想传给谁,就直接说给谁对吧,也就是说,每两个人之间都是直接传递过去的,有一条“专线”,这就是全网状结构了。但是如果你想专递给隔壁班暗恋的可爱的蓝孩子(老脸一红),那就不好办了。结果你机智的发现,走廊上竟然有个值日生在打扫卫生(不要问我为什么他不用上课,我高中还真的每学期每个班有两天不上课全校打扫卫生的……),于是你机智的让他帮忙传递过去。也就是说,所有纸条,都必须先传递给这位勤劳的值日生,然后再由他传递给收件方。这就是星型结构。在这个问题上,那个值日生自己是否参与传纸条就不是核心问题了,也就是说,星型结构的中心节点可以是一台专门的服务器,也可以由其中一台客户端担任。甚至可以在当做中心节点的客户端网络条件突然下降的时候,自动由另外的客户端接任。

来想想这两种方式各有什么优劣吧,全网状结构,首先的问题就是线路过多,某条线搞不好就从老师眼皮底下过呢?在有15个人传纸条的情况下,就需要105条直连的线路了,如果说在10分钟不出问题的概率是90%的话,那么105条线路10分钟都不出问题的概率……已经基本为0了。所以参与人数增加,网状结构靠谱度指数级下降,不适合玩家过多的情况。而星型结构呢?倒没有这个问题,多一个人也就多一条线而已,这个时候,最大的风险就是,这一切传递都依赖那位勤劳的值日生啊,万一他傲娇一下不传了,那整个网络就崩坏了。

那么怎么选择呢?

全网状结构,只适合玩家人数很少,可以确保网络条件良好(如局域网内),而对延迟又非常敏感的情况使用。

星型结构的主要问题是响应慢(必须经过转发)、节点一旦中断,游戏就无法恢复、需要转发,逻辑上稍微麻烦一些、作为节点的玩家传输负荷比其他玩家高,不甚公平。

所以总的来说,主要影响因素是玩家规模,次要因素是对延迟的容忍程度。

至于同步方式与异步方式,或者说帧同步与状态同步,只要抓住一个重点就好理解了。同步方式同步的是输入,事实上不保证状态一致。异步方式同步的是状态,输入是客户端自己处理的。

好啦,方案基本都说完了,但是实际上具体还是有非常多的细节在里面的,十分推荐真的有兴趣的话还是仔细阅读一遍《网》,只需要一个周末的时间,真的是收获非常大。

RPC与共享内存

还有一点想特别提到的就是这个问题。之前一直知道网络游戏的通讯方式嘛,肯定是Socket了,至于TCP和UDP的问题,书上说有一小部分(1%)路由器不允许UDP通过,所以全部使用TCP。目前我了解的情况是国内主流还是推荐使用UDP的,快嘛。还有一些传说中的有人自己定的一些更厉害的协议,这个有兴趣的话还是可以研究一下的。

Socket实际上只是发送byte[]而已,具体使用的时候主流做法还是依靠RPC(远程过程调用)封装。我们把“进程之间”调用的函数接口(RPC接口)称作“协议”。

协议设计的原则主要有这几点:

  1. 后端实现基本的、通用的功能,前端实现专用功能
  2. 前端依赖后端架构
  3. 协议是无状态和简单操作的集合
  4. 在一个地方接受外部的异常状态

这我不一一解释了,有兴趣可以看书。

我觉得特别重要的一个思想是,函数接口应该明确的分为

  • 单向消息 (one-way message)
  • 查询 (query)
  • 查询结果 (query result)
  • 通知 (notification)

单向消息就是一个无返回的函数,例如使用鼠标移动角色的时候,调用:

void move(int x, int y);

需要返回值的接口,称作“查询”,比如打开物品栏时,需要获取物品列表:

void get_inventory_list();

inventory是个游戏术语,代表所持物品的列表,这个词真的有用,感谢作者,虽然你自己在书上还拼错了(虽然更有可能是译者的锅……)
此外,肯定有人发现了,明明是“需要返回值的接口”,为什么是void的?
这是因为RPC中所有的接口都应该是异步的,所以“需要返回值”的函数都明确的以get作为前缀,而且会有一个对应的查询结果接口:

void inventory_list(int item_id[]);

最后一类,有可能需要把一些信息从服务端推送到客户端,这就是通知了。书上建议是否一定需要通知是需要仔细思考的,因为一旦需要通知,接口实际是写在客户端的,但是调用方是服务端,这样会极大增加debug难度,毕竟如果这里出现问题,因为实际调用这个函数的代码在网络的另一头,你是无法追踪下去的。
通知的例子,服务器向客户端通知敌人的行动:

void notify_move(int id, int x, int y);

接口命名的时候以notify作为前缀,就很清楚这是服务端发的通知了。

如果你问,那如果通知需要返回值怎么办呢?

好问题。这种纯RPC方式一般用于纯服务器型的C/S架构,也就是说,所有的资料其实都是服务器计算出来的,客户端只是一个“显示器”或者“浏览器”而已,当然不可能有什么事情是需要服务端向客户端请求的啦。

那么对于反射型的C/S架构呢?换句话说就是我所谓的“客户端计算的状态同步”,有这种实际上是数据双向共享需求的情况下,就很合适使用这种称为“共享内存”的方式了。

刚开始看到这个词的时候我还吓了一跳,我们在谈网络通讯的问题,跟共享内存有什么关系?结果仔细看了看,还确实……没关系……作者也明确表达了和POSIX的shm_函数完全没关系,只是为了和RPC比较。

根本区别是:在游戏逻辑中,游戏进度数据的保存处理(覆盖或者不覆盖数据)所发生的时间和地点不同。换言之,RPC传递的是变化的请求,而共享内存传递的是变化的结果。从表现上看,RPC是可以像调用本地函数一样调用远端程序的函数,共享内存则是允许某个变量在端与端之间永远保持同步,这么看还确实有点“共享内存”的意思。

看到这里,UNET使用中一直困扰我的ClientRpc Calls、Commands和SyncVars到底该如何使用的问题就迎刃而解了,原来UNET只是提供了所有需要的功能任君选择罢了,原来我一直很纠结实现一个需求的方式太多到底哪个好……再次感谢作者,并且感叹一下UNET库比我想象中更加优雅。

到目前为止的新想法

上面说到现在我实在觉得UNET不错了,昨天也仔细查了查 .NET Core的情况,也安装了SDK试了试,但是毕竟和Unity实现的标准还是不一致的,这么一想,其实继续使用Unity也是个不错的选择,实际上Unity自己做服务端是可以实现上述全部情况的,而且还都挺方便。只是感觉服务端代码和客户端代码肯定是要分两个项目了,不然岂不是打包的时候全部暴露了?但是如果分成两个项目,目前我还不清楚UNET是怎么处理跨项目的RPC调用的,同一个项目中是自动生成的列表并通过Attribute直接调用函数,这个可能还得思考并尝试一下。

此外,对于现在这个项目的架构也有了比较明确的认识,首先肯定不是P2P,而且需要服务器,所以物理架构肯定是算C/S架构,逻辑架构上,星形没得说,也不做同步,肯定是异步。之前因为觉得没有同步,但是有很多数据肯定是要存在服务端的,所以非常纠结这到底算什么。其实很简单,就是个不M不M但是O的RPG嘛,除了不用考虑玩家同步的问题,其他按照MMO的思路就好了。

今天来本来的计划是,因为昨天查了登陆以及社交等服务器Asset Store里面是有看上去很靠谱的包卖,所以想看一下,还有几个网络库也想看一下,毕竟如果不用UNET,还是需要实现RPC的,还是需要找找靠谱的库。结果这一写就花了5个小时(再次感谢乐于分享的大大们)……当然总结一下对自己还是很有帮助的XD

于是现在就去实现计划吧~

P.S. 《网》里面还是有很多对我启发很大的方面,以后有机会遇到实际问题肯定要再多聊聊的!