技术宝典 | NeCodeGen:基于 clang 的源到源转译工具
導讀:我們生活在一個多樣的世界:豐富多樣的操作系統、豐富多樣的編程語言、豐富多樣的技術棧,如此豐富多樣的技術棧為軟件提供商帶來了的挑戰:如何快速覆蓋這些系統/技術棧以滿足不同背景的用戶的需求?本文基于網易云信的落地場景,詳細介紹了基于 clang 的源到源轉譯工具。
文|開開
網易云信資深 C++ 開發工程師
01 前言
我們生活在一個多樣的世界: ?豐富多樣的操作系統、豐富多樣的編程語言、豐富多樣的技術棧,下面是對前端一個粗略地統計:
?
如此豐富多樣的技術棧為軟件提供商帶來了的挑戰:如何快速覆蓋這些系統/技術棧以滿足不同背景的用戶的需求?
以網易云信 IM 為例,它的研發流程大致如下:
?
隨著業務發展,網易云信 IM 的 API 越來越多(有幾百個),為適配其他平臺,工程師需要投入大量的時間來編寫 language binding,這部分工作冗雜、耗時、且重復性極大;在維護階段,對 C++ 接口的修改都需要同步到各個 language binding 中,稍有遺漏則會導致問題。為提高生產力和研發效率,將工程師從重復且繁重的"體力活"中解放出來讓其更專注于重要功能的研發,?網易云信的大前端團隊研發了基于 clang 的源到源轉譯工具 NeCodeGen,本文將對 NeCodeGen 進行介紹,以期為面臨相同問題的工程師們提供解決問題的方法與思路。
02 為什么要重造輪子?
網易云信團隊對 language binding 有很多靈活的自定義需求:?
從實現層面:需要能夠自定義命名風格、方法實現細節、業務邏輯的封裝等;
從接口易用性、友好性的角度:作為軟件提供商,需要保證 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 的方式發布,工程師可以基于它來開發自己的應用,它的目的是簡化具有相同需求的用戶的開發成本,提供解決這類問題的最佳工程實踐,具備如下特性:
使用靈活: 內置模板引擎 jinja,讓工程師使用 jinja 模板語言來靈活的描述代碼模板;
支持從 C++ 同時生成多種目標語言程序,便于工程師同時管理多種目標語言程序,這一點和 SWIG 類似;
提供最佳工程實踐;
充分利用 Python 的語法糖;
在實現上 NeCodeGen 使用 Python3 作為開發語言,使用 Libclang 作為 compiler front end,使用 jinja 作為模板引擎,它借鑒了:
在 Python 中非常流行的 web 框架 Flask;
clang 的 LibASTMatchers 和 LibTooling;
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 functionint fooFunc(){ return 1;}/// test classclass 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,下面是它非常重要的成員:
kind,類型是 clang.cindex.CursorKind;
type,類型是 clang.cindex.Type,通過它可以獲得類型信息;
spelling,它表示節點的名稱。
05 jinja 模板引擎簡介
由于后面的例子中會使用 jinja,故先對它進行簡單介紹。讀者不需要有學習新事物的惶恐,因為 jinja 非常簡單易學,模板并不是什么新概念,熟悉模板元編程的讀者對于模板應該不會陌生,并且 jinja 的模板語言和 Python 基本相同,因此并不會引入太多新的概念,一些 jinja 中的概念其實完全可以使用我們熟知的概念來進行類比。
下面是一個簡單的 jinja 模板和渲染模板的程序:
from typing import Listfrom 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 給出的解法是:
對于重復的代碼,工程師需要抽象出它們的通用模式(代碼模板),然后使用 template language 來描述代碼模板,在 NeCodeGen 中,使用的 template language 是 jinja;
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/clang-based-src2src-demo-code
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,關于此,讀者可以閱讀如下文章:?
-
https://clang.llvm.org/docs/LibASTMatchers.html#matching-the-clang-ast
-
https://clang.llvm.org/docs/LibASTMatchersTutorial.html
云信 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/Comparison_of_code_generation_tools
?作者介紹?
開開,網易云信資深 C++ 開發工程師,負責云信基礎技術研發,具有豐富的研發經驗,熟悉編程語言理論。
總結
以上是生活随笔為你收集整理的技术宝典 | NeCodeGen:基于 clang 的源到源转译工具的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 来自开发者的点赞!网易云信揽获3大技术奖
- 下一篇: 来自开发者的点赞 · 网易云信揽获三大技