Como o DailyMart oferece suporte a vários modos de login? - Aplicação abrangente de padrões de design ~

Estou participando do "Nuggets·Plano Inicial"

Bem-vindo de volta, eu sou Piao Miao. Continue a atualizar a série de artigos sobre DDD e microsserviços hoje.

1. Entenda as responsabilidades do modelo de domínio no DDD

Antes de começarmos o tópico de hoje, vamos responder algumas perguntas dos leitores.

Após a publicação do artigo anterior DailyMart05: Apresentando um processo completo de desenvolvimento de DDD por meio de cadastro de usuários , recebi muitos comentários e mensagens privadas de fãs. As pessoas estão confusas principalmente sobre minha prática de colocar alguns comportamentos de usuário na camada de aplicativo em vez da camada de domínio no artigo. A principal questão é: "No DDD, o modelo de domínio é um modelo sangrento que precisa conter os atributos e comportamentos de objetos. Então, por que a verificação de exclusividade do usuário e o armazenamento persistente não convergem no objeto de domínio?”

Esta é uma grande pergunta. No DDD, os objetos na camada de domínio (como agregados, entidades) contêm propriedades e lógica de negócios. No entanto, sua responsabilidade principal é definir o modelo de negócios e encapsular a lógica de negócios. Isso significa que a camada de domínio deve lidar com as operações diretamente relacionadas às regras de negócios e à lógica de negócios , como validar regras de negócios, garantir a consistência e a integridade dos dados de negócios, executar operações de negócios e processos de negócios e encapsular lógicas de negócios complexas.

No entanto, nem toda lógica de negócios deve ser colocada em objetos de domínio. Aqui estão alguns exemplos típicos:

  1. Operações de persistência de dados : operações como salvar, atualizar e excluir devem ser feitas pelo warehouse. O modelo de domínio não deve se preocupar com a forma como os dados são armazenados e recuperados. A persistência de dados geralmente é implementada pela camada de infraestrutura e invocada pela camada de serviço de aplicativo.
  2. Operações de consulta : se essas operações de consulta forem apenas para buscar dados e não fizerem parte das regras de negócios, elas devem ser implementadas no modelo de consulta ou repositório, não no modelo de domínio.
  3. Computação independente de estado : se uma operação ou computação não alterar o estado de um objeto de domínio, ela pode ser mais adequada como uma classe de utilidade ou parte de um serviço de domínio em vez de parte do modelo de domínio.

Agora, vamos começar o tópico de hoje: como fazer o DailyMart suportar vários métodos de login.

2. Método de login flexível do DailyMart

No sistema DailyMart, fornecemos aos usuários uma variedade de métodos de login. Para se registrar, os usuários precisam fornecer nome de usuário, senha, número de telefone celular e endereço de e-mail. Em seguida, fornecemos aos usuários três métodos de login por meio de uma interface de login unificada:

  • nome de usuário + senha
  • Número do celular + código de verificação do celular
  • E-mail + código de verificação de e-mail

Especificamente, se o usuário escolher o método de nome de usuário + senha para fazer login, ele precisará fornecer os parâmetros userName e password; se escolher o método de número de telefone celular + código de verificação de telefone celular para fazer login, precisará fornecer o telefone e o smsCode parâmetros; se eles escolherem o método de endereço de e-mail + código de verificação de e-mail para fazer login, eles precisarão fornecer os parâmetros e-mail e emailCode. Não importa qual método de login o usuário escolha, assim que o login for bem-sucedido, o sistema retornará um token jwt. Todas as operações subsequentes no sistema DailyMart precisam carregar esse parâmetro de token.

Vendo isso, você pode pensar em usar if...elsepara conseguir essa função, o pseudocódigo pode ser o seguinte:

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

Embora esse método possa realizar a função de login, o código está cheio de muita if elselógica, o que não é elegante o suficiente.

2.1 Otimização: Usando o Modo Estratégia

Para otimizar nosso código, podemos adotar o padrão de estratégia, que é um padrão de design comum.

Podemos definir uma interface de login abstrata LoginProvidere deixar a classe de implementação concreta lidar com a lógica de login de maneiras diferentes. Por exemplo, UserNameLoginProvideré responsável pelo processamento da lógica de login do nome de usuário + senha, PhoneLoginProviderresponsável pelo processamento da lógica de login do número do celular + código de verificação do celular e EmailLoginProviderresponsável pelo processamento da lógica de login do e-mail + código de verificação do e-mail. Dessa forma, podemos evitar muita lógica if else, tornando o código mais claro e elegante.

Diagrama de classe usando padrão de estratégia

No entanto, descobrimos que entre os três métodos de login, existem alguns processos de implementação comuns:

  1. Primeiro, a verificação dos parâmetros do usuário precisa ser concluída.
  2. Em segundo lugar, a lógica de login do usuário precisa ser implementada. Por exemplo, o modo de senha do nome de usuário precisa verificar se a senha está correta e a lógica de login do número do celular + código de verificação precisa verificar se o código de verificação corresponde corretamente.
  3. Finalmente, um token jwt precisa ser gerado.

Nessas três etapas, a lógica de geração do token jwt pode ser compartilhada, mas agora tem que ser implementada em cada classe de implementação, o que sem dúvida trará muito código repetido.

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

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

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

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

Diagrama de classe usando padrão de modelo

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

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

fábrica simples

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

Finalmente, definimos a interface de login. Nesta interface, primeiro LoginDTOFactoryconvertemos os parâmetros passados ​​pelo front-end em objetos de parâmetro correspondentes e, em seguida, chamamos customerService.login()o método para efetuar login. A implementação específica é a seguinte:

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

3.9 Teste de Login

imagem-20230620171847084

4. Resumo

Neste artigo, exploramos como implementar vários métodos de login no sistema DailyMart.

Adotamos o modo de estratégia e o modo de modelo para abstrair o processo de login público e colocamos a lógica de login específica em suas respectivas subclasses, tornando o código mais claro e fácil de expandir. Também resolvemos o problema de diferentes métodos de login exigirem parâmetros diferentes, definindo uma classe base de parâmetro abstrato e permitindo que parâmetros de interface de diferentes métodos de login sejam herdados dessa classe base. Por fim, implementamos a lógica de login e definimos a interface de login. Esses designs e implementações seguem a especificação de desenvolvimento do DDD, o que melhora a legibilidade e a manutenção do código.

O código-fonte da série DDDµservice foi carregado no GitHub. Se você precisar obter o endereço do código-fonte, siga a conta oficial java Rizhilu e responda com a palavra-chave  DDD  .

Acho que você gosta

Origin juejin.im/post/7246960365737082940
Recomendado
Clasificación