大家好,我是肖恩,源码解析每周见
谁是最好的语言?当然是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可以做到比较好的性能,不过我个人认为,这种优化工作,应该是编译器/运行时做的工作,而不是程序编码。举个例子:
# 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 项目里有一些展示,我自己也有一些对比实测数据,如下表:
| 框架 | 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实现的主线程:
//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,也就是用户可控制的线程,然后主线程和这个子线程交互消息。线程的实现代码:
// add this to workerExample.js file. const { workerData, parentPort } = require('worker_threads') console.log("receive:", workerData) parentPort.postMessage({ welcome: workerData }) 复制代码
如果换成使用ts实现,则worker大概需要提供两个文件,分别是js和ts,代表编译时候和运行时候的状态, 理解起来有点困难:
# 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依赖,但是要想很好的工作也需要逐个去了解:
# 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年达到顶峰,然后逐渐下跌。
c#
c#在国内后端圈,感觉非常小众,可能是微软给人的深刻影响导致。比如下面juejin的主题关注和文章数据对比:
实际上可能大家一叶障目了,是刻板印象。c#的语法比java更优秀,传闻java的很多语法都又借鉴自c#,.net也很早就支持跨平台运行。据2017年数据统计显示,微软是github上贡献最大的公司,而且现在github也是它家的。我们公司最近引入了一位大佬,极力推荐c#。大佬在c#上构建了部分游戏服务生态,比如一个叫做 鲁班(luban) 的配置工具,可以一键将游戏策划的excel转换成程序可以使用的配置数据,提供了一个配置文件的完整解决方案。
目前鲁班(luban)已经完全在github上开源,获得251个赞。欢迎大家点击文章底部参考链接前去围观点赞。送人玫瑰,手留余香。
c#使用下面简单的几行代码,就可以实现一个http服务, 全部的依赖仅仅使用系统包:
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给鲁班点赞哦。
参考链接
- 鲁班github.com/focus-creat…
- github.com/TechEmpower…
- www.tslang.cn/index.html
- 微软开源软件列表 www.infoq.cn/article/201…
- docs.microsoft.com/en-us/dotne…