比最差的API(ETW)更差的API(LTTng)是如何炼成的, 谈如何写一个好的接口
最近这几天在帮柠檬看她的APM系统要如何收集.Net运行时的各种事件, 这些事件包括线程开始, JIT执行, GC触发等等.
.Net在windows上(NetFramework, CoreCLR)通过ETW(Event Tracing for Windows), 在linux上(CoreCLR)是通过LTTng跟踪事件.
ETW的API设计已经被很多人诟病, 微软推出的类库krabsetw中直指ETW是最差的API并且把操作ETW的文件命名为噩梦.hpp.
而且这篇文章中, Casey Muratori解释了为什么ETW是最差的API, 原因包括:
然而Casey Muratori的文章对我帮助很大, 我只用了1天时间就写出了使用ETW收集.Net运行时事件的示例代码.
之后我开始看如何使用LTTng收集这些事件, 按照我以往的经验linux上的类库api通常会比windows的好用, 但LTTng是个例外.
我第一件做的事情是去查找怎样在c程序里面LTTng的接口, 我打开了他们的文档然后开始浏览.
很快我发现了他们的文档只谈了如何使用代码发送事件, 却没有任何说明如何用代码接收事件, 我意识到我应该去看源代码.
使用LTTng跟踪事件首先需要创建一个会话, 启用事件和添加上下文参数, 然后启用跟踪, 在命令行里面是这样的调用:
lttng create --live lttng enable-event --userspace --tracepoint DotNETRuntime:GCStart_V2 lttng add-context --userspace --type vpid lttng add-context --userspace --type vtid lttng start
lttng这个命令的源代码在github上, 通过几分钟的查找我发现lttng的各个命令的实现都是保存在这个文件夹下的.
打开create.c后又发现了创建会话调用的是lttng_create_session函数, 而lttng_create_session函数可以通过引用lttng.h调用.
再过了几分钟我写出了第一行代码
int ret = lttng_create_session_live("example-session", "net://127.0.0.1", 1000000);
运行后立刻就报错了, 错误是"No session daemon is available".
原因是lttng-sessiond这个程序没有启动, lttng是通过一个独立服务来管理会话的, 而这个服务需要手动启动.
使用独立服务本身没有错, 但是lttng-sessiond这个程序提供了很多参数,
如果一个只想跟踪用户事件的程序启动了这个服务并指定了忽略内核事件的参数, 然后另外一个跟踪内核事件的程序将不能正常运作.
正确的做法是使用systemd来启动这个服务, 让系统管理员决定用什么参数, 而不是让调用者去启动它.
解决这个问题只需要简单粗暴的两行, 启动时如果已经启动过新进程会失败, 没有任何影响:
system("lttng-sessiond --daemonize"); std::this_thread::sleep_for(std::chrono::seconds(1));
现在lttng_create_session_live会返回成功了, 但是又发现了新的问题,
创建的会话是由一个单独的服务管理的, 即使当前进程退出会话也会存在, 第二次创建的时候会返回一个已存在的错误.
这个问题和ETW的问题一模一样, 解决方法也一模一样, 在创建会话前关闭它就可以了.
于是代码变成了这样:
system("lttng-sessiond --daemonize"); std::this_thread::sleep_for(std::chrono::seconds(1)); lttng_destroy_session(SessionName); int ret = lttng_create_session_live("example-session", "net://127.0.0.1", 1000000);
经过一段时间后, 我用代码实现了和命令行一样的功能:
// start processes, won't replace exists system("lttng-sessiond --daemonize"); std::this_thread::sleep_for(std::chrono::seconds(1)); // create new session lttng_destroy_session(SessionName); int ret = lttng_create_session_live(SessionName, SessionUrl, LiveSessionInterval); if (ret != 0) { std::cerr << "lttng_create_session: " << lttng_strerror(ret) << std::endl; return -1; } // create handle from session lttng_domain domain = {}; domain.type = LTTNG_DOMAIN_UST; lttng_handle* handle = lttng_create_handle(SessionName, &domain); if (handle == nullptr) { std::cerr << "lttng_create_handle: " << lttng_strerror(ret) << std::endl; return -1; } // enable event lttng_event event = {}; event.type = LTTNG_EVENT_TRACEPOINT; memcpy(event.name, EventName.c_str(), EventName.size()); event.loglevel_type = LTTNG_EVENT_LOGLEVEL_ALL; event.loglevel = -1; ret = lttng_enable_event_with_exclusions(handle, &event, nullptr, nullptr, 0, nullptr); if (ret < 0) { std::cerr << "lttng_enable_event_with_exclusions: " << lttng_strerror(ret) << std::endl; return -1; } // add context lttng_event_context contextPid = {}; contextPid.ctx = LTTNG_EVENT_CONTEXT_VPID; ret = lttng_add_context(handle, &contextPid, nullptr, nullptr); if (ret < 0) { std::cerr << "lttng_add_context: " << lttng_strerror(ret) << std::endl; return -1; } // start tracing ret = lttng_start_tracing(SessionName); if (ret < 0) { std::cerr << "lttng_start_tracing: " << lttng_strerror(ret) << std::endl; return -1; }
到这里为止是不是很简单? 尽管没有文档, 但是这些api都是非常简单的api, 看源代码就可以推测如何调用.
获取事件在告诉LTTng启用跟踪后, 我还需要获取发送到LTTng的事件, 在ETW中获取事件是通过注册回调获取的:
EVENT_TRACE_LOGFILE trace = { }; trace.LoggerName = (char*)mySessionName.c_str(); trace.EventRecordCallback = (PEVENT_RECORD_CALLBACK)(StaticRecordEventCallback); trace.BufferCallback = (PEVENT_TRACE_BUFFER_CALLBACK)(StaticBufferEventCallback); trace.ProcessTraceMode = PROCESS_TRACE_MODE_EVENT_RECORD | PROCESS_TRACE_MODE_REAL_TIME; TRACEHANDLE sessionHandle = ::OpenTrace(&trace); if (sessionHandle == INVALID_PROCESSTRACE_HANDLE) { // ... } ULONG processStatus = ::ProcessTrace(&sessionHandle, 1, nullptr, nullptr);
我寻思lttng有没有这样的机制, 首先我看到的是lttng.h中的lttng_register_consumer函数, 这个函数的注释如下:
This call registers an "outside consumer" for a session and an lttng domain. No consumer will be spawned and all fds/commands will go through the socket path given (socket_path).
翻译出来就是给会话注册一个外部的消费者, 听上去和我的要求很像吧?
这个函数的第二个参数是一个字符串, 我推测是unix socket, lttng会通过unix socket发送事件过来.
于是我写了这样的代码:
ret = lttng_register_consumer(handle, "/tmp/custom-consumer");
一执行立刻报错, 错误是Command undefined, 也就是命令未定义, 服务端不支持这个命令.
经过搜索发现lttng的源代码中没有任何调用这个函数的地方, 也就是说这个函数是个装饰.
看起来这个办法行不通.
经过一番查找, 我发现了live-reading-howto这个文档, 里面的内容非常少但是可以看出使用lttng-relayd这个服务可以读取事件.
读取事件目前只支持TCP, 使用TCP传输事件数据不仅复杂而且效率很低, 相对ETW直接通过内存传递数据这无疑是个愚蠢的办法.
虽然愚蠢但是还是要继续写, 我开始看这TCP传输用的是什么协议.
对传输协议的解释文档在live-reading-protocol.txt, 这篇文档写的很糟糕, 但总比没有好.
和lttng-relayd进行交互使用的是一个lttng自己创造的半双工二进制协议, 设计如下:
客户端发送命令给lttng-relayd需要遵从以下的格式
[data_size: unsigned 64 bit big endian int, 命令体大小] [cmd: unsigned 32 bit big endian int, 命令类型] [cmd_version: unsigned 32 bit big endian int, 命令版本] [命令体, 大小是data_size]
发送命令的设计没有问题, 大部分二进制协议都是这样设计的, 问题在于接收命令的设计.
接收命令的格式完全依赖于发送命令的类型, 例如LTTNG_VIEWER_CONNECT这个命令发送过去会收到以下的数据:
[viewer_session_id: unsigned 64 bit big endian int, 服务端指定的会话ID] [major: unsigned 32 bit big endian int, 大版本] [minor: unsigned 32 bit big endian int, 中版本] [type: 客户端的类型]
可以看出接收的数据没有数据头, 没有数据头如何决定接收多少数据呢? 这就要求客户端定义的回应大小必须和服务端完全一致, 一个字段都不能漏.
服务端在以后的更新中不能给返回数据随意添加字段, 返回多少字段需要取决于发送过来的cmd_version, 保持api的兼容性将会非常的麻烦.
目前在lttng中cmd_version是一个预留字段, 也就是他们没有仔细的想过api的更新问题.
正确的做法应该是返回数据也应该提供一个数据头, 然后允许客户端忽略多出来的数据.
看完协议以后, 我在想既然使用了二进制协议, 应该也会提供一个sdk来减少解析的工作量吧?
经过一番查找找到了一个头文件lttng-viewer-abi.h, 包含了和lttng-relayd交互使用的数据结构体定义.
这个头文件在源代码里面有, 但是却不在LTTng发布的软件包中, 这意味着使用它需要复制它到项目里面.
复制别人的源代码到项目里面不能那么随便, 看了一下LTTng的开源协议, 在include/lttng/*和src/lib/lttng-ctl/*下的文件是LGPL, 其余文件是GPL,
也就是上面如果把这个头文件复制到自己的项目里面, 自己的项目必须使用GPL协议开源, 不想用GPL的话只能把里面的内容自己一行行重新写, 还不能写的太像.
既然是测试就不管这么多了, 把这个头文件的代码复制过来就开始继续写, 首先是连接到lttng-relayd:
int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (fd < 0) { perror("socket"); return -1; } sockaddr_in address = {}; address.sin_addr.s_addr = inet_addr("127.0.0.1"); address.sin_family = AF_INET; address.sin_port = htons(5344); ret = connect(fd, (sockaddr*)&address, sizeof(address)); if (ret < 0) { perror("connect"); return -1; }
连接成功以后的交互流程在阅读上面的协议文档以后可以整理如下:
初始化 客户端发送命令 LTTNG_VIEWER_CLIENT_COMMAND + 构造体 lttng_viewer_connect 服务端返回构造体 lttng_viewer_connect 客户端发送命令 LTTNG_VIEWER_CREATE_SESSION + 构造体 lttng_viewer_create_session_response 服务端返回构造体 lttng_viewer_create_session_response 列出会话 客户端发送命令 LTTNG_VIEWER_LIST_SESSIONS, 不带构造体 服务端返回构造体 lttng_viewer_list_sessions + 指定长度的 lttng_viewer_session 附加到会话 客户端发送命令 LTTNG_VIEWER_ATTACH_SESSION + 构造体 lttng_viewer_attach_session_request 服务端返回构造体 lttng_viewer_attach_session_response + 指定长度的 lttng_viewer_stream 循环 { 如果需要获取新的流 { 客户端发送命令 LTTNG_VIEWER_GET_NEW_STREAMS + 构造体 lttng_viewer_new_streams_request 服务端返回构造体 lttng_viewer_new_streams_response + 指定长度的 lttng_viewer_stream } 如果需要获取新的元数据(metadata) { 枚举现存的metadata流列表 { 客户端发送命令 LTTNG_VIEWER_GET_METADATA + 构造体 lttng_viewer_get_metadata 服务端返回构造体 lttng_viewer_metadata_packet + 指定长度的payload } } 枚举现存的trace流列表 { 客户端发送命令 LTTNG_VIEWER_GET_NEXT_INDEX + 构造体 lttng_viewer_get_next_index 服务端返回构造体 lttng_viewer_index 检查返回的 index.flags, 如果服务端出现了新的流或者元数据, 需要先获取新的流和元数据才可以继续 客户端发送命令 LTTNG_VIEWER_GET_PACKET + 构造体 lttng_viewer_trace_packet 服务端返回构造体 lttng_viewer_trace_packet + 指定长度的payload 根据metadata packet和trace packet分析事件的内容然后记录事件 } }
是不是觉得很复杂?
因为协议决定了服务端发给客户端的数据没有数据头, 所以服务端不能主动推送数据到客户端, 客户端必须主动的去进行轮询.
如果你注意到构造体的名称, 会发现有的构造体后面有request和response而有的没有, 如果不看上下文只看构造体的名称很难猜到它们的作用.
正确的做法是所有请求和返回的构造体名称末尾都添加request和response, 不要去省略这些字母而浪费思考的时间.
为了发送命令和接收构造体我写了一些帮助函数, 它们并不复杂, 使用TCP交互的程序都会有类似的代码:
int sendall(int fd, const void* buf, std::size_t size) { std::size_t pos = 0; while (pos < size) { auto ret = send(fd, reinterpret_cast<const char*>(buf) + pos, size - pos, 0); if (ret <= 0) { return -1; } pos += static_cast<std::size_t>(ret); } return 0; } int recvall(int fd, void* buf, std::size_t size) { std::size_t pos = 0; while (pos < size) { auto ret = recv(fd, reinterpret_cast<char*>(buf) + pos, size - pos, 0); if (ret <= 0) { return -1; } pos += static_cast<std::size_t>(ret); } return 0; } template <class T> int sendcmd(int fd, std::uint32_t type, const T& body) { lttng_viewer_cmd cmd = {}; cmd.data_size = htobe64(sizeof(T)); cmd.cmd = htobe32(type); if (sendall(fd, &cmd, sizeof(cmd)) < 0) { return -1; } if (sendall(fd, &body, sizeof(body)) < 0) { return -1; } return 0; }
初始化连接的代码如下:
lttng_viewer_connect body = {}; body.major = htobe32(2); body.minor = htobe32(9); body.type = htobe32(LTTNG_VIEWER_CLIENT_COMMAND); if (sendcmd(fd, LTTNG_VIEWER_CONNECT, body) < 0) { return -1; } if (recvall(fd, &body, sizeof(body)) < 0) { return -1; } viewer_session_id = be64toh(body.viewer_session_id);
后面的代码比较枯燥我就省略了, 想看完整代码的可以看这里.
进入循环后会从lttng-relayd获取两种有用的数据:
元数据(metadata), 定义了跟踪数据的格式 跟踪数据(trace), 包含了事件信息例如GC开始和结束等获取元数据使用的是LTTNG_VIEWER_GET_METADATA命令, 获取到的元数据内容如下:
Wu@"Jtf@oe/* CTF 1.8 */ typealias integer { size = 8; align = 8; signed = false; } := uint8_t; typealias integer { size = 16; align = 8; signed = false; } := uint16_t; typealias integer { size = 32; align = 8; signed = false; } := uint32_t; typealias integer { size = 64; align = 8; signed = false; } := uint64_t; typealias integer { size = 64; align = 8; signed = false; } := unsigned long; typealias integer { size = 5; align = 1; signed = false; } := uint5_t; typealias integer { size = 27; align = 1; signed = false; } := uint27_t; trace { major = 1; minor = 8; uuid = "a3df4090-0722-4a74-97a4-81e066406f03"; byte_order = le; packet.header := struct { uint32_t magic; uint8_t uuid[16]; uint32_t stream_id; uint64_t stream_instance_id; }; }; env { hostname = "ubuntu-virtual-machine"; domain = "ust"; tracer_name = "lttng-ust"; tracer_major = 2; tracer_minor = 9; }; clock { name = "monotonic"; uuid = "f397e532-4837-402b-8cc9-700ed92a339d"; description = "Monotonic Clock"; freq = 1000000000; /* Frequency, in Hz */ /* clock value offset from Epoch is: offset * (1/freq) */ offset = 1514336042565610080; }; typealias integer { size = 27; align = 1; signed = false; map = clock.monotonic.value; } := uint27_clock_monotonic_t; typealias integer { size = 32; align = 8; signed = false; map = clock.monotonic.value; } := uint32_clock_monotonic_t; typealias integer { size = 64; align = 8; signed = false; map = clock.monotonic.value; } := uint64_clock_monotonic_t; struct packet_context { uint64_clock_monotonic_t timestamp_begin; uint64_clock_monotonic_t timestamp_end; uint64_t content_size; uint64_t packet_size; uint64_t packet_seq_num; unsigned long events_discarded; uint32_t cpu_id; }; struct event_header_compact { enum : uint5_t { compact = 0 ... 30, extended = 31 } id; variant <id> { struct { uint27_clock_monotonic_t timestamp; } compact; struct { uint32_t id; uint64_clock_monotonic_t timestamp; } extended; } v; } align(8); struct event_header_large { enum : uint16_t { compact = 0 ... 65534, extended = 65535 } id; variant <id> { struct { uint32_clock_monotonic_t timestamp; } compact; struct { uint32_t id; uint64_clock_monotonic_t timestamp; } extended; } v; } align(8); stream { id = 0; event.header := struct event_header_compact; packet.context := struct packet_context; event.context := struct { integer { size = 32; align = 8; signed = 1; encoding = none; base = 10; } _vpid; integer { size = 32; align = 8; signed = 1; encoding = none; base = 10; } _vtid; }; }; event { name = "DotNETRuntime:GCStart_V2"; id = 0; stream_id = 0; loglevel = 13; fields := struct { integer { size = 32; align = 8; signed = 0; encoding = none; base = 10; } _Count; integer { size = 32; align = 8; signed = 0; encoding = none; base = 10; } _Depth; integer { size = 32; align = 8; signed = 0; encoding = none; base = 10; } _Reason; integer { size = 32; align = 8; signed = 0; encoding = none; base = 10; } _Type; integer { size = 16; align = 8; signed = 0; encoding = none; base = 10; } _ClrInstanceID; integer { size = 64; align = 8; signed = 0; encoding = none; base = 10; } _ClientSequenceNumber; }; };
这个元数据的格式是CTF Metadata, 这个格式看上去像json但是并不是, 是LTTng的公司自己创造的一个文本格式.
babeltrace中包含了解析这个文本格式的代码, 但是没有开放任何解析它的接口, 也就是如果你想自己解析只能写一个词法分析器.
这些格式其实可以使用json表示, 体积不会增加多少, 但是这公司硬是发明了一个新的格式增加使用者的负担.
写一个词法分析器需要1天时间和1000行代码, 这里我就先跳过了.
接下来获取跟踪数据, 使用的是LTTNG_VIEWER_GET_NEXT_INDEX和LTTNG_VIEWER_GET_PACKET命令.
LTTNG_VIEWER_GET_NEXT_INDEX返回了当前流的offset和可获取的content_size, 这里的content_size单位是位(bit), 也就是需要除以8才可以算出可以获取多少字节,
关于content_size的单位LTTng中没有任何文档和注释说明它是位, 只有一个测试代码里面的某行写了/ CHAR_BIT.
使用LTTNG_VIEWER_GET_PACKET命令, 传入offset和content_size/8可以获取跟踪数据(如果不/8会获取到多余的数据或者返回ERR).
实际返回的跟踪数据如下:
000000: c1 1f fc c1 29 82 6b fe 24 10 4c 6b 97 91 4d c3 ....).k.$.Lk..M. 000010: ed d4 41 8f 00 00 00 00 03 00 00 00 00 00 00 00 ..A............. 000020: 92 91 49 96 08 0a 00 00 07 a0 58 b9 08 0a 00 00 ..I.......X..... 000030: 50 05 00 00 00 00 00 00 00 80 00 00 00 00 00 00 P............... 000040: 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 000050: 03 00 00 00 1f 00 00 00 00 92 91 49 96 08 0a 00 ...........I.... 000060: 00 e1 1b 00 00 03 00 00 00 02 00 00 00 01 00 00 ................ 000070: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1f ................ 000080: 00 00 00 00 4d ae a7 af 08 0a 00 00 e1 1b 00 00 ....M........... 000090: 04 00 00 00 02 00 00 00 01 00 00 00 00 00 00 00 ................ 0000a0: 00 00 00 00 00 00 00 00 00 00 ..........
跟踪数据的格式是CTF Stream Packet, 也是一个自定义的二进制格式, 需要配合元数据解析.
babeltrace中同样没有开放解析它的接口(有python binding但是没有解析数据的函数), 也就是需要自己写二进制数据解析器.
操作LTTng + 和relayd通讯 + 元数据词法分析器 + 跟踪数据解析器全部加起来预计需要2000行代码, 而这一切使用ETW只用了100多行代码.
糟糕的设计, 复杂的使用, 落后的文档, 各种各样的自定义协议和数据格式, 不提供SDK把LTTng打造成了一个比ETW更难用的跟踪系统.
目前在github上LTTng只有100多星而babeltrace只有20多, 也印证了没有多少人在用它们.
我不清楚为什么CoreCLR要用LTTng, 但欣慰的是CoreCLR 2.1会有新的跟踪机制EventPipe, 到时候可以更简单的实现跨平台捕获CoreCLR跟踪事件.
我目前写的调用ETW的代码放在了这里, 调用LTTng的代码放在了这里, 有兴趣的可以去参考.
教训最差的API(ETW)和更差的API(LTTng)都看过了, 那么应该如何避免他们的错误, 编写一个好的API呢?
Casey Muratori提到的教训有:
设计API的第一条和第二条规则: "永远都从编写用例开始"设计一个API时, 首先要做的是站在调用者的立场, 想想调用者需要什么, 如何才能最简单的达到这个需求.
编写一个简单的用例代码永远是设计API中必须的一步.
不要过多的去想内部实现, 如果内部实现机制让API变得复杂, 应该想办法去抽象它.
因为需求会不断变化, 设计API的时候应该为未来的变化预留空间, 保证向后兼容性.
例如ETW中监听的事件类型使用了位标记, 也就是参数是32位时最多只能有32种事件, 考虑到未来有更多事件应该把事件类型定义为连续的数值并提供额外的API启用事件.
现在有很多接口在设计时会考虑到版本, 例如用v1和v2区分, 这是一个很好的策略.
不要为了节省代码去让一个接口接收或者返回多余的信息.
在ETW中很多接口都共用了一个大构造体EVENT_TRACE_PROPERTIES, 调用者很难搞清楚接口使用了构造体里面的哪些值, 又影响了哪些值.
设计API时应该明确接口的目的, 让接口接收和返回必要且最少的信息.
对调用者来说, 100行的示例代码通常比1000行的文档更有意义.
因为接口的设计者和调用者拥有的知识量通常不对等, 调用者在没有看到实际的例子之前, 很可能无法理解设计者编写的文档.
这是很多接口都会犯的错误, 例如ETW中决定事件附加的信息时, 1表示时间戳, 2表示系统时间, 3表示CPU周期计数.
如果你需要传递具有某种意义的数字给接口, 请务必在SDK中为该数字定义枚举类型.
我从LTTng中吸收到的教训有:
写文档99%的调用者没有看源代码的兴趣或者能力, 不写文档没有人会懂得如何去调用你的接口.
现在有很多自动生成文档的工具, 用这些工具可以减少很多的工作量, 但是你仍然应该手动去编写一个入门的文档.
创造一个新的协议意味着需要编写新的代码去解析它, 而且每个程序语言都要重新编写一次.
除非你很有精力, 可以为主流的程序语言都提供一个SDK, 否则不推荐这样做.
很多项目都提供了REST API, 这是很好的趋势, 因为几乎每个语言都有现成的类库可以方便地调用REST API.
定义一个好的二进制协议需要很深的功力, LTTng定义的协议明显考虑的太少.
推荐的做法是明确区分请求和回应, 请求和回应都应该有一个带有长度的头, 支持全双工通信.
如果你想设计一个二进制协议, 强烈建议参考Cassandra数据库的协议文档, 这个协议无论是设计还是文档都是一流的水平.
但是如果你没有对传输性能有很苛刻的要求, 建议使用现成的协议加json或者xml.
这里我没有写轻易, 如果你有一个数据结构需要表示成文本, 请使用更通用的格式.
LTTng表示元数据时使用了一个自己创造的DSL, 但里面的内容用json表示也不会增加多少体积, 也就是说创造一个DSL没有任何好处.
解析DSL需要自己编写词法分析器, 即使是经验老道的程序员编写一个也需要不少时间(包含单元测试更多), 如果使用json等通用格式那么编写解析的代码只需要几分钟.
虽然这篇文章把LTTng批评了一番, 但这可能是目前全世界唯一一篇提到如何通过代码调用LTTng和接收事件数据的文章.
希望看过这篇文章的设计API时多为调用者着想, 你偷懒省下几分钟往往会导致别人浪费几天的时间.