Note 19(第二章). SpringBoot3-手动实现一个最佳规范的策略模式登录注册认证工厂
Note 19(第二章). SpringBoot3-手动实现一个最佳规范的策略模式登录注册认证工厂
ProriseNote 19.2. 逻辑引擎:基于策略模式的认证工厂
19.2.1. 问题引入
在开始设计认证工厂之前,我们需要先理解一个问题:为什么不能用 if-else 实现多种登录方式?
场景模拟:五种登录方式的传统实现
假设我们现在要支持 5 种登录方式:
- 账号密码登录:用户输入用户名和密码
- 手机验证码登录:用户输入手机号和验证码
- 微信扫码登录:用户扫描二维码,微信返回授权码
- GitHub OAuth2 登录:用户授权后,GitHub 返回授权码
- Google OAuth2 登录:用户授权后,Google 返回授权码
在没有设计模式的情况下,我们的 Controller 会是这样的:
📄 文件路径:auth-web/src/main/java/com/example/auth/web/controller/AuthController.java(传统写法)
1 |
|
传统写法的五大致命问题
问题一:代码膨胀
这个 Controller 的 login 方法已经超过 250 行代码。如果再加上异常处理、日志记录、参数校验,代码量会超过 400 行。
想象一下,当你需要修改某个登录方式的逻辑时,你需要在这 400 行代码中找到对应的 if-else 分支,然后小心翼翼地修改,生怕影响到其他分支。这种体验就像在一个巨大的迷宫中寻找出口。
问题二:违反开闭原则
当我们需要新增一个登录方式(如 “Apple 登录”)时,必须修改 AuthController 的代码,增加一个新的 else if 分支。这违反了开闭原则(Open-Closed Principle):对扩展开放,对修改关闭。
在传统写法中,每次新增登录方式都需要修改 Controller,这意味着:
- 需要重新测试所有登录方式(因为修改了 Controller)
- 可能引入新的 Bug(因为修改了现有代码)
- 代码越来越臃肿(每次新增都会增加 50+ 行代码)
问题三:难以测试
我们无法单独测试某个登录方式的逻辑。如果要测试 “微信登录”,必须:
- 启动整个 Spring Boot 应用
- 模拟所有依赖(UserService、WechatService 等)
- 构造完整的 HTTP 请求
- 验证响应结果
这种测试方式效率极低,而且容易受到其他登录方式的干扰。
问题四:职责不清
Controller 层不应该包含业务逻辑。它的职责应该是:
- 接收请求
- 参数校验
- 调用 Service 层
- 返回响应
但现在 Controller 层包含了大量的业务逻辑(密码验证、用户创建、Token 生成等),违反了单一职责原则(Single Responsibility Principle)。
在传统写法中,Controller 承担了太多职责:
- 路由分发(根据 type 选择登录方式)
- 参数解析(从 Map 中提取参数)
- 业务逻辑(验证密码、调用第三方 API)
- 异常处理(捕获并转换异常)
问题五:无法动态管理
我们无法在运行时动态禁用某个登录方式。如果要禁用 “微信登录”,必须:
- 修改代码(注释掉对应的 if-else 分支)
- 重新编译
- 重新部署
这在生产环境中是不可接受的。想象一下,如果微信登录接口出现故障,我们需要紧急禁用这个功能,但却需要重新部署整个应用,这会导致服务中断。
解决方案:策略模式 + 工厂模式
我们需要一个更优雅的设计:
- 策略模式:将每种登录方式封装为一个独立的策略类
- 工厂模式:使用工厂类根据登录类型自动选择对应的策略
架构对比:
| 对比维度 | 传统 if-else | 策略模式 + 工厂模式 |
|---|---|---|
| Controller 代码量 | 250+ 行 | 10 行 |
| 新增登录方式 | 修改 Controller | 只需实现 AuthStrategy 接口 |
| 可测试性 | 难以单独测试 | 可以单独测试每个策略 |
| 职责分离 | Controller 包含业务逻辑 | Controller 只负责调度 |
| 动态管理 | 不支持 | 支持(配置化管理) |
| 符合设计原则 | ❌ 违反开闭原则、单一职责原则 | ✅ 符合开闭原则、单一职责原则 |
在接下来的章节中,我们将一步步构建这个认证工厂,让你亲眼见证代码从 250 行减少到 10 行的过程。
19.2.2. 架构设计
在上一节中,我们已经看到了传统 if-else 登录的五大致命问题。现在,让我们设计一个更优雅的解决方案。
在开始编写代码之前,我们需要先设计整个认证工厂的架构。这就像盖房子之前要先画设计图一样,架构设计决定了代码的可维护性和可扩展性。
核心设计理念
我们的认证工厂基于三个核心理念:
| 理念 | 类比 | 系统实现 |
|---|---|---|
| 统一入口 | 机场安检口:无论国内航班还是国际航班,都从同一个入口进入,然后根据航班类型被引导到不同的登机口 | - 统一入口:AuthController 的 /auth/login 接口- 多态分发: AuthStrategyFactory 根据 authType 选择对应的策略 |
| 策略独立 | 餐厅的不同厨师:川菜师傅和粤菜师傅各司其职,互不干扰 | - 账号密码登录:PasswordAuthStrategy- 手机验证码登录: SmsAuthStrategy- 微信扫码登录: WechatAuthStrategy- 每个策略类只负责自己的验证逻辑,不需要知道其他策略的存在 |
| 工厂管理 | 公司的人力资源部门:自动发现和管理所有员工,不需要手动登记 | - AuthStrategyFactory 在启动时自动扫描所有实现了 AuthStrategy 接口的 Bean- 将这些策略注册到 Map<AuthType, AuthStrategy> 中- 后续根据 authType 自动选择对应的策略 |
架构全景图
让我们先看一下整个认证工厂的架构全景图:
架构说明:
从上到下,整个系统分为六层:
第一层:客户端层
- 前端应用(Web、移动端、小程序等)发送登录请求
- 请求中必须包含
authType字段,标识登录方式
第二层:接入层
AuthController接收请求,这是系统的统一入口- Controller 不包含任何业务逻辑,只负责接收请求和返回响应
第三层:工厂层
AuthStrategyFactory根据authType选择对应的策略- 这是整个系统的 “中枢神经”,负责策略的管理和分发
第四层:策略层
- 每种登录方式都是一个独立的策略类
- 策略类只负责验证身份,返回用户 ID
第五层:服务层
- 策略类调用各种服务完成业务逻辑
- 如
UserService(查询用户)、SmsService(验证验证码)、WechatService(调用微信 API)
第六层:数据层
- MySQL 存储用户数据
- Redis 存储会话信息(由 Sa-Token 管理)
请求流转时序图
现在让我们看一下一个完整的登录请求是如何在系统中流转的:
时序说明:
让我们逐步分析这个流程:
步骤 1:前端发送登录请求
1 | { |
前端必须在请求中携带 authType 字段,标识这是哪种登录方式。
步骤 2:Controller 接收请求
AuthController 接收到请求后,不做任何业务逻辑处理,直接委托给 AuthStrategyFactory:
1 |
|
步骤 3:工厂选择策略
AuthStrategyFactory 根据 authType 字段,从 Map<AuthType, AuthStrategy> 中获取对应的策略:
1 | AuthStrategy strategy = strategyMap.get(request.getAuthType()); |
步骤 4:策略执行认证
策略类执行具体的认证逻辑(查询用户、验证密码等),返回用户 ID:
1 | Long userId = strategy.authenticate(request); |
步骤 5:工厂调用 Sa-Token 登录
工厂类拿到用户 ID 后,调用 Sa-Token 的登录方法:
1 | StpUtil.login(userId); |
Sa-Token 会自动生成 Token 并存入 Redis。
步骤 6:返回 Token
工厂类构建响应对象,包含 Token 信息:
1 | return AuthTokenVO.builder() |
步骤 7:层层返回
响应对象层层返回,最终到达前端。
策略模式与 Sa-Token 的协同关系
现在让我们深入理解策略模式与 Sa-Token 是如何协同工作的。
Sa-Token 的职责边界
Sa-Token 只负责 “会话管理”,不负责 “身份验证”。具体来说:
Sa-Token 负责的事情:
- ✅ 生成 Token(
StpUtil.login(userId)会自动生成) - ✅ 验证 Token(
StpUtil.checkLogin()会自动验证) - ✅ 管理会话(将会话信息存入 Redis)
- ✅ 权限验证(
StpUtil.checkPermission()等) - ✅ 踢人下线(
StpUtil.kickout(userId))
Sa-Token 不负责的事情:
- ❌ 验证用户名和密码
- ❌ 验证手机验证码
- ❌ 调用微信 API 获取 openId
- ❌ 查询或创建用户
策略模式的职责边界
策略模式负责 “身份验证”,不负责 “会话管理”。具体来说:
策略类负责的事情:
- ✅ 验证用户名和密码
- ✅ 验证手机验证码
- ✅ 调用第三方 API 获取用户信息
- ✅ 查询或创建用户
- ✅ 返回用户 ID
策略类不负责的事情:
- ❌ 生成 Token(由 Sa-Token 负责)
- ❌ 管理会话(由 Sa-Token 负责)
- ❌ 权限验证(由 Sa-Token 负责)
协同工作流程
让我们用一个具体的例子来说明它们是如何协同工作的。假设用户使用账号密码登录:
1 | // 步骤 1:策略类验证身份 |
关键设计点:
策略类只返回 userId,不返回 Token。这是因为:
- Token 的生成是 Sa-Token 的职责,策略类不应该关心
- 这样做可以让策略类保持职责单一,易于测试
工厂类负责调用 StpUtil.login(userId)。这是因为:
- 工厂类是策略模式和 Sa-Token 的 “桥梁”
- 所有策略类都需要调用 Sa-Token 登录,放在工厂类中可以避免重复代码
策略模式的类图结构
让我们用类图来展示策略模式的结构:
类图说明:
AuthRequest 接口
- 这是所有认证请求的统一接口
- 定义了一个方法:
getAuthType(),用于标识登录类型 - 所有具体的请求类(如
PasswordAuthRequest)都必须实现这个接口
AuthStrategy 接口
- 这是所有认证策略的统一接口
- 定义了两个方法:
authenticate(AuthRequest):执行认证,返回用户 IDgetSupportedType():返回支持的认证类型
AuthStrategyFactory 工厂类
- 管理所有策略的映射关系(
Map<AuthType, AuthStrategy>) - 提供
authenticate(AuthRequest)方法,作为统一的认证入口 - 在应用启动时自动发现和注册所有策略
具体策略类
PasswordAuthStrategy:账号密码登录SmsAuthStrategy:手机验证码登录WechatAuthStrategy:微信扫码登录
每个策略类都实现了 AuthStrategy 接口,并提供自己的认证逻辑。
设计模式的职责分工
让我们用一个表格来总结各个设计模式的职责:
| 设计模式 | 职责 | 核心类 | 关键方法 |
|---|---|---|---|
| 策略模式 | 封装算法族,让它们可以互相替换 | AuthStrategy 接口及其实现类 | authenticate(AuthRequest) |
| 工厂模式 | 根据条件创建对象,隐藏创建逻辑 | AuthStrategyFactory | getStrategy(AuthType) |
| 多态 | 统一接口,不同实现 | AuthRequest 接口及其实现类 | getAuthType() |
策略模式的核心价值:
将每种登录方式封装为独立的策略类,它们之间互不依赖。这样做的好处是:
- 新增登录方式不需要修改现有代码
- 可以单独测试每个策略
- 可以动态启用或禁用某个策略
工厂模式的核心价值:
根据登录类型自动选择对应的策略,隐藏了策略选择的复杂性。这样做的好处是:
- Controller 不需要知道如何选择策略
- 策略的注册和管理都由工厂类负责
- 可以在运行时动态添加或删除策略
多态的核心价值:
使用统一的接口(AuthRequest、AuthStrategy),让不同的实现类可以互相替换。这样做的好处是:
- Controller 只需要依赖接口,不需要依赖具体实现
- 可以在不修改 Controller 的情况下替换实现类
- 符合依赖倒置原则(Dependency Inversion Principle)
与传统写法的对比
让我们用一个表格来对比传统写法和策略模式的差异:
| 对比维度 | 传统 if-else | 策略模式 + 工厂模式 |
|---|---|---|
| 代码组织 | 所有逻辑混在一起 | 每个策略独立成类 |
| Controller 代码量 | 250+ 行 | 10 行 |
| 新增登录方式 | 修改 Controller,增加 if-else 分支 | 只需实现 AuthStrategy 接口 |
| 可测试性 | 难以单独测试某个登录方式 | 可以单独测试每个策略 |
| 职责分离 | Controller 包含业务逻辑 | Controller 只负责调度 |
| 动态管理 | 不支持 | 支持(配置化管理) |
| 符合设计原则 | ❌ 违反开闭原则、单一职责原则 | ✅ 符合开闭原则、单一职责原则 |
本节小结
在本节中,我们完成了认证工厂的架构设计。
我们基于三个核心理念设计了整个系统:
- 统一入口,多态分发:所有登录请求通过同一个接口进入,然后自动分发到对应的策略
- 策略独立,互不干扰:每种登录方式都是独立的策略类,可以单独开发和测试
- 工厂管理,自动发现:工厂类在启动时自动发现和注册所有策略
我们绘制了三张关键的架构图:
- 架构全景图:展示了系统的六层结构
- 请求流转时序图:展示了一个完整的登录请求如何在系统中流转
- 策略模式类图:展示了策略模式的类结构
我们明确了策略模式与 Sa-Token 的职责边界:
- 策略类负责验证身份,返回用户 ID
- Sa-Token 负责生成 Token 和管理会话
- 工厂类是它们之间的桥梁
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 架构全景图 | 理解系统整体结构 | 识别六层架构及其职责 |
| 请求流转时序图 | 理解登录流程 | 跟踪请求从前端到数据库的完整路径 |
| 策略模式类图 | 理解策略模式结构 | 识别接口、工厂、策略三者的关系 |
在下一节中,我们将快速搭建项目骨架,让整个系统跑起来。
19.2.3. 快速搭建:10 分钟启动项目骨架
在上一节中,我们已经完成了认证工厂的架构设计,理解了策略模式与 Sa-Token 的协同关系。现在,让我们动手搭建一个可运行的项目骨架。
本节的目标很明确:用最短的时间搭建一个能够跑起来的基础环境,让你能够立即开始编写策略类。我们不会在这里讲解每个配置项的深层原理(那是后续章节的事情),而是专注于 “快速启动”。
环境准备检查清单
在开始之前,请确认你的开发环境满足以下要求:
| 组件 | 版本要求 | 检查方式 | 备注 |
|---|---|---|---|
| JDK | 17 或更高 | 终端执行 java -version | 必须是 LTS 版本 |
| Maven | 3.6+ | 终端执行 mvn -v | 或使用 IDE 内置 Maven |
| Redis | 5.0+ | 终端执行 redis-cli ping,返回 PONG | 本地或远程均可 |
| IDE | IntelliJ IDEA 2023+ | - | 推荐使用 Ultimate 版 |
如果你的 Redis 尚未启动,请先在终端执行 redis-server 启动 Redis 服务。Windows 用户可以使用 WSL 或 Docker 运行 Redis。
项目结构全景
我们将创建一个单模块的 Spring Boot 项目,目录结构如下:
1 | auth-factory-demo/ |
这个结构非常简洁,没有复杂的多模块划分。我们的重点是 策略模式的实现,而不是项目结构的复杂性。
步骤 1:创建 Spring Boot 项目
打开 IntelliJ IDEA,选择 File → New → Project。
在弹出的窗口中:
左侧选择
Spring Initializr右侧配置项目信息:
- Name:
auth - Language:
Java - Type:
Maven - Group:
com.example - Artifact:
auth - Package name:
com.example.auth - JDK:选择
17或更高版本 - Java:
17 - Packaging:
Jar
- Name:
点击
Next,在依赖选择页面,暂时不选择任何依赖(我们稍后手动添加)点击
Finish,等待项目创建完成
验证项目创建成功:
项目创建完成后,你应该能看到:
- 左侧项目树中出现了
auth-factory-demo文件夹 src/main/java/com/example/auth目录下有一个AuthFactoryDemoApplication.java启动类pom.xml文件已经生成
步骤 2:配置 Maven 依赖
现在我们需要在 pom.xml 中添加必要的依赖。
📄 文件:pom.xml
打开项目根目录下的 pom.xml 文件,将 <dependencies> 标签内的内容替换为以下内容:
1 | <dependencies> |
依赖说明:
| 依赖 | 作用 | 为什么需要 |
|---|---|---|
spring-boot-starter-web | 提供 Web 功能 | 我们需要创建 REST API |
sa-token-spring-boot3-starter | Sa-Token 核心 | 提供认证与会话管理能力 |
sa-token-redis-jackson | Sa-Token Redis 集成 | 将会话信息存入 Redis |
spring-boot-starter-data-redis | Redis 客户端 | 连接 Redis 服务器 |
commons-pool2 | 连接池 | 提高 Redis 连接性能 |
jackson-databind | JSON 处理 | 实现多态反序列化 |
spring-boot-starter-validation | 参数校验 | 验证前端传来的参数 |
lombok | 代码简化 | 自动生成 getter/setter |
刷新 Maven 依赖:
保存 pom.xml 后,点击 IDE 右侧的 Maven 面板 → 点击刷新图标(或按 Ctrl + Shift + O)。
验证依赖下载成功:
观察 IDE 底部的进度条走完,且 pom.xml 中的依赖没有红色波浪线,说明依赖下载成功。
步骤 3:配置 application.yml
现在我们需要配置 Spring Boot 的应用配置文件。
📄 文件:src/main/resources/application.yml(新建)
在 src/main/resources 目录下,右键选择 New → File,输入文件名 application.yml,然后粘贴以下内容:
1 | # 服务器配置 |
配置说明:
Redis 配置:
host和port:指定 Redis 服务器的地址和端口password:如果你的 Redis 设置了密码,请填写密码;否则留空database:Redis 有 16 个数据库(0-15),我们使用 0 号数据库lettuce.pool:连接池配置,用于提高 Redis 连接性能
Sa-Token 配置:
token-name:前端请求头中携带 Token 的字段名,我们使用Authorizationtimeout:Token 的有效期,设置为 1 天(86400 秒)active-timeout:如果用户 30 分钟内没有任何操作,Token 会自动续期is-concurrent:允许同一个账号在多个设备上同时登录is-share:不共享 Token(每个应用使用独立的 Token)token-style:Token 的生成风格,使用 UUIDis-log:开启 Sa-Token 的操作日志,方便调试
19.2.4. 接口抽象:定义统一的认证规范
在上一节中,我们已经完成了项目骨架的搭建,确认了 Spring Boot 应用能够正常启动,Redis 连接正常。现在,我们需要开始设计认证工厂的核心接口。
这一节是整个认证工厂的 “输入规范”,它定义了所有登录方式必须遵循的统一接口。就像工厂的生产线需要标准化的零件接口一样,我们的认证工厂也需要标准化的请求接口,才能让不同的登录策略无缝接入。
19.2.4.1. 统一接口的设计意图
在传统写法中,Controller 使用 Map<String, Object> 接收请求参数:
1 |
|
这种写法存在三个核心问题:
问题一:类型不安全
Map<String, Object> 中的值都是 Object 类型,需要手动强制转换。如果前端传错了类型,只能在运行时才能发现错误。
问题二:无法校验
我们无法使用 Spring 的 @Valid 注解进行参数校验,只能在业务逻辑中手动判断,导致校验代码散落在各个 if-else 分支中。
问题三:工厂无法分发
工厂模式的核心是 “根据类型选择策略”。但 Map<String, Object> 无法携带类型信息,工厂类只能通过字符串判断,这与我们的策略模式设计理念相悖。
解决方案:定义统一的接口
我们定义一个 AuthRequest 接口,所有登录请求都必须实现这个接口。这样做的核心价值是:
- 类型安全:编译时就能发现类型错误
- 参数校验:可以使用
@Valid注解自动校验 - 工厂分发:工厂类可以通过
getAuthType()方法获取类型,自动选择对应的策略
接口在工厂模式中的作用
在策略模式中,接口扮演着 “统一输入” 的角色:
1 | 前端请求 → AuthRequest 接口 → 工厂类根据 authType 选择策略 → 策略类执行认证 |
所有登录方式的请求都实现 AuthRequest 接口,工厂类只需要依赖这个接口,就能处理所有类型的登录请求。这就是 “面向接口编程” 的核心思想。
19.2.4.2. 认证类型枚举(AuthType)
在定义接口之前,我们需要先定义一个枚举类,用于标识系统支持的所有登录方式。
📄 文件:src/main/java/com/example/auth/enums/AuthType.java(新建)
1 | package com.example.auth.enums; |
设计要点
这个枚举类有两个核心字段:
code:用于前端传参和数据库存储,必须是简短的英文标识符description:用于日志记录和错误提示,必须是易读的中文描述
这样设计的好处是:前端传参时使用 code(如 "password"),简洁高效;日志记录时使用 description(如 "账号密码登录"),易于理解。
fromCode 方法用于将前端传来的字符串转换为枚举。如果前端传来的 code 不存在,会抛出 IllegalArgumentException,提示 “不支持的认证方式”。
19.2.4.3. 认证请求接口(AuthRequest)
现在我们定义统一的认证请求接口。这个接口是所有登录请求的 “父类”,它定义了所有登录方式必须遵循的规范。
📄 文件:src/main/java/com/example/auth/model/request/AuthRequest.java(新建)
1 | package com.example.auth.model.request; |
设计要点
这个接口只定义了一个方法:getAuthType()。因为不同登录方式的参数不同(账号密码登录需要 username 和 password,手机验证码登录需要 phone 和 code),我们无法在接口中定义统一的参数字段。
接口上的两个注解(@JsonTypeInfo 和 @JsonSubTypes)用于配置 Jackson 的多态反序列化。这样 Spring Boot 就能根据前端传来的 authType 字段,自动将 JSON 转换为对应的请求类。
Jackson 多态反序列化的工作流程
1 | 1. 前端发送 JSON: {"authType": "PASSWORD", "username": "admin", "password": "123456"} |
19.2.4.4. 具体请求类示例
现在我们定义一个具体的认证请求类,用于演示接口的实现方式。
📄 文件:src/main/java/com/example/auth/model/request/PasswordAuthRequest.java(新建)
1 | package com.example.auth.model.request; |
设计要点
这个请求类实现了 AuthRequest 接口,并定义了账号密码登录所需的两个字段:username 和 password。
@JsonTypeName("PASSWORD") 注解指定了类型名称,必须与 @JsonSubTypes 中的 name 属性一致,这样 Jackson 才能正确地将 "PASSWORD" 映射到 PasswordAuthRequest 类。
authType 字段设置了默认值 AuthType.PASSWORD,这样即使前端忘记传 authType 字段,也能正确识别类型。
其他请求类
按照同样的方式,我们还需要定义 SmsAuthRequest 和 WechatAuthRequest:
📄 文件:src/main/java/com/example/auth/model/request/SmsAuthRequest.java(新建)
1 | package com.example.auth.model.request; |
📄 文件:src/main/java/com/example/auth/model/request/WechatAuthRequest.java(新建)
1 | package com.example.auth.model.request; |
19.2.4.5. 本节小结
本节完成了认证工厂的输入规范设计,定义了 AuthType 枚举、AuthRequest 接口以及三个具体的请求类。这些接口为后续的工厂实现奠定了基础,工厂类可以通过 getAuthType() 方法获取类型,自动选择对应的策略。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| AuthType 枚举 | 标识登录方式 | 定义 code 和 description 字段 |
| AuthRequest 接口 | 统一请求规范 | 定义 getAuthType() 方法 |
| 具体请求类 | 实现不同登录方式 | 实现 AuthRequest 接口,添加 @JsonTypeName 注解 |
在下一节中,我们将定义认证策略接口(AuthStrategy),让每种登录方式都遵循统一的策略规范。
19.2.5. 策略接口:定义统一的认证策略
在上一节中,我们定义了统一的认证请求接口(AuthRequest),解决了 “如何统一接收不同登录方式的参数” 这个问题。现在我们需要解决另一个核心问题:如何统一处理不同登录方式的验证逻辑?
这就是策略模式的核心所在。我们将定义一个 AuthStrategy 接口,让每种登录方式都实现这个接口,从而实现 “统一规范,各自实现”。
19.2.5.1. 策略模式的核心思想
在传统写法中,所有登录方式的验证逻辑都混在 Controller 的 if-else 分支中:
1 | if ("password".equals(type)) { |
这种写法的问题在于:验证逻辑与分发逻辑耦合在一起。Controller 既要负责 “选择哪种登录方式”,又要负责 “执行具体的验证逻辑”,违反了单一职责原则。
策略模式的解决方案
策略模式将 “算法的选择” 与 “算法的实现” 分离:
- 工厂类负责选择:根据
authType选择对应的策略 - 策略类负责实现:每种登录方式都是一个独立的策略类,实现具体的验证逻辑
这样做的核心价值是:
- 职责清晰:Controller 只负责调度,策略类只负责验证
- 易于扩展:新增登录方式只需实现
AuthStrategy接口,不需要修改任何现有代码 - 易于测试:可以单独测试每个策略类,不需要启动整个应用
策略模式的三个角色
在我们的认证工厂中,策略模式包含三个角色:
| 角色 | 职责 | 在我们系统中的对应类 |
|---|---|---|
| Strategy(策略接口) | 定义所有策略的统一接口 | AuthStrategy |
| ConcreteStrategy(具体策略) | 实现具体的算法 | PasswordAuthStrategy、SmsAuthStrategy 等 |
| Context(上下文) | 持有策略引用,委托策略执行 | AuthStrategyFactory |
19.2.5.2. 策略接口的设计
现在我们定义 AuthStrategy 接口。这个接口是所有登录策略的 “父类”,它定义了所有策略必须遵循的规范。
📄 文件:src/main/java/com/example/auth/strategy/AuthStrategy.java(新建)
在 src/main/java/com/example/auth 目录下,右键选择 New → Package,输入包名 strategy,然后在 strategy 包下新建 AuthStrategy.java 文件:
1 | package com.example.auth.strategy; |
设计要点解析
设计点一:为什么返回 Long 而不是 AuthToken?
这是本接口最关键的设计决策。让我们对比两种设计方案:
方案 A:策略类返回 AuthToken(不推荐)
1 | public interface AuthStrategy { |
这种设计的问题:
- ❌ 策略类职责过重:既要验证身份,又要生成 Token
- ❌ 代码重复:每个策略类都要写一遍
StpUtil.login()和构建AuthToken的代码 - ❌ 难以测试:测试策略类时,必须模拟 Sa-Token 的行为
方案 B:策略类返回 Long(推荐)
1 | public interface AuthStrategy { |
这种设计的优势:
- ✅ 策略类职责单一:只负责验证身份
- ✅ 代码复用:Token 生成逻辑统一在工厂类中
- ✅ 易于测试:测试策略类时,只需要验证返回的用户 ID 是否正确
设计点二:为什么需要 getSupportedType() 方法?
这个方法用于标识策略类支持的认证类型。工厂类在启动时会调用这个方法,将策略注册到 Map<AuthType, AuthStrategy> 中:
1 | // 工厂类的构造函数 |
这样设计的好处是:
- ✅ 自动注册:策略类只需要实现接口,工厂类会自动发现并注册
- ✅ 类型安全:使用枚举而非字符串,避免拼写错误
设计点三:authenticate 方法的参数是 AuthRequest 接口
这是多态的体现。虽然参数类型是 AuthRequest 接口,但实际传入的是具体的实现类(如 PasswordAuthRequest)。
策略类内部需要将 AuthRequest 强制转换为具体的类型:
1 |
|
这个转换是安全的,因为工厂类会根据 authType 选择对应的策略。如果 authType 是 PASSWORD,工厂类一定会选择 PasswordAuthStrategy,而前端传来的请求一定是 PasswordAuthRequest。
19.2.5.3. 策略接口的职责边界
在实现具体的策略类之前,我们需要明确策略接口的职责边界。这是避免代码混乱的关键。
AuthStrategy 应该做什么?
策略类只负责 “验证身份”,具体包括:
- ✅ 验证用户名和密码
- ✅ 验证手机验证码
- ✅ 调用第三方 API 获取用户信息
- ✅ 查询或创建用户
- ✅ 返回用户 ID
AuthStrategy 不应该做什么?
策略类不负责 “会话管理”,具体包括:
- ❌ 不应该生成 Token(由 Sa-Token 负责)
- ❌ 不应该管理会话(由 Sa-Token 负责)
- ❌ 不应该处理 HTTP 请求和响应(由 Controller 负责)
- ❌ 不应该直接操作 Redis(应该通过 Sa-Token)
19.2.5.4. 策略模式与 Sa-Token 的协同关系
现在让我们理解策略模式与 Sa-Token 是如何协同工作的。
完整的认证流程
1 | 1. 前端发送登录请求 → Controller 接收 |
代码示例
让我们用一个具体的例子来说明:
1 | // 步骤 1:策略类验证身份 |
关键设计点
- 策略类只返回
userId,不返回Token - 工厂类负责调用
StpUtil.login(userId) - Sa-Token 自动生成 Token 并存入 Redis
- 工厂类通过
StpUtil.getTokenValue()获取 Token 值
19.2.5.5. 本节小结
本节完成了认证策略接口的设计,定义了 AuthStrategy 接口,明确了策略类的职责边界。策略类只负责验证身份并返回用户 ID,Token 的生成和会话管理由 Sa-Token 负责,工厂类作为桥梁连接两者。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| AuthStrategy 接口 | 定义策略规范 | 定义 authenticate() 和 getSupportedType() 方法 |
| 返回 Long 而非 AuthToken | 保持职责单一 | 策略类只返回用户 ID |
| 职责边界 | 避免代码混乱 | 策略类只负责验证身份,不负责生成 Token |
在下一节中,我们将实现认证策略工厂(AuthStrategyFactory),让它能够自动发现和注册所有策略,并根据 authType 自动选择对应的策略。
19.2.6. 工厂实现:自动发现与策略分发
在上一节中,我们定义了 AuthStrategy 接口,明确了策略类的职责边界:策略类只负责验证身份并返回用户 ID。现在,我们需要实现认证策略工厂(AuthStrategyFactory),让它能够自动发现和注册所有策略,并根据 authType 自动选择对应的策略。
这一节是整个认证工厂的 “中枢神经”,它连接了策略类和 Sa-Token,是策略模式能够运转的核心。
19.2.6.1. 工厂模式的核心职责
在策略模式中,工厂类扮演着 “上下文(Context)” 的角色。它的核心职责有三个:
职责一:策略管理
工厂类需要维护一个 Map<AuthType, AuthStrategy>,用于存储所有策略的映射关系。
职责二:自动发现
工厂类需要在应用启动时,自动扫描所有实现了 AuthStrategy 接口的 Bean,并注册到 Map 中。Spring 会将所有标注了 @Component 的策略类打包成 List<AuthStrategy>,注入到工厂类的构造函数中。
职责三:策略分发
工厂类需要根据认证类型(AuthType),从 Map 中获取对应的策略,并委托策略执行认证。认证成功后,工厂类调用 Sa-Token 的 StpUtil.login(userId) 完成登录,并返回 Token 信息。
工厂类的工作流程
1 | 1. 应用启动 → Spring 扫描所有 @Component 标注的 AuthStrategy 实现类 |
19.2.6.2. 定义统一响应对象(AuthTokenVO)
在实现工厂类之前,我们需要先定义统一的响应对象。因为工厂类的 authenticate 方法会返回这个对象,所以必须先定义它。
📄 文件:src/main/java/com/example/auth/model/vo/AuthTokenVO.java(新建)
在 src/main/java/com/example/auth/model 目录下,右键选择 New → Package,输入包名 vo,然后在 vo 包下新建 AuthTokenVO.java 文件:
1 | package com.example.auth.model.vo; |
设计要点
这个响应对象包含三个字段:
tokenName:Token 的名称,对应 Sa-Token 配置中的token-name(默认为"Authorization")tokenValue:Token 的值,前端需要在后续请求的 Header 中携带这个值loginId:用户 ID,用于前端展示或其他业务逻辑
前端收到这个响应后,需要将 tokenValue 存储到本地(如 LocalStorage),并在后续请求的 Header 中携带:
1 | // 前端示例 |
19.2.6.3. 实现 AuthStrategyFactory
现在我们开始实现认证策略工厂。
📄 文件:src/main/java/com/example/auth/factory/AuthStrategyFactory.java(新建)
在 src/main/java/com/example/auth 目录下,右键选择 New → Package,输入包名 factory,然后在 factory 包下新建 AuthStrategyFactory.java 文件:
1 | package com.example.auth.factory; |
19.2.6.4. 关键设计点
设计点一:为什么要检查重复的策略类型?
如果有两个策略类返回相同的 AuthType,会导致后注册的策略覆盖先注册的策略。这是一个严重的 Bug,必须在启动时就检测出来:
1 | if (strategyMap.containsKey(type)) { |
如果检测到重复,应用会在启动时抛出异常,而不是在运行时才发现问题。
设计点二:为什么要提供 authenticate 门面方法?
虽然调用者可以直接调用 getStrategy(authType).authenticate(request),但这样会暴露策略选择的细节。提供门面方法可以:
- 隐藏策略选择的复杂性
- 统一异常处理
- 统一日志记录
- 集成 Sa-Token(策略类只返回
userId,工厂类负责调用StpUtil.login(userId))
设计点三:为什么要提供 getSupportedTypes 方法?
前端可以调用这个接口,动态获取系统支持的所有登录方式,然后展示对应的登录按钮。这样做的好处是:
- 前端不需要硬编码登录方式
- 后端新增登录方式后,前端自动感知
- 可以通过配置动态启用或禁用某个登录方式
19.2.6.5. 本节小结
本节完成了认证策略工厂的实现。工厂类在应用启动时自动扫描并注册所有策略,在收到登录请求时根据 authType 选择对应的策略,策略验证成功后调用 Sa-Token 完成登录并返回 Token 信息。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 自动注册 | 应用启动时 | Spring 注入所有策略 Bean,工厂类遍历并注册到 Map |
| 策略分发 | 收到登录请求时 | 根据 authType 从 Map 中获取对应的策略 |
| Sa-Token 集成 | 策略验证成功后 | 调用 StpUtil.login(userId) 完成登录 |
在下一节中,我们将重构第一章问题引入的Controller 层,将原来的 if-else 分支替换为工厂模式,实现真正的 “零修改扩展”。
19.2.7. 控制器实现:统一入口与零分支调度
在上一节中,我们完成了认证策略工厂的实现,工厂类能够自动发现和注册所有策略,并根据 authType 自动选择对应的策略。现在,我们需要创建 Controller 层,提供统一的登录入口。
这一节是整个认证工厂的 “对外门面”,它将展示策略模式的最终效果:无论有多少种登录方式,Controller 层的代码永远只有几行。
19.2.7.1. Controller 层的职责定位
在传统的 MVC 架构中,Controller 层的职责非常明确:
Controller 应该做什么?
- ✅ 接收 HTTP 请求
- ✅ 参数校验(通过
@Valid注解) - ✅ 调用 Service 层或工厂类
- ✅ 返回统一的响应格式
Controller 不应该做什么?
- ❌ 不应该包含业务逻辑(如验证密码、查询用户)
- ❌ 不应该包含策略选择逻辑(如 if-else 分支)
- ❌ 不应该直接操作数据库或 Redis
在我们的认证工厂中,Controller 层只需要做一件事:将请求委托给工厂类。所有的策略选择、身份验证、Token 生成都由工厂类和策略类完成。
19.2.7.2. 定义统一响应格式
在创建 Controller 之前,我们需要先定义统一的响应格式。这样可以确保所有接口的返回值都遵循同一套规范。
📄 文件:src/main/java/com/example/auth/common/Result.java(新建)
在 src/main/java/com/example/auth 目录下,右键选择 New → Package,输入包名 common,然后在 common 包下新建 Result.java 文件:
1 | package com.example.auth.common; |
设计要点
这个响应类包含三个字段:
code:响应码,200 表示成功,其他表示失败message:响应消息,用于提示用户data:响应数据,泛型类型,可以是任何对象
我们提供了四个静态方法,用于快速构建响应对象:
ok():成功响应,无数据ok(T data):成功响应,有数据fail(String message):失败响应,默认错误码 500fail(Integer code, String message):失败响应,自定义错误码
19.2.7.3. 创建 AuthController
现在我们开始创建 Controller 层。
📄 文件:src/main/java/com/example/auth/controller/AuthController.java(新建)
在 src/main/java/com/example/auth 目录下,右键选择 New → Package,输入包名 controller,然后在 controller 包下新建 AuthController.java 文件:
1 | package com.example.auth.controller; |
关键设计点解析
设计点一:login 方法只有 3 行核心代码
1 |
|
这就是策略模式的最终效果。无论系统支持多少种登录方式,Controller 层的代码永远只有这几行。所有的策略选择、身份验证、Token 生成都由工厂类和策略类完成。
设计点二:@Valid 注解自动校验参数
1 | public Result<AuthTokenVO> login( AuthRequest request) |
@Valid 注解会自动触发 Spring 的参数校验机制。如果前端传来的参数不符合要求(如 username 为空),Spring 会自动返回 400 错误,错误信息为我们在请求类中定义的 message(如 “用户名不能为空”)。
设计点三:getSupportedTypes 方法返回前端友好的格式
1 |
|
前端调用这个接口后,会得到如下格式的响应:
1 | { |
前端可以根据这个列表动态展示登录按钮,而不需要硬编码登录方式。
19.2.7.4. 对比传统写法
现在让我们对比一下传统写法和策略模式的差异。
传统写法(假设的 if-else 版本)
1 |
|
策略模式写法(当前实现)
1 |
|
对比结果
| 对比维度 | 传统写法 | 策略模式 |
|---|---|---|
| Controller 代码量 | 250+ 行 | 3 行 |
| if-else 分支 | 5 个 | 0 个 |
| 新增登录方式 | 修改 Controller | 只需实现 AuthStrategy 接口 |
| 可测试性 | 难以单独测试 | 可以单独测试每个策略 |
| 职责分离 | Controller 包含业务逻辑 | Controller 只负责调度 |
19.2.7.5. 本节小结
本节完成了 Controller 层的创建,提供了统一的登录入口。Controller 层的代码非常简洁,只有 3 行核心代码,所有的策略选择、身份验证、Token 生成都由工厂类和策略类完成。这就是策略模式的最终效果:无论有多少种登录方式,Controller 层的代码永远只有几行。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 统一响应格式 | 所有接口 | 定义 Result 类,包含 code、message、data 三个字段 |
| 统一登录接口 | 前端发送登录请求 | 委托给工厂类,工厂类自动选择策略 |
| 获取支持的登录方式 | 前端动态展示登录按钮 | 调用工厂类的 getSupportedTypes 方法 |
在下一节中,我们将实现具体的策略类,让整个认证工厂真正运转起来。








