作为长期使用的维护的远程 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 {
@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(); 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 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;
@ConfigMapping(prefix = "api.headers") public interface HeaderNames {
@WithDefault("X-Version") String version();
@WithDefault("X-Sign") String sign();
@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 {
@Context HttpHeaders headers;
@Inject HeaderNames headerNames;
@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> {
String version();
Response response(String authorization, T form);
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(); 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;
@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;
@ApplicationScoped public final class ApiServiceRegistry {
final Logger logger = LoggerFactory.getLogger(ApiServiceRegistry.class);
final Map<String, ApiService<?>> services = new HashMap<>();
@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()); } }
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 {
@Context HttpHeaders headers;
@Inject HeaderNames headerNames;
@Inject ApiServiceRegistry serviceRegistry;
@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 的方法也更灵活.