API 接口设计

作为长期使用的维护的远程 API 接口, 大部分都是用类似于 /v1/user/login 之类做版本控制管理;
但是实际上其实内部问题也很多, 比如长期运行的路由表堆积问题, 并且基于 Path 匹配导致没办法做到无感知升级.

之后接口设计我更加推崇的是 Header 版本字段控制, 请求时附加以下 Header 字段(以下字段都可以自定义, 最好做成动态配置):

  • X-Version: 请求的版本字段

  • X-Sign: 请求的字段签名

  • X-Authorization: 请求的授权 Token

  • X-App-Id: 可选配置, 如果采用多应用管理才需要, 一般单应用接口不需要用到

然后后续都是采用统一的请求接口 /user/login 之类, 而内部就是直接通过 Header 相关参数来调配转发到对应版本.

这里还是用 Quarkus 来做接口请求

按照常规的接口配置之后就编写控制器类来做接收请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

import java.util.Collections;
import java.util.Map;
import java.util.Objects;

@Path("/user/login")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class UserLoginController {


/**
* 加载 header 列表
*/
@Context
HttpHeaders headers;

/**
* 简单的响应结构, 后续提升到到全局, 目前仅仅作为示例放置于此
*/
public record ApiResponse<T>(
String version,

int status,
long time,
String message,
T data
) {

/**
* 减少多余参数的匹配构造方法
*/
public ApiResponse(String version, int status, String message, T data) {
this(version, status, System.currentTimeMillis(), message, data);
}

/**
* 未知接口请求响应
*/
public static ApiResponse<Map<String, Object>> unknown() {
// 下面可以直接构建的静态初始化
Response.Status status = Response.Status.PRECONDITION_FAILED;
String reason = status.getReasonPhrase().toUpperCase(); // 将 Precondition Failed 切换成大写
reason = reason.replace(" ", "_"); // 空格以下划线分割

// 构建未知响应
return new ApiResponse<>(
"unknown",
status.getStatusCode(),
reason,
Collections.emptyMap()
);
}

/**
* 接口成功响应
*/
public static <T> ApiResponse<T> success(String version, T data) {
Response.Status status = Response.Status.OK;
return new ApiResponse<>(
version,
status.getStatusCode(),
status.getReasonPhrase().toUpperCase(),
data
);
}
}


/**
* 采用默认的 POST 请求 route 路径方法
* <p>
* 注意: 如果没有 Header 参数推荐采用 412 Precondition Failed 异常, 代表请求条件不成立
*
* @return Response
*/
@POST
public Response route() {
// 版本分析
String version = headers.getHeaderString("X-Version");
if (Objects.isNull(version) || version.isBlank()) {
return Response
.status(Response.Status.PRECONDITION_FAILED)
.entity(ApiResponse.unknown())
.build();
}

// 签名分析
String sign = headers.getHeaderString("X-Sign");
if (Objects.isNull(sign) || sign.isBlank()) {
return Response
.status(Response.Status.PRECONDITION_FAILED)
.entity(ApiResponse.unknown())
.build();
}

// 授权分析
String authorization = headers.getHeaderString("X-Authorization");
if (Objects.isNull(authorization) || authorization.isBlank()) {
return Response
.status(Response.Status.PRECONDITION_FAILED)
.entity(ApiResponse.unknown())
.build();
}


// 最后返回响应结果
return Response
.ok()
.entity(ApiResponse.success(version, Collections.emptyMap()))
.build();
}
}

可以看到只需要单个 /user/login 请求路由, 要求 header 必须要提供版本和字段签名等授权信息,
可以考虑将 header 字段提升为系统配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;

/**
* 加载配置表之中的关于 header 的默认名称
*/
@ConfigMapping(prefix = "api.headers")
public interface HeaderNames {

/**
* 请求接口版本的名称
*/
@WithDefault("X-Version")
String version();

/**
* 字段签名的名称
*/
@WithDefault("X-Sign")
String sign();

/**
* 授权 Token 的名称
*/
@WithDefault("X-Authorization")
String authorization();
}

定义之后就可以直接通过 @Inject 挂载全局配置来加载, 并且实现 application.properties 达成动态修改的需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 其他略
public class UserLoginController {
/**
* 加载 header 列表
*/
@Context
HttpHeaders headers;

/**
* 加载配置的 header 名称列表
*/
@Inject
HeaderNames headerNames;

/**
* 采用默认的 POST 请求 route 路径方法
* <p>
* 注意: 如果没有 Header 参数推荐采用 412 Precondition Failed 异常, 代表请求条件不成立
*
* @return Response
*/
@POST
public Response route() {
// 省略部分代码
String version = headers.getHeaderString(headerNames.version());
String sign = headers.getHeaderString(headerNames.sign());
String authorization = headers.getHeaderString(headerNames.authorization());
}
}

可以通过在 application.properties 随意声明 api.headers.{version,sign,authorization} 来改变字段名称.

版本约束服务

后面就是需要利用 Java 的接口功能来做版本约束, 让开发者自己去做版本派生实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import jakarta.ws.rs.core.Response;

import java.util.Collections;
import java.util.Map;

/**
* 服务接口, 要求继承并衍生对应的服务响应
*/
public interface ApiService<T> {

/**
* 要求接口服务必须声明目前提供支持的 API 版本值, 用于和 header 的版本匹配
*/
String version();


/**
* 默认响应方法, form 一般是客户端请求过来参数结构体
*/
Response response(String authorization, T form);


/**
* 简单的响应结构
* <p>
* 现在响应结构放置于 Service, 其实放置于全局成为工具类最好
*/
record ApiResponse<T>(
String version,

int status,
long time,
String message,
T data
) {

/**
* 减少多余参数的匹配构造方法
*/
public ApiResponse(String version, int status, String message, T data) {
this(version, status, System.currentTimeMillis(), message, data);
}

/**
* 未知接口请求响应
*/
public static ApiResponse<Map<String, Object>> unknown() {
// 下面可以直接构建的静态初始化
Response.Status status = Response.Status.PRECONDITION_FAILED;
String reason = status.getReasonPhrase().toUpperCase(); // 将 Precondition Failed 切换成大写
reason = reason.replace(" ", "_"); // 空格以下划线分割

// 构建未知响应
return new ApiResponse<>(
"unknown",
status.getStatusCode(),
reason,
Collections.emptyMap()
);
}

/**
* 接口成功响应
*/
public static <T> ApiResponse<T> success(String version, T data) {
Response.Status status = Response.Status.OK;
return new ApiResponse<>(
version,
status.getStatusCode(),
status.getReasonPhrase().toUpperCase(),
data
);
}
}
}

然后就是具体的版本声明匹配, 每个请求对应一个服务入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.Collections;
import java.util.Map;

/**
* 实现接口约束的 UserLogin 服务, 版本为 v1
*/
@ApplicationScoped
public final class UserLoginServiceV1Impl implements ApiService<Map<String, String>> {

@Override
public String version() {
return "v1";
}

@Override
public Response response(String authorization, Map<String, String> form) {
return Response.ok(ApiResponse.success(version(), Collections.emptyMap())).build();
}
}

之后就是挂载到服务注册中心, 用于根据注册对应版本和服务关联:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

/**
* API 服务注册中心
*/
@ApplicationScoped
public final class ApiServiceRegistry {

/**
* 日志对象
*/
final Logger logger = LoggerFactory.getLogger(ApiServiceRegistry.class);

/**
* 服务列表
*/
final Map<String, ApiService<?>> services = new HashMap<>();


/**
* 构造方法时候加载获取全局 ApiService 实例
*
* @param instance 全局加载实例
*/
@Inject
public ApiServiceRegistry(Instance<ApiService<?>> instance) {
for (ApiService<?> service : instance) {
String version = service.version().trim();
if (services.containsKey(version)) {
throw new IllegalStateException("API版本冲突!版本号[%s]已被%s实现,不允许重复注册".formatted(version, service.getClass().getName()));
}
services.put(version, service);
logger.info("API 服务注册成功, 版本: {}, 服务: {}", version, service.getClass().getSimpleName());
}
}

/**
* 根据版本号获取对应服务实例
*
* @param version 请求头中的X-Version值
* @return 匹配的服务实例
*/
public Optional<ApiService<?>> getService(String version) {
return Optional.ofNullable(services.get(version));
}

/**
* 获取所有已注册的版本号
*/
public Map<String, ApiService<?>> getAllServices() {
return Map.copyOf(services);
}
}

最后就是直接获取注册中心调用即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import me.meteorcat.game.config.HeaderNames;
import me.meteorcat.game.services.ApiService;
import me.meteorcat.game.services.ApiServiceRegistry;

import java.util.Collections;
import java.util.Objects;
import java.util.Optional;

@Path("/user/login")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class UserLoginController {


/**
* 加载 header 列表
*/
@Context
HttpHeaders headers;

/**
* 加载配置的 header 名称列表
*/
@Inject
HeaderNames headerNames;

/**
* 服务注册中心
*/
@Inject
ApiServiceRegistry serviceRegistry;


/**
* 采用默认的 POST 请求 route 路径方法
* <p>
* 注意: 如果没有 Header 参数推荐采用 412 Precondition Failed 异常, 代表请求条件不成立
*
* @return Response
*/
@POST
@SuppressWarnings("unchecked")
public Response route() {
// 版本分析
String version = headers.getHeaderString(headerNames.version());
if (Objects.isNull(version) || version.isBlank()) {
return Response
.status(Response.Status.PRECONDITION_FAILED)
.entity(ApiService.ApiResponse.unknown())
.build();
}

// 签名分析
String sign = headers.getHeaderString(headerNames.sign());
if (Objects.isNull(sign) || sign.isBlank()) {
return Response
.status(Response.Status.PRECONDITION_FAILED)
.entity(ApiService.ApiResponse.unknown())
.build();
}

// 授权分析
String authorization = headers.getHeaderString(headerNames.authorization());
if (Objects.isNull(authorization) || authorization.isBlank()) {
return Response
.status(Response.Status.PRECONDITION_FAILED)
.entity(ApiService.ApiResponse.unknown())
.build();
}


// 校验版本服务是否存在
Optional<ApiService<?>> targetService = serviceRegistry.getService(version);
if (targetService.isEmpty()) {
return Response.status(Response.Status.PRECONDITION_FAILED)
.entity(ApiService.ApiResponse.unknown())
.build();
}

// 调用对应版本的业务方法,返回响应
try {
ApiService<Object> service = (ApiService<Object>) targetService.get();
return service.response(authorization, Collections.emptyMap());
} catch (Exception e) {
// 服务异常, 这里最好加上日志记录
return Response.serverError().build();
}
}
}

这里写得比较粗糙, 但是大致流程都概括好了, 直接命令行请求就可以返回对应参数:

1
2
3
4
5
6
curl --location 'http://127.0.0.1:8080/user/login' \
--header 'X-Version: v1' \
--header 'X-Sign: test' \
--header 'X-Authorization: test' \
--header 'Content-Type: application/json' \
--data '{}'

这样就可以实现接口 Path 不变的情况下, 依靠 Header 能够更好调配对应版本的接口数据, 比起需要修改 Path 的方法也更灵活.