参考文章为 :
文中最后的执行流程是 :
监听接口元数据 -> 触发hook -> 执行代码生成 -> 更新到代码仓库 -> 自动化测试 -> CI/CD -> 发布到仓库(maven等)
核心的内容在于 代码生成 (因为后面的运维工作不太熟悉, 思考如何简化实现)
关于技术选型,考虑
简单的对比了二者,最终选择FreeMarker(前者需要web环境,并且FreeMarkeri语法更加友好,上手难度相对低)。
遇到的问题 :
-
ftl无法存储空格, 需要通过 ${"\t"}
来渲染 , 手动添加 制表符 是不可能的 , 代码改变世界! 这里的解决方案是写了一个FileUtil , 在准备完ftl文件之后调用一下就可以了
-
接口元信息 : 如果只是简单的接口调用, 那么还是非常简单的。但是自动化的SDK代码生成需要考虑到各种的情况 , 然后制定某种规范,比如
- 方法名称问题
- import导入问题
- 参数问题
- 请求方式问题
这些都是需要考虑的, 同时我也总结了对应的方案(部分方案可能比较蠢,不过代码能正确的跑通就可以)
关于方法名称以及参数,需要扩展interface表, 添加上对应的字段(前面的设计中已经对 固定数据 和 变化数据进行分表了 , 因此还是比较轻松的)
-
ftl文件编写的问题(由于不熟悉别的语言 , 因此这里只能暂时编写java 版本的SDK模板, 但是也遇到了很多的问题, 大多是在系统设计上)
关于FreeMarker的语法 , 需要知道
- 获取值 : ${}
- 转义字符 : ${“\t”}
- if - else : <#if ***> </#if> , 中间可以加上 <#elseif> , 如果需要判空, 使用
<#if api.sdkParamName?has_content>
- list : <#list apis as api> </#list> 即可 , 其中
apis
是上下文的参数
这里给出一份参数示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| private Map<String, Object> getData() { Map<String, Object> context = new HashMap<>(); List objs = new ArrayList<>(); for (int i = 0; i < 1; i++) { Map<String, Object> api = new HashMap<>(); api.put("modelName", "com.dhx.apicommon.model.v1.Poet"); api.put("sdkMethodName", "callRandomPoet" + i); api.put("name", "this is api-name"); api.put("requestMethod", "GET"); api.put("version", "v1"); api.put("docUrl", "http://blog.dhx.icu"); api.put("sdkParamName", "com.dhx.apicommon.model.v1.query.PoetQuery"); api.put("callPath", "api/v1/common/poet/random"); api.put("description", "this is desc"); objs.add(api); } context.put("apis", objs); context.put("basePackage", "com.dhx.apisdk"); context.put("methodName", "getRandomPoet"); context.put("time", "2023-12-29"); context.put("className", "TurboAPIClientImpl"); return context; }
|
如果把格式正确的代码粘贴到ftl文件中, 格式会变乱
比如 , 如果直接用这样的template来生成代码, 会导致我们的代码格式非常混乱
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public class ${className} {
public ${api.modelName} ${api.methodName}() { try { String nowTime = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date()); String result = HttpRequest.get(SERVER_HOST + "${callPath}").addHeaders(getHeaderMap()).execute().body(); BaseResponse baseResponse = JSONUtil.toBean(result, BaseResponse.class); if (baseResponse.getCode() == 200) { String dataStr = JSONUtil.toJsonStr(baseResponse.getData()); if (dataStr == null || dataStr.equals("")) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 调用接口失败 --" + baseResponse.toString()); } ${basePackage}.model.${api.modelName} obj = JSONUtil.toBean(dataStr, ${basePackage}.model.${api.modelName}.class); return obj; } else { throw new BusinessException(baseResponse.getCode(), baseResponse.getMessage()); } } catch (IORuntimeException e) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 访问服务器失败 --" + e.getMessage()); } catch (RuntimeException e) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 调用接口失败 --" + e.getMessage()); } }
}
|
这里是通过对固定的括号进行处理(当然, 需要编写格式规范的模板代码)
核心的思路就是遍历 {}
, 然后维护一个depth, 在每层插入之前添加上我们的 制表符
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| public String handleCodeTab(String fileName) throws IOException { InputStream is = getClass().getClassLoader().getResourceAsStream(fileName); BufferedReader br = new BufferedReader(new InputStreamReader(is)); StringBuilder sb = new StringBuilder(); int depth = 0; String line; StringBuilder tmp = new StringBuilder(); while ((line = br.readLine()) != null) { tmp.delete(0, tmp.length()); for (int i = 0; i < depth; i++) { tmp.append("${\"\\t\"}"); } if (line.charAt(line.length() - 1) == '}' || line.charAt(0) == '}') { tmp.delete(0, 7); depth--; } if (line.charAt(line.length() - 1) == '{') { depth++; } tmp.append(line).append("\n"); sb.append(tmp.toString()); } br.close(); String handledFile = "format/" + fileName; FileWriter fileWriter = new FileWriter(handledFile); fileWriter.write(sb.toString()); fileWriter.close(); is.close(); return handledFile; }
|
生成的结果也是可以接受的, 初步大概是这样 :
我是在开发的过程中不断记录的, 因此可能会某些部分代码出现问题, 一切以最终结果为准
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| public class ${className} { ${"\t"}@Slf4j ${"\t"}public ${basePackage}.model.${modelName} ${methodName}() { ${"\t"}${"\t"}try { ${"\t"}${"\t"}${"\t"}String nowTime = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date()); ${"\t"}${"\t"}${"\t"}String result = HttpRequest.get(SERVER_HOST + "${callPath}").addHeaders(getHeaderMap()).execute().body(); ${"\t"}${"\t"}${"\t"}BaseResponse baseResponse = JSONUtil.toBean(result, BaseResponse.class); ${"\t"}${"\t"}${"\t"}if (baseResponse.getCode() == 200) { ${"\t"}${"\t"}${"\t"}${"\t"}String dataStr = JSONUtil.toJsonStr(baseResponse.getData()); ${"\t"}${"\t"}${"\t"}${"\t"}if (dataStr == null || dataStr.equals("")) { ${"\t"}${"\t"}${"\t"}${"\t"}${"\t"}log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 调用接口失败 --" + baseResponse.toString()); ${"\t"}${"\t"}${"\t"}${"\t"}} ${"\t"}${"\t"}${"\t"}${"\t"}${basePackage}.model.${modelName} obj = JSONUtil.toBean(dataStr, ${basePackage}.model.${modelName}.class); ${"\t"}${"\t"}${"\t"}${"\t"}return obj; ${"\t"}${"\t"}${"\t"}} else { ${"\t"}${"\t"}${"\t"}${"\t"}throw new BusinessException(baseResponse.getCode(), baseResponse.getMessage()); ${"\t"}${"\t"}${"\t"}} ${"\t"}${"\t"}} catch (IORuntimeException e) { ${"\t"}${"\t"}${"\t"}log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 访问服务器失败 --" + e.getMessage()); ${"\t"}${"\t"}} catch (RuntimeException e) { ${"\t"}${"\t"}${"\t"}log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 调用接口失败 --" + e.getMessage()); ${"\t"}${"\t"}} ${"\t"}} }
|
ftl模板编写
对于一个接口, 在编写代码模板的时候需要注意 :
- 参数
- 请求方法
- 响应结果
- import
首先是参数的问题 , 为了方便进行代码生成 这里我选择
- 所有的参数都通过Body进行传输 , 并且参数全部通过单独的类来进行维护
- 参数命名统一使用param
通过这样的做法, 可以解决1 3 4 的问题 , 至于问题2 , 只需要在模板代码中加上一个if-else 的逻辑就可以了。
那么可以简单的得出下面的模板方法代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| <#if api.paramName> public ${api.modelName} ${api.methodName}(${api.paramName} param) { try { String nowTime = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date()); <#if api.requestMethod==${"GET"}> String result = HttpRequest.get(SERVER_HOST + "${callPath}").addHeaders(getHeaderMap()).body(JSONUtil.toJsonStr(param).execute().body(); <#elseif api.requestMethod==${"POST"}> String result = HttpRequest.post(SERVER_HOST + "${callPath}").addHeaders(getHeaderMap()).body(JSONUtil.toJsonStr(param).execute().body(); </#if> BaseResponse baseResponse = JSONUtil.toBean(result, BaseResponse.class); if (baseResponse.getCode() == 200) { String dataStr = JSONUtil.toJsonStr(baseResponse.getData()); if (dataStr == null || dataStr.equals("")) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 调用接口失败 --" + baseResponse.toString()); } ${basePackage}.model.${api.modelName} obj = JSONUtil.toBean(dataStr, ${basePackage}.model.${api.modelName}.class); return obj; } else { throw new BusinessException(baseResponse.getCode(), baseResponse.getMessage()); } } catch (IORuntimeException e) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 访问服务器失败 --" + e.getMessage()); } catch (RuntimeException e) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 调用接口失败 --" + e.getMessage()); } }
|
进行测试 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
|
public class ApiSDKMultiTest {
Configuration cfg = null;
{ cfg = new Configuration(Configuration.VERSION_2_3_30); cfg.setTemplateLoader(new ClassTemplateLoader(this.getClass(), "/templates")); cfg.setDefaultEncoding("UTF-8"); } @Test public void Test() throws IOException, TemplateException { Template template = cfg.getTemplate("api-sdk-client-base.java.ftl"); Map<String, Object> map = getData(); String fileName = generate(template, map, "tmp"); } private String generate(Template template, Map<String, Object> map, String path) throws IOException, TemplateException { File folder = new File(path); if (!folder.exists()) { folder.mkdirs(); } String fileName = path + "/" + map.get("name") + template.getName().replace(".ftl", ""); FileOutputStream fos = new FileOutputStream(fileName); OutputStreamWriter out = new OutputStreamWriter(fos); template.process(map, out); fos.close(); out.close(); return fileName; }
private Map<String, Object> getData() { Map<String, Object> context = new HashMap<>(); List objs = new ArrayList<>(); for (int i = 0; i < 5; i++) { Map<String, Object> api = new HashMap<>(); api.put("modelName", "com.dhx.common.model.v1.Poet" + i); api.put("methodName", "getWeather" + i); api.put("name", "this is api-name"); api.put("requestMethod", "GET"); api.put("version", "v1"); api.put("paramModel", "com.dhx.common.model.v1.query.PoetQuery"); api.put("callPath", "api/v1/test/api"); api.put("description", "this is desc"); objs.add(api); } context.put("apis", objs); context.put("basePackage", "com.dhx.apisdk"); context.put("methodName", "getRandomPoet"); context.put("className", "HxApiClientTest"); return context; }
}
|
生成代码展示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| package com.dhx.apisdk;
import cn.hutool.core.io.IORuntimeException; import cn.hutool.core.util.RandomUtil; import cn.hutool.http.HttpRequest; import cn.hutool.json.JSONException; import cn.hutool.json.JSONUtil; import com.dhx.apicommon.common.BaseResponse; import com.dhx.apicommon.common.exception.ErrorCode; import com.dhx.apicommon.util.ResultUtil; import com.dhx.apisdk.client.HxApiClient; import com.dhx.apicommon.common.exception.BusinessException; import com.dhx.apisdk.util.SignUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import com.dhx.apicommon.model.v1.com.dhx.common.model.v1.Poet0; import com.dhx.apicommon.model.v1.com.dhx.common.model.v1.Poet1; import com.dhx.apicommon.model.v1.com.dhx.common.model.v1.Poet2; import com.dhx.apicommon.model.v1.com.dhx.common.model.v1.Poet3; import com.dhx.apicommon.model.v1.com.dhx.common.model.v1.Poet4;
import javax.servlet.http.HttpServletRequest; import java.net.URI; import java.net.URISyntaxException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.Map;
import static com.dhx.apisdk.HxApiClientConfig.SERVER_HOST;
@Slf4j
public class HxApiClientTest {
public com.dhx.common.model.v1.Poet0 getWeather0(com.dhx.common.model.v1.query.PoetQuery param) { try { String nowTime = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date()); String result = HttpRequest.get(SERVER_HOST + "api/v1/test/api").addHeaders(getHeaderMap()).body(JSONUtil.toJsonStr(param).execute().body(); BaseResponse baseResponse = JSONUtil.toBean(result, BaseResponse.class); if (baseResponse.getCode() == 200) { String dataStr = JSONUtil.toJsonStr(baseResponse.getData()); if (dataStr == null || dataStr.equals("")) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 调用接口失败 --" + baseResponse.toString()); } com.dhx.apisdk.model.com.dhx.common.model.v1.Poet0 obj = JSONUtil.toBean(dataStr, com.dhx.apisdk.model.com.dhx.common.model.v1.Poet0.class); return obj; } else { throw new BusinessException(baseResponse.getCode(), baseResponse.getMessage()); } } catch (IORuntimeException e) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 访问服务器失败 --" + e.getMessage()); } catch (RuntimeException e) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 调用接口失败 --" + e.getMessage()); } }
public com.dhx.common.model.v1.Poet1 getWeather1(com.dhx.common.model.v1.query.PoetQuery param) { try { String nowTime = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date()); String result = HttpRequest.get(SERVER_HOST + "api/v1/test/api").addHeaders(getHeaderMap()).body(JSONUtil.toJsonStr(param).execute().body(); BaseResponse baseResponse = JSONUtil.toBean(result, BaseResponse.class); if (baseResponse.getCode() == 200) { String dataStr = JSONUtil.toJsonStr(baseResponse.getData()); if (dataStr == null || dataStr.equals("")) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 调用接口失败 --" + baseResponse.toString()); } com.dhx.apisdk.model.com.dhx.common.model.v1.Poet1 obj = JSONUtil.toBean(dataStr, com.dhx.apisdk.model.com.dhx.common.model.v1.Poet1.class); return obj; } else { throw new BusinessException(baseResponse.getCode(), baseResponse.getMessage()); } } catch (IORuntimeException e) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 访问服务器失败 --" + e.getMessage()); } catch (RuntimeException e) { log.error("\u001B[31m" + e.getClass() + "\u001B[0m: " + "[HxApiClient] 调用接口失败 --" + e.getMessage()); } }
}
|
可以看到大致上还是满足需求的,具体的细节需要在后面进行打磨。
后续考虑扩展一些细节内容
- javdoc
- 关于代码生成的标注等内容
- 返回值修改, 是否有必要去返回实体类的内容? 直接返回BaseResponse更好
参数与模块设计
为了方便区分接口, 我通过在路径中标识 v1 等来进行区分(大量的接口和代码生成可能会导致类膨胀的问题),
对于参数类, 放到api-common
中进行维护(方便接口模块和SDK模块进行访问)
关于接口的meta-data, 有
- basePackage
- className(ApiClient)
- apis:
- name
- description
- callPath
- serviceAddress
- requestParam
- requestMethod
- requestHeaders
- sdkMethodName
- sdkParamName
- imageUrl
- version
- requestExample
- responseExample
- docUrl
- status
- categories
定义InterfaceMetaDataDTO类如下 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
| @Data @Builder @AllArgsConstructor @NoArgsConstructor public class InterfaceMetaDataDTO {
private String name;
private String description;
private String imageUrl;
private List<String> categories;
private String status;
private String docUrl;
private String requestMethod;
private String requestParam;
private String requestHeaders;
private String callPath;
private String serviceAddress;
private String requestExample;
private String responseExample;
private String sdkMethodName;
private String sdkParamName;
private String version; }
|
并且修改interface_variable_info表, 添加上相关的字段
接着通过上面的meta-data以及javadoc , 可以生成这样的代码 :
方便调用的同时, 也能快速的定位到接口文档, 实际的使用体验应该是很不错的。
实际上 代码模板 在编写的时候遇到了很多的小bug , 不过也没有想到好的办法, 只能是一点点的debug了
- 生成的时候可以先不用SpringBoot的测试(跑得太慢) , 仅仅使用单元测试库即可
接口实现与代码测试
暂时的执行逻辑是 添加接口元数据 -> 调用代码生成接口 -> 测试client
通过相对路径来进行指定文件的代码生成:
创建配置类 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Data @Component @ConfigurationProperties(prefix = "template.gen") public class FreeMarkerConfig {
private String docPath;
private String sdkPath;
}
|
配置文件:
1 2 3 4
| template: gen: doc-path: ../api-doc/docs/gen sdk-path: ../api-sdk/src/main/java/com/dhx/apisdk/client
|
接口实现 :
这里给出SDK代码生成的实现, 对于markdown文档, 只会更加简单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| @Override public void genSDKCode(List<Long> interfaceIds) { try { List<InterfaceMetaDataDTO> templateDTOS = interfaceIds .stream().map(this::getInterfaceTemplateData).collect(Collectors.toList()); Template template = cfg.getTemplate("api-sdk-client.java.ftl"); generateSDKCode(template, templateDTOS); } catch (IOException | TemplateException e) { throw new RuntimeException(e); } }
private void generateSDKCode(Template template, List<InterfaceMetaDataDTO> metaDataDTOS) throws IOException, TemplateException { String docPath = freeMarkerConfig.getSdkPath(); File folder = new File(docPath); if (!folder.exists()) { ThrowUtil.throwIf(!folder.mkdirs(), ErrorCode.SYSTEM_ERROR, "创建文件夹失败!"); } Map<String, Object> context = new HashMap<>(); context.put("apis", metaDataDTOS); context.put("basePackage", "com.dhx.apisdk"); context.put("className", "TurboAPIClientImpl"); context.put("time", DateUtil.format(DateTime.now(),"yyyy-MM-dd")); String fileName = docPath + "/" + "TurboAPIClientImpl.java" ; FileOutputStream fos = new FileOutputStream(fileName); OutputStreamWriter out = new OutputStreamWriter(fos); template.process(context, out); fos.close(); out.close(); }
|
单元测试代码:
需要先调用 hTest方法进行 ftl 文件的格式化。
然后手动把内容粘贴到 /templates/ ******.ftl 中, 需要注意每一行不能有任何的前置空格
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @SpringBootTest public class InterfaceTest {
@Test public void hTest() throws IOException { FileUtil.handleCodeTab("templates/api-sdk-client-base.java.ftl"); }
@Resource InterfaceInfoService interfaceInfoService; @Test public void Test() throws Exception { interfaceInfoService.genSDKCode(Collections.singletonList(5L)); }
}
|
格式化之后的ftl文件为 :
执行结果 :
代码 0 报错, 并且可以直接使用
自动化代码生成
这里的监听器方案如下:
- 监听MySQL binlog (DB层面)
- MySQL触发器 (DB层面)
- 监听接口(代码层面)
- 手动调用接口(代码多了之后可能不太方便维护)
为了方便进行代码编写(学习成本) , 考虑到执行代码生成的接口以及更改接口信息的代码都在interfaceService
中, 这里就直接调用接口进行实现了(偷个懒)
后面有时间再去研究别的方案吧。
累了累了, 踩了很多坑~~
总结
回头看 https://tech.meituan.com/2023/01/05/openplatform-sdk-auto-generate.html
其中的内容已经基本上完成了(CI/CD除外 , 后续考虑通过Github Action 构建jar包然后发布到Github Release中)
从最开始的见到知识星球中的文章分享,
到了解到模板引擎(以前用过Thymeleaf, 一直以为模板引擎只是用来渲染Html的, 没有想到是代码生成) ,
接着尝试自己动手实现(从markdown接口文档到SDK代码)
然后不断地Debug ftl模板, 重构项目接口模块的设计 , 总是算基本完成了这个功能。
目前的设计是基本上满足Java中代码生成的需求的, 如果需要扩展其他语言的SDK, meta-data已经有了, 需要做的就是 编写代码模板
考虑在Java模板的基础上通过AIGC去生成其他SDK?
Reference