大家好,我是肖恩,源码解析每周见

谁是最好的语言?当然是php了 :) 先说一声抱歉,最近工作上有个里程碑要交付,比较忙, 本周的celery源码系列又又要延期了。为了避免大家误以为停更,今天简单聊点别的内容吧。近期我们公司做架构升级,调研了一下各种语言, 包括TypeScript,c#,rust, 还有java和go。这个过程中有一些个人看法,可能会有些偏颇或者不正确的地方,我就简单一说,大家一乐,无意引战。

游戏公司和互联网公司不太一样的地方,除了前端web,ios,安卓,后端这些划分外,还有重要的一块是客户端游戏引擎。现在的客户端游戏引擎中,主流的 商业 引擎大概是UE4,Unity和cocos-creator。早些时候2d&2.5d游戏用cocos比较多,现在3d游戏unity比较多,而更重度的3A大作,主要使用UE4。cocos支持JavaScript实现,也支持TypeScript实现;unity中可以使用c#,JavaScript,lua…;UE4需要使用c++。游戏客户端还有一个重要的特点是: 热更。如果一个简单的功能更新,或者是bug修复,需要玩家重新安装客户端,玩家流失率肯定要飙升,产品绝对不会主动接受这样的缺陷。使用脚本语言实现的功能,更容易进行热更新,不需要玩家重新安装客户端。另外一个因素是如果每个引擎都需要专门的人才储备,也是不小的成本,企鹅家的puerts可以支持TypeScript编写,跨UE4和Unity引擎工作。这样看,使用TypeScript是游戏客户端较好的语言选择。

我们公司服务端的语言体系也比较多,有python,java,go,还有c#,我们传统使用的是python。现在有新的业务机会,老板也支持来一次重新选择,为以后的后端架构奠定一点基础。对于后端的语言选择,我们的核心需求是高性能,这个一点也不意外,高性能意味着低服务器成本。同样如果可以支持热更会更好,可以不重启服务直接修复bug,对追求糙猛快的游戏服务非常有吸引力。天下武功,唯快不破。如果后端语言能够和客户端统一,那更是极好的。除了工具栈统一,方便交流,还有一个易见的好处:代码重用。有些游戏逻辑,比如战斗功能,客户端需要计算,服务器也需要对客户端结果进行校验,不然外挂横行,会冲垮游戏体系。同样的功能和逻辑,不同的语言各自实现一遍,成本肯定是双杀。

在语言及其生态之外,还得考虑人才市场的情况,架构不只是技术。对后端来说,c/c++肯定性能最高效,但是人难招。我们也要跟随市场上主流的技术方向。

以上是一些主要背景知识,我之前工作中还做过一些网页游戏开发,用的是Flash/ActionScript/html5; 单机游戏开发,用的是cocos-xLua; 基于java的企业系统开发; 基于php的网站和CRM系统.., 对这些语言也是略有了解,虽然杂而不精。回到正题,下面我们正式聊聊各种语言:TypeScript(ts),c#,rust, go 和 java,当然包括绕不过去的python。

python

python毕竟简单轻便,人生苦短,我用python,上手极快,功能强大,在公司里一直霸榜。可以做游戏服务,可以做内部系统,可以做自动化测试,可以做数据分析,可以做自动化运维…。优点很多,但是就游戏服务而言,主要就一个缺点:性能有限。由于GIL锁的原因,导致无法很好的发挥CPU多核的潜力。虽然我们有一些实践,利用pypy协程+twisted的异步IO,可以提高单线程的并发能力和降低编码难度,可是终究无法绕过多进程模型这个槛。举个简单的例子。游戏里有大量的配置文件,在多进程模型下,每个进程都要加载配置并序列化到内存,这样就造成内存浪费。同时因为进程数量庞大,管理和维护成本也在递增。当我们的玩家规模增长后,就出现各种问题,要解决这些问题,又需要做一些复杂的hack设计。

python可以做到比较好的性能,不过我个人认为,这种优化工作,应该是编译器/运行时做的工作,而不是程序编码。举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# py-amqp-5.0.6/amqp/method_framing.py

def frame_writer(connection, transport,
                 pack=pack, pack_into=pack_into, range=range, len=len,
                 bytes=bytes, str_to_bytes=str_to_bytes, text_t=str):
    """Create closure that writes frames."""
    write = transport.write
    ...
    write(pack('>BHI%dsB' % framelen,
                       type_, channel, framelen, frame, 0xce))
    ...

示例选自py-amqp的代码,write 函数来自 transport 的同名函数,为了提高 frame_writer 的性能,在函数内部进行了本地化,重新定义了一个内部变量。这种提高性能的处理方式,我们可以在很多库中看到。关于一些框架的性能实测数据,github上的 FrameworkBenchmarks 项目里有一些展示,我自己也有一些对比实测数据,如下表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
| 框架            | QPS (plaintext/json) |
| --------------- | -------------------- |
| nginx           | 85267                |
| go              | 89354/87626          |
| node            | 45212/40965          |
| node-thread     | 31487/32903          |
| node-cluster(2) | 77450/71943          |
| pypy3+twisted   | 14349/15921          |
| python3+aiohttp | 4959/4625            |
| c#              | 59347/58829            

最终的结果个人觉得,python目前的服务性能优化,就像你拿着手工刀在绣花,费老大劲;结果别人用3d打印,瞬间完成。

对我的实测详细数据感兴趣的请公众号留言,如果留言比较多,我会整理公开分享出来。

TypeScript

TypeScript是微软家推出的语言,是JavaScript的超集。TypeScript 发展至今,已经成为大型项目的标配,其提供的静态类型系统,大大增强了代码的可读性以及可维护性;同时,它提供最新和不断发展的 JavaScript 特性,能让我们建立更健壮的组件。TypeScript经过静态编译,生成JavaScript代码供各种环境执行。对后端服务来说,主要就是基于的NodeJS环境的运行时。

ts语法支持类型注解,解决了JavaScript的弱类型这一诟病;同时又是面向对象的语言,支持类,接口,继承,泛型等等;提供了很多先进的语法糖,比如装饰器,Mixin,预处理指令等。得益于Node.js 处理非阻塞 I/O 操作的事件循环机制运行效率也非常高效。主要缺点也是多进程和多线程使用起来复杂,而且不一定高效。比如上面示例,多线程方式反而比单线程效率低;多进程实现,又没法共享可变数据。

就语言的特定来说,其编译时和运行时两个状态加大了心理负担。比如下面的多线程实现, 这是node-js实现的主线程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//add this code snippet to main.js

const { Worker } = require('worker_threads')

const runService = (WorkerData) => {
    console.log(WorkerData)
    return new Promise((resolve, reject) => {
    
        // import workerExample.js script..
        // 注意workerData是一个字典
        const worker = new Worker('./workerExample.js', { workerData:WorkerData });
        worker.on('message', resolve);
        worker.on('error', reject);
        worker.on('exit', (code) => {
            if (code !== 0)
                reject(new Error(`stopped with  ${code} exit code`));
        })
    })
}

const run = async () => {
    const result = await runService('hello John Doe')
    console.log(result);
}

run().catch(err => console.error(err))

主要就是启动主线程的时候创建了一个worker,也就是用户可控制的线程,然后主线程和这个子线程交互消息。线程的实现代码:

1
2
3
4
// add this to workerExample.js file.
const { workerData, parentPort } = require('worker_threads')
console.log("receive:", workerData)
parentPort.postMessage({ welcome: workerData })

如果换成使用ts实现,则worker大概需要提供两个文件,分别是js和ts,代表编译时候和运行时候的状态, 理解起来有点困难:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# worker.js
const path = require("path");
require("ts-node").register();
require(path.resolve(__dirname, "./worker.ts"));

# worker.ts
import worker from "worker_threads";

const wd = worker.workerData;
process.on('message', func(this));

还要一个缺点是,语言提供的库太少,导致项目依赖项有点太多了。比如下面是一个项目的依赖情况,虽然有一些是dev依赖,但是要想很好的工作也需要逐个去了解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# package.json
{
    "devDependencies": {
        "@lerna/batch-packages": "^3.16.0",
        "@lerna/filter-packages": "^4.0.0",
        "@lerna/project": "^4.0.0",
        "@rollup/plugin-commonjs": "^17.1.0",
        "@rollup/plugin-json": "^4.1.0",
        "@rollup/plugin-node-resolve": "^11.2.0",
        "@rollup/plugin-typescript": "^8.2.1",
        "@types/debug": "^0.0.31",
        "@types/express": "^4.16.1",
        "@types/fossil-delta": "^1.0.0",
        "@types/jest": "^26.0.24",
        "@types/koa": "^2.0.49",
        "@types/mocha": "^5.2.7",
        "@types/node": "^16.3.2",
        "@types/sinon": "^10.0.2",
        "all-contributors-cli": "^5.4.0",
        "assert": "^2.0.0",
        "benchmark": "^2.1.1",
        "c8": "^7.7.2",
        "colyseus.js": "^0.14.13",
        "cors": "^2.8.5",
        "express": "^4.16.2",
        "httpie": "^2.0.0-next.13",
        "jest": "^27.0.6",
        "koa": "^2.8.1",
        "lerna": "^4.0.0",
        "minimist": "^1.2.5",
        "mocha": "^5.1.1",
        "rimraf": "^2.7.1",
        "rollup": "^2.47.0",
        "rollup-plugin-node-externals": "^2.2.0",
        "sinon": "^11.1.1",
        "ts-jest": "^27.0.3",
        "ts-node": "^7.0.1",
        "ts-node-dev": "^1.1.6",
        "tslint": "^5.11.0",
        "typescript": "^4.3.5"
  },
}

从Node.JS的趋势上看。07年大家耳熟的Atwood定律:凡是可以用 JavaScript 来写的应用,最终都会用 JavaScript 来写。进过十年后,在17年达到顶峰,然后逐渐下跌。 Node.JS

c#

c#在国内后端圈,感觉非常小众,可能是微软给人的深刻影响导致。比如下面juejin的主题关注和文章数据对比:

实际上可能大家一叶障目了,是刻板印象。c#的语法比java更优秀,传闻java的很多语法都又借鉴自c#,.net也很早就支持跨平台运行。据2017年数据统计显示,微软是github上贡献最大的公司,而且现在github也是它家的。我们公司最近引入了一位大佬,极力推荐c#。大佬在c#上构建了部分游戏服务生态,比如一个叫做 鲁班(luban) 的配置工具,可以一键将游戏策划的excel转换成程序可以使用的配置数据,提供了一个配置文件的完整解决方案。

目前鲁班(luban)已经完全在github上开源,获得251个赞。欢迎大家点击文章左下角「阅读原文」前去围观点赞。送人玫瑰,手留余香。

c#使用下面简单的几行代码,就可以实现一个http服务, 全部的依赖仅仅使用系统包:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
using System;
using System.IO;
using System.Text;
using System.Net;
using System.Threading.Tasks;

namespace HttpListenerExample
{
    class HttpServer
    {
        public static HttpListener listener;
        public static string url = "http://localhost:8000/";
       
        public static async Task HandleIncomingConnections()
        {

            // While a user hasn't visited the `shutdown` url, keep on handling requests
            while (true)
            {
                // Will wait here until we hear from a connection
                HttpListenerContext ctx = await listener.GetContextAsync();

                // Peel out the requests and response objects
                HttpListenerRequest req = ctx.Request;
                HttpListenerResponse resp = ctx.Response;

                // Print out some info about the request
                Console.WriteLine(req.Url.ToString());
                Console.WriteLine(req.HttpMethod);
                Console.WriteLine(req.UserHostName);
                Console.WriteLine(req.UserAgent);

                byte[] data = Encoding.UTF8.GetBytes("Hello, c#");
                resp.ContentType = "text/html";
                resp.ContentEncoding = Encoding.UTF8;
                resp.ContentLength64 = data.LongLength;

                // Write out to the response stream (asynchronously), then close it
                await resp.OutputStream.WriteAsync(data, 0, data.Length);
                resp.Close();
            }
        }

        public static void Start()
        {
            // Create a Http server and start listening for incoming connections
            listener = new HttpListener();
            listener.Prefixes.Add(url);
            listener.Start();
            Console.WriteLine("Listening for connections on {0}", url);

            // Handle requests
            Task listenTask = HandleIncomingConnections();
            listenTask.GetAwaiter().GetResult();

            // Close the listener
            listener.Close();
        }
    }
}

这样看c#的语法会和java很类似,同时dotnet还提供了mvc模式的框架,使用起来和spring-mvc很接近。对于java熟悉的同学上手应该非常迅速。

c#也有一些问题,比较不爽的地方就是不像go一样,可以方便的编译成单个bin文件,跨平台运行。还有一个问题是它歧视macOS,你想不到吧,它竟然歧视macOS。我们计划的架构中使用http2协议作为服务间通讯,经过一番实验发现,mac下竟然不支持,官方文档是这样说的 “HTTP/2 will be supported on macOS in a future release.” 。曾经的鄙视链底端windows,翻身农奴把歌唱了,鄙视起尊贵的mac。

好了,时间到。今天就先闲聊到这里吧,下篇我们继续讲讲go和java的故事,别忘了「阅读原文」去github点赞哦。

参考链接