:擺脫重復(fù)CRUD)
做過幾年后端開發(fā)應(yīng)該都體會過被重復(fù)CRUD代碼支配的感覺。表建好了字段列出來接下來就是實體類、Mapper接口、XML、Service、Controller一套組合拳下來幾千行代碼基本是復(fù)制粘貼改字段名。手寫一時爽維護(hù)火葬場尤其當(dāng)字段有增減或者需要統(tǒng)一加個邏輯刪除字段時所有相關(guān)文件全得跟著改一遍。后來我開始用模板引擎做代碼自動生成試過幾款工具最終固定在Freemarker上。這篇文章就專門聊聊基于Freemarker的代碼自動生成器是怎么設(shè)計的模板怎么寫數(shù)據(jù)模型怎么組織以及實際生成過程中最常見的坑。如果你也厭倦了無腦的CRUD想把手動重復(fù)勞動交給腳本這篇文章應(yīng)該能給你一個可以直接抄作業(yè)的起點。1. 為什么我選擇用Freemarker做代碼生成1.1 代碼自動生成到底解決什么問題一個典型的后端接口從數(shù)據(jù)庫表到對外服務(wù)中間要經(jīng)歷多少層實體類負(fù)責(zé)映射字段Mapper接口聲明增刪改查方法Mapper XML里寫SQLService層處理業(yè)務(wù)和事務(wù)Controller層接收參數(shù)返回結(jié)果。這還不算DTO、VO、Query對象以及最簡單的分頁查詢。別說小公司沒有嚴(yán)格的架構(gòu)規(guī)范一旦業(yè)務(wù)量上來這些文件的數(shù)量會增長得很快。我見過有人用Excel維護(hù)表結(jié)構(gòu)然后手動照著建實體類字段一多就漏改編譯期根本發(fā)現(xiàn)不了。也有人用IDEA的live template但每個新表都要重新操作一遍只能減少一半的機(jī)械動作。真正解決問題的思路很簡單把“代碼的固定骨架”抽出來做成模板把“會變化的部分”比如類名、字段名、字段類型、注釋抽出來做成數(shù)據(jù)兩者一合并就能批量生成整套代碼。用Freemarker做代碼生成核心價值在于三個字可復(fù)用。模板寫一次后面所有新表都能套用。更重要的是當(dāng)團(tuán)隊約定發(fā)生變化比如所有Controller需要加一個Validated注解只要改模板文件再重新生成一遍即可而不是去幾百個類里手工加。這就是為什么我愿意花幾天時間把生成器搭起來它長期來看是省時間的。1.2 Freemarker和其他模板引擎/手寫拼接的對比早期我嘗試過用StringBuilder硬拼。先寫一段方法用字符串拼接把實體類的字段定義構(gòu)造出來。一開始覺得挺靈活后來發(fā)現(xiàn)模板邏輯和業(yè)務(wù)邏輯糾纏在一起Java代碼里混著一堆空格對齊維護(hù)起來像在解毛線團(tuán)。只要格式化要求變一點比如縮進(jìn)從4個空格改成Tab就得改生成器代碼重新編譯非常痛苦。后來了解過JavaPoet它用Java代碼API構(gòu)建Java代碼類型安全做得很好適合寫庫或框架級別的生成。但它的學(xué)習(xí)曲線略高而且對于XML這種文本文件JavaPoet幫不上忙。至于Velocity和Freemarker功能重疊度很高但Freemarker的模板語法我更熟悉空值處理和宏macro機(jī)制也更順手。Freemarker本身是給網(wǎng)頁渲染設(shè)計的但它另一個重要用途就是代碼生成。它的好處可以列一下模板是獨立的.ftl文件不用編譯改完立即生效。語法簡單會寫HTML就會寫模板${變量}、#list、#if基本夠用。內(nèi)置空值默認(rèn)值處理不會因為某個字段缺失直接崩潰。支持宏定義可以把重復(fù)片段抽成公共模塊。性能不錯Configuration會緩存模板適合循環(huán)批量生成。當(dāng)然它也有缺點比如類型不嚴(yán)格數(shù)據(jù)模型結(jié)構(gòu)錯了只能運行時才發(fā)現(xiàn)。但相比手寫拼接它已經(jīng)讓我從字符串地獄里解脫出來。項目里如果沒有特殊約束Freemarker幾乎是最均衡的選擇。2. 核心思路模板 數(shù)據(jù)模型 輸出2.1 數(shù)據(jù)模型的構(gòu)造Freemarker的生成原理用一句話概括模板負(fù)責(zé)靜態(tài)結(jié)構(gòu)數(shù)據(jù)模型負(fù)責(zé)動態(tài)變量。模板里寫${classInfo.className}運行時Freemarker到數(shù)據(jù)模型里找classInfo對象取出className屬性替換成實際的類名。所以設(shè)計生成器第一步不是寫模板而是設(shè)計數(shù)據(jù)模型。數(shù)據(jù)模型統(tǒng)一了“業(yè)務(wù)信息”的傳遞格式。我一般會給每個實體構(gòu)造一個Map或?qū)iT的POJO內(nèi)容大致如下MapString, Object classInfo new HashMap(); classInfo.put(packageName, com.example.demo.modules.user); classInfo.put(className, User); classInfo.put(entityName, user); classInfo.put(tableName, t_user); classInfo.put(author, YourName); classInfo.put(date, 2025-01-20);字段信息則是列表每個元素包含列名、Java屬性名、Java類型、注釋、是否主鍵等ListMapString, String fields new ArrayList(); MapString, String field new HashMap(); field.put(columnName, id); field.put(fieldName, id); field.put(javaType, Long); field.put(comment, 主鍵); field.put(isPrimaryKey, true); fields.add(field);這些數(shù)據(jù)無外乎三個來源數(shù)據(jù)庫表結(jié)構(gòu)元數(shù)據(jù)、實體類反射、你手工維護(hù)的表結(jié)構(gòu)描述文件。我比較推薦直接從數(shù)據(jù)庫讀取元數(shù)據(jù)因為表和代碼容易保持同步。你可以用JDBC去查information_schema.COLUMNS表拿到列名、數(shù)據(jù)類型、注釋然后做一層類型映射把數(shù)據(jù)庫類型轉(zhuǎn)成Java類型。比如bigint轉(zhuǎn)Longvarchar轉(zhuǎn)Stringdatetime轉(zhuǎn)LocalDateTime這個映射表很重要。數(shù)據(jù)模型設(shè)計得好不好直接影響模板的復(fù)雜程度。我的原則是模板能看懂的數(shù)據(jù)結(jié)構(gòu)要接近自然語言。比如讓模板里出現(xiàn)field.comment而不是field[3]可讀性會好很多。2.2 模板語法速覽如果你沒用過Freemarker這里快速過一遍。生成代碼用到的語法不超過十種。輸出變量${user.name}如果user為null會報錯但可以用${user.name!}給默認(rèn)空。循環(huán)#list fields as field ... /#list。分支#if field.isPrimaryKey true ... #else ... /#if。自定義變量#assign className classInfo.className Entity。導(dǎo)入宏#import common.ftl as common調(diào)用common.copyright/。比如要生成實體類的屬性定義模板可以這樣寫#list fields as field /** ${field.comment} */ private ${field.javaType} ${field.fieldName}; /#list這段模板循環(huán)遍歷fields列表每個字段生成注釋和屬性定義。Freemarker會嚴(yán)格按照模板里的換行和縮進(jìn)輸出所以寫模板時要注意格式不然生成出來的代碼縮進(jìn)是亂的。后面我會專門講這個問題。2.3 生成流程的關(guān)鍵環(huán)節(jié)代碼生成的整個流程可以拆成四步準(zhǔn)備數(shù)據(jù)模型讀取數(shù)據(jù)庫表信息或解析實體類得到classInfo和fields。加載模板用Freemarker的Configuration配置模板目錄、編碼、版本。合并輸出調(diào)用template.process(data, writer)將數(shù)據(jù)模型填充到模板得到代碼字符串。寫入文件根據(jù)目標(biāo)路徑和包名把字符串寫到對應(yīng)的Java文件或XML文件。這里最需要注意的是Configuration初始化。它最好只創(chuàng)建一次因為Configuration會緩存模板反復(fù)創(chuàng)建會引入不必要的開銷。模板目錄的狀態(tài)也要維護(hù)好如果用的是絕對路徑換機(jī)器就得改配置我建議使用項目相對路徑或者通過Classpath來加載模板打包成JAR后也能用。另外合并輸出時writer要指定編碼否則容易出現(xiàn)中文亂碼。一般用OutputStreamWriter包一層指定UTF-8這看起來是個小細(xì)節(jié)實際上亂碼問題十有八九都和編碼設(shè)置有關(guān)。3. 從零搭建一個可用的生成器3.1 工程結(jié)構(gòu)和依賴我們先搭一個最小可用的Maven工程不需要引入Spring一個普通Java程序就行。工程結(jié)構(gòu)大概長這樣code-generator/ ├── pom.xml ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/example/generator/ │ │ │ ├── CodeGenerator.java │ │ │ ├── TableMetaReader.java │ │ │ └── model/ │ │ │ ├── ClassMeta.java │ │ │ └── FieldMeta.java │ │ └── resources/ │ │ └── templates/ │ │ ├── entity.ftl │ │ ├── mapper.ftl │ │ ├── service.ftl │ │ └── controller.ftlpom.xml里只需要一個Freemarker依賴我用的是2.3.32版本老項目用2.3.28以上問題都不大。dependency groupIdorg.freemarker/groupId artifactIdfreemarker/artifactId version2.3.32/version /dependency如果不想依賴額外工具用JDK自帶的java.util.HashMap裝數(shù)據(jù)就夠了。對一個小型生成器來說不必一上來就引入模板數(shù)據(jù)模型的復(fù)雜框架。3.2 核心工具類實現(xiàn)下面我給出一個精簡但可直接運行的生成器骨架。這個代碼把數(shù)據(jù)模型硬編碼在main方法里實際使用時可以從數(shù)據(jù)庫讀取替換。public class CodeGenerator { public static void main(String[] args) throws Exception { MapString, Object dataModel new HashMap(); ClassMeta classMeta new ClassMeta(); classMeta.setPackageName(com.example.demo.user); classMeta.setClassName(User); classMeta.setTableName(t_user); classMeta.setAuthor(YourName); classMeta.setDate(LocalDate.now().toString()); dataModel.put(classMeta, classMeta); ListFieldMeta fields new ArrayList(); fields.add(new FieldMeta(id, id, Long, 主鍵, true)); fields.add(new FieldMeta(user_name, userName, String, 用戶名, false)); fields.add(new FieldMeta(age, age, Integer, 年齡, false)); dataModel.put(fields, fields); Configuration cfg new Configuration(Configuration.VERSION_2_3_32); cfg.setDirectoryForTemplateLoading(new File(src/main/resources/templates)); cfg.setDefaultEncoding(UTF-8); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); MapString, String outputFiles new HashMap(); outputFiles.put(entity.ftl, src/main/java/com/example/demo/user/User.java); outputFiles.put(mapper.ftl, src/main/java/com/example/demo/user/UserMapper.java); outputFiles.put(service.ftl, src/main/java/com/example/demo/user/UserService.java); outputFiles.put(controller.ftl, src/main/java/com/example/demo/user/UserController.java); for (Map.EntryString, String entry : outputFiles.entrySet()) { Template template cfg.getTemplate(entry.getKey()); File targetFile new File(entry.getValue()); if (!targetFile.getParentFile().exists()) { targetFile.getParentFile().mkdirs(); } try (Writer writer new OutputStreamWriter( new FileOutputStream(targetFile), StandardCharsets.UTF_8)) { template.process(dataModel, writer); } } } }這段代碼的核心思路是維護(hù)一個“模板文件 - 輸出文件”的映射關(guān)系。每執(zhí)行一次讀取該模板對應(yīng)的輸出路徑確保父目錄存在然后合并寫入。注意FileOutputStream和OutputStreamWriter嵌套時我把編碼設(shè)置到了Writer層這是防止中文亂碼的關(guān)鍵。使用ClassMeta和FieldMeta這種POJO比直接用Map更清晰也方便后期擴(kuò)展字段。比如ClassMeta里再加一個moduleName屬性Controller的RequestMapping前綴就可以自動帶上模塊名。3.3 模板文件示例模板是整個生成器的靈魂。我以實體類模板為例看看Freemarker模板長什么樣package ${classMeta.packageName}; import java.io.Serializable; import java.time.LocalDateTime; /** * ${classMeta.tableName} 表對應(yīng)的實體類 * * author ${classMeta.author} * date ${classMeta.date} */ public class ${classMeta.className} implements Serializable { private static final long serialVersionUID 1L; #list fields as field /** * ${field.comment} */ private ${field.javaType} ${field.fieldName}; /#list #list fields as field public ${field.javaType} get${field.fieldName?cap_first}() { return ${field.fieldName}; } public void set${field.fieldName?cap_first}(${field.javaType} ${field.fieldName}) { this.${field.fieldName} ${field.fieldName}; } /#list }注意幾個細(xì)節(jié)field.fieldName?cap_first會把屬性名的首字母大寫這樣userName變成getUserName這是Freemarker內(nèi)置的字符串處理。每個字段的注釋用field.comment輸出如果注釋為空可以加!變成${field.comment!}防止空指針。Mapper接口模板就更簡單了package ${classMeta.packageName}; import org.apache.ibatis.annotations.Mapper; /** * ${classMeta.tableName} 表 Mapper接口 */ Mapper public interface ${classMeta.className}Mapper { int insert(${classMeta.className} entity); int updateById(${classMeta.className} entity); int deleteById(Long id); ${classMeta.className} selectById(Long id); }Service模板和Controller模板也類似無非是導(dǎo)包、類注釋、方法簽名里出現(xiàn)了類名。因為類名和包名都來自數(shù)據(jù)模型模板本身就很機(jī)械這恰恰是它適合生成的原因。3.4 生成后的效果運行上面的生成器會在目標(biāo)目錄生成對應(yīng)的Java文件。實體類的效果大致如下package com.example.demo.user; import java.io.Serializable; /** * t_user 表對應(yīng)的實體類 * * author YourName * date 2025-01-20 */ public class User implements Serializable { private static final long serialVersionUID 1L; /** * 主鍵 */ private Long id; /** * 用戶名 */ private String userName; /** * 年齡 */ private Integer age; public Long getId() { return id; } public void setId(Long id) { this.id id; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName userName; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age age; } }這就是我們想要的成品。在IDE里打開包路徑正確、類名正確、注釋齊全和手寫沒有區(qū)別。唯一不同的是創(chuàng)建這個文件只花了幾毫秒而且不會因為字段太多而漏掉getter/setter。4. 實操細(xì)節(jié)如何寫出易維護(hù)的模板4.1 模板命名與目錄規(guī)劃模板文件命名我建議盡量直觀后綴統(tǒng)一用.ftl前面可以加目標(biāo)文件的類型。比如entity.ftl、mapper.ftl、service.ftl、controller.ftl、mapperXml.ftl。如果你有不同的風(fēng)格模板比如一種用于單表CRUD一種用于樹形結(jié)構(gòu)那可以在templates下建子目錄分成crud和tree這樣生成的時候按需選擇。還有一點容易忽略模板目錄的維護(hù)。生成器有可能在多個項目里復(fù)用如果模板寫死在絕對路徑比如/Users/me/generator/templates換一臺電腦就要改代碼。我推薦把模板放到當(dāng)前工程的src/main/resources/templates下用類路徑加載。用Spring時可以直接通過ClassPathResource獲取目錄純Java的話可以用CodeGenerator.class.getResource(/templates)拿到URL。這樣生成的jar包自帶模板部署和分發(fā)都方便。4.2 公共片段抽取macro如果你有多個模板都需要生成版權(quán)注釋、都需要生成一個“定期重建”的警告說明把這個片段復(fù)制到每個模板里就違背了DRY原則。Freemarker的宏機(jī)制可以讓你把這些公共片段抽出來。以版權(quán)頭注釋為例新建一個common.ftl#macro fileHeader author date /** * 本文件由代碼生成器自動生成請勿手動修改。 * 如需調(diào)整業(yè)務(wù)邏輯請修改模板或生成規(guī)則。 * * author ${author} * date ${date} */ /#macro然后在任何模板里導(dǎo)入并使用#import common.ftl as common common.fileHeader author${classMeta.author} date${classMeta.date}/這樣當(dāng)公司規(guī)定要在文件頭加一段版權(quán)信息或免責(zé)聲明時你只需要改common.ftl所有模板生成的文件都會同步更新。我在實際項目里把生成器的時間戳、表結(jié)構(gòu)變更記錄都做成了宏模板看起來清爽很多。除了宏你還可以用模板繼承#assign配合使用但代碼生成場景下宏基本夠用了。模板里出現(xiàn)三行以上重復(fù)內(nèi)容時就考慮是否要抽成宏。4.3 包名、路徑和覆蓋策略生成器的輸出路徑要和Java包名對應(yīng)否則IDE里看得到文件但編譯不通過。比較穩(wěn)妥的方式是把輸出路徑定義成src/main/java/${classMeta.packageName?replace(., /)}/${classMeta.className}Mapper.java在Java代碼里做字符串替換也可以但放在模板通過配置統(tǒng)一處理更直觀。我習(xí)慣在生成器里維護(hù)一個Mapkey是模板名value是輸出路徑模板其中的${packagePath}和${className}在運行時替換。覆蓋策略也要提前想清楚。生成器最常見的坑是舊文件被覆蓋你手工改過的代碼全丟了。我的建議是對于自動生成的代碼要么保留一個明顯的“不要手工修改”注釋要么把生成文件輸出到單獨目錄比如src/main/java-gen然后作為額外源目錄參與編譯。這樣即使重新生成也不會破壞手寫業(yè)務(wù)代碼。如果非要覆蓋原文件至少要在覆蓋前自動備份一份到backup目錄或者用Git管理好變更便于diff。我在項目里就吃過虧生成器跑一遍把某個手寫的Service實現(xiàn)覆蓋了萬幸有版本控制才找回來。從那以后我再也不敢把Service模板寫得過于“智能”真正需要手寫的部分都留空生成后手動補(bǔ)充。5. 實戰(zhàn)中的常見問題與排查5.1 文件亂碼問題亂碼是我遇到最多的Bug。生成出來的Java文件中文注釋變成???或者編譯時報“非法字符”。原因基本繞不開三點模板文件編碼、Freemarker讀取編碼、輸出文件編碼。排查順序是這樣的確認(rèn)模板文件本身以UTF-8保存。IDEA右下角可以看文件編碼Windows下尤其容易變成GBK。確認(rèn)cfg.setDefaultEncoding(UTF-8)有些舊代碼忘了加導(dǎo)致Freemarker用默認(rèn)的ISO-8859-1讀模板。確認(rèn)寫入文件時Writer指定了UTF-8。用FileWriter是默認(rèn)平臺的編碼Windows下是GBK所以我堅持使用OutputStreamWriter StandardCharsets.UTF_8。另外在讀取數(shù)據(jù)庫表注釋時如果連接串里沒有加characterEncodingutf8中文注釋也會亂碼。這一層的問題不會被Freemarker感知但最終會把亂碼寫進(jìn)生成的文件里排查時容易混淆。記得JDBC連接串加上useUnicodetruecharacterEncodingutf8。5.2 空值處理和默認(rèn)值數(shù)據(jù)模型里某個字段缺失Freemarker默認(rèn)會拋異常提示foo is undefined。這個設(shè)計是雙刃劍好在能暴露數(shù)據(jù)問題壞處是偶爾一個表缺注釋整個生成流程中斷。我的處理原則是必填信息類名、包名缺失必須報錯業(yè)務(wù)信息注釋、作者缺失可以提供默認(rèn)值。比如模板里用${field.comment!}注釋為空就輸出空字符串。用${classMeta.author!unknown}作者沒傳就寫unknown。如果你想針對List循環(huán)處理空集合可以在List外層加判斷#if fields?? fields?size gt 0 #list fields as field ... /#list /#if??是Freemarker判斷變量是否存在?size取集合大小。模板里多寫這種防御式判斷生成器的健壯性會好很多。5.3 模板語法錯誤排查Freemarker模板出錯的報錯信息一般會帶模板文件路徑和行號比如TemplateModelException: Error executing FreeMarker template FreeMarker template error (DEBUG mode; use RETHROW in production!): The following has evaluated to null or missing: field (in template entity.ftl at line 15, column 10)看到這種信息別慌定位到實體模板第15行看上下文就知道是field為空。通常是因為fields列表里混入了null或者key拼錯了。給列表加一個#list fields??判斷就沒這個問題。還有一種情況是#list和/#list沒有配對或者嵌套時少閉合Freemarker會報“Unexpected end of template”。這時候建議用支持Freemarker語法高亮的IDE插件編輯模板或者寫一個單元測試把所有模板和數(shù)據(jù)模型組合跑一遍確保語法正確。我推薦至少寫一個最簡單的測試用例把每個模板都process到字符串里斷言不拋異常這樣每次改動模板都能快速回歸。5.4 生成效率與批量處理如果只是偶爾生成幾張表性能無所謂。但你要一次生成幾十張表、每個表生成8個文件就需要注意一些細(xì)節(jié)。Freemarker的Configuration在第一次獲取模板時會解析并緩存所以用同一個Configuration實例多次調(diào)用getTemplate是沒問題的。真正影響性能的往往是IO操作比如每次都新建FileOutputStream。我建議用try-with-resources嚴(yán)格管理流同時盡量復(fù)用輸出目錄的判斷邏輯。另外Freemarker默認(rèn)對數(shù)字的格式化會有逗號分隔比如金額1234567會輸出成1,234,567這在生成代碼時很要命因為方法參數(shù)里的數(shù)字可能變成千分位格式。需要在Configuration里設(shè)置數(shù)字格式cfg.setNumberFormat(0);這是一個很多人會忽略的大坑。如果你生成代碼里包含serialVersionUID 123456789L不設(shè)置NumberFormat結(jié)果會變成123,456,789L編譯直接報錯。6. 最后分享一點個人心得關(guān)于代碼生成器我用Freemarker折騰了幾年最大的心得是“模板寧簡勿繁”。模板里寫的邏輯越簡單它就越可靠越容易維護(hù)。不要把業(yè)務(wù)判斷都堆進(jìn)模板如果模板里出現(xiàn)大量#if和嵌套循環(huán)說明你的數(shù)據(jù)模型設(shè)計不到位應(yīng)該在生成器代碼里預(yù)處理出貼近最終輸出的數(shù)據(jù)結(jié)構(gòu)。另一個小技巧是把模板目錄和生成器打包成可執(zhí)行JAR放到項目里以后新接手的同事只要跑一條命令或者一個main方法就能生成全套代碼。配合Maven的exec-maven-plugin甚至可以用mvn compile exec:java一步完成。對比讓新人手敲一遍CRUD這個體驗差別還是很明顯的。我目前還把生成器接入了數(shù)據(jù)庫表結(jié)構(gòu)讀取邏輯配合一個簡單的配置類做到“指定表名 - 自動生成整套代碼”下一步打算支持自定義模板組合比如生成到微服務(wù)多模塊目錄結(jié)構(gòu)。如果你也準(zhǔn)備做代碼生成建議從最小的單表模板開始先把數(shù)據(jù)模型理順再逐步加模板。代碼生成不是炫技而是把機(jī)械勞動壓縮到最低把精力留給真正有業(yè)務(wù)價值的代碼。