跳到主要内容

生成客户端API

启用客户端能力

默认情况下,自动生成客户端的能力是关闭的。要启用这种个功能,有两种选择

  1. 采用@EnableImplicitApi修饰项目中任何一个类。

    对于Spring Boot应用而言,Application是一个不错的选择。由于过于简单,无需示范。

  2. 为每一个Controller和内部的HTTP放方法加上@Api

    HelloWorldController.java
    @Api
    @RestController
    public class HelloWorldController {

    @Api
    @GetMapping("/helloworld")
    public String helloworld() {
    return "hello world"
    }
    }

为什么要如此设计呢?让我们来看一个Controller

XController.java
@RestController
public class XController {

@GetMapping("/clientFriendlyData")
SomePojo clientFriendlyData() {
......
}

@GetMapping("/clientUnfriendlyData")
Object clientUnfriendlyData() {
......
}
}
  • ❶ 精确的Api定义,对客户端友好

  • ❷ 非常模糊的Api定义,对客户端不友好,甚至可以说是远程API的不良设计。

当然,导致客户端不友好的原因很多,这里只是列举一种最简单的案例。

如果要求Jimmer为客户端不友好Api生成客户端代码,将会导致编译错误。所以,我们需要有选择性地对一部分Api生成客户端代码,而非盲目地处理所有Api。

  • 如果大部分Api都是客户端不友好的,只有个别Api才是友好的 (这种项目处理的大部分信息都非结构化,结构化Api很少),建议选择显式地为Controller类和HTTP方法添加@Api注解。由于这种做法已经示范过,不再重复。

  • 如果大部分Api都是客户端友好的,只有个别Api才是不友好的,推荐

    1. 先用@EnableImplicitApi修饰任何一个类,比如SpringBoot的主类。由于过于简单,不必示范。

    2. 再用@ApiIgnore修饰无法支持的类或方法,比如

      XController.java
      @RestController
      public class XController {

      @GetMapping("/clientFriendlyData")
      SomePojo clientFriendlyData() {
      ......
      }

      @ApiIgnore
      @GetMapping("/clientUnfriendlyData")
      Object clientUnfriendlyData() {
      ......
      }
      }
提示

@ApiIgnore还有另外一个重要作用,比如Spring安全相关的编程中,Java/Kotlin方法经常通过参数注入一些安全上下文相关的东西,比如javax.security.Principal类型的参数,这类参数只是spring运行所需,并非Api契约的一部分,可以为这类参数添加@ApiIgnore

开发Web服务

声明@FetchBy

前面讨论了,使用Jimmer构建REST服务并由服务端罗列客户端所需对象的所有形状是本文要讨论的话题。

要使用这种开发方式,需要在REST API中使用注解@org.babyfish.jimmer.client.FetchBy修饰返回类型中的动态实体类型,为客户端标注动态对象的具体形状。

提示

@FetchBy并不是简单地修饰REST API的返回值,而是用于修饰类型引用,其声明代码如下

package org.babyfish.jimmer.client;

import java.lang.annotation.*;

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE_USE)
public @interface FetchBy {

......
}

因此,REST API的返回类型非常灵活,你可以在任何地方 (包括范型参数) 使用它修饰Jimmer实体类型,例如

  • @FetchBy("...") Book
  • List<@FetchBy("...") Book>
  • Page<@FetchBy("...") Book>
  • Tuple2<@FetchBy("...") BookStore, @FetchBy("...") Author>
  • Map<String, Map<String, @FetchBy("...") Book>>
BookController.java
@GetMapping("/books")
public Page<
@FetchBy("SIMPLE_BOOK") Book
> findBookById(
@RequestParam(defaultValue = "0") int pageIndex,
@RequestParam(defaultValue = "5") int pageSize,
@RequestParam(defaultValue = "name asc, edition desc") String sortCode
) {
return bookRepository.findBooks(
PageRequest.of(pageIndex, pageSize, SortUtils.toSort(sortCode)),
SIMPLE_BOOK
);
}

@GetMapping("book/{id}")
@Nullable
public
@FetchBy("COMPLEX_BOOK") Book
findComplexBook(
@PathVariable("id") long id
) {
return bookRepository.findNullable(
id,
COMPLEX_BOOK
);
}

private static final Fetcher<Book> SIMPLE_BOOK =
Fetchers.BOOK_BOOK
.name();

private static final Fetcher<Book> COMPLEX_BOOK =
Fetchers.BOOK_BOOK
.allScalarFields()
.store(
Fetchers.BOOK_STORE_BOOK
.name()
)
.authors(
Fetchers.AUTHOR_BOOK
.firstName()
.lastName()
);
  • ❶ 对外承诺,GET /books返回的分页对象中的每一个Book对象的形状为静态常量SIMPLE_BOOK所表达的形状

  • ❷ 内部实现,GET /books内部使用静态常量SIMPLE_BOOK查询数据

    警告

    作为对外承诺的❶和作为内部实现的❷必须一致

  • ❸ 对外承诺,如果GET /book/{id}返回非null, 其形状为静态常量COMPLEX_BOOK所表达的形状

  • ❹ 内部实现,GET /book/{id}内部使用静态常量COMPLEX_BOOK查询数据

    警告

    作为对外承诺的❸和作为内部实现的❹必须一致

  • ❺和❻,以静态常量的方式声明对象的形状。

通过@FetchBy的修饰,Jimmer就明白每个对象对外返回的数据的具体形状了,它就可以为客户端生成代码了,包括TypeScript。

@DefaultFetcherOwner

在上个例子中,使用注解@FetchBy的类和为各种形状声明Fetcher类型静态常量的类是同一个类 (BookController)

若非如此,需要为@FetchBy注解指定ownerType参数,例如

@FetchBy(value = "COMPLEX_BOOK", ownerType = FetcherConstants.class)

然而,为每个@FetchBy都配置ownerType比较繁琐,因此Jimmer支持@DefaultFetcherOwner

BookController
@RestController
@DefaultFetcherOwner(FetcherConstants.class)
public class BookController {

public List<@FetchBy("SIMPLE_BOOK") Book> getSimpleBooks(......) {
......
}

public List<@FetchBy("DEFAULT_BOOK") Book> getDefaultBooks(......) {
......
}

@Nullable
public @FetchBy("COMPLEX_BOOK") Book findComplexBookById(long id) {
......
}
}

在类级别使用@DefaultFetcherOwner可以一次性调整所有@FetchByownerType属性,不必为每个@FetchBy配置ownerType了。

查看Api文档

为了识别@FetchBy等Jimmer特有的注解,Jimmer对OpenAPI/Swagger给予了一套极具特色的实现。

无需使用JVM生态中任何其他关于自动生成OpenAPI/Swagger的框架,只需对application.yml*(或application.properties)*进行修改如下即可

application.yml
jimmer:
...省略其他配置...
client:
openapi:
path: /openapi.yml
ui-path: /openapi.html
properties:
info:
title: My Web Service
description: |
Restore the DTO explosion that was
eliminated by server-side developers
version: 1.0

启动Web项目,使用浏览器访问其/openapi.html,则可见

openapi

  • 展开/books,可以看到返回的集合中,每一个元素都是一个相对简单的DTO对象

    openapi-simple

  • 展开/books/{id},可以看到返回类型是一个相对复杂的DTO类型

    openapi-complex

开发Web客户端

生成TypeScript代码

可以在application.ymlapplication.properties中声明如下配置,用于下载相关的客户端代码

jimmer:
...省略其他配置...
client:
ts:
path: /ts.zip ❶

目前,Jimmer支持生成两种客户端代码,TypeScript和Spring Cloud所需的Java Feign Client代码

接下来,我们讨论TypeScript代码。

启动服务,下载http://localhost:8080/ts.zip,解压缩。设解压缩后的根目录为`${ts_root}`:

让我们先看${ts_root}/model/dto/BookDto.ts

BookDto.ts
export type BookDto = {
'BookController/SIMPLE_BOOK': {
readonly id: number,
readonly name: string
},
'BookController/COMPLEX_BOOK': {
readonly id: number,
readonly name: string,
readonly edition: number,
readonly price: number,
readonly store?: {
readonly id: number,
readonly name: string
},
readonly authors: ReadonlyArray<{
readonly id: number,
readonly firstName: string,
readonly lastName: string
}>
}
}
信息

很明显,在服务端被消灭掉的DTO爆炸,在客户端被恢复了。

让我们再看看${ts_root}/services/BookService.ts

import type { BookDto } from '../model/dto';
import type { Page } from '../model/static';

export class BookService {

async findBooks(
options: BookServiceOptions['findBooks']
): Promise<
Page<
BookDto['BookService/SIMPLE_BOOK']
>
> {
...省略代码...
}

async findBookById(
options: BookServiceOptions['findBookById']
): Promise<
BookDto['BookService/COMPLEX_BOOK'] |
undefined
> {
...省略代码...
}

...省略其他代码...
}

export type BookServiceOptions = {
'findBooks': {
readonly pageIndex: number,
readonly pageSize: number,
readonly sortCode: string
},
'findBookById': {
readonly id: number
}
}
信息

很明显,每个业务场景的返回类型都得到了精确的定义。

使用生成的TypeScript代码

  1. 创建React项目

    首先创建一个基于typescript的react项目

    yarn create react-app my-web-app --template typescript
  2. 自动生成客户端代码

    很显然,不可能在每次服务端发生变化的时候,都要求客户端开发人员都需要手动下载服务端代码,解压,并替换本地代码。

    所以,我们需要编写一个小脚本,自动完成最新TypeScript代码的下载、解压和替换。

    在项目根目录下添加文件夹scripts,在其下添加文件generate-api.js,该文件由nodejs执行,是开发工具的代码,不是客户端本身的代码。

    scripts/generate-api.js
    const http = require('http'); 
    const fs = require('fs');
    const fse = require('fs-extra');
    const uuid = require('uuid');
    const tempDir = require('temp-dir');
    const AdmZip = require('adm-zip');

    const sourceUrl = "http://localhost:8080/ts.zip";
    const tmpFilePath = tempDir + "/" + uuid.v4() + ".zip";
    const generatePath = "src/__generated";

    console.log("Downloading " + sourceUrl + "...");

    const tmpFile = fs.createWriteStream(tmpFilePath);

    const request = http.get(sourceUrl, (response) => {
    response.pipe(tmpFile);
    tmpFile.on("finish", () => {
    tmpFile.close();
    console.log("File save success: ", tmpFilePath);

    // Remove generatePath if it exists
    if (fs.existsSync(generatePath)) {
    console.log("Removing existing generatePath...");
    fse.removeSync(generatePath);
    console.log("Existing generatePath removed.");
    }

    // Unzip the file using adm-zip
    console.log("Unzipping the file...");
    const zip = new AdmZip(tmpFilePath);
    zip.extractAllTo(generatePath, true);
    console.log("File unzipped successfully.");

    // Remove the temporary file
    console.log("Removing temporary file...");
    fs.unlink(tmpFilePath, (err) => {
    if (err) {
    console.error("Error while removing temporary file:", err);
    } else {
    console.log("Temporary file removed.");
    }
    });
    });
    });

    其中,adm-zip需要单独安装

    yarn add adm-zip --dev

    修改项目的package.json,在其"scripts"字段下添加如下代码

    {
    ...省略其他代码...
    "scripts": {
    ...省略其他代码...
    "api": "node scripts/generate-api.js"
    }
    ...省略其他代码...
    }

    这样,每次服务端团队通知REST API发生变化时,都可以简单地执行yarn api刷新本地的TypeScript客户端代码

    警告

    这个方法仅仅使用规模很少的前端团队,如果前端对象人数较多,更推荐的做法是对CI环境实施二次开发,实现如下功能:

    每次服务端特定分支代码被提交后,由CI环境编译并启动后端服务,然后,下载ts代码,解压,并提交到git中。最后,前端工程师统一从git拉取最新代码即可。

  3. 创建全局API对象

    生成的TypeScript代码中,有一个__generated/Api.ts文件,需要用该类实例化一个全局变量并完成必要的配置。

    src下创建ApiInstance.ts,定义并导出全部变量api

    src/ApiInstance.ts
    import { Api } from "../__generated";

    const BASE_URL = "http://localhost:8080";

    // 导出全局变量`api`
    export const api = new Api(async({uri, method, headers, body}) => {
    const tenant = (window as any).__tenant as string | undefined;
    const response = await fetch(`${BASE_URL}${uri}`, {
    method,
    body: body !== undefined ? JSON.stringify(body) : undefined,
    headers: {
    'content-type': 'application/json;charset=UTF-8',
    ...headers,
    ...(tenant !== undefined && tenant !== "" ? {tenant} : {})
    }
    });
    if (response.status !== 200) {
    throw response.json();
    }
    const text = await response.text();
    if (text.length === 0) {
    return null;
    }
    return JSON.parse(text);
    });
  4. 调用REST API

    现在,我们就可以基于全局变量api调用REST API了。

    信息

    下面的例子,基于use-immerTanStack/Query

    熟练使用或快熟掌握各种远程请求库,是web前端工程师的基本素养,所以,这里不再细致交代。

    • 体验api.bookService.findBooks

      const [options, setOptions] = useImmer<
      // RequestOf是Jimmer生成的TypeScript辅助类,
      // 用于提取任何REST API的参数类型
      RequestOf<
      typeof api.bookService.findBooks
      >
      >(() => {
      return {
      pageIndex: 0,
      pageSize: 10,
      sortCode: "name asc"
      };
      });

      const {
      isLoading,
      // 如果`data`非`undefined`, 则其类型必为
      // `Page<BookDto['BookService/SIMPLE_BOOK']>`
      data,
      error,
      refetch
      } = useQuery({
      queryKey: ["Books", options],
      // `data`的类型由此决定
      queryFn: () => api.bookService.findBooks(options)
      });

      如果请求成功,data的类型为Page<BookDto['BookService/SIMPLE_BOOK']>

      其中, BookDto['BookService/SIMPLE_BOOK']的定义为

      {
      readonly id: number,
      readonly name: string,
      readonly edition: number,
      readonly price: number,
      }
    • 体验api.bookService.findBookById

      在下面的代码中,假设id为当前React主键的参数

      const { 
      // 如果`data`非`undefined`, 则其类型必为
      // `BookDto['BookService/COMPLEX_BOOK']`
      data,
      isLoading,
      error
      } = useQuery({
      queryKey: ["book/detail", id],
      queryFn: () => api.bookService.findBookById({id: id!}),
      enabled: id !== undefined
      });

      如果请求成功且data非null, 其类型为BookDto['BookService/COMPLEX_BOOK']。该类型定义如下

      {
      readonly id: number,
      readonly name: string,
      readonly edition: number,
      readonly price: number,
      readonly store?: {
      readonly id: number,
      readonly name: string,
      readonly website?: string,
      readonly avgPrice: number
      },
      readonly authors: ReadonlyArray<{
      readonly id: number,
      readonly firstName: string,
      readonly lastName: string,
      readonly gender: Gender
      }>
      }
    提示

    可见,任何REST API调用都会返回严格的数据类型定义,这些严格的类型定义也会影响tsx文件中React UI模板代码。

    这会充分发挥TypeScript的优势,让前端项目具备良好的IDE智能提示,并保证在编译时发现所有问题,具备良好的开发体验。

和自定义数据结合

虽然@FetchBy和Jimmer动态实体相结合能够在客户端代码中还原DTO类型定义,但仍然需要认真考虑一种情况:返回的数据类型和底层实体模型差异很大。例如

ActiveAuthorInfo.java
@lombok.Data
public class ActiveAuthorInfo {

private Author raw;

private List<BookStore> stores;
}

在这个例子中,ActiveAuthorInfo表示活跃度很高的作者,里面包含了作者的原始信息raw,以及所有售卖他/她的书籍的书店的集合。

对应的HTTP服务接口为

AuthorController.java
@GetMapping("/authors/mostActive")
public List<ActiveAuthorInfo> findMostActiveAuthorInfos(
@RequestParam(defaultValue = "10") int limit
) {
......
}

很明显,这个数据结构和底层实体模型差异较大。在实体模型中,BookStoreBook之间存在关联,BookAuthor之间也存在关联,但是,BookStoreAuthor之间并不存在关联。

提示

某些情况下,客户端需要的数据可能包含多种实体对象,它们之间并不存在直接的ORM关联,只是特定业务层面的一种的联系。

如果这种特定业务层面的联系毫无通用性,那么为实体定义复杂计算属性也并非一个好的选择。

这时,我们可以打破实体对象图的定式思维,用自定义数据表示返回结果,就如同这里的ActiveAuthorInfo

然而,ActiveAuthorInfo并不是纯粹的用户自定义数据类型,其内部混合使用了Jimmer实体。我们不妨称之为为混合类型。

可以用@FetchBy注解修饰这种混合类型的字段,比如

ActiveAuthorInfo.java
@lombok.Data
public class ActiveAuthorInfo {

private @FetchBy("AUTHOR_BOOK") Author raw;

private List<@FetchBy("STORE_BOOK") BookStore> stores;

private static final Fetcher<Author> AUTHOR_BOOK =
Fetchers.AUTHOR_BOOK
.firstName()
.lastName();

private static final Fetcher<BookStore> STORE_BOOK =
Fetchers.AUTHOR_BOOK.name();
}

最终,ActiveAuthorInfo所生成的TypeScript相关类型如下 (为了方便,这里混合了多个TypeScript文件的代码)

export interface ActiveAuthorInfo {

readonly raw: AuthorDto['ActiveAuthorInfo/AUTHOR_BOOK'];

readonly stores: ReadonlyArray<
BookStoreDto['ActiveAuthorInfo/STORE_BOOK']
>;
}

export type AuthorDto {
'ActiveAuthorInfo/AUTHOR_BOOK': {
readonly id: number,
readonly firstName: string,
readonly lastName: string
},
...省略其他DTO类型定义...
}

export type BookStoreDto {
'ActiveAuthorInfo/STORE_BOOK': {
readonly id: number,
readonly name: string
},
...省略其他DTO类型定义...
}

Api分组

有两种方式分组方式

  • Controller级别

    @Api("pc")
    @RestController
    public class BookPcController {......}

    @Api("mobile")
    @RestController
    public class BookMobileController {......}
  • Api级别

    BookController.java
    @RestController
    public class BookController {

    @Api("pc")
    @GetMapping("/pc/books")
    public List<@FetchBy("BOOK_FOR_PC")> findPcBooks(......) {
    ......
    }

    @Api("mobile")
    @GetMapping("/mobile/books")
    public List<@FetchBy("BOOK_FOR_MOBILE")> findMobileBooks(......) {
    ......
    }
    }
提示

@Api可以接受多个组名,比如@Api({"group1", "group2", "groupN"})。为了简化问题,上例未示范

警告

类级别和方法级别的分组可以混用,此时,方法级的任何组名都必须是类级组名之一,否则无法编译。

查看/下载客户端的URL如下:

现在,可以通过如下方法只查看/下载PC相关的客户端

同理,也可以只查看/下载Mobile相关的客户端

也可以附加多个参数 (当然,对于本例而言,这样做和不指定参数效果一样)