浏览 1.8k
编程是门手艺,NGINX社区的经验分享 一文提过,专业的程序员擅长整体设计和细节处理能力。本文探讨整体设计,尤其是模块化这个技能。
FFmpeg,最强大的流媒体库
QEMU,硬件虚拟化的虚拟机
TCC,迷你CC编译器
QuickJS,100%支持JS语法的C引擎
等等,以上皆出自一人之手,法国天才。
去年QuickJS曾一度刷爆技术圈,NGINX社区的哥们第一时间推荐给我看,并以天才称他。
这软件开拓了我的视野。本文以它为引子探讨我认为非常重要的技能:如何组织代码。
私下问过Fabrice Bellard(给QJS提过patch)开发QJS的历程,答案令人惊叹,他只用了两年的业余时间。参与NJS这几年,才深知实现语言引擎有多复杂。
NJS从17年开始,现在差不多完成40%。但基础已经非常良好,后续功能开发会快速很多。而且常用功能都已经支持,其中我支持了模块化,箭头函数,等常用的。语言解析引入了LL(k)。
看似做了些不错的工作。然而跟QJS比,以台球打个比方。一个长距离很准的选手,90%的球都能打进,看似很厉害。但对一个发力非常厉害的人来说,他可能只需80%的准度,再加良好的走位,就能轻松一杆清台。
提QJS不是妄自菲薄,这样对比很不公平。QJS作者本身就是个JS专家,他都能用JS实现虚拟机。参与NJS的人员,包括Igor都不是真正的JS语法行家,JS的语法着实太庞大。我们平时开发过程中,有个社区外的JS行家对我们帮助非常大,简直就是JS活字典。因此在前期,只能靠着语法手册,然后实现,有些实现跟语法的本质有出入的话,又得重头再来。举个例子,早期实现的apply和call两个语法真是让人吃尽了苦头,这也是我最早参与的,因为修复它的bug,做了重构,然后发现社区的人非常接受这种重构的做法,有种碰到知音的感觉。
我会解释这种做法是合理的。此时必须提出来,后面再详加解释。
我在参与NJS时,第一件事就是让它支持模块化编程。NJS刚出来时我就开始关注,后面挺长一段时间,用NJS写代码只能放在一个文件里,这对代码组织是极不友好的。先看下JS的模块化用法:
main.js
/* 自定义模块 */
import foo from 'foo.js';
foo.inc();
/* 内置模块 */
import crypto from 'crypto';
var h = crypto.createHash('md5');
var hash = h.update('AB').digest('hex');
foo.js
var state = {count:0}
function inc() {
state.count++;
}
function get() {
return state.count;
}
export default {inc, get}
支持模块化之后,变得非常好用。这个大功能也是NGINX作者Igor亲自帮review和调整的,收获良多。客观讲,JS语法比Lua实在好用太多,NJS目前已经非常稳定,只是功能没那么繁多,推荐轻量应用考虑用NJS,而且社区非常活跃,相信未来可期。
现在轻瞥一下QuickJS的源码。
JSContext *JS_NewContext(JSRuntime *rt)
{
JSContext *ctx;
ctx = JS_NewContextRaw(rt);
if (!ctx)
return NULL;
JS_AddIntrinsicBaseObjects(ctx);
JS_AddIntrinsicDate(ctx);
JS_AddIntrinsicEval(ctx);
JS_AddIntrinsicStringNormalize(ctx);
JS_AddIntrinsicRegExp(ctx);
JS_AddIntrinsicJSON(ctx);
JS_AddIntrinsicProxy(ctx);
JS_AddIntrinsicMapSet(ctx);
JS_AddIntrinsicTypedArrays(ctx);
JS_AddIntrinsicPromise(ctx);
return ctx;
}
void *JS_GetContextOpaque(JSContext *ctx)
{
return ctx->user_opaque;
}
void JS_SetContextOpaque(JSContext *ctx, void *opaque)
{
ctx->user_opaque = opaque;
}
所有源代码扔进一个文件里,我看过不少软件的源码,而且是比较完整的。NGINX, Unit, NJS, Lua等,以个人感观而言,QuickJS是最好的。初看有点凌乱,但细看的话(可能需要很熟悉JS语法),绝对的大师之作。
假如想删除某个语法功能,在QuickJS里可以连续的从某行一直删除到另一行,连续的一块。这在其它软件是不可能做到的,要么多个文件都要删除,要么在一个文件也要删除多个不同的地方。我认为这就是模块化的精髓:高内聚。
学过设计原则的同学想必都知道软件要高内聚,低耦合。我的理解是只要做到了高内聚,低耦合就是自然而然的事情。
举个例子,要实现nginx lua模块。有两个重要的功能:nginx模块相关函数,lua封装相关函数。
过度设计方式:
ngx_http_lua_module.c
/* nginx模块相关函数 */
ngx_http_lua_request.c
/* lua封装相关函数 */
合理方式
ngx_http_lua_module.c
/* nginx模块相关函数 */
/* lua封装相关函数 */
https://github.com/hongzhidao/nginx-lua-module/blob/master/src/ngx_http_lua_module.c
过度设计是一种很容易踩进去的陷井。
讨论1:
如果有更多的功能,比如http subrequest这种功能进来时怎么办?
建议还是放在同一个文件里,不要被代码行数影响。
讨论2:
又有更多的功能,比如http share memory这种功能进来时怎么办?
是可以考虑独立到另一个文件了,原则就是要找到一个信服的理由,新的功能能独立成一个高内聚的模块。有个特征是它往往会有专门的API,比如共享内存操作的get, set等。
换另一个角度看,一个文件的引入本身也是一种成本,而且比函数级别更高。每次的重构都应该带来实质的价值。这是我坚持尽量放同一个文件的原因。我早期提过几次建议,想对njs做类似的事情,后来证明有些是过度设计的。而有些是正确的,比如把njs_vm.c分成njs_vm.c和njs_vmcode.c。一个负责虚拟机,一个负责字节码处理。
总结一下:
高内聚是最高准则。
引入新文件成本高于函数,要有实质的价值才做。
不要被代码行数影响。
协作只是一种分工,不能做为破坏高内聚的理由。
前面说QuickJS的代码质量非常高,是因为他的设计令人折服。整个QJS的代码行数不到5万,实现了100%的语法,其中还包括非常硬核的大数和正则,都自己造轮子。从整个引擎的实现方面它就做了高度的抽象,而且用的算法非常简单有效。举个例子,JS里对象的属性操作应该是最常用的,比如 a[‘name’]。a和name在语法解析时都是字符串,术语叫token。QJS用一个非常高效的hash实现,将所有JS用到字符串的都包括进去了,代码也很少。
typedef struct JSShapeProperty {
uint32_t hash_next : 26; /* 0 if last in list */
uint32_t flags : 6; /* JS_PROP_XXX */
JSAtom atom; /* JS_ATOM_NULL = free property entry */
} JSShapeProperty;
struct JSShape {
uint32_t prop_hash_end[0]; /* hash table of size hash_mask + 1
before the start of the structure. */
JSGCObjectHeader header;
/* true if the shape is inserted in the shape hash table. If not,
JSShape.hash is not valid */
uint8_t is_hashed;
/* If true, the shape may have small array index properties 'n' with 0
<= n <= 2^31-1. If false, the shape is guaranteed not to have
small array index properties */
uint8_t has_small_array_index;
uint32_t hash; /* current hash value */
uint32_t prop_hash_mask;
int prop_size; /* allocated properties */
int prop_count;
JSShape *shape_hash_next; /* in JSRuntime.shape_hash[h] list */
JSObject *proto;
JSShapeProperty prop[0]; /* prop_size elements */
};
里面指针还用到负操作, 他是数学行家玩的转。
为什么NJS不能这样呢?依赖,各细节之间相互引用。软件开发中没办法的事情。
还以打球为例,那些走位和发力非常老道的球手,打法往往是简单有效的,不要奇怪为什么有些球不先击打进去,而选择更不好打的,一切在掌握之中。
这是我这两年比较大的体会。以前会觉得有这设计的功夫,早把东西实现好了,而且认为重构能解决一切的设计不足。这是没错的,问题是花了更多的时间在走弯路。
write some code, think, write more, meditate, write a meaningful commit log, take a sleep, think again, and re-read, split/fold/re-write, think, become happy with the final result.
以上是Unit的负责人给的建议,个人觉得这是一种可行有效的方式。NGINX的http2实现就出自他的手笔。对了,NGINX的http3即将完成。
本系列文章都会有实操方法。实践对想提升代码的同学是很有效的方式,我个人觉得学习或写项目是一种方式。
utopia是我写的一个API网关框架,只有一千行代码。里面的一些设计就参考 Unit,尤其是路由部分。我了解他们的设计历程,非常优秀。这是一个非常适合学习的项目。
设计可以聊的实在太多,远不止一文可以讲完,以后会不断的夹杂在其它章节。
[nginx-lua-module] https://github.com/hongzhidao/nginx-lua-module
[the-craft-of-programming] https://github.com/hongzhidao/the-craft-of-programming
技术问题欢迎issue里交流
[utopia] https://github.com/hongzhidao/utopia
还未开源,关注公众号及时了解更新
按点赞数排序
按时间排序