如何用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的定位。其实生成相关方法的方式非常简单

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的示例,基本涵盖了全部定义

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插件

@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);
        }
    }
}

然后再命令行执行

mvn arc:generate

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

mvn arc:generate -Dtarget=java

4. 效果

4.0 添加Maven插件依赖

<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

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

{
  "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实现相关扩展定义,比如:

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 自动化生成后发现无事可做,不可替代性如何保障?

-_-#

Welcome to my other publishing channels