工程结构¶
先看“代码放在哪里”,再看“每层负责什么”,最后理解数据怎样从数据库变成接口响应。
阅读前:项目、模块、包、类有什么区别¶
| 名称 | 从大到小理解 |
|---|---|
| Maven 模块(module) | 有自己 pom.xml 的构建单元,产出 Jar 或负责聚合;这里不是 Java 的 module-info.java 模块系统 |
| 包(package) | Java 类的命名空间,例如 com.hh.user.controller,通常对应源码目录层级 |
| 类(class) | 定义字段与行为的类型;new 可以创建实例,但 Spring 管理的组件通常由容器创建 |
| 接口(interface) | 声明能力契约;Service 可以有普通实现类,Mapper 可以由框架生成代理实现 |
| Jar(Java Archive) | Java 类与资源的归档;普通依赖 Jar 与可 java -jar 启动的 Boot Jar 不是同一种使用方式 |
| War(Web Application Archive) | 面向 Servlet Web 部署的归档形式;当前 hhjava 使用可执行 Jar,不需要为了学习目录另建 War 工程 |
例如文件 src/main/java/com/example/learning/HelloController.java 的包声明应是 package com.example.learning;。完整类名为 com.example.learning.HelloController;import 让代码能简写其他包的类名,不负责下载依赖。下载与编译依赖由 Maven 管理。
1. 从 Maven 项目开始理解¶
Maven 使用 pom.xml 描述模块坐标、依赖和构建规则。模块可以是可启动的服务,也可以是被其他模块使用的普通 Jar;看到一个文件夹有 POM,不代表它必须独立启动。
hhjava 是多模块 Maven 工程。在 IDEA 中打开根目录并导入根 pom.xml,让 Maven 管理依赖,不需要把 MySQL 驱动等 Jar 手工加入工程。
常见源码目录:
模块/
├── pom.xml
├── src/main/java/ # Java 生产代码
├── src/main/resources/ # 配置、Mapper XML、Flyway SQL、自动配置入口等
├── src/test/java/ # 自动化测试
├── src/test/resources/ # 测试专用配置与数据
└── target/ # Maven 生成物,不作为源码提交
2. hhjava 的模块和依赖边界¶
hhjava/
├── pom.xml
├── hhjava-unify/
├── hhjava-common/
├── hhjava-basic/
│ └── hhjava-file-starter/
├── hhjava-service/
│ ├── hhjava-user/
│ └── hhjava-backup-file/
├── hhjava-gateway/
└── scripts/
| 模块 | 负责什么 | 是否独立启动 |
|---|---|---|
| 根 POM | 聚合构建、版本和依赖/插件元数据 | 否 |
hhjava-unify |
ResponseResult、分页 DTO、业务码 |
否 |
hhjava-common |
跨服务通用配置、Servlet 公共异常处理 | 否 |
hhjava-basic |
基础能力聚合 | 否 |
hhjava-file-starter |
MinIO 自动配置和文件存储接口 | 否 |
hhjava-service |
业务服务聚合及公共构建配置 | 否 |
hhjava-user |
注册、标准密码认证、OAuth2/OIDC、移动会话、RSA JWT/JWKS | 是 |
hhjava-backup-file |
文件上传/删除业务、自身 JWT 验签 | 是,需准备存储等依赖 |
hhjava-gateway |
路由、CORS、可信转发头治理、JWT 验签、网关 401/403 | 是 |
微服务是可以独立部署、通过明确接口协作的服务;不是目录越多架构越好。表中的 CORS 是 Cross-Origin Resource Sharing(跨源资源共享),控制浏览器跨源读取;JWT(JSON Web Token)是令牌格式,JWKS(JSON Web Key Set)在这里提供 RSA 验签公钥;OAuth2 与 OIDC(OpenID Connect)分别提供授权协议和其上的身份层。先知道这些能力属于哪个模块即可,详细基础见 认证和授权。Servlet 指 Java Web 请求处理 API,响应式网关使用另一套 WebFlux 体系。
聚合与依赖不同:<modules> 决定一起构建哪些模块,<dependencies> 决定代码能使用哪些库。dependencyManagement 负责统一版本,不会凭空把所有库加入每个模块。详见 Maven POM。
领域服务不要直接依赖另一个可启动服务的实现类或数据库表。共同使用 ResponseResult 可以放在 unify,但用户注册规则不能为了“复用”搬进 common。
3. 服务内部的三层职责¶
- Controller:接收 HTTP、验证参数、调用 Service、返回结果。
- Service:业务规则、多个操作的编排和事务。
- Mapper:数据库读写。
- security/config:认证 Provider、过滤链、JWT、配置装配等框架边界。
例如移动密码登录由 MobileAuthController 接收 JSON,MobileAuthServiceImpl 调用标准认证管理器;查询用户和角色由用户 Service 与 Mapper 完成。Controller 不比较密码,也不自行构造 JWT。
4. PO、DTO、VO、BO 怎么理解¶
这些名称是分工约定,不要求每个功能都创建四套几乎相同的对象。
| 名称 | 常见用途 | 当前项目例子或注意事项 |
|---|---|---|
| 实体 / PO(Persistent Object,持久化对象) | 对应持久化数据 | User、Role、UserRole |
| DTO(Data Transfer Object,数据传输对象) | 在层或接口边界传递数据 | RegisterDto、PasswordLoginRequest、MobileTokenResponse;内部 UserDto 用于认证资料组装 |
| VO(本文指 View Object,展示对象) | 面向展示的响应模型 | 需要独立展示模型时再引入;其他资料可能用 VO 指 Value Object,要结合上下文 |
| BO(Business Object,业务对象) | 封装领域操作中的业务数据 | 业务复杂到需要独立模型时使用,不为凑齐层次新增 |
实体中的密码哈希不能随用户信息响应返回。AuthenticatedUser 是 Spring Security 使用的认证身份,不是对外用户信息 DTO;它从数据库结果创建,携带稳定用户 ID,并在认证成功后擦除密码哈希。
字段较少时显式转换容易理解和审查;转换繁多时再考虑映射工具。无论用什么工具,都必须确认秘密字段不会自动复制到对外响应。
用一个请求理解几种模型¶
注册 JSON(username、password)
→ RegisterDto:接收并校验输入
→ Service:检查规则,计算密码哈希
→ User 实体:对应数据库列,Mapper 保存
→ 安全响应 DTO:只返回允许公开的字段
→ JSON:由框架转换后交给 App
字段同名不代表应该全部复制。注册请求需要原始密码,数据库保存密码哈希,普通用户简介响应两者都不应该有。MapStruct 是编译期生成对象映射代码的工具,不是自动判断秘密的安全组件;当前任务不要求为了几行赋值引入它。
constants、exception、model、utils 怎么放¶
constants:明确归属的常量,如某个协议键;不把所有业务数字都放进一个跨项目巨型常量类。exception:业务或基础设施异常类型,以及属于本模块的处理逻辑。model:按实际需要组织实体、请求/响应 DTO、枚举等数据模型;名称不是框架强制要求。utils:真正无业务状态、边界清晰的辅助方法;数据库访问、登录认证不应藏进“工具类”。
Feign 是声明式 HTTP 客户端工具,客户端接口可表达服务间调用契约;不代表每个项目必须建立 xxx-feign-api。hhjava 当前目录没有的模块,不应仅因通用教程提过就创建。
5. 统一响应只描述数据¶
hhjava-unify 中的 ResponseResult<T> 包含:
code 业务状态码
message 面向调用方的提示
data 实际业务数据,类型由 T 确定
常见用法:
// 调用示例:只创建响应体;HTTP 状态由 Controller/异常处理边界决定。
ResponseResult<String> ok = ResponseResult.success("用户注册成功");
ResponseResult<Void> failed = ResponseResult.error(400, "请求参数不正确");
JSON 写出交给 Jackson。DTO 中不放 JSON 库演示、业务查询或 main 测试方法。
泛型、工厂方法、枚举、分页分别解决什么¶
<T>是“先留一个数据类型位置”:ResponseResult<String>装字符串,ResponseResult<List<...>>装集合;编译器可以帮助发现类型用错。static success(...)是静态工厂方法,调用时不必先 new。当前success("用户注册成功")将字符串放在 data,message 来自成功枚举,不要误以为一个参数就是 message。
OAuth2/OIDC 协议端点保持标准响应格式,不因为项目有统一 DTO 就强行包装。异常映射见 异常处理。
6. 公共能力通过什么方式接入¶
ServletExceptionAutoConfiguration导出 common 的默认异常处理器,只对 Servlet 应用生效。MinIOConfig导出文件 Starter 的客户端和FileStorageService,按配置条件创建,并在应用提供同类型 Bean 时退让。- 不通过扩大包扫描范围去碰运气,也不要求普通组件 Jar 提供启动类。
自动装配原理见 SpringBoot,文件能力见 MinIO。
7. 配置、启动和验证¶
开发秘密放在 hhjava 父目录的 .hhjava-dev-config,不在源码树中。scripts/setup-dev-config.sh 负责生成供应用导入的配置;该目录必须持续被 Git 忽略。
远端 Nacos 路由、数据库和 MinIO 配置不完整保存在仓库中。存在某个 application.yml 不代表外部服务已经可用;必须结合部署配置和实际连通性验证。
所有 Maven 命令从 hhjava 根目录执行:
mvn -pl hhjava-service/hhjava-user -am test
mvn -pl hhjava-gateway -am test
mvn clean test
# 测试完成后,按需验证可打包产物。
mvn -DskipTests package
应用启动入口只保留启动与必要的框架配置。学习实验放在测试代码中,不在每次启动时输出演示 Bean 或执行无关逻辑。测试覆盖代码边界,真实 MySQL、Nacos、MinIO 和浏览器流程仍需独立验收。
8. 动手观察“内部模型不等于对外响应”¶
前置:完成 Spring Boot 入门练习,使用同一 Mac 学习工程,已有 test 和 Jackson 依赖。下面使用“内部备注”模拟不能对外返回的字段,不在测试中保存真实密码。
文件:src/test/java/com/example/learning/ModelBoundaryTest.java
package com.example.learning;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@SpringBootTest
class ModelBoundaryTest {
// 用 record 简化实验的字段声明,不表示数据库实体都应该改成 record。
record InternalUser(Long id, String username, String internalNote) {}
record UserProfileView(Long id, String username) {}
@Autowired
private ObjectMapper mapper;
@Test
void shouldExposeOnlyChosenFields() throws Exception {
InternalUser source = new InternalUser(1L, "learning-user", "内部备注");
UserProfileView view = new UserProfileView(source.id(), source.username());
ApiResponse<UserProfileView> response = ApiResponse.success(view);
var json = mapper.readTree(mapper.writeValueAsString(response));
assertEquals("learning-user", json.path("data").path("username").asText());
assertFalse(json.path("data").has("internalNote"));
}
}
步骤:在上述路径创建文件 → 学习工程根目录运行 mvn -Dtest=ModelBoundaryTest test → 预期通过。关键不是字段上是否有某个注解,而是对外模型根本没有内部字段,减少意外泄露的机会。
若找不到 ApiResponse,先完成 Spring Boot 练习;如果直接返回 InternalUser,测试设计的安全边界就被绕过了。对应回 hhjava 时,查找 RegisterDto、User、AuthenticatedUser、MobileTokenResponse 的用途,不要仅看它们都含 username 就合并成一个类。