跳到主要内容

返回输出DTO

Java/Kotlin应用自己使用查询结果

上一篇文档中,我们介绍让Web服务无需定义DTO类型,直接返回实体,并辅以@FetchBy注解在自动生成的客户端代码中恢复所有DTO类型定义。

然而,如果某个查询的返回结果并不是为了返回给Web远程客户端,而是服务端自己用呢?

List<Book> books = bookRepository.findBooksByName(
"graphql",
Fetchers.BOOK_FETCHER
.name()
.edition()
);
for (Book book : books) {
System.out.println("--------");
System.out.println("Id: " + book.id());
System.out.println("Name: " + book.name());
System.out.println("Edition: " + book.edition());
System.out.println("Price:" + book.price());
}

这里,没有Web服务,没有远程调用,就是同一个JVM内部的调用

  • ❶处,只查询对象的三个属性:id (隐含)nameedition

  • ❷处,访问book对象的未被查询属性price

    这种错误访问会导致异常

    • 异常类型: org.babyfish.jimmer.UnloadedException

    • 异常消息:The property "com.yourcompany.yourproject.model.Book.price" is unloaded

可见,仅仅考虑在远程客户端Api中自动定义DTO类型是不够的。当JVM自身直接使用查询结果时,如果需要足够的编译时安全性,为Java/Kotlin定义DTO类型将不可避免,需要定义它们来保证更好的编译时安全。

DTO语言

实体对象和DTO对象之间的相互转化,是一件无聊、耗费体力且容易出错的事,是信息管理类软件开发中常见的痛点。尽管有很多框架都在试图缓解这个问题,但开发效率一直无法得到质的提高。

为了让DTO类型的制作成本尽可能低廉,Jimmer引入了DTO语言,该语言作为Java/Kotlin类型系统的补充,可以在编译时快速生成Java/Kotlin的DTO类型定义。

本文只做快速浏览,不做详细介绍,如果需要了解完整信息,请参见DTO语言

DTO语言插件

有Jimmer用户为DTO语言提供了Intellij插件,详情请见https://github.com/ClearPlume/jimmer-dto

安装DTO语言插件不是必须的,但是安装后可以获得更好的开发体验,推荐安装。

定义DTO文件

  1. 对于任何需要使用DTO语言的Java/Kotlin项目而言,在其src/main目录下新建一个子目录dto。即,src/main/dto是DTO文件存放的位置。

  2. src/main/dto目下新建一个Book.dto文件,输入如下代码

    export com.yourcompany.yourproject.model.Book
    -> package com.yourcompany.yourproject.model.dto

    SimpleBookView {
    id
    name
    }

    ComplexBookView {
    #allScalars(this)
    store {
    id
    name
    }
    authors {
    id
    firstName
    lastName
    }
    }
  3. 编译项目 (既可在命令行中使用gradle/maven命令,也可以在Intellij右侧点击gradle/maven的build),即可生成相关的DTO类型

查看生成的DTO

编译后,会自动生成如下两个类型SimpleBookViewComplexView,各自代码如下:

  • SimpleBookView

    SimpleBookView.java
    @GeneratedBy(
    file = "<yourproject>/src/main/dto/Book.dto"
    )
    public class SimpleBookView implements View<Book> {

    private long id;

    @NotNull
    private String name;

    public SimpleBookView(@NotNull Book base) {
    ......
    }

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

    ...省略getters和setters...

    ...省略hashCode/equals/toString...

    ...省略其他成员...
    }
    • ❶ 提醒用户,这是Jimmer自动生成的代码

    • 基于Book实体的Output DTO需实现View<Book>接口

    • ❸ 将实体转化为DTO

    • ❹ 将DTO转化为实体

  • ComplexBookView

    ComplexBookView.java
    @GeneratedBy(
    file = "<yourproject>/src/main/dto/Book.dto"
    )
    public class ComplexBookView implements View<Book> {

    private long id;

    @NotNull
    private String name;

    private int edition;

    @NotNull
    private BigDecimal price;

    @Nullable
    private TargetOf_store store;

    @NotNull
    private List<TargetOf_authors> authors;

    public ComplexBookView(@NotNull Book base) {
    ......
    }

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

    ...省略getters和setters...

    ...省略hashCode/equals/toString...

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

    public static class TargetOf_store implements View<BookStore> {

    private long id;

    @NotNull
    private String name;

    public TargetOf_store(@NotNull BookStore base) {
    ......
    }

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

    ...省略getters和setters...

    ...省略hashCode/equals/toString...

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

    public static class TargetOf_authors implements View<Author> {

    private long id;

    @NotNull
    private String firstName;

    @NotNull
    private String lastName;

    public TargetOf_authors(@NotNull Author base) {
    ......
    }

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

    ...省略getters和setters...

    ...省略hashCode/equals/toString...

    ...省略其他成员...
    }
    }
    • ❶ 提醒用户,这是Jimmer自动生成的代码

    • ❷ 基于Book实体的Output DTO需实现View<Book>接口

    • ❸ 将实体转化为DTO

    • ❹ 将DTO转化为实体

    • ❺ 多对一关联Book.store所引用的关联对象的DTO定义

    • ❻ 多对多关联Book.authors所引用的关联对象的DTO定义

新的BookRepository

重温旧的BookRepository

功能介绍一文中,我们编写了一个BookRepository

public class BookRepository {

@Nullable
public Book findBookById(
long id,
Fetcher<Book> fetcher
) {
......
}

public List<Book> findBooksByName(
@Nullable String name,
@Nullable Fetcher<Book> fetcher
) {
......
}

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

每个查询方法添加了一个类型为Fetcher<Book>的参数,我们可以通过它灵活控制被查询对象的格式 (即,被查询的数据结构的形状)

这是推荐的使用方式,Repository仅负责筛选、排序、分页等操作,但不控制返回数据的格式,而是通过Fetcher<E>参数将数据格式的控制权暴露出去,让更上层的业务逻辑来决定。

编写新BookRepository

现在,这个BookRepository不再符合我们的要求了,因为我们现在不想查询Jimmer实体,而是想查询由DTO语言自动生成的DTO类型,需要修改。

但是,我们希望BookRepository仍然保持形状控制权对外暴露的优秀品质,修改代码如下。

@Component
public class BookRepository {

private final JSqlClient sqlClient;

public BookRepository(JSqlClient sqlClient) {
this.sqlClient = sqlClient;
}

@Nullable
public <V extends View<Book>> V findBookById(
long id,
Class<V> viewType ❷
) {
return sqlClient.findById(
viewType,
id
);
}

public <V extends View<Book>> List<V> findBooksByName(
@Nullable String name,
Class<V> viewType ❺
) {
BookTable table = Tables.BOOK_TABLE;
return sqlClient
.createQuery(table)
.whereIf(
name != null && !name.isEmpty(),
table.name().ilike(name)
)
.select(
table.fetch(viewType)
)
.execute();
}
}
  • ❶ ❹: Java的<V extends View<Book>>或kotlin的<V: View<Book>>定义一个范型参数V,表示任何由Book衍生而来的Output DTO类型。

    比如:上文中自动生成的SimpleBookViewComplexBookView,它们都实现了View<Book>接口。

  • ❷ ❺: 用任何一个由Book衍生而来的DTO的类型作为参数。

    返回类型随着参数类型的变化而变化,实现任意DTO类型的查询,将DTO类型的决定权交给更上层的调用者。

  • ❸ ❻: 让Jimmer查询指定类型的数据

    提示

    DTO类型内部已经包括了与形状之匹配的Fetcher,先通过此Fetcher查询出形状匹配的实体数据结构,再自动转化为DTO类型。

试用新的BookRepository

bookRepository.findById为例

  • 查询相对简单的SimpleBookView

    System.out.println(
    bookRepository.findBookById(
    1L,
    SimpleBookView.class
    )
    );

    打印输出

    SimpleBookView(
    id=1,
    name=Learning GraphQL
    )
  • 查询相对复杂的ComplexBookView

    System.out.println(
    bookRepository.findBookById(
    1L,
    ComplexBookView.class
    )
    );

    打印输出

    ComplexBookView(
    id=1,
    name=Learning GraphQL,
    edition=1,
    price=50.0,
    store=ComplexBookView.TargetOf_store(
    id=1,
    name=O'REILLY
    ),
    authors=[
    ComplexBookView.TargetOf_authors(
    id=1,
    firstName=Eve,
    lastName=Procello
    ),
    ComplexBookView.TargetOf_authors(
    id=2,
    firstName=Alex,
    lastName=Banks
    )
    ]
    )

编写BookController

虽然DTO语言更适合于Java/Kotlin应用内部自己使用查询结果,但你也可以用它们作为HTTP API的返回信息,和使用普通的POJO没有任何区别。

BookController.java
@RestController
public class BookController implements Fetchers {

private final BookRepository bookRepository;

public BookController(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}

@Nullable
@GetMapping("/book/{id}")
public ComplexBookView findBookById(@PathVariable("id") long id) {
return bookRepository.findBookById(
id,
ComplexBookView.class
);
}

@GetMapping("/books")
public List<SimpleBookView> findBooksByName(
@RequestParam(name = "name", required = false) String name
) {
return bookRepository.findBooksByName(
name,
SimpleBookView.class
);
}
}

文档注释

上一篇文章中,我们提及了Jimmer能把Java/Kotlin代码中的文档注释复制到客户端Api中,无论是OpenApi在线文档,还是生成TypeScript代码。

本文介绍的这种方式具有相同的功能,但是需要说明一点,DTO语言中的类型和属性和Java/Kotlin类型一样支持文档注释,因此DTO语言可以覆盖Java/Kotlin的文档注释。例如,原始实体定义如下

/**
* The book entity
*/
@Entity
public interface Book {

/**
* The name of book entity
*/
String name();

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

这里的文档注释就是原始的文档注释

DTO语言也支持文档注释,例如

Book.Dto
export com.yourcompany.yourproject.model.Book
-> package com.yourcompany.yourproject.model.dto

/**
* Simple book dto
*/
SimpleBookView {

/**
* The name of simple book dto
*/
name

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

...省略其他DTO类型定义...
信息

DTO语言中的文档注释具有更高的优先级。

即,DTO语言中的文档注释能覆盖原始实体中的文档注释,是Jimmer自动生成OpenApi文档或TypeScript代码时优先参考的。

Flat关联ID

如果关联对象只有id属性,那么关联Id会比关联对象更好用,例如

  • 使用关联对象,会导致大量的只有id属性的对象,结果稍显冗余

    {
    "id" : 1,
    "name" : "Learning GraphQL",
    "edition" : 1,
    "price" : 50.00,
    "store" : {
    "id" : 1
    },
    "authors" : [{
    "id" : 1
    }, {
    "id" : 2
    }]
    }
  • 使用关联Id,结果相对简练

    {
    "id" : 1,
    "name" : "Learning GraphQL",
    "edition" : 1,
    "price" : 50.00,
    "storeId" : 1,
    "authorIds" : [1, 2]
    }

如果选择返回DTO (而非上一篇文章中的直接返回实体),则定义如下DTO代码即可

export com.yourcompany.yourproject.model.Book
-> package com.yourcompany.yourproject.model.dto

ShallowBookView {
#allScalars(this)
id(store)
id(authors) as authorIds
}

...省略其他DTO定义...

编译后,生成如下代码

ShallowBookView.java
@GeneratedBy(
file = "<yourproject>/src/main/dto/Book.dto"
)
public class ShallowBookView implements View<Book> {

private long id;

@NotNull
private String name;

private int edition;

@NotNull
private BigDecimal price;

@Nullable
private Long storeId;

@NotNull
private List<Long> authorIds;

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

Flat关联对象

很大一部分服务端开发团队,会接触到一种前端开发团队,他们不接受由关联连接多个对象而成的数据结构,只愿意接受一个庞大的孤单对象。因此他们要求讲所有非集合关联都平坦化。即

  • 他们不接受结构化的返回信息

    {
    "prop1": 1,
    "prop2": 2,
    "a": {
    "prop1": 3,
    "prop2": 4,
    "b": {
    "prop1": 5,
    "prop2": 6,
    }
    },
    "c": {
    "prop1": 7,
    "prop2": 8,
    "d": {
    "prop1": 9,
    "prop2": 10,
    }
    }
    }
  • 坚持索要这样的扁平数据

    {
    "prop1": 1,
    "prop2": 2,
    "aProp1": 3,
    "aProp2": 4,
    "aBProp1": 5,
    "abProp2": 6,
    "cProp1": 7,
    "cProp2": 8,
    "cdProp1": 9,
    "cdProp2": 10
    }

其实这种扁平的非结构化数据对与需要状态管理的客户端程序而言是一场灾难,但是这类前端团队只做UI渲染不做状态管理,所以意识不到这个问题,并对此非常坚持。

当争论不过但需要快速完成任务时,如此编写DTO代码即可

Book.dto
export com.yourcompany.yourproject.model.Book
-> package com.yourcompany.yourproject.model.dto

FlatBookView {
#allScalars(this)
flat(store) { ❶
as(^ -> store) { ❷
#allScalar(this)
}
}
}

...省略其他DTO定义...
  • flat函数表示将多对一关联Book.store所指的关联对象的属性平铺到当前对象。

  • ❷ 对于关联对象的属性,平铺到当前对象后,其属性名需要变更,在旧的属性名前加上store前缀。例如,name -> storeName

编译后,生成如下代码

FlatBookView.java
@GeneratedBy(
file = "<yourproject>/src/main/dto/Book.dto"
)
public class FlatBookView implements View<Book> {

private long id;

@NotNull
private String name;

private int edition;

@NotNull
private BigDecimal price;

@Nullable
private Long storeId;

@Nullable
private String storeName;

@Nullable
private String storeWebsite;

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

这里,平铺后的属性全部可null,因为Book.store关联本身允许为null。