参考文章为 :

文中最后的执行流程是 :

监听接口元数据 -> 触发hook -> 执行代码生成 -> 更新到代码仓库 -> 自动化测试 -> CI/CD -> 发布到仓库(maven等)

核心的内容在于 代码生成 (因为后面的运维工作不太熟悉, 思考如何简化实现)

关于技术选型,考虑

  • Thymeleaf
  • FreeMarker

简单的对比了二者,最终选择FreeMarker(前者需要web环境,并且FreeMarkeri语法更加友好,上手难度相对低)。

遇到的问题 :

  1. ftl无法存储空格, 需要通过 ${"\t"} 来渲染 , 手动添加 制表符 是不可能的 , 代码改变世界! 这里的解决方案是写了一个FileUtil , 在准备完ftl文件之后调用一下就可以了

  2. 接口元信息 : 如果只是简单的接口调用, 那么还是非常简单的。但是自动化的SDK代码生成需要考虑到各种的情况 , 然后制定某种规范,比如

    • 方法名称问题
    • import导入问题
    • 参数问题
    • 请求方式问题

    这些都是需要考虑的, 同时我也总结了对应的方案(部分方案可能比较蠢,不过代码能正确的跑通就可以)

    关于方法名称以及参数,需要扩展interface表, 添加上对应的字段(前面的设计中已经对 固定数据 和 变化数据进行分表了 , 因此还是比较轻松的)

  3. 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;
}

code-Format

如果把格式正确的代码粘贴到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\"}");
}
// 7 chars
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模板编写

对于一个接口, 在编写代码模板的时候需要注意 :

  1. 参数
  2. 请求方法
  3. 响应结果
  4. import

首先是参数的问题 , 为了方便进行代码生成 这里我选择

  1. 所有的参数都通过Body进行传输 , 并且参数全部通过单独的类来进行维护
  2. 参数命名统一使用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
/**
* @author adorabled4
* @className ApiSDKMultiTest
* @date : 2023/12/28/ 20:00
**/
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());
}
}

//..............


}

可以看到大致上还是满足需求的,具体的细节需要在后面进行打磨。
后续考虑扩展一些细节内容

  1. javdoc
  2. 关于代码生成的标注等内容
  3. 返回值修改, 是否有必要去返回实体类的内容? 直接返回BaseResponse更好

参数与模块设计

为了方便区分接口, 我通过在路径中标识 v1 等来进行区分(大量的接口和代码生成可能会导致类膨胀的问题),

对于参数类, 放到api-common中进行维护(方便接口模块和SDK模块进行访问)

meta-data维护

关于接口的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;

/**
* SDK中的方法名称
*/
private String sdkMethodName;

/**
* SDK参数类名称(全类名)
*/
private String sdkParamName;

/**
* 版本:v1,v2,v3...
*/
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 {

/**
* api-doc的相对路径
*/
private String docPath;


/**
* api-sdk的相对路径
*/
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 报错, 并且可以直接使用

image-20231229115158495

自动化代码生成

这里的监听器方案如下:

  • 监听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