跳到主要内容

处理空值

Input DTO用于数据录入,对于客户端提交的对象中的可空属性提供了强大行为控制能力,并将这种能力标准化。

数据录入中null相关的问题

回顾:直接保存实体对象

Jimmer实体最重要一个特征之一,就是严格区分数据未知 (不指定对象属性) 和没有数据 (将对象属性指定为null)

让我们暂时将Input DTO的概念放到一旁,来回顾一下直接使用Jimmer实体保存数据时二者的差异。

  • 将可空属性指定为null

    Book book = BookDraft.$.produce(draft -> {
    draft.setId(12L);
    draft.setName("TURING");
    draft.setStoreId(null);
    });
    sqlClient.update(book);

    生成如下SQL:

    update BOOK
    set
    NAME = ?, /* TURING */
    // highlight-next-line
    STORE_ID = ? /* <null: long> */
    where
    ID = ? /* 12 */`

    可见,明确地将对象的属性设置为null,利用保存指令执行update操作,数据库中的值会被修改为null。

  • 根本不指定可空属性

    Book book = BookDraft.$.produce(draft -> {
    draft.setId(12L);
    draft.setName("TURING");
    // `storeId` is not specified
    });
    sqlClient.update(book);

    生成如下SQL:

    update BOOK
    set
    NAME = ? /* TURING */
    // highlight-next-line
    /* `STORE_ID` is not updated */
    where
    ID = ? /* 12 */`

    可见,不设置对象的属性,利用保存指令执行update操作,数据库中的值不会被更改。

信息

这个区别非常重要。

在本文的后续内容中,我们不再讨论ORM生成了什么样的SQL语句,因为我们只需关注由Input DTO转换而得的实体对象属于哪种即可。

Input DTO面临的问题

现在,让我们来定义一个Input DTO:

input BookUpdateInput {
id!
name
id(store)
}

更多有关DTO语言的细节请参考相关章节,这里我们重点关注Jimmer预编译器根据这段DTO代码自动生成的Java/Kotlin代码。

生成代码如下

BookUpdateInput.java
@GeneratedBy(file = "<your_project>/src/main/dto/Book.dto")
public class BookUpdateInput implements Input<Book> {

private long id;

private String name;

@Nullable
private Long storeId;

@Override
public Book toEntity() {
......
}

...省略其他成员...
}

在原实体中,Book.store关联属性允许为null。此处的DTO语言未对此做出改变,所以,在生成代码中,storeId字段也允许为null。

如果用户上传的BookUpdateInput对象的storeId属性为null,那么用户的意图是将数据库中对应的外键STORE_ID更新为null,还是根本不更新改这段呢?

实际上,两种诉求都是普遍存在的。长期以来,开发人员对这两种行为的约定非常随意,即使API配套文档提及这类细节,形式也非常随意。这构成了沟通和理解困难,并对行业形成了持久伤害。

Input DTO对这个问题进行标准化定义,以求用规范化的方式处理不同的诉求。

Input DTO中可空属性的4种处理方法

为了解决上文提出的问题,DTO语言规定,如果一个DTO属性同时满足以下两个条件:

  • 被定义在input类型中

  • 允许为null

那么,可以为该DTO属性添加一个用于表示null处理模式的修饰符:可以为fixedstaticdynamicfuzzy

为了方便后续讨论,我们假设存在如下Web Controller:

@RestController
public class BookController {

@PutMapping("/book")
public void update(
@RequestBody BookUpdateInput input
) {
Book book = input.toEntity();
System.out.println(book);
...后续代码略...
}

...省略其他成员...
}

这里,我们把用户上传的Input DTO对象转化为Jimmer实体,并打印出来。

我们只需要关注打印结果即可,如前文所言,我们只需关注由Input DTO转换而得的实体对象是什么即可,而无需讨论ORM会生成什么样的SQL语句。

因此,后续代码不重要,省略。

1. fixed

DTO代码示范如下:

input BookUpdateInput {
id!
name
fixed id(store)
}

也可称该模式为超级静态模式。

  • 禁止用户提交Input DTO时不指定某些属性,即使想让某个属性为null,也需要明确指定。

  • 如果Input DTO的属性为null,得到的Jimmer实体对象的对应属性也会被设置为null,即将数据库中对应的字段修改为null。

客户端提交数据的两种用法如下:

  • 提交storeId属性为null的Input DTO

    curl -X 'PUT' \
    'http://localhost:8080/book' \
    -H 'accept: */*' \
    -H 'Content-Type: application/json' \
    -d '{
    "id": 12,
    "name": "TURING",
    "storeId": null
    }'

    打印结果 (最终得到的Jimmer实体对象) 如下:

    {
    "id":12,
    "name":"TURING",
    "store":null
    }

    即,后续操作会把数据库中对应字段修改为null。

  • 提交没有storeId属性的Input DTO

    curl -X 'PUT' \
    'http://localhost:8080/book' \
    -H 'accept: */*' \
    -H 'Content-Type: application/json' \
    -d '{
    "id": 12,
    "name": "TURING"
    }'

    该请求会被拒绝,HTTP错误代码400,参数错误。如果查看Java日志,可看到如下错误:

    Resolved [org.springframework.http.converter.HttpMessageNotReadableException: 
    JSON parse error: Cannot construct instance of
    `org.doc.j.model.dto.BookUpdateInput$Builder`,
    problem: An object whose type is "org.doc.j.model.dto.BookUpdateInput"
    cannot be deserialized by Jackson.
    The current type is fixed input DTO so that
    all JSON properties must be specified explicitly,
    however, the property "storeId" is not specified by JSON explicitly.
    Please either explicitly specify the property as null in the JSON,
    or specify the current input properties as static, dynamic or fuzzy
    in the DTO language]
提示

如果采用Jimmer中自动生成TypeScript的功能,生成的TypeScript代码要求Web开发人员必须为BookUpdateInput对象提供storeId属性,否则无法通过编译。

2. static

DTO代码示范如下:

input BookUpdateInput {
id!
name
static id(store)
}
  • 用户提交Input DTO时,既可以将storeId属性指定为null,也可以根本不指定。

  • 无论用户如何选择,行为不变,得到的Jimmer实体对象的对应属性一定会被设置为null。即,数据库中对应的字段会被修改为null。

客户端提交数据的两种用法如下:

  • 提交storeId属性为null的Input DTO

    curl -X 'PUT' \
    'http://localhost:8080/book' \
    -H 'accept: */*' \
    -H 'Content-Type: application/json' \
    -d '{
    "id": 12,
    "name": "TURING",
    "storeId": null
    }'

    打印结果 (最终得到的Jimmer实体对象) 如下:

    {
    "id":12,
    "name":"TURING",
    "store":null
    }

    即,后续操作会把数据库中对应字段修改为null。

  • 提交没有storeId属性的Input DTO

    curl -X 'PUT' \
    'http://localhost:8080/book' \
    -H 'accept: */*' \
    -H 'Content-Type: application/json' \
    -d '{
    "id": 12,
    "name": "TURING"
    }'

    打印结果 (最终得到的Jimmer实体对象) 如下:

    {
    "id":12,
    "name":"TURING",
    "store":null
    }

    即,后续操作会把数据库中对应字段修改为null。

信息

两种操作的效果一样,最终效果只受到DTO形状影响,与用户是否指定DTO属性无关。

3. dynamic

DTO代码示范如下:

input BookUpdateInput {
id!
name
dynamic id(store)
}
  • 如果用户选择将DTO的storeId属性设定为null,那么最终得到的Jimmer实体对象的storeId属性也为null。即,将数据库中对应字段修改为null。

  • 如果用户根本不设置DTO的storeId属性,那么最终得到的Jimmer实体对象的storeId属性也不会被设置。即,将数据库中对应字段不会被修改。

客户端提交数据的两种用法如下:

  • 提交storeId属性为null的Input DTO

    curl -X 'PUT' \
    'http://localhost:8080/book' \
    -H 'accept: */*' \
    -H 'Content-Type: application/json' \
    -d '{
    "id": 12,
    "name": "TURING",
    "storeId": null
    }'

    打印结果 (最终得到的Jimmer实体对象) 如下:

    {
    "id":12,
    "name":"TURING",
    "store":null
    }

    即,后续操作会把数据库中对应字段修改为null。

  • 提交没有storeId属性的Input DTO

    curl -X 'PUT' \
    'http://localhost:8080/book' \
    -H 'accept: */*' \
    -H 'Content-Type: application/json' \
    -d '{
    "id": 12,
    "name": "TURING"
    }'

    打印结果 (最终得到的Jimmer实体对象) 如下:

    {
    "id":12,
    "name":"TURING",
    // 此处没有storeId属性
    }

    即,后续操作不会修改数据库中对应字段。

信息

两种用法对应两种完全不同的行为,适合专业的客户端团队对服务行为进行灵活的控制。

4. fuzzy

注意

该模式以牺牲功能为代价换取保守和安全,是唯一一种功能不健全的模式。

DTO代码示范如下:

input BookUpdateInput {
id!
name
fuzzy id(store)
}
  • 如果用户把DTO对象的storeId指定为非null值,那么最终得到的Jimmer实体对象的storeId属性被指定。即,将数据库中对应字段修改为对应的值。

  • 否则 (无论把DTO对象的storeId属性指定为null,还是根本不指定),最终得到的Jimmer实体对象的storeId属性都不会被指定。即,将数据库中对应字段不会被修改。

客户端提交数据的三种用法如下:

  • 提交storeId属性为null的Input DTO

    curl -X 'PUT' \
    'http://localhost:8080/book' \
    -H 'accept: */*' \
    -H 'Content-Type: application/json' \
    -d '{
    "id": 12,
    "name": "TURING",
    "storeId": null
    }'

    打印结果 (最终得到的Jimmer实体对象) 如下:

    {
    "id":12,
    "name":"TURING",
    // 此处没有storeId属性
    }

    即,后续操作不会修改数据库中对应字段。

  • 提交没有storeId属性的Input DTO

    curl -X 'PUT' \
    'http://localhost:8080/book' \
    -H 'accept: */*' \
    -H 'Content-Type: application/json' \
    -d '{
    "id": 12,
    "name": "TURING"
    }'

    打印结果 (最终得到的Jimmer实体对象) 如下:

    {
    "id":12,
    "name":"TURING",
    // 此处没有storeId属性
    }

    即,后续操作不会修改数据库中对应字段。

  • 提交storeId属性为非null的Input DTO

    前面两种用法都无法修改数据库中的对应字段,除非指定非null值,如下:

    curl -X 'PUT' \
    'http://localhost:8080/book' \
    -H 'accept: */*' \
    -H 'Content-Type: application/json' \
    -d '{
    "id": 12,
    "name": "TURING",
    "storeId": 2
    }'

    打印结果 (最终得到的Jimmer实体对象) 如下:

    {
    "id":12,
    "name":"TURING",
    "store":3
    }
信息

该模式以牺牲不能把数据库中对应字段修改为null的功能为代价,换取绝对的保守和安全。尤其适合经验缺乏的客户端团队。

更高级别的配置

在上面的例子中,fixed, static, dynamicfuzzy这些关键字被用于修饰Input DTO的可空属性。

字段级别的控制是最细腻的。然而,如果Input DTO中的可空属性很多,一个一个配置可能比较麻烦。

Jimmer提供影响范围更大的配置方法

  • input类型级别

    dynamic input XxxInput {
    fixed nullableProp1
    static nullableProp2
    nullableProp3
    fuzzy nullableProp4
    nullableProp5
    }

    这里,并没有nullableProp3nullableProp5声明空处理模式,它们将共享input类型级别的配置 (对于本例而言,就是dynamic)

  • 预编译器级别

    如果在input类型级别也找不到配置,则参考预编译器 (对Java而言,就是APT;对于kotlin而言,就是KSP) 的全局配置参数jimmer.dto.defaultNullableInputModifier

    <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.13.0</version>
    <configuration>
    <compilerArgs>
    <arg>-Ajimmer.dto.defaultNullableInputModifier=fixed</arg>
    </compilerArgs>
    </configuration>
    </plugin>
  • 最终默认模式

    如果预编译器级别也没有配置,最终默认为static

信息

不同级别的配置可能冲突,彼此之间优先级如下:

input属性级配置 > input类型级配置 > 与编译器全局配置 > 最终默认static

注意事项

警告

对于fixeddynamic模式而言,Jimmer要求服务端的采用Jackson进行反序列化。

因此,如果你打算使用fixeddynamic模式,请

  • 添加@RequestBody

    如果你仔细看本文的例子,你会发现那里使用了@RequestBody

  • 请不要替换Spring Boot默认为你启用的Jackson Message Converter。

    事实上,不仅是本文讨论的使用了fixeddynamic模式的Input DTO会有此要求;如果用户需要使用Jimmer实体本身的序列化/反序列化,也需要使用Jackson。

    Jackson经过精心设计,在功能和性能之间找到了最完美的平衡点。因此,Jimmer将Jackson视为必备基础设施。