如何用GraphQL+JavaPoet把前端逼成瓶颈

AKA: 如何炒了自己 -_-#

代码已开源: https://github.com/YituHealthcare/Arc

上篇文章《DDD探险——基于GraphQL+Dgraph实践》中提到了一种假设

如果前端能通过领域模型进行数据操作,能否通过GraphQL Schema同时描述领域模型、API接口以及数据库结构?

基于此假设提供了Arc框架,同时采用Dgraph作为数据存储。
既然我们已经有了Schema,能否基于这个描述自动生成系统实现?通过降低开发成本、提升效率,达成快速迭代、试错领域模型的目的。

尝试通过代码生成器来完成这个假设。

1. GraphQL Schema -> Dgraphql Schema

1.1 概念:

  1. Dgraph Schema: Dgraph数据库结构化语句,类似mysql中的DDL
  2. predicate: Dgraph的数据库字段,一个predicate可以被多个type使用
  3. domainClass: Arc框架中定义javaBean类型,用于反序列化

1.2 方案

解析GraphQL Schema后,根据转换逻辑生成Dgraph Schema

注意Arc框架限制

  1. DB结构中拥有框架依赖通用predicate,如 domainClass
  2. 为了解决predicatetype定义问题,dgraph中的predicate命名增加type前缀

1.3 注意

  1. 需要框架提供根据DgraphSchema自动初始化数据库的能力

2. GraphQL Schema -> JavaCode

2.1 JavaPoet

JavaPoet is a Java API for generating .java source files.

通过JavaPoet可以方便、详细的描述Java源文件并生成。

看官方issue发现很多人希望提供快速生成gettersetter方法的方式,官方并未采纳,给出的回复是

Basically we’re a tool for generating exactly what you tell us and not a tool for inferring code to generate. You can write a static method or helper class which can produce a field, getter, and setting in one shot onto a TypeSpec.Builder.

这个回复也明确了JavaPoet的定位。其实生成相关方法的方式非常简单

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
public class FieldSpecGenSetter implements Function<FieldSpec, MethodSpec> {

@Override
public MethodSpec apply(FieldSpec fieldSpec) {
return MethodSpec.methodBuilder("set" + StringUtils.capitalize(fieldSpec.name))
.addModifiers(Modifier.PUBLIC)
.addParameter(fieldSpec.type, fieldSpec.name)
.addStatement("this.$L = $L", fieldSpec.name, fieldSpec.name)
.build();
}

}

public class FieldSpecGenGetter implements Function<FieldSpec, MethodSpec> {

@Override
public MethodSpec apply(FieldSpec fieldSpec) {
return MethodSpec.methodBuilder(GenSpecUtil.getGetterPrefix(fieldSpec) + StringUtils.capitalize(fieldSpec.name))
.addModifiers(Modifier.PUBLIC)
.addStatement("return $L", fieldSpec.name)
.returns(fieldSpec.type)
.build();
}

}

提供一个生成Builder的示例,基本涵盖了全部定义

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
public class TypeSpecGenBuilder implements UnaryOperator<TypeSpec> {

@Override
public TypeSpec apply(TypeSpec source) {
if (CollectionUtils.isEmpty(source.fieldSpecs)) {
return source;
} else {
TypeSpec target = TypeSpec.classBuilder(source.name + "Builder").build();
return source.toBuilder()
.addMethod(getAllArgsConstructor(source.fieldSpecs))
.addMethod(getNoArgsConstructor())
.addMethod(getBuilderInstanceMethod(target))
.addType(getBuilderType(source, target))
.build();
}
}

private MethodSpec getAllArgsConstructor(List<FieldSpec> fieldSpecs) {
MethodSpec.Builder builder = MethodSpec.constructorBuilder()
.addModifiers(Modifier.PUBLIC)
.addParameters(fieldSpecs.stream().map(f -> ParameterSpec.builder(f.type, f.name).build()).collect(Collectors.toList()));
fieldSpecs.forEach(fieldSpec -> builder.addStatement("this.$L = $L", fieldSpec.name, fieldSpec.name));
return builder.build();
}

private MethodSpec getNoArgsConstructor() {
return MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC).build();
}

private MethodSpec getBuilderInstanceMethod(TypeSpec target) {
return MethodSpec.methodBuilder("builder")
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.returns(getType(target))
.addStatement("return new $T()", getType(target))
.build();
}

private TypeSpec getBuilderType(TypeSpec source, TypeSpec target) {
return target.toBuilder()
.addFields(source.fieldSpecs.stream().map(it -> FieldSpec.builder(it.type, it.name, Modifier.PRIVATE).build()).collect(Collectors.toList()))
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.addMethod(MethodSpec.constructorBuilder().addModifiers(Modifier.PRIVATE).build())
.addMethods(getBuilderSetMethodSpecList(source, target))
.addMethod(getSourceInstanceMethod(source))
.build();
}

private List<MethodSpec> getBuilderSetMethodSpecList(TypeSpec source, TypeSpec target) {
return source.fieldSpecs.stream()
.map(fieldSpec -> MethodSpec.methodBuilder(fieldSpec.name)
.addModifiers(Modifier.PUBLIC)
.returns(getType(target))
.addParameter(ParameterSpec.builder(fieldSpec.type, fieldSpec.name).build())
.addStatement("this.$L = $L", fieldSpec.name, fieldSpec.name)
.addStatement("return this")
.build()).collect(Collectors.toList());
}

private MethodSpec getSourceInstanceMethod(TypeSpec source) {
return MethodSpec.methodBuilder("build")
.addStatement("return new $L($L)", source.name, source.fieldSpecs.stream().map(f -> f.name).collect(Collectors.joining(",")))
.addModifiers(Modifier.PUBLIC)
.returns(ClassName.get("", source.name))
.build();
}

private TypeName getType(TypeSpec typeSpec) {
return ClassName.get("", typeSpec.name);
}

}

2.2 实现

基于 graphql-java 解析 GraphqlSchema,通过 javapoet 按照Arc约束生成相关java文件

  • Graphql Enum -> dictionary
  • Graphql input -> input
  • Graphql type -> typeapirepository

2.3 注意

  1. 通过只提供interface方式,避免修改生成的代码。保障每次schema变更后都可以重新生成。
  2. 如果存在需要修改生成代码的场景,通过配置方式跳过相关Java源文件生成。

3. Maven Plugin

生成逻辑通过上述定义完成后,如何触发生成的动作?Maven插件是一个不错的选择。
只需要继承AbstractMojoOverride execute()中调用相关生成方法即可。这里暂不展开如何开发Maven插件

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
@Mojo(name = "generate")
public class GeneratorMojo extends AbstractMojo {

@Parameter(defaultValue = "${project}", readonly = true, required = true)
private MavenProject project;

@Parameter(defaultValue = "${basedir}/src/main/resources/arc-generator.json")
private File configJson;
@Parameter(defaultValue = "${basedir}/src/main/resources/graphql/schema.graphqls")
private File schemaPath;
@Parameter(property = "target", defaultValue = "all")
private String target;

private static final String TARGET_ALL = "all";
private static final String TARGET_JAVA = "java";
private static final String TARGET_DGRAPH = "dgraph";

@Override
public void execute() throws MojoExecutionException, MojoFailureException {
final CodeGenConfig config = getConfig();
if (TARGET_ALL.equalsIgnoreCase(target) || TARGET_JAVA.equalsIgnoreCase(target)) {
generateJava(config);
}
if (TARGET_ALL.equalsIgnoreCase(target) || TARGET_DGRAPH.equalsIgnoreCase(target)) {
generateDgraph(config);
}
}
}

然后再命令行执行

1
mvn arc:generate

即可按照配置及规则生成相关代码。也可以通过参数决定只生成java代码,不生成 Dgraph Schema

1
mvn arc:generate -Dtarget=java

4. 效果

4.0 添加Maven插件依赖

1
2
3
4
5
6
7
8
9
<build>
<plugins>
<plugin>
<groupId>com.github.yituhealthcare</groupId>
<artifactId>arc-maven-plugin</artifactId>
<version>1.4.0</version>
</plugin>
</plugins>
</build>

4.1 创建schema. 默认路径为 resources:graphql/schema.graphqls

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
88
89
90
91
92
93
94
95
96
97
98
99
scalar DateTime

schema{
query: Query,
mutation: Mutation
}

type Query{
project(
id: String
): Project
users: [User]
}

type Mutation{
createProject(
payload: ProjectInput
): Project
createMilestone(
payload: MilestoneInput
): Milestone
}

"""
项目分类
"""
enum ProjectCategory {
"""
示例项目
"""
DEMO
"""
生产项目
"""
PRODUCTION
}

"""
名称
为了达到某个产品迭代、产品模块开发、或者科研调研等目的所做的工作.
"""
type Project{
id: String!
name: String!
description: String!
category: ProjectCategory
createTime: DateTime!
milestones(
status: MilestoneStatus
): [Milestone]
}
"""
里程碑
表述一个Project的某个时间阶段及阶段性目标. 一个Project可以同时拥有多个处于相同或者不同阶段的Milestone.
"""
type Milestone{
id: String!
name: String!
status: MilestoneStatus
}

type User {
name: String!
}

"""
里程碑状态
"""
enum MilestoneStatus{
"""
未开始
"""
NOT_STARTED,
"""
进行中
"""
DOING,
"""
发布
"""
RELEASE,
"""
关闭
"""
CLOSE
}

input ProjectInput{
name: String!
description: String!
vendorBranches: [String!]!
category: ProjectCategory!
}


input MilestoneInput{
projectId: String!
name: String!
}

4.2 新建配置文件, 默认路径为 resources:arc-generator.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"basePackage": "com.github.yituhealthcare.arcgeneratorsample",
"dropAll": false,
"genStrategies": [
{
"codeGenOperation": "SKIP_IF_EXISTED",
"codeGenType": "REPO"
},
{
"codeGenOperation": "OVERRIDE",
"codeGenType": "API"
}
],
"ignoreJavaFileNames": [
"User"
],
"dgraphPath": "dgraph/schema.dgraph"
}

4.3 执行命令行

-w688

4.4 代码生成

-w942

在聊清楚schema后,只需要执行一行命令,然后编写一个实现类即可完成接口服务的开发。在schema修改后,重复执行这个过程。

5. 缺陷

5.1 GraphQL描述力不够。只描述来数据结构与类型,没有描述数据存储可能会用到的主键、索引、缓存等信息,以及DDD中的限界上下文(BoundedContext)、聚合根(AggregateRoot)等概念

可以通过自定义directive实现相关扩展定义,比如:

1
2
3
4
5
6
7
8
9
10
11
scalar DateTime
directive @Cache on FIELD_DEFINITION
directive @Alloftext on FIELD_DEFINITION
directive @AggregateRoot on OBJECT

type Project @AggregateRoot {
id: String!
name: String! @Cache @Alloftext
description: String!
createTime: DateTime!
}

但是这个时候QL已经变成了DSL,需要考虑额外的学习、推广成本

5.2 生成的代码和实现代码混合,如何进行code review?

schema和生成的代码先提交一次PR,对schema进行review。之后的实现代码再单独提交PR

5.3 Dgraph更新时数据如何处理?

已经上线后无法自动迭代。数据版本迁移不应属于开发过程

5.4 仍需编写实现类(生成的代码是否允许编辑?)

理想情况是完全通过代码生成完成相关实现。但是实际业务并不是简单的crud,如果通过schema描述复杂业务,就会发现本质上是通过schema来编写另一种java代码。所以只生成interface,需要开发者补全相关实现。让schema专注于描述DDD。不排除提供生成简单crud实现的功能,在修改了相关实现类后,再通过配置SKIP_IF_EXISTED跳过相关实现

5.5 自动化生成后发现无事可做,不可替代性如何保障?

-_-#