很多做后端的朋友应该都有过这种经历:一个项目刚开始只有 PC 端登录,代码简单,一个 login 方法里写死用户名密码校验。过了半年 App 端上线了,加一个分支。再后来小程序扫码登录、内部系统免密登录、第三方授权登录……每个需求来都是往原来那个 login 方法里怼代码,最后那个方法变成几百行 if-else 不说,改一处还怕动到另一处。今天这篇就来聊怎么用工厂模式加策略模式,把这堆乱成一团的登录逻辑拆干净,让多端登录从“能跑”变成“容易改”。
这个方案不是什么新奇发明,它就是把两种经典设计模式组合起来用:策略模式负责“每种登录方式各管各的”,工厂模式负责“根据请求参数找到该用哪个策略”。再加上 Spring Boot 自身的依赖注入能力,连自己 new 对象的步骤都省了。适合在原有单体项目上渐进式改造,也适合新项目一开始就把登录模块搭规整。后面我讲的全是 Spring Boot 项目里的落地细节,代码都是可以直接抄的。
在这之前我先把结论说在前面:登录逻辑乱的根源,不是因为“登录”这个动作复杂,而是因为“登录来源”的差异被揉进了一个方法里。我们真正的目标不是把代码写得更高深,而是让每种来源的差异约束在一个类里,新增来源时不去碰老代码。后面所有设计都是围绕这一句话展开的。
1. 登录逻辑差的根源:一段代码是怎么变成一团乱麻的
1.1 多端登录的典型混乱场景
先描述一下典型症状。我接手过的一个老项目,登录入口长这样:Controller 里有一个 login 方法,接收一个 Map 参数,然后里面从头到尾就是一堆 if-else 判断 loginType。PC 密码走这段,App 短信验证码走那段,微信登录又走另一段。每个分支里还夹杂着验验证码、查用户、生成 token、更新最后登录时间、记录登录日志这些通用逻辑,写到后面整整一个方法五六百行,同事跟我说“改登录没人敢动”。
这种写法带来的问题不止是难看,更麻烦的是它把“登录方式独有的逻辑”和“登录成功后的通用逻辑”焊死在了一起。今天要加一个扫码登录,你得在一个巨大方法中间找位置插入新分支,旁边没有任何隔离。测一次密码登录,得从头走一遍所有分支判断;改一个通用逻辑,所有端的行为都跟着变,风险根本不可控。如果再碰上团队几个人同时改这个文件,那 git 冲突能把人逼疯。
1.2 为什么用策略加工厂而不是继续堆 if-else
要解决这个混乱,第一步是让“不同的登录方式”变成“不同的策略”,各自封装成一个类。策略模式解决的是算法族的封装问题:每种登录方式自己知道怎么验证身份、怎么获取用户信息、怎么组装登录结果。第二步是用工厂来承接“根据请求类型找到策略”这件例行公事。工厂模式解决的是创建与选择逻辑的集中,避免调用方到处写 switch-case。
我把这套方案选型的关键理由拆成三条。第一条,单一职责,每个策略类只做自己这一类登录的事,类小、易读、易测;第二条,开闭原则,新增登录方式时新增一个策略类就行,不需要改任何老类;第三条,配合 Spring 容器后,策略自动注册,连工厂里的 map 注册逻辑都不用改。这三条对工程化维护的价值,比任何炫技都实在。你可能觉得“又多了一层类”,但实际上每一层都在回应一个明确的职责变化点。
2. 工厂加策略的架构设计:先定契约,再管路由
2.1 策略接口:登录动作的统一契约
设计策略接口的时候,不能设计得太具体,否则每种策略都要被迫实现一堆用不上的方法;也不能设计得太抽象,否则调用方又要做类型判断。经过反复调整,我最后用的接口长这样。
public interface LoginStrategy { /** * 当前策略支持的登录方式标识 * 例如:pc_password、app_sms、wx_scan */ String supportLoginType(); /** * 执行登录逻辑,返回统一结果结构 */ LoginResult login(LoginRequest request); }这个接口就两个方法。第一个是 supportLoginType,用来让工厂知道这个策略服务哪种登录方式;第二个是 login,真正干活的入口。有人可能会问,为什么不在抽象类里把公共逻辑比如“登录成功后的埋点”也写了。我的建议是,公共逻辑可以放在抽象模板类里,但策略接口本身一定要保持克制。接口越轻,扩展成本越低。
2.2 工厂类:从登录类型到策略实例的转换器
工厂类的核心,就是把一个字符串类型的登录类型,转换成对应的策略对象。Spring 容器里如果所有策略都注册成 Bean,那么在工厂构造时通过构造器注入一个 List ,Java 8 之后的写法非常清爽。这里我直接给出一版在实际项目里跑过的实现。
@Component public class LoginStrategyFactory { private final Map<String, LoginStrategy> strategies; public LoginStrategyFactory(List<LoginStrategy> loginStrategyList) { this.strategies = loginStrategyList.stream() .collect(Collectors.toMap(LoginStrategy::supportLoginType, Function.identity())); } public LoginStrategy getStrategy(String loginType) { LoginStrategy strategy = strategies.get(loginType); if (strategy == null) { throw new IllegalArgumentException("未找到对应的登录策略: " + loginType); } return strategy; } }这里用 List 注入,Spring 会自动把容器里所有 LoginStrategy 实现类的 Bean 丢进来。toMap 会把每个策略的 supportLoginType 当作 key,策略实例当作 value。调用方只依赖于 getStrategy 这一个方法,不用关心策略怎么来的。如果登录类型不支持,直接抛出异常,这个异常在统一异常处理器里会被转成友好提示。这比你返回 null 再让调用方判断要安全得多。
2.3 Spring Bean 自动注册带来的扩展红利
用这个方案的另一个好处是,因为策略本身就是一个普通 Spring 托管 Bean,新加登录方式的时候,你只需要在项目中新增一个实现 LoginStrategy 的类,标注 @Component 或者在其配置类中注册为 Bean,然后它就自动出现在工厂的 List 里了。工厂不需要加 if,不需要加 switch,原来的策略类也一个都不用改。
这在多人协作的场景下特别有用。A 同事在加 App 扫码登录,B 同事在加微信小程序登录,两个人各自写各自的策略类,互相不碰文件,提交代码基本没有冲突。配合代码评审也能一眼看出新策略实现有没有问题,因为每个策略类都足够小。我之前在一个项目里用这套方式,二十多个登录入口,代码结构始终没乱过。
3. 实操落地:用统一登录入口替换掉几百行 if-else
3.1 工程准备与基础依赖
我用的是 Spring Boot 2.7.x 加 JDK 8,即便你用的是更高版本甚至 Spring Boot 3.x,核心思路完全一致。工程里需要引入的基础依赖基本就是 spring-boot-starter-web,加上你自己项目里已有的 MyBatis 或 JPA 等持久层框架。登录的时候涉及 JWT 生成 token 的话,再引入一个 jjwt 依赖就可以。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>JWT 的细节不是这篇的重点,重点是:token 生成这套逻辑属于“登录成功后的通用动作”,我建议放在一个单独的 TokenService 里,每个策略需要发 token 的时候直接调用它,而不是每个策略自己重新搓一遍。后面会看到这样做对多端互斥、token 失效这些需求帮助非常大。
3.2 登录请求与响应 DTO 设计
多端登录的参数其实差异不小。PC 密码登录要账号密码验证码,App 短信登录要手机号验证码,微信扫码登录要临时 code。虽然参数不一样,但 HTTP 入口最好统一,所以我会用一组带可变字段的 DTO 去承接,同时把登录类型字段放在最前面。
public class LoginRequest { private String loginType; // pc_password / app_sms / wx_scan private String username; private String password; private String captchaKey; private String captchaCode; private String phone; private String smsCode; private String wxCode; private String clientType; // pc / app / mini_program // getter/setter 省略 }这样的 DTO 虽然不完美,但胜在实用。所有可选字段都在一个对象里,策略内部取自己需要的字段,取不到就抛业务异常。相比每个登录方式各写一个 Request 类,这个方案在接口入口上更统一,前端的联调成本也更低。当然如果你对接口文档要求更高,可以在 Controller 层做分组校验,那是后续优化的事。
3.3 三种策略的具体实现
先看最简单的 PC 密码登录策略。它做的事情是:校验图形验证码,按用户名查用户,比对密码,生成 token,返回结果。
@Component public class PcPasswordLoginStrategy implements LoginStrategy { private final UserMapper userMapper; private final TokenService tokenService; private final CaptchaService captchaService; private final PasswordEncoder passwordEncoder; public PcPasswordLoginStrategy(UserMapper userMapper, TokenService tokenService, CaptchaService captchaService, PasswordEncoder passwordEncoder) { this.userMapper = userMapper; this.tokenService = tokenService; this.captchaService = captchaService; this.passwordEncoder = passwordEncoder; } @Override public String supportLoginType() { return "pc_password"; } @Override public LoginResult login(LoginRequest request) { captchaService.validate(request.getCaptchaKey(), request.getCaptchaCode()); if (StringUtils.isBlank(request.getUsername()) || StringUtils.isBlank(request.getPassword())) { throw new BusinessException("账号密码不能为空"); } User user = userMapper.selectByUsername(request.getUsername()); if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new BusinessException("用户名或密码错误"); } String token = tokenService.generateToken(user.getId(), request.getClientType()); return LoginResult.success(token, user); } }这种代码就是“一个类一个世界”。图形验证码校验、密码校验、用户查询这些逻辑被清楚地分层。假如验证码校验逻辑在密码登录里要用,而在注册流程里也要用,这种跨功能的公共能力就抽成 CaptchaService,策略只在需要时注入,不强塞。
再看 App 短信登录策略。它的特殊之处在于:不再有用户名密码互查,而是直接用手机号查用户,用户不存在则走快速注册流程。
@Component public class AppSmsLoginStrategy implements LoginStrategy { private final UserMapper userMapper; private final TokenService tokenService; private final SmsValidateService smsValidateService; public AppSmsLoginStrategy(UserMapper userMapper, TokenService tokenService, SmsValidateService smsValidateService) { this.userMapper = userMapper; this.tokenService = tokenService; this.smsValidateService = smsValidateService; } @Override public String supportLoginType() { return "app_sms"; } @Override public LoginResult login(LoginRequest request) { smsValidateService.validate(request.getPhone(), request.getSmsCode()); User user = userMapper.selectByPhone(request.getPhone()); if (user == null) { user = userMapper.createQuickUser(request.getPhone()); } String token = tokenService.generateToken(user.getId(), request.getClientType()); return LoginResult.success(token, user); } }这里有个实操细节:像 smsValidateService.validate 校验失败抛异常时,最好抛业务异常 BusinessException,便于统一异常处理器捕捉。千万避开在策略内部返回 LoginResult.error 之类的对象,让策略只负责业务成败,统一结果对象的结构由外层封装,职责会更清晰,也避免调用方还要处理半成功半失败的状态。
最后一个微信扫码登录策略。拿到的 wxCode 是临时授权码,需要向微信接口换取 openId 和用户信息,再在自己的系统里找到对应的用户。这个策略跟另外两个核心差异在于要调外部接口,而且有网络超时、code 过期这些风险,异常处理要额外关注。
@Component public class WxScanLoginStrategy implements LoginStrategy { private final UserMapper userMapper; private final TokenService tokenService; private final WxAuthService wxAuthService; public WxScanLoginStrategy(UserMapper userMapper, TokenService tokenService, WxAuthService wxAuthService) { this.userMapper = userMapper; this.tokenService = tokenService; this.wxAuthService = wxAuthService; } @Override public String supportLoginType() { return "wx_scan"; } @Override public LoginResult login(LoginRequest request) { String openId = wxAuthService.getOpenIdByCode(request.getWxCode()); User user = userMapper.selectByOpenId(openId); if (user == null) { throw new BusinessException("该微信尚未绑定任何账号"); } String token = tokenService.generateToken(user.getId(), request.getClientType()); return LoginResult.success(token, user); } }三个策略写下来你会发现,它们各自只关心自己的登录来源,登录成功之后的 token 生成、用户信息返回,全都在各策略内部依赖同一个 TokenService 完成,这样保证多端的登录结果结构一致。对前端来说,不管什么端登录,拿到的都是 token 加用户信息,使用体验是统一的。
3.4 统一入口的 Controller 层改造
Controller 这层就非常简单了,因为真正的复杂性已经被策略和工厂消化掉了。
@RestController public class LoginController { private final LoginStrategyFactory strategyFactory; public LoginController(LoginStrategyFactory strategyFactory) { this.strategyFactory = strategyFactory; } @PostMapping("/login") public Result<LoginResult> login(@RequestBody @Validated LoginRequest request) { return Result.ok(strategyFactory.getStrategy(request.getLoginType()).login(request)); } @PostMapping("/logout") public Result<Void> logout(@RequestHeader("Authorization") String token) { tokenService.invalidate(token); return Result.ok(); } }一个 login 接口,一个 logout 接口,所有端共用。新增登录方式时,Controller 一行不用改,工厂一行不要动,只要新增策略类。这就是我们前面说的“扩展开放、修改关闭”真正落到代码上的样子。前端调登录接口时只需要多传一个 loginType 字段,成本几乎可以忽略。
3.5 策略选择器的兜底与日志
虽然有了统一入口,我还是建议在工厂的 getStrategy 上加一个兜底:登录类型不合法的时候,不要返回 null,直接抛异常。这能省掉 Controller 层的判空逻辑,也避免前端拿空对象以为登录成功了。
我再补一个判断技巧:如果你发现某个策略里需要根据字段再走一大段 if-else,那说明你策略粒度没拆分好。比如同样是 App 端,密码登录、验证码登录、生物识别登录就应该继续拆成三个策略类。宁可多几个小类,也不要在一个类里再次出现分支地狱。判断粒度是否合适的标准很简单:这个类能不能用一句话说清自己的职责,能就继续用,不能就继续拆。
4. 向真实场景延伸:验证码、扫码登录与多端互斥
4.1 用策略模式快速扩展新的登录方式
我们的工厂方案天然适合不停新增登录方式。假设下一版要支持“管理后台 OTP 动态口令登录”,就新增一个 AdminOtpLoginStrategy,supportLoginType 填 "admin_otp",然后实现校验 OTP、查询管理员用户、生成 token 的逻辑。完成后它自动被 Spring 发现,工厂的 List 注入时自动带上它,无需改动任何老代码。
这种扩展带来的直接收益是,产品经理每提一个新登录需求,我们不用再评估“改登录方法会不会影响其他端”,因为从设计上就已经切割干净了。这种安全感是做登录模块最需要的。我见过太多项目因为登录逻辑不敢动,导致新需求一拖再拖,最后拖成技术债。有了这套结构,新需求来了就是“复制一个类改一改”的事,效率完全不一样。
4.2 多端互斥与 token 管理方案
多端登录有时候不只是“都能登录”,还要控制“同一账号能否同时在多端在线”。这在运营后台、财务后台这类安全要求高的场景里很有必要。我一般在 TokenService 里做两个动作:一是生成 token 时带上用户唯一标识和端标识;二是维护一张 token 状态表,记录 token 与用户、端的绑定关系。如果业务要求互斥,就在生成新 token 时把该用户其他端或者同端旧 token 标记失效。
这里最容易被忽略的是“端标识”。如果所有端生成的都是同一种无差异 token,那互斥就没法做到精细颗粒度。我们的策略类里应该把 clientType 从 LoginRequest 里取出来,再传给 tokenService.generateToken。这样 TokenService 在生成 token 时可以拼上端信息,互斥逻辑也能按端做不同策略:同端互斥、多端各自在线还是只能单一在线,这些都是配置化的。
给一个更简洁的做法:TokenService 里维护一个存有效 token 的关系表,key 是 userId 加 clientType,value 是当前有效 token。登录时如果发现已有有效 token 且要求互斥,则先把旧 token 加入黑名单,再签新的。因为工厂模式下各策略都走同一个 TokenService,所以互斥策略能一致地生效,不会出现某个端绕开了规则。黑名单可以用 Redis,TTL 设置为 token 剩余有效期,自然过期也不怕。
4.3 兼容老接口:渐进式改造怎么过渡
如果你跟我的情况一样,不是从零写新项目,而是改造一个已经上线多年的老登录接口,那别急着把老接口拆掉。稳妥的做法是保留老的 /login/auth 接口对外不可变,内部把它适配到工厂模式的调用上,也就是让老接口也调用 strategyFactory.getStrategy("pc_password"),这样前端不用改,后端换了一颗“新心脏”,等全量验证通过后再让前端切换新接口。
渐进式改造的关键在于灰度。我建议在改造后加上一行日志,把 loginType 和策略类名打出来,可以很方便地在链路里确认实际执行了哪个策略类,避免“明明切了新逻辑,线上却还在走老分支”这种尴尬。上线之后先观察几天,重点看错误率有没有变化,登录成功率是否正常,确认没问题后再切流量,这一步不能省。
5. 踩坑记录与排查套路
5.1 策略 Bean 没有被注入
现象是启动报错或者调用时提示策略找不到。我遇到过的一个高发原因是:策略类放在扫描包之外,Spring 没有把它注册成 Bean,自然就不在工厂的 List 里。解决办法很简单,要么把类移进扫描路径,要么在配置类里用 @Bean 方式显式注册。
第二种高发原因是有两个类返回了相同的 supportLoginType。Collectors.toMap 在遇到重复 key 时默认会抛 IllegalStateException,启动时就失败,反而帮我们提前暴露了问题。我自己的习惯是,把登录类型的常量统一放在一个枚举或者常量类里,禁止用裸字符串到处散落,这样重复的概率就低很多。
5.2 Bean 命名冲突与类型区分
当策略类名以相同单词开头时,Spring 默认的 Bean 名字可能会冲突。比如你的策略实现类叫 WxQrcodeLoginStrategy 和 WxScanLoginStrategy,同时你还想手动写 @Bean 方法,可能会遇到 Bean 名重复的问题。我的建议是,策略类一律采用“端加方式加 Strategy”的命名风格,是否显式命名 Bean 都不要紧,因为我们的工厂是通过 supportLoginType 来区分的,不依赖 Bean 名字。
真正要警惕的是 @Bean 方法重名。如果两个配置类里都有 @Bean 方法返回 LoginStrategy,而且方法名一样,Spring 会直接报错。遇到这种情况,我给 @Bean 方法加上不同的名字,或者干脆让策略类自己加 @Component,不要重复注册。越是依赖自动装配,越要留意这些隐式规则。
5.3 策略对象里的循环依赖
如果一个策略依赖 ServiceB,ServiceB 又反过来依赖策略,那在 Spring 里就会形成循环依赖。虽然 Spring Boot 2.6 开始把循环依赖默认禁止了,但老项目开着允许开关,代码会继续转起来,只是性能有损耗,而且结构上有隐患。遇到这种情况,我的做法是调整依赖方向,抽出一个中间层。
比如把 token 生成抽成独立的 TokenService,让 ServiceB 依赖 TokenService 而不是依赖具体策略,这样策略和服务之间的耦合就切开了。循环依赖看起来是配置问题,其实是设计问题,别用 @Lazy 敷衍,把依赖方向理清楚才是根治。
5.4 快速定位当前执行了哪个策略
线下调试的时候,最直接的办法是在工厂的 getStrategy 方法里加一行日志,打印登录类型和找到的策略类名。
public LoginStrategy getStrategy(String loginType) { LoginStrategy strategy = strategies.get(loginType); if (strategy == null) { throw new IllegalArgumentException("未找到对应的登录策略: " + loginType); } log.info("获取到登录策略: {} -> {}", loginType, strategy.getClass().getSimpleName()); return strategy; }线上排查时,这行日志是救命稻草。用户反馈某个端登录不了,我们第一反应就是看日志里走的是哪个策略类。如果登录类型值和策略类对不上,基本就是前端传错参数或者配置没有刷到最新版本。如果日志显示策略类正确但没有拿到业务数据,就得往下面去查具体策略里依赖的 Service 和外部接口,排查范围一下子就能缩小。
5.5 单测怎么写才有效
最后补一个我自己的测试习惯。策略类因为职责单一,单测反而非常好写。针对每个策略,覆盖成功分支、参数缺失分支、校验失败分支、用户不存在分支就够了。工厂类只需要测试两个点:存在的 loginType 能返回正确策略,不存在的 loginType 抛异常。Controller 层做接口级测试时,直接通过 MockMvc 调用,再断言返回的 token 是否生成成功。这样一套测试下来,改动任何一个策略都不会波及其他端的测试,回归范围小,信心足。
这套方案我在好几个项目里落地过,最大的体会不是“代码变好看了”,而是“功能迭代的压力变小了”。以前听到新登录需求就头大,现在拿到需求基本就是新建一个策略类,写自己的业务,测试也只需要针对这个新类做单测。不是所有场景都需要设计模式,但登录这种天然带有多种变体、且变体会不断增长的业务,确实是策略加工厂最典型的适用场景。如果你现在正被一段几百行的 login 方法折磨,可以先按这个思路把接口抽出来,再把分支挪到策略类里,跑通一个端之后再逐步迁移其他端。这中间踩坑的过程,能让你对 Spring 容器和设计模式的理解深一个档次。