PS:根據過去編寫 Modernizing 相關的開源工具里,編寫的《代碼分析與自動化重構》指南。
遺留系統的現代化演進是一門藝術。在日常的軟件開發里,我們經常會遇到一系列的問題:
- 如何解決人類智商不夠的問題?模式、原則和工具
- 誰應該去解決代碼的問題?代碼
- ……
應對于這些問題,其中的一個解決方案就是:自動化的工具,有些人喜歡稱之為器。支撐這些工具的便是一系列的原則與模式,將它們融入到工具之中。另外一個解決人成長的方案就是:元元(meta-meta),這是另外一個故事。應對于日常編碼而言,它便是代碼的分析,以及后續的自動化重構。
代碼分析與自動化重構流程:
簡介
Why 開源 + 遺留系統現代化工具
遺留系統是常態。在大多數公司里,我們所遇到的系統里多數是是遺留系統,來到一個新項目時,可能就需要對他們快速的分析,以提供洞見 —— 寫 PPT 匯報。所以,在過去的幾年里,我們也沉淀了一系列的遺留系統分析和重構的工具,比如新哥的 Tequila、正在開源的架構分析和守護工具 ArchGuard 等等。除此,在有些重構項目里,還要編寫定制的工具來進行分析,諸如于先前我的同事覃宇和俊斌等所寫的「移動應用遺留系統重構」 系列。
技術熱情發電。對多數人而言,我們面臨的一個重要挑戰則是:拿自己的業余時間來完善工具。
既然要用自己的時間來開發,還和項目沒有關系,這種用愛發電的事情,用開源的方式最合適了。
我們需要怎樣的工具?
從對于使用工具的結果來看,我們需要這個現代化工具是:
- 可視化驅動。快速生成項目的分析結果,并展示出來給開發人員了解現狀,還有編寫 PPT。
- 必要的交互性。用于在重構的過程中,尋找合適的切入點。
- 定制化開發。
- 特定壞味道。不同的開發團隊會有不同的壞味道,有些壞味道是無法由 SonarQube 這樣的工具識別的。
- 自動化重構。基于已知的壞味道,對應的代碼位置信息,對代碼進行自動化重構。
- 適當的語法精準度。更高的語法精準度,意味著更高的開發成本,需要有針對地平衡它們。
- 多平臺。我們用的是 macOS,而多數時候,客戶使用的是 Windows。
如何開發這樣的工具?
這里定義的遺留系統現代化工具包含了這么幾部分:語法分析、結果及可視化、自動化重構、架構守護。
語法分析
對代碼進行語法分析,生成特定的語言的數據結構。常用的工具有:Antlr、Ctags、Tree-sitter、Doxygen、CodeQuery 等。一個大致的對比(拍腦袋訂的)如下表所示:
工具 | 精確度量化 | 開發難度 | 跨語言學習成本 | 添加新語言成本 | 可自動化重構 |
---|---|---|---|---|---|
語言編譯器 | 完美 | 低 | 高 | - | Yes |
Antlr | 極高 | 中 | 中 | 中 | Yes |
Ctags | 中 | 低 | 低 | 高 | Yes(成本高) |
Tree-sitter | 高 | 高 | 中 | 高 | Yes(成本高,S) |
Doxygen | 中 | 低 | 低 | 高 | No |
CodeQuery | 極高 | 中 | 中 | 高 | Yes(成本高) |
結果及可視化
通常來說,我們會出于以下的一些情況,來對遺留系統進行可視化:
- 數值化。如針對于特定的 smell 進行自動化重構,類似于 SonarQube,常見的模式和原則源自于《重構》一書。在 Coca 里,還引入了在一些論文里看到了測試的 bad smell,諸如于沒有斷言的測試等。
- 可視化依賴。如針對于代碼中的類、包等的依賴情況進行可視化,主要用于分析分層架構等。常用的工具有:PlantUML、Graphviz、D3.js、Echarts 等。
- 代碼屬性可視化。如針對于文件的修改頻率、大小等屬性進行可視化,可以獲取諸如于單位時間內的文件變化頻率。一個文件經常修改,還大量被引用,那說明它是一個不穩定的類、文件,除了業務變化,最有可能就是設計不合理。
- 其它。
自動化重構
這一步是可選的,它取決于我們的場景。通常來說,編寫這樣的功能主要彌補是現代化的 IDE 無法完成的工作,諸如于:
- 多代碼庫間的未使用類刪除。
- 多代碼庫間的聚類。
- 針對于 CSS 顏色的重構。
架構守護
編寫架構的守護規則,以對于系統的架構進行守護,用的工具有:ArchUnit、ArchGuard 等。在參考了 ArchUnit 的語法之后,我們也設計了一個多語言的架構守護工具:Guarding。
遺留系統現代化工具集
在?Modernizing?里,我們集合了先前開發的一系列工具。并創建了:awesome-modernization?用于對其它的一系列相關的工具進行收集。
在 Modernizing 里,針對于單個編程語言的工具有:
- 針對于 Java 語言的系統重構、系統遷移和系統分析的工具:Coca,Go 語言,GitHub stars:691。Coca 是一個“全功能”的重構工具,基于 Antlr 進行語法分析的,除了常規的可視化、調用分析,還可以進行自動化重構。Coca 一名的由來是:對標新哥寫的?Tequila?—— 龍舌蘭酒 vs 快樂水。
- 針對于 CSS/LESS/CSS 的分析和自動化重構工具:Lemonj,TypeScript 語言,GitHub stars:128。當時設計的主要目的是:用來對 CSS 中的顏色進行提取,基于 Antlr 的語法樹分析,可以用于進行自動化的重構。
- 針對于 MySQL 代碼進行自動化分析,并從中構建中 UML,并生成其關系的:SQLing,Go 語言,使用 PingCap 的 SQL 解析器解析。當然了,還有一個初始化的針對于 PL/SQL 的版本:pling。
- 適用于 Ant 轉 Maven 的半自動化工具:Merry,Go 語言 + Antlr。
- 前端規范化改造工具:Clij,用于一鍵添加 eslint、husky、lint-staged 等,TypeScript 語言。
針對于多語言的工具,我們有:
- 基于 Antlr 的多語言的語言模型分析工具:Chapi,Kotlin 語言。其設計的初衷是用于生成 Coca 相同的數據結構,以接入更多的可視化工具。在語法分析上,采用的是 Antlr 進行分析。
- 基于 Doxygen 的多語言分析和可視化工具:Go mod 版本的新哥的 Tequila。其中,還有一系列的迷之代碼,需要重構掉。
- 基于 Ctags 的多語言模型分析和可視化工具:Modeling,Rust 語言。分析源碼,并生成基于模型的可視化依賴。
- 基于 Tree-sitter 的多語言架構守護工具:Guarding,Rust 語言。通過自制的 DSL,來對系統架構進行守護。
除此,還有一個在 Inherd 開源小組下開源的:Coco,它主要是通過代碼的物理屬性:修改頻率 + 目錄 + 行數來分析系統的工具。以及現在緊鑼密鼓開源中的 ArchGuard。
我們使用一系列不同的語言和工具來開發這些軟件,因為不同的場景之下,都會有不同的選擇。
自動化重構:代碼分析
代碼分析是我們編寫自動化重構、架構守護等一系列工具的第一步。而代碼分析的方式有多種不同的形態,最常見的是基于源碼以及基于編譯后的字節碼(常見于 Java 語言)的靜態程序分析。
通常來說,根據我們的目標獲取的信息是不同的,如:類/結構體、成員、函數(含參數、返回值、注解)、引用(import)、表達式等。因此,所選的工具也是不同的:
目標 | 語法信息級別 | 可選 工具 |
---|---|---|
HTTP API | @注解、參數、類、方法 | 語法分析器(語言自帶、三方、Antlr) |
領域模型 | 類/結構體、成員等 | 根據不同精度,可以考慮 Ctags、Tree-sitter等 |
包、類依賴關系 | 引用、函數調用等。 | Doxygen、 語法分析器等 |
調用鏈 | 全部信息 | 語法分析器(語言自帶、三方、Antlr) |
根據我們的不同需求,我們還需要記錄語法的位置信息。比如,同樣是 HTTP API 的情況下,我們想獲取:
- API URI 列表。只需要解析注解即可。
- API 的輸入和輸出參數。注解 + 解析函數簽名。
- API 輸入到數據庫。注解 + 解析函數簽名 + 調用鏈。
因此,是不是使用語言自帶的語法分析器,生成一個完整的模型就行了,如 Java 使用?Javaparser。事情并不是這么簡單,如今是微服務時代,每個服務都可能使用不同語言,一個二三十人的研發團隊,可能使用 7~8 種語言 —— 為每個服務挑選合適的語言,老系統 C#、新系統 Java、大數據 Scala、AI 用 Python 等。除此,為某個語言寫一個成本也是頗高的,并且用處可能還不大。
所以 ,在不斷平衡之間,我們有了一系列的工具選型。
編譯器前端
編譯器粗略分為詞法分析,語法分析,類型檢查,中間代碼生成,代碼優化,目標代碼生成,目標代碼優化。
基于語法分析器(parser)
從實現的層面來看,使用官方的 parser 是最準確的 —— 前提是它提供了便利的接口,像 Java 語言好像就沒有這樣的接口。
- 官方支持。如?Coca?早期在解析 Golang 時,使用的是 Go 的?parser?包。
- 三方。在?SQLing?中,我們使用的 TiDB 的?parser,它宣稱與 MySQL 完全兼容,并盡可能兼容 MySQL 的語法。
使用這一類 parser 比較麻煩的是在于跨語言的支持,每實現一個新的語言,就需要實現一套,不能復用。
自制 parser
為了實現更好的跨平臺,以及更好玩,選用一個合適的解析器生成器就更“科學” 了。在這一方面,除了傳統的 Flex 和 Bison,Antlr 也是一個不錯的選擇 —— 多語言支持:JavaScript、Golang、Java、Rust 等。
Antlr 社區維護了一個語法庫:https://github.com/antlr/grammars-v4/,內置了幾十種編程語言的 Antlr 語法文件。雖然,部份語法可能不太準確,需要我們手動進行修改,但是依舊可以大大減少我們的編寫成本 —— 除了學習 Antlr 是個成本。Antlr 之類工具的迷人之處在于:你可以重溫一下《編譯原理》,又或者是《計算機程序的構造和解釋(SICP)》,畢竟它是編譯器的前端部分。你再掌握一下 LLVM 的 API,就可以開發個語言了。它的挑戰之處在于,你需要知道語言的各類語法細節,所以也是一個不錯的學習新語言語法的機會。
不過,諸如 Java、C++ 等支持在編譯時進行代碼生成的語言,也會遇到一系列的挫折。諸如于:
- 引用推斷。最難受的?
junit.*
需要做一些推斷 - 生成工具推斷。如 lombok 等
所以,我們需要通過編譯過程中的中間表示,來做一些額外的處理。
基于中間表示(IR)
IR-Intermediate Representation(中間表示)是程序編譯過程中,源代碼與目標代碼之間翻譯的中介。
為了提升語法分析的精準度,就需要應對編譯其的代碼生成,于是,就需要分析 IR。如:Java 里的 ASM。能對?.class
?文件進行分析。只是,IR 處理了一些信息,所以如 class 文件里有些內容(如 annotation)好像并不會被記錄行號信息,詳見:LineNumberTable Attribute。
Java、Android 在編譯過程中對于 Annotation 的操作,又或者是在編譯后的騷優化,也是 666。
不過,它能完成大部分我們所需要的工作。
編輯器語法樹
編輯器在做語法高亮的時候,也在做類似的事情。正好,我先前在某 spike 過編輯器 / IDE 的架構和實現。
- Atom/VSCode。主要由 JSON/PList 格式的 TMLanguage(源自 TextMate) + 正則表達式實現,即?VSCode TextMate?和?Oniguruma?共同構成了 VSCode 的一部分語法高亮功能。吐槽一句,非常難以維護。
- Eclipse。需要手寫解析器,FAQ How do I write an editor for my own language?。
- Intellij IDEA。可以通過 BNF 來添加相應的功能:Custom Language Support Tutorial?。
- Vim。由自帶的 Vim 腳本 + 正則表達式(類似)來實現,示例:Rust.vim
- Emacs。由 Emacs Lisp 語言 + 正則表達式(類似)來實現,示例:rust-mode
只是呢,上述的工具,在離開了編輯器之后,這個 API 嘛,就有些難用了。于是,有一些獨立的工具出現了。
基于語言服務器(LSP)
雖然,我還沒有嘗試過使用 LSP 來實現語法分析,但是我嘗試構建過一個語言及其 LSP。因此,從理論來說,LSP 也能達成此目的。并且與 Antlr 類似,Microsoft 也維護了一個 LSP 的目錄:Language Servers。
麻煩的是,不同語言的 LSP 可能由不同的語言來實現,在系統的集成上會比較困難。其所需要的語言運行環境比較多,比如 Java 的就需要一個 JDK/SDK,在編寫分析工具時,自動化測試環境搭建起來也比較麻煩。
Ctags:有限的解析
Ctags 可以快速實現對類、成員的解析,所以它經常被用在 Vim 的語法高亮上。只是呢,使用 Ctags 難以實現支持:某個函數調用了哪些函數、哪些函數被某個函數調用。從流程上,先用 ctags 生成 tags 文件,然后解析這個 tags 文件即可。如下是一個 tags 文件(部分):
MethodInfo src/coco_struct.rs /^pub struct MethodInfo {$/;\" struct line:21 language:Rust
name src/coco_struct.rs /^ pub name: String,$/;\" field line:22 language:Rust struct:MethodInf
然后,再寫幾個正則表達式 match 一下:
Regex::new(r"(?x)/\^([\s]*)
([A-Za-z0-9_.]+)
(,(\s|\t)*([A-Za-z0-9_.]+))*(\s|\t)*
(?P<datatype>[A-Za-z0-9_.<>\[\]]+)").unwrap();
因此,在不考慮正則表達式難寫和代碼精準度的情況下,使用 Ctags 還會存在一些小問題:
- 版本沖突,如 macOS 環境自帶了一個 ctags,需要 override,或者自定義路徑。
- 下載 ctags。特別是如果客戶是在內網環境時,又會比較麻煩。
所以,Tree-sitter 成了一個更好的選擇:平衡。
Tree-sitter
Tree-sitter 是一個解析器生成工具和增量解析庫。 它可以為源文件構建具體的語法樹,并在編輯源文件時有效地更新語法樹。這個工具最初是為 Atom 編輯器設計的。Tree-sitter 內置了一個?S 表達式,可以快速構建出我們想要的模型。如下是一個 C# 代碼:
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;[ApiController]
public class SharpingClassVisitor { }
對應的 S 表達式如下:
(using_directive(qualified_name) @import-name)(class_declaration(attribute_list (attribute name: (identifier) @annotation.name))?name: (identifier) @class-name
)
我們在 Guarding 中使用了 Tree-sitter 來實現,示例:[Guarding Ident](https://github.com/modernizing/guarding/tree/master/guarding_ident/src/identify),與 Ctags 相比,沒有這個環境依賴,會比較清爽。
其在線 Playground:https://tree-sitter.github.io/tree-sitter/playground?。
其它生成工具
除了上述的幾類,還有一些可選的工具。
文檔生成器:Doxygen
Doxygen 是一個適用于 C++、C、Java、Objective-C、Python、IDL、Fortran、VHDL、PHP、C# 和 D 語言的文檔生成器。為了生成代碼的文檔,它需要能支持對于代碼進行語法分析。所以,它也內置了有限的語法分析功能。
在?Tequila?中,是通過分析 Doxygen 生成的文檔結果,從而構建出內部的依賴關系。如下是一個 Doxygen 生成的 Graphviz 文件:
digraph "Domain::AggregateRootB"
{// LATEX_PDF_SIZEedge [fontname="Helvetica",fontsize="10",labelfontname="Helvetica",labelfontsize="10"];node [fontname="Helvetica",fontsize="10",shape=record];Node1 [label="Domain::AggregateRootB",height=0.2,width=0.4,color="black", fillcolor="grey75", style="filled", fontcolor="black",tooltip=" "];Node2 -> Node1 [dir="back",color="midnightblue",fontsize="10",style="solid",fontname="Helvetica"];Node2 [label="Domain::AggregateRoot",height=0.2,width=0.4,color="black", fillcolor="white", style="filled",URL="$class_domain_1_1_aggregate_root.html",tooltip=" "];Node3 -> Node2 [dir="back",color="midnightblue",fontsize="10",style="solid",fontname="Helvetica"];Node3 [label="Domain::Entity",height=0.2,width=0.4,color="black", fillcolor="white", style="filled",URL="$class_domain_1_1_entity.html",tooltip=" "];
}
解析這個?dot
?文件,從而生成項目的類與類之間的依賴信息。
索引工具:CodeQuery
CodeQuery?是由 GitHub 推出的索引和查詢工具,它主要結合了 Ctags 和 Cscope,cscope 可以實現部分語言的表達式(expression)的支持。它試圖結合 cscope 和 ctags 提供的功能,提供比 cscope 更快的數據庫訪問(因為它使用 sqlite)。雖然,我還沒有試過,但是應該也是可以玩一玩的。架構如下所示:
它結合了 starscope、pyscope、cscope 等多個工具,來實現對于代碼的解析。
自動化重構:為代碼再建個代碼模型
為代碼建模并不是一件很難的事情,畢竟每個編譯器都在重復做同樣的事情。
從代碼到模型
現在,回憶一下你大學學的編譯原理 —— 雖然有些人可能和我一樣沒上過對應的課。
class GFG {public static void main(String[] args){System.out.println("Hello World!");}
}
解析成 AST 后,可以用如下的形式來表示(可能沒有對照 JVM 里的實現):
CLASS_DEF -> CLASS_DEF [1:0]
|--MODIFIERS -> MODIFIERS [1:0]
| `--LITERAL_PUBLIC -> public [1:0]
|--LITERAL_CLASS -> class [1:7]
|--IDENT -> GFG [1:13]
`--OBJBLOCK -> OBJBLOCK [1:17]|--LCURLY -> { [1:17]|--METHOD_DEF -> METHOD_DEF [2:4]| |--MODIFIERS -> MODIFIERS [2:4]| | |--LITERAL_PUBLIC -> public [2:4]| | `--LITERAL_STATIC -> static [2:11]| |--TYPE -> TYPE [2:18]| | `--LITERAL_VOID -> void [2:18]| |--IDENT -> main [2:23]| |--LPAREN -> ( [2:27]| |--PARAMETERS -> PARAMETERS [2:34]| | `--PARAMETER_DEF -> PARAMETER_DEF [2:34]| | |--MODIFIERS -> MODIFIERS [2:34]| | |--TYPE -> TYPE [2:34]| | | `--ARRAY_DECLARATOR -> [ [2:34]| | | |--IDENT -> String [2:28]| | | `--RBRACK -> ] [2:35]| | `--IDENT -> args [2:37]| |--RPAREN -> ) [2:41]| `--SLIST -> { [2:43]| |--EXPR -> EXPR [3:26]| | `--METHOD_CALL -> ( [3:26]| | |--DOT -> . [3:18]| | | |--DOT -> . [3:14]| | | | |--IDENT -> System [3:8]| | | | `--IDENT -> out [3:15]| | | `--IDENT -> println [3:19]| | |--ELIST -> ELIST [3:27]| | | `--EXPR -> EXPR [3:27]| | | `--STRING_LITERAL -> "Hello World!" [3:27]| | `--RPAREN -> ) [3:41]| |--SEMI -> ; [3:42]| `--RCURLY -> } [4:4]`--RCURLY -> } [5:0]
對于代碼分析來說,我們就是:
- 構建類?AST 模型。通過代碼分析工具,得到一個類似上述內容的結果,不同的工具得到的詳盡程度不同。
- 基于標準的 AST 構建分析模型。如我們只取類、函數的信息,就需要解析?
CLASS_DEF
?里的?IDENT
?,以及其 children 中的?METHOD_DEF
?里的?IDENT
,遍歷-取值,就這么簡單。
所以,要構建出一個完善的 AST 及其模型,基本上就是寫一個語言的編譯器前端。在現代的編程語言里,Rust 能提供一個非常不錯的參考,如 Rust 的編譯過程是 AST → HIR → MIR → LIR,其官方在引入 MIR 的時候寫了一篇博客《Introducing MIR》
在 Rust 編譯器里, HIR 相當于是 Rust 的 AST,它在源碼的基礎上進行解析、宏擴展和名稱解析之后生成。如下是 Rust 的 hello, world! 生成的 HIR 表示:
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
fn main() {{::std::io::_print(::core::fmt::Arguments::new_v1(&["hello, world!\n"],&[]));};}
基于不同階段的編構建模型,得到的模型結果是不完全一樣的。如果我們想分析編程語言調用系統的庫,或者是三方的庫,那么從這里得到的才是更精確的 —— 對比于 Java 的 Bytecode。Java 編程過程中對于 Annotation 的處理,也在側面反應兩者的差異之處。所以,想獲取準備的代碼模型,可以在這的基礎上,進一步探索下編程語言的構建。
如此一來,你也就是一個真正的代碼專家了。
0.1 初始化版本:面向 Java
在和新哥設計第一個 MVP 版本的時候,只是想比 Doxygen/Tequila 更準確地記錄 Java 代碼的調用鏈。所以,設計的模型也相當的簡單:
type JClassNode struct {Package stringClass stringType stringPath stringFields []JAppFieldMethods []JMethodMethodCalls []JMethodCall
}
如上的歷史代碼所示,在面向 Java 語言設計,只記錄一個類(Class)的名稱、包名、類型、路徑、成員變量(包含了依賴的類型)、函數/方法、函數調用關系。因為最初只是為依賴設計,所以調用關系只保存在 ClassNode 里。基于 Antlr 這樣的解析器生成器之后,其對應的解析代碼(Listener 模式)也頗為簡單(java_full_listener.go):
func (s *JavaCallListener) EnterClassDeclaration(ctx *ClassDeclarationContext) {currentType = "Class"currentClz = ctx.IDENTIFIER().GetText()if ctx.EXTENDS() != nil {currentClzExtends = ctx.TypeType().GetText()}
}
從 ClassDef/ClassDecl 中獲取 ident 就是類名,如果有 extends 關系的話,再獲取 extends 關系。這是一個初始化的版本,所以沒有考慮到非常復雜的場景,比如多重繼承、泛型等等。
但是,它也讓我重新理解了一下,為什么有的語言的語法設計得有點詭異 —— 解析器不好寫。
1.0 版本:更多的工具,更多的模型
在發布了 Coca 之后,從 GitHub 幾百的 stars 和對應的遷移指南?Migration?2.8k 的 stars 來看,這個領域的需求還是相當的旺盛。
所以,我們開發了更多的功能,也一步步陷入了「人類創造的三個系統」的陷阱中。
適用于重構的模型
而后,為了生成實現不適用在 IDE 用的重構功能(多代碼庫引用檢測、類移動等),我們又構建了一個新的模型,因為我們就只需要這么多信息:
type JFullMethod struct {Name stringStartLine intStartLinePosition intStopLine intStopLinePosition int
}type JField struct {Name stringSource stringStartLine intStopLine int
}type JPkgInfo struct {Name stringStartLine intStopLine int
}
這個時候要實現的功能,還是比較簡單的,所以并不是那么復雜
適用于測試代碼壞味道的模型
除了重構之后,在 Coca 中,還有一個非常有意思的特性:測試代碼壞味道。測試代碼壞味道,是指單元測試代碼中的不良編程實踐(例如,測試用例的組織方式,實現方式以及彼此之間的交互方式),它們表明測試源代碼中潛在的設計問題。簡單來說,就是看測試是否有斷言?ignore 的測試數量等等。需求不復雜,所以構建的模型也比較簡單:
type BSDataStruct struct {core_domain.CodeDataStructFunctions []BSFunctionDataStructBS ClassBadSmellInfo
}type BSFunction struct {core_domain.CodeFunctionFunctionBody stringFunctionBS FunctionBSInfo
}
當然了,細節都是魔鬼,比如?FunctionBSInfo
?長什么樣的?
2.0 AST 集合:一個臃腫而緩慢的系統
隨后,我們試圖構建一個更理想的系統,于是就有了「第二個系統」,一個經過精心設計的系統。
兼容更多的語言
隨著 Coca/Chapi 的演進,陸陸續續想支持 Golang、Java、Python 等語言。于是,一個平凡的 ClassNode 已經變成了 DataStruct:
@Serializable
open class CodeDataStruct(var NodeName: String = "",var Type: DataStructType = DataStructType.EMPTY,var Package: String = "",var FilePath: String = "",var Fields: Array<CodeField> = arrayOf(),var MultipleExtend: Array<String> = arrayOf(),var Implements: Array<String> = arrayOf(),var Extend: String = "",var Functions: Array<CodeFunction> = arrayOf(),var InnerStructures: Array<CodeDataStruct> = arrayOf(),var Annotations: Array<CodeAnnotation> = arrayOf(),var FunctionCalls: Array<CodeCall> = arrayOf(),@Deprecated(message = "looking for constructor method for SCALA")var Parameters: Array<CodeProperty> = arrayOf(), // for Scalavar Imports: Array<CodeImport> = arrayOf<CodeImport>(), // todo: select node useonly importsvar Extension: JsonElement = JsonObject(HashMap())
) { ...
}
一味地進行了兼容設計,導致它變得異常復雜。而和多數系統一樣,這種兼容設計并非是最理想的,沒有進一步做一些抽象,比如函數的屬性,參數、返回類型等,是否能構建?Type Signature?雖然這是一個技術項目,但是也掉入了同樣的業務模型的常見問題中。
最后,我嘗試將非 Java 語言分離成插件,但是因為 Golang 當時的版本并不支持插件化架構。所以,從形態拆分為了 Java + 其它語言 CLI,并轉向了 Rust 語言。
更多的模型
既然,原來的模型可能太重了,那么是不是會有新模型。所以,陸陸續續又構建了一系列的模型。如,在設計 Guarding/Modeling 的時候,我們也構建了一個簡化的版本:
#[repr(C)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CodeClass {pub name: String,pub package: String,pub extends: Vec<String>,pub implements: Vec<String>,pub constant: Vec<ClassConstant>,pub functions: Vec<CodeFunction>,pub start: CodePoint,pub end: CodePoint
}#[repr(C)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CodeFunction {pub name: String,// todo: thinking in modifierpub vars: Vec<String>,pub start: CodePoint,pub end: CodePoint
}
在這個版本里,使用的是 Tree-sitter 沒有函數調用,所以顯得非常簡單 —— 只是記錄基本的類、函數信息等,又是一個非常簡單的初始化版本。
3.0 進行中的向下抽象:MIR <=> AST
既然,從 AST 做合集太復雜,那么是不是往下鉆,尋找更 common 的元素,就能獲得更通用的結果,畢竟最后運行在機器上的是一樣的。
所以,這就成為了 2022 年的一個潛在的業余樂趣,如果你有興趣,歡迎到 GitHub 上討論:https://github.com/modernizing/kernel
潛在的路:MIR
在這一方面,Rust 編譯器的 MIR 就是一個不錯的參考,它基于控制流圖、也沒有嵌套表達式,并且所有類型都是完全顯式的 —— 更多的細節可以查看官方的文檔:Rust MIR。除此,你可以在?Rust Playground?里,查看 Rust 在 HIR、MIR、LLVM IR 不同階段的形式,當然直接詩源碼是最簡單的。如下是一段 Rust 的代碼(本來應該用 Hello, world!,但是它更復雜)。
fn main() {let mut vec = Vec::new();vec.push(1);vec.push(2);
}
生成的 CFG 示例
...bb0: {_1 = Vec::<i32>::new() -> bb1;}bb1: {_3 = &mut _1; _2 = Vec::<i32>::push(move _3, const 1_i32) -> [return: bb2, unwind: bb5];}bb2: {_5 = &mut _1; _4 = Vec::<i32>::push(move _5, const 2_i32) -> [return: bb3, unwind: bb5];}...
在這個階段,MIR 比 AST 添加了更完整的細節 —— 我們能知道?push
?方法是從哪里來的,不需要自己做一些推斷。
與之相類似的,還有一個名為?MIR Project?的項目更有意思,它嘗試建立多語言的抽象。只是從形式上來看,它接近于 LIR:
hello_m: moduleimport printf
hello: func i64local i64:r # local variable has to be i64
# prototype of printf
p_printf: proto i32, p:fmt
format: string "hello, world\n"call p_printf, printf, r, formatret rendfuncendmodule
不過,在代碼模型上,還是接近于 MIR 的:
/* Function definition */
typedef struct MIR_func {const char *name;MIR_item_t func_item;size_t original_vars_num;DLIST (MIR_insn_t) insns, original_insns;uint32_t nres, nargs, last_temp_num, n_inlines;MIR_type_t *res_types;char vararg_p; /* flag of variable number of arguments */char expr_p; /* flag of that the func can be used as a linker expression */VARR (MIR_var_t) * vars; /* args and locals but temps */void *machine_code; /* address of generated machine code or NULL */void *call_addr; /* address to call the function, it can be the same as machine_code */void *internal; /* internal data structure */
} * MIR_func_t;
它讓我重新思考起,我如何去組件 Struct/Class 和 Function 的關系?從 AST 的層面來說,這個不好解決,但是從 MIR/LIR 的話,這個問題就變得異常簡單了 —— 在底層沒有繼承。
所以,我們應該如何去設計這樣一個模型呢?
還有 CLR 和 Graal IR ?
在先前設計 Chapi 的期間,鵬飛推薦了一本書《CLR via C#》,在設計 Chapi 的時候,參考了一部分。簡單來說,就是 Microsoft .NET Framework 里構建了一個公共語言運行時(Common Language Runtime,CLR)。其核心功能(如內存管理、程序集加載、安全性、異常處理和線程同步)可面向 CLR 的所有語言使用。我并不關心 CLR 怎么實現,我關心的是其中的 “通用類型系統”(Common Type System)。
另外一個有意思的項目就是 Graal VM,它是一個生態系統和共享運行時,不僅提供基于 JVM 的語言(如Java,Scala,Groovy和Kotlin)的性能優勢,還提供其他編程語言(如JavaScript,Ruby,Python和R)的性能優勢。其中的 Graal IR 便是 Graal 的核心構建塊之一:GraalVM Compiler,這個可以作為下一個階段的研究的樂趣。
這部分足夠讓我們重新思考一下:公共語言模型是怎樣的?
其它
我并非編譯器方面的專家,更細節的內容可以自己去讀代碼或者比編譯原理相關的書籍。除了傳統的龍書、虎書、鯨書,在編譯器前端上,Antlr 作者編寫的《編程語言實現模型》和后續的《ANTLR 4權威指南》能更快速地幫你入門語法解析。
其它常見問題:
- 沒有類型怎么辦?諸如于 JavaScript 這一類動態語言,就需要自己嘗試性地做一些類型推斷。
- 在底層 MIR 真的能做融合嗎?不確定,但是可以試試,畢竟有上述的 MIR 大佬說可以。
自動化重構:代碼可視化代碼
有了模型,有了分析代碼,便可以將代碼序列化為一個個的數據。接著,剩下的就是對數據進行操作的過程了。
根據不同的可視化需求,我使用過一系列的可視化形式,如下表:
工具 | 編碼成本 | 可定制性 | 代碼可維護性 | UI 美觀性 | 額外工具 | 主要使用場景 |
---|---|---|---|---|---|---|
Graphviz | 低(→ 形式的DSL) | 差 | 高 | 低 | 是(打包到工具中,需要考慮跨平臺) | 快速構建 PoC,諸如于依賴可視化 |
PlantUML | 低(標準化的格式) | 差 | 高 | 低 | 是 | 面向模型建模 + IDE的可視化支持 |
Web 之 D3.js | 高 | 高 | 低 | 取決于設計 | 不需要,可以打包到工具中 | 可交互性要求高,如依賴、調用分析 |
Web 之 Echats/AntV | 低 | 中 | 中 | 高 | 不需要,可以打包到工具中 | 可交互性要求高,如依賴、調用分析 |
Web 3D 之 Three.js | 高 | 高 | 低 | 取決于設計 | 不需要,可以打包到工具中 | 3D 交互、 VR 世界 |
總的來說,我們依舊是在各種的平衡,每個工具都有自身的特點和優勢。在代碼模型不一致的時候,我們需要一層 adapter,用于從代碼模型轉換到可視化所需要的模型,進而將代碼可視化。在這個時候,就能體現出會寫前端代碼的好處。從模式上來說,主要是分為兩類:
- 利用已有工具的靜態可視化。比較常見的有,諸如于使用 Dot 語言描述的 Graphviz,使用 UML 描述的 PlantUML。
- 開發新工具的交互性可視化。常見的有 Web 技術開發的工具,如 D3.js 等。
相似的,和代碼分析一樣,也需要一個成本的考慮。從無到有,優先考慮已有的工具;從 1 到 100,便是考慮自己做個可視化工具。
靜態的代碼可視化
這里主要以我使用過的 Graphviz、PlantUML 作為示例。
神器 Graphviz:依賴可視化
Graphviz 是自 1991 年開發的,歷史悠久,比較從使用頻率來看,它應該是用得最多的一類工具。參見?Graphviz 的 wiki,諸如于 Doxygen、Rust、Sphinx 等大量的工具都會用它來生成文檔中的圖形,而像 OmniGraffle 這一類工具,則使用它來生成自動化布局。從場景上來看,主要就是利用它便利的 Dot 語言描述,結合圖形算法,來自動生成依賴關系。
Graphviz 中的 Dot 語言非常便利,只需要使用?→
?這樣的語法,就可以生成調用關系。如下是 Coca 中生成調用鏈的 dot 文件示例:
digraph G { "POST /books" -> "com.phodal.pholedge.book.BookController.createBook";"com.phodal.pholedge.book.BookController.createBook" -> "com.phodal.pholedge.book.BookService.createBook";...
}
對應轉換后的圖形如下所示(因為是測試代碼中有多個相同的 Controller,所以是雙份箭頭):
對于代碼量較大的工程來說,生成的 SVG 就會比較大,以致于可能會在瀏覽器上渲染許久。為此,常見的一種解決方案就是:添加大量的 filter 函數、參數,以有選擇性的過濾。這也造成了另外一個問題,工具的學習成本和試命令的成本比較高。有一個很好的例子就是,雖然我是 Coca 的作者,但是很多功能,我現在已經不記得了。
PlantUML:模型可視化
和 Graphviz 相比,UML 更為人所知,是個建模的好工具。PlantUML?是一個開源工具,能讓你通過純文本的方式來生成 UML 圖(Unified Model Language 統一建模語言)。在 Modernizing 的幾個工具里,主要是用它來對模型進行可視化,諸如于:
- Modeling,結合 Ctags 對代碼庫中的模型(如 repository)進行分析,結合 id 等,構建出簡單的依賴關系。
- SQLing,結合 MySQL parser 對數據庫的 Schema 進行分析,結合外鍵關系,構建出表的依賴關系,進而幫助我們推導出模型的關系。
以 SQLing 為例,如下是一個網上找的 SQL 代碼:
CREATE TABLE human(...
)
CREATE TABLE car(id VARCHAR(12) PRIMARY KEY,mark VARCHAR(24),price NUMERIC(6,2),hid VARCHAR(12),CONSTRAINT fk_human FOREIGN KEY(hid) REFERENCES human(id)
)
通過 SQLing,可以轉換為如下的結果(UML):
@startuml
class Human {...
}
class Car {- Id: String- Mark: String- Price: BigDecimal- Hid: String
}
Car --> Human
@enduml
這樣一來,就可以配合 IDEA 的 PlantUML 插件進行可視化了:
Modeling 的依賴構建會比 SQLing 復雜一些,在構建模型的時候,還要從?xxId
?中嘗試分析出是否存在這樣的類,以構建出對應的依賴關系 —— 當然,這種是基于編碼模式的分析,有些人的代碼寫的是?id
?沒有前綴,這就分析不出來了。
交互的代碼可視化
在基于微服務、代碼庫小的場景下,上述的 Graphviz、PlantUML 基本上可以完成大部分的工作。而對于遺留系統來說,它巨大的代碼量,就意味著我們需要更強的交互工具。所以,我找了個周末寫了個工具:Merry。
從 Graphviz 到 D3.js:OSGi 的天坑
我嘗試構建的第一個場景是一個 OSGi 系統的 Ant 轉移到 Maven 方案上,我們的目標是告訴客戶:你還不如重寫。不過,你需要有強壯的證據,還有可估算的成本證明。采用 OSGi 框架,就意味著系統可能有幾十、幾百個 bundle,可以理解為模塊,而這些模塊又可以相互依賴,妥妥的一個大泥球。與此同時,采用 Ant 又意味著系統的依賴是放在某個目錄里管理的,具體的版本什么的,也不定會在文件名中體現。所以,我們所要做的就是:
- 解析?
build.xml
,從中獲取?classpath
?中的 jar 路徑。 - 解析 jar 包中的 Manifest.MF、pom.properties,從中解析出包名、版本號、Export、Import 等一系列的信息。
- 自動生成一個 pom.xml 文件。(PS:需要對一些依賴進行人工校驗,所以是半自動的。可以通過配置 map 文件,在后續變成全自動化。)
其中,最過于坑人的,要數 Manifest.MF 存在多個不同的版本的問題。在使用正則無力的情況下,最后只能用 Antlr 來寫解析器了。有意思的是,OSGi 生成的 Manifest.MF 里,必須有?Import-Package
?和?Export-Package
,便可以從中生成項目的依賴信息。就這么找了 Apache 的 OSGi 項目,run 了一下,寫了個 demo,it works:
然后,來到客戶現場,一試,嘿,傻眼了,客戶有幾百個 bundle。怎么看清包之間的關系,怎么看清哪個 bundle 被依賴最多?所以,讓 D3 來干活吧。
依賴圖
在有了依賴關系之后,只需要生成一個 JSON 文件,就可以給 D3.js 使用了。剩下要做的就是打包 Web 應用,以便于在客戶的 Windows 電腦上運行 —— 這就體現出了 Golang 的跨平臺優勢。在采用有了 GitHub Action 的多平臺構建之后,Rust 也可以實現同樣的效果。接著,迅速實現了個 demo,然后拿 Eclipse 的 OSGi 框架 Equinox 跑了一下,這圖估計也 hold 不住,幾百個 bundle:
于是,又從 D3.js 的 Gallery 里繼續拿個圖了測試一下:
效果比上面好一點,但是依舊不理想。然后,我就一如即往的棄坑了 —— 在 OSGi 技術越來越難見到的時代,投精力開發工具,顯得非常不值得。和 D3.js 的簡單 demo 相比,我們在 ArchGuard 設計的、基于 AntV G6 的可視化來說,它顯得更加的好用。
Merry 可視化的最后 demo 見:Merry Dependencies Analyser
可交互的變化
上面的可交互性僅限于當前時期,但是歷史上的變化有時候往往更重要。于是,在設計效能分析工具?Coco?時,我們做提分析 Git 的提交歷史,從中發現歷史上的高頻變更。如下是?Nginx?的示例,可以播放,然后查看變化:
對于本身就是增量變更的 Git 來說,分析 Git 的日志,就能得到上面的結果。但是,對于代碼來說,要分析模型上的增量變更,還是稍微有一點麻煩。如果有哪個小伙伴有空,可以去構建這樣的功能。
面向風口的可視化
幾年前,在閱讀《Your Code as a Crime Scene》一書之后,我便一直想構建一個 Code City,只是我一直看不到有效的使用場景。在設計 Coco 和 Coca 的時候,雖然圖形是 2D 的,表現力是有限的,但是多數時候是夠用的 —— 受客戶開發機的性能影響。所以,去年在元世界又開始火了之后,結合了幾年前在 Thoughtworks 國內構建的第一個 VR 機器人,并寫了 Code City 的 demo:https://github.com/modernizing/codecity。
當然,這還只是一個玩具。只要一打開 Oculus Quest 2,我就沉迷在 Beat Saber 中。But the way,我構建了我一直想構建的 Code City demo。
開發工具就是這樣的,在業余的時候,需要先搭建個架子,等到使用的時候,就可以改吧改吧上線了。而不是用的時候,發現沒有架子,然后就不做了。
讓代碼修改代碼
程序員嘛,重復的事情都應該盡可能自動化。所以,在我們呈現完問題,就要一一去解決問題。
以機器的角度來考慮,對于重構來說,就是發現 bad smell 的模式,尋找解決方案,編程以自動化重構。諸如于 Intellij IDEA 這類的 IDE,以及各類 Lint 工具,便也是類似于此。不過,在已經有了大量的現有工具的情況下,我們編寫的工具能做點什么?
- 規模化修改。比起一個個在 IDE 中敲入?
Alt
?+?Enter
?來得更有效率 —— 對于大型的工程來說。 - IDE 難以完成的工作。跨多個工程的代碼重構,一來是性能問題,二來是不支持。
- 其它不常見的 bad smell 模式
好的習慣不容易學習,但是不好的、便利的習慣,往往非常容易上手。先來看一個簡單的 CSS 重構案例。
前端:自動化的顏色重構
在諸多的前端項目中,在早期如果沒有構建好項目模板,又或者是后期沒有按規范捃,那么項目中的顏色中就會分散在各個 CSS 和各類 CSS 預處理器。這個時候,當我們來一個主題類的需求,比如過年的大紅色。那么,就需要一個個的 debug。因此,一個比較簡單的方式,就是識別代碼中的 CSS 中的顏色,提取出來,統一管理。于是,在 2020 年的時候,我和劉宇構建了一個簡單的 CSS 重構工具:Lemonj。
思路上也頗為簡單:
- 識別代碼中的各類顏色。記錄每一個顏色的文件信息,位置信息等。
- 生成顏色的 mapping 文件。
- 修改生成的 mapping 文件。通過記錄的信息,將顏色值,修改成對應的變量
- 執行重構。將顏色變量修改到文件中。
從技術實現上,就是使用 Antlr 構建一個跨 CSS 預處理器的顏色解析,主要是針對于 LESS。其中,比較麻煩的一個點在于 CSS 里的顏色,除了?color
?屬性,在?box-shadow
、border
?等一系列的屬性中都會出現:
switch (propertyKey) {case 'color':case 'background-color':case 'border-color':case 'background':...case 'border':case 'border-right':case 'border-left':case 'border-bottom':case 'border-top':case 'border-right-color':case 'border-left-color':case 'border-bottom-color':case 'border-top-color':case 'box-shadow':case '-webkit-box-shadow':case '-moz-box-shadow':...}
主要分析代碼見:RefactorAnalysisListener.ts。隨后,生成一個 Mapping 文件:
// _fixtures/less/color/border.less
@color1: #ddd;
// _fixtures/less/color/border.less
@color2: green;
// _fixtures/less/color/rgba.less
@color3: rgba(255, 0, 0, 0.3);
// _fixtures/less/color/sample.less
其中的注釋信息主要是用于人為的 debug。當然,它還不是全自動化的,后續還需要一系列小的代碼修改。但是,大體上已經大大減少了工作量了。隨后,我們在這基礎上構建了一個簡單的 CSS 的 bad smell 識別,用于證明 Antlr 語法的可用性。如下是一個 bad smell 示例:
Code Smell: {colors: 24,importants: 4,issues: 8,mediaQueries: 1,absolute: 0,oddWidth: 1
}
這個項目還有一系列的 Todo 要做,有興趣的小伙伴可以基于此來構建自己的 CSS 重構工具,又或者是接手、完善?Lemonj。
模式上依舊是:識別 bad smell 模式,尋找解析方案,編寫自動化重構代碼。
后端:批量性 Java 代碼重構
回到先前說到的 Coca 支持的 Java 代碼重構上。同樣的,也是識別代碼味道的模式,然后重構。場景上是:客戶有一個?common
?的?common
?包,簡單來說,就是上百人的團隊,最后維護出一個非常大的?common
?包,JVM 啟動慢 blabla。有些團隊離開了這個包,有些團隊還在使用,所以需要分析哪些不被使用了。于是,基于 Coca 的分析功能,我們開始構建的第一個例子里,刪除未使用的?import
?—— 客戶寫的代碼太爛了。歷史有點悠久,當時似乎好像是在 IDEA 中,只要?import
?的,但是未使用的,也會被視為依賴?。另外一個原因是,代碼量較大,一個個過濾成本高。
在有了 AST 的基礎上,分析代碼就非常簡單了:
func BuildErrorLines(node models2.JFullIdentifier) []int {var fields = node.GetFields()var imports = node.GetImports()var errorLines []intfor index := range imports {imp := imports[index]ss := strings.Split(imp.Name, ".")lastField := ss[len(ss)-1]var isOk = falsefor _, field := range fields {if field.Name == lastField || lastField == "*" {isOk = true}}if !isOk {errorLines = append(errorLines, imp.StartLine)}}return errorLines
}
從上述代碼,其實有一個雷那就是?lastField == "*"
?此坑嘛,沒有填上。然后,就是重構 —— 隨機刪除代碼了:
func (j *RemoveUnusedImportApp) Refactoring(resultNodes []models2.JFullIdentifier) {for _, node := range resultNodes {if node.Name != "" {errorLines := BuildErrorLines(node)removeImportByLines(currentFile, errorLines)}}
}
只要編譯通過了,就說明我們的重構是好的。第一次寫 Goland 寫了 Coca,所以代碼寫得比較一般了,不過測試覆蓋率有 90%,也算是方便大家對這個代碼庫重構了。
后續,我用這個項目來向客戶證明,嘿,我們的代碼都是有測試的,你不需要 100%,只需要 90% 即可(手動狗頭)。
Coca 還有比較簡單的批量移動 + 重命名功能。速度比 IDEA 高效 + 快速,至少放在當時,客戶的機器 + 他們的代碼量,IDEA 就未響應了。通過如下的配置形式,以支持重命名 + 移動:
move.a.ImportForB -> move.b.ImportForB
move.c.ImportForB -> move.d.ImportForB
簡單易懂,還相當的靠譜(我覺得),下班回去后一兩個小時就能寫完 —— billable 時間寫不了。
當然,在 IDEA 支撐得住,代碼量小的情況下,還是告訴客戶你們手動移動吧,然后自己回去想想怎么自動化。
讓重構消失:構建前置的架構守護
重構,從理論上來說,是一種事后補救的方式。我們應該盡量避免 bad smell 的出現,從 CI 上的 SonarQube,到 Git Hooks 的 pre check,再到 IDE 里的 Checkstyle,我們無一不是在構建架構適應度函數,以讓系統的架構逐步演進到合適的狀態。
在我們有了代碼模型,又有了語法分析能力之后,我們就能構建出一個跨越任何語言的架構守護工具,類似于?ArchUnit。好的架構模式、設計模式,只有變成代碼,可測試、可度量,它才有發揮的空間。通過前面的一系列 Antlr 的語法分析基礎,很容易就能具備編寫一套新的 DSL,再配上老馬的《領域特定語言》作為指導思想,《ANTLR 4權威指南》作為實踐手冊,我們就是一個“代碼專家”。
于是呢,我按照這個想法,開了一個坑:Guarding?一個用于 Java、JavaScript、Rust、Golang 等語言的架構守護工具。結合 Tree-sitter 進行目標代碼的模型構建,借助于易于理解的 DSL,來編寫守護規則。在設計上參考了 ArchUnit 的語法,采用了 Rust 里的 pest 作為解析器 —— 主要是一年前 Rust 的 Antlr 支持不好(完整的語法:guarding.pest):
normal_rule = {rule_level ~ ("(" ~ scope ~ ")")? ~ (use_symbol ~ expression)? ~ should? ~ only? ~ operator ~ assert ~ ";"?
}rule_level = {"package" |"class" |"struct" |"function" |"file"
}use_symbol = {"::" |"->"
}
rule_level 對應 ArchUnit 里的 CodeUnits,后面的?operator
?和?assert
便是核心的計算邏輯所在。最后的規則示例:
// class
class(implementation "BaseParser")::name should endsWith "Parser";
class("java.util.Map") only accessed(["com.phodal.pepper.refactor.staticclass"]);
class(implementation "BaseParser")::name should not contains "Lexer";// naming
class("..myapp..")::function.name should contains("Model");// 簡單的值計算
package(".")::file.len should < 200;
package(".")::file.len should > 50;
代碼中的?::
?可以換成?→
?表示,都是在?use_symbol
?中聲明的,自己寫的語法嘛,怎么開心就這么寫。最后,代碼是可以 work 的,也沒有枉費我看了許久的 ArchUnit 源碼。
順帶一提,先前提到的 Tree-sitter 的 S 表達式還挺好玩的,有空應該實現一個:
(using_directive(qualified_name) @import-name)
上述的代碼可以用于識別 C# 里的?using
聲明。不過,我在 Guarding 中實現的解析倒是不太好。
其它
重構是一件有技巧、有難度的手工活。但是,作為一個工程實踐上的專家,我們應該讓重構消失。
回到開始,成為一個代碼方面的專家非常有意思。