JSON Schema 接口校验

发布于:2025-08-19 ⋅ 阅读:(19) ⋅ 点赞:(0)

用 JSON Schema 做接口校验:从原理到在 Spring Boot 中的落地实践

适用读者:熟悉 Spring Boot / REST API,有过 @Valid(Bean Validation)经验,想把校验规则配置化、跨语言复用、支持复杂条件


目录

  1. 为什么要用 JSON Schema(对比 @Valid
  2. JSON Schema 核心原理(你至少该知道的那部分)
  3. 在 Spring Boot 中的三种集成方式
    3.1 代码内置 Schema(最简单)
    3.2 资源文件/远程加载(团队协作友好)
    3.3 数据库存储 + 动态热加载(高扩展)
  4. 可复用的“Schema 注册中心”与缓存设计
  5. 控制器入参校验的落地:拦截、报错、返回体规范
  6. 复杂场景示例(if/then/else、oneOf、数组约束、$ref 组合)
  7. @Valid 的协同:分层职责与迁移策略
  8. 性能与稳定性建议(预编译、缓存、限流、格式语义)
  9. 运维与治理:版本化、灰度、回滚、可观测性
  10. 附录:完整代码片段汇总

1. 为什么要用 JSON Schema(对比 @Valid

@Valid(Hibernate Validator)优点是直观、与 Java 深度集成,但它强绑定语言与代码,规则改动往往要发版;描述跨字段条件也不够灵活。

JSON Schema 的优势:

  • 跨语言复用:一份 Schema,Java/Node/Python 同时用。
  • 配置化/动态化:把规则放到资源库或数据库,无需改代码即可调整
  • 表达力强if/then/elseoneOfallOf、数组/对象深度约束、格式(email、uuid、date-time)等。
  • 前后端统一:前端表单校验、Mock、文档生成都可复用同一份 Schema。

典型使用场景:低代码/表单系统、报表/检索条件、可配置化业务参数,或需要对外生态(跨语言 SDK)的平台级接口。


2. JSON Schema 核心原理

  • Schema 本体:一份 JSON,用来描述另一个 JSON的结构与约束。

  • 关键字段

    • $schema:声明使用的草案版本(建议选 draft-07 或更高)。
    • type / properties / required / items / enum / format / pattern / minimum 等。
    • 组合与条件:allOfanyOfoneOfnotif/then/else
    • 复用:$id + $ref 支持跨文件引用。
  • 验证过程JSON → 解析为 JsonNode → 调用验证器与 JsonSchema 对比 → 返回错误列表(路径 + 失败原因)。


3. 在 Spring Boot 中的三种集成方式

下文示例均基于 Java + Spring Boot + com.networknt:json-schema-validator(选择最新版稳定版)。该库 API 清晰,支持较新草案;如果你已有 Everit 也可替换,设计相同。

3.1 代码内置 Schema(最简单)

适合:规则较少、变动不频繁的内部接口。

Maven 依赖(示意)

<dependency>
  <groupId>com.networknt</groupId>
  <artifactId>json-schema-validator</artifactId>
  <version><!-- 选用最新稳定版 --></version>
</dependency>

Controller 直接校验

@RestController
@RequestMapping("/orders")
public class OrderController {
    private final ObjectMapper mapper = new ObjectMapper();
    private final JsonSchemaFactory factory =
        JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V201909); // 或 draft-07

    // 示例:下方 schema 文本可以提到配置文件/DB,这里为简化直接内嵌
    private static final String ORDER_CREATE_SCHEMA = """
    {
      "$schema": "http://json-schema.org/draft-07/schema#",
      "title": "CreateOrder",
      "type": "object",
      "properties": {
        "type": { "enum": ["physical", "virtual"] },
        "userId": { "type": "string", "minLength": 1 },
        "items": {
          "type": "array",
          "minItems": 1,
          "items": {
            "type": "object",
            "properties": {
              "sku": { "type": "string", "minLength": 1 },
              "qty": { "type": "integer", "minimum": 1 },
              "price": { "type": "number", "minimum": 0 }
            },
            "required": ["sku", "qty", "price"],
            "additionalProperties": false
          }
        },
        "email": { "type": "string", "format": "email" },
        "address": { "type": "string" }
      },
      "required": ["type","userId","items"],
      "if": { "properties": { "type": { "const": "virtual" } } },
      "then": { "required": ["email"] },
      "else": { "required": ["address"] },
      "additionalProperties": false
    }
    """;

    @PostMapping
    public ResponseEntity<?> create(@RequestBody JsonNode body) {
        JsonSchema schema = factory.getSchema(ORDER_CREATE_SCHEMA);
        Set<ValidationMessage> errors = schema.validate(body);
        if (!errors.isEmpty()) {
            throw new SchemaValidationException(errors);
        }
        // 通过校验后再转换为业务 DTO(可选)
        CreateOrderRequest req = mapper.convertValue(body, CreateOrderRequest.class);
        // 业务处理...
        return ResponseEntity.ok(Map.of("ok", true));
    }
}

全局异常处理

@ResponseStatus(HttpStatus.BAD_REQUEST)
class SchemaValidationException extends RuntimeException {
    private final Set<ValidationMessage> errors;
    SchemaValidationException(Set<ValidationMessage> errors) { this.errors = errors; }
    public Set<ValidationMessage> getErrors() { return errors; }
}

@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(SchemaValidationException.class)
    public ResponseEntity<Map<String, Object>> onSchemaError(SchemaValidationException ex) {
        List<Map<String, Object>> details = ex.getErrors().stream().map(e -> Map.of(
            "path", e.getPath(),        // $.items[0].qty
            "message", e.getMessage()   // must be greater than or equal to 1
        )).toList();
        return ResponseEntity.badRequest().body(Map.of(
            "code", "INVALID_ARGUMENT",
            "errors", details
        ));
    }
}

3.2 资源文件/远程加载(团队协作友好)

.schema.json 放到 resources/schemas/,按业务对象 + 版本命名,例如:

resources/schemas/order/create/v1.schema.json
resources/schemas/order/create/v2.schema.json

加载方式:

@Component
public class ClasspathSchemaLoader implements SchemaLoader {
    private final JsonSchemaFactory factory =
        JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V201909);

    @Override
    public JsonSchema load(String path) {
        try (InputStream in = getClass().getResourceAsStream("/schemas/" + path)) {
            if (in == null) throw new IllegalArgumentException("Schema not found: " + path);
            return factory.getSchema(in);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}

interface SchemaLoader {
    JsonSchema load(String path);
}

Controller 中按“业务名 + 版本”决定加载哪份:

@PostMapping("/v2")
public ResponseEntity<?> createV2(@RequestBody JsonNode body) {
    JsonSchema schema = schemaLoader.load("order/create/v2.schema.json");
    Set<ValidationMessage> errors = schema.validate(body);
    if (!errors.isEmpty()) throw new SchemaValidationException(errors);
    // ...
    return ResponseEntity.ok().build();
}

3.3 数据库存储 + 动态热加载(高扩展)

适合:规则经常变化、希望“配置改了立即生效”。

表结构(示意,存字符串最通用)

CREATE TABLE schema_def (
  id           BIGSERIAL PRIMARY KEY,
  name         VARCHAR(128) NOT NULL,   -- 业务名,如 order.create
  version      VARCHAR(32)  NOT NULL,   -- 版本,如 v1、2025-08-01
  schema_text  TEXT         NOT NULL,   -- JSON Schema 字符串
  status       VARCHAR(16)  NOT NULL DEFAULT 'active',  -- active/archived
  created_at   TIMESTAMP    NOT NULL DEFAULT now(),
  UNIQUE (name, version)
);

JPA 实体与仓库(简化)

@Entity @Table(name = "schema_def")
class SchemaDef {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
    String name;
    String version;
    @Lob String schemaText;
    String status;
    Timestamp createdAt;
    // getters/setters...
}

public interface SchemaDefRepo extends JpaRepository<SchemaDef, Long> {
    Optional<SchemaDef> findFirstByNameAndVersionAndStatus(String name, String version, String status);
}

动态加载 + 本地缓存

@Component
public class DbBackedSchemaRegistry {
    private final SchemaDefRepo repo;
    private final JsonSchemaFactory factory =
        JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V201909);
    private final ConcurrentMap<String, JsonSchema> cache = new ConcurrentHashMap<>();

    public DbBackedSchemaRegistry(SchemaDefRepo repo) { this.repo = repo; }

    public JsonSchema get(String name, String version) {
        String key = name + ":" + version;
        return cache.computeIfAbsent(key, k -> {
            SchemaDef def = repo.findFirstByNameAndVersionAndStatus(name, version, "active")
                .orElseThrow(() -> new IllegalArgumentException("Schema not found: " + key));
            return factory.getSchema(def.getSchemaText());
        });
    }

    public void evict(String name, String version) {
        cache.remove(name + ":" + version);
    }
}

使用

@PostMapping("/dynamic/{version}")
public ResponseEntity<?> createDynamic(@PathVariable String version, @RequestBody JsonNode body) {
    JsonSchema schema = registry.get("order.create", version);
    Set<ValidationMessage> errors = schema.validate(body);
    if (!errors.isEmpty()) throw new SchemaValidationException(errors);
    return ResponseEntity.ok().build();
}

运维上可配一个“刷新缓存”管理接口,在你更新数据库规则后调用 evict() 或全量清理。


4. 可复用的“Schema 注册中心”与缓存设计

目标:统一入口 + 预编译缓存 + 容错。

  • Key 设计{domain}.{action}:{version},例如 order.create:v2
  • 缓存ConcurrentHashMap 简单可用;生产建议 Caffeine(支持 TTL/最大容量/统计)。
  • 预热:应用启动时预编译“热点 Schema”。
  • 容错:加载失败/Schema 无效时快速告警(日志 + 指标 + 健康检查失败)。
  • 并发computeIfAbsent 避免同一 key 的并发重复编译。

5. 控制器入参校验的落地:拦截、报错、返回体规范

拦截层级选择:

  • Controller 层(上文示例):最直观,便于因接口不同选择不同 Schema。
  • Filter/HandlerInterceptor:适合统一入口(如按路由选择 Schema),但需要解析请求体两次(注意缓存流)。
  • 自定义 @SchemaValidated(name="order.create", version="v2"):结合 AOP 实现“所见即所得”的注解式体验。

错误返回体建议统一:

{
  "code": "INVALID_ARGUMENT",
  "errors": [
    { "path": "$.items[0].qty", "message": "must be >= 1" },
    { "path": "$.email", "message": "must be a valid email" }
  ],
  "requestId": "..."  // 便于追踪
}

6. 复杂场景示例

6.1 条件约束:虚拟商品需要邮箱,实物需要地址

(已在 3.1 示例展示)

6.2 互斥/择一(oneOf

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "wechatId": { "type": "string", "minLength": 1 },
    "email": { "type": "string", "format": "email" }
  },
  "oneOf": [
    { "required": ["wechatId"] },
    { "required": ["email"] }
  ],
  "additionalProperties": false
}

6.3 数组约束 + 去重

{
  "type": "array",
  "items": { "type": "string", "minLength": 1 },
  "minItems": 1,
  "uniqueItems": true
}

6.4 复用与拆分:$ref + 组件化

common/address.schema.json

{
  "$id": "https://example.com/schemas/common/address.schema.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "province": { "type": "string" },
    "city":     { "type": "string" },
    "street":   { "type": "string" },
    "zip":      { "type": "string", "pattern": "^[0-9]{6}$" }
  },
  "required": ["province","city","street","zip"],
  "additionalProperties": false
}

order/create/v2.schema.json

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://example.com/schemas/order/create/v2.schema.json",
  "type": "object",
  "properties": {
    "type": { "enum": ["physical","virtual"] },
    "userId": { "type": "string" },
    "items": { "$ref": "items.schema.json" },
    "address": { "$ref": "https://example.com/schemas/common/address.schema.json" }
  },
  "required": ["type","userId","items"],
  "if": { "properties": { "type": { "const": "physical" } } },
  "then": { "required": ["address"] },
  "additionalProperties": false
}

注意:跨文件 $ref 需要你的加载器支持解析相对/绝对 $id 与引用networknt 支持通过 SchemaValidatorsConfig 等配置控制解析策略;你也可以实现自定义 URIFetcher(高级用法)。


7. 与 @Valid 的协同:分层职责与迁移策略

推荐分工:

  • JSON Schema:负责边界格式与结构校验(字段类型、范围、必填、条件组合)。
  • @Valid/业务校验:负责领域规则(与数据库/外部系统相关,如 SKU 是否存在、库存是否充足、状态流转是否合法)。

迁移策略:

  1. 先把变动频繁跨语言的接口迁到 JSON Schema。
  2. 保留 @Valid 处理稳定的 DTO领域规则
  3. 新接口默认先上 Schema;内部服务间、性能敏感的可保留 @Valid

8. 性能与稳定性建议

  • 预编译 Schemafactory.getSchema() 相对昂贵,务必缓存。
  • 只在边界校验一次:Controller 校验通过后,内部不要重复。
  • 限流与体积限制:防止超大 JSON DoS;Servlet 层/网关层限制 Body 大小。
  • 数字精度:金额用 string + 业务侧 BigDecimal 解析,避免浮点误差。
  • date-time 语义:Schema 只校验格式,不校验时区/业务区间;时间区间请在业务层做。
  • 错误可读性:必要时自定义“友好提示”映射(英文转中文、路径压缩)。
  • 观测:统计“校验失败率/Top 错误字段/平均耗时”,问题可回溯。

9. 运维与治理:版本化、灰度、回滚

  • 版本号策略v1v2 或日期戳 2025-08-01;同一时间只维护一个 active
  • 灰度:按 Header 或租户路由到不同版本 Schema(网关/Controller 层实现)。
  • 回滚:保留旧版本 Schema,切换路由即可秒回滚。
  • 变更审计:Schema 变更走评审流程(PR/DB 审批),保留修改人/理由。
  • 兼容性:新增字段默认非必填;删除字段先标记弃用,再移除。

10. 附录:完整代码片段汇总

10.1 统一的校验服务

@Component
public class JsonSchemaValidatorService {
    private final ObjectMapper mapper = new ObjectMapper();

    public void validate(JsonSchema schema, JsonNode body) {
        Set<ValidationMessage> errors = schema.validate(body);
        if (!errors.isEmpty()) throw new SchemaValidationException(errors);
    }

    public void validate(JsonSchema schema, String json) {
        try {
            validate(schema, mapper.readTree(json));
        } catch (IOException e) {
            throw new SchemaValidationException(Set.of(new ValidationMessage.Builder()
                .path("$").format("invalid JSON").build()));
        }
    }
}

10.2 AOP 注解式(可选)

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface SchemaValidated {
    String name();
    String version();
}

@Aspect
@Component
class SchemaValidationAspect {
    private final DbBackedSchemaRegistry registry;
    private final JsonSchemaValidatorService service;
    private final ObjectMapper mapper = new ObjectMapper();

    @Around("@annotation(ann)")
    public Object around(ProceedingJoinPoint pjp, SchemaValidated ann) throws Throwable {
        Object[] args = pjp.getArgs();
        // 假设第一个 @RequestBody 是 JSON
        for (Object arg : args) {
            if (arg instanceof JsonNode node) {
                JsonSchema schema = registry.get(ann.name(), ann.version());
                service.validate(schema, node);
                break;
            }
        }
        return pjp.proceed();
    }
}

10.3 单元测试(示意)

@SpringBootTest
@AutoConfigureMockMvc
class OrderApiTest {
    @Autowired MockMvc mvc;

    @Test
    void create_virtual_order_missing_email_should_fail() throws Exception {
        String json = """
          {"type":"virtual","userId":"u1","items":[{"sku":"A","qty":1,"price":9.9}]}
        """;
        mvc.perform(post("/orders").contentType(MediaType.APPLICATION_JSON).content(json))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.errors[0].path").exists());
    }
}

结语

  • 如果你的接口只在 Java 内部使用、规则稳定,@Valid 已足够。
  • 一旦进入跨语言协作、快速变更、复杂结构/条件的领域,JSON Schema 能把“规则”抽离成资产,实现前后端/多语言统一灵活演进
  • 按本文给出的三种集成方式,先从资源文件加载 + 缓存起步,逐步演进到数据库动态热加载 + 版本治理,即可在生产环境稳妥落地。