「ナゲッツ・スターティングプラン」に参加中です
おかえりなさい、ピャオ・ミャオです。今日も DDD とマイクロサービスに関する一連の記事を更新し続けます。
1. DDD におけるドメイン モデルの責任を理解する
今日のトピックを始める前に、読者の質問にいくつか答えてみましょう。
前回の記事DailyMart05: ユーザー登録による完全な DDD 開発プロセスの紹介の公開後、ファンから多くのコメントやプライベート メッセージを受け取りました。人々は主に、記事内のドメイン層ではなくアプリケーション層にユーザーの動作を記述するという私のやり方について混乱しています。彼らの主な質問は次のとおりです。「DDD では、ドメイン モデルは、ドメイン モデルの属性と動作を含める必要がある血まみれのモデルです。」 「では、ユーザーの一意性チェックと永続ストレージをドメイン オブジェクトに統合しないのはなぜでしょうか?」
これは素晴らしい質問です。DDD では、ドメイン層のオブジェクト (集約、エンティティなど) にはプロパティとビジネス ロジックが含まれています。ただし、彼らの主な責任は、ビジネス モデルを定義し、ビジネス ロジックをカプセル化することです。これは、ドメイン層が、ビジネス ルールの検証、ビジネス データの一貫性と整合性の確保、ビジネス オペレーションとビジネス プロセスの実行、複雑なビジネス ロジックのカプセル化など、ビジネス ルールとビジネス ロジックに直接関連する操作を処理する必要があることを意味します。
ただし、すべてのビジネス ロジックをドメイン オブジェクトに配置する必要はありません。典型的な例をいくつか示します。
- データ永続化操作: 保存、更新、削除などの操作はウェアハウスによって実行される必要があります。ドメイン モデルは、データの保存方法と取得方法を考慮すべきではありません。データの永続性は通常、インフラストラクチャ層によって実装され、アプリケーション サービス層によって呼び出されます。
- クエリ操作: これらのクエリ操作がデータの取得のみを目的としており、ビジネス ルールの一部ではない場合は、ドメイン モデルではなくクエリ モデルまたはリポジトリに実装する必要があります。
- 状態に依存しない計算: 操作または計算によってドメイン オブジェクトの状態が変更されない場合、ドメイン モデルの一部ではなく、ユーティリティ クラスまたはドメイン サービスの一部として適している可能性があります。
さて、今日のトピック、DailyMart に複数のログイン方法をサポートさせる方法を始めましょう。
2. DailyMartの柔軟なログイン方法
DailyMart システムでは、ユーザーにさまざまなログイン方法を提供します。登録するには、ユーザー名、パスワード、携帯電話番号、電子メール アドレスを入力する必要があります。次に、統合されたログイン インターフェイスを通じて 3 つのログイン方法をユーザーに提供します。
- ユーザー名+パスワード
- 携帯電話番号 + 携帯電話認証コード
- メールアドレス + メール認証コード
具体的には、ユーザーがログインにユーザー名 + パスワードの方法を選択した場合は、userName およびパスワードのパラメーターを指定する必要があります。ログインに携帯電話番号 + 携帯電話の確認コードの方法を選択した場合は、電話番号と smsCode を指定する必要があります。パラメータ。電子メール アドレス + 電子メール確認コードを使用してログインする方法を選択した場合は、電子メール パラメータと emailCode パラメータを指定する必要があります。ユーザーがどのログイン方法を選択しても、ログインが成功すると、システムは jwt トークンを返します。DailyMart システムの後続のすべての操作では、このトークン パラメーターを運ぶ必要があります。
これを見て、この機能を実現するために を使用することを考えるかもしれません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
、洗練されたものではありません。
2.1 最適化: ストラテジーモードの使用
コードを最適化するために、一般的な設計パターンである戦略パターンを採用できます。
抽象的なログイン インターフェイスを定義しLoginProvider
、具体的な実装クラスにさまざまな方法でログイン ロジックを処理させることができます。たとえば、UserNameLoginProvider
ユーザー名 + パスワードのログイン ロジックの処理を担当し、PhoneLoginProvider
携帯電話番号 + 携帯電話認証コードのログイン ロジックの処理を担当し、EmailLoginProvider
電子メール + メール認証コードのログイン ロジックの処理を担当します。このようにして、多くの if else ロジックを回避し、コードをより明確でエレガントにすることができます。
ただし、3 つのログイン方法の中には、いくつかの共通の実装プロセスがあることがわかりました。
- まず、ユーザーパラメータの検証を完了する必要があります。
- 次に、ユーザー ログインのロジックを実装する必要があります。たとえば、ユーザー名パスワード モードではパスワードが正しいかどうかを検証する必要があり、携帯電話番号 + 検証コードのログイン ロジックでは検証コードが正しく一致するかどうかを検証する必要があります。
- 最後に、jwt トークンを生成する必要があります。
これら 3 つのステップで、jwt トークンを生成するロジックを共有できますが、今度はそれを各実装クラスに実装する必要があり、間違いなく大量のコードが繰り返されることになります。
2.2 进一步优化:采用模板模式
为了解决这个问题,我们可以采用模板模式,这是另一种常见的设计模式。
我们可以定义一个抽象的登录接口AbstractLoginProvider
,它实现了LoginProvider
接口。在AbstractLoginProvider
中,我们定义了登录流程的骨架流程。然后,我们让UserNameLoginProvider
、PhoneLoginProvider
、EmailLoginProvider
继承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 ログインテスト
4. まとめ
この記事では、DailyMart システムに複数のログイン方法を実装する方法を検討しました。
ストラテジー モードとテンプレート モードを採用してパブリック ログイン プロセスを抽象化し、特定のログイン ロジックをそれぞれのサブクラスに配置することで、コードがより明確になり、拡張が容易になります。また、抽象パラメータ基本クラスを定義し、さまざまなログイン メソッドのインターフェイス パラメータがこの基本クラスから継承できるようにすることで、ログイン メソッドごとに異なるパラメータが必要になるという問題も解決しました。最後に、ログイン ロジックを実装し、ログイン インターフェイスを定義しました。これらの設計と実装は DDD の開発仕様に従っており、コードの可読性と保守性が向上します。
DDDµservice シリーズのソースコードを GitHub にアップロードしましたので、ソースコードのアドレスを取得する必要がある場合は、公式アカウント java Rizhilu をフォローし、キーワード DDD を返信して ください 。