qinghuan
← Java Web / 全部笔记

单元 44 / Java Web

P0 · 必会主线L1 · 基础

接口分层与异常处理:让输入输出各司其职

按输入、存储和展示分别设计字段,再把业务失败转换成清楚、稳定且不会泄露内部信息的响应。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能为一个用例设计输入输出对象和失败响应,并把业务与持久化职责分开。

学习理由、准备要求与知识点

为什么学它

输入、存储与展示模型分工清楚,接口才能稳定演进并避免泄露内部字段。

先读这些内容

知识点清单

  • DTO/Entity/VO 分工
  • 输出字段白名单
  • Controller 边界职责
  • Service 业务编排
  • Mapper 数据读写
  • 异常类型映射
  • HTTP 状态与错误体
  • 列表响应规模

运行前先确认Java 17、Spring Framework 6.2;ProblemDetail 基于 RFC 9457。源码固定 v6.2.11;示例只实现单个处理器集合中的异常类型匹配,不模拟完整 MVC 解析器。

01先弄懂它是什么

设计接口时先分清三份数据:用户允许提交什么、数据库需要存什么、页面允许看到什么。输入 DTO、数据库 Entity 和展示对象因此不一定有相同字段。本篇把展示对象称为 VO,其他项目的叫法可能不同。员工密码摘要可以是数据库字段,却不应出现在列表返回中;客户端提交了管理员标识,也不能直接写入实体。

Controller 接收请求并返回响应,Service 处理业务规则与事务,Mapper 负责存取数据。这样分工是为了让每种变化有明确位置。例如新增菜品时,先把输入整理成业务需要的数据,再核对权限和价格,保存后只返回允许展示的字段。字段复制工具只能帮助搬数据,不能代替权限、校验或敏感字段筛选。

失败同样属于契约。HTTP 状态描述协议结果,稳定错误代码帮助客户端分支,给用户的 detail 提供安全解释,服务端日志保存堆栈和关联标识。统一结构不意味着所有失败都用 200。错误类型、状态和业务代码需要有明确映射;字符串消息可翻译,不宜作为程序判断的唯一依据。

文中这几个词,先认一认

输入白名单
只接收当前操作允许用户修改的字段,不能因为实体有这个属性就允许外部传入。
ProblemDetail
描述 HTTP 错误的结构,可记录问题类型、标题、状态码、安全说明和这次问题的位置等。
边界翻译
把内部业务失败转换成调用者能理解的 HTTP 错误,避免直接暴露堆栈等内部细节。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

从请求到响应可以走一条明确的数据路线:反序列化请求、执行字段校验、获取服务端认证身份、验证资源权限、执行业务、映射返回 DTO。前两步只能说明输入形式与约束满足要求,不能证明用户有权操作指定资源。比如 userId 路径参数存在并为正数,仍要验证它是否属于当前用户或受授权管理范围。

Controller 抛出异常后,MVC 会按规则寻找异常处理器。写在当前 Controller 的处理方法,与由全局 Advice 提供的处理方法,适用范围和选择顺序不同,还可能受可返回的媒体类型影响。已知业务失败应转换成对应错误,没预料到的故障应保留为内部错误。不要捕获所有 Throwable 后继续返回成功;严重错误和结果不确定的情况仍要被记录、发现和处理。

错误响应已经开始提交时,无法保证再改写状态和完整 JSON。流式输出、异步请求和网关断连需要分别处理。对一般 JSON 接口,可以在提交前形成完整响应,并把数据库异常转成安全消息。日志中的 requestId 用于关联本次请求,业务幂等键用于识别同一业务操作,两者用途不同。

正在加载流程图…

字段校验、资源授权和输出投影是独立关卡;异常响应需要在协议允许的提交边界内完成。
查看流程图源码(Mermaid)
flowchart TD
 A["请求 DTO"] --> B["格式校验"]
 B --> C["服务端身份与资源授权"]
 C --> D["业务用例与事务"]
 D --> E["响应白名单投影"]
 E --> F["成功状态与响应体"]
 B --> G["异常边界"]
 C --> G
 D --> G
 G --> H["稳定错误码与 HTTP 状态"]
 G --> I["服务端日志与关联标识"]

按顺序看,每一步做了什么

  1. 先列出输入可修改字段、数据库字段和输出可见字段三组集合。
  2. 在服务端获取当前主体,验证资源归属和业务状态。
  3. 为已知失败规定 HTTP 状态、稳定错误代码和安全说明。
  4. 让异常处理器统一序列化,避免每个 Controller 重复捕获数据库异常。
  5. 为未知故障保留日志关联,客户端只接收必要的信息。

03对照实现代码

Spring 异常处理方法的匹配与具体程度 (新标签页)↗

Spring Framework v6.2.11 · ExceptionHandlerMethodResolver#getMappedMethod、#resolveExceptionMapping

源码引导下的原创教学实现,非节选。仅保留类型可赋值匹配与继承距离;真实实现还有媒体类型、缓存、cause 链以及上层 Advice 顺序。

核心机制代码 · java
import java.util.*;
class SourceModel {
    record Handler(Class<? extends Throwable> type, String name) {}
    static String select(Throwable error, List<Handler> handlers) {
        Handler chosen = null;
        int nearest = Integer.MAX_VALUE;
        for (Handler h : handlers) {
            // 父异常类型可以接收其子类,但反向不成立。
            if (!h.type().isAssignableFrom(error.getClass())) continue;
            int depth = 0;
            for (Class<?> c = error.getClass(); c != h.type(); c = c.getSuperclass())
                depth++;
            // 更短的继承距离代表更具体的异常映射。
            if (depth < nearest) { chosen = h; nearest = depth; }
        }
        // 未匹配应交给下一处理边界,不能伪装成业务成功。
        return chosen == null ? null : chosen.name();
    }
}

源码先收集类型兼容且媒体类型兼容的候选,再在多候选时排序。读到 isAssignableFrom 时要确认左右方向,父类型处理器才能处理子类型异常。

异常类型相差几层继承会影响选择,但这不是全应用唯一的优先规则。不同 Advice 的先后还由上层解析器安排,所以教学模型中一个候选列表的结果,不能直接代表所有 Controller 的最终选择。

相同异常和媒体类型映射到不同处理方法会被视为歧义。让注册阶段拒绝歧义,比运行时依赖遍历顺序更容易维护。

resolveExceptionMapping 可以在当前异常未匹配时沿 cause 查找。根异常和原因异常的选择还受处理器范围影响,设计时应优先显式业务异常而非依赖偶然包装层数。

代码先调用谁,再调用谁

  1. MVC 的异常解析入口
  2. 选择 Controller 或适用的 Advice
  3. ExceptionHandlerMethodResolver 解析类型与媒体类型
  4. 比较异常深度并定位方法
  5. 调用方法并生成 HTTP 错误响应

04动手写一个例子

给单个 API 边界建立确定的异常分类器

注册异常父类型到 HTTP 状态与业务码的规则。重复类型拒绝注册;分类时选择距离实际异常最近的父类,未匹配返回安全 500。为纯 Java17 单继承异常模型,不解析 cause 或媒体类型。

输入 1
StockEmpty extends Business,同时注册 Business=409 与 StockEmpty=422
输出 1
422 / STOCK_EMPTY
输入 2
IOException,只有 Business 规则
输出 2
500 / INTERNAL_ERROR

异常类只有一条父类继承链,因此可以从实际类型向上查找。遇到第一个已注册类型就得到最近父类,无需把所有规则排序;接口规则和复杂谓词不在本题范围。

注册使用 Map.putIfAbsent 之前必须先判断重复,避免错误发生后覆盖旧规则。状态只接收四百至五百九十九的错误状态,业务码不能为空,使错误契约在构建时即可验证。

分类不读取异常 message,避免 SQL、路径或上游响应被自动返回。返回值由受控规则组成,默认内部错误与具体异常的记录应在日志边界完成。

为什么这样写是对的向上遍历到父类时,所有更近的异常类型均已确认没有规则;一旦命中即可返回最具体的已注册映射。

时间 注册均摊 O(1),单次分类 O(H)额外空间 O(R)

R 为规则数,H 为异常类继承高度;分类每层至多查一次哈希表,不随全部规则数线性扫描。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 47 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
import java.io.IOException;
public class Main {
    static class Business extends RuntimeException {}
    static class StockEmpty extends Business {}
    record Result(int status, String code) {}
    static class Classifier {
        private final Map<Class<? extends Throwable>,Result> rules = new HashMap<>();
        void register(Class<? extends Throwable> type, int status, String code) {
            Objects.requireNonNull(type);
            if (status < 400 || status > 599 || code == null || code.isBlank())
                throw new IllegalArgumentException("bad rule");
            // 先拒绝重复,避免替换原有错误契约。
            if (rules.containsKey(type)) throw new IllegalArgumentException("duplicate");
            rules.put(type, new Result(status, code));
        }
        Result classify(Throwable error) {
            Objects.requireNonNull(error);
            // 从实际类型逐级向上,第一条规则就是最具体规则。
            for (Class<?> c = error.getClass(); c != null; c = c.getSuperclass()) {
                Result r = rules.get(c);
                if (r != null) return r;
            }
            // 默认结果不携带异常消息,避免自动泄露内部数据。
            return new Result(500, "INTERNAL_ERROR");
        }
    }
    static void eq(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) throw new AssertionError(actual);
    }
    static void reject(Runnable test) {
        try { test.run(); throw new AssertionError("expected rejection"); }
        catch (IllegalArgumentException expected) {}
    }
    public static void main(String[] args) {
        Classifier c = new Classifier();
        c.register(Business.class, 409, "BUSINESS_CONFLICT");
        eq(new Result(409,"BUSINESS_CONFLICT"), c.classify(new StockEmpty()));
        c.register(StockEmpty.class, 422, "STOCK_EMPTY");
        eq(new Result(422,"STOCK_EMPTY"), c.classify(new StockEmpty()));
        eq(new Result(500,"INTERNAL_ERROR"), c.classify(new IOException("password=secret")));
        reject(() -> c.register(Business.class, 400, "OTHER"));
        eq(new Result(409,"BUSINESS_CONFLICT"), c.classify(new Business()));
        reject(() -> c.register(IOException.class, 200, "OK"));
        System.out.println("PASS 6 exception mapping tests");
    }
}

这些特殊情况也要试一试

  • 没有匹配规则时使用安全默认值,不能返回 null 并让成功路径继续。
  • 父类和子类同时注册时,子类优先,与注册顺序无关。
  • 重复类型必须失败且保持旧映射,非法状态不得进入规则表。
进阶选读 · 多维错误映射与协议兼容演进

场景同一异常对 JSON 和 HTML 客户端返回不同内容,且多个 Advice 按业务域划分。

做法把作用域、Advice 顺序、异常距离、媒体类型兼容与具体程度作为有明确顺序的选择阶段;在启动时检测同一选择键歧义,并为旧客户端保留稳定业务码。

复杂度朴素匹配 O(RH),可按异常类型与媒体类型缓存;缓存空间需要上限。

好处与代价更丰富的规则表达能力提高维护成本,不能简单比较几个数字代替真实 MVC 语义,必须有集成测试覆盖协商与顺序。

05项目里怎么用

返回 DTO 时,明确选出允许展示的字段。这样以后数据库实体新增密码摘要等敏感属性,也不会因为整份复制就跟着返回。更新接口同样要列清允许修改的字段:即使客户端传了 ownerId 或 role,也不能自动改变记录归属或用户权限。数据库约束负责阻止不合规则的数据落库,业务授权负责判断这个人能不能修改;两者都需要。

状态码要让调用者知道下一步怎么办:格式错误通常用 400,未认证和不允许访问分别考虑 401、403,不存在用 404,当前状态冲突可用 409。为隐藏资源是否存在,有些系统会统一用 404,但应明确记录这个策略。是否采用 422 取决于校验约定,不能把示例的库存状态码照搬到所有接口。

监控要同时观察 HTTP 状态与业务结果,不以 200 数量当作业务成功率。错误日志应可关联请求、版本和用例,却不要直接记录完整密码、令牌或敏感请求体。已知业务拒绝与系统故障可以使用不同日志级别,避免告警被正常校验失败淹没。

容易踩的坑

  • 让 Entity 直接接收所有请求字段,形成越权修改入口。
  • 将未知异常 message 原样放到 detail 中,暴露 SQL 或路径。
  • 所有失败返回 200,使客户端与监控无法利用 HTTP 语义。
  • 误以为全局 Advice 能改写已经提交的任意流式响应。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
统一 ProblemDetail协议字段通用,便于工具和客户端处理。仍需定义业务码及字段错误扩展。面向多客户端的新 HTTP API。
既有 Result 契约兼容已有调用方。需同时保留正确 HTTP 状态并维护结构规范。有稳定历史客户端的接口。
按用例显式 DTO输入和输出白名单清晰。映射代码与版本演进需要维护。包含权限、敏感字段或长期外部契约的系统。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01DTO、Entity、VO 的命名是否是 Java 强制标准?

展开参考答案与推导

不是。应在项目约定中说明职责,关键是输入、持久化和输出边界。

02统一错误体为何仍需状态码?

展开参考答案与推导

状态服务于 HTTP 客户端、代理和监控,错误体提供更细业务解释。

03字段校验通过能证明有权操作资源吗?

展开参考答案与推导

不能。资源授权必须结合服务端身份与资源归属或策略判断。

换个情况,还会处理吗

01新增实体字段 passwordHash 后列表意外出现该字段,如何修复?

展开参考答案与推导

将响应映射为显式白名单 DTO,并增加敏感字段不出现的契约测试。

02两条全局异常规则都可能处理一个异常怎么办?

展开参考答案与推导

核对 Advice 顺序、作用范围、异常类型与媒体类型,避免靠声明顺序猜测。

03客户端收到 500,但页面需要可报告的编号怎么办?

展开参考答案与推导

返回安全关联标识,服务端日志用同一标识记录原因,不把堆栈交给用户。

写代码试一试

01怎样验证规则注册顺序不影响结果?

输入 / 条件
交换 Business 和 StockEmpty 的注册顺序
输出 / 预期
StockEmpty 仍映射到 STOCK_EMPTY

提示 沿类层次从近到远。

展开参考答案与推导

建立两个分类器反向注册,断言对同一子类结果相同。

02如何确保重复注册不污染原规则?

输入 / 条件
Business 先注册 409,重复注册 400
输出 / 预期
重复失败后仍返回 409

提示 先检测,再写入。

展开参考答案与推导

把原映射结果作为失败后的断言,不能只断言抛异常。

03如何构建安全员工响应?

输入 / 条件
实体含 id、name、passwordHash、role
输出 / 预期
按接口授权只包含允许字段

提示 显式投影而非整对象复制。

展开参考答案与推导

使用响应 record 并逐字段构造;测试序列化结果不包含密码摘要和未授权管理字段。

商户项目 · 本篇加练

只读商户 REST:写清每一种响应

核查日期:2026-10-11。状态:接口约定和验收案例尚未运行,不代表已有后端服务可用。

目标与前置知识

将商户查询的正常结果、错误输入和服务故障分开。先会集合查找、HTTP 状态码、Controller / Service 分层和参数校验。已有“接口分层与异常处理”保留,本节在其后补一个连续练习。

步骤

  1. 先记录实际练习项目的 JDK、Boot、Spring Framework 及依赖树。网站自身是展示资料的前端项目,没有可据此确认的 Java 后端依赖组合;不要把资料页面的版本拼成新的依赖清单。
  2. 约定只读入口 GET /api/merchants?merchantId=1001,身份由已验证的服务端会话或令牌提供,不能信任查询参数中的 tenantId。A 只能查 1001,B 只能查 1002;9999 不存在。教学数据 1001 为“测试茶铺”,营业时间 09:00–18:00。
  3. 参数先绑定并校验,再检查可信身份是否有 merchant:read 权限;通过后按服务端身份中的 tenant 加 merchantId 限定查询。A 查 1002 与 A 查 9999 对外统一返回 404“商户不存在或当前身份无权访问”,不泄漏编号存在性。只有教学夹具知道 9999 全局不存在,应用不能据此声称已查询并确认全局不存在。方法授权和租户查询约束承担不同职责。
  4. 正常响应只放编号、名称、营业时间和数据版本等允许展示的字段。错误响应使用稳定 code、面向用户的 message 和 requestId;堆栈、SQL、令牌不进入响应。
  5. 从应用入口通过真实 HTTP 请求验收,记录响应及存储/下游调用。浏览器看到 JSON 只覆盖那一次请求,不等于所有错误分支已验证。

验收清单

场景设计期望调用边界
A 查询 1001200,测试茶铺,09:00–18:00只查有权记录
缺 merchantId400,明确缺参商户查询下游 0 次
abc、0、负数、溢出编号400,明确非法参数商户查询下游 0 次
A 查询 1002404,商户不存在或当前身份无权访问允许进入有授权的只读查询入口,但只能查 tenant=A,不能取得 B 的资料
A 查询 9999同上 404夹具全局不存在;实际只证明租户范围内未查得,不当基础设施故障
数据库或下游不可用本练习映射为 503,稳定错误格式不伪造空商户或成功

越权与不存在的外部响应相同,内部断言不同:前者还要核对没有跨租户读取,后者核对夹具确无 9999。工具入口被调用不等于已经越权读取数据。提交接口约定、请求表、状态和响应断言、实际请求记录;实际结果目前留空。读取操作不能顺便触发订单、支付或其他业务写入。

原始来源与阅读范围

Spring REST 入门:读 Create a Resource Representation Class、Create a Resource Controller、Test the Service。官方例子是带可选 name 的问候接口;本节必填商户编号、错误矩阵和权限约定是本站自拟扩展。页面写 Java 17+,没有证明用户所给各版本可直接兼容。

商户项目 · 延伸练习 · 未运行

同一约束要从 HTTP 和工具入口分别验收

核查日期:2026-10-13 00:11 JST;2026-10-16复核相关文档。来源为 Spring Framework 7.0.9、Spring AI 2.0.1,发布日期未标明。尚未编译或运行双入口校验。

先读参数绑定与校验,并准备商户工具调用入口。继续使用本站的参数名 merchantId,避免同一练习又增加一个含义相同的编号字段。明确目标 Java 整数类型,以及是否允许数字字符串。

Spring MVC Validation提示:控制器签名不同,可能分别出现对象参数校验异常和方法参数校验异常。已有 HTTP 校验通过,并不能证明绕过控制器的工具入口也触发同样规则;@ToolParam 可描述参数并声明是否必需,这类输入说明不能代替执行中的业务检查。两个入口应到达同一套受控约束,再进入数据库或检索器。

先固定一份教学契约:编号为目标类型可表示的正整数,检索 topK 为 1–10,问题去除首尾空白后长度为 1–200;这些数值只用于本练习。规定缺失、转换失败、范围错误、授权范围内未查得四类结果,身份仍由服务端提供。

  1. 把缺编号、null、0、负数分别送到 HTTP 和真实工具适配器,核对拒绝阶段与商户查询下游零调用。另加入有 @Valid 却没有实际值约束的错误配置,确认测试能发现它。
  2. 输入非数字文字、小数编号和超出目标整数类型的值,检查没有截断、溢出成另一编号或猜测修复。数字字符串是否接受,以预先写定的契约为准。
  3. 检索上限测试 0、1、10、11;问题测试空白、长度边界与超限。非法请求不得进入向量查询;若设计选择截断,应明确写入契约并验证转换结果。
  4. 让相同合法请求、授权范围内未查得请求和非法请求走两个入口,对比规范化值、业务分类与下游次数。HTTP 状态和工具错误对象可以不同,但同一错误不能在另一入口被改成成功。

提交入口、虚构原输入、规范化结果、校验阶段、安全字段路径、错误代码及下游调用数。真实项目采用方法代理还是显式 Validator,都要证明实际调用触发了检查。MVC 6.1 起的内建方法校验与类级 @Validated 有配置差别,不可把来源版本拼成已验证依赖组合。Tool Calling的参数生成也不提供“全部 Bean Validation 已执行”的保证。格式合法仍不等于授权通过。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

商户列表:稳定排序、分页起点与终止条件

先完成本页只读单商户接口,再读固定快照上的稳定游标分页和联合键边界与稳定倒序游标分页。已有文章解释唯一顺序和内存快照;下面练习数据库滚动位置及 HTTP/工具的续页约定。

核查日期:2026 年 10 月 13 日 12:07 JST(UTC+9)。来源:PostgreSQL LIMIT and OFFSET(页面主版本18,导航标18.6)及 Spring Data Commons Scrolling(4.1.1)。两页正文发布日期均未标明;PostgreSQL页顶新闻日期不作为该节发布日期。适用:从单商户查询扩展到列表接口或Agent“下一批”工具时;前置为SQL排序、请求校验、商户唯一ID。本节是尚未运行的教学设计;不假定原项目使用PostgreSQL或此Spring版本。

官方事实:PostgreSQL要求LIMIT配合能形成唯一顺序的ORDER BY,否则不同分页条件可能得到不一致子集;大OFFSET仍需计算被跳过的行。Spring Data滚动位置是排他的,下一批从所给位置之后开始。ScrollPosition.offset()表示起点,而offset(0)代表第一条的位置,会从它之后取数,导致首条被跳过。Keyset方式按排序字段及主键重建条件;排序字段需非空,投影需包含全部排序属性,且适配排序的索引有助于查询。这里描述框架机制,不保证任意自写SQL自动补主键。

本节使用独立的分页假数据夹具,1001–1004 仅代表该用例内的记录;每次查询仍限于测试身份被明确授权的数据集。不要沿用单商户练习中“A 只能查 1001、B 只能查 1002”的身份,然后把四条记录全部交给 A。

最小教学契约:固定数据按createdAt升序、merchantId升序形成唯一顺序,两字段在本实验中非空且不修改。页大小限定1至20,默认2,仅为练习边界;HTTP和工具入口使用同一约定。工具返回items、hasNext及服务端生成的nextCursor;模型只原样传递合法游标,不自行加减页号、推算游标或声称已列完全部商户。hasNext为假时停止,不能因“正好取满一页”就断言没有下一页。筛选或排序改变时重新开始,不能沿用旧查询位置。

三组可控练习:

  • 同值排序与起点:设1001、1002、1003的createdAt相同,1004较晚;页大小2,固定快照下期望先1001/1002、再1003/1004,无重复遗漏。检查数据库实际排序含merchantId。使用Spring滚动API时分别测试offset()和offset(0),让断言发现跳首条问题;不要把page=0、SQL OFFSET 0和滚动位置0视为同一个概念

  • 边界与终止:空集合、1条、恰好2条、3条及非法页大小分别走HTTP和工具适配器。服务端可采用额外读取1条的方法判定hasNext,再只返回约定条数;这是实现选项,不是所有仓储默认行为。检查最后页无继续游标、越界或非法输入的错误稳定,模型不会反复请求相同终止页;未知总数不填写0或伪造total

  • 跨页数据变化:第一批完成后插入排序位置更靠前的假商户,再请求第二批,分别观察OFFSET和Keyset结果;另在隔离用例修改排序字段或删除未读行。记录具体重复/遗漏与变化顺序,不把静态数据通过推广为并发数据无遗漏。Keyset只定位排序边界,本身不是跨HTTP请求的固定快照;若业务要求“完整导出同一时刻集合”,需另设计快照或物化结果契约

记录每批筛选条件、排序字段与方向、位置类型、页大小、返回ID顺序、hasNext、游标是否存在及并发事件序号。用确定性屏障控制插入发生在两次查询之间,不能靠睡眠猜时序。静态数据可以断言完整集合相等;变化数据按预先约定的可见性语义验收,不能为了通过而只去重最终列表掩盖遗漏。

限制:没有执行SQL、编译仓储或测量性能;具体Spring Data模块对滚动查询的支持、数据库索引、空值排序及时间精度仍须核对。游标不是访问凭据,每次查询仍须应用当前服务端授权范围;游标格式、完整性和过期处理留待单独核查。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

游标与查询条件绑定,以及失效后的重新开始

接在本页商户列表分页练习之后,沿用独立假数据和服务端授权范围。前置知识是游标、规范化筛选条件和稳定错误响应。

核查日期:2026 年 10 月 13 日 18:08 JST(UTC+9)。来源:Google AIP 158 Pagination,状态Approved;页侧Created及Updated均标2019年2月18日,但正文Changelog另列2025年7月8日澄清跳过结果行为。因此记录两处日期,不把侧栏日期误称为最后内容更新时间。适用:上一节商户列表支持“下一批”之后;前置为游标、筛选条件、错误分类和假数据。本节未实现或运行。AIP是Google API设计指导,不是所有HTTP接口必须遵循的通用协议。

官方事实:续页时除page_size外其他参数应保持一致,改变时建议返回INVALID_ARGUMENT;page_size可在续页时改变,服务应尊重新值。令牌只表示续页位置,不能替代每次请求的授权。令牌应为不透明且URL安全的字符串,简单Base64编码透明内容不足以达到文档的不透明要求。服务可以让保存的令牌过期;文档的三天是经验提示,不是统一有效期。另一个边界是零条结果仍可能附带下一页令牌,不能仅凭items为空就认定全集结束。

教学设计:服务端把游标与规范化的查询上下文关联,至少包括接口用途、筛选条件、排序和适用的数据范围;每次续页核对一致性。选择服务端保存的opaque句柄,或经完整性保护的自包含表示,需要项目另行决定,本节不生成密钥、不给出未经验证的编码实现。自包含表示也需要满足不透明要求:完整性保护只检查是否被篡改,不等于隐藏内部字段;不能仅给可读内容加签名就认定满足该要求。客户端和模型只回传原字符串;内部编码是否能解码,不构成授权或防篡改证明。

为商户工具明确三类状态:有效游标继续;查询条件不匹配则要求移除游标并开始新查询;未知、损坏或过期游标返回稳定安全错误,并说明需要重新开始,不能悄悄退回第一页后仍声称“继续”。CURSOR_INVALID与CURSOR_EXPIRED可作为教学错误码,不是AIP规定的统一代码;是否对外区分取决于接口契约,不能暴露内部存储细节。重新开始产生新的遍历,已收集的旧结果不应自动并入并宣称是同一快照。

三个未运行验收:

  • 条件绑定与例外:先按城市A、营业中生成游标,再改为城市B或改排序,预期明确拒绝续用;相同规范化条件正常续页。若本项目选择遵循AIP规则,只将页大小从2改为3应允许,不能把它误判为筛选变化;仍应用服务端上限。上一节页大小1至20是教学自定契约,与AIP对0表示默认值的规则不同,采用哪套语义要明确,不混写成框架默认

  • 失效与重开:用可控时钟使假游标失效,并分别提交随机字符串、截断字符串及旧版本游标,检查安全错误、无正常查询执行、无静默第一页回退。模型收到重开提示后必须把后续请求当作新遍历;重复同一失效游标不能形成无限工具循环。记录假时间与错误类别,不用实际等待三天

  • 空页与续页:测试替身先返回items为空但有nextCursor,再返回有效商户,最后返回无nextCursor。按契约映射hasNext,不能在空页提前停止,也不能在结束后继续。若服务反复返回相同游标,触发客户端循环保护并报告“遍历未完成”,不能伪装正常结束;步数预算是应用保护,不保证已经收集全集

记录初始查询标识、规范化条件是否一致、页大小、游标状态(不记录真实令牌全文)、错误码、是否重新开始及每步工具次数。限制:未运行任何协议测试;令牌持久化、有效期、完整性保护及版本迁移仍需实际项目设计。有效游标不保证数据未变或授权仍有效。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

时间字段与营业日查询:先固定含义,再计算边界

先读日期与时间:分清日历时间、时间点和时区,再进入本页商户列表筛选。已有文章解释 Java 时间类型、固定 Clock 与夏令时;本节把这些约束接到接口、数据库映射和续页。

核查日期:2026 年 10 月 14 日 00:06 JST(UTC+9)。来源:Java Time Zone and Offset(页示最后更新2022年1月27日,未绑定单一JDK版本)与 PostgreSQL Date Time Types(主版本18,导航标18.6,正文发布日期未标明)。适用:商户创建时间筛选、按营业日查记录及Agent解释“今天”时;前置为日期解析、数据库字段映射和范围查询。本节例子和测试均未运行,不假定项目数据库类型。

数据库映射事实:Java 的区域时区与固定偏移先按前置文章区分;PostgreSQL 的timestamp without time zone会忽略输入中的时区指示;timestamptz将时刻按UTC保存,输出依当前会话时区转换,并不保留原始时区。文档也说明夏令时切换可能让一天为23或25小时。由此不能把“全部字段存成无时区字符串”或“每个营业日固定加24小时”作为通用方案。

教学契约:createdAt表示已发生事件的时刻,接口要求带明确偏移或Z并规范化比较;“商户当地2026-10-13”是业务日期,需要另有商户区域时区,如Asia/Tokyo。每周09:00营业是当地营业规则,不是一个唯一UTC时刻,不能只保存本次换算结果代替整套营业规则。Agent工具将日期、业务时区和已解析时刻区分为不同参数含义;“今天”按约定的商户时区和请求开始时刻解析一次,续页沿用固定边界,不能午夜后重新解释为另一天。未知商户时区不能悄悄使用服务器默认值。

日期筛选建议采用左闭右开范围:当地日期起点包含,下一当地日期起点不包含,再将两端转换成时刻比较。东京2026-10-13对应UTC起点2026-10-12T15:00:00Z、终点2026-10-13T15:00:00Z;这是时区换算示例,不是查询实测。用下一天起点代替“23:59:59.999”,避免依赖数据库的小数精度。其他区域须按其日期规则分别求两个起点,而不是复制东京偏移。

三个隔离验收:

  • 同时刻与无偏移负例:2026-10-13T09:00:00+09:00和2026-10-13T00:00:00Z应归一到同一时刻,查询结果相同;同字段收到2026-10-13T09:00:00时按上述契约拒绝。另在假数据库映射用例对比有/无时区类型,验证偏移没有被静默丢失;不以展示字符串相同作为通过标准

  • 东京跨日边界:固定假记录分别位于UTC起点前一存储精度单位、恰好起点、终点前一单位和恰好终点;只包含中间两条。把测试进程默认时区设为UTC及Asia/Tokyo分别运行,预期业务范围相同;此处是待运行测试步骤,没有修改真实环境设置

  • 区域规则与上下文:从测试JDK实际ZoneRules选取有夏令时切换的日期,验证相邻当地日边界不强行相隔24小时,并记录JDK及规则版本。若接受当地日期时间输入,需明确重叠/不存在时间的拒绝或选择政策,不把API默认调整当业务确认。另固定请求时钟在东京午夜前发起“今天”查询、午夜后续页,检查仍使用原始日期和边界

记录字段语义、原输入、ZoneId或偏移、规范化时刻、两端边界、存储精度、最终包含的假记录ID及解析错误。若原始区域或偏移本身具有业务价值,单独保存对应字段;不能期待timestamptz往返恢复它。限制:未编译或执行数据库测试;JDBC/ORM映射、序列化格式、时区数据库版本和小数舍入仍须核对。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

商户查询计划:先核对结果,再比较组合索引

先完成本页分页与时间边界练习,再读MySQL 索引与 EXPLAIN及慢查询诊断。下面是 PostgreSQL 18 文档对应的独立验收设计,不能把 MySQL 或 Java 类比的结果直接迁移过去。

实际核查时间:2026 年 10 月 15 日 06:11 JST(UTC+9)。来源:PostgreSQL Using EXPLAIN 与 Multicolumn Indexes,当前主版本18,文档导航标18.6;正文发布日期未标明。适用:已有正确的商户筛选、时间边界和稳定排序后;前置为SQL、B-tree基础及隔离假数据。本节全部实验未运行,不假定原项目使用PostgreSQL。

官方事实:EXPLAIN的cost是规划器成本单位,不是毫秒;rows表示节点预计输出行数,不等于扫描行数。EXPLAIN ANALYZE会真正执行语句并带来原有副作用,且其测量不等于完整接口端到端耗时。组合B-tree通常在前导列有约束时更有效;18版文档也描述缺少某些前导等值条件时可能采用skip scan,不能绝对断言“不满足最左列就一定不能用索引”。小表或高命中比例时顺序扫描可能合理。

教学查询:在假商户列表按city等值筛选、createdAt左闭右开范围筛选,并按createdAt、merchantId升序取前20条。只取所需展示字段,沿用前面时间与排序契约。提出(city, createdAt, merchantId)作为候选组合索引,与只有city索引及无新增索引的基线比较;这是待验证候选,不是生产建索引指令或保证最优的建议。先证明返回ID和顺序相同,再比较执行代价。

三项未运行对照:

  • 同查询不同索引:在可恢复隔离夹具中保存各方案的计划,观察Index Cond、Filter、Sort或Incremental Sort、Limit以及各节点预计行数。若后续获准运行EXPLAIN ANALYZE,只针对确认无副作用的假数据SELECT,记录actual rows、loops、过滤掉的行及缓冲区信息;不要把“有Index Scan”当唯一通过条件,也不要把父子节点累计成本重复相加

  • 同结构不同选择性:分别选数据占比高和低的城市,再比较短与长时间范围。记录数据总量、分布和统计信息状态,解释计划为何变化;用少量均匀假行得到的结论不能外推真实大表。若估计与实际不符,先排除LIMIT提前停止和多次loops等含义,再检查统计与分布,不立即强制索引

  • 改变查询形状:保留时间范围但去掉城市条件,或改为全量导出不带LIMIT,观察候选索引是否仍有价值。对缺少前导条件的情况只记录实际计划,不保证出现skip scan。把数据库执行、结果序列化和HTTP/工具端到端耗时分别测量;计划成本下降不能直接写成用户响应快了多少

观察表应记录数据库版本、SQL形状及参数类型、数据规模与分布、索引定义、统计采集状态、计划节点、估计/实际输出行数、loops、排序方式、缓冲区计数、测量次数及冷热缓存条件。没有运行的数值留空。至少核对结果正确性与不同参数下的表现,避免仅挑一次最快结果;索引空间和写入维护成本也须另评估。

安全与限制:本节没有创建索引、执行 SQL、更新统计或修改数据库设置,没有毫秒、倍数或吞吐成绩。EXPLAIN ANALYZE不是只读计划预览,不能在真实写操作上随手运行,也不把事务回滚当作消除一切副作用的保证。文档中的18版特性须与实际数据库核对;不通过关闭顺序扫描等手段制造“索引生效”结论。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

一次商户列表请求到底发出了多少条 SQL

接在本页查询计划对照之后。先会实体关联、DTO 和连接与结果集的资源范围,再检查完整 HTTP 正文或工具结果生成期间的数据库往返。

实际核查时间:2026 年 10 月 15 日 12:14 JST(UTC+9)。来源:Hibernate 关联抓取指南 第8.4至8.6及8.17节,与 Statistics API。指南页标7.1.36.Final,最后更新2026年9月17日19:29:13 UTC;API位于7.1文档路径,单页发布日期未核实。适用:上一节单条SQL计划验收之后,检查一次商户列表HTTP或工具请求的整体数据库往返;前置为实体关联、DTO及隔离测试。本节实验未运行,不假定项目使用 Hibernate。

官方事实:先查N条主记录,再逐项读取关联而触发N次查询,是典型N加1问题,也可能出现在手写DAO中。Hibernate提供join、batch和subselect抓取;批量抓取能减少往返,但不保证只剩一条SQL。按当前用例明确抓取所需关联,不等于把所有映射改成默认EAGER;同时join多个多值关联可能产生笛卡尔积和很大的结果集。第8.17节说明可启用统计,Statistics API明确其覆盖同一个SessionFactory的全部会话;getPrepareStatementCount统计取得的预编译语句数,不能直接改名为数据库执行次数。

教学设计:假商户列表返回merchantId、displayName和categoryName,先固定每店一个分类,再加入多个商户共享同一分类的对照。计数边界包含查询、DTO构造和最终响应序列化;否则关联读取若发生在后两阶段,单测仓储方法可能漏报。这是待验收的应用设计,不是已发现原项目故障。

三个未运行练习:

  • 增长对照:在独立会话与明确缓存条件下,分别请求1、5、20家假商户,完整生成HTTP正文或工具结果。记录主查询、关联查询、分页count查询及其他SQL的形状和次数;观察随返回条数增长的重复关联查询。共享分类及缓存命中可能使次数小于1加N,不能只用是否恰好等于21判断问题。空列表另测,不把合理的count查询误判为N加1

  • 抓取方案对照:对同一业务结果比较逐项读取、显式所需关联抓取或DTO查询、批量抓取候选。先断言ID顺序、字段、空关联处理和分页契约相同,再比较往返及实际返回行数。两条可解释的查询可以优于一条膨胀结果的查询;若扩展到多个集合,另核查行数乘积,不以“SQL变一条”作为唯一验收。不得为了减少计数把无权或无用字段一起返回

  • 计数隔离:仅在隔离测试夹具中使用独占SessionFactory或请求关联的数据库观测,夹具准备完成后开始计数。若取全局Statistics前后差值,必须保证期间没有其他请求、后台作业或测试共享该实例;不能在共享线上实例调用clear来制造单请求统计。区分HQL查询计数、语句获取计数和JDBC实际执行事件,用可控简单查询核对所选观测口径后再写阈值

记录请求标识、入口、页大小、实际结果条数、不同关联ID数、事务与会话范围、一级及二级缓存条件、查询形状分类、计数工具与准确含义、序列化是否包含、返回行数和正确性断言。若用日志,只记录假数据和必要SQL形状,不启用真实敏感参数外泄。性能计时与调用次数分别登记,数值未知留空。

限制:未编译、运行或配置Hibernate统计,没有真实SQL次数、耗时或优化收益;未修改依赖或应用。JDBC驱动、缓存、分页实现及实际Hibernate版本均须项目核对。SQL 次数与单条 SQL 的索引效果分开验收。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

把连接获取、SQL 调用和结果读取分段诊断

先完成本页整次请求 SQL 次数观察,再读JDBC 与连接池。已有文章区分各类超时与归还资源;本节用屏障、替身和池级指标检验具体归因。

核查时间:2026 年 10 月 15 日 18:10 JST(UTC+9)。来源:HikariCP 官方 README 与 Spring Boot Metrics。HikariCP当前展示dev分支、依赖示例7.1.0,README最后修改栏为2026年6月14日;这不是单独核验的版本发布日期。Boot页标4.1.1,正文发布日期未标明。适用:已完成上一节SQL次数观察、但接口仍慢时;前置为JDBC连接生命周期、异常清理及隔离测试。本节实验未运行,项目实际连接池与依赖版本待核对。

与已有连接池说明衔接:connectionTimeout 约束取得池连接的等待,不能当作 SQL 执行超时;leakDetectionThreshold 只报告可能持有过久,不能证明永久泄漏。Boot 的数据源指标以jdbc.connections为前缀,提供当前活跃、空闲及允许最大/最小连接数;Hikari专用指标另有hikaricp前缀,按池名标记。这些池级观测不能直接解释某一条请求的全部延迟。

教学计时边界:分别记录请求开始、调用getConnection、成功取得连接、各次SQL调用开始/返回、结果读取完成、连接归还、响应完成。获取连接区间可能包含池等待、创建或校验,不能一律叫排队时间;连接持有时间还可能包含结果读取、事务和应用工作,不能当成SQL时间。JDBC调用耗时也包含驱动/网络等开销,不等于数据库服务端纯执行时间。异步与重叠区间不能简单相加成接口总耗时。

三组未运行练习,限可恢复的假数据夹具:

  • 等连接但SQL未开始:测试专用池上限设为1,由受控任务持有连接并用屏障暂停,另一请求执行商户查询。分别在获取超时前归还,以及持续持有至超时;检查前者取得连接后才执行查询,后者没有执行该商户SQL。失败标记为连接获取阶段,不能记录一条并不存在的“慢SQL”;池自身校验等SQL与业务查询分开计数。所有持有连接最终在清理块归还

  • 已拿到连接但SQL阶段变慢:保持池有空闲,使用可控JDBC替身延迟查询返回,观察连接获取短而SQL调用区间长;对照上一用例。替身只能验证计时与分类,不能据此判断真实数据库锁、执行计划或网络故障。若未来接隔离数据库测试,须另外采集数据库侧证据,不用提高connectionTimeout来冒充修复SQL超时

  • 异常后是否归还:在取得连接后、结果读取中分别注入受控异常,验证资源清理,随后正常请求仍能获得连接。用屏障确认前一个请求已完成清理,再观察池状态与下一请求,避免依赖一次瞬时gauge。另让合法长操作超过泄漏告警阈值再正常归还,区分超时持有告警与真正漏关;不能只凭告警数量宣布泄漏已修复

记录请求ID、池名、连接获取结果、失败阶段、各区间及计时单位、业务SQL次数、结果读取完成与归还证据、同窗口活跃/空闲数。计时使用同进程单调时钟;瞬时池指标与请求事件结合解释,不从一张截图推导排队因果。只用假身份和SQL形状,真实参数、凭证及敏感数据不进入诊断记录。

限制:未运行上述实验,未启用指标端点、安装监控、调整池大小或超时,无耗时和吞吐结论。池大小1仅用于制造可控教学竞争,不是生产推荐;增加最大连接数也不必然改善性能,需结合数据库容量和工作负载另测。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

金额在 HTTP、Java 与工具之间的精度和单位

先读 BigDecimal 的构造、舍入与比较和两个工具的金额单位规范化,再完成本页HTTP 与工具双入口校验。本节适用于商户查询 DTO 和工具输入校验已经明确之后,补充同一金额经过 HTTP、Java、工具结果与展示文本的往返检查。

资料原核查:2026 年 10 月 17 日 00:05–00:06 JST。来源为 RFC 8259 第 6 节(2017 年 12 月)、OpenJDK BigDecimal 源码与 API 注释(当时 master 页面显示文件提交短号 52c1cf3,正式发行版本和发布日期未核实)及 Google Money 协议定义(master 移动分支,发行版本和日期未核实)。这些版本记录不要求改变练习项目依赖。以下往返与边界练习均未运行。

从已有金额规则延伸到传输边界

RFC 8259 允许实现限制数字的范围与精度,常见 binary64 实现共同精确表示的整数区间为 −(2^53)+1 至 (2^53)−1。JSON 文本合法不代表每个接收端都无损。已有 BigDecimal 文章解释从字符串构造及数值与 scale 的区别;在这条链路中还要确认序列化或展示阶段没有重新转成 double。

只对假商户展示价格约定:amount 是主货币单位的十进制字符串,currency 来自服务端允许的币种;Java 从原始字符串构造 BigDecimal。写明字符串语法、长度和数值范围、允许小数位、负数政策及是否接受指数形式。字符串是传输选择,仍须校验。既有接口若使用最小单位整数,就保留该字段的明确含义并验整数边界,不能静默改类型。

Google Money 把币种、整单位 units 与十亿分之一单位 nanos 分开,并约束范围和正负号。这是另一套有明确刻度的表示法,不是要求本练习接入该协议或统一允许九位小数。币种规则、定价精度和支付渠道规则分别确定;模型展示保留已有币种和单位,缺失时不自行补齐或换算。

建议练习:逐段保留同一笔价格的证据

  1. 十进制往返。 将 "0.10" 和 "19.99" 分别送到 HTTP 与工具入口,记录原始 JSON、Java 数值与 scale、最终工具 JSON 和展示文本;断言数值不变、币种不丢失。用 BigDecimal(0.1) 作故意错误对照。把“2.0 与 2.00 数值相等”和“展示两位”分开检查。
  2. 大整数与单位。 仅作传输压力夹具,比较 9007199254740991 与 9007199254740993 经实际客户端、序列化器和工具结果后的原始文本;后者超出上述共同精确整数区间,不能预先断言所有解析器都会失真。超业务上限的输入应在入口拒绝。把同一个 100 分别标成主单位与最小单位,检查转换确有契约依据;缺币种、单位不明或币种不同时,不直接拼接总价。
  3. 文本位数与可无损缩放。 夹具自定 USD 展示价两位、JPY 展示价整数,仅为本练习约定。输入 USD "1.005"、JPY "100.5" 和 "1.000",按预先声明的拒绝或处理规则验收。setScale 返回新值;UNNECESSARY 拒绝确需舍入的缩放,末尾多余零未必报错。若要求原文本最多两位,还需独立语法校验,可参看完整数字解析与业务约束。缺失价格与零价格分开,不能把缺失写成免费。

交付入口、原始文本、币种与单位契约、Java 数值/scale、工具输出、展示结果及逐项断言。舍入只发生在明确的业务步骤,不能为了让测试通过临时补舍入规则。

限制:没有编译代码、运行 JSON 往返或调用模型,没有通过率。实际 Jackson 配置、客户端数字类型、数据库列精度和业务货币规则尚待项目验证;本节仅使用假展示价格,不涉及真实付款或换汇。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

查询结果、经营状态与指定时刻营业判断

先完成本页只读商户响应约定、营业日与时区和工具安全错误分类。适用阶段:商户详情接口和 Agent 工具 DTO 已具备后,核对“查询有没有完成”“商户怎样经营”“指定时刻是否开门”三个含义。

资料原核查:2026 年 10 月 17 日 06:08 JST;2026 年 10 月 20 日复核 Google 的 BusinessStatus 与 OpeningHours 定义。Google Places API v1英文页仍标最后更新 2026 年 8 月 6 日 UTC。HTTP 依据为 RFC 9110 的 404、410及 503,版本日期 2022 年 6 月。本节固定夹具、实际接口和模型验收均未运行。

成功拿到详情,不等于此刻营业

Google 分别定义 OPERATIONAL、CLOSED_TEMPORARILY、CLOSED_PERMANENTLY 与 FUTURE_OPENING;其中 OPERATIONAL 不保证此刻开门。其 OpeningHours 还区分 periods 为空与字段不存在。适配层不能把缺失一律变为空数组,再解释成不营业。这些是该供应商的定义,不会自动成为本项目契约,也不要求为练习接入收费 API。

404 表示未找到目标资源的当前表示,或服务不愿透露其存在,单独不能判定临时或永久。410 表达资源访问不可用且很可能永久,仍不能证明现实商户倒闭。503 表达服务暂时不能处理请求,不能改写成商户不存在或停业。基础接口已有的安全 404 映射继续保留;有关权限的隐藏信息不能为“更准确解释”而透露。

教学设计把查询结果 lookupOutcome、来源经营状态 businessStatus、指定时刻判断 openAtRequestedTime 分开;字段名与枚举由项目明确。暂时停业商户的详情可以成功取得;查询超时只说明本次没查成,不能覆盖已知经营状态。无匹配只支持“本次数据源与查询范围内未查到”,搜索空列表不能替代按唯一 ID 的存在性确认。可对照批次结果中的否定与未知,再检查经营状态这一层。

建议练习:保留三种含义

  1. 详情成功,经营状态不同。 同一假商户依次返回暂时停业、永久停业、正常经营但指定时刻未营业。前两者不能变成“商户不存在”,最后一项不能变成“已停业”。仅有 OPERATIONAL 而没有时段依据时,应表达“正常经营,但无法确认此刻是否开门”。
  2. 没查到与没查成。 分别模拟已完成的无匹配查询、上游 503、隐藏资源的 404 与资源 410,保存原始状态、适配结果及最终回答。503 不变成 NOT_FOUND;404/410 不变成“永久倒闭”。对外分类仍按既有不泄漏资源存在性的契约处理。
  3. 字段缺失、空数组与正常时段。 固定查询时刻和商户时区,依供应商契约分别处理。检查 DTO 默认集合没有抹掉字段是否存在;Google 的空 periods 不进一步推成永久停业。模型不能凭缺失时段补出开门时间。

记录请求范围、字段是否存在、来源状态、原始 HTTP 状态、固定时刻/时区、适配后的安全结果与回答依据。未运行项保持空白,不把设计期望填成实际结果。

限制:没有调用真实商户 API、模型或回归测试。供应商覆盖范围、字段选择和更新时间仍需项目核实,经营状态也可能过期;一个通用 isAvailable 布尔值不足以表达这些不同信息。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

同一响应中的列表总数与分页数据一致性

先完成本页稳定排序与分页,再读 COUNT 的计数对象、JOIN 后的结果行和事务读取可见性。适用阶段:商户列表已有筛选和分页,要解释同一个响应内 total 与 items 是否观察了同一范围、同一数据状态。

资料原核查:2026 年 10 月 18 日 06:08 JST。来源:PostgreSQL Transaction Isolation(原核查时标题主版本 18、导航标 18.6)与 PostgreSQL 18 Aggregate Functions。独立页面发布日期未标明,页顶其他版本新闻不作为本页日期;本节沿用原核查的 18 版语义,不要求升级数据库。下面 SQL 和并发夹具均未运行。

一个事务中,两条查询仍可能读到不同状态

PostgreSQL 默认 Read Committed 的普通 SELECT 读取语句开始时的已提交数据,两次 SELECT 之间可能看到其他事务新提交的变更。Repeatable Read 依事务内第一条非事务控制语句建立快照,后续读取不看到其他事务此后提交的变化,但仍看到自身修改。因此,只给 count 和列表查询加事务,不能保证它们在默认级别下观察同一状态;不能将这条规则推广到所有数据库同名级别。

已有 COUNT 与 JOIN 文章解释“行数”不一定是“商户数”。落实到接口时,明确 total 是分页前、同授权范围和业务条件下的精确商户数,还是估算、缓存值或不提供。items 与 total 共用租户/权限范围、软删除规则和业务条件;分页位置不算全量筛选条件。count(*) 统计输入行,count(表达式) 只统计非 null 值;一对多联表和可空字段都可能改变计数。精确全表 count 需扫描整表或覆盖所有行的索引,不能预设为低成本。

若承诺同快照的精确 total,可比较同一短只读 Repeatable Read 事务的两次查询与合适的单语句方案,前提是相关读取确在同一数据库观察范围。它们是待评估方案,不能未经 SQL、边界和性能检查就改造;缓存或读副本也不会自动纳入保证。

建议练习:对照时间范围与统计对象

  1. 受控并发。 初始一个匹配假商户,事务 A 读 count=1 后在屏障暂停;事务 B 插入另一匹配商户并提交,再放行 A 按唯一键排序、容量足够的列表查询。Read Committed 下可能出现 total=1 而 items 有两项;同一个只读 Repeatable Read 事务、无自身写入且条件相同时,应保持同一快照。记录隔离级别、SQL/参数、连接与事务标识、B 提交顺序;不用随机 sleep 猜并发,两个连接各开事务不能算同一事务。
  2. 计数对象、权限与空页。 夹具包括有两个标签的商户 A、有一个标签且电话为 null 的商户 B,以及其他租户和软删除商户。按每店一次的契约对照联表行数、电话计数和应匹配商户集合;确认 total/items 的完整筛选与授权相同。请求末页之后时,items 为空不意味着 total=0。若单语句候选把总数附在每行上,还必须设计没有结果行时怎样交回总数。

交付 total 的定义、过滤条件、SQL、事务/连接范围、事件顺序、预期集合、实际 items 与统计值。前置 SQLite 示例和本节 PostgreSQL 设计的运行边界分别记录。

限制:没有建表、运行 SQL、修改隔离级别或测耗时。Repeatable Read 不证明响应发送时仍最新,也不保证后续 HTTP 分页继续同快照;不要为等待翻页长期保持事务。仅需继续加载时可另评估无需精确 total 的契约,但不能静默删改已有字段。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

评分空值、文本比较与跨页排序契约

先完成稳定分页和游标条件绑定,可用非空整数游标项目复习完整排序。适用阶段:商户列表新增评分或名称排序。旧夹具的 createdAt 与 merchantId 非空,本节只补新字段的空值与文本比较规则。

资料原核查:2026 年 10 月 18 日 18:08 JST。来源为 PostgreSQL 18 Sorting Rows与 Collation Support,原核查时导航标 18.6;两页准确发布日期未标明,其他版本发布新闻不作为本页日期。排序、SQL 和跨页验收均未运行,不要求改变项目数据库版本。

排序方向、空值位置与文本相等分别约定

PostgreSQL 中 ASC 默认 NULLS LAST,DESC 默认 NULLS FIRST;每个表达式单独指定方向和空值位置,ORDER BY x, y DESC 不表示两列都降序。若业务需要“评分从高到低,未知始终在最后”,可明确采用 rating DESC NULLS LAST,再用唯一 merchantId 确定并列顺序。未知评分保持未知,不能改成真实的 0 分。

文本排序和比较使用表达式的 collation,lower 等大小写转换也受其影响。非确定性 collation 可让不同字节的字符串比较相等;具体是否忽略大小写或重音取决于 provider 和配置,deterministic=false 本身不实现任意期待的规则,ICU 支持与版本也是环境前提。

名称排序需要写明表达式、collation 与并列项规则,数据库分页沿用同一比较语义。只拿到一页后在 Java 或界面转小写排序,只改变这一页,不能保证整个列表有序。这是由先分页再局部排序推出的限制,并非已经观察到项目故障。

建议练习:与完整有序序列对照

  1. 空值与方向。 假商户 A/C 评分 4.8,B 为 NULL,D 为真实 0,各自 ID 固定且不同。比较默认 DESC 与显式 DESC NULLS LAST,再切到 ASC 但仍要求未知置后。A/C 按约定 ID 排列,D 保留 0 的含义;记录完整 ORDER BY。先用单次查询或静态 OFFSET 分页验收,NULLS LAST 不会解决 Spring Data Keyset 对排序属性非空的要求。
  2. 文本等价与跨页顺序。 准备不同 ID 的 Alpha、alpha、Álpha 和含组合字符的名称。先记录真实 provider、collation、版本及业务期望关系,不假定环境都采用同一字母顺序。冻结数据,对比数据库一次完整有序查询与逐页连接的 ID 序列,验无遗漏、无重复、顺序一致。再加入数据库先分页、应用仅页内忽略大小写排序的负例。名称在所选规则下比较相等时,仍按独立 ID 保留商户,不能由排序自动合并实体。

交付字段与方向、NULL 位置、文本比较规则、并列 ID、每页 ID 和完整参考序列;沿用本课程的 merchantId 作为唯一并列键。

限制:项目数据库、collation 与 Java 比较器尚未核验,没有创建 collation、改表、建索引或执行 SQL。改变排序可能改变分页位置,实施时按既有游标契约处理,不能续用旧规则游标却不说明;静态结果也不能证明并发更新下固定快照。

↑ 返回延伸练习目录

商户项目 · 延伸练习 · 未运行

批量查询的逐项对应与失败契约

先完成本页只读商户响应约定与批量接口逐项核对。适用阶段:单商户查询、工具结果 DTO、逐项授权和错误分类已经明确,准备扩展一次查询多个商户;先用固定接口替身。本节检验一次传输批次内的接口契约,工具调用次数与停止行为继续按工具限额与部分结果处理。

资料原核查:2026 年 10 月 20 日 18:12–18:13 JST;同日复核下列两份官方正文。Google AIP-231:Batch methods: Get 状态为 Approved,Created 与侧栏 Updated 均为 2019-06-18,正文最新可见 Changelog 为 2022-06-02,不能只据侧栏日期判断后续变更。Microsoft Graph JSON batching 页面标最后更新 2025-02-21,请求示例使用 v1.0。这些是各自 API 的契约,不是所有批量接口的统一标准。下面两项练习均未运行,未调用 Graph、真实商户接口或模型,也未运行 Java 测试。

先选择整批结果,还是逐项结果

对照契约完成方式对应关系
AIP-231 BatchGet全部资源成功或整批失败,不允许部分成功响应资源顺序与请求 names 顺序一致
Microsoft Graph JSON batching外层 HTTP 200 不证明每个子请求成功,必须检查各项 status返回可能乱序,用批内唯一的 id 关联请求;Graph 的 id 比较不区分大小写

商户适配器必须明确选择并实现一种业务契约。允许部分完成的自定义接口不能因此宣称遵守 AIP-231 的原子 BatchGet 契约;反过来,也不能因为另一家批量服务允许部分成功,就静默改变已有整批接口。这里的契约对照没有证明本项目已经实现事务或同一时刻读取。

沿用前置文章的逐项账本,requestItemId 表示本批中的一次请求,shopId 表示商户身份;同一商户可以对应两次不同请求,是否允许这种输入需单独约定。字段名称属于教学设计,不是 Graph 格式。先检查标识唯一、已知且完整,再核对成功正文里的商户编号;不要把失败项删掉后按数组位置给下一家配结果。若接入真实供应商,还需按其规则比较标识,不能把示例字符串比较直接当作兼容实现。

每个商户仍按服务端可信身份、租户范围和授权规则查询。对外沿用本页“不存在或当前身份无权访问”的安全 404 约定;测试内部知道拒绝原因,不代表可以向用户透露该商户确实存在。摘要只使用已成功且获授权的业务数据,同时明确保留失败、未知或缺失项。关联正确不能代替权限检查,也不能证明多家商户的数据来自同一时刻。

两项适配练习:先写契约,再记录结果

下面使用独立的虚构身份 U:允许读取 1001、1002,不允许读取 1003;不改本页原有 A/B 权限隔离用例。分别建立请求表、授权表和预期结果,实际结果留待执行填写。

  1. 乱序与重复商户。 输入 r1 → 1001、r2 → 1002、r3 → 1001,替身按 r3、r1、r2 返回。在允许重复商户的逐项契约下,按 requestItemId 保留三次请求,分别核对商户与状态,不能先按 shopId 去重而丢掉一次查询。继续使用前置文章的异常检查:分别注入重复响应 ID、未知 ID 或漏项,明确标为协议异常或不完整,不覆盖、不移位补齐。如果项目选择禁止重复商户,应在输入阶段明确拒绝并检查下游未执行;若实现 AIP-231 式返回契约,乱序响应本身就应被识别为违约,不能把客户端重排当作服务端已遵守契约。
  2. 外层成功与子项失败。 使用另一份输入 r1 → 1001、r2 → 1002、r3 → 1003,避免把上一题同一商户的重复项同时设为成功和拒绝。在逐项契约下,替身外层返回 200,r1 成功、r2 暂时不可用、r3 内部授权拒绝且对外保持安全 404。只把 r1 的业务数据交给摘要,同时保留另外两项的安全结果,不能写“全部查到”。若选择整批原子契约,任一项不能取得时按约定返回整批错误,不把成功子集包装成完整成功;也不要求凭空制造该契约没有提供的逐项响应。

交付所选契约、重复输入策略、标识比较规则、请求与响应映射、授权夹具、外层状态及契约提供的逐项状态,另列未知、重复、缺失 ID 和摘要依据。预期与实际分列;两项练习使用不同输入,不能混用其结果。

限制:这里只对照官方文档并设计商户适配验收,没有测量性能、调用真实服务或确认模型会忠实总结。前置文章已有的 Python 本机 HTTP 运行记录不能作为本节 Java、权限处理或模型练习已通过的证据。

↑ 返回延伸练习目录

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能为一个用例设计输入输出对象和失败响应,并把业务与持久化职责分开。

本篇重点加练

重点巩固输出白名单与错误协议,让客户端合同在实体和实现演进后保持可验证。

  • 输出字段白名单
  • HTTP 状态与错误体

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01设计评审

    用户实体含 id、email、passwordHash、internalFlag、createdAt;分别设计注册输入、个人资料输出和管理员列表输出,列出每个字段出现或缺失的业务依据。

    提交内容
    三份数据合同及实体映射表。
    怎样算完成
    • 输出字段按场景授权
    • 不依赖实体未来字段保持不变

    练到的知识点DTO/Entity/VO 分工 · 输出字段白名单

  2. 作业 02原理问答

    “创建订单并发送确认信息”涉及参数读取、价格校验、持久化与响应生成。把职责放入 Controller、Service、Mapper,画出调用链并标明错误在哪层产生。

    提交内容
    职责表和调用链。
    怎样算完成
    • 业务编排位置清晰
    • 持久化细节不泄露到输入合同

    练到的知识点Controller 边界职责 · Service 业务编排 · Mapper 数据读写

综合应用

  1. 作业 03面试场景

    资源不存在、库存不足、数据库不可达分别触发失败。设计客户端可执行的处理合同,包含状态码、错误类型、关联编号和重试政策,但不返回内部异常堆栈。

    提交内容
    错误响应表和三个请求示例。
    怎样算完成
    • HTTP 与业务错误体一致
    • 重试依据具体故障语义

    练到的知识点异常类型映射 · HTTP 状态与错误体

  2. 作业 04编程实践

    实现内存课程列表接口或纯 Java 服务,输入页号、页大小和可选关键词;返回白名单字段,限制每页上限,并明确定义空结果与越界页。

    提交内容
    实现、自写边界及字段泄露测试、运行证据。
    怎样算完成
    • 验证输出字段集合
    • 分页规模受限
    • 边界合同在测试中落实

    练到的知识点DTO/Entity/VO 分工 · 输出字段白名单 · Controller 边界职责 · Service 业务编排 · 列表响应规模

  3. 作业 05排错实验

    在测试中抛出业务异常、带 cause 的基础设施异常和未知运行异常,检查实际返回体及内部日志。给实体新增一个仅内部字段后再次执行响应契约测试。

    提交内容
    测试代码、前后响应记录和问题修复说明。
    怎样算完成
    • 异常分类稳定
    • 未知错误可关联定位
    • 新增实体字段不自动进入公共响应

    练到的知识点异常类型映射 · HTTP 状态与错误体

  4. 作业 06编程实践

    实现一个课程详情响应映射器,输入内部对象含讲师私人备注;模拟成功、无权和不存在三类结果,输出字段按合同固定。

    提交内容
    Java 实现、自写字段集合及错误合同测试、运行证据。
    怎样算完成
    • 每类结果输出逐字段检查
    • 实体新增字段的影响有回归测试

    练到的知识点输出字段白名单 · HTTP 状态与错误体

  5. 作业 07设计评审

    管理员和普通学员共用导出接口,产品只允许管理员查看报名人邮箱。设计两个响应合同及权限变化后的字段验收,不直接返回内部对象。

    提交内容
    字段权限矩阵和代表性响应草图。
    怎样算完成
    • 角色和字段的关系明确
    • 缺失字段与空字段语义一致

    练到的知识点输出字段白名单

  6. 作业 08面试场景

    旧客户端只看 HTTP 状态,新客户端读取错误码。系统准备调整库存不足的协议表示,制定兼容性验证与迁移公告需要包含的内容。

    提交内容
    版本兼容矩阵及两类客户端测试方案。
    怎样算完成
    • 旧新客户端决策路径都有检查
    • 错误码与状态改变有明确范围

    练到的知识点HTTP 状态与错误体

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成流式处理后:管理员需导出百万条记录,现有列表接口最多返回一百条。比较独立导出任务和同步流式响应的合同,设计取消、权限和失败状态。

    提交内容
    方案比较、资源预算和验收案例。
    怎样算完成
    • 不简单放开列表上限
    • 说明输出规模与失败恢复成本

    练到的知识点输出字段白名单 · Service 业务编排 · 列表响应规模

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    检验“所有响应统一成相同字段集合就更易维护”的提案。构造包含权限差异或异步结果的最小接口集合,比较统一与分开建模的代价。

    提交内容
    接口样本、字段约束与方案比较。
    怎样算完成
    • 论据涉及真实调用行为
    • 结论保留统一模型适用条件

    练到的知识点输出字段白名单 · HTTP 状态与错误体

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗