不为有趣之事,何遣有涯之生
不失其所者久,死而不亡者寿

SpringDataJPA系统(3)

hzqiuxm阅读(18)评论(0)

DQM的命名语法与参数

在工作中,你是否经常为方法名的语义、命名规范而发愁?是否要为不同的查询条件写各种的 SQL 语句?是否为同一个实体的查询,写一个超级通用的查询方法或者 SQL?如果其他开发同事不查看你写的 SQL 语句,而直接看方法名的话,却不知道你想查什么而郁闷?

Spring Data JPA 的 Defining Query Methods(DQM)通过方法名和参数,可以很好地解决上面的问题,也能让我们的方法名的语义更加清晰,开发效率也会提升很多。

可以说Spring Data JPA 的最大特色是利用方法名定义查询方法(Defining Query Methods)来做 CRUD 操作。
DQM 语法共有 2 种,可以实现上面的那些问题:

  • 一种是直接通过方法名就可以实现
  • 另一种是 @Query 手动在方法上定义

定义查询方法的配置和使用方法

若想要实现 CRUD 的操作,常规做法是写一大堆 SQL 语句。但在 JPA 里面,只需要继承 Spring Data Common 里面的任意 Repository 接口或者子接口,然后直接通过方法名就可以实现,神不神奇?

比如我们要增加一个根据Email地址来查询用户,可以在接口中这么写:

然后你不用写具体的实现就可以直接使用该方法来达到根据邮箱地址查询用户的目的了,测试下该方法,完全没问题


方法查询策略设置

在平时的工作中,你可以通过方法名,或者定义方法名上面添加 @Query 注解两种方式来实现 CRUD 的目的,而 Spring 给我们提供了两种切换方式。

在实际生产中还没有遇到要修改默认策略的情况,但我们必须要知道有这样的配置方法,做到心中有数,这样我们才能知道为什么方法名可以,@Query 也可以。

通过 @EnableJpaRepositories 注解来配置方法的查询策略,共有三种方式:

  • Create:直接根据方法名进行创建,规则是根据方法名称的构造进行尝试,一般的方法是从方法名中删除给定的一组已知前缀,并解析该方法的其余部分。如果方法名不符合规则,启动的时候会报异常,这种情况可以理解为,即使配置了 @Query 也是没有用的。

  • USE_DECLARED_QUERY:声明方式创建,启动的时候会尝试找到一个声明的查询,如果没有找到将抛出一个异常,可以理解为必须配置 @Query。

  • CREATE_IF_NOT_FOUND:这个是默认的,除非有特殊需求,可以理解为这是以上 2 种方式的兼容版。先用声明方式(@Query)进行查找,如果没有找到与方法相匹配的查询,那用 Create 的方法名创建规则创建一个查询;这两者都不满足的情况下,启动就会报错。(所以一般用默认就足够了)

DQM语法

该语法是:带查询功能的方法名由查询策略(关键字)+ 查询字段 + 一些限制性条件组成,具有语义清晰、功能完整的特性,我们实际工作中 80% 的 API 查询都可以简单实现。

我们来看一个复杂点的例子,这是一个 and 条件更多、distinct or 排序、忽略大小写的例子。

interface PersonRepository extends Repository<User, Long> {
   // and 的查询关系
   List<User> findByEmailAddressAndLastname(EmailAddress emailAddress, String lastname);
   // 包含 distinct 去重,or 的 sql 语法
   List<User> findDistinctPeopleByLastnameOrFirstname(String lastname, String firstname);
   // 根据 lastname 字段查询忽略大小写
   List<User> findByLastnameIgnoreCase(String lastname);
   // 根据 lastname 和 firstname 查询 equal 并且忽略大小写
   List<User> findByLastnameAndFirstnameAllIgnoreCase(String lastname, String firstname); 
  // 对查询结果根据 lastname 排序,正序
   List<User> findByLastnameOrderByFirstnameAsc(String lastname);
  // 对查询结果根据 lastname 排序,倒序
   List<User> findByLastnameOrderByFirstnameDesc(String lastname);
}

下面罗列了DMQ里的一些关键字列表:
可以前往官方看更加详细的介绍:https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods

  • 方法名的表达式通常是实体属性连接运算符的组合,如 And、or、Between、LessThan、GreaterThan、Like 等属性连接运算表达式,不同的数据库(NoSQL、MySQL)可能产生的效果不一样,如果遇到问题,我们可以打开 SQL 日志观察。
  • IgnoreCase 可以针对单个属性(如 findByLastnameIgnoreCase(…)),也可以针对查询条件里面所有的实体属性忽略大小写(所有属性必须在 String 情况下,如 findByLastnameAndFirstnameAllIgnoreCase(…))
  • OrderBy 可以在某些属性的排序上提供方向(Asc 或 Desc),称为静态排序,也可以通过一个方便的参数 Sort 实现指定字段的动态排序的查询方法(如 repository.findAll(Sort.by(Sort.Direction.ASC, "myField")))

分页与排序

Spring Data JPA 为了方便我们排序和分页,支持了两个特殊类型的参数:Sort 和 Pageable。

Sort 在查询的时候可以实现动态排序

Sort 里面的dierection决定了我们哪些字段的排序方向(ASC 正序、DESC 倒序)

Pageable 在查询的时候可以实现分页效果和动态排序双重效果,我们看下下面这些例子:

Page<User> findByManagerId(String lastname, Pageable pageable);//根据分页参数查询User,返回一个带分页结果的Page(下一课时详解)对象(方法一)
Slice<User>findByManagerId(String lastname, Pageable pageable);//我们根据分页参数返回一个Slice的user结果(方法二)
List<User> findByManagerId(String lastname, Sort sort);//根据排序结果返回一个List(方法三)
List<User> findByManagerId(String lastname, Pageable pageable);//根据分页参数返回一个List对象(方法四)
  • 方法一:允许将 org.springframework.data.domain.Pageable 实例传递给查询方法,将分页参数添加到静态定义的查询中,通过 Page 返回的结果得知可用的元素和页面的总数。这种分页查询方法可能是昂贵的(会默认执行一条 count 的 SQL 语句),所以用的时候要考虑一下使用场景。

  • 方法二:返回结果是 Slice,因为只知道是否有下一个 Slice 可用,而不知道 count,所以当查询较大的结果集时,只知道数据是足够的,也就是说用在业务场景中时不用关心一共有多少页。目前项目开发中越来越推荐使用这个。

  • 方法三:如果只需要排序,需在 org.springframework.data.domain.Sort 参数中添加一个参数,正如上面看到的,只需返回一个 List 也是有可能的。

  • 方法四:排序选项也通过 Pageable 实例处理,在这种情况下,Page 将不会创建构建实际实例所需的附加元数据(即不需要计算和查询分页相关数据),而仅仅用来做限制查询给定范围的实体。

方法二比较实用,下面是它的一个具体实现和测试


限制查询数量

有的时候我们想直接查询前几条数据,也不需要动态排序,那么就可以简单地在方法名字中使用 First 和 Top 关键字,来限制返回条数。
下面是几个方法示例:

User findFirstByOrderByLastnameAsc();
User findTopByOrderByAgeDesc();
List<User> findDistinctUserTop3ByLastname(String lastname, Pageable pageable);
List<User> findFirst10ByLastname(String lastname, Sort sort);
List<User> findTop10ByLastname(String lastname, Pageable pageable);
  • 查询方法在使用 First 或 Top 时,数值可以追加到 First 或 Top 后面,指定返回最大结果的大小
  • 如果数字被省略,则假设结果大小为 1
  • 限制表达式也支持 Distinct 关键字
  • 支持将结果包装到 Optional 中
  • 如果将 Pageable 作为参数,以 Top 和 First 后面的数字为准,即分页将在限制结果中应用

关于NULL支持

  • @NonNullApi:在包级别用于声明参数,以及返回值的默认行为是不接受或产生空值的。
  • @NonNull:用于不能为空的参数或返回值(在 @NonNullApi 适用的参数和返回值上不需要)
  • @Nullable:用于可以为空的参数或返回值

在资源库的package-info.java文件中加入@NonNullApi注解,表示该模块下返回值不接受或产生空值

当我们查询参数为空或数据库没有数据返回值为空时,则都会抛出异常,下面是测试示例代码

但好像返回值为空抛异常不是很友好,所以我们可以使用Optional来进行返回值包装,如下所示:

原理解析

每个版本更新后可能支持的语法和效果会有所不同,注意看最新的官方文档介绍。我们这里介绍下其简单实现原理。
通过org.springframework.data.repository.query.parser.PartTree 查看相关源码了解其逻辑和处理方法


根据源码我们也可以分析出来,query method 包含其他的表达式,比如 find、count、delete、exist 等关键字在 by 之前通过正则表达式匹配。

由此可知,我们方法中的关键字不是乱填的,是枚举帮我们定义好的。接下来打开枚举类 Type 源码看下,比什么都清楚。
所以知道了这些通过看源码就可以知道框架支持了哪些逻辑关键字,比如 NotIn、Like、In、Exists 等,有的时候比查文档和任何人写的博客都准确、还快。

总结

DQM的命名规范值得我们去借鉴,在我们开发的给一些方法取名字的时候可以按照这个规范去执行,有利于提升方法的表意性。
我们还可以自己定义一个自己的BaseService,声明了常用的CRUD操作。我们也可以建立自己的 PagingAndSortingService、ComplexityService、SampleService 等来划分不同的 service接口,供不同目的 Service 子类继承。

public interface BaseService<T, ID> {
    Class<T> getDomainClass();
    <S extends T> S save(S entity);
    <S extends T> List<S> saveAll(Iterable<S> entities);
    void delete(T entity);
    void deleteById(ID id);
    void deleteAll();
    void deleteAll(Iterable<? extends T> entities);
    void deleteInBatch(Iterable<T> entities);
    void deleteAllInBatch();
    T getOne(ID id);
    <S extends T> Optional<S> findOne(Example<S> example);
    Optional<T> findById(ID id);
    List<T> findAll();
    List<T> findAll(Sort sort);
    Page<T> findAll(Pageable pageable);
    <S extends T> List<S> findAll(Example<S> example);
    <S extends T> List<S> findAll(Example<S> example, Sort sort);
    <S extends T> Page<S> findAll(Example<S> example, Pageable pageable);
    List<T> findAllById(Iterable<ID> ids);
    long count();
    <S extends T> long count(Example<S> example);
    <S extends T> boolean exists(Example<S> example);
    boolean existsById(ID id);
    void flush();
    <S extends T> S saveAndFlush(S entity);
}

下面是对Baseservice的基本实现

public class BaseServiceImpl<T, ID, R extends JpaRepository<T, ID>> implements BaseService<T, ID> {
    private static final Map<Class, Class> DOMAIN_CLASS_CACHE = new ConcurrentHashMap<>();
    private final R repository;
    public BaseServiceImpl(R repository) {
        this.repository = repository;
    }
    @Override
    public Class<T> getDomainClass() {
        Class thisClass = getClass();
        Class<T> domainClass = DOMAIN_CLASS_CACHE.get(thisClass);
        if (Objects.isNull(domainClass)) {
            domainClass = GenericsUtils.getGenericClass(thisClass, 0);
            DOMAIN_CLASS_CACHE.putIfAbsent(thisClass, domainClass);
        }
        return domainClass;
    }
    protected R getRepository() {
        return repository;
    }
    @Override
    public <S extends T> S save(S entity) {
        return repository.save(entity);
    }
    @Override
    public <S extends T> List<S> saveAll(Iterable<S> entities) {
        return repository.saveAll(entities);
    }
    @Override
    public void delete(T entity) {
        repository.delete(entity);
    }
    @Override
    public void deleteById(ID id) {
        repository.deleteById(id);
    }
    @Override
    public void deleteAll() {
        repository.deleteAll();
    }
    @Override
    public void deleteAll(Iterable<? extends T> entities) {
        repository.deleteAll(entities);
    }
    @Override
    public void deleteInBatch(Iterable<T> entities) {
        repository.deleteInBatch(entities);
    }
    @Override
    public void deleteAllInBatch() {
        repository.deleteAllInBatch();
    }
    @Override
    public T getOne(ID id) {
        return repository.getOne(id);
    }
    @Override
    public <S extends T> Optional<S> findOne(Example<S> example) {
        return repository.findOne(example);
    }
    @Override
    public Optional<T> findById(ID id) {
        return repository.findById(id);
    }
    @Override
    public List<T> findAll() {
        return repository.findAll();
    }
    @Override
    public List<T> findAll(Sort sort) {
        return repository.findAll(sort);
    }
    @Override
    public Page<T> findAll(Pageable pageable) {
        return repository.findAll(pageable);
    }

    @Override
    public <S extends T> List<S> findAll(Example<S> example) {
        return repository.findAll(example);
    }
    @Override
    public <S extends T> List<S> findAll(Example<S> example, Sort sort) {
        return repository.findAll(example, sort);
    }

    @Override
    public <S extends T> Page<S> findAll(Example<S> example, Pageable pageable) {
        return repository.findAll(example, pageable);
    }
    @Override
    public List<T> findAllById(Iterable<ID> ids) {
        return repository.findAllById(ids);
    }
    @Override
    public long count() {
        return repository.count();
    }
    @Override
    public <S extends T> long count(Example<S> example) {
        return repository.count(example);
    }
    @Override
    public <S extends T> boolean exists(Example<S> example) {
        return repository.exists(example);
    }
    @Override
    public boolean existsById(ID id) {
        return repository.existsById(id);
    }
    @Override
    public void flush() {
        repository.flush();
    }
    @Override
    public <S extends T> S saveAndFlush(S entity) {
        return repository.saveAndFlush(entity);
    }
}

以上代码就是 BaseService 常用的 CURD 实现代码,我们这里面大部分也是直接调用 Repository 提供的方法。mybatics-plus框架中也实现了基本服务层代码,可以去参考下。

SpringDataJPA系列(2)

hzqiuxm阅读(80)评论(0)

Spring Data Common核心Repository

Spring Data Commons依赖关系

我们通过 Gradle 看一下项目依赖,了解一下 Spring Data Common 的依赖关系

通过上图的项目依赖,不难发现,数据库连接用的是 JDBC,连接池用的是 HikariCP,强依赖 Hibernate;Spring Boot Starter Data JPA 依赖 Spring Data JPA;而 Spring Data JPA 依赖 Spring Data Commons。 Spring Data Commons 是终极依赖,所以它是我们重点关注对象

Repository 接口

Repository 是 Spring Data Common 里面的顶级父类接口,操作 DB 的入口类。

Resposiory 是 Spring Data 里面进行数据库操作顶级的抽象接口,里面什么方法都没有,但是如果任何接口继承它,就能得到一个 Repository,还可以实现 JPA 的一些默认实现方法。
Spring 利用 Respository 作为 DAO 操作的 Type,以及利用 Java 动态代理机制就可以实现很多功能,比如为什么接口就能实现 DB 的相关操作?这就是 Spring 框架的高明之处。
Spring 在做动态代理的时候,只要是它的子类或者实现类,再利用 T 类以及 T 类的 主键 ID 类型作为泛型的类型参数,就可以来标记出来、并捕获到要使用的实体类型,就能帮助使用者进行数据库操作。

Repository 类层次关系

查看Repository 所在源码的路径,我们可以发现JPA存储库可以分成四个大类:

  • 1处:CoroutineCrudRepository 这条继承关系链是为了支持 Kotlin 语法而实现的
  • 2处:ReactiveCrudRepository 这条线是响应式编程,主要支持当前 NoSQL 方面的操作,因为这方面大部分操作都是分布式的,所以由此我们可以看出 Spring Data 想统一数据操作的“野心”,即想提供关于所有 Data 方面的操作。目前 Reactive 主要有 Cassandra、MongoDB、Redis 的实现。
  • 3处:RxJava2CrudRepository 这条线是为了支持 RxJava 2 做的标准响应式编程的接口,可以看到它有两个版本的实现
  • 4处:CrudRepository 这条继承关系正是我们目前大多数项目开发经常使用到的 JPA 相关的操作接口

再看下Resposiory相关接口的关系图:

  • Repository(org.springframework.data.repository),没有暴露任何方法
  • CrudRepository(org.springframework.data.repository),简单的 Curd 方法
  • PagingAndSortingRepository(org.springframework.data.repository),带分页和排序的方法
  • QueryByExampleExecutor(org.springframework.data.repository.query),简单 Example 查询
  • JpaRepository(org.springframework.data.jpa.repository),JPA 的扩展方法
  • JpaSpecificationExecutor(org.springframework.data.jpa.repository),JpaSpecification 扩展查询
  • QueryDslPredicateExecutor(org.springframework.data.querydsl),QueryDsl 的封装
  • SimpleJpaRepository(org.springframework.data.jpa.repository.support),JPA 所有接口的默认实现类
  • QueryDslJpaRepository(org.springframework.data.jpa.repository.support),QueryDsl 的实现类

我们使用的时候一般是使得自定义接口继承JpaRepository

分水岭JpaRepository

JpaRepository之前的几个接口都是 Spring Data 为了兼容 NoSQL 而进行的一些抽象封装,而从 JpaRepository 开始是对关系型数据库进行抽象封装。所以你使用mysql数据库的时候都是直接继承JpaRepository。
JpaRepository中主要增加的是一些批量操作的方法,其实现类也是 SimpleJpaRepository。还优化了批量删除的性能,类似于之前 SQL 的 batch 操作,并不是像上面的 deleteAll 来 for 循环删除。其中 flush() 和 saveAndFlush() 提供了手动刷新 session,把对象的值立即更新到数据库里面的机制。
我们都知道 JPA 是 由 Hibernate 实现的,所以有 session 一级缓存的机制,当调用 save() 方法的时候,数据库里面是不会立即变化的。

Resposiory接口与实现类SimpleJpaRepository

我们可以在CrudRepository、PagingAndSortingRepository等接口发现一些方法,而这些方法的实现都是在SimpleJpaRepository类中(如果是其他 NoSQL的 实现如 MongoDB,那实现就在 Spring Data MongoDB 的 jar 里面的 MongoRepositoryImpl)。

我们可以在SimpleJpaRepository源码中看到每个方法的注释,标识出了该方法是实现了哪个接口中的哪个方法

这里特别强调了一下 Delete 和 Save 方法,是因为在实际工作中,看到有的同事画蛇添足:自己在做 Save 的时候先去 Find 一下,其实是没有必要的,Spring JPA 底层都考虑到了。在进行 Update、Delete、Insert 等操作之前会通过 findById 先查询一下实体对象的 ID,然后再去对查询出来的实体对象进行保存操作。而如果在 Delete 的时候,查询到的对象不存在,则直接抛异常。

如果有些业务场景需要进行扩展了,可以继续继承此类。如 QueryDsl 的扩展(虽然不推荐使用了,但我们可以参考它的做法,自定义自己的 SimpleJpaRepository),如果能将此类里面的实现方法看透了,基本上 JPA 中的 API 就能掌握大部分内容。

UserRepository 的实现类是 Spring 启动的时候,利用 Java 动态代理机制帮我们生成的实现类,而真正的实现类就是 SimpleJpaRepository。 SimpleJpaRepository 的实现机制,是通过 EntityManger 进行实体的操作,而 JpaEntityInforMation 里面存在实体的相关信息和 Crud 方法的元数据等。

我们可以在 RepositoryFactorySupport 设置一个断点,启动的时候,在我们的断点处就会发现 UserRepository 的接口会被动态代理成 SimpleJapRepository 的实现


这里需要注意的是每一个 Repository 的子类,都会通过这里的动态代理生成实现类。

总结

在接触了 Repository 的源码之后,我们在工作中遇到过一些类似需要抽象接口和写动态代理的情况,可以从Repository 的源码中获得这些启发:

  • 上面的 7 个大 Repository 接口,我们在使用的时候可以根据实际场景,来继承不同的接口,从而选择暴露不同的 Spring Data Common 给我们提供的已有接口。这其实利用了 Java 语言的 interface 特性,在这里可以好好理解一下 interface 的妙用。
  • 利用源码也可以很好地理解一下 Spring 中动态代理的作用,可以利用这种思想,在改善 MyBatis 的时候使用

SpringDataJPA系列(1)

hzqiuxm阅读(92)评论(0)

SpingDataJPA概述


SpringDataJPA似乎越来越流行了,我厂的mysql数据库和MongoDB数据库持久层都依赖了SpringDataJPA。为了更好的使用它,我们内部还对MongoDB的做了进一步的抽象和封装。为了查漏补缺,温故而知新,整理下SpringDataJPA相关知识,Check下实践过程中哪些地方还存在哪些不足,从而进一步的优化之。

几种ORM框架对比

首先看下几种流行ORM的对比

JPA的开源实现

  • 一套 API 标准定义了一套接口,在 javax.persistence 的包下面,用来操作实体对象,执行 CRUD 操作,而实现的框架(Hibernate)替代我们完成所有的事情,让开发者从烦琐的 JDBC 和 SQL 代码中解脱出来,更加聚焦自己的业务代码,并且使架构师架构出来的代码更加可控
  • 定义了一套基于对象的 SQL:Java Persistence Query Language(JPQL),像 Hibernate 一样,我们通过写面向对象(JPQL)而非面向数据库的查询语言(SQL)查询数据,避免了程序与数据库 SQL 语句耦合严重,比较适合跨数据源的场景(一会儿 MySQL,一会儿 Oracle 等)
  • ORM(Object/Relational Metadata)对象注解映射关系,JPA 直接通过注解的方式来表示 Java 的实体对象及元数据对象和数据表之间的映射关系,框架将实体对象与 Session 进行关联,通过操作 Session 中不同实体的状态,从而实现数据库的操作,并实现持久化到数据库表中的操作,与 DB 实现同步。

JPA 的宗旨是为 POJO 提供持久化标准规范,可以集成在 Spring 的全家桶使用,也可以直接写独立 application 使用,任何用到 DB 操作的场景,都可以使用,极大地方便开发和测试,所以 JPA 的理念已经深入人心了。Spring Data JPA、Hibernate 3.2+、TopLink 10.1.3 以及 OpenJPA、QueryDSL 都是实现 JPA 协议的框架

俗话说得好:“未来已经来临,只是尚未流行”,大神资深开发用 Spring Data JPA,编程极客者用 JPA;而普通 Java 开发者,不想去挑战的 Java“搬砖者”用 Mybatis。

SpringData子项目

Spring Data Common 是 Spring Data 所有模块的公共部分,该项目提供了基于 Spring 的共享基础设施,它提供了基于 repository 接口以 DB 操作的一些封装,以及一个坚持在 Java 实体类上标注元数据的模型。

Spring Data 不仅对传统的数据库访问技术如 JDBC、Hibernate、JDO、TopLick、JPA、MyBatis 做了很好的支持和扩展、抽象、提供方便的操作方法,还对 MongoDb、KeyValue、Redis、LDAP、Cassandra 等非关系数据的 NoSQL 做了不同的实现版本,方便我们开发者触类旁通。

下图为目前 Spring Data 的框架分类结构图:

主要接口类关系

注意:图中的UserRepository是我们自己实现的接口,继承了JpaRepository和JpaSpecificationExecutor

7个核心接口

  • Repository(org.springframework.data.repository),没有暴露任何方法;
  • CrudRepository(org.springframework.data.repository),简单的 Curd 方法;
  • PagingAndSortingRepository(org.springframework.data.repository),带分页和排序的方法;
  • QueryByExampleExecutor(org.springframework.data.repository.query),简单 Example 查询;
  • JpaRepository(org.springframework.data.jpa.repository),JPA 的扩展方法;
  • JpaSpecificationExecutor(org.springframework.data.jpa.repository),JpaSpecification 扩展查询;
  • QueryDslPredicateExecutor(org.springframework.data.querydsl),QueryDsl 的封装

2大实现类

  • SimpleJpaRepository(org.springframework.data.jpa.repository.support),JPA 所有接口的默认实现类;
  • QueryDslJpaRepository(org.springframework.data.jpa.repository.support),QueryDsl 的实现类

本章节主要是对JPA有个大致的认知,下一节开始进行核心类与功能的讲解

领域驱动战略篇(5)

hzqiuxm阅读(287)评论(0)

DDD实践原则规范

DDD设计原则

聚合根

  • 业务逻辑优先在聚合根边界内完成
  • 对聚合根的设计需要提防上帝对象(God Object),也即用一个大而全的领域对象来实现所有的业务功能

    例如:不同的限界上下文中都有各自的Product,有些Product是聚合根,有些不是

  • 聚合根的实现应该与框架无关,最好是POJO

  • 聚合同时也是存储的单元,这样可以保证持久化的简单,特别适合nosql
  • 聚合根之间的引用通过ID完成
  • 聚合根内部的所有变更都必须通过聚合根完成,聚合根是整个聚合的管理者
  • 如果出现一个事务更新了多个聚合根,重新检查聚合根边界是否出了问题;如果确实是业务需要,可以引入消息机制和事件驱动来保证
  • 聚合根不应该引用基础设施
  • 外界不应该持有聚合根内部数据结构
  • 尽量设计小聚合,以后即使要合并聚合也比拆分更简单
  • 聚合根里面没有实体,并不意味着数据库就只有一张表,可以设计成多张表。DB设计和领域建模没有关系
聚合的三大基本规则
  • 1 只引用聚合根,客户端只能通过聚合根对聚合进行操作
  • 2 聚合间引用必须使用主键
  • 3 在一个事务中只能创建或更新一个聚合(微服务中表现几乎完美结合saga做事务管理,在使用关系型数据库可以适当打破)

实体与值对象

  • 实体对象表示的是具有一定生命周期(聚合根消亡,其他实体也消亡)并且拥有全局唯一标识(ID)的对象
  • 值对象是不变的,也就说一个值对象一旦被创建出来了便不能对其进行变更,如果要变更,必须重新创建一个新的值对象整体替换原有的
  • 区分实体和值对象的一个很重要的原则便是根据相等性来判断,前者通过ID来区别,后者通过属性
  • 将业务概念尽量建模为值对象

资源库

  • 资源库(Repository)就是用来持久化聚合根的,只有聚合根才“配得上”拥有Repository,所以一个聚合对应一个资源库
  • 有的DDD实践者甚至认为一个纯粹的Repository只应该包含save和byId两个方法(我们不强求,还要结合具体业务,特别是读操作)
  • 保证资源库接口不要混入基础设施的实现
  • 在领域层,只有领域服务才依赖于资源库
  • 要持久化除根实体在外的其他实体或值对象,必须交由聚合对应的资源库来实现(会不会导致资源库非常庞大?是不是要引入CQRS)
    > 还是可以单独更新/替换聚合中的其他实体或值对象的,有一个方法saveAggr(),还可以有saveEntity()方法
  • 实体的持久化要依赖聚合根
  • 值对象的持久化可独立,通过层级对象来实现(自定义一个拥有id的父类)

工厂

  • 简单的聚合根创建过程,直接在聚合根中实现Factory方法
  • 复杂度的聚合根创建过程,使用独立的Factory类
  • 自定义的创建方法含义是业务上的创建,而构造方法是技术语言上的创建

领域服务

  • 聚合根中不合适放置的业务逻辑才考虑放到DomainService中
  • 应用服务是必须要有的,领域服务不是,它是对非行为方法的一种妥协(通常是一些业务代理,生成ID类)

命令对象

  • 外部向领域模型发起的一次命令操作,Controller都要进行封装
  • 写操作一般以command作为后缀,传递给应用服务
  • 读操作一般以Representation作为后缀,传递给客户端
    public OrderId createOrder(CreateOrderCommand command) ; 
    public void changeProductCount(String id, ChangeProductCountCommand command) ;
    public void pay(String id, PayOrderCommand command) ;
    public OrderRepresentation toRepresentation(Order order);

业务中读写操作

  • 写操作中,我们需要严格地按照“应用服务 -> 聚合根 -> 资源库”的结构进行编码
  • 读操作中,领域模型中的对象不能直接返回给客户端

  • 以上三种方式,优先推荐基于数据模型的方式,一些类似于遵循JPA规范的框架,采用基于领域模型的方式也可以;CQRS比较庞大,需要结合业务场景慎重考虑

与工具技术结合使用原则

Lombok

会使用到Lombok的对象一般有领域实体对象、Command、Representation、DomainEvent。
Lombok不能随意使用,不然谁都可以任意调用,破坏了“最小权限”和“完整性”原则

请启用lombok的配置文件
  • 在项目的根目录中创建lombok.config文件,内容如下:
lombok.anyConstructor.addConstructorProperties=true # 方便Jackson找到对应的构造函数
lombok.addLombokGeneratedAnnotation = true # 将Lombok生成的代码标记为`Generated`,便于JaCoCo等排除这些代码
实体对象只用@Build和@Getter
  • @Builder会在没有自定义构造函数的情况下创建全参构造函数,结合先前的lombok.anyConstructor.addConstructorProperties=true,可以满足Jackson的反序列化
  • 外加自定义的create()方法来保证构建的完备性,其内部可以使用builder进行创建
  • 为了方便测试代码准备测试数据,测试代码可以调用builder直接构建对象而不用create()方法
@Builder
@Getter
public class Product extends BaseAggregate {
    private String id;
    private String name;
    private String description;
    private BigDecimal price;
    private Instant createdAt;
    private int inventory;
    private String categoryId;

    public static Product create(String name, String description, BigDecimal price, String categoryId) {
        Product product = Product.builder()
                .id(newUuid())
                .name(name)
                .description(description)
                .price(price)
                .createdAt(Instant.now())
                .inventory(0)
                .categoryId(categoryId)
                .build();
        product.raiseEvent(new ProductCreatedEvent(product.getId(), name, description, price, product.getCreatedAt()));
        return product;
    }
}
值对象只能使用@value
  • @value相当于是@Data的不可变形式
  • 不要去使用@Build,因为值对象的创建无法保证完备性
  • 对于Command对象,应该使用JSR-380中的注解来完成验证
@Value
public class CreateProductCommand {

    @NotBlank(message = "产品名字不能为空")
    private String name;

    @NotBlank(message = "产品描述不能为空")
    private String description;

    @NotNull(message = "产品价格不能为空")
    private BigDecimal price;

    @NotBlank(message = "产品所属目录不能为空")
    private String categoryId;
}
领域事件需要使用@ConstructorProperties和@Getter
  • DomainEvent一般是有继承关系的,Lombok创建父类并不优雅,使用@ConstructorProperties好看一点
  • @ConstructorProperties()是Spring提供的显示构造函数注解,
@Getter
public class ProductNameUpdatedEvent extends ProductEvent {
    private String oldName;
    private String newName;

    @ConstructorProperties({"productId", "oldName", "newName"})
    public ProductNameUpdatedEvent(String productId, String oldName, String newName) {
        super(productId);
        this.oldName = oldName;
        this.newName = newName;
    }
}

中间父类ProductEvent为:

@Getter
public abstract class ProductEvent extends DomainEvent {
    private String productId;

    protected ProductEvent(String productId) {
        this.productId = productId;
    }
}

最终的父类DomainEvent为:

@Getter
public abstract class DomainEvent {
    private String _id = UuidGenerator.newUuid();
    private Instant _createdAt = now();

    @Override
    public String toString() {
        return this.getClass().getSimpleName() + "[" + _id + "]";
    }
}

这样既保证了调用方不会乱调构造函数,也保证了Jackson能够正确的反序列化

@Data用于基础设施层
  • @Data:主要用于基础设施层,在使用一些框架时如有需要可以使用,比如在java项目中使用mapstruct在各个对象之间进行复制时

类名规范

约束与规范

  • 如何检查代码是否范围了约束?
    1 使用自定义元注解的方式,比如@Aggregate表示一个聚合根,然后用代码检查聚合根是不是有互相引用的情况
    2 聚合根和实体用不同的类后缀,通过解析判断该类后缀文件有没有互相引用

推荐:ArchUnit和jQAssistant

测试驱动编程(3)

hzqiuxm阅读(300)评论(0)

测试驱动编程—模拟消除依赖

模拟框架Mockito

大道至简

什么要模拟

单元测试的要点就在于验证单个单元是否正常,而不考虑依赖,TDD中的单元测试尤其如此。
对于内部依赖,我们应该已对其进行测试过;对于外部依赖(JDK包),我们应该信任它们
消除依赖的两种手段:合理的设计和模拟实现,合理的设计与具体的业务有关,本次只介绍模拟实现如何去实践
下图是一个实际的关系,我们的目标就是通过模拟简化关系,方便测试

名词解释

测试替身的其它名字:哑元对象(dummy object)、测试存根(test stub)、测试间谍(test spy)、模拟对象(mock object)、伪造对象(fake object)

Mockito常用注解

使用的模拟框架是Mockito,它的常用注解如下:

  • @Mock:用于模拟的创建,使得测试类更具可读性(不调用真实方式,默认返回都是null),需要配对@ExtendWith(MockitoExtension.class)才能使用
  • @Spy:用于创建间谍实例,代替spy(Object)方法(调用真实方式)
  • @InjectMocks:用于自动实例化测试对象,并将所有的@Mock或@Spy注解字段依赖项注入其中(类似Spring框架中自动注入)
  • @Captor:用于创建参数捕获器

要处理所有上述注释,请MockitoAnnotations.initMocks(testClass); 必须至少使用一次。 要处理注释,我们可以使用内置的运行器MockitoJUnitRunner或规则MockitoRule 。 我们还可以在@Before注释的Junit方法中显式调用initMocks()方法。

Mockito常用静态方法

除了使用注解,我们还需要用到是它的三个主要静态方法:

  • mock():创建模拟对象,还可以使用when()和given()指定模拟行为
  • spy():实现部分模拟,调用实际的对象
  • verify():检查调用方法时提供的参数是否是指定参数,是一种断言

Mockito测试流程三部曲

  • 模拟:mock一个模拟对象

    模拟一个List对象,它会给所有方法添加基本实现,返回值和由方法的返回类型决定,如 int 会返回 0,布尔值返回 false。对于其他 type 会返回 null

  • 打桩:Stub打桩设置预期

    指定条件和预期返回

  • 验证:验证预期和实际值是否一致

基础用法

  • 无返回值,使用notify

  • 监视对象

  • 抛出异常

  • 模拟传入参数


    Mockito 提供 argument matchers 机制,例如 anyString() 匹配任何 String 参数,anyInt() 匹配任何 int 参数,anySet() 匹配任何 Set,any() 则意味着参数为任意值。自定义类型也可以,如 any(User.class)

可变返回结果

之前我们thenReturn 是返回结果都是写死的,如果要让被测试的方法不写死,返回实际结果并让我们可以获取到应该怎么做呢?
利用 InvocationOnMock 提供的方法可以获取 mock 方法的调用信息。下面是它提供的方法:

  • getArguments() 调用后会以 Object 数组的方式返回 mock 方法调用的参数
  • getMethod() 返回 java.lang.reflect.Method 对象
  • getMock() 返回 mock 对象
  • callRealMethod() 真实方法调用,如果 mock 的是接口它将会抛出异常

验证verfily

由程序员自己来决定验证结果,可以关注调用参数、返回结果、调用次数(times(0))
verify 也可以像 when 那样使用模拟参数,若方法中的某一个参数使用了matcher,则所有的参数都必须使用 matcher


在最后的验证时如果只输入字符串”hello”是会报错的,必须使用 Matchers 类内建的 eq 方法

对象监视spy

spy 的意思是你可以修改某个真实对象的某些方法的行为特征,而不改变他的基本行为特征

spy 保留了 list 的大部分功能,只是将它的 size() 方法改写了。不过 spy 在使用的时候有很多地方需要注意,一不小心就会导致问题,所以不到万不得已还是不要用 spy

示例实战

升级版井字游戏

“井字游戏”第二版的需求很简单:添加永久性存储,让玩家能够保存游戏的当前状态,以便以后接着玩。

需求一

作为玩家,我希望把当前下的棋能够保存起来,以便于我能看到下的历史记录
需求分析:需要保存的信息有轮次、X和Y坐标以及玩家
我这边打算以mongoDB数据库来进行持久化保存,选用什么数据库和测试驱动没什么关系
准备好依赖和环境配置:

-- 依赖
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
-- 配置
spring:
    data:
      mongodb:
        host: 192.168.3.112
        port: 27117
        database: tic-tac

我们的数据库叫tic-tac,并打算将数据保存在一个叫game的mongoDB集合中;接下来设计一个持久化类,用来保存游戏的相关数据
(示例项目源码链接将在文末给出,大家不用太关注技术细节,关注在功能和测试模拟上)

一般刚接触单元测试的开发人员的第一反应就是先初始化一个数据在数据库中,然后测试下能不能查到,类似下面代码:

上面是这些spring的相关配置和资源库自动注入,大家不用关心,和具体业务无关

1处表示构建一个游戏记录,然后将它保存到数据库中
2处表示根据唯一编号从数据库中取出游戏保存记录,检验下唯一编号是否对应的上

初看起来好像这个测试并没有什么问题,但是其实这里存在至少四个大问题:1 构建一个游戏记录本身就需要操作数据库,而且运行每个测试方法都要构建,很多数据库会导致主键冲突;2 构建记录本身需要依赖具体的数据库,需要配置一大堆额外的东西(数据库驱动、获取实例、进行连接、释放资源等等);3 万一有人修改了数据库的数据,测试用例将失败,你每次测试的时候得告诉别人别动我的数据!;4 因为设计到了数据库操作,要时刻保证你的数据库运行正常,因为测试用例往往很多,所以你还要忍耐相当长时间的数据库操作(本来单元测试都是在内存中快速运行的)

等等我们的目的就是要测试下井字游戏的逻辑,现在怎么好像变得在测试数据库了?数据库作为外部依赖我们原则上应该信任它才对呀,所以我们需要使用mockit来模拟数据库操作

所以我们如果只是测试下的一步棋被保存了,其实就是调用了资源库的保存方法即可,我们默认是信任资源库能保存成功的

模拟的对象的方式有二种一种是使用注解,一种是使用静态方法:


上面是静态方法方式


上面是注解的方式,需要在测试类上添加@ExtendWith(MockitoExtension.class)

井字游戏涉及到的几个类职责如下:

  • TicTacToeBean:存储游戏状态信息的实体类
  • TicTacService:提供存储服务,与资源库进行协作
  • TicTacRepository:对实体类数据进行持久化

下面是一个保存一步棋的测试示例,采用了注解的方式

注意:可以看到我们的测试类并没有使用@SpringBootTest注解,单元测试已经脱离了Spring上下文环境
1处模拟了资源库和服务,因为需要服务中需要调用资源库对数据库进行操作,所以需要将资源库注入到服务中,采用了@InjectMocks注解
2处是构建一个某步棋的状态信息
3处是调用服务保存下棋的信息
4处是验证,我们关心的是验证资源库的save方法是不是被调用了一次

因为还没有saveMove方法,所以编译直接报错了,这个是我们的的阶段,接着实现一个空方法,让编译通过

执行下测试用例后,发现报错了,因为和预期的不一样

预期是要产生一次数据库的save操作,结果实际上是0,然后实现刚才添加的空方法,让其变绿

跑下测试用了,发现变绿了,并查看下测试报告

我们目前只是测试了服务中的saveMove()方法,其实资源库的方法我们也应该测试下,由于项目中我们使用的基于SpringJpaData项目下的Spring Data MongoDB框架来进行操作数据库,底层实现和默认的方法都是遵循JPA规范的,不需要我们定义各种增删改查方法。各种方法的返回值也确定,比如之前的Save方法,其接口如下:

所以我们测试成功和失败的时候分别返回的是当前保存对象以及空对象,示例代码如下:

跑下测试用例,验证我们的结果

我们发现其中需要的游戏状态信息是重复代码,所以可以重构下,重构完毕后记得验证下测试用例

需求二

刚才只是实现了其中一步游戏信息状态的保存,在游戏过程中,我们还需要保存每一步的信息,并且每次重新开始的时候要清空数据库
在实施需求二之前,我们先把第一版的测试用例加进来,运行保证其正确性
经过分析大概有这么几个阶段:

  • 游戏开始时,初始化一个全新的游戏状态信息对象,同时删除原来的游戏记录

以上两个方法比较简单和之前的类似,就直接给出代码了,不一小步一小步的讲解了,完成后一定要保证测试用例全部通过

  • 每当玩家下一步就保存起来

运行测试用例发现报错了

因为游戏的逻辑中目前我们还没有调用保存,经过分析,我们应该在setBoard()中保存,如果在play中编写,第一版的大量测试用例需要重构。
目前该方法已经具备了保存的所有参数信息(不熟悉该方法的需要回顾下井字游戏的第一版本,即该系列教程的第一篇)
当前的方法如下所示,接收的是多个参数,我们需要重构为一个TicTacBean

重构后如下:

play方法中调用的地方也应该做相应的修改:

运行所有的测试用例,第一版游戏的用例和第二版游戏用例都全部通过,大功告成

需求三

当玩家是继续游戏时,读取当前游戏的状态然后继续
作为玩家,我想保存当前游戏,以便于我下次可以继续上次的游戏
需要保存的游戏信息:棋盘以及最后的玩家,我这边就先不给出步骤了,请大家自行实现下

PS:代码仓库 https://gitee.com/hzqiuxm/tdd-demo-lessons.git 的jinggame2模块

总结

单元测试的本质就是要限定好单元的边界,如果单元测试的过程在为如何解析请求报文或数据库连接之类事情烦恼,那么你的单元测试很有可能超过边界了

为了在边界内快速响应结果做出业务实现,在涉及到数据库等第三方依赖的时候,会使用模拟的方式来解决。Mockito是一个优秀的模拟框架,在性能和灵活性上做到了很好的平衡,值得大家去熟悉其常用的API和注解

一个模拟测试过程有三步:1 模拟(对象);2 打桩(预期);3 验证(结果),然后结合`红-绿-重构``大法,就是良好的TDD实践之路。

我们都希望自己写的代码持续的焕发出活力,想持续焕发活力,你必须为以后重构提供坚实的基础。敏捷大行其道的今天,TDD也是敏捷思想核心技术之环的一个重要环节,没有技术实践的敏捷注定是失败的,没有TDD为代表的测试驱动技术,技术之环同样也不够完善。

测试驱动编程(2)

hzqiuxm阅读(426)评论(0)

测试驱动编程-进阶单元测试

单元测试

要打造出出类拔萃的作品,你必须专注于最小的细节

单元测试正确打开方式

  • 单元测试应该是测试流程中占比最重的部分
  • 单元测试通常来说是指方法,但是有些情况下,整个类乃至整个应用程序都可视为单元
  • 单元测试是为了缩小其它(功能,集成)测试的范围,如果一个组织主要依赖于手动测试,这本身就是有问题的
  • 单元测试编写更容易,也能让代码的问题尽早暴露
  • 单元测试为代码重构提供了有力的支持

各类测试比较


下图测试金字塔展示的是UI测试、集成测试、单元测试之间的比重,单元测试要比其它测试多得多

拿最常见的用户注册测试场景举例,最常看到的测试用例就是要考虑用户名为空、密码为空、用户名格式、密码格式、用户名是否存在、密码安全等级等等。

仅为测试这一项功能,就可能需要数十个测试,再加上边界值等情况可能导致上百个测试用例,如果是人工的UI测试,那么代价将是非常大的。

如果使用单元测试来做这些会简单和方便的多,人工只需要填写一个集成测试,检查是否调用了正确的后端方法即可,而无需关注细节。当注册逻辑发生改变了也没关系,因为新单元测试会自动进行测试。

TDD中的单元测试

TDD中的单元测试和传统的单元测试有什么不同呢?答案是:时机
看过我之前写的一篇文章,测试驱动编程快速入门的应该都知道了一个名字:红绿重构

传统先写业务再写单元测试,主要是为了验证业务代码写的对不对,而TDD中的单元测试主要目标是作为驱动开发和设计的动力,定义了我们应该做什么以及做到什么程度,验证只是副产品。
完成的工作量和具体的测试(单元、功能、集成)有关,TDD中的单元测试制订接下来完成尽可能小的任务,要求我们遵循一些设计原则(KISS、SOLID),确保代码的可测试性和解耦性。
TDD迫使我们详细地考虑需求和设计,编写整洁而可行的代码,以及创建可执行的需求并频繁重构。

示例实战

接收同事的需求

你的同事正在开发一个远程控制轮船的程序,他已经完成了一部分工作,但是他休假去了, 现在任务交给了你。你的同事是一个TDD的践行者,你接下来要在他完成的工作基础上继续开发

下图是你同事设计的几个主要的类:

  • Ship:表示轮船,目前没有任何方法,继续要迭代的地方
  • Point:表示二维坐标
  • Direction:代表方向的枚举类以及左右转弯的方法
  • Planet:地球,轮船航行的地方,包含最大的航行坐标点以及分布了哪些障碍物设置
  • Location:轮船当前方位,包含所在的坐标以及当前的行驶方向,可以前进后退、进行航行

下面是你同事已经完成的测试验证方法,所以请你放心的改造下去

方向测试类

轮船行驶范围(地球)测试类

二维坐标测试类

方位(包含方向与二维坐标)测试类

控制方向和坐标参考系如下:

特别要注意的是地球是圆的,起点和终点是重叠的。例如:往x轴最远处前进将回到x=1的坐标点

开始迭代需求

上面看到同事之前写的代码所有测试经过都是绿灯,我们可以放心的进行后面的需求迭代了

故事迭代1

我们需要给定轮船的起始位置以及方向

因为每次控制轮船的提前肯定是要有一艘轮船,所以在每个测试方法前都应该需要一艘船,我们把它放在@BeforeEach注解的方法里

Ship默认只有无参的构造函数所以报错了,这也是正常的红-绿-重构阶段,接下来我们进入绿阶段

写一个测试方法测试下

故事迭代2

轮船可以前进或后退

在编写轮船可以前进后退的时候,我们是不是要编写各个方向的前进和后退测试方法呢?类似下面的

该测试方法测试的是轮船往北移动,Y轴的坐标将加1
实际上我们不应该这么去编写测试,原因有三:1,我们的测试方法的结果依赖了其它方法、类的内部工作原理(否则我们怎么知道y轴需要检测的是8);2,会导致编写多个测试方法,我们肯定要把东南西北各个方向相关的方法都写一遍;3,依赖的其它方法虽然经过测试,但是如果发生变化,我们需要找到调用这个方法的所有地方进行修改

这里我们可以巧妙利用已经存在的方位方法进行测试用例编写:

编写moveForward()方法,使其变绿

轮船前进的完成了,后退的功能非常相似,就不详细说明了

故事迭代3

轮船可以左转或右转

和故事迭代2有点类似,在之前辅助类Location中包含了左右转的方法,我们可以结合实现

故事迭代4

轮船接收一个指令然后完成相应的动作(比如:lrfb表示左转右转前进再后退)

每个命令都用一个字符表示:f表示前进、b表示后退、l表示左转、r表示右转
先编写一个测试用例,接收一个字符串,解析并调用相关的方法

完成 的步骤后进入 绿

测试下结果没问题

故事迭代5

轮船行驶的地球是圆的,所以可以从一边的尽头跨越到另外一边

目前为止,轮船的构造函数只有一个参数location,代表轮船的方位,现在需要引入一个planet参数代表行驶的范围(水域大小)

新增一个两个参数的Ship构造函数,使其变绿

我们发现其实之前有过ship单个参数的测试方法,而且还有一个初始化方位的方法,其实我们可以将其重构成每次测试前都初始化一艘带方位和行驶范围的轮船

刚才的测试方法就可以简化为

Ship的构造参数也只需要保留一个就可以了

接下来编写一个往东边再前进回到x轴坐标1的测试方法,因为我们初始化的时候船就是朝东的,所以不同重新设置方向了

跑下测试用例肯定报错的,我们期望的是1,但是实际是51,超出边界了

度假中的同事之前已经在location辅助类中完成了跨越边界的逻辑,所以我们只要重构下ship中的前后形式方法,带上边界范围即可

运行下ship的所有测试方法,全部绿灯,故事迭代完成

故事迭代6

每次移动需要进行障碍检测,遇到障碍的话停在原处报告遇到的障碍

地球上的水域不是畅通无阻的,有暗礁和陆地,所以我们得在行驶区域中加入障碍物,轮船行驶的时候先检查是否有有障碍物

我们首先在初始化的时候在行驶区域内加入障碍物

重构下初始化的方法

无需添加实现,测试就可以通过
接下来我们测试有障碍物的时候,轮船无法行驶,我们的设计是让接收指令的方法返回一个字符串,代表每条指令的执行情况

根据测试代码,障碍物和船形式的路线如下:(先往蓝色箭头指示方向,后往绿色箭头指示方向)

执行下肯定是报错的,因为我们之前逻辑不支持障碍物,接下来要重构下我们的业务代码:
第一个地方:调用位置帮助类的时候,前进后退的方法要加上障碍物信息

第二个地方:指令接收方法要判断每次执行的结果,拼接成字符串返回

运行下我们的测试类

至此我们的遥控轮船开发完成,同事度假回来了,后面的需求交给他吧,毕竟我们也成为了TDD的践行者,让他放心的迭代吧

PS:验收代码仓库 https://gitee.com/hzqiuxm/tdd-demo-lessons.git 的ship模块

测试驱动编程(1)

hzqiuxm阅读(402)评论(0)

java测试驱动编程简明教程

本篇教程基于junit5,不熟悉的人可以先去官网了解下:https://junit.org/junit5/ 当然比较推荐我个人整理的junit5的简明教程,可以帮你省下不少时间

测试驱动编程实战

我们的原始需求

开发一个“井”字游戏,双方在3X3的网格中画X和O,最先在水平、垂直、对角线上将自己3个标记连起来的玩家获胜

  • 初始用户故事:作为玩家,可以将棋子放在3X3棋盘上任何没有棋子的地方

故事验收:

  • 1 如果棋子放在超出X轴边界地方,引发RuntimeException;
  • 2 如果棋子放在超出Y轴边界地方,引发RuntimeException;
  • 3 如果棋子放在已经有棋子的地方,引发RuntimeException

开始红-绿-重构

  • 首先我们在业务模块创建一个井字游戏的实现类 JingGameService
  • 然后利用Idea工具创建对应的测试类(手动也可以,这里只是图个方便)

选择创建一个在每个测试方法之前都执行的方法

  • 添加一个初始化井字游戏实例的方法

  • 编写X轴越界的测试方法

注意,我们创建的井字游戏类还没有任何的方法,所以肯定会报错的,这个过程就是红-绿-重构过程中的阶段

  • 为了进入绿阶段我们实现play方法

  • 执行下该测试方法

我们看到测试通过了,这个过程就是红-绿-重构过程中的绿阶段

  • 我们还可以查看下Junit5自动生成的测试报告

  • 此时我们的代码比较简单也没有重构的必要,继续完成其他的验收测试

  • Y轴的异常逻辑和X的如同一辙,我们马上就可以实现


执行下整个类的测试,发现都是绿的,此时我们又经过了一次红-绿-重构的过程(重构没做?因为目前还不用嘛)

  • 好了,只剩下最后一个验收测试了:确定棋子在棋盘内,并且该位置是没有棋子的
  • 先完成位置冲突的测试用例

执行下,不出意外的报错了:

  • 接下来进入绿阶段,进行业务代码的编写

执行下整个测试类,全部通过:

  • 此时的代码其实已经可以重构了,因为我们的判断语句比较多,不能很好的表达业务含义,并且这些判断可能其它地方也会用到,应该抽取为不同的方法,用明确的含义表示出来该步骤的意图

下面是一个代码重构的参考:

重构完记得重新执行下测试类哦

PS:使用Idea重构很方便,只要二步
第一步:选中要重构的代码

第二步:起个方法名和选择需要的参数

  • 好了到目前为止,需求1的用户故事结束了

进入需求2

我们得加入处理玩家落子的规则,验收测试如下:

  • 白子先下
  • 如果上一次是白子,那么这次是黑子
  • 如果上一次是黑子,那么这次是白子

我们开始红-绿-重构的过程:
先编写测试方法,当白子先下,下一个玩家应该是白,我们用W表示白子,B表示黑子

的过程完成了,接下来是绿的过程,编写业务方法

我们只要编写一行代码就能实现了,然后继续完成白方黑方交替下的过程
编写白方完成后轮到黑方测试方法:

修改刚才的nextPalyer方法,将它变绿:


因为我们修改了nextPalyer方法,之前的用例也要重新跑一次,保证一直是绿的
那么我们需要编写黑方下完后应该是白方的逻辑吗?如果你写完测试方法,发现业务代码啥也不用动就能正确通过了,那么说明这个测试是多余的,我们应该删除掉
OK,我们又完成了第二个需求

进入需求3

终于进入该游戏的关键规则环节了:如何获胜?最先在水平、垂直、对角线上将自己3个标记连起来的玩家获胜
我们需要检查三个方向:水平,垂直,对角线,当没有分出胜负时,我们也要返回没有赢家,先来完成这个

看来我们又要重构/修改play方法了

之前我们是使用0或1来表示是否有棋子的,现在不仅要知道某个位置上是否有棋子,还要知道棋子是谁下的,所以保存的时候直接保存是谁下的



继续编写垂直方向的

写了水平的,垂直的也就简单了

最后是对角线三连,注意对角线有二种可能

因为都只有唯一的一条线,判断起来也更加简单了

最后所有的用例跑一次看看

完美,所有的用例都通过了,需求3也完成了

进入需求4

最后我们要处理下平局,毕竟这个游戏的大多数结局可能是平局
平局只有一种可能,就是棋盘满了,所以最后的验收测试是,当棋盘满了,游戏显示为平局

进入阶段

进入绿阶段


最后看下测试结果:

好了,一个超级简单的井字游戏就告一个段落了,代码层面还有一些优化的地方,比如获胜的判断这块。当然这不是本教程的主题,我主要演示的是如何通过测试驱动来进行业务需求/用户故事的开发

我们可以清晰的体会到,我们在编写的时候没有一开始就写业务代码,而是分析需求的验收测试,然后将验收测试编程Junit5的测试代码,当然这个过程中,代码肯定是会报错的,报错的原因有二类:

  • 编译错误:缺少必要的方法和参数
  • 运行错误:逻辑上的不满足

我们要做的事情就是把不通过的测试用例都变绿,如此反复进行。当我们发现代码臃肿难以阅读时,就应该及时的进行重构,我们可以放心的进行重构,因为有之前的测试为我们检验重构后的结果,让我们不用过于担心代码重构的不可控。

测试覆盖率

最后我们看下Junit5提供的测试覆盖率功能:

在Idea的右上角可以看到下图所示,类、方法、代码行的测试覆盖率情况,让我们做到心里有数,有多少代码或方法是测试过的

一些忠告

  • 测试方法的名称:使用given(前置条件,一般如果有公共的前置,方法中可省略)/when(描述操作)/then(描述期望的结果)
  • 红-绿-重构的过程:保证短节奏式的交替进行

最后的话

本文讲述的只是一个非常简单的测试驱动需求例子,现实情况中需求比较复杂。我们需要转变的是我们开发系统的思想,一开始可能你还不是很熟练,但是随着练习次数,你将会喜欢上这种方法。就像当初学习DDD的时候,上手比较困难,各种概念难以理解,但是一旦你上手后,你再也不想回到以前那种贫血失血模型的开发中去了。

如果你立志成为一个优秀的工程师,那么测试驱动编程是你一定要掌握好的技能。

PS:本教程的源码分享在了 https://gitee.com/hzqiuxm/tdd-demo-lessons.git 的jingame模块

OKR思考与实战

hzqiuxm阅读(562)评论(0)

OKR思考与实战笔记

本文主要来自对黄勇老师课程相关学习的总结和思考,以及自己在OKR实践过程中的一些经验与复盘,很早时候就拜读过黄勇老师架构探险系列书籍以及技术博文,一直十分推崇。

快速入门

为什么要学习OKR?

OKR主要有以下四个优势:

  • 1)专注:OKR使方向更加聚焦,专注解决核心问题,提倡要事第一原则;
  • 2)透明:OKR让沟通更加透明,增强彼此间的信任,建立共识,达成共赢;
  • 3)激励:OKR设定的目标挑战越大,成就感越大,对人的激励效果就越好;
  • 4)简单:OKR理解容易且用法简答,用数据和事实说话,结果容易度量;
    OKR不只是优秀的目标管理工具,也是高效的沟通框架,更是有效的激励手段,还是自我成长思维

看看OKR的流行趋势:

OKR除了推动战略落地、优化绩效管理,还有其它几个场景:管理变革项目、激发组织创新、强化组织文化、提升管理水平

什么是OKR?

OKR是一套科学的目标管理工作法,它由O和KR两部分组成,O表示Object(目标),KR表示Key Result

好OKR标准

遵循SMART基本原则:

O和KR分别遵循的标准:

KPI和OKR相爱相杀

下图是二者的区别:

OKR 以过程论成败,KPI 以结果论英雄
OKR 重视过程、关注成长、勇于挑战,而 KPI 关注结果、被动执行、利益挂钩,所以从员工可接受程度来看,似乎 OKR 更有“人情味”,更能体现人的价值

OKR不能做的事

目标是可以包含绩效的,但是千万不能把绩效和考核挂钩,更不要和奖金或薪资挂钩,否则OKR就会被你彻底玩坏了

比如,研发团队在交付项目时总是延期,你希望将“无延期上线”这项绩效指标放入 OKR,这样做是可以的,但你一定不要告诉团队“如果项目发生延期需扣工资,如果提前或准时上线可发奖金”,否则 OKR 就会变成一种利益驱动的工具,以后一旦缺乏利益,团队也就缺乏动力了,最终结果是 OKR 被“玩坏”了

适用场景

  • 推算型、机械化的工作,更加适合用KPI,这类工作一般有现成的指令、规定和方法可以遵循
  • 探索型、创造性的工作,更加适合OKR

相互结合

KPI 中包括的绩效指标一定是只看结果,而不看过程的,只要结果达到了就行,而 OKR 需要更多地关心过程,从结果中判断目标的具体达成情况。因此,我们需要打造一个“以 OKR 为主并以 KPI 为辅”的研发团队。

企业中的快速落地

使用四层目标金字塔:

我们在制定目标的时候,尽可能与上级目标保持对齐,而不是完全对齐
千万不要让HR去主导,否则很容易变成KPI2.0
企业和部门或小队实施起来难度是不一样的,请遵循下面三个步骤:

  1. 一定不要强制性地去推广 OKR,更不要在大家概念不一致的情况下就大规模推广 OKR。
  2. 当大家对 OKR 理解不一致时,一定要请外部 OKR 专家到企业内部做 OKR 培训,才能快速建立共识。
  3. 要充分发挥你的领导力去影响身边的人,让高管们对 OKR 表示认同,让团队成员们尝到 OKR 的甜头,这才是我们作为领导者需要去做的事情。

研发团队如何实施OKR?

首先要判断研发团队的内驱力如何?主要是个人成长和团队贡献两个方面

内驱力不够的话,建立先培养或激发出一定内驱力,或者先在内驱力强的几个人身上先试实行
先决条件1:部门应该对产品的设计、开发和交付的整个过程负责
先决条件2:公司的成败很大程度上取决于产品本身的特性和表现,对销售市场环节的依赖度有限

产品型互联网公司发展路径模型:

产品型公司的发展规律告诉我们,企业往往不是依赖销售目标去牵引,而是通过产品技术竞争力去推动的

  • 初创阶段:技术产品负责人要对企业希望达成的商业目标、目标客户选择,以及要解决的客户问题上,心知肚明;

    该阶段有意义的OKR建议:识别标杆客户、提升销售转化率、完善关键产品特性、消除量产缺陷、偿还技术债务

  • PMF后阶段:首要目标就是思考同倍的资源增加能否带来同倍的业绩增长

    该阶段OKR建议:打造中台系统、提供数据分析能力、赋能业务团队、提升运营效率

操作指南

打造OKR土壤

团队文化是否达标
  • 面对沟通时,是否公开透明?
  • 面对交付时,是否信守承诺?
  • 面对压力时,是否用于挑战?

在目标“制定”过程中,通过跟自己的上级、平级、下级沟通,从而实现目标对齐。在目标“执行”过程中,我们需要不断跟踪目标的进度,一旦有调整就需要及时沟通。在目标“评估”过程中,我们需要对目标进行评估和复盘,需要跟团队一起开会讨论,这又会产生更进一步的沟通。

既然制定了目标,那就一定要为之努力并争取最大化地实现目标,而不是一遇到困难就轻易放弃。这绝不是 OKR 所推崇的价值观。如果你发现你的团队在承诺上做得还不够,那么你现在首先要做的就是,建立起遵守诺言的团队文化。

团队内驱力是否达标?

内驱力不强的团队其实更加需要OKR来打造团队文化,OKR是领导和员工建立信任的纽带
好好利用团队1V1沟通方式,慢慢走近员工的内心
打造内驱力团队文化的三点实战心得:

  • 先管理好自己:自己先按照你要打造的团队文化标准去做
  • 给团队更多的空间:通过聊天和分享为团队搭建施展能力的舞台
  • 文化由团队说了算:你是教练,文化不需要贴在墙上,写在纸片上,它应该在每个团队成员的身上
正确与认清OKR使用
  • 不需要一上来就全员推行,要找到符合OKR的土壤才行
  • 作为领导者,只需要影响自己身边值得被影响的人

  • 制订OKR不是拿团队的KR作为自己的O,而是用自己的O去对其团队的O
  • 乐观的说一个公司的成功取决于大家的努力,悲观的说其实是少数人的努力

制定基本原则

有效使用O的三点
  • 方向明确:目标不能太抽象,也不能含混不清,要让每个人都能看得懂
  • 目标对齐:下级要对齐上级的目标,即向上对齐,平级还要相互对齐,即水平对齐
  • 时间限制:目标需要有时间边界,不能遥遥无期,而且时间边界要恰到好处
有效使用KR的三点
  • 有挑战性:关键结果要有挑战性,拒绝平庸,拒绝不切实际,要做到“跳一跳,就能够得着”。
  • 容易度量:无法度量的关键结果是没有任何意义的,宁可抛弃。
  • 不是任务:关键结果需要对目标产生直接影响,它不是日常任务,也不是行动计划
OKR三板斧
  • 制定 OKR:自顶向下地制定目标,自底向上对齐目标
  • 执行 OKR:唯有做到持续追踪、持续更新,才能做到拥抱变化
  • 评估 OKR:周期性地评估和复盘,持续改进、持续迭代

轻松定制OKR

在OKR制订的过程中一定要沟通和评审

如何制定O
  • O其实是做什么,尽量让O看起来有使命感
  • O的制订要注意满足三条核心原则:1 有实际价值,所以你要深度思考;2 能激励人心,所以你要善于表达;3 能短期实现,所以要脚踏实地
  • O尽量以动词开头,带上制订O的动机
如何制定KR
  • KR其实是怎么做层面的
  • KR是定性的,它可能是一个数字,一个范围,一个里程碑事件
  • KR要对当事人或部门有一定的挑战性,不能太低也不能太高
  • 用信心指数来表达KR的挑战性,初始值为5/10,表示完成和不能完成的概率是50%

推动OKR的技巧

OKR推行过程中常见问题
  • 很长时间大家都没有去更新
  • 没有完成OKR的理由:工作太忙,根本没时间
  • 完成的不好理由:制订时候对其的O,最后因为同级的O变了,导致自己的O失去意义或价值
  • 不能及时同步大家OKR的执行情况
OKR推行技巧
  • 每天更新,每周回顾,每月优化,每季复盘
  • 将OKR和日常工作关联起来,通过任务去驱动
  • OKR要有固定的周期

OKR的评估

  • 最好的OKR评估方式就是自评,而OKR评估也是自我管理的有效手段
  • 评价结果就分为四档:0、0.3、0.7、1
  • 一般 OKR 平均得分在 0.6~0.7 范围内,就会被认为完成得很好。如果得到 1,可能就需要反思是否目标设定得太低
    信心指数与评分的关系图:

OKR的复盘

  • 1 审视目标:当初定目标原因,完成的结果如何?为什么是这个结果?
  • 2 回顾过程:你是如何执行的?
  • 3 分析得失:哪些做得好?哪些做得不好?分别是什么原因?
  • 4 总结规律 :如果再次做相同的事情,你会怎么做?收获了哪些规律、方法论?

OKR实施需要一位教练角色

在OKR布道推广的时候,如果你还不是OKR专家时,你需要借力请一位OKR专家来帮助落地,善于借力也是领导力的表现之一
当你成为OKR教练时,你要注意在扮演教练角色时:1 学会引导;2 保持学习;3 不做权威

如何将OKR和"个人利益"挂钩

原则:OKR 不要与个人利益直接挂钩,但可以与个人利益间接关联

【OKR定位】
OKR解耦公司:1 将绩效作为目标,放到OKR中进行管理;2 通过OKR来实现个人成长与团队贡献;3 衡量员工的成长和贡献速度,来提升员工岗位级别;4 当岗位级别提升时,决定薪资的具体涨幅

上面的链条关系: 绩效--目标--成长/贡献--岗位级别--薪资
本职工作完成可以拿到工资,OKR完成的好进行升级加薪

【本职工作和OKR】
那么问题来了:本职过于饱和的情况下,哪有时间去完成OKR呢?升级加薪迎娶白富美岂不是东方夜谭?
OKR的分类:

  • 承诺型:是我们必须努力完成的,例子:项目及时上线
  • 愿景型:是我们需要挑战才能实现的,例子:提升开发效率,优化系统架构

建议:将本职工作放入承诺型OKR,然后将个人成长与团队愿景放入愿景型OKR,二者互相集合
在一个OKR周期中,必须要有一个愿景型OKR
优先完成承诺型OKR(重要且紧急的事),再去完成愿景型OKR(重要非紧急的事)
技术人员不要陷入生产代码陷阱中,否则生产的只是代码而不是价值

管理心经

改变研发是"成本中心"思想定位

有句话对技术的定位很准确:在一个公司中,创业初期技术的重要性往往被高估,后面稳定长期发展时技术的重要性又往往被低估
很多不懂技术的人都会有:技术部门属于成本中心,销售部门才是利润中心,我们想要改变这个认知,可以做以下几个方面的努力:

  • 让其他部门的核心人员了解一个工程开发的基本流程
  • 让其他人了解到软件开发是一个很复杂的体系,是一个工程性要求极强的工作
  • 支撑技术部门各岗位之间的协作,不仅要求大家有较强的逻辑思维能力,还要有一系列的技术规范和研发流程
  • 利用向大家同步OKR来体现技术团队的价值,同时工程师也要懂得自己代码是如何变现的过程

向上管理的套路

向上管理的本质就是上级对你的期望,所以一定要弄清楚这一点,请看下面这个常见的例子

  • 场景一

  • 场景二:

  • 场景三:

如果选择回答一,那么可能会给团队埋下工作效率低,士气下降的祸根
如果选择回答二,将影响老板对你的信任,他可能会说:如何提高团队效率,是你们团队负责人的事
如果选择回答三,那么老板一般都会说,其它部门我会去问下,你这边先开始

那么如何利用OKR的思维来进行向上管理呢?

面对老板996强制加班要求,一般人内心都是拒绝和不认同的,我们希望带领的是一支能“打仗”的队伍,该拼的时候一定要能拼,而不是为了加班而去加班,做样子给别人看,而且阿里等企业的成功,那绝不是单纯靠长期加班而取得的,而且人家有完善的加班体系和福利制度作保障,如果只要求马儿一样跑,但却没有相同的草,那就是耍流氓

所以我们要先弄清楚老板的O:我明白您的意思,那么您期望达到的目标是什么呢?
老板答案无外乎:项目早点上线、提升大家工作效率、有耕更多的产出,肯定不会有老板说:我就是想大家工作时间长一点

那么此时我们归纳出老板的O了,例如:成为一家工作效率高的公司

同时我们表态:是的,我们应该不断提高我们的工作效率,我有个想法,希望你能给我些建议;既然要关注工作效率,那么我们就应该用某个指标科学合理的去衡量工作效率,一般称之为 人效,我们每个部门可以先根据自己人员情况,计算下当前的人效,然后定一个目标,逐步来提高对大家的要求。既然是一家互联网公司,利用数据驱动的方式也更能体现我们文化和价值

OKR和项目开发结合

  • OKR比较适合迭代式的项目开发
  • 每次迭代的O尽量少,迭代周期一般为二周
  • 技术、产品、业务达成OKR共识
  • 迭代的时候开发将产品需求或用户故事拆分为多个任务

活学活用

招聘与考核

  • 与HR一起制定招聘的OKR,设置明确的时间期限和关键路径

  • 和候选人一起制订试用期OKR,让新人明确自己的目标是什么

人才激励

作者朋友圈有效激励小调查:

  • 第一:金钱激励最有效,但时效短
  • 第二:个人成长
  • 第三:认同感
  • 第四:其他福利

我们来了解下激励四象限:

  • 物质激励:金钱、福利
  • 精神激励:成长、认同、期权、荣誉、参与、特权、贡献、工作
    仅用物质激励显然不够持久,只用精神激励显然也不够刺激,物质激励与精神激励相结合,才能产生更强烈、更持久的激励效果

激励的三要素:

  • 1 及时性:激励越及时,激励效果越好
  • 2 多样性:激励方式越多,使用上灵活性也更强
  • 3 差异性:激励要有差异,也要有个性

CRF敏捷绩效管理

  • C:Conversation(对话):员工找管理者1V1的聊天(员工找管理者吃饭,管理者买单,反之亦然)

    对话环节注意点:1 在整个对话过程中,要围绕员工的“个人 OKR”进行引导,并有针对性地进行辅导;2 自始至终,你都在扮演一位“教练”的角色,你的目的是让“队员”产出高绩效;3 需要打造出“双向辅导”的交流氛围,你帮他成为更好的员工,他帮你成为更好的管理者。

  • R:Feedback(反馈):双向反馈,可以根据实际情况,选择实名或匿名

    反馈环节注意点: 1 当每季度 OKR 自评完毕后,即可进入到 CFR 反馈环节;2 反馈要做到尽可能具体,要能聚焦在某件事情上;3 不要依据反馈的结果,调整奖金多少与加薪幅度

  • F:Recognition(认可):一种激励方式,多鼓励员工之间进行相互认可

    认可环节注意点:1 认可要及时;2 认可要全面,阳光普照;3 认可要真实,认可具体的事情

两种OKR类型,评分要求:

OKR易踩的坑

  • 将OKR用于绩效考核
  • 用KPI思维做OKR
  • O数量太多,不够聚焦
  • KR不够量化,无法评估
  • 将KR当成任务或待办清单
  • KR挑战不够或不切实际
  • 忽略OKR的上下平级对齐
  • 忽略OKR的更新于复盘
  • 忽略OKR的评分环节
  • 用OKR替代了KPI

OKR十诫

  • 1 OKR 不与绩效考核相关,不与奖金或薪资直接挂钩。
  • 2 OKR 必须向上对齐,一起努力实现组织目标。
  • 3 OKR 是动态的,需要根据实际情况来灵活调整。
  • 4 OKR 是公开透明的,每个人都能看到所有人的 OKR。
  • 5 OKR 需要具备一定挑战,是通过努力才能达成的。
  • 6 OKR 要有评分,评分不是越高越好,越高说明挑战越不够。
  • 7 OKR 目的在于让事情变得聚焦,在正确的轨道上做出努力。
  • 8 OKR 重在沟通,通过沟通达到目标的理解一致性。
  • 9 OKR 是工作法,也是管理方法,它能激励团队取得成功。
  • 10 OKR 需做出承诺并付出行动,它是思想,也是文化

Docker简明教程(13)

hzqiuxm阅读(547)评论(0)

Docker网络管理

网络基础基础

  • 科普下常见的两种网络分层结构类型

一个是7层一个是4层,4层是对7的一个归纳和抽象,标准的模型是7层的

  • 常用查看网卡命令: ls /sys/class/net / 或者 ip a

  • 关于docker的几个网卡的简单介绍
    执行命令ip a

默认创建的容器都是在docker0这个network中,可以创建自己的network,指定容器运行在特定的network中

namespaceh和veth-pair技术

veth pair :Virtual Ethernet Pair,是一个成对的端口(veth-pair的link),可以实现两个namespace之间通信

观察容器中veth-pair对

  • 创建两个tomcat容器
docker run -d --name tomcat01 -p 8081:8080 tomcat
docker run -d --name tomcat02 -p 8082:8080 tomcat

查看宿主机网络情况:ip a

从上图中我们发现生成了两个veth-pair的link,但是它们其实不是一对,应为二者明显数字不是连续的

我们再看下两个tomcat容器的网路情况:docker exec -it tomcat01 ip a

其中eth0@if6和eth0@if7是一对veth-pair,eth0@if8和eth0@if9是一对veth-pair;它们都通过docker0来进行通信
我们使用类似命令 docker exec -it tomcat1 ping 172.17.0.3可以在tomcat01和tomcat02之间互相ping通

其原理示意图如下:

自定义网络配置

  • docker默认的网络配置方式 docker network ls

桥接 bridge(默认):虚拟机和宿主机通过桥接方式直连物理网络
主机host:通过共享宿主机ip通信去通信,和宿主机的网络配置显示是一模一样的
none:和外界完全不同的服务,特殊环境下使用

  • 创建自己的network: docker network create my-network

查看docker链接的各容器网络情况:docker network inspect bridge

可以把其它容器加入到多个network中实现之间的通信
自定义的 network中容器间可以通过名字直接访问(自动添加了DNS记录),默认的docker0是只能通过ip地址访问
如果是默认的docker0的network想通过容器名字直接访问,就要通过--link显性的指定要链接的容器
但是--link在生产环境中很少去使用,更加提倡自定义自己的bridge式network去管理容器

所以单机docker的通信最佳方案就是自己创建network,通过容器名称访问

跨network通信

当我们把类似的应用加入到不同的network中时,它们之属于不同的网段,肯定是不能互相通信的
比如新增一个tomcat3,指定在我们刚才创建的network下:docker run -d --name tomcat03 --network my-network -p 8083:8080 tomcat

观察发现tomcat3的网段是在172.18.0.2,而之前的是172.17.0.*,二者是不同的, 通过 docker exec -it tomcat03 ping 172.17.0.3测试是ping不通的

我们观察my-network的情况,也发现了该network中也只有一个容器

要想让tomcat3和tomcat1能互通该怎么办呢?一个想法就是把tomcat1加入到my-network中来
执行命令 docker network connect my-network tomcat01

omcat01已经被加入到了my-network当中,分到的IP是172.18.0.3,这个是时候我们不仅可以通过IP进行ping通,甚至可以根据容器名称来进行,大大提升了开发的灵活性

跨主机通信

  • 简单的方案就是通过端口映射来解决,通过外网,依赖DNS
  • 另外一个是通过vxlan技术实现,组建成内网,内部地址不能冲突,底层技术是overlay
    大致示意图如下:


这块内容涉及到比较专业的网络知识,详情待到后续补充新的文章再细说了

参考文献

docker官方文档: https://docs.docker.com/

领域驱动架构篇

hzqiuxm阅读(2002)评论(0)

领域驱动架构篇—菱形对称架构

领域驱动设计中,对于架构风格有一个指导思想:不同的限界上下文,根据其领域模型和业务特征,可以选用不同的架构风格。
在传统的分层架构与领域驱动理念相结合的过程中,产生了多种架构风格:六边型架构、整洁架构、微服务架构等。本文是根据对对IT文艺工作者张逸老师的相关Chat阅读和思考整理而得

前言

菱形对称架构(Rhombic Symmetric Architecture) 主要针对领域层次的架构,借鉴了六边形架构、分层架构、整洁架构的知识,并结合了领域驱动设计的元模型,使其能够更好地运用到限界上下文的架构设计中。

架构演进之路

六边型架构

六边型架构的特点就是:在满足整洁架构思想的同时,更加关注内层与外层自己与外部资源之间的通信本质
对于外界每种类型都有一个适配器与之对应:消息适配器,REST适配器,SOAP适配器,持久化适配器
通过内外两个六边形的不同边界清晰地展现了了领域与技术的边界(蓝色是领域,灰色是技术)

  • 端口:我们可以把端口看成是具体的协议
  • 适配器:看成是具体的通信垂直技术,如:Servlet、REST、JPA
  • 应用:用例级别的业务逻辑,体现了一个业务场景
  • 领域模型:系统级别业务逻辑,多个服务和聚合构成一个用例

六边型架构中,端口是解耦的关键:入口隔离了外部请求和技术实现细节;出口隔离了数据持久化和外部访问设备

六边型架构的业务(预定机票)示例:

  • ReservationResource:订票请求发送给以 RESTful 契约定义的资源服务,它作为入口适配器,介于应用六边形与领域六边形的边界之内,在接收到以 JSON 格式传递的前端请求后,将其转换(反序列化)为入口端口ReservationAppService需要的请求对象
  • ReservationAppService:入口端口为应用服务,位于领域六边形的边界之上。当它在接收到入口适配器转换后的请求对象后,调用位于领域六边形边界内的领域服务TicketReservation
  • TicketReservation:订票的领域逻辑
  • ReservationRepository:出口端口为资源库,位于领域六边形的边界之上,定义为接口
  • ReservationRepositoryAdapter:真正访问数据库的逻辑则由介于应用六边形与领域六边形边界内的出口适配器,该实现访问了数据库,将端口发送过来的插入订票记录的请求转换为数据库能够接收的消息,执行插入操作

整洁架构

整洁架构的模型是一个类似内核模式的内外层结构,它具有以下特点:

  • 1 越靠近中心,层次越高
  • 2 由外到内:框架与驱动程序 --》接口适配器 --》应用级业务逻辑--》系统级业务逻辑
  • 3 源码中依赖关系必须指向同心圆内层,从外到内

我们可以在整洁架构上得到很多值得回味的设计思想:

  • 不同层次的组件其变化频率也不相同,引起变化的原因也不相同(符合高内聚低耦合设计思想)
  • 层次越靠内组件依赖越少,处于核心业务实体没有任何依赖(领域模型和技术完美解耦)
  • 层次越靠内组件与业务关系越密切,专属于特定领域的内容,很难形成通用的框架(领域构建出了壁垒)

在这个架构中,外层圆代表的是机制,内层圆代表的是策略;机制和具体的技术实现有关,容易受到外部环境变化;策略与业务有关,封装了最核心领域模型,最不容易受到外界环境变化的影响

六边形架构仅仅区分了内外边界,提炼了端口与适配器角色,并没有规划限界上下文内部各个层次与各个对象之间的关系;而整洁架构又过于通用,提炼的是企业系统架构设计的基本规则与主题。因此,当我们将六边形架构与整洁架构思想引入到领域驱动设计的限界上下文时,还需要引入分层架构给出更为细致的设计指导,即确定层、模块与角色构造型之间的关系。

经典分层架构

分层架构是运用最为广泛的架构模式,几乎每个软件系统都需要通过层(Layer)来隔离不同的关注点(Concern Point),以此应对不同需求的变化,使得这种变化可以独立进行。

  • 用户界面层:负责向用户展现信息和解释用户命令。包含web端UI界面、移动端UI界面、第三方服务等。
  • 应用层:很薄的一层,用来协调应用的活动,它不包含业务逻辑,它不保留业务对象的状态。在领域设计中,它其实是一个外观(Facade),一般是供其他界限上下文基础设置层中controller调用。
  • 领域层:本层包含领域的信息,是业务软件的核心所在。在这里保留业务对象的状态,对业务对象状态的持久化被委托给基础设置层。它包含领域设计中的:聚合、值对象、实体、服务、资源接口等。
  • 基础设置层:不要简单的理解为物理上的一层,它作为其他层的支撑库而存在。它提供了层间的通讯,实现对业务对象的持久化。一般包含:网络通讯、资源实现(数据库持久化实现)、异步消息服务、网关、controller控制等。

菱形对称架构

菱形对称架构融合了分层架构和六边型架构的思想

对六边型进行分层映射

  • 入口适配器:响应边界外客户端的请求,需要实现进程间通信以及消息的序列化和反序列化,这些功能皆与具体的通信技术有关,故而映射到基础设施层
  • 入口端口:负责协调外部客户端请求与内部应用程序之间的交互,恰好与应用层的协调能力相配,故而映射到应用层
  • 应用程序:承担了整个限界上下文的领域逻辑,包含了当前限界上下文的领域模型,毫无疑问,应该映射到领域层
  • 出口端口:作为一个抽象的接口,封装了对外部设备和数据库的访问,由于它会被应用程序调用,遵循整洁架构思想,也应该映射到领域层
  • 出口适配器:访问外部设备和数据库的真正实现,与具体的技术实现有关,映射到基础设施层

突破分层架构

分层架构仅仅是对限界上下文的逻辑划分,在编码实现时,逻辑层或许会以模块的形式表现,但是也可能将整个限界上下文作为一个模块,每个层不过是命名空间的差异,定义为模块内的一个包。不管是物理分离的模块,还是逻辑分离的包,只要能保证限界上下文在六边形边界的保护下能够维持内部结构的清晰,就能降低架构腐蚀的风险。

依据整洁架构遵循的“稳定依赖原则”,领域层不能依赖于外层。因此,出口端口只能放在领域层。事实上,领域驱动设计也是如此要求的,它在领域模型中定义了资源库(Repository),用于管理聚合的生命周期,同时,它也将作为抽象的访问外部数据库的出口端口。

将资源库放在领域层确有论据佐证,毕竟,在抹掉数据库技术的实现细节后,资源库的接口方法就是对聚合领域模型对象的管理,包括查询、修改、增加与删除行为,这些行为也可视为领域逻辑的一部分。

然而,限界上下文可能不仅限于访问数据库,还可能访问同样属于外部设备的文件、网络与消息队列。为了隔离领域模型与外部设备,同样需要为它们定义抽象的出口端口,这些出口端口该放在哪里呢?如果依然放在领域层,就很难自圆其说。例如,出口端口EventPublisher支持将事件消息发布到消息队列,要将这样的接口放在领域层,就显得不伦不类了。倘若不放在位于内部核心的领域层,就只能放在领域层外部,这又违背了整洁架构思想。

既然出口端口的位置如此尴尬,而且很明显出和入不太对称,所以我们干脆就把出和入对称下,将端口和适配器统一掉,组合成“网关”

上面的对称架构虽脱胎于六边形架构与领域驱动设计分层架构,却又有别于二者。
对称架构北向网关定义的远程网关与本地网关同时承担了端口与适配器的职责,这实际上改变了六边形架构端口-适配器的风格;领域层与南北网关层的内外分层结构,以及南向网关规定的端口与适配器的分离,又与领域驱动设计的分层架构渐行渐远。

既然已经改变,就根据思想,重新抽象下架构图

就得到了菱形对称架构,主要体现了南北网关的对称关系

菱形对称架构的组成

菱形架构其上下文的组成:

  • 北向网关的远程网关:进程间通信
  • 北向网关的本地网关:进程内通信
  • 领域层的领域模型:领域逻辑
  • 南向网关的端口抽象:各种资源库操作的抽象借接口,可以被领域层依赖,
  • 南向网关的适配器实现:网关端口的实现,运行时通过依赖注入将适配器实现注入到领域层

以六边型中的业务示例为例,改成菱形架构的话,其架构如下:

引入上下文映射

北向网关演变

北向网关和开放式主机服务很类似,但是职能更多,相当于开放式主机层,包含了远程服务和本地服务

  • 远程服务:跨进程通信;包含资源(Resource)服务、供应者(Provider)服务、控制器(Controller)服务与事件订阅者(Event Subscriber)服务
  • 本地服务:进程内通信;对应于应用层的应用服务

当外部请求从远程服务进入时,如果需要调用领域层的领域逻辑,则必须经由本地服务发起对领域层的请求。此时的本地服务又扮演了端口的作用,可认为远程服务是本地服务的客户端。

南向网关演变

南向网关引入了抽象的端口来隔离内部领域模型对外部环境的访问,这一价值很等同于上下文映射中的防腐层,同样它也扩大了防腐层的功能
南向网关的端口分为:

  • 资源库(repository)端口:隔离对外部数据库的访问,对应的适配器提供聚合的持久化能力
  • 客户端(client)端口:隔离对上游限界上下文或第三方服务的访问,对应的适配器提供对服务的调用能力
  • 事件发布者(event publisher)端口:隔离对外部消息队列的访问,对应的适配器提供发布事件消息的能力

改进后的菱形对称架构

菱形对称架构去掉了应用层和基础设施层的概念,以统一的网关层进行概括,并以北向与南向分别体现了来自不同方向的请求。如此形成的对称结构突出了领域模型的核心作用,更加清晰地体现了业务逻辑、技术功能与外部环境之间的边界。

资源库视为防腐层的端口与适配器,作为领域建模时的角色构造型,与场景驱动设计更好地结合,增强了领域模型的稳定性。应用层被去掉之后,被弱化为开放主机服务层的本地服务,相当于从设计层面回归到服务外观的本质,也有助于解决应用服务与领域服务之间的概念之争。

代码模型示例:

ohs 为开放主机服务模式的缩写,acl 是防腐层模式的缩写,pl 代表了发布语言;也可以使用北向(northbound)与南向(sourthbound)取代 ohs 与 acl 作为包名,使用消息(messages)契约取代 pl 的包名。

菱形对称架构价值体现

当我们为限界上下文引入菱形对称架构之后,一方面可以更加清晰地展现上下文映射模式之间的差异,并凸显了防腐层与开放主机服务的重要性;另一方面,遵循菱形对称架构的领域驱动架构亦具有更好的响应变化的能力。

展现上下文映射

以“查询订单”为例,若需求要求查询返回的订单需要包含商品的信息,这时可能产生订单上下文的订单与商品上下文的商品之间的“模型依赖”。

遵奉者或共享内核模式

一种方式是让订单直接重用商品上下文的领域模型,即采用遵奉者(Conformist) 模式或共享内核(Shared Kernel) 模式

重用领域模型的方式会突破菱形对称架构北向网关修筑的堡垒,让商品上下文的领域模型直接暴露在外

防腐层和开放式主机结合

如果订单上下文与商品上下文处于同一进程,根据菱形对称架构的定义,位于下游的订单上下文可以通过其南向网关发起对商品上下文北向网关中本地服务的调用。
为了保护领域模型,商品上下文在北向网关中还定义了消息契约模型

此时的菱形对称架构体现了防腐层模式与开放主机服务模式共同协作,位于商品上下文北向网关的本地服务还定义了消息契约,从而产生了消息契约模型之间的依赖。

如果订单上下文与商品上下文位于不同的进程,两个限界上下文之间的模型依赖就不存在了,我们需要各自为其定义属于自己的模型对象。

上图所示的订单上下文与商品上下文仅存在上下游调用关系,通过南向网关的防腐层与北向网关的开放主机服务亦降低了彼此的耦合;然而,在各自边界内定义的ProductResponse之间,却隐隐包含了逻辑概念的映射关系,它会带来变化的级联反应(一边改动后另一边也要改动)。

分离方式

若真正体会了限界上下文作为知识语境的业务边界特征,就可以将订单包含的商品信息视为订单上下文的领域模型,隐含的统一语言为“已购商品(Purchased Product)”,它与商品上下文的商品属于不同的领域概念,处于不同的业务边界,然后共享同一个productId

当用户只看订单及商品基本信息时,无需求助商品上下文,若要获取更多的商品信息时,可以通过productId向商品上下文发起请求,此时也和订单上下文无关,做到很好的隔离

这种方式真正展现了限界上下文的价值,即对领域模型的控制力。当限界上下文针对业务关注点进行垂直切割时,不仅要从语义相关性划分领域概念,还要考虑这些概念之间存在的功能相关性。

例如,商品与商品上下文是语义强相关的,但在查询订单这一领域场景中,获得订单时随之返回对应的商品信息,却是功能相关性发挥着作用。它也充分体现了“最小完备”的自治特性,因为在查询订单时,如果不为订单项提供对应的商品信息,该限界上下文就是不完备的。

当我们在定义领域模型时,如果一些领域概念出现矛盾或冲突时,就是引入限界上下文维护概念一致性的时机,也是统一语言发挥作用的时候。

响应变化的能力

限界上下文之间产生协作时,通过菱形对称架构可以更好地响应协作关系的变化。

客户方-供应方模式

通常说来,这种协作模式就是典型的客户方-供应方(Customer-Supplier) 模式,参与协作的角色包括

  • 下游限界上下文:防腐层的客户端端口(acl.ports.Client)作为适配上游服务的接口,客户端适配器(acl.adapters.ClientAdapter)封装对上游服务的调用逻辑
  • 上游限界上下文:开放主机服务的远程服务(ohs.remote.Resource)与本地服务(ohs.local.AppService)为下游限界上下文提供具有业务价值的服务

客户端适配器到底该调用上游限界上下文的远程服务还是本地服务,取决于这两个限界上下文的通信边界
如果上下游的限界上下文位于同一个进程边界内,客户端适配器可以直接调用本地服务。

如果上下游的限界上下文处于不同的进程边界,就由远程服务来响应下游客户端适配器发起的调用。

发布者和订阅者模式

采用这种模式时,限界上下文之间的耦合主要来自对事件的定义。作为事件发布者的限界上下文可以不用知道有哪些事件的订阅者,反之亦然,彼此之间的解耦往往通过引入事件总线(可以是本地的事件总线,也可以是单独进程的事件总线)来保证

在限界上下文内部,同样需要隔离领域模型与事件通信机制,这一工作由菱形对称架构网关层中的设计元素来完成。事件的发布者位于防腐层,发布者端口(acl.ports.EventPublisher)提供抽象定义,发布者适配器(acl.adapters.EventPublisherAdapter)负责将事件发布给事件总线;事件的订阅者(ohs.remote.EventSubscriber)属于开放主机服务层的远程服务,在订阅到事件之后,交由本地服务(ohs.local.ApplicationService)来处理事件。

应用事件:

领域事件:

一个订单提交的示例

业务场景如下:

  • 客户提交订单,向订单上下文发送提交订单的客户端请求
  • 订单上下文向库存上下文发送检查库存量的客户端请求
  • 库存上下文查询库存数据库,返回库存信息
  • 若库存量符合订单需求,则订单上下文访问订单数据库,插入订单数据
  • 订单上下文调用库存上下文的锁定库存量服务,对库存量进行锁定
  • 提交订单成功后,发布应用事件,并发布到事件总线
  • 通知上下文订阅应用事件,调用客户上下文获得该订单的客户信息,组装通知内容
  • 通知上下文调用短信服务,发送短信通知客户

识别的上下文如下:

菱形调用关系如下:

订单上下文内部

客户提交订单通过前端 UI 向订单上下文远程服务OrderController提交请求,然后将请求委派给本地服务OrderAppService

远程服务与本地服务使用的消息契约模型定义在ohs.local.pl包中,如此即可同时支持两种开放主机服务,这些消息契约模型都定义了如to()和from之类的转换方法,用于消息契约模型与领域模型之间的互相转换。

订单上下文与库存上下文协作

本地服务OrderAppService收到PlacingOrderRequest请求后,会将该请求对象转换为Order领域对象,然后通过领域服务OrderService提交订单。

提交订单时,首先需要验证订单有效,然后再检查库存量。验证订单的有效性由领域模型对象Order聚合根承担,库存量的检查则需要通过端口InventoryClient,并由注入的适配器InventoryClientAdapter发起向库存上下文远程服务InventoryResource的调用

位于南向网关的客户端端口InventoryClient及其适配器实现需要调用与之对应的消息契约模型,如CheckingInventoryRequest和InventoryReviewResponse。

根据前面的分析,如果上下游的限界上下文都处于同一个进程中,则下游上下文的南向网关可以重用上游上下文的消息契约。既然这里假定各个限界上下文都处于不同进程,就需要自行定义消息契约了

库存上下文协作

InventoryResource又会通过库存上下文的本地服务InventoryAppService调用领域服务InventoryService,然后经由端口InventoryRepository与适配器InventoryRepositoryAdapter访问库存数据库,获得库存量的检查结果

订单上下文发布应用事件

领域服务OrderService在确认了库存量满足订单需求后,通过端口OrderRepository以及适配器OrderRepositoryAdapter访问订单数据库,插入订单数据。

一旦订单插入成功,订单上下文的领域服务OrderService还要调用库存上下文的远程服务InventoryResource锁定库存量。订单上下文的本地服务OrderAppService会在OrderService成功提交订单之后,组装OrderPlacedEvent应用事件,并调用端口EventPublisher,由适配器EventPulbisherAdapter将该事件发布到事件总线

通知上下文订阅事件

通知上下文的远程服务EventSubsriber订阅了OrderPlacedEvent事件。一旦接收到该事件,就会通过本地服务NotificationAppService调用其领域层中的NotificationService领域服务

通知上下文和客户上下文协作

NotificationService领域服务会调用端口CustomerClient,经由适配器CustomerClientAdapter向客户上下文的远程服务CustomerResource发送调用请求。

在客户上下文内部,由北向南,依次通过远程服务CustomerResource、本地服务CustomerAppService、领域服务CustomerService和南向网关的端口CustomerRepository与适配器CustomerRepositoryClient完成对客户信息的查询,返回调用者需要的信息。

通知上下文的领域服务NotificationService在收到该响应消息后,组装领域对象Notification,再通过本地的端口SmsClient与适配器SmsClientAdapter,调用短信服务发送通知短信

小结

显然,若每个限界上下文都采用菱形对称架构,则代码结构是非常清晰的,各个层各个模块各个类都有着各自的职责,泾渭分明,共同协作。同时,网关层对领域层的保护是不遗余力的,没有让任何领域模型对象泄露在限界上下文边界之外,唯一带来的成本就是可能存在重复定义的消息契约对象,以及对应的转换逻辑实现。

欢迎加入极客江湖

进入江湖关于作者