多端登录还在写 if-else?工厂 + 策略模式一招解耦

密码、微信、短信验证码……登录方式一多,LoginService 里就容易堆成一坨 if-else。用策略模式把每种登录封装成独立算法、工厂模式负责按类型创建实例,新增一种登录方式只加一个类、零改动老代码。这篇给一套能直接落地的 Spring Boot 实现。

多端登录还在写 if-else?工厂 + 策略模式一招解耦

登录接口大概是后端项目里最容易”长歪”的地方。 Product 一句”我们要支持微信扫码”,你就往 LoginService 里加一个 else if;过两周又来”再加个手机号验证码”,再加一个。等支持到第五种方式时,一个方法里塞了上百行分支,改一处生怕漏了另一处的校验。

这不是你代码写得差,是用 if-else 表达”多分支算法”天生就难维护——它把”算法选择”和”算法本身”揉在了一起。解决办法其实是设计模式里的两个老面孔:策略模式(Strategy)负责把每种登录拆成独立算法,工厂模式(Factory)负责按类型把算法挑出来。两者一组合,登录模块就变成了一个”热插拔”接口。

下面是一套能直接落地的 Spring Boot 实现,从接口定义到控制器集成,最后看怎么”加一种登录方式只动一个文件”。

一、先把问题说清楚:传统写法卡在哪

典型的”面条登录”长这样:

public String login(String loginType, Map<String, Object> params) {
    if ("password".equals(loginType)) {
        // 用户名密码:校验密码、查账号是否锁定
    } else if ("wechat".equals(loginType)) {
        // 微信:拿 authCode 调开放平台、绑定关系
    } else if ("sms".equals(loginType)) {
        // 短信:校验验证码有效期和正确性
    } else {
        throw new IllegalArgumentException("不支持的登录类型");
    }
}

它有三个绕不开的坑:

  1. 违反开闭原则:每加一种登录方式,就得回来改这个方法。改得越多,回归测试越心惊。
  2. 职责混乱:密码加密、调微信 API、查 Redis 验证码全挤在一个类里,可读性差,新人接手一头雾水。
  3. 公共逻辑抽不出:登录成功后生成 Token、写登录日志,每种方式都要一遍——复制粘贴就埋下了不一致的种子。

策略模式正好对症:把”每种登录怎么走”封装成独立类,调用方只认一个统一接口,不关心里面是什么。

二、定义统一策略接口

先约定一个接口,所有登录方式都实现它。getLoginType() 返回和前端约定的类型标识(如 password / wechat / sms),execute() 是真正的登录逻辑。

public interface LoginStrategy {

    /** 登录类型标识,与前端约定的值一致,如 "password"、"wechat" */
    String getLoginType();

    /** 执行登录,返回登录结果(Token、用户信息等) */
    LoginResult execute(LoginRequest request);
}

请求参数用一个统一入参包住类型 + 业务参数,不同登录方式参数不同,业务参数放在 params 里:

public class LoginRequest {
    /** 登录类型:password / wechat / sms */
    private String loginType;
    /** 具体业务参数,不同登录方式字段不同 */
    private Map<String, Object> params;
    // getter / setter 省略
}

这里 params 用 Map 是为了让接口不被某一种登录方式的字段绑架。如果你追求更强类型,也可以让每个策略自己定义一个 DTO,在 execute 内部转换——接口层保持宽松,实现层各自严丝合缝。

三、三种登录策略:每种都是独立”演员”

每个策略只管自己的事。注意它们都标了 @Service,交给 Spring 管理。

用户名密码登录:

@Service
public class PasswordLoginStrategy implements LoginStrategy {

    @Override
    public String getLoginType() {
        return "password";
    }

    @Override
    public LoginResult execute(LoginRequest request) {
        String username = (String) request.getParams().get("username");
        String password = (String) request.getParams().get("password");
        // 实际应从数据库查用户 + 校验加密后的密码
        if (!isValidPassword(username, password)) {
            throw new LoginException("用户名或密码错误");
        }
        checkUserLocked(username);
        return buildLoginResult(username);
    }

    private void checkUserLocked(String username) {
        // 查账号是否被锁定,锁了抛异常
    }
}

微信扫码登录:

@Service
public class WechatLoginStrategy implements LoginStrategy {

    @Override
    public String getLoginType() {
        return "wechat";
    }

    @Override
    public LoginResult execute(LoginRequest request) {
        String authCode = (String) request.getParams().get("authCode");
        // 拿 authCode 调微信开放平台,换 openid
        String openId = wechatClient.exchangeOpenId(authCode);
        // 用 openid 查系统用户绑定关系
        String userId = userBindingRepository.findUserIdByOpenId(openId);
        if (userId == null) {
            throw new LoginException("微信账号未绑定系统用户");
        }
        return buildLoginResult(userId);
    }
}

手机号验证码登录:

@Service
public class SmsLoginStrategy implements LoginStrategy {

    @Override
    public String getLoginType() {
        return "sms";
    }

    @Override
    public LoginResult execute(LoginRequest request) {
        String phone = (String) request.getParams().get("phone");
        String code  = (String) request.getParams().get("code");
        // 实际应从 Redis 取验证码并比对,顺便校验有效期
        if (!smsCodeRepository.matches(phone, code)) {
            throw new LoginException("验证码错误");
        }
        checkPhoneRegistered(phone);
        return buildLoginResult(phone);
    }
}

每个类各写各的,互不干扰,单测也能各自独立跑——这是和 if-else 版本比最直观的收益。

四、工厂:按类型把策略挑出来(关键的一步)

思路是:让 Spring 把所有的 LoginStrategy 实现类收集起来,工厂在构造时按 getLoginType() 建一张映射表。调用方拿类型来查,查不到就直接报错。

@Component
public class LoginStrategyFactory {

    private final Map<String, LoginStrategy> strategyMap;

    /** Spring 自动注入所有 LoginStrategy 实现,构造时按类型建索引 */
    public LoginStrategyFactory(List<LoginStrategy> strategies) {
        this.strategyMap = strategies.stream()
                .collect(Collectors.toMap(
                        LoginStrategy::getLoginType,
                        s -> s,
                        (a, b) -> { throw new IllegalStateException("登录类型重复: " + a.getLoginType()); }
                ));
    }

    public LoginStrategy getStrategy(String loginType) {
        LoginStrategy strategy = strategyMap.get(loginType);
        if (strategy == null) {
            throw new UnsupportedLoginTypeException("不支持的登录类型: " + loginType);
        }
        return strategy;
    }
}

网上不少教程会再写一个 @Configuration 去逐个 @Bean 并手动指定 Bean 名,其实多此一举——既然每个策略已经用 getLoginType() 自报类型,直接按这个字段建表就够了,不需要再维护一份 Bean 名到类型的对应关系。少一层映射,就少一处会过期的配置。

那个 (a, b) -> throw ... 是防止两个策略返回了同一个 loginType,及早暴露而不是静默覆盖。

五、控制器:对外只有这一个入口

控制器完全不关心具体登录逻辑,只负责”解析类型 → 问工厂拿策略 → 执行”:

@RestController
@RequestMapping("/login")
public class LoginController {

    private final LoginStrategyFactory factory;

    public LoginController(LoginStrategyFactory factory) {
        this.factory = factory;
    }

    @PostMapping
    public LoginResult login(@RequestBody LoginRequest request) {
        LoginStrategy strategy = factory.getStrategy(request.getLoginType());
        return strategy.execute(request);
    }
}

前端三种登录方式,请求体只是 loginType 不同:

{ "loginType": "password", "params": { "username": "user123", "password": "******" } }
{ "loginType": "wechat", "params": { "authCode": "wechat_auth_code_456" } }
{ "loginType": "sms", "params": { "phone": "13800000000", "code": "666888" } }

六、加一种登录方式:只加一个类

现在产品说”再加个支付宝登录”。你只需要新建一个类,不用动任何老代码:

@Service
public class AlipayLoginStrategy implements LoginStrategy {

    @Override
    public String getLoginType() {
        return "alipay";
    }

    @Override
    public LoginResult execute(LoginRequest request) {
        String authCode = (String) request.getParams().get("authCode");
        // 支付宝登录逻辑:用 authCode 换 userId、绑定关系……
        return buildLoginResult(exchangeAlipayUserId(authCode));
    }
}

Spring 启动时会把它自动收进 List<LoginStrategy>,工厂的映射表自动多一项,/login 接口自动支持 alipay。这就是开闭原则落到实处——对扩展开放,对修改关闭。

七、什么时候该上这套,什么时候别上

好处很明确:

  • 解耦:每种登录逻辑独立成类,可读性、可测性都上来了。
  • 易扩展:加方式 = 加类,老代码一行不动。
  • 类型安全:工厂在运行期就能拦掉非法 loginType,不会等到策略里才 NPE。

但也要说实话:如果登录方式就一两种,且长期不变,直接用 if-else 反而更直白。设计模式是为了对抗”变化”,没有变化就别提前上模式,那叫过度设计。判断标准很简单——你预计未来半年还会加登录方式吗?会,就上;不会,就别折腾。

顺带一提,平台级登录尤其适合这套。像我们 FOLIO 项目里要同时接密码登录和机构 SSO,登录入口一旦用 if-else 堆起来,每加一种认证源就是一次”地震”;换成策略 + 工厂,新增认证源只是注册一个策略 Bean,auth 模块的骨架稳得住。

八、几个别忽略的实践细节

  1. 参数校验前置:loginType 是否为空、必填字段是否齐,在控制器或统一校验里先拦,别让每个策略类都重复判空。
  2. 公共逻辑抽抽象类:登录成功生成 Token、写登录日志,这些大家都有的动作,抽到一个 AbstractLoginStrategy,具体策略继承它,避免到处复制。
  3. 统一异常:策略里抛具体的 LoginException / UnsupportedLoginTypeException,由全局异常处理器翻译成友好的错误结构和状态码,别让 Controller 直接兜底。
  4. 配置化支持的类型:把当前支持的 loginType 列表暴露成一个接口(或读配置文件),前端渲染登录按钮时先拉这份清单,避免硬编码。

小结

登录逻辑别硬刚,策略模式来分装;工厂负责挑实例,开闭原则记心上。

说到底,工厂 + 策略不是什么高深技巧,它只是把”选择算法的代码”和”算法的代码”分开——这一分,登录模块就从一团乱麻变成了可插拔的插件集。下次 Product 再提”加个登录方式”,你回一句”加个类就行”,比改十七处代码体面多了。

奇妙感 本文采用署名-非商业性使用-相同方式共享协议,转载请注明出处与作者。

目录