品尝Zig
在本系列文章中,我们首先将 Zig 用作 C/C++ 编译器,然后深入研究如何实现交叉编译。在上一篇文章中,我们放弃了 Make 和所有其他编译依赖,转而使用 Zig 编译。
这是个好地方,也很可能是你旅程的终点。就我而言,如果我拥有一个 C 代码库,我肯定会对使用 Zig 而不是 C 继续开发感兴趣,所以让我们最后深入 Redis 代码库,了解 Zig 和 C 如何互操作。
如果需要了解 Zig 语言,请查看 @andrewrk 的演讲。
该讲座中一个特别相关的论点是 "Zig 在使用 C 语言库方面比 C 语言本身更胜一筹"。一定不要错过这段话。
扩展 Redis
在本文中,我们将为 Redis 添加一条新命令。这将是一个很好的机会,展示如何在现有的 C 代码库中加入 Zig 的现实而非繁琐的示例。
我们的新命令需要与现有的 Redis 生态系统集成,以打开密钥、读取其内容并回复客户端。这将使我们能够检验 Zig 与 C 语言之间的互操作性(即 C 语言调用 Zig 代码和 Zig 使用 C 语言定义)。
最后,我要预先告诉大家,这并不是我们将看到的一种特殊的 "最佳情况";事实上,我们将面临编译器目前在读取 C 头文件方面的限制,我们将为此实施一种简单的变通方法。
看看我,现在我是机长维护员了
本系列的整体思路是以我们维护的项目 Redis 为例,因此对 "我们的 "代码库进行此类修改是合理的,但要注意的是,编写 Redis 模块是以用户身份向 Redis 添加新命令的正确方法(使用 Zig 也很容易做到这一点,但这是另一个故事了)。
由于我们必须在 "我们的 "代码库中进行操作,我还将向你介绍 Redis 编写过程中的一些细节和怪癖,因为虽然这种添加绝不是侵入性的,但我们要进行正确的集成,这就需要了解一些 Redis 的琐事。
为 Redis 添加 UTF8 支持
Redis 中最基本的键类型是字符串。Redis 中的字符串只是字节序列,所以它们不必遵守任何特定的编码(谢天谢地),但这意味着某些基本命令的行为偶尔会不尽如人意。一个简单的例子就是 STRLEN 命令,它会返回字节数,而在处理 unicode 数据时,这通常不是你想要的。
好了,没什么大不了的,让我们给 Redis 添加一条 UTF8LEN 命令,让它返回编码点的数量。方便的是,Zig 标准库已经实现了 std.unicode.utf8CountCodepoints,因此只需添加必要的粘合剂,就能与 Redis 生态系统进行交互。
指令表
我们旅程的起点可能是查找 Redis 中所有命令的注册位置,这样我们就能沿着面包屑找到现有命令的实现,并从中汲取灵感。在这个过程中,STRLEN 显然是一个不错的候选命令。
Redis 命令表定义在 server.c,除了命令名称与函数指针的映射关系外,它还包含了一些有关命令性质的其他细节,我们可以在本文中安全地忽略这些细节。
{"strlen",strlenCommand,2,
"read-only fast @string",
0,NULL,1,1,1,0,0,0},
现在我们知道,STRLEN 的实现(顺便说一句,Redis 中的命令不区分大小写)是在一个名为 strlenCommand 的函数中实现的,我们还可以利用这个机会在它后面添加一个新条目,以注册我们即将发布的 UTF8LEN 命令。
{"utf8len",utf8lenCommand,2,
"read-only fast @string",
0,NULL,1,1,1,0,0,0},
好了,现在我们必须在 C 文件中声明 utf8lenCommand(只是正向声明,实际实现将在 Zig 中完成),但我们还不知道签名。看看 strlenCommand 的签名就能回答我们的问题,但为了方便起见,你需要在 server.c 文件顶部添加以下内容。
void utf8lenCommand(client *c);
查看 Redis 命令的实现
现在让我们来看看 strlenCommand 的实现。如果您是盲人摸象,您就必须在整个代码库中搜索该符号,或者按照包含链来猜测实现的位置。
幸运的是,我是你的 Virgilio,我可以告诉你,Redis 中的每种键类型都有自己的 C 文件,所有相关函数都在其中实现。为了方便查找,这些类型的文件都以 t_ 开头,因此我们要找的函数可以在 src/t_string.c 文件的最后找到。
void strlenCommand(client *c) {
robj *o;
if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.czero)) == NULL ||
checkType(c,o,OBJ_STRING)) return;
addReplyLongLong(c,stringObjectLen(o));
}
// from object.c
size_t stringObjectLen(robj *o) {
serverAssertWithInfo(NULL,o,o->type == OBJ_STRING);
if (sdsEncodedObject(o)) {
return sdslen(o->ptr);
} else {
return sdigits10((long)o->ptr);
}
}
好吧,让我们拆开 strlenCommand。虽然只有两行代码,但它们有点密不透风。
第一行复杂的代码是 if 语句。其要点是,lookupKeyReadReply 要么能打开密钥,要么(作为副作用)向客户端回复错误,而 or 表达式的第二部分将检查密钥类型,如果密钥不是字符串,则作为副作用向客户端回复错误。如果任一情况为真,strlenCommand 将提前返回。这部分似乎有点混乱,因为第一个函数在失败情况下返回 NULL,而 checkType 有一个 "错误 "代码返回逻辑,即 0 以外的任何值都是错误。
总之,如果检查通过(可以访问键且键的类型正确),我们就会向客户端回复以字节为单位的长度。
这就是 Redis 的另一个奇特之处,因为 Redis 没有任何内置的数字类型。
如果你想在 Redis 中存储一个数字,无论是 int 还是 float,都必须使用字符串键,事实上,有一些命令专门针对包含数字的字符串键进行操作,比如 INCR。这是否意味着每次需要对字符串进行操作时,这些命令都会从字符串中解析出一个数字?其实不然,字符串对象结构体中有一个标志,它可以告诉你字符串对象结构体的 ptr 字段是指向字节数组,还是指向数字本身而不是指针。这就是 stringObjectLen 在调用 sdsEncodedObject 时要做的事情。
请牢记这一点,因为我们在以后编写 Zig 代码时必须考虑到数字。
摘下每一个 "Zig"!
我们学习了 Redis 如何实现命令的基础知识,注册了我们的新命令,还在 server.c 中为它留下了转发声明!
为了尊重项目的惯例,我将把这个文件命名为 t_string_utf8.zig。在开始编写代码之前,我们先将其添加到编译过程中。
添加一个 Zig 编译单元
Zig 可以导出与 C ABI 兼容的函数和定义。这意味着我们可以将 Zig 作为一个单独的编译单元进行编译,然后让链接器像通常在 C/C++ 项目中那样解析所有符号。
为了方便起见,我们只需将代码编译为静态库,然后将其添加到 redis_server 的主编译步骤中即可(更多内容请参考上一篇文章)。
const t_string_utf8 = b.addStaticLibrary("t_string_utf8", "src/t_string_utf8.zig");
t_string_utf8.setTarget(target);
t_string_utf8.setBuildMode(mode);
t_string_utf8.linkLibC();
t_string_utf8.addIncludeDir("src");
t_string_utf8.addIncludeDir("deps/hiredis");
t_string_utf8.addIncludeDir("deps/lua/src");
// Add where the `redis_server` step is being defined
redis_server.linkLibrary(t_string_utf8);
Zig 实现
首先,我们需要访问 server.h 中的定义,因为它公开了我们需要的所有函数的声明,如 checkType。
const redis = @cImport({
@cInclude("server.h");
});
然后,我们需要重新实现该函数,最后加入我们的想法(计算代码点而不是字节)。让我们从重新实现原始函数开始。
const std = @import("std");
const redis = @cImport({
@cInclude("server.h");
});
export fn utf8lenCommand(c: *redis.client) void {
var o: *redis.robj = redis.lookupKeyReadOrReply(c, c.argv[1], redis.shared.czero) orelse return;
if (redis.checkType(c, o, redis.OBJ_STRING) != 0) return;
// Get the strlen
const len = redis.stringObjectLen(o);
redis.addReplyLongLong(c, @intCast(i64, len));
}
这个函数还不能做任何有趣的事情,但它是一个很好的检查点,可以用来编译和测试一切是否正常。
运行 zig build 编译所有内容,然后运行 ./zig-out/bin/redis-server 启动 Redis 服务器。
在另一个标签页中,你可以启动 ./zig-out/bin/redis-cli,这将允许我们向 Redis 下达新命令:
> set foo "Hello World!"
OK
> strlen foo
12
> utf8len foo
12
添加 UTF8 支持
要在函数中加入新的旋转,我们需要区分两种情况:
- 当字符串键指向字节时
- 当字符串键是一个数字,因此没有字节时
这一点很重要,因为如果我们试图取消引用编码为数字的指针,服务器就会崩溃。
我们已经看到 o.ptr 是指向字节(或数字)的指针,再仔细观察一下 stringObjectLen(),就会发现 o.encoding 会告诉你我们处于这两种情况中的哪一种。
这意味着,如果不是目前 cImport 函数的限制,下面的代码就可以正常工作。
export fn utf8lenCommand(c: *redis.client) void {
var o: *redis.robj = redis.lookupKeyReadOrReply(c, c.argv[1], redis.shared.czero) orelse return;
if (redis.checkType(c, o, redis.OBJ_STRING) != 0) return;
// Get the strlen
const len = redis.stringObjectLen(o);
// If the key encodes a number we're done.
if (o.encoding == redis.OBJ_ENCODING_INT) {
redis.addReplyLongLong(c, @intCast(i64, len));
return;
}
// Not a number! Grab the bytes and count the codepoints.
const str = @ptrCast([*]u8, o.ptr)[0..len];
const cps = std.unicode.utf8CountCodepoints(str) catch {
redis.addReplyError(c, "this aint utf8 chief");
return;
};
redis.addReplyLongLong(c, @intCast(i64, cps));
}
如果我们现在尝试编译,就会出现这样的错误:
./src/t_string_utf8.zig:15:10: error: no member named 'encoding' in opaque type '.cimport:3:15.struct_redisObject'
if (o.encoding == redis.OBJ_ENCODING_INT) {
让我们看看如何解决最后这个问题。
与 C 头文件有关的问题
当你 cImport 一个头文件时,Zig 会尝试把它的内容翻译成 Zig 的对应文件(这和链接到 C 编译单元的过程是不同的)。同样的功能也可以通过命令行使用 zig translate-c 来实现,这对于诊断 cImport 系统的问题也很有用,比如我们现在遇到的问题。
如果我们在头文件中运行 translate-c,就会发现 robj(Redis Object)结构体的定义被翻译成了不透明类型,因为 Zig 无法解析位域指定符。目前,translate-c 有一个不支持 C 语言特性的简短列表,这些特性正在逐步得到解决,但可惜的是,我们现在需要找到一种解决方法。
下面是 robj 的 C 语言定义,取自 server.h:
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
最普遍的解决方法是手动完成 translate-c 无法完成的工作,即在 Zig 中编写与 C 语言兼容的结构体定义。同样的方法也可以用在函数声明上,事实上,我们也可以完全不导入 server.h,而只是手动写下所有需要的符号的外部定义。尽管如此,在这种情况下,我们可以做一些比对同一结构体进行第二次定义更简单易行的事情:我们可以在 server.c 中创建几个 getter 函数,然后在 Zig 中使用它们。
从C获得帮助
既然我们很难进入 robj,那就添加几个 C 语言函数来帮我们解决这个问题吧。
在 server.c 中添加
void* getPtrFromObj(robj* r) {return r->ptr;}
unsigned getEncodingFromObj(robj* r) {return r->encoding;}
然后在 server.h 中添加相对正向声明:
void* getPtrFromObj(robj*);
unsigned getEncodingFromObj(robj*);
请注意:我们不能直接在 server.h 中定义我们的函数,因为我们要使用 cImport 将 server.h 转换为 Zig 代码,这仍然会导致中断。这样,我们只向 Zig 提供正向声明,让函数在链接时被解析。
最后,如果你担心 getter 函数会影响性能,也不必担心,因为 LTO(链接时间优化)可以跨越语言界限。
工作代码
在使用了新的 getter 函数后,我们终于可以用 Zig 编写实现了。
export fn utf8lenCommand(c: *redis.client) void {
var o: *redis.robj = redis.lookupKeyReadOrReply(c, c.argv[1], redis.shared.czero) orelse return;
if (redis.checkType(c, o, redis.OBJ_STRING) != 0) return;
// Get the strlen
const len = redis.stringObjectLen(o);
// If the key encodes a number we're done.
if (redis.getEncodingFromObj(o) == redis.OBJ_ENCODING_INT) {
redis.addReplyLongLong(c, @intCast(i64, len));
return;
}
// Not a number! Grab the bytes and count the codepoints.
const str = @ptrCast([*]u8, redis.getPtrFromObj(o))[0..len];
const cps = std.unicode.utf8CountCodepoints(str) catch {
redis.addReplyError(c, "this aint utf8 chief");
return;
};
redis.addReplyLongLong(c, @intCast(i64, cps));
}
现在,在重建项目后,你应该可以看到 UTF8LEN 的新行为。
> set foo "voilà"
OK
> strlen foo
6
> utf8len foo
5
您可以在 GitHub 上找到完整列表
总之
这次的工作强度有点大,但真正的项目就是这样。我希望能在不引入不必要概念的前提下,为你打开一扇了解 Redis 的有趣窗口。
正如你所看到的,将 Zig 添加到 C 项目中并不能自动解决所有复杂问题,但大部分情况下都是无缝的,而且考虑到 C/Zig 互操作的工作方式,你可以在遇到障碍时轻松找到解决方法。此外,随着使用量的增加,translate-c 也在不断改进,所以我相信很快就会覆盖缺失的 C 语言语法。
如果你喜欢 Zig 的发展方向,不妨看看安德鲁的 "Zig 1.0 之路",了解一下 Zig Learn,并加入 Zig 社区!
最后,如果您想帮助我们更快地实现 Zig 1.0,请考虑向 Zig 软件基金会捐款,以便我们聘请更多的全职贡献者。
加分
还想了解更多吗?这里有几件事值得思考!
正确的错误!
在进行实时流编码时,安德鲁利用 std.unicode.utf8CountCodepoints 具有精确的可能错误集这一事实,添加了更好的错误报告。
const cps = std.unicode.utf8CountCodepoints(str) catch |err| return switch (err) {
error.Utf8ExpectedContinuation => redis.addReplyError(c, "Expected UTF-8 Continuation"),
error.Utf8OverlongEncoding => redis.addReplyError(c, "Overlong UTF-8 Encoding"),
error.Utf8EncodesSurrogateHalf => redis.addReplyError(c, "UTF-8 Encodes Surrogate Half"),
error.Utf8CodepointTooLarge => redis.addReplyError(c, "UTF-8 Codepoint too large"),
error.TruncatedInput => redis.addReplyError(c, "UTF-8 Truncated Input"),
error.Utf8InvalidStartByte => redis.addReplyError(c, "Invalid UTF-8 Start Byte"),
};
以下是如何触发其中一些错误的方法:
> set foo "\xc3\x28"
OK
> utf8len foo
(error) ERR Expected UTF-8 Continuation
下面是 foo 的一些其他值,它们会引发不同的错误:
"\xa0\xa1"
"\xc0\x80"
"\xf4\x90\x80\x80"
"\xed\xbf\xbf"
代码点?
如果要处理现实世界中的文本,计算 UTF8 代码点远远不够。多个编码点可以组合成新的符号,例如宇航员表情符号就是由 3 个编码点(人、零宽度接合器、火箭)组合而成的。
Ziglyph就是这个问题的解决方案。一旦完成向 Zig 编译器自托管实现的过渡,Zig 还将捆绑软件包管理器,使 Zig 成为获取依赖关系、构建和扩展 C/C++ 项目的完整解决方案。届时,将 Ziglyph(或任何其他 Zig 软件包)与 Redis 挂钩将是一件非常有趣的事情。