SpringBoot接口分层规范-外网网关内部服务与参数边界

SpringBoot接口分层规范:外网网关、内部服务与参数边界

Spring Boot 接口"能接收参数并返回 JSON"并不等于契约设计完整。前后端流量与服务间调用面对的信任来源、协议包装、校验和错误语义并不相同。

如果外网 Controller 直接承担业务,内部服务无法复用;如果内外接口共用同一请求对象,签名、用户上下文和业务字段会互相污染。标准写法的核心是流量边界,而不是注解数量。

MetaLite 将外网 Gateway 与内部 Controller 分开声明,共用业务参数但不共用请求协议。本文先建立南北流量和东西流量模型,再结合 backend-gateway 与 backend-admin 的接口代码给出完整检查清单。

一、先看完整链路:一个接口声明两次并不是重复

以"创建用户"为例,请求链路如下:

text 复制代码
浏览器或外部调用方
        ↓
backend-gateway:外部接口
  校验外部系统参数与登录凭证
  解密、解析业务参数
  External...Req → Internal...Req
        ↓
InternalServiceClient
  根据 provider 和 endpoint 调用内部服务
        ↓
backend-admin:内部接口
  校验 InternalReq 和嵌套业务参数
  调用 SysUserService
        ↓
统一 Resp<T> 沿原链路返回

Gateway 和 Admin 中都有 /api/admin/sys/user/create,看起来像重复声明,实际职责不同:

层次 面向对象 请求模型 核心职责
backend-gateway 浏览器、App、第三方调用方 External*Req 公网协议、安全处理、请求转换、RPC 转发
backend-admin 可信的内部服务调用 Internal*Req 内网协议、业务参数校验、Service 编排

同一个 endpoint 在两个边界上保持一致,使网关不必维护额外的"外部路径到内部路径"映射表。但相同 URI 不等于相同信任级别,真正的边界由请求类型、处理器链和部署网络共同建立。

二、对外接口的类应该怎样声明

backend-gateway 中的 SysUserApi 使用了统一的类级声明:

java 复制代码
@Tag(name = "系统管理模块 - 用户相关接口")
@RequestMapping(
        path = "/api/admin/sys/user",
        consumes = MediaType.APPLICATION_JSON_VALUE,
        produces = MediaType.APPLICATION_JSON_VALUE
)
@RestController
public class SysUserApi {
}

这里一次明确四件事:

  1. @RestController 表示返回值直接序列化为响应体;
  2. @RequestMapping 统一模块路径,方法只维护动作路径;
  3. consumes 明确只接收 JSON,避免接口对输入媒体类型含糊;
  4. produces 明确输出 JSON,文档、调用方和测试工具得到一致预期。

@Tag 与方法上的 @Operation 则负责接口文档分组与业务说明:

java 复制代码
@Operation(summary = "创建用户", description = "")
@PostMapping(path = "/create")

MetaLite 的后台接口采用"资源路径+动作"的 POST JSON 风格。这是一种适合内部 RPC 和复杂业务命令的工程选择,并不是 REST 的唯一标准。如果团队公开的是资源型开放 API,也可以采用 GET、POST、PUT、DELETE;重要的是风格与错误语义保持一致,而不是混用两套规则。

三、对外请求为什么不能只写一个业务 DTO

公网请求除了业务字段,还需要携带协议和安全上下文。MetaLite 把外部请求拆成四种类型:

请求类型 是否登录 是否带业务参数
ExternalReq
ExternalBizParamReq<T>
ExternalLoginReq
ExternalLoginBizParamReq<T>

最基础的 ExternalReq 包含:

java 复制代码
public class ExternalReq implements Pojo {
    @NotBlank
    private String apiVersion;

    @NotBlank
    private String appId;

    private String encryptData;
    private String plaintext;

    @Min(1677654864000L)
    private long timestamp;

    private String sign;
}

需要登录的请求增加 userToken

java 复制代码
public class ExternalLoginReq extends ExternalReq {
    @NotBlank
    private String userToken;
}

带业务参数的登录请求则通过泛型声明业务类型:

java 复制代码
public class ExternalLoginBizParamReq<T extends Param>
        extends ExternalLoginReq {
    private T bizParam;
}

这里有一个容易误读的细节:对外调用方真正提交的是 plaintextencryptDatabizParam 主要用于接口文档展示,源码注释也明确要求调用方不要直接上送该字段。

因此,Gateway 的第一阶段校验重点是公网协议字段;加密业务数据经过安全处理器解密为 plaintext 后,再由 WebUtil.genInternalReq 解析成具体参数类型并构造内部请求。

四、一个标准的 Gateway 方法长什么样

创建用户的外部入口如下:

java 复制代码
@Operation(summary = "创建用户", description = "")
@PostMapping(path = "/create")
public Resp<Void> createUser(
        @RequestBody @Valid
        ExternalLoginBizParamReq<SaveSysUserParam> req,
        @Parameter(hidden = true)
        BindingResult bindingResult) {

    return internalServiceClient.callOneInstanceRtnData(
            RpcRequest.builder()
                    .provider(InternalServiceEnum.ADMIN.getCode())
                    .endpoint(WebUtil.getRequestUri())
                    .rpcMode(RpcModeEnum.HTTP_POST_JSON)
                    .param(WebUtil.genInternalReq(
                            req,
                            SaveSysUserParam.class
                    ))
                    .build(),
            Void.class
    );
}

它只做协议层工作:

  • 用请求泛型声明业务参数类型;
  • 让 Bean Validation 检查外部请求;
  • 指定内部服务提供者;
  • 沿用当前请求 URI 作为内部 endpoint;
  • 将外部请求转换为内部请求;
  • 告诉 RPC 客户端响应数据的目标类型。

这里没有创建用户的业务规则。Gateway 不应该知道用户名怎样判重、默认密码怎样生成、操作日志怎样记录。这些逻辑属于 Admin 服务。

五、BindingResult 为什么必须跟在 @Valid 参数后面

MetaLite 的标准方法签名同时保留 @ValidBindingResult

java 复制代码
public Resp<Void> createUser(
        @RequestBody @Valid ExternalLoginBizParamReq<SaveSysUserParam> req,
        @Parameter(hidden = true) BindingResult bindingResult) {
    // ...
}

BindingResult 应紧跟在被校验的参数后面,Spring 才能把该对象的校验结果与它正确关联。@Parameter(hidden = true) 则避免把框架内部参数展示到 OpenAPI 文档中。

业务方法里为什么没有手工写:

java 复制代码
if (bindingResult.hasErrors()) {
    // 拼装错误返回
}

因为 backend-applicationApiReceiveParamHandler 已经统一处理:

java 复制代码
BindingResult bindingResult =
        aspectInfo.findParam(BindingResult.class);

if (bindingResult == null || !bindingResult.hasErrors()) {
    return Resp.ok();
}

for (FieldError fieldError : bindingResult.getFieldErrors()) {
    if (Strings.CS.equalsAny(
            fieldError.getCode(),
            PARAM_REQUIRED_CODES)) {
        return Resp.error(
                ErrorCode.PARAM_REQUIRED,
                fieldError.getField()
        );
    }
    return Resp.error(
            ErrorCode.PARAM_INVALID,
            fieldError.getField()
    );
}

它把 @NotBlank@NotEmpty@NotNull 归为"参数必填",其他约束归为"参数不合法",还支持以 @ 开头的自定义提示。这样所有接口使用同一种错误协议,Controller 不再复制校验分支。

六、为什么业务参数要到内部接口再完整校验

Gateway 收到的业务内容可能是密文,也可能是 JSON 字符串。WebUtil.genInternalReq 负责验证 JSON 形态、反序列化并转换请求:

java 复制代码
public static InternalBizParamReq genInternalReq(
        ExternalReq externalReq,
        Class<? extends Param> bizParamClass) {

    InternalBizParamReq internalReq =
            new InternalBizParamReq();

    String plaintext = externalReq.getPlaintext();
    if (StringUtils.isBlank(plaintext)) {
        return internalReq;
    }

    Param param = FastJson.json2Obj(
            plaintext,
            bizParamClass
    );
    internalReq.setBizParam(param);
    return internalReq;
}

内部请求明确要求业务参数存在,并递归校验:

java 复制代码
public class InternalBizParamReq<T extends Param>
        extends InternalReq {

    @NotNull
    @Valid
    private T bizParam;
}

@NotNull 保证业务对象存在,@Valid 继续检查 SaveSysUserParam 内部的字段约束。于是校验形成两个层次:

  1. Gateway 校验调用方可以控制的外部协议参数;
  2. Admin 校验解密并反序列化后的真实业务参数。

这比在 Gateway 中同时处理签名、解密和所有业务字段更清晰,也避免内部服务重复接受公网协议字段。

七、业务参数类应该怎样写

业务参数应该使用明确类型实现 Param,而不是 Map<String, Object>

java 复制代码
@Data
@Schema
public class SaveSysUserParam implements Param {
    private String userId;

    @NotBlank
    @Schema(title = "登录用户名", requiredMode = REQUIRED)
    private String userName;

    @NotBlank
    @Schema(title = "手机号", requiredMode = REQUIRED)
    private String phone;

    private String nickName;
    private String email;
    private int status;
}

建议遵守几条规则:

1. 一个参数类表达一个稳定用例

查询、保存、ID 定位不要混成一个万能对象。MetaLite 分别使用 SaveSysUserParamQueryUserParamUserIdParam

2. 必填约束写在最接近数据的位置

字段用 @NotBlank@NotNull@Min 等约束,嵌套对象用 @Valid 触发递归校验。

3. 查询条件优先使用包装类型

Integer status 能区分"不传"和"查询状态 0";int status 不能。如果字段不传时就应自然等于 0,基本类型才是合适选择。

4. 创建和更新约束不同就不要勉强共用

源码中的保存参数复用了创建和更新,userId 只在更新场景需要。如果两种场景的必填字段差异继续扩大,应拆成两个参数类,或明确采用校验分组,不能把所有规则都推迟到 Service 中。

5. Schema 是文档,不是校验

requiredMode = REQUIRED 让接口文档更清楚,但真正阻止空值的是 Bean Validation 注解。两者应该保持一致,不能只写其中一个。

八、内部接口应该怎样声明

backend-admin 保持与 Gateway 相同的资源路径和动作路径,但参数换成内部协议:

java 复制代码
@Tag(name = "系统管理模块 - 用户相关接口")
@RequestMapping(
        path = "/api/admin/sys/user",
        consumes = MediaType.APPLICATION_JSON_VALUE,
        produces = MediaType.APPLICATION_JSON_VALUE
)
@RestController
public class SysUserApi {

    @Resource
    private SysUserService sysUserService;

    @Operation(summary = "创建用户", description = "")
    @PostMapping(path = "/create")
    public Resp<Void> createUser(
            @RequestBody @Valid
            InternalBizParamReq<SaveSysUserParam> req,
            @Parameter(hidden = true)
            BindingResult bindingResult) {
        return sysUserService.createUser(req.getBizParam());
    }
}

内部接口不再验证 appId、签名和公网时间戳,而是接收由框架构造的 InternalReq

java 复制代码
public class InternalReq implements Pojo {
    @NotBlank
    private String provider;

    @NotBlank
    private String consumer;
}

这两个字段描述服务调用关系,并由框架自动填充。它们不是让外部调用方伪造的"内部凭证"。生产环境仍需配合网络隔离、服务身份校验和入口限制,不能因为请求类名叫 InternalReq 就默认调用可信。

九、没有业务参数时不要制造空 DTO

如果接口只依赖登录上下文,不需要业务参数,对外使用 ExternalLoginReq

java 复制代码
public Resp<SysUserEntity> getMyself(
        @RequestBody @Valid ExternalLoginReq req,
        BindingResult bindingResult) {
    return internalServiceClient.callOneInstanceRtnData(
            RpcRequest.builder()
                    .provider(InternalServiceEnum.ADMIN.getCode())
                    .endpoint(WebUtil.getRequestUri())
                    .rpcMode(RpcModeEnum.HTTP_POST_JSON)
                    .param(WebUtil.genInternalReq(req))
                    .build(),
            SysUserEntity.class
    );
}

内部对应使用 InternalReq,业务身份从统一线程上下文读取:

java 复制代码
public Resp<SysUserEntity> getMyself(
        @RequestBody @Valid InternalReq req,
        BindingResult bindingResult) {
    String userId = ThreadContext.getLoginUserId();
    return Resp.ok(
            sysUserService.getUserDtoByUserId(userId)
    );
}

不要为了形式统一创建 EmptyParam,也不要让客户端上传一个本应由认证链确定的用户 ID。系统上下文与业务参数属于两种不同来源。

十、返回值怎样写才稳定

MetaLite 所有接口统一返回 Resp<T>,数据形态通过泛型表达:

无业务数据

java 复制代码
Resp<Void>

用于创建、更新、删除等只需表达成功或失败的操作。

单个对象

java 复制代码
Resp<SysUserEntity>

内部接口可以使用:

java 复制代码
return Resp.ok(sysUserService.getUserDtoByUserId(userId));

如果对外契约对字段稳定性或安全性要求较高,应返回专用 DTO,而不是直接暴露持久化实体。源码中的实体返回适合当前工程边界,不应被理解为所有开放 API 的通用结论。

列表

java 复制代码
Resp<List<UserOrgInfoDto>>

分页结果

java 复制代码
Resp<PageResultDto<SysUserEntity>>

Gateway 对普通数据调用:

java 复制代码
internalServiceClient.callOneInstanceRtnData(
        rpcRequest,
        SysUserEntity.class
);

分页数据则调用:

java 复制代码
internalServiceClient.callOneInstanceRtnPageData(
        rpcRequest,
        SysUserEntity.class
);

RPC 客户端拿到了元素类型,才能把内部响应稳定地反序列化为目标泛型结构。不要统一返回裸 Object,更不要让每个 Controller 自己发明 code/message/data

十一、Gateway 与 Admin 的职责红线

一个对外接口可以做:

  • 外部请求协议声明;
  • 调用方、用户和安全上下文接入;
  • 请求解密与外转内;
  • 路由到目标内部服务;
  • 对响应做网关级加密和状态处理。

它不应该做:

  • 数据库查询;
  • 业务唯一性判断;
  • 业务事务;
  • 领域状态流转;
  • 为了少一次 RPC 复制 Service 逻辑。

内部接口负责:

  • 校验已经解析的业务参数;
  • 从可信上下文读取内部身份;
  • 调用 Service 完成业务编排;
  • 将结果包装为统一响应。

内部 Controller 同样不应塞入大段业务代码。它是传输协议到应用服务之间的适配层,不是另一个 Service。

十二、一份可复用的接口检查清单

新增 MetaLite 风格接口时,可以逐项检查:

接口声明

  • 是否使用 @RestController
  • 类级路径是否稳定且模块化;
  • consumes/produces 是否明确;
  • @Tag@Operation 是否描述真实用途;
  • 对外和对内 endpoint 是否保持可追踪的一致关系。

参数写法

  • 是否根据登录与业务参数选择正确的 External*Req
  • 内部是否使用 InternalReqInternalBizParamReq<T>
  • 业务参数是否使用明确的 Param 类型;
  • 可选数值是否使用包装类型;
  • 系统上下文是否避免由客户端重复提交。

参数校验

  • @RequestBody @Valid 是否完整;
  • BindingResult 是否紧跟被校验参数;
  • 嵌套业务对象是否有 @Valid
  • Bean Validation 与 Schema 必填说明是否一致;
  • 创建和更新约束差异是否已经显式处理。

返回值

  • 是否统一返回 Resp<T>
  • 无数据是否使用 Void
  • 分页是否使用 PageResultDto<T>
  • RPC 是否提供正确的反序列化类型;
  • 对外契约是否需要专用 DTO 隔离实体变化。

十三、什么才叫"标准写法"

Spring Boot 并不存在一份适合所有项目的官方 Controller 模板。所谓标准,不是注解排列得整齐,而是团队对每一个边界都有一致答案:

  • 什么请求来自公网;
  • 什么请求只在服务内部传播;
  • 系统参数与业务参数怎样分开;
  • 校验在哪一层发生;
  • 身份上下文由谁建立;
  • 业务逻辑放在哪里;
  • 返回值怎样稳定传递。

MetaLite 用外部四类请求、内部两类请求、统一参数校验处理器、WebUtil 请求转换、InternalServiceClientResp<T>,把这些答案固化成可以复制的工程结构。

这才是"标准接口"真正带来的价值:不是让某一个 Controller 少写几行,而是让下一百个接口仍然能被快速理解、统一治理和稳定演进。


框架简介

MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。

源码基线

JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目 backend-bom 为准。

作者简介

15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。

持续更新

MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。

在线演示

演示地址: https://admin.metalite.top/

演示账号: guess

演示密码: admin@2026

相关推荐
Co_Hui1 小时前
Java 线程状态
java
凤山老林1 小时前
聊聊企业级 API 安全:Spring Boot 落地 OAuth 2.1、mTLS 与接口签名防篡改
spring boot·后端·安全·oauth
土司大王2 小时前
LeetCode hot100——35.搜索插入位置:Java 二分模板、左闭右开区间与插入点分析
java·算法·leetcode
土司大王2 小时前
LeetCode hot100——34.在排序数组中查找元素的第一个和最后一个位置:Java 二分模板、边界分析
java·算法·leetcode
金金计较.2 小时前
Go语言-3
java·开发语言·golang
Wang's Blog3 小时前
Java框架快速入门: Spring Security+OAuth2之元注解简化权限表达式
java·数据库·spring
xifangge20253 小时前
AGENTS.md 怎么写?涵盖 Java、Python、Vue、Go 的 8 套开箱即用模板
java·vue.js·python
Wang's Blog4 小时前
Java框架快速入门: Spring Security+OAuth2之云服务集成与多因子认证设计
java·开发语言·spring
liangsheng_g4 小时前
SpringAOP拦截器链递归与事务钩子补偿源码实战
java·spring