黄磊的博客 - 后端相关
type: channel
link: https://www.huangleicole.com/category/backend-related/
items: API版本化实战：我们如何在“向前兼容”和“代码整洁”之间找到出路
items: 容器化之路：Docker & Kubernetes入门避坑指南
items: Logback源码解读：如何自定义一个强大的日志组件？
items: Docker入门到实践：将Spring Boot应用容器化并发布
items: 并发编程的陷阱：六年里，我见过的那些诡异的线程安全问题
items: RESTful API设计指南：从混乱到优雅的实践之路
items: 超越JPA：MyBatis动态SQL与复杂查询的工程化实践
items: MyBatis源码解读：一条SQL语句是如何被执行的？
items: DDD（领域驱动设计）实践初探：如何用代码表达业务逻辑？
items: 超越CRUD：利用Spring的Validator做更优雅的参数校验

API版本化实战：我们如何在“向前兼容”和“代码整洁”之间找到出路
type: item
date: 2025-09-21T15:04:00+08:00
description: AI摘要 文章分享了作者在API版本化实践中的经验和教训，探讨了如何在向前兼容和代码整洁之间找到平衡。文章首先介绍了URL版本化和请求头版本化的问题，然后提出了区分领域模型和API模型的解决方案，接着详细描述了三层架构方案，包括路由层、控制层和业务层，并提出了向前兼容的四原则。文章还介绍了API契约测试、版本兼容性检查器和客户端使用分析等工具，最后讨论了何时创建新版本、维护多少个版本和代码组织的问题。作者认为API版本化是一种沟通，而不是隔离。 此内容根据文章生成，仅用于文章内容的解释与总结 第一次遇到API版本化问题，是因为我改动了登录接口的返回格式。原本返回{ "token": "xxx" }，现在我想增加用户信息，改成{ "token": "xxx", "user": { ... } }。我觉得这是个优化——多返回数据总是好事，对吧？第二天，前端同事气急败坏地找我：“登录功能挂了！你们后端怎么不通知就改接口？”我理直气壮：“我加字段而已，旧的客户端应该自动忽略不认识的字段啊。”他发来一张截图，是他们的APP日志：“TypeError: Cannot read property 'token' of undefined”。原来他们的代码写的是response.token，而现在返回的是response.data.token。“等等，我没改字段名啊。”“我们用的是三年前的老SDK，它会自动把响应包一层data。”那一刻，我明白了：API一旦发布，就不再是你的私有财产。它是一份与无数客户端签订的、无法轻易撕毁的契约。我们团队花了三年时间，踩遍了API版本化的所有坑，最终找到了一条在“向前兼容”和“代码整洁”之间的狭窄出路。这不是一篇关于URL中该用/v1/还是/v2/的教程，而是一个关于如何在现实约束下做设计决策的故事。第一章：最初的混乱——每个人都以为自己懂版本化1.1 URL版本化的陷阱刚开始，我们采用了最直观的方式：URL中带版本号。// UserController.java @RestController public class UserController { @GetMapping("/api/v1/users/{id}") public UserV1 getUserV1(@PathVariable Long id) { User user = userService.getById(id); return UserV1.from(user); } @GetMapping("/api/v2/users/{id}") public UserV2 getUserV2(@PathVariable Long id) { User user = userService.getById(id); return UserV2.from(user); } }看起来多么清晰！v1和v2分离，互不干扰。我们还专门建立了dto/v1和dto/v2包，里面是一模一样的类，除了包名不同。问题很快出现了：代码爆炸：每增加一个版本，就多一套Controller、一套DTO、一套Mapper。UserV1、UserV2、UserV3... 它们95%的代码都一样。Bug修复地狱：发现UserV1的一个字段映射有错误，我改完UserV1，还得记得去改UserV2、UserV3... 常常忘记。逻辑不一致：更可怕的是，有些版本里加了特殊的业务逻辑：// UserV2Mapper.java public UserV2 from(User user) { UserV2 dto = new UserV2(); dto.setId(user.getId()); dto.setName(user.getName()); // 只有v2才有的特殊逻辑 if ("admin".equals(user.getRole())) { dto.setAccessLevel("HIGH"); } return dto; }等我们需要v3时，要么复制这段特殊逻辑，要么重新实现。几个月后，没人记得这些特殊逻辑为什么存在，该不该保留。1.2 请求头版本化的困境于是我们换了一种思路：版本号放请求头，URL保持不变。@GetMapping("/api/users/{id}") public ResponseEntity<?> getUser( @PathVariable Long id, @RequestHeader(value = "API-Version", defaultValue = "v1") String version) { User user = userService.getById(id); if ("v1".equals(version)) { return…

容器化之路：Docker & Kubernetes入门避坑指南
type: item
date: 2025-05-16T21:45:00+08:00
description: AI摘要 本文是作者三年容器化实践经验的总结，包括Docker和Kubernetes的使用过程中遇到的各种问题和解决方案。作者分享了Docker镜像构建、网络连接、权限问题、构建缓存等常见问题的处理方法，以及Kubernetes中YAML配置、资源调度、服务暴露、ConfigMap更新、日志管理等方面的坑和解决方法。最后，作者提出了一个本地开发工作流方案，并强调容器化是一种手段而非目标，建议读者从Docker开始，逐步尝试Kubernetes，优化开发工作流，让容器化成为助力而非负担。 此内容根据文章生成，仅用于文章内容的解释与总结 直到现在，我还记得第一次在自己的MacBook上成功运行docker run hello-world时，那个命令行里跳出来的“Hello from Docker!”带给我的那种奇妙的兴奋感。那感觉就像第一次学会骑自行车——你知道这只是一个开始，前方是全新的世界。但很快，这种兴奋就被现实打得粉碎。我亲手打包的第一个Spring Boot应用，在Docker容器里怎么也连不上本地的MySQL。那个红色的连接异常日志，像一盆冷水浇醒了我的美梦。三年过去了，我从一个连容器和虚拟机都分不清的新手，到能在团队里带着新人趟过容器化的坑。这篇记录，就是这三年里那些让我凌晨三点还在查文档、那些“啊哈！”顿悟瞬间的真实合集。这不是一篇完美的教程，而是一份“伤痕文学”——希望我的伤，能让你走得顺一点。第一章：Docker篇——当“在我的机器上能运行”成为诅咒坑1：镜像构建的“空间恐惧症”我的第一个Dockerfile简单得近乎天真：FROM ubuntu:latest RUN apt-get update && apt-get install -y openjdk-8-jdk COPY . /app WORKDIR /app CMD ["java", "-jar", "myapp.jar"]构建镜像，2.7GB。是的，你没看错，2.7GB。上传到仓库要20分钟，同事下载时看我的眼神都不对劲了。我学到的：ubuntu:latest是个巨兽。请用openjdk:8-jre-slim或alpine版本，它们能轻量到让你感动。每个RUN命令都会产生一层镜像。一定要合并命令，并且记得清理apt缓存：RUN apt-get update && apt-get install -y --no-install-recommends \ openjdk-8-jdk \ && rm -rf /var/lib/apt/lists/*坑2：那个永远连不上的“localhost”我在application.yml里写的数据库地址是jdbc:mysql://localhost:3306/mydb。容器启动成功，但应用死活连不上数据库。我花了整整一个下午，在Stack Overflow上各种尝试，直到看到一句话：“容器有自己的网络命名空间，localhost指向的是容器自己，不是你的宿主机。”解决方案有几个，取决于场景：开发时：用host.docker.internal（Docker Desktop）或172.17.0.1（Linux docker0网桥）代替localhost更好的做法：永远不要在配置里写死主机名。用环境变量：spring: datasource: url: jdbc:mysql://${DB_HOST:localhost}:3306/mydb然后运行容器时：docker run -e DB_HOST=host.docker.internal myapp坑3：权限的幽灵——那些“Permission Denied”我把一个本地的日志目录挂载到容器里：-v ./logs:/app/logs。应用启动失败，报“Permission denied”。原来，容器内的进程通常以非root用户运行（这是好事！），但宿主机上的./logs目录属于我的用户，容器用户没权限写。解决办法：简单粗暴但危险：让容器以root运行（--user root），不推荐。在Dockerfile里创建用户并设置合适的UID：RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser appuser USER appuser或者，在宿主机上设置目录权限为777（仅用于开发）。坑4：构建缓存的“薛定谔”状态有时候我只是改了源码里的一行注释，重新构建镜像，却发现Docker又从apt-get update开始执行，慢得要死。有时候我希望它完全不使用缓存，它却固执地用了旧缓存。我学到的构建缓存原理：Docker的构建是一层层的，每一层都有哈希。如果某一层没变化，Docker就会复用缓存。一旦某一层发生了变化，它之后…

Logback源码解读：如何自定义一个强大的日志组件？
type: item
date: 2025-04-15T20:39:00+08:00
description: AI摘要 本文通过作者阅读Logback源码的经历，详细阐述了如何从对Logback一无所知到理解其内部机制，并最终实现自定义日志组件的过程。文章首先解释了阅读源码的必要性，然后介绍了Logback的三层架构，接着通过跟踪日志打印的全过程找到了自定义日志输出的切入点。作者动手实现了一个“上下文ID”编码器，并将其集成到Logback配置中。最后，文章讨论了如何让组件可配置，并分享了阅读源码的价值。 此内容根据文章生成，仅用于文章内容的解释与总结 这篇博客记录了我如何通过阅读Logback源码，从“只会复制粘贴配置”到真正理解其内部机制，并最终打造属于自己的日志组件的过程。这不是一篇速成指南，而是一个工程师与开源框架的深度对话。第一步：为什么要读源码？因为文档不会告诉你这些我最初的需求很简单：在我们的日志中自动添加上下文ID，便于追踪一个请求在整个调用链中的流转。现有的MDC（Mapped Diagnostic Context）需要手动设置，我漏了，同事也漏了。我想，能不能让Logback自动为每个请求生成一个唯一ID，并自动输出到每条日志里？官方文档只告诉我怎么用，没告诉我怎么改。于是，我决定读源码。不是因为我有受虐倾向，而是因为当你需要框架做它原本不做的事情时，源码是唯一能告诉你“为什么不能”和“怎样才能”的地方。第二步：从“用”到“看”——理解Logback的三层架构我克隆了Logback的GitHub仓库，在IDE中打开了这个项目。第一个感觉是：比我想象的清晰。Logback的核心架构分为清晰的三层，这让我想起了很多经典框架：Logger：我们最熟悉的层面，log.info()的调用者。它决定这条日志是否要记录（级别过滤）。Appender：决定日志去哪里。控制台、文件、数据库、网络——每个目的地对应一个Appender。Layout/Encoder：决定日志长什么样。PatternLayout将日志事件格式化成字符串，Encoder（更现代）负责将日志事件编码成字节流。我平时写的配置，比如：<appender name="FILE" class="ch.qos.logback.core.FileAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> <file>app.log</file> </appender>就是在实例化一个FileAppender，并给它配置了一个PatternLayoutEncoder。这个认知让我豁然开朗：如果我需要改变日志的“样子”，我应该改Encoder；如果我需要改变日志的“去处”，我应该改Appender。我的需求是改变输出格式（自动添加ID），所以我的战场是Encoder。第三步：潜入深海——跟踪一次日志打印的全过程为了知道在哪里动手，我必须知道一次log.info("hello")调用，究竟经历了什么。我在ch.qos.logback.classic.Logger类的buildLoggingEventAndAppend方法打了断点，然后开始了一次奇妙的调用栈旅行：Logger.info(String msg)被调用。经过级别过滤后，创建ILoggingEvent对象（这是日志事件的核心接口，包含了时间、线程、消息、MDC等所有信息）。调用callAppenders(ILoggingEvent event)，将事件传递给所有关联的Appender。在AppenderBase的doAppend()方法中，我看到了关键逻辑：先调用encoder.encode(event)将事件转换成字节，再写出。重点在encoder.encode(event)！ 这就是日志被格式化的地方。我顺藤摸瓜，找到了PatternLayoutEncoder，它内部又委托给PatternLayout。我打开了PatternLayout的源码，看到了那熟悉的%d、%thread、%msg——没错，就是配置文件里<pattern>标签的内容。它在doLayout(ILoggingEvent event)方法中，将各种“转换词”替换成具体的值。至此，我找到了切入点：如果我能自定义一个Layout或Encoder，在输出前把自定义的上下文ID塞进去，问题就解决了。第四步：动手创造——实现一个“上下文ID”编码器我决定不直接修改PatternLayout，而是用装饰者模式包装它。这样更干净，也符合开闭原则。我先创建一个简单的上下文管理器（模拟请求链路）：public class TraceContext { private static final Th…

Docker入门到实践：将Spring Boot应用容器化并发布
type: item
date: 2025-01-16T20:36:00+08:00
description: AI摘要 本文介绍了作者如何将Spring Boot应用容器化并发布到镜像仓库的全过程。文章分为五部分，包括思想转变、编写Dockerfile、本地实践、发布镜像和使用docker-compose编排全家桶。作者分享了在实践中遇到的问题和解决方案，强调了Docker带来的确定性和效率，使得项目环境配置更加标准化和易于管理。 此内容根据文章生成，仅用于文章内容的解释与总结 还记得我第一次接手一个老项目时，为了在本地跑起来，折腾了整整两天：安装特定版本的MySQL、配置Redis、设置各种环境变量...那种“在我的机器上明明是好的”的挫败感，让我对一种叫“容器”的技术产生了强烈的好奇。Docker，对我而言，最初的需求并非为了高深的微服务部署，只是想让我和我的应用，都能拥有一份“说走就走”的自由。这篇文章，就是我学习并使用Docker，将最熟悉的Spring Boot应用一步步打包、运行、并最终发布到镜像仓库的全过程。我会重点分享那些在文档上看不到、只有亲手实践才会撞上的“墙壁”。第一部分：思想转变——从“手动配环境”到“一键拥有环境”在Docker之前，我的世界是这样的：开发机：安装JDK 8, Maven, MySQL, Redis...测试环境：可能需要JDK 11，不同版本的MySQL...处理依赖冲突，处理端口占用，心力交瘁。Docker带来的是一种“降维打击”式的思想转变：我们交付的不仅仅是一个Jar包，而是一个完整的、隔离的、包含了应用本身及其全部运行时的“集装箱”。这个集装箱里，操作系统、JDK、我们的应用，都被精密地打包在一起。无论是在本地、测试服务器还是云上，只要你能运行Docker，这个集装箱就能以完全相同的方式启动。第二部分：第一步——编写Dockerfile，我们的“造船说明书”一切始于一个名为Dockerfile的文件。它就像一份详细的造船说明书，告诉Docker如何一步步地把我们的应用构建成一个镜像（Image）。我的第一个（也是糟糕的）Dockerfile：一开始，我想得很简单：找个有JDK的基础镜像，把Jar包扔进去，运行就好了。FROM openjdk:8-jdk COPY target/my-app-0.0.1-SNAPSHOT.jar app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]看起来没问题，对吧？但这里隐藏着几个我后来才意识到的问题：基础镜像过于庞大：openjdk:8-jdk镜像基于完整的操作系统，包含了各种我们不需要的开发工具，体积高达500MB以上。以root用户运行：默认情况下，容器内的进程以root用户身份运行，这有潜在的安全风险。不够优化：每次代码改动，整个Jar包都要重新复制，无法利用Docker的构建缓存。进化后的Dockerfile：使用多阶段构建在踩了坑并学习了最佳实践后，我重写了一份更专业的Dockerfile：# 第一阶段：构建阶段 （命名为 builder） FROM maven:3.8.5-openjdk-11 AS builder WORKDIR /app COPY pom.xml . # 利用Docker缓存层：如果pom.xml没变，则直接使用缓存的依赖 RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 第二阶段：运行阶段 FROM openjdk:11-jre-slim WORKDIR /app # 创建一个非root用户并切换 RUN groupadd -r springapp && useradd -r -g springapp springapp USER springapp # 从构建阶段复制打好的Jar包，注意改了个简单的名字 COPY --from=builder /app/target/my-app-0.0.1-SNAPSHOT.jar app.jar # 暴露端口（只是一个声明，便于理解） EXPOSE 8080 # 使用非阻塞式的启动命令，支持优雅关闭 ENTRYPOINT ["java", "-jar", "app.jar"]这样写的好处：镜像极小：运行环境使用-jre-slim（仅包含运行环境），最终镜像体积从500MB+降到了200MB以内。安全：使用非root用户运行应用。构建速度快：分离了依赖安装和源码编译，如果pom.xml没变，mvn dependency:go-offline这一层会被缓存，无需重复下载依赖。职责分离：构建环境与运行环境完全隔离，更干净。第三部分：本地实践——构建与运行，遭遇“墙壁”在项目根目录下，执行构建命令：docker build -t m…

并发编程的陷阱：六年里，我见过的那些诡异的线程安全问题
type: item
date: 2024-12-12T16:25:00+08:00
description: AI摘要 作者以六年踩坑经历，揭示并发“幽灵”：SimpleDateFormat共享状态、Check-then-Act空档、迭代器隐式fail-fast、volatile误当原子锁。教训：单线程测试不可信，共享可变必审访问路径，优先使用JUC工具，理解内存语义，方得线程安全。 此内容根据文章生成，仅用于文章内容的解释与总结 如果说编程是教会计算机思考，那并发编程就是教会它“一心多用”。而让我真正对并发产生敬畏的，不是书本上的概念，而是在自己代码里撞见的那些诡异瞬间。它们不像生产事故那样轰轰烈烈，却像程序世界里悄无声息的“幽灵”，一次次挑战我对代码确定性的信仰。我记得早期，我天真地以为线程安全就是synchronized。直到我踩进一个又一个陷阱，才明白并发问题的诡异在于：它绝大多数时候都运行良好，就像一颗藏在代码深处的定时炸弹，只等一个完美的、难以复现的条件组合来引爆。陷阱一：看似“局部”的变量，却引来了“全局”的灾难场景： 一个简单的日期格式化工具类。我写了一个public static String formatDate(Date date)方法，内部使用了SimpleDateFormat实例。在我的单元测试里，百分百通过。但在一个压测脚本中，偶尔会出现格式化后的日期字符串错乱，甚至抛出NumberFormatException。排查过程：轻敌阶段： 首先怀疑是测试数据问题，反复检查，无果。困惑阶段： 在IDE里单步调试，一切正常。这让我无比沮丧。顿悟时刻： 当我用多个线程同时调用这个工具类时，幽灵出现了。日期串了，时间错了。我这才意识到，SimpleDateFormat内部维护了一个Calendar对象用于计算。当线程A正在格式化一个日期，还没完成时，线程B闯了进来，重置了Calendar对象，然后把自己的日期塞了进去。最终，A线程拿到的就是被B“污染”后的结果。教训：我犯了一个经典错误：认为没有“写”操作就是线程安全的。我误以为format方法只是“读”传入的Date对象。但实际上，SimpleDateFormat的内部状态（那个Calendar对象）在格式化过程中被剧烈地“写”改了。任何一个有状态（尤其是可变状态）的对象，如果被多个线程共享，就必须考虑线程安全问题。解决方案：最直接： 每次调用都new SimpleDateFormat()。性能差，但简单有效。线程隔离： 使用ThreadLocal<SimpleDateFormat>，为每个线程分配一个独立的实例。这是性能和安全的良好折衷。拥抱新时代： 换成Java 8引入的DateTimeFormatter，它被明确设计为不可变且线程安全的。陷阱二：“检查-执行”的致命间隙场景： 实现一个简单的缓存MyCache。逻辑是：如果缓存里有，直接返回；如果没有，就去数据库查，然后放入缓存。我写了类似这样的代码：public Object getData(String key) { Object data = cache.get(key); if (data == null) { // 检查通过，认为需要加载 data = loadDataFromDB(key); // 这是一个耗时的操作 cache.put(key, data); // 执行放入操作 } return data; }在低并发下完美工作。但模拟高并发访问时，数据库连接池偶尔会报超限错误。排查过程：日志显示，对于同一个key，在极短的时间内，loadDataFromDB这个耗时操作被执行了多次。这太反直觉了！我的if (data == null)检查是干什么用的？幽灵再现：想象一下，线程A和线程B同时调用getData(“key1”)。A检查缓存，key1不存在。A开始执行耗时的loadDataFromDB(“key1”)。就在A查询数据库的这段时间里，线程B也来检查缓存。此时A还没把结果放进去，所以B也检查到key1不存在。B也开始了loadDataFromDB(“key1”)。结果就是，数据库被同一个查询轰炸了两次，缓存里也被重复放入了两次（虽然结果一样，但浪费了资源）。教训：这就是典型的检查-执行（Check-then-Act） 竞态条件。在“检查”（data == null）和“执行”（cache.put）之间，存在一个时间窗口，其他线程可以趁虚而入。synchronized可以解决，但粒度太粗，影响性能。解决方案：使用ConcurrentHashMap的putIfAbsent方法，或者更优雅的computeIfAbsent方法。它能保证对于同一个key，加载数据的函数只被执行一次。这才是真正的原子操作。public Object getData(String key) { return cache.…

RESTful API设计指南：从混乱到优雅的实践之路
type: item
date: 2024-03-12T15:05:00+08:00
description: AI摘要 文章以作者亲历的微服务接口混乱为引，系统梳理RESTful设计：用资源与状态转移替代动词CRUD，统一复数名词路径；规范HTTP方法、状态码、分页、过滤、版本管理及文档；给出电商订单API完整示例，强调直观、一致、可预测、兼容，为前端与协作效率服务。 此内容根据文章生成，仅用于文章内容的解释与总结 前传：我们为什么需要这份指南？我曾参与一个中型的微服务项目，大约有20个服务。最初，每个团队按照自己的理解设计API。结果，当我需要调用不同团队的接口时，发现自己像是在阅读不同的语言：用户服务：GET /api/v1/user/getUserInfo?id=123订单服务：POST /api/order/queryOrderList支付服务：GET /payment-service/retrievePayment/123同一个项目里，有的用单数名词，有的用复数；有的用动词，有的用名词；有的路径带api前缀，有的带服务名。当新同事加入时，光是学习这些不一致的API规范就需要一周时间。更糟糕的是，前端对接时经常抱怨："你们后端自己都没统一，我怎么知道该调哪个？"从那次经历开始，我意识到一套统一的API设计规范不是"锦上添花"，而是"雪中送炭"。第一章：RESTful的灵魂——你真的理解了吗？1.1 别把REST当CRUD的简单包装很多开发者认为RESTful就是给CRUD操作换个HTTP方法的外衣，于是写出了这样的代码：// 反面教材：这只是CRUD的HTTP包装 @PostMapping("/api/user/create") public User createUser(@RequestBody User user) { ... } @GetMapping("/api/user/read/{id}") public User getUser(@PathVariable Long id) { ... } @PostMapping("/api/user/update") public User updateUser(@RequestBody User user) { ... } @PostMapping("/api/user/delete/{id}") public void deleteUser(@PathVariable Long id) { ... }问题在哪？在URI中使用动词（create/read/update/delete）用POST方法处理所有操作资源的状态变化不清晰1.2 RESTful的核心：资源与状态转移REST（Representational State Transfer）的核心思想是资源和状态转移。让我们重构上面的例子：// 正确示例：基于资源的操作 @PostMapping("/api/users") // 创建用户 -> 往用户集合中添加资源 public User createUser(@RequestBody User user) { ... } @GetMapping("/api/users/{id}") // 获取特定用户 public User getUser(@PathVariable Long id) { ... } @PutMapping("/api/users/{id}") // 全量更新用户 public User updateUser(@PathVariable Long id, @RequestBody User user) { ... } @PatchMapping("/api/users/{id}") // 部分更新用户（可选） public User partialUpdateUser(@PathVariable Long id, @RequestBody Map<String, Object> updates) { ... } @DeleteMapping("/api/users/{id}") // 删除用户 public void deleteUser(@PathVariable Long id) { ... }关键理解：URI标识的是资源（用户），而不是操作HTTP方法表示对资源的操作类型同一个URI，不同的HTTP方法可以有不同的含义第二章：命名规范——让API自己会说话2.1 资源命名：名词、复数、小写糟糕的命名：GET /getAllUsers POST /createNewOrder GET /fetchProductDetails/123问题诊断：在URI中使用动词命名不一致（getAll vs fetch）单复数混用优雅的命名：GET /users # 获取用户列表 POST /users # 创建用户 GET /users/{id} # 获取特定…

超越JPA：MyBatis动态SQL与复杂查询的工程化实践
type: item
date: 2023-10-11T18:21:00+08:00
description: AI摘要 文章以电商订单查询为例，展示MyBatis通过``、``、``、``、``及`@SelectProvider`等机制，将动态SQL、复用、复杂映射与Java构建SQL结合，实现高可维护、高性能的复杂查询工程化，从而超越JPA。 此内容根据文章生成，仅用于文章内容的解释与总结 在Java持久层框架的选择上，JPA（及其实现如Hibernate）与MyBatis的争论从未停止。JPA的优势在于快速开发、对象化思维和标准规范，但在面对复杂多变的查询场景时，我们常常会感到束手束脚。这时，MyBatis的真正威力——尤其是其动态SQL能力——就显现出来了。这篇博客不是要贬低JPA，而是要展示当业务逻辑超出简单的CRUD范畴时，如何通过MyBatis的工程化实践，优雅、高效地解决复杂查询问题。一、为什么是MyBatis？从一个日常场景说起假设你正在开发一个电商后台的“订单管理”页面。前端传过来的查询条件可能包括：订单号（精确）用户ID（精确）订单状态（多选）商品名称（模糊）创建时间范围（起始-结束）排序字段（按时间、金额等）如果用JPA的Specification或QueryDSL来写，代码会迅速变得复杂且难以阅读。更重要的是，当查询条件极度复杂时，你可能会被迫使用@Query手写JPQL，这又回到了字符串拼接的老路，失去了类型安全，并且难以动态化。而MyBatis从一开始就为此而生。它的核心理念是：SQL是专家的事，框架负责把参数映射好，把结果映射好，中间的过程交给专家（也就是你）来把控。二、动态SQL基础：不仅仅是<if>标签大家都知道MyBatis有<if>标签，但工程化实践远不止于此。1. <where> 标签的智慧：告别丑陋的 1=1新手常这样写：SELECT * FROM orders WHERE 1=1 <if test="orderNo != null"> AND order_no = #{orderNo} </if> <if test="status != null"> AND status = #{status} </if>1=1是为了避免所有条件都为空时出现WHERE AND的语法错误。但这不够优雅。工程化写法：使用 <where>SELECT * FROM orders <where> <if test="orderNo != null"> AND order_no = #{orderNo} </if> <if test="statusList != null and statusList.size() > 0"> AND status IN <foreach collection="statusList" item="status" open="(" close=")" separator=","> #{status} </foreach> </if> <if test="productName != null and productName != ''"> AND EXISTS ( SELECT 1 FROM order_item oi JOIN product p ON oi.product_id = p.id WHERE oi.order_id = orders.id AND p.name LIKE CONCAT('%', #{productName}, '%') ) </if> </where><where>标签的智能之处在于：只有当子元素有返回内容时，才插入WHERE关键字。会自动剔除掉子句开头的AND或OR。 这样你就不用担心第一个条件前多一个AND了。2. <choose> 标签：实现“多选一”的逻辑业务场景：优先按订单号查，如果订单号没给，就按用户ID查，如果都没给，就查最近一周的订单。 SELECT * FROM orders <where> <choose> <when test="orderNo != null"> order_no = #{orderNo} </when> <when test="userId != null"> AND user_id = #{userId} </when> <otherwise> AND created_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) </otherwise> </choose> <if test="status != null"> AND status = #{status} </if> </where> <choose>就像Java里的if-else if-else，保证了分支的唯一性，让查询逻辑更清晰。3. <sql> 与 <include>：实现SQL片段复用这是工程化的关键一步。当多个查…

MyBatis源码解读：一条SQL语句是如何被执行的？
type: item
date: 2023-09-04T20:16:00+08:00
description: AI摘要 MyBatis执行SQL全流程：SqlSessionFactory解析配置生成MappedStatement；SqlSession.getMapper创建代理，MapperProxy.invoke转调MapperMethod.execute；DefaultSqlSession委托Executor，先查一级缓存，未命中则SimpleExecutor.doQuery，StatementHandler创建PreparedStatement，ParameterHandler设参，ResultSetHandler映射结果并返回。 此内容根据文章生成，仅用于文章内容的解释与总结 在日常开发中，我们早已习惯了这样的写法：User user = userMapper.selectById(1L);这行代码简洁得令人安心。但你是否曾停下来想过，这个没有任何实现的接口方法selectById，究竟是如何神奇地变成了一个SELECT * FROM user WHERE id = ?的数据库调用？今天，我们就化身“源码侦探”，深入MyBatis的内部，完整地跟踪一条SQL语句的“一生”。这不仅是为了满足好奇心，更是为了在遇到那些“诡异”的问题时（比如为什么这里的参数没生效？为什么缓存莫名其妙？），我们能心中有数，直击要害。序幕：一切的起点——SqlSessionFactory故事开始于一个重量级对象：SqlSessionFactory。它通常在我们的代码中这样诞生：String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);SqlSessionFactoryBuilder.build()方法是MyBatis初始化的核心。它会：解析mybatis-config.xml全局配置文件。加载所有的*Mapper.xml文件，并将每一个<select|insert|update|delete>标签解析成一个MappedStatement对象。这个MappedStatement是真正的核心，它包含了这条SQL语句的所有信息：SQL源码、参数类型、结果类型、缓存配置、执行类型（如SELECT）等。可以把SqlSessionFactory想象成一个已经装配完毕的汽车工厂，而MappedStatement就是一辆辆已经设计好蓝图、等待生产的汽车图纸。第一幕：获取舞台——SqlSession有了工厂，我们就可以获取一次数据库操作的“舞台”——SqlSession。// 通常我们通过这个方式获取Mapper接口的代理对象 UserMapper userMapper = sqlSession.getMapper(UserMapper.class); // 或者直接使用SqlSession的方法（更底层，但不常用） // User user = sqlSession.selectOne("com.example.mapper.UserMapper.selectById", 1L);getMapper()方法是魔法开始的地方。 这里用到了动态代理。MyBatis并没有为你UserMapper接口生成一个实体类，而是通过java.lang.reflect.Proxy创建了一个代理对象。当你调用userMapper.selectById(1L)时，实际上调用的是Proxy的InvocationHandler的invoke方法。在MyBatis中，这个处理器是MapperProxy。第二幕：代理的魔法——MapperProxyMapperProxy.invoke()方法是整个执行流程的“交通枢纽”。它的核心逻辑如下：// 简化版的 MapperProxy.invoke 逻辑 public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 1. 如果是调用Object本身的方法（如toString, hashCode），直接执行 if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } // 2. 如果是接口的默认方法（Java8+），也直接执行 if (method.isDefault()) { return invok…

DDD（领域驱动设计）实践初探：如何用代码表达业务逻辑？
type: item
date: 2023-06-02T15:59:00+08:00
description: AI摘要 文章以供应链系统重构为例，展示从贫血模型到DDD的演进：先指出贫血模型“失忆、碎片化、难维护”的痛点；再给出富血实体、值对象、领域服务、聚合、领域事件等战术设计，让业务规则内聚于代码；通过测试、分层架构、渐进式迁移策略，实现“代码即文档”，最终达成业务与技术同频，提升系统可维护性与响应变化能力。 此内容根据文章生成，仅用于文章内容的解释与总结 前言：在我工作的第四年，接手了一个“代码写得不错，但业务逻辑看不懂”的供应链系统。实体类里全是getter/setter，Service层里充斥着if(status == 1){...}else if(status == 2){...}。更可怕的是，业务专家说“订单超过30分钟未支付自动取消”，代码里却分散在三个地方：定时任务、支付回调、订单查询。这次经历让我意识到：如果代码不能清晰表达业务，那它只是在模拟业务，而不是实现业务。一、 从“数据表驱动”到“领域驱动”的认知转变传统贫血模型的困局先看看我接手的那个“反例”——一个典型的贫血模型实现：// 贫血的订单实体（只是个数据容器） @Data @Entity @Table(name = "t_order") public class Order { @Id private Long id; private String orderNo; private BigDecimal amount; private Integer status; // 1-待支付 2-已支付 3-已发货 4-已完成 5-已取消 private Long userId; private LocalDateTime createTime; private LocalDateTime payTime; private LocalDateTime deliverTime; private LocalDateTime completeTime; private LocalDateTime cancelTime; private String cancelReason; // 还有30多个字段... } // 贫血的服务层（承载所有业务逻辑） @Service @Transactional @Slf4j public class OrderService { @Autowired private OrderRepository orderRepository; @Autowired private PaymentService paymentService; @Autowired private InventoryService inventoryService; @Autowired private MessageService messageService; /** * 创建订单（业务逻辑分散在各个Service中） */ public Long createOrder(CreateOrderRequest request) { // 1. 参数校验（业务规则？） if (request.getItems() == null || request.getItems().isEmpty()) { throw new RuntimeException("订单商品不能为空"); } if (request.getUserId() == null) { throw new RuntimeException("用户ID不能为空"); } // 2. 创建订单实体（只是set数据） Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setAmount(calculateAmount(request.getItems())); order.setStatus(1); // 硬编码状态：待支付 order.setCreateTime(LocalDateTime.now()); // 3. 保存订单 Order savedOrder = orderRepository.save(order); // 4. 扣减库存（业务逻辑在另一个Service） inventoryService.deductStock(request.getItems()); // 5. 发送消息（另一个Service） messageService.sendOrderCreatedMessage(savedOrder.getId()); return savedOrder.getId(); } /** * 支付订单（if-else的海洋） *…

超越CRUD：利用Spring的Validator做更优雅的参数校验
type: item
date: 2023-05-17T20:05:00+08:00
description: AI摘要 文章以“用户注册”为例，展示从手写if-else到Spring Validator的演进：先用JSR-303注解+@Valid替代冗余校验，再自定义注解解决密码一致性、枚举、数据库唯一性等复杂规则，结合分组、嵌套、方法级校验及统一异常处理，把60行“校验地狱”压缩成10行清晰业务，实现70%代码瘦身，并给出性能优化与最佳实践，最终让参数校验成为可配置、可复用、可扩展的“优雅艺术”。 此内容根据文章生成，仅用于文章内容的解释与总结 前言：在我工作的第二年，接手了一个老项目的用户注册模块。代码里到处都是这样的校验逻辑：if (username == null || username.isEmpty()) { throw new RuntimeException("用户名不能为空"); }，重复、冗长且难以维护。直到我深入学习了Spring Validator，才发现参数校验可以如此优雅。现在，我已经用它重构了十几个接口，让代码从"校验地狱"变成了"优雅艺术"。一、 从丑陋的校验代码说起先看看我曾经维护的那个"反例"：@RestController @RequestMapping("/user") public class UserController { @PostMapping("/register") public Result register(@RequestBody Map<String, Object> params) { // 1. 参数非空校验 if (params == null || params.isEmpty()) { return Result.error("参数不能为空"); } // 2. 逐个字段校验 String username = (String) params.get("username"); if (username == null || username.trim().isEmpty()) { return Result.error("用户名不能为空"); } if (username.length() < 6 || username.length() > 20) { return Result.error("用户名长度必须在6-20位之间"); } if (!username.matches("^[a-zA-Z0-9_]+$")) { return Result.error("用户名只能包含字母、数字和下划线"); } String password = (String) params.get("password"); if (password == null || password.trim().isEmpty()) { return Result.error("密码不能为空"); } if (password.length() < 8 || password.length() > 32) { return Result.error("密码长度必须在8-32位之间"); } if (!password.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$")) { return Result.error("密码必须包含大小写字母和数字"); } String email = (String) params.get("email"); if (email == null || email.trim().isEmpty()) { return Result.error("邮箱不能为空"); } if (!email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")) { return Result.error("邮箱格式不正确"); } // 3. 业务校验（查数据库） User existingUser = userRepository.findByUsername(username); if (existingUser != null) { return Result.error("用户名已存在"); } existingUser = userRepository.findByEmail(email); if (existingUser != null) { return Result.error("邮箱已注册"); } // 4. 真正的业务逻辑（被淹没在大量校验代码中） User user = new User(); user.setUsername(username); user.setPassword(passwordEncoder.encode(password)); user.setEm…