在DailyMart中是如何支持多种登录模式的? - 设计模式的综合运用~

我正在参加「掘金·启航计划」

欢迎回来,我是飘渺。今天继续更新DDD&微服务的系列文章。

1. 理解DDD中的领域模型职责

在我们开始今天的主题之前,让我们先回答一些读者的疑问。

在上一篇文章 DailyMart05:通过用户注册呈现一个完整的DDD开发流程 发布以后,我收到不少粉丝的留言和私信。大家主要对我在文章中将一些用户行为放到应用层而非领域层的做法感到疑惑,他们的主要疑问是:“在DDD中,领域模型是充血模型,需要包含对象的属性和行为。那么,为什么用户唯一性校验和持久化保存不收敛在领域对象中呢?”

这是一个很好的问题。在DDD中,领域层的对象(如聚合、实体)确实包含属性和业务逻辑。然而,它们的主要职责是定义业务模型并封装业务逻辑。这意味着领域层应该处理那些直接涉及到业务规则和业务逻辑的操作,例如验证业务规则、保证业务数据的一致性和完整性、执行业务操作和业务流程,以及封装复杂的业务逻辑。

然而,并不是所有的业务逻辑都应该放在领域对象中。以下是一些典型的例子:

  1. 数据持久化操作:如保存、更新、删除等操作应由仓储来完成。领域模型不应该关心数据是如何存储和获取的。数据持久化通常由基础设施层来实现,而由应用服务层来调用。
  2. 查询操作:如果这些查询操作只是为了获取数据,而并非业务规则的一部分,那么它们应该在查询模型或仓储中实现,而不是领域模型中。
  3. 状态无关的计算:如果一项操作或计算并不会改变领域对象的状态,那么它可能更适合作为一个工具类或者领域服务的一部分,而不是领域模型的一部分。

现在,让我们开始今天的主题:如何让DailyMart支持多种登录方式。

2. DailyMart的灵活登录方式

在DailyMart系统中,我们为用户提供了多样化的登录方式。为了注册,用户需要提供用户名、密码、手机号和邮箱。然后,我们通过一个统一的登录接口,为用户提供三种登录方式:

  • 用户名+密码
  • 手机号+手机验证码
  • 邮箱+邮箱验证码

具体来说,如果用户选择用户名+密码方式登录,他们需要提供userName和password参数;如果选择手机号+手机验证码方式登录,他们需要提供phone和smsCode参数;如果选择邮箱+邮箱验证码方式登录,他们需要提供email和emailCode参数。无论用户选择哪种登录方式,一旦登录成功,系统都会返回一个jwt token。在DailyMart系统中进行的所有后续操作都需要携带此token参数。

看到这里,你可能会想到使用if...else 来实现这个功能,伪代码可能如下:

String logType = request.getParameter("logType");
if("USERNAME_PASSWORD".equals(logType)){
   String userName = request.getParameter("userName");
   String password = request.getParameter("password");
   loginWithUserName(userName,password);
}else if("PHONE_SMS".equals(logType)){
  ...
  loginWithPhone(phone,smsCode);
}else if("EMAIL_CODE".equals(logType)){
  ...
  loginWithEMail(email,emailCode);
}
...
# 生成jwt token
generateAccessToken();

虽然这种方法可以实现登录功能,但是代码中充斥着大量的if else的逻辑,显得不够优雅。

扫描二维码关注公众号,回复: 15370778 查看本文章

2.1 优化:采用策略模式

为了优化我们的代码,可以采用策略模式,这是一种常见的设计模式。

我们可以定义一个抽象的登录接口LoginProvider,并让具体的实现类来处理不同方式的登录逻辑。例如,UserNameLoginProvider负责处理用户名+密码的登录逻辑,PhoneLoginProvider负责处理手机号+手机验证码的登录逻辑,EmailLoginProvider负责处理邮箱+邮箱验证码的登录逻辑。这样,我们就可以避免大量的if else逻辑,使代码更加清晰和优雅。

使用策略模式的类图

然而,我们发现在这三种登录方式中,有一些共同的实现流程:

  1. 首先,需要完成用户参数的校验。
  2. 其次,需要实现用户登录的逻辑。例如,用户名密码模式需要校验密码是否正确,手机号+验证码登录逻辑中需要校验验证码是否正确匹配。
  3. 最后,需要生成jwt token。

在这三个步骤中,生成jwt token的逻辑是可以公用的,但现在却要在每个实现类中都实现一遍,这无疑也会带来大量重复的代码。

2.2 进一步优化:采用模板模式

为了解决这个问题,我们可以采用模板模式,这是另一种常见的设计模式。

我们可以定义一个抽象的登录接口AbstractLoginProvider,它实现了LoginProvider接口。在AbstractLoginProvider中,我们定义了登录流程的骨架流程。然后,我们让UserNameLoginProviderPhoneLoginProviderEmailLoginProvider继承AbstractLoginProvider,并分别实现用户校验的差异逻辑。公共的jwt token生成逻辑则直接由父类生成。

此时的类图关系如下所示:

使用模板模式的类图

在成功地应用模板模式和策略模式后,我们将公共的登录流程(例如生成jwt token)抽象到父类中,同时将各种登录方式的特定逻辑(例如用户登录校验)放在各自的子类中。这不仅减少了重复的代码,也使得每种登录方式的实现更加清晰和易于管理。此外,这种设计提高了代码的可扩展性。如果未来我们需要添加更多的登录方式,只需要添加新的子类即可。

2.3 处理不同登录方式的参数问题

最后,我们需要解决的是不同登录方式需要的参数不同的问题。

我们可以定义一个抽象参数基类UserLoginDTO,然后让不同登录方式的接口参数继承这个基类。以用户名密码登录方式为例:

@EqualsAndHashCode(callSuper = true)
@Data
public class UsernamePasswordLoginDTO extends UserLoginDTO {
    private final String username;
    private final String password;

    @Override
    public LoginType getLoginType() {
        return LoginType.USERNAME_PASSWORD;
    }
}

然后,在我们对外提供的登录接口中,我们可以根据登录类型组装不同的参数对象。当然,这部分逻辑我们可以通过简单工厂设计模式来封装,使得代码更加清晰和易于管理

简单工厂

3. DailyMart登录的代码实现

接下来,我们将实现多种登录方式的功能。

3.1 定义系统的登录类型

首先,我们定义系统支持的登录类型,如下所示:

public enum LoginType {
    USERNAME_PASSWORD,
    PHONE_SMS,
    EMAIL_CODE;

    /**
     * 提供此方法主要是为了替换原始valueOf方法,捕获参数异常,用于转换成自定义异常
     */
    public static LoginType parseLoginType(String loginTypeStr){
        try {
            return LoginType.valueOf(loginTypeStr.toUpperCase());
        } catch (IllegalArgumentException e) {
            throw new RuntimeException("不支持此登录方式:" + loginTypeStr);
        }
    }
}

3.2 定义抽象登录接口

接下来,我们定义一个抽象的登录接口LoginProvider,如下所示:

public interface LoginProvider {
    UserLoginRespDTO login(UserLoginDTO loginDTO);
    boolean supports(LoginType loginType);
}

3.3 定义模板接口

然后,我们定义一个模板接口AbstractLoginProvider,它实现了LoginProvider接口。在这个接口中,我们定义了登录流程的骨架流程,并将生成jwt token(基于nimbusds实现)的逻辑抽象到父类中。具体的用户参数校验和用户认证逻辑则由子类来实现。

@Component
@Slf4j
public abstract class AbstractLoginProvider implements LoginProvider{

    @Resource
    protected CustomerUserAssembler customerUserAssembler;

    @Override
    public UserLoginRespDTO login(UserLoginDTO loginDTO) {
        log.info("login start >>>:{}",loginDTO);
        // 1. 校验参数
        preAuthenticationCheck(loginDTO);

        // 2. 认证登录
        CustomerUser customerUser = authenticate(loginDTO);
        UserLoginRespDTO userLoginRespDTO = customerUserAssembler.customerUserToLoginRespDTO(customerUser);

        // 3. 生成accessToken
        String accessToken = generateAccessToken(userLoginRespDTO.getUserName());
        userLoginRespDTO.setAccessToken(accessToken);

        log.info("login end >>>:{}",userLoginRespDTO);

        return userLoginRespDTO;
    }

    protected abstract void preAuthenticationCheck(UserLoginDTO loginDTO);

    protected abstract CustomerUser authenticate(UserLoginDTO loginDTO);

    /**
     * 生成 accessToken
     */
     private String generateAccessToken(String subject) {
         try {
             return JwtUtil.createJWT(subject);
         } catch (JOSEException e) {
             throw new RuntimeException(e);
         }
     }


    @Override
    public abstract boolean supports(LoginType loginType) ;
}

需要强调的是,我们并没有将generateAccessToken()生成jwt的操作放在领域对象CustomerUser中,而是选择让应用服务直接调用JwtUtil这个工具类。这主要是因为生成jwt的操作并不涉及特定的业务规则或领域对象状态,因此,让应用服务直接调用工具类来完成这个操作更为合适。

3.4 实现登录逻辑

接下来,我们将实现登录逻辑。这里,我们以用户名密码登录为例:

@Component
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class UserNameLoginProvider extends AbstractLoginProvider{

    private final CustomerUserRepository customerUserRepository;

    @Override
    protected void preAuthenticationCheck(UserLoginDTO loginDTO) {
        UsernamePasswordLoginDTO usernamePasswordLoginDTO = (UsernamePasswordLoginDTO) loginDTO;
        if(StringUtils.isEmpty(usernamePasswordLoginDTO.getUsername()) || StringUtils.isEmpty(usernamePasswordLoginDTO.getPassword())){
            throw new IllegalArgumentException("用户名或密码错误");
        }
    }


    @Override
    protected CustomerUser authenticate(UserLoginDTO loginDTO) {
        UsernamePasswordLoginDTO usernamePasswordLoginDTO = (UsernamePasswordLoginDTO) loginDTO;

        CustomerUser actualUser = customerUserRepository.findByUserName(usernamePasswordLoginDTO.getUsername());

        if(actualUser == null){
            throw new RuntimeException("用户名不存在");
        }

        if(!actualUser.getPassword().matches(usernamePasswordLoginDTO.getPassword())){
            throw new RuntimeException("用户名密码错误");
        }

        return actualUser;
    }

    @Override
    public boolean supports(LoginType loginType) {
        return loginType.equals(LoginType.USERNAME_PASSWORD);
    }

}

在这个类中,我们首先在preAuthenticationCheck()方法中进行了参数校验。然后,在authenticate()方法中,我们根据用户名查找用户,并检查用户输入的密码是否正确。最后,在supports()方法中,我们指定了这个类支持的登录方式。

其他登录方式的实现基本类似。然而,目前在DailyMart中,我们还没有实现用户手机验证码和邮件验证码的发送功能。这是一个待填补的空白,我们将在完成通用消息平台的实现后再来补充这部分内容。

3.5 定义系统的登录类型

public enum LoginType {
    USERNAME_PASSWORD,
    PHONE_SMS,
    EMAIL_CODE;

    /**
     * 提供此方法主要是为了替换原始valueOf方法,捕获参数异常,用于转换成自定义异常
     */
    public static LoginType parseLoginType(String loginTypeStr){
        try {
            return LoginType.valueOf(loginTypeStr.toUpperCase());
        } catch (IllegalArgumentException e) {
            throw new RuntimeException("不支持此登录方式" + loginTypeStr);
        }
    }
}

3.6 通过策略模式选择具体的登录类型

最后,我们通过策略模式选择具体的登录类型,如下所示:

@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Slf4j
public class CustomerUserService {

    private final CustomerUserAssembler customerUserAssembler;

    private final List<LoginProvider> providers;

    public UserLoginRespDTO login(UserLoginDTO loginDTO) {
        return
            providers.stream()
                    .filter(provider -> provider.supports(loginDTO.getLoginType()))
                    .findFirst()
                    .map(provider -> provider.login(loginDTO))
                    .orElseThrow(() -> new IllegalArgumentException("Unsupported login method :" + loginDTO.getClass().getName()));
    }

}

在这个类中,我们首先获取所有的登录提供者,然后根据用户选择的登录类型找到对应的登录提供者,最后调用该提供者的login()方法进行登录。如果没有找到对应的登录提供者,我们将抛出一个异常。

3.7 定义参数转换工厂

在DDD的接口层,我们定义了一个参数转换工厂。这个工厂的作用是根据前端接口传入的登录类型,转换成对应的参数对象。具体实现如下:

public class LoginDTOFactory {
    public static UserLoginDTO getLoginDTO(Map<String, String> parameters) {
        String loginTypeStr = parameters.get("loginType");
        LoginType loginType = Optional.ofNullable(loginTypeStr)
                .map(String::toUpperCase)
                .map(LoginType::parseLoginType)
                .orElseThrow(() -> new RuntimeException("loginType must be provided"));


        return switch (loginType){
            case USERNAME_PASSWORD -> new UsernamePasswordLoginDTO(parameters.get("userName"), parameters.get("password"));
            case PHONE_SMS -> new PhoneCodeLoginDTO(parameters.get("phone"), parameters.get("smsCode"));
            case EMAIL_CODE -> new EmailCodeLoginDTO(parameters.get("email"),parameters.get("emailCode"));
        };

    }
}

3.8 定义登录接口

最后,我们定义了登录接口。在这个接口中,我们首先使用LoginDTOFactory将前端传入的参数转换成对应的参数对象,然后调用customerService.login()方法进行登录。具体实现如下:

 @PostMapping("/api/customer/login")
    public UserLoginRespDTO login(@RequestBody Map<String, String> parameters){
        UserLoginDTO loginDTO = LoginDTOFactory.getLoginDTO(parameters);
        return customerService.login(loginDTO);
    }

3.9 登录测试

image-20230620171847084

4. 小结

在本文中,我们探讨了如何在DailyMart系统中实现多种登录方式。

我们采用了策略模式和模板模式,将公共的登录流程抽象出来,同时将特定的登录逻辑放在各自的子类中,使得代码更加清晰和易于扩展。我们还解决了不同登录方式需要的参数不同的问题,通过定义一个抽象参数基类,并让不同登录方式的接口参数继承这个基类。最后,我们实现了登录逻辑,并定义了登录接口。这些设计和实现都遵循了DDD的开发规范,提高了代码的可读性和可维护性。

DDD&微服务系列源码已经上传至GitHub,如果需要获取源码地址,请关注公号 java日知录 并回复关键字 DDD 即可。

猜你喜欢

转载自juejin.im/post/7246960365737082940
今日推荐