文|开开 网易云信资深 C++ 开发工程师

01 前言

我们生活在一个多样的世界:  丰富多样的操作系统、丰富多样的编程语言、丰富多样的技术栈,下面是对前端一个粗略地统计:

 

如此丰富多样的技术栈为软件提供商带来了的挑战:如何快速覆盖这些系统/技术栈以满足不同背景的用户的需求?

以网易云信 IM 为例,它的研发流程大致如下:

(https://p3-juejin.byteimg.com...)

 

随着业务发展,网易云信 IM 的 API 越来越多(有几百个),为适配其他平台,工程师需要投入大量的时间来编写 language binding,这部分工作冗杂、耗时、且重复性极大;在维护阶段,对 C++ 接口的修改都需要同步到各个 language binding 中,稍有遗漏则会导致问题。为提高生产力和研发效率,将工程师从重复且繁重的"体力活"中解放出来让其更专注于重要功能的研发, 网易云信的大前端团队研发了基于 clang 的源到源转译工具 NeCodeGen,本文将对 NeCodeGen 进行介绍,以期为面临相同问题的工程师们提供解决问题的方法与思路。

02 为什么要重造轮子?

网易云信团队对 language binding 有很多灵活的自定义需求: 

  1. 从实现层面: 需要能够自定义命名风格、方法实现细节、业务逻辑的封装等;
  2. 从接口易用性、友好性的角度: 作为软件提供商,需要保证 API 简单易用,且符合语言的最佳实践;

调研了当前比较流行的同类工具后,发现它们在自定义代码生成上的支持不够,用户很难对生成的代码进行控制,无法满足上面提及的需求。为此云信团队结合自身需求研发了 NeCodeGen,通过代码模板给予使用者对生成的代码完全的控制,使之成为一个通用的、灵活的工具。

当前开源世界中存在很多非常优秀的自动化生成 language binding 工具,比如强大的 SWIG、dart ffigen 等,NeCodeGen 的主要目标是满足灵活的自定义需求,能够作为现有工具集的一个补充。在云信团队中,常常将它和其他代码生成工具结合使用来提升研发效率,下面是云信的一个应用场景:


 

由于 dart ffigen 只支持 C 接口,因此首先使用 NeCodeGen 开发生成 C API 和对应的 C implementation 的应用程序,然后使用 dart ffigen 由 C API 来生成的 dart binding,由于 dart ffigen 生成的 dart binding 大量使用 dart ffi 中的类型,它无法满足易用性、友好性需求(上图中将称为low level dart binding)。还需要基于它进一步进行封装,云信再次使用 NeCodeGen 生成更加友好易用的 high level dart binding,在实现上依赖 low level dart binding。

03 NeCodeGen 简介

NeCodeGen 是一个代码生成框架,它以 Python package 的方式发布,工程师可以基于它来开发自己的应用,它的目的是简化具有相同需求的用户的开发成本,提供解决这类问题的最佳工程实践, 具备如下特性:

  1. 使用灵活: 内置模板引擎 jinja,让工程师使用 jinja 模板语言来灵活的描述代码模板;
  2. 支持从 C++ 同时生成多种目标语言程序,便于工程师同时管理多种目标语言程序,这一点和 SWIG 类似;
  3. 提供最佳工程实践;
  4. 充分利用 Python 的语法糖;

在实现上 NeCodeGen 使用 Python3 作为开发语言,使用 Libclang 作为 compiler front end,使用 jinja 作为模板引擎,它借鉴了:

  1. 在 Python 中非常流行的 web 框架 Flask;
  2. clang 的 LibASTMatchers 和 LibTooling;
  3. SWIG;

下文将对 NeCodeGen 的各个部分进行更加详细的介绍。

04 clang 的简介

clang 是 LLVM project 的 C 系语言 compiler front end,它支持的语言包括: C、C++、Objective C/C++ 等。clang 采用的是“Library Based Architecture"”(基于 library 的架构),这意味着它的各个功能模块会以独立的库的方式实现,工程师可以直接使用这些功能,并且 clang 的 AST 能够完整的反映 source code 的信息。clang 的这些特性帮助了工程师基于它来开发一些工具,典型的例子就是 clang-format。网易云信的工程师在调研后选择使用 clang 来作为 NeCodeGen 的 compiler front end。

05 工欲善其事,必先利其器: 学习 clang AST

我们先做一些准备工作: 学习 clang AST,这是使用它来实现源到源转译工具的前提,如果读者已经掌握了 clang AST,可以跳过本段。clang AST 比较庞杂,从根本上来说这是源于 C++ 语言的复杂性,本节使用 Libclang 的 Python binding 带领读者以实践探索的方式学习 clang AST。

读者首先需要安装 Libclang 的 Python binding,命令如下:

pip install libclang

为便于演示,不将 C++ code 保存到文件中,而是通过字符串的方式传入到 Libclang 中进行编译,完整程序如下:

import clang.cindex

code = """
#include <string>
/// test function
int fooFunc(){
    return 1;
}/// test class
class FooClass{
    int m1 = 0;
    std::string m2 = "hello";
    int fooMethod(){
        return 1;
    }
};
int main(){
    fooFunc();
    FooStruct foo1;
    FooClass foo2;
 }"""  # C++源代码
index = clang.cindex.Index.create()  # 创建编译器对象

translation_unit = index.parse(path='test.cpp', unsaved_files=[('test.cpp', code)], args=['-std=c++11'])  #

index.parse 函数编译 C++ code,参数 args 表示编译参数。

Translation unit

index.parse 函数的返回值类型为 clang.cindex.TranslationUnit(转换单元),我们可以使用 Python 的 type 函数进行验证: 

 type(translation_unit) 
 Out[6]: clang.cindex.TranslationUnit

查看 include

for i in translation_unit.get_includes():
    print(i.include.name)

通过调用 get_includes() 可以查看 translation unit 所包含的所有的头文件。如果读者实际进行执行的话,会发现它实际包含的头文件不止 <string>,这是因为头文件 <string> 会包含其他头文件,而这些头文件还会包好其他的头文件,compiler 需要逐个包含。

get_chidren

clang.cindex.TranslationUnit 的 cursor 属性表示它的 AST,我们来验证一下它的类型:

​​​​​​​

 type(translation_unit.cursor) 
 Out[9]: clang.cindex.Cursor

从输出可以看出,它的类型是 clang.cindex.Cursor;它的成员方法  get_children() 可以返回它的直接子节点:

​​​​​​​

for child in translation_unit.cursor.get_children():
  print(f'{child.location}, {child.kind}, {child.spelling}'
)

输出摘要如下:

​​​​​​​

......
<SourceLocation file 'D:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.29.30133\include\string', line 24, column 1>, CursorKind.NAMESPACE, std
<SourceLocation file 'test.cpp', line 4, column 5>, CursorKind.FUNCTION_DECL, fooFunc
<SourceLocation file 'test.cpp', line 8, column 7>, CursorKind.CLASS_DECL, FooClass

"......"表示省略了部分输出内容;仔细观察最后四行,它们是文件 test.cpp 中的内容,能够和源代码正确地匹配,这也验证了前面提及的:"clang AST 能够完整的反映 source code 的信息"。

DECL 是“declaration”的缩写,表示“声明”。

walk_preorder

clang.cindex.Cursor 的 walk_preorder 方法对 AST 进行先序遍历:

​​​​​​​

children = list(translation_unit.cursor.get_children())
foo_class_node = children[-2] # 选取 class FooClass 的节点树
for child in foo_class_node.walk_preorder(): # 先序遍历   
    print(f'{child.location}, {child.kind}, {child.spelling}')

上述对 class FooClass 对应的 AST 进行先序遍历,输出如下:

​​​​​​​

<SourceLocation file 'test.cpp', line 8, column 7>, CursorKind.CLASS_DECL, FooClass
<SourceLocation file 'test.cpp', line 9, column 9>, CursorKind.FIELD_DECL, m1
<SourceLocation file 'test.cpp', line 9, column 14>, CursorKind.INTEGER_LITERAL,
<SourceLocation file 'test.cpp', line 10, column 17>, CursorKind.FIELD_DECL, m2
<SourceLocation file 'test.cpp', line 10, column 5>, CursorKind.NAMESPACE_REF, std
<SourceLocation file 'test.cpp', line 10, column 10>, CursorKind.TYPE_REF, std::string
<SourceLocation file 'test.cpp', line 11, column 9>, CursorKind.CXX_METHOD, fooMethod
<SourceLocation file 'test.cpp', line 11, column 20>, CursorKind.COMPOUND_STMT,
<SourceLocation file 'test.cpp', line 12, column 9>, CursorKind.RETURN_STMT,
<SourceLocation file 'test.cpp', line 12, column 16>, CursorKind.INTEGER_LITERAL,

请读者自行将上述输出和源代码进行对比。

AST node: clang.cindex.Cursor

对于 clang.cindex.Cursor,下面是它非常重要的成员:

  1. kind, 类型是 clang.cindex.CursorKind;
  2. type, 类型是 clang.cindex.Type,通过它可以获得类型信息;
  3. spelling, 它表示节点的名称。

05 jinja 模板引擎简介

由于后面的例子中会使用 jinja,故先对它进行简单介绍。读者不需要有学习新事物的惶恐,因为 jinja 非常简单易学,模板并不是什么新概念,熟悉模板元编程的读者对于模板应该不会陌生,并且 jinja 的模板语言和 Python 基本相同,因此并不会引入太多新的概念,一些 jinja 中的概念其实完全可以使用我们熟知的概念来进行类比。

下面是一个简单的 jinja 模板和渲染模板的程序:

from typing import List
from jinja2 import Environment, BaseLoader

jinja_env = Environment(loader=BaseLoader)
view_template = jinja_env.from_string(
    'I am {{m.name}}, I am familiar with {%- for itor in m.languages %} {{itor}}, {%- endfor %}')  # jinja模板
class ProgrammerModel:
    """
    model
    """
    def __init__(self):
        self.name = ''  # 姓名
        self.languages: List[str] = []  # 掌握的语言
def controller():
    xiao_ming = ProgrammerModel()
    xiao_ming.name = 'Xiao Ming'
    xiao_ming.languages.append('Python')
    xiao_ming.languages.append('Cpp')
    xiao_ming.languages.append('C')
    print(view_template.render(m=xiao_ming))

if __name__ == '__main__':
    controller()

上面程序定义了一个简单的软件工程师自我介绍的模板 view_template,然后对其进行渲染,从而得到完整的内容,运行程序,它的输出如下:

I am Xiao Ming, I am familiar with Python, Cpp, C,

jinja template variable 其实就是 "模板参数"

仔细对比 view_template 和最终的输出,可以发现其中使用 {{ }} 括起来的部分会被替换,它就是 jinja template variable,即“模板参数”,它的语法为: {{template variable}}。

MVC 设计模式

在上面的程序中,其实我们使用了 MVC 设计模式:

 

在后面的程序中,还会继续使用这种设计模式,NeCodeGen 非常推荐工程师使用这种设计模式来构建应用,在后面有专门的章节对 MVC 设计模式进行介绍。

jinja render 其实很像是“替换”

view_template.render(m=xiao_ming) 即是对模板进行渲染,这个过程可以简单的理解为“替换”,即使用变量 xiao_ming 对模板参数 m 进行替换,如果使用函数参数来进行类比的话,变量 xiao_ming 是实参。

06 Abstraction and code template

当程序中出现重复代码的时候,我们最先想到的是泛型编程、元编程、注解等编程技巧,它们能够帮助工程师简化代码,但不同的 programming language 的抽象能力不同,并且对于一些编程任务上述编程技巧也无济于事。这些都导致了工程师不可避免地去重复写相同模式的代码,这种问题在实现 language binding 中尤其突出。

对于这类问题,NeCodeGen 给出的解法是:

  1. 对于重复的代码,工程师需要抽象出它们的通用模式(代码模板),然后使用 template language 来描述代码模板,在 NeCodeGen 中,使用的 template language 是 jinja;
  2. NeCodeGen 会编译源程序文件并生成 AST,工程师需要从 AST 中提取必要的数据,然后执行转换(参见后面的“代码转换”章节),然后将转换后的数据作为代码模板中模板参数的实参完成了代码模板的渲染,从而得到了目标代码。

下面就结合简单的例子来对对上述解法进行更加具体的说明,在这个例子中,工程师需要将 C++ 中的 struct 在 TypeScript 中进行等价的定义,为清晰起见,下面以表格的形式展示了一个具体的例子: 

C++TypeScrip
  

现在我们需要考虑如何让程序自动化地帮我们完成这个任务。显然通过 clang,我们可以拿到 struct NIM_AuthInfo 的 AST,我们还需要考虑如下问题:

Q1:C++ 类型和 TypeScript 类型的对应关系?

A: std::string -> string,int -> integer

Q2:C++ 中 struct 在 TypeScript 中如何进行命名?

A:为简单起见,我们让 TypeScript 中的名称和 C++ 的保持一致。

Q3:TypeScript 中使用什么语法来描述类似于 C++struct?

A: 使用的 TypeScript interface 来进行描述,我们可以使用 jinja 写出通用的代码模板来进行描述。

下面我们给出具体的实现。按照前面的 MVC 章节提出的思想,我们可以首先建立 struct 的数据建模:

class StructModel:
    def __init__(self):
        self.src_name = ''  # 源语言中的名称
        self.des_name = ''  # 目标语言的名称
        self.fields: List[StructFieldModel] = []  # 结构体的字段

class StructFieldModel:
    def __init__(self):
        self.src_name = ''  # 源语言中的名称
        self.des_name = ''  # 目标语言的名称
        self.to_type_name = ''  # 目标语言的类型名称

然后我们写出 TypeScript 的代码模板,这个代码模板是基于 StructModel 来写的: 

​​​​​​​

export interface {{m.des_name}} {
{% for itor in m.fields %}{{itor.des_name}} :
{{itor.to_type_name}} ,
{% endfor %}
}

接下来的工作就是从 C++ struct AST 中提取关键数据并进行必要的转换:

​​​​​​​

def controller(struct_node: clang.cindex.Cursor, model: StructModel) -> str:
    model.src_name = model.des_name = struct_node.spelling  # 提取struct的name       for field_node in struct_node.get_children():
        field_model = StructFieldModel()
        field_model.src_name = field_model.des_name = field_node.spelling  # 提取字段的name
        field_model.to_type_name = map_type(field_node.type.spelling)  # 执行类型映射
        model.fields.append(field_model)
    return view_template.render(m=model)  # 渲染模板,得到TypeScript代码

完整程序

完整程序可以通过如下链接获得: 

https://github.com/dengking/c...

07 从源语言到目标语言的转译

将由源语言编写的程序转译为目标语言的程序时,主要涉及如下三个方面的转换:

类型转换 type mapping

从源语言中的类型到目标语言中的类型的转换。在 NeCodeGen 中对 C++ 语言的内置类型和 C++ 标准库类型进行了枚举并给出了预定义,对于这部分类型的转换,使用 hash map 建立映射关系;对于用户自定义类型,NeCodeGen 无法给出预定义,则需要由工程师自行定义。

命名转换 name mapping

不同语言的命名规范不同,因此工程师需要考虑命名转换。如果源程序遵循统一的命名规范,那么使用正则表达式能够方便地进行命名的转换,这样能够保证生成的程序的严格的遵循用户设置的命名规范,这也体现了自动化代码生成工具的优势:程序对命名规范的遵守比工程师更加严格。

语法转换 syntax mapping

在网易云信的 NeCodeGen 中,语法转换主要是通过代码模板完成的, 工程师需要按照目标语言的语法来编写代码模板,然后通过渲染即可得到符合目标语言语法的程序。

08 NeCodeGen 的 Design pattern

至此,读者已经对云信 NeCodeGen 有了一些基本认识,本节主要介绍云信 NeCodeGen 推荐的一些设计模式,在云信 NeCodeGen 的实现中,提供了支持这些 design pattern 的基础功能。这些设计模式是经过工程实践后总结得出的,能够帮助工程师开发出更易维护的应用,由于 C++ 语言的复杂性,其 AST 的处理也会比较复杂,合适的设计模式就尤为重要,这对于大型项目而言,具有重要意义。

Matcher

在编写源到源转译工具时,常用的模式是匹配感兴趣的节点,然后对匹配的节点执行对应的处理, 比如名称转换、类型转换。Matcher pattern 就是为这种典型的需求而创建的:框架遍历 AST,并执行用户注册的 match funcion(匹配函数),一旦匹配成功,则执行 match funcion 对应的 callback。这种模式是 clang 社区为开发 clang tool 而总结出来的,并提供了支持库  LibASTMatchers,关于此,读者可以阅读如下文章: 

云信 NeCodeGen 借鉴了这种模式,并结合 Python 语言的特性、自身的需求进行了本地化实现,它运用了 Python 的 decorator 语法糖,通用的写法如下:

​​​​​​​

@frontend_action.connect(match_func)
def callback():
    pass

上述写法的含义是: 告诉frontend_action连接(connect) match funcionmatch_func 和 callback  callback;frontend_action 在遍历 AST 时,会将节点作为入参,依次执行所有向它注册的 match func,如果 match func 返回 True,则表示匹配成功,框架就会执行 callback 函数来对匹配成功的节点进行处理,否则 pass。

通过实践来看,这种模式能够应用的结构更加清晰、代码复用程度更高。

目前 clang 官方并没有提供 LibASTMatchers 的 Python binding,为便于用户使用,云信 NeCodeGen 提供对常用节点进行匹配的 match funcion。

MVC

MVC 模式读者应该不会陌生,它是前端开发中经常使用的一种模式,在本文前面的“jinja模板引擎简介”章节中已经对其进行了简单介绍,云信 NeCodeGen 中 MVC 可以归纳为:

![]

 

实际使用中,推荐工程师使用自顶向下的思路: 定义 model,确定 model 的成员,基于 model 来编写代码模板,然后再编写提取、转换函数来获取数据来对 model 进行初始化,最后使用 model 来渲染模板。

从实践来看,MVC 能够使代码结构清晰,维护性更强;对于需要从一种源语言生成多种目标语言程序的项目,MVC 能够保证 Model 在目标语言中保持一致,这在一定程度上能够提示代码复用。

总结

Matcher pattern 是 NeCodeGen 框架使用的模式,MVC pattern 则是推荐应用开发者使用的模式;Matcher pattern 的 callback 对应了 MVC pattern 的 controller,即工程师在 callback 中实现 controller 的功能。

09 How NeCodeGen run

通过前面的介绍,我们已经对 NeCodeGen 的运行流程有了大致的认识,下面是以流程图的形式对 NeCodeGen 的运行流程进行总结

 

10 应用价值

代码生成工具的主要目的是提升生产力,对于大型项目而言,它的作用更加明显。在网易云信的工程实践中,工程师会综合运用多种代码生成工具,充分发挥工具的威力,并将工具加入 CICD,这样的做法极大地提升了研发效率;对生产力的提高还体现在重构和维护上,在修改源语言程序后,运行工具即可得到更新后的目标语言程序,这能够避免由于源语言程序和目标语言程序的不一致而导致的错误;重构工作也将变得简单,对目标语言程序的重构将简化为修改代码模板,重新运行工具后,即可完成所有的重构。代码生成工具优势还体现在对代码规范的遵守上,通过将命名规范、代码规范等编码在工具中,能够保证生成的程序对代码规范百分之百的遵守。

NeCodeGen 除了可以应用于 language binding 的生成上,还可以应用于其他领域,比如实现类似于 QT 的 Meta-Object System,它也可以作为一个 stub code generator。

术语

参考内容

https://en.wikipedia.org/wiki...

作者介绍

开开,网易云信资深 C++ 开发工程师,负责云信基础技术研发,具有丰富的研发经验,熟悉编程语言理论。

03-06 00:00