技術乾貨 | NeCodeGen:基於 clang 的源到源轉譯工具

網易雲信發表於2022-01-19
導讀:我們生活在一個多樣的世界:豐富多樣的作業系統、豐富多樣的程式語言、豐富多樣的技術棧,如此豐富多樣的技術棧為軟體提供商帶來了的挑戰:如何快速覆蓋這些系統/技術棧以滿足不同背景的使用者的需求?本文基於網易雲信的落地場景,詳細介紹了基於 clang 的源到源轉譯工具。

文|開開 網易雲信資深 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 設計模式:

 

 title=

在後面的程式中,還會繼續使用這種設計模式,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++ 開發工程師,負責雲信基礎技術研發,具有豐富的研發經驗,熟悉程式語言理論。

相關文章