DDD 入门实践
DDD 是 Domain-Driven Design 的缩写,中文翻译为领域驱动设计。DDD 是一种软件设计的方法论。
简单来说,就是通过领域问题驱动模型设计,模型驱动软件设计。
要从领域问题出发,去设计领域模型,来解决它。并设计具体的软件来实现领域模型。
进而得到一个可以解决领域问题的软件系统。
而这里面的领域,实际上指的是业务范围,比如:
- 电商系统中的订单领域
- 支付系统中的交易领域
- 社交系统中的关系领域
- 用户系统中的认证领域
本文将以一个最常见的场景,用户登录注册,来帮助快速理解 DDD 的基本实践方式。
为什么使用 DDD
以用户登录注册为例:
// service.go
type Service struct {
repo Repository
}
// ...
// handler.go
type Handler struct {
service Service
}
func (h *Handler) Register(ctx *gin.Context) {
// 校验用户输入
// 检查密码强度
// 检索数据库
// 加密密码
// 存储用户
// 响应请求
}
func (h *Handler) Login(ctx *gin.Context) {
// 校验用户输入
// 查询用户
// 校验密码
// 更新登录时间
// 生成 token
// 响应请求
}
最初的代码是这样没问题,但随着业务增长,引入各种规则,例如,支持手机号注册;邮箱验证码;OAuth 登录;登录风控等,Service 就会越来越庞大。
而 Service 膨胀的根本原因,是业务规则缺乏明确归属,导致规则被堆积在流程层。
这是因为代码是围绕着功能进行组织的,而不是围绕着业务模型组织的。
而 DDD 恰好提供了一种新的思路,围绕领域模型来组织代码系统。
领域建模
如果从业务角度分析,“用户登录注册”涉及哪些核心概念?
我们很容易识别出几个对象:
首先是用户对象 User,它是系统中的核心业务对象,它拥有:
- 用户 ID
- 用户名
- 密码
- 状态
- 注册时间
- 凭证(Credential)
接着是登录认证相关信息 Credential,例如:
- 密码哈希
- 盐值
- 登录方式
然后是用户状态 UserStatus,用户可能处于:
- 未激活
- 正常
- 冻结
- 注销
之后是登录行为,登录不是简单查库,它包含业务规则:
- 校验密码
- 判断账号状态
- 更新登录时间
- 记录登录日志
这时候我们已经在做领域建模了。
聚合根
聚合是 DDD 中的一个重要概念。简单来说,就是一组具备一致性边界的对象,说人话就是这些对象同属一个业务范围。
聚合根就是这组聚合的入口,所有修改都必须经过聚合根,而无法直接操作聚合根内部。
而对于用户系统,User 就是聚合根,因为所有和用户有关的操作都需要经过 User:
- 登录需要通过它
- 注册需要创建它
- 状态变更作用于它
type User struct {
// ...
}
func (u *User) Register() {}
func (u *User) ChangePassword() {}
func (u *User) ChangeStatus() {}
func (u *User) VerifyPassword() {}
与传统分层架构不同的是,这些逻辑不在 Service 中,而是在 User 内部。
因为这些逻辑是 User 的业务行为,是直接作用于用户状态,需要维护用户业务一致性的行为。
而有关于密码加密,token 签发,风控等逻辑,并不直接作用于用户状态,也不需要维护用户业务一致性的行为,那么就不应该放在 User。
这便是领域模型的核心。
值对象
DDD 强调不是所有的东西都是字符串,register('admin', '123456') 这里的参数语义会很弱,在 DDD 中,会把需要的参数建模为值对象。
var emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
type Email string
func NewEmail(email string) (Email, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return "", ErrEmailEmpty
}
if !emailRegexp.MatchString(email) {
return "", ErrEmailInvalid
}
return Email(email), nil
}
type Password string
func NewPassword(pw string) (Password, error) {
l := len(pw)
if l < minPasswordLength {
return "", ErrPasswordTooShort
}
if l > maxPasswordLength {
return "", ErrPasswordTooLong
}
if PasswordRule&MustHasSymbol != 0 && !strings.ContainsAny(pw, passwordSymbol) {
return "", ErrPasswordMustHasSymbol
}
if PasswordRule&MustHasUppercase != 0 && !strings.ContainsAny(pw, passwordUppercase) {
return "", ErrPasswordMustHasUppercase
}
if PasswordRule&MustHasLowercase != 0 && !strings.ContainsAny(pw, passwordLowercase) {
return "", ErrPasswordMustHasLowercase
}
if PasswordRule&MustHasDigit != 0 && !strings.ContainsAny(pw, passwordDigits) {
return "", ErrPasswordMustHasDigit
}
return Password(pw), nil
}
值对象是不可变的,且 a1 = Email("a@example.com") 一定等同于 a2 = Email("a@example.com"),因为值对象关注的是值,而不是身份。
值对象的意义在于,封装了校验规则,提高表达能力,防止非法状态,此外,如果值对象创建成功,就一定是合法的。
仓储管理持久化
领域对象不会直接操作数据库。
DDD 使用抽象的 Repository 来管理存储。
// user_repository.go
type UserRepository interface {
Save(user *User) error
ExistsByEmail(email Email) (bool, error)
FindByEmail(email Email) (*User, error)
}
// user_repository_postgres.go
var _ UserRepository = (*UserRepositoryPostgres)(nil)
type UserRepositoryPostgres struct {
// ...
}
func (repo *UserRepositoryPostgres) Save(user *User) error {
// ...
}
func (repo *UserRepositoryPostgres) ExistsByEmail(email Email) (bool, error) {
// ...
}
func (repo *UserRepositoryPostgres) FindByEmail(email Email) (*User, error) {
// ...
}
简单来说就是面向对象的接口模式,不关心具体实现,只关心接口。
服务协调
既然业务逻辑都放在了领域对象,那么还要 Service 做什么?
简单来说,Service 的作用是变为了进行流程编排,而不是接着做业务决策。
它负责调用领域对象,协调仓储并管理事务。
而领域模型则负责业务规则,状态变化和领域行为。
type UserService struct {
// ...
}
func (s *UserAppService) Register(cmd RegisterCommand) error {
email, err := NewEmail(cmd.Email)
if err != nil {
return err
}
password, err := NewPassword(cmd.Password)
if err != nil {
return err
}
exists, err := s.repo.ExistsByEmail(email)
if err != nil {
return err
}
if exists {
return ErrUserAlreadyExists
}
user := NewUser(email, s.passwordHasher.Hash(password))
if err := s.repo.Save(user); err != nil {
return err
}
s.eventBus.Publish(UserRegisteredEvent{
UserID: user.ID(),
})
return nil
}
项目结构
user
|- application # 流程协调
|- UserService
|- Command
|- domain # 业务核心
|- model
|- User
|- Email
|- Password
|- repository
|- service
|- infrastructure # 基础实现
|- persistence
|- security
|- interfaces # 对外接口
|- controller
请求流程
HTTP Request
↓
Controller
↓
Application Service
↓
Domain Model
↓
Repository Interface
↓
Infrastructure Repository
↓
Database
POST /register
→ RegisterController
→ UserAppService.Register()
→ NewEmail()
→ NewPassword()
→ User.New()
→ UserRepository.Save()
→ Response
DDD 的价值
使用 DDD 的好处主要就是业务语义清晰化了。
原先需要 checkPassword,checkStatus,updateLoginTime 等多段流程的代码,直接被 user.login(dto) 替代。
虽然这看起来只是封装了这些这些流程,不使用 DDD 也能做到,但那样只是有了形,但没有意。
DDD 的核心是通过分析领域问题,从而进行领域建模,随后基于领域建模开发软件,最后得到可以解决领域问题的系统。
因此 DDD 的价值在于迫使团队显式建模业务规则,把“隐含在代码流程中的业务知识”提升为清晰的领域模型。
只有复杂业务系统,核心业务规则频繁变化,多人协作大型项目等才适用 DDD 开发。
简单的 CRUD 后台,一次性工具等并不适合。强行使用反而会增加复杂度。