写在前面

​ DDD(领域驱动设计)中涉及到几个概念,实体,值对象,聚合,限定上下文。本篇只涉及实践,概念讲解将放在下一篇,同时上一篇为什么我们需要领域驱动设计作为科普帖,大家可以在看完代码之后再回头理解一下,同时对比一下现有项目,知其然更要知其所以然,你经常遇到了什么问题,为什么DDD能够更好的解决软件负责的问题。

需求描述

  1. ​ 认证功能即登录功能,登录成功登录态的设定,登录失败的处理方式例如IP锁定,失败超过次数锁定等方式

  2. ​ 授权功能即对认证通过的用户,进行角色和权限授予,同时开启资源保护,未具备访问该资源权限的用户将无法访问。

    本篇将详细介绍如何在DDD的指导下实现第一点功能。

领域、子域和界限上下文

​ 我们先明白的一点是领域这个词语承载了太多的含义,既可以表示整个业务系统,也可以表示其中的某个核心域或者支撑子域。举个不是很恰当的例子,假设我们原本想要在一个叫账户模块实现了这个功能,同时还有用户信息功能,这个时候,账户就是一个大的领域,一块的大蛋糕,而oneday-auth则是这块大蛋糕的某一块,用户信息又是另一块,这被分出的一块一块蛋糕,我们称之为由账户领域分成的子域,权限子域和用户信息子域。子域下还可以再接着划分出子域,没有最小的子域,只有最合适的子域。

​ 你会觉得这个微服务的拆分很像,是的,微服务的拆分是遵循DDD的思想,但是你再仔细思考下,你是不是只学了一个形式而已?可以对比一下下面的了两张图片和你的思路是不是不谋而合。

领域驱动设计之实战权限系统微服务-LMLPHP

​ 本文中我将权限子域再划分出了认证上下文和授权上下文。对于界限上下文,我们把重点放在界限上,摘抄实现领域驱动设计的一段话:

​ 我在oneday-auth中设计了一个类LoginUserE,用来代表登录用户实体类,包含的信息仅仅跟认证和授权相关,而用户信息子域中,肯定也有一个用户类UserInfo,但是这里的代表的含义是跟业务系统相关信息,比如说性别,昵称。我相信大多数读者肯定经历过一个类中承担过多功能,试图去创建一个全功能的类,最终导致的结果各位也可想而知,贪一时之方便带来的是不断拆东墙补西墙。

​ 用户进入认证界限上下文,他在这里只会被认为 一个待认证,而且只具备认证相关的信息,用户进入授权界限上下文,他在这里只会被认为一个认证成功,等待授权或者具备权限的用户。认证上下文和授权上下文我们可以

​ 于是在代码里,我划分了两个包模块:

> one.day.auth:
    >> authentication :认证即用户登录,身份识别等功能
    >> authorization :授权上下文:给予用户身份,角色,权限,并判断用户是否具备访问某个功能的权限等功能

​ 看到这里,请读者自己思考一个问题,如果按照原来的做法,你会不会分出两个包,你的大致做法是不是如下

> one.day.auth.service
    >> authenticationServiceImpl
    >> authorizationServiceImpl

​ 如果你看到这里突然有了一种思维的自我斗争,甚至有一种恍然大悟的感觉,那么恭喜你,你已经开始培养了DDD的思维。

​ 小结:代码目录的不同,就从一开始决定了你的开发思维。传统的MVC分层注定无法真正有效的划分领域,从而实现面向对象开发

代码实践

代码分层

为什么我们需要领域驱动设计提到了两个架构,四层架构和六边形架构(又称端口-适配器)。其中六边形架构是从四层架构进一步发来而来的,是逻辑意义上的,代码的物理分层是做不到所谓六边形的。我们暂时抛开这一切,只关注我们想要的目的。

​ 应用层:非业务相关处理;领域层:业务相关处理;基础设施:持久化,缓存等技术细节实现。代码目录分层如下:

> one.day.auth.authentication
> > app 应用层
> > client 二方包,这里方便起见放在了同一个Maven项目中
> > domain 领域层
> > > entity 实体包,具备行为,不具备数据状态
> > > port 端口定义,外部依赖统一定义为端口
> > > service 领域服务
> > infrastructure 基础设施层
> > > adapt 适配器,实现领域层定义的端口接口
> > > converter DTO,DO,Entity互相转换的工具类
> > > dataobject 表映射包 不具备行为,具备数据状态
> > > repository 仓储
> > > tunnel 通道

功能实现

​ 我们来看看登录这一个功能具体是如何实现的。

@Component
public class AuthenticationApp {
   /**
     * 领域层,登录领域服务
     */
    @Autowired
    private LoginService loginService;
    /**
     * 登录
     *
     * @param loginCmd
     */
    public void login(LoginCmd loginCmd) {
        //调用领域层进行登录校验
        String userId = loginService.login(loginCmd);
        //session中存放userId已证明登录
        //由于领域层主要负责登录,或者校验密码,登录成功之后的登录态设定不关心,交由应用层负责
        ProjectUtil.setSession("userId", userId);
    }
    public void addLoginUser(AddLoginUserCmd addLoginUserCmd) {
        loginService.addLoginUser(addLoginUserCmd);
    }
}

​ 我们可以看到,应用层AuthenticationApp先调用了领域层的领域服务LoginService,当该方法没有抛出异常则证明用户校验成功,但是注意的是LoginService核心作用的是校验,登录不登录,即登录态的设定并不是他所关心的,并不是他的业务逻辑。领域层只保证用户和密码是正确的,而其他一切东西都是外围,应用层,甚至是上游服务得知校验成功之后再来设定登录态。

领域驱动设计之实战权限系统微服务-LMLPHP

​ 我们接着看看领域层,领域服务是如何工作的。

​ 我们先介绍两个类,LoginUserRepositoryPortLoginUserConverter。读者可能会有一个疑惑是,怎么可能会没有技术细节呢,我怎样都需要将数据保存到数据库中,这肯定就涉及到持久化技术,这个时候六边形架构就应运而生了。我们的口号是“领域层不掺杂任何技术细节”,任何的外部依赖,我们都定义成一个端口类,而具体的实现交由各个层的适配器去实现,通过依赖注入实现相应的依赖功能。如何检验这一点,就是要看你的领域层能不能做到拷贝不走样,即如果你单纯复制domain目录到其他的项目中,是否能够正常编译。

LoginUserConverter存在的意义是什么,DTO,Entity,DataObject之间总会互相转换,将这一部分代码统一放到Converter类中。我相信读者的不少项目,各种转换都是很随意的,开心就好:)

@Service
public class LoginServiceImpl implements LoginService {
    private final LoginUserRepositoryPort loginUserRepositoryPort;
    private final LoginUserConverter loginUserConverter;
    @Autowired
    public LoginServiceImpl(LoginUserRepositoryPort loginUserRepositoryPort, LoginUserConverter loginUserConverter) {
        this.loginUserRepositoryPort = loginUserRepositoryPort;
        this.loginUserConverter = loginUserConverter;
    }
    @Override
    public String login(LoginCmd loginCmd) {
        Optional<LoginUserE> optionalLoginUserE = loginUserRepositoryPort.findByUsername(loginCmd.getUsername());
        optionalLoginUserE.orElseThrow(() -> new BaseException(GlobalEnum.NON_EXIST));
        LoginUserE loginUserE = optionalLoginUserE.get();
        loginUserE.login(loginCmd.getPassword());
        //todo 登录成功,异步通知观察者
        return loginUserE.getUserId();
    }
    @Override
    public void addLoginUser(AddLoginUserCmd addLoginUserCmd) {
        LoginUserE loginUserE = loginUserConverter.convert2Entity(addLoginUserCmd);
        loginUserE.prepareToAdd();
        loginUserRepositoryPort.add(loginUserE);
    }
}

​ 领域服务LoginServiceImpl的第一件事是通过依赖注入获取的LoginUserRepositoryPort去查询获取登录用户LoginUserE,如果存在则调用login方法。我们看看LoginUserE究竟是什么玩意。

@Data
public class LoginUserE extends Unique {
    public static final String COMMON_SALT = "commonSalt";
    /**
     * 登录用户名
     */
    private String username;
    /**
     * 登录密码
     */
    private String password;
    /**
     * 盐
     */
    private String salt;
    /**
     * 加密算法
     */
    private EncryptionAlgorithmV encryptionAlgorithmV;
    /**
     * 业务唯一ID
     */
    private String userId;
    private TenantIdV tenantIdV;
    /**
     * 比较密码
     *
     * @param sendPwd 传入的密码
     * @return true/false
     */
    public boolean login(String sendPwd) {
        //检查available
        //错误次数限制
        //锁号 ip
        return StringUtils.equals(password, encryptionAlgorithmV.getPasswordEncoder().encoder(sendPwd, salt));
    }
    /**
     * 密码加密
     */
    public void encryptPassword() {
        this.setSalt(RandomStringUtils.randomNumeric(8));
        this.setPassword(encryptionAlgorithmV.getPasswordEncoder().encoder(password, salt));
    }
}

​ 代码逻辑其实很简单,留着几个扩展功能没有实现,一个是针对登录失败的各种场景操作,第二个是,对不同的租户下的用户系统实现不同的加密器。功能从上帝Service类转移到具备真正意义的实体类上,具备真正的行为,符合类的单一职责标准。

​ 到这里登录功能讲解就算是结束,但其中我留有一个功能未开发,即登录成功,异步通知观察者,DDD中同时倡导事件驱动开发和最终一致性。这其实也是跟类的单一职责原则有关。在整个登录功能中,校验是第一步,校验成功紧接着是进行授权,两者是上下游关系,核心业务逻辑不应该写在一块,这在传统MVC项目中两者是绝对的耦合在一起。而采用事件驱动可以将两者分离,无论是异步或者同步,简单起见的话可以直接使用guava的EventBus。

​ 持久化层的设计和特点本篇暂不涉及,不可一步而就,事实上如果你还关心这一点的话则证明你还未能理解DDD。重点是业务逻辑,无技术细节。持久化只是一种存储技术,不要因为用了这一个技术反而被绑架了你的思路。

总结

​ 业务层执行非业务逻辑,领域层只执行业务逻辑,使用端口-适配器模式隔离外部依赖,检验的标准是拷贝不走样。第一步的界限上下文划分很关键。一开始的划分就决定了你是面向对象还是面向过程。不要被持久化技术绑架了我们的开发思路。我们的口号是“领域层不掺杂任何技术细节”,我们的目标是真正的面向对象开发,我们的理想是永不加班!!!

​ 源码地址:https://github.com/iamlufy/oneday-auth

​ 作者:plz叫我红领巾   

​ 出处:https://juejin.im/post/5cd3d1a8f265da034c7042c6

  本博客欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

05-10 22:59