How does DailyMart support multiple login modes? - Comprehensive application of design patterns~

I am participating in the "Nuggets·Starting Plan"

Welcome back, I am Piao Miao. Continue to update the series of articles on DDD & microservices today.

1. Understand the domain model responsibilities in DDD

Before we start today's topic, let's answer some readers' questions.

After the publication of the previous article DailyMart05: Presenting a complete DDD development process through user registration , I received many comments and private messages from fans. People are mainly confused about my practice of putting some user behaviors in the application layer instead of the domain layer in the article. Their main question is: "In DDD, the domain model is a bloody model that needs to contain the attributes and behaviors of objects. Then , why don’t the user’s uniqueness check and persistent storage converge in the domain object?”

This is a great question. In DDD, objects in the domain layer (like aggregates, entities) do contain properties and business logic. However, their primary responsibility is to define the business model and encapsulate business logic. This means that the domain layer should handle those operations directly related to business rules and business logic , such as validating business rules, ensuring the consistency and integrity of business data, executing business operations and business processes, and encapsulating complex business logic.

However, not all business logic should be placed in domain objects. Here are some typical examples:

  1. Data persistence operations : operations such as saving, updating, and deleting should be done by the warehouse. The domain model should not be concerned with how data is stored and retrieved. Data persistence is usually implemented by the infrastructure layer and invoked by the application service layer.
  2. Query operations : If these query operations are just for fetching data and not part of the business rules, then they should be implemented in the query model or repository, not in the domain model.
  3. State-independent computation : If an operation or computation does not change the state of a domain object, it may be more suitable as a utility class or part of a domain service rather than part of the domain model.

Now, let's start today's topic: how to make DailyMart support multiple login methods.

2. DailyMart's flexible login method

In the DailyMart system, we provide users with a variety of login methods. In order to register, users need to provide username, password, mobile phone number and email address. Then, we provide users with three login methods through a unified login interface:

  • username+password
  • Mobile phone number + mobile phone verification code
  • Email + email verification code

Specifically, if the user chooses the username + password method to log in, they need to provide the userName and password parameters; if they choose the mobile phone number + mobile phone verification code method to log in, they need to provide the phone and smsCode parameters; if they choose the email address + email verification code method to log in , they need to provide email and emailCode parameters. No matter which login method the user chooses, once the login is successful, the system will return a jwt token. All subsequent operations in the DailyMart system need to carry this token parameter.

Seeing this, you may think of using if...elseto achieve this function, the pseudocode may be as follows:

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();

Although this method can realize the login function, the code is full of a lot of if elselogic, which is not elegant enough.

2.1 Optimization: Using Strategy Mode

In order to optimize our code, we can adopt strategy pattern, which is a common design pattern.

We can define an abstract login interface LoginProvider, and let the concrete implementation class handle the login logic in different ways. For example, UserNameLoginProviderit is responsible for processing the login logic of user name + password, PhoneLoginProviderresponsible for processing the login logic of mobile phone number + mobile phone verification code, and EmailLoginProviderresponsible for processing the login logic of email + email verification code. In this way, we can avoid a lot of if else logic, making the code clearer and more elegant.

Class Diagram Using Strategy Pattern

However, we found that among the three login methods, there are some common implementation processes:

  1. First, the verification of user parameters needs to be completed.
  2. Second, the logic for user login needs to be implemented. For example, the user name password mode needs to verify whether the password is correct, and the mobile phone number + verification code login logic needs to verify whether the verification code matches correctly.
  3. Finally, a jwt token needs to be generated.

In these three steps, the logic of generating jwt token can be shared, but now it has to be implemented in each implementation class, which will undoubtedly bring a lot of repeated code.

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

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

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

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

Class Diagram Using Template Pattern

在成功地应用模板模式和策略模式后,我们将公共的登录流程(例如生成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;
    }
}

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

simple factory

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 定义登录接口

Finally, we define the login interface. In this interface, we first LoginDTOFactoryconvert the parameters passed in by the front end into corresponding parameter objects, and then call customerService.login()the method to log in. The specific implementation is as follows:

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

3.9 Login Test

image-20230620171847084

4. Summary

In this article, we explored how to implement multiple login methods in the DailyMart system.

We adopt the strategy mode and template mode to abstract the public login process, and put the specific login logic in their respective subclasses, making the code clearer and easier to expand. We also solved the problem that different login methods require different parameters by defining an abstract parameter base class and allowing interface parameters of different login methods to inherit from this base class. Finally, we implemented the login logic and defined the login interface. These designs and implementations follow the development specification of DDD, which improves the readability and maintainability of the code.

The source code of the DDDµservice series has been uploaded to GitHub. If you need to obtain the source code address, please follow the official account java Rizhilu and reply with the keyword  DDD  .

Guess you like

Origin juejin.im/post/7246960365737082940