生成客户端API
启用客户端能力
默认情况下,自动生成客户端的能力是关闭的。要启用这种个功能,有两种选择
-
采用
@EnableImplicitApi修饰项目中任何一个类。对于Spring Boot应用而言,Application是一个不错的选择。由于过于简单,无需示范。
-
为每一个Controller和内部的HTTP放方法加上@Api
- Java
- Kotlin
HelloWorldController.java@Api
@RestController
public class HelloWorldController {
@Api
@GetMapping("/helloworld")
public String helloworld() {
return "hello world"
}
}HelloWorldController.kt@Api
@RestController
class HelloWorldController {
@Api
@GetMapping("/helloworld")
fun helloworld() = "hello world"
}
为什么要如此设计呢?让我们来看一个Controller
- Java
- Kotlin
@RestController
public class XController {
@GetMapping("/clientFriendlyData")
SomePojo clientFriendlyData() { ❶
...略...
}
@GetMapping("/clientUnfriendlyData")
Object clientUnfriendlyData() { ❷
...略...
}
}
@RestController
class XController() {
@GetMapping("/clientFriendlyData")
fun clientFriendlyData(): SomePojo = ❶
...略..
@GetMapping("/clientUnfriendlyData")
fun clientUnfriendlyData(): Any = ❷
...略...
}
-
❶ 精确的Api定义,对客户端友好
-
❷ 非常模糊的Api定义,对客户端不友好,甚至可以说是远程API的不良设计。
当然,导致客户端不友好的原因很多,这里只是列举一种最简单的案例。
如果要求Jimmer为客户端不友好Api生成客户端代码,将会导致编译错误。所以,我们需要有选择性地对一部分Api生成客户端代码,而非盲目地处理所有Api。
-
如果大部分Api都是客户端不友好的,只有个别Api才是友好的 (这种项目处理的大部分信息都非结构化,结构化Api很少),建议选择显式地为Controller类和HTTP方法添加@Api注解。由于这种做法已经示范过,不再重复。
-
如果大部分Api都是客户端友好的,只有个别Api才是不友好的,推荐
-
先用
@EnableImplicitApi修饰任何一个类,比如SpringBoot的主类。由于过于简单,不必示范。 -
再用
@ApiIgnore修饰无法支持的类或方法,比如- Java
- Kotlin
XController.java@RestController
public class XController {
@GetMapping("/clientFriendlyData")
SomePojo clientFriendlyData() {
...略...
}
@ApiIgnore
@GetMapping("/clientUnfriendlyData")
Object clientUnfriendlyData() {
...略...
}
}XController.kt@RestController
class XController() {
@GetMapping("/clientFriendlyData")
fun clientFriendlyData(): SomePojo =
...略..
@ApiIgnore
@GetMapping("/clientUnfriendlyData")
fun clientUnfriendlyData(): Any =
...略...
}
-
@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("...") BookList<@FetchBy("...") Book>Page<@FetchBy("...") Book>Tuple2<@FetchBy("...") BookStore, @FetchBy("...") Author>Map<String, Map<String, @FetchBy("...") Book>>
- Java
- Kotlin
@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()
);
@GetMapping("/books")
fun findBooks(
@RequestParam(defaultValue = "0") pageIndex: Int,
@RequestParam(defaultValue = "5") pageSize: Int,
@RequestParam(defaultValue = "name asc, edition desc") sortCode: String
): Page<
@FetchBy("SIMPLE_BOOK") Book ❶
> =
bookRepository.findBooks(
PageRequest.of(pageIndex, pageSize, SortUtils.toSort(sortCode)),
name,
storeName,
authorName,
SIMPLE_BOOK ❷
)
@GetMapping("/book/{id}")
fun findBookById(
@PathVariable id: Long,
): @FetchBy("COMPLEX_BOOK") Book? = ❸
bookRepository.findNullable(
id,
COMPLEX_BOOK ❹
)
companion object {
private val SIMPLE_BOOK = ❺
newFetcher(Book::class).by {
name()
}
private val COMPLEX_BOOK = ❻
newFetcher(Book::class).by {
allScalarFields()
store {
name()
}
authors {
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
- Java
- Kotlin
@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) {
...略...
}
}
@RestController
@DefaultFetcherOwner(FetcherConstants.class)
class BookController {
fun getSimpleBooks(...略...): List<@FetchBy("SIMPLE_BOOK") Book> =
...略...
fun getDefaultBooks(...略...): List<@FetchBy("DEFAULT_BOOK") Book> =
...略...
fun findComplexBookById(long id): @FetchBy("COMPLEX_BOOK") Book? =
...略...
}
在类级别使用@DefaultFetcherOwner可以一次性调整所有@FetchBy的ownerType属性,不必为每个@FetchBy配置ownerType了。
查看Api文档
为了识别@FetchBy等Jimmer特有的注解,Jimmer对OpenAPI/Swagger给予了一套极具特色的实现。
无需使用JVM生态中任何其他关于自动生成OpenAPI/Swagger的框架,只需对application.yml*(或application.properties)*进行修改如下即可
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,则可见

-
展开
/books,可以看到返回的集合中,每一个元素都是一个相对简单的DTO对象 -
展开
/books/{id},可以看到返回类型是一个相对复杂的DTO类型
开发Web客户端
生成TypeScript代码
可以在application.yml或application.properties中声明如下配置,用于下载相关的客户端代码
jimmer:
...省略其他配置...
client:
ts:
path: /ts.zip ❶
目前,Jimmer支持生成两种客户端代码,TypeScript和Spring Cloud所需的Java Feign Client代码
-
❶ 可通过 http://localhost:8080/ts.zip 下载Web客户端所需的TypeScript代码
-
❷ 可通过 http://localhost:8080/java-feign.zip 下载Spring Cloud所需的Java Feign Client代码
接下来,我们讨论TypeScript代码。
启动服务,下载http://localhost:8080/ts.zip,解压缩。设解压缩后的根目录为`${ts_root}`:
让我们先看${ts_root}/model/dto/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代码
-
创建React项目
首先创建一个基于typescript的react项目
yarn create react-app my-web-app --template typescript -
自动生成客户端代码
很显然,不可能在每次服务端发生变化的时候,都要求客户端开发人员都需要手动下载服务端代码,解压,并替换本地代码。
所以,我们需要编写一个小脚本,自动完成最新TypeScript代码的下载、解压和替换。
在项目根目录下添加文件夹
scripts,在其下添加文件generate-api.js,该文件由nodejs执行,是开发工具的代码,不是客户端本身的代码。scripts/generate-api.jsconst 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拉取最新代码即可。