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

SpringDataJPA系列(6)

hzqiuxm阅读(53)评论(0)

6 Entiry注解使用

JPA协议规定

  • 实体是直接进行数据库持久化操作的领域对象,必须通过 @Entity 注解进行标示
  • 实体必须有一个 public 或者 protected 的无参数构造方法
  • 实体里面必须要有一个主键,主键标示的字段可以是单个字段,也可以是复合主键字段
  • 持久化映射的注解可以标示在 Entity 的字段 field 上,也可以将持久化注解运用在 Entity 里面的 get/set 方法上
//字段上
@Column(length = 20, nullable = false)
private String userName;
//get/set上
@Column(length = 20, nullable = false)
public String getUserName(){
    return userName;
}

详细的协议地址:https://download.oracle.com/otn-pub/jcp/persistence-2_2-mrel-spec/JavaPersistence.pdf

Entiry注解

有哪些Entity注解,可以打开@Entity注解所在的包一窥究竟:

差不多有100多个注解......
这里只提及一些最常见的,包括 @Entity、@Table、@Access、@Id、@GeneratedValue、@Enumerated、@Basic、@Column、@Transient、@Lob、@Temporal 等。

  • @Entity:定义对象将会成为被 JPA 管理的实体,必填,将字段映射到指定的数据库表中,使用起来很简单,直接用在实体类上面即可
  • @Table:指定数据库的表名,表示此实体对应的数据库里面的表名,非必填,默认表名和 entity 名字一样
  • @Access:指定 entity 里面的注解是写在字段上面,还是 get/set 方法上面生效,非必填。当实体里面的第一个注解出现在字段上或者 get/set 方法上面,就以第一次出现的方式为准
  • @Id:定义属性为数据库的主键,一个实体里面必须有一个主键,但不一定是这个注解,可以和 @GeneratedValue 配合使用或成对出现
  • @GeneratedValue:主键生成策略,共有四个值

  • @Enumerated:这个注解很好用,因为它对 enum 提供了下标和 name 两种方式,用法直接映射在 enum 枚举类型的字段上

//有一个枚举类,用户的性别
public enum Gender {
    MAIL("男性"), FMAIL("女性");
    private String value;
    private Gender(String value) {
        this.value = value;
    }
}
//实体类@Enumerated的写法如下
@Entity
@Table(name = "tb_user")
public class User implements Serializable {
    @Enumerated(EnumType.STRING)
    @Column(name = "user_gender")
    private Gender gender;
    .......................
}

这时候插入两条数据,数据库里面的值会变成 MAIL/FMAIL,而不是“男性” / 女性。

  • @Basic:表示属性是到数据库表的字段的映射。如果实体的字段上没有任何注解,默认即为 @Basic。也就是说默认所有的字段肯定是和数据库进行映射的,并且默认为 Eager 类型
  • @Transient:该属性并非一个到数据库表的字段的映射,表示非持久化属性
  • @Column:定义该属性对应数据库中的列名
  • @Temporal:设置 Date 类型的属性映射到对应精度的字段(日期、时间、日期时间)

注解生成技巧

生成的结果示例如下:

联合主键

@IdClass 做联合主键

可以通过 javax.persistence.EmbeddedId 和 javax.persistence.IdClass 两个注解实现联合主键的效果。
第一步:新建一个 UserInfoID 类里面是联合主键。

public class UserInfoID implements Serializable {
   private String name,telephone;
}

第二步:再新建一个 UserInfo 的实体,采用 @IdClass 引用联合主键类。

@IdClass(UserInfoID.class)
public class UserInfo {
   private Integer ages;
   @Id
   private String name;
   @Id
   private String telephone;
}

使用示例:

 userInfoRepository.save(UserInfo.builder().ages(1).name("jack").telephone("123456789").build());
Optional<UserInfo> userInfo = userInfoRepository.findById(UserInfoID.builder().name("jack").telephone("123456789").build());

资源库仍然按照标准DQM方式进行名称查询,实际上表的主键是 primary key (name, telephone),而 Entity 里面不再是一个 @Id 字段了。

@Embeddable 和@EmbedId

第一步:在我们上面例子中的 UserInfoID 里面添加 @Embeddable 注解。

@Embeddable
public class UserInfoID implements Serializable {
   private String name,telephone;
}

第二步:改一下我们刚才的 User 对象,删除 @IdClass,添加 @EmbeddedId 注解:

public class UserInfo {
   private Integer ages;
   @EmbeddedId
   private UserInfoID userInfoID;
   @Column(unique = true)
   private String uniqueNumber;
}

使用情况和上面@IdClass 的类似,那么 @IdClass 和 @EmbeddedId 的区别是什么?在使用的时候,Embedded 用的是对象,而 IdClass 用的是具体的某一个字段,二者的JPQL 也会不一样。

继承关系的实现

在 Java 面向对象的语言环境中,@Entity 之间的关系多种多样,而根据 JPA 的规范,我们大致可以将其分为以下几种:

  • 纯粹的继承,和表没关系,对象之间的字段共享。利用注解 @MappedSuperclass,协议规定父类不能是 @Entity

  • 单表多态问题,同一张 Table,表示了不同的对象,通过一个字段来进行区分。利用@Inheritance(strategy = InheritanceType.SINGLE_TABLE)注解完成,只有父类有 @Table

  • 多表多态,每一个子类一张表,父类的表拥有所有公用字段。通过@Inheritance(strategy = InheritanceType.JOINED)注解完成,父类和子类都是表,有公用的字段在父表里面

  • Object 的继承,数据库里面每一张表是分开的,相互独立不受影响。通过@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)注解完成,父类(可以是一张表,也可以不是)和子类都是表,相互之间没有关系。

@Inheritance 的这种使用方式会逐渐被淘汰,因为这样的表的设计很复杂,本应该在业务层面做的事情(多态),而在 datasoure 的表级别做了。所以在 JPA 中使用这个的时候你就会想:“这么复杂的东西,我直接用 Mybatis 算了。”其实它们是一样的,只是我们使用的思路不对。

我个人建议第一种情况,项目中会经常碰到,其它三种除非是老项目中维护需要,不建议如此使用了

SpringDataJPA系列(5)

hzqiuxm阅读(145)评论(0)

@Query应该怎么用?

之前说到过,DMQ查询策略有两种:方法命令和@Query注解的方式。为什么需要两种呢?它们分别适用的场景是怎么样的?

@Query使用

定义一个通过名字查询用户的方法

以下是测试方法:


QueryLookupStrategy 实现原理

我们可以通过QueryExecutorMethodInterceptor类来进行跟踪和分析,它是查询方法的拦截器,我们在lookupQuery()方法中打个断点。
可以看到显示默认的策略是CreateIfNotFound,也就是如果有@Query注解,那么以@Query的注解内容为准,可以忽略方法名方式。

我们可以看到strategy.resolveQuery采用了策略模式,它有三种实现策略:

我们可以看到在解析查询的时候,还有个容错机制,出错后还会采用一次方法名个识别方式进行sql语句的拼接

那么接着进入到 llookupStratrgy.resolveQuery 方法里面,我们可以看到图中 ①处,如果 Query 注解找到了,就不会走到 ② 处了。

这时我们点开 Query 里面的 Query 属性的值看一下,你会发现这里同时生成了两个 SQL:一个是查询总数的 Query 定义,另一个是查询结果 Query 定义。

到这里我们已经基本明白了,如果想看看 Query 具体是怎么生成的、上面的 @Param 注解是怎么生效的,可以在上面的图 ① 处 debug 继续往里面看

PS:这里要注意,在Spring启动过的时候,JPA会对资源库的每个方法都进行扫描,然后进行具体查询器RepositoryQuery的选择。

下图是关于RepositoryQuery接口相关类图:

@Query用法和语法

基本语法

@Query 用法是使用 JPQL 为实体创建声明式查询方法。我们一般只需要关心 @Query 里面的 value 和 nativeQuery、countQuery 的值即可,因为其他的不常用。

  • value:JPQL表达式
  • nativeQuery:JPQL是否是原生的Sql语句
  • countQuery :指定count的JPQL语句,如果不指定将根据query自动生成

使用声明式 JPQL 查询有个好处,就是启动的时候就知道你的语法正确不正确。它的语法结构有点类似我们 SQL:

//查询
SELECT ... FROM ...
[WHERE ...]
[GROUP BY ... [HAVING ...]]
[ORDER BY ...]
//删除
DELETE FROM ... [WHERE ...]
//更新
UPDATE ... SET ... [WHERE ...]

你会发现它的语法结构有点类似我们 SQL,唯一的区别就是 JPQL FROM 后面跟的是对象,而 SQL 里面的字段对应的是对象里面的属性字段

其中“...”省略的部分是实体对象名字和实体对象里面的字段名字,而其中类似 SQL 一样包含的语法关键字有:

SELECT FROM WHERE UPDATE DELETE JOIN OUTER INNER LEFT GROUP BY HAVING FETCH DISTINCT OBJECT NULL TRUE FALSE NOT AND OR BETWEEN LIKE IN AS UNKNOWN EMPTY MEMBER OF IS AVG MAX MIN SUM COUNT ORDER BY ASC DESC MOD UPPER LOWER TRIM POSITION CHARACTER_LENGTH CHAR_LENGTH BIT_LENGTH CURRENT_TIME CURRENT_DATE CURRENT_TIMESTAMP NEW EXISTS ALL ANY SOME

用法案例

  • 单条件查询
  @Query("select u from User u where u.emailAddress = ?1")
  User findByEmailAddress(String emailAddress);
  • LIKE查询
  @Query("select u from User u where u.firstname like %?1")
  List<User> findByFirstnameEndsWith(String firstname);
  • 原始sql查询,nativeQuery = true 即可,注意nativeQuery 不支持直接 Sort 的参数查询
  @Query(value = "SELECT * FROM USERS WHERE EMAIL_ADDRESS = ?1", nativeQuery = true)
  User findByEmailAddress(String emailAddress);
  • nativeQuery 排序的正确写法
@Query(value = "select * from user_info where first_name=?1 order by ?2",nativeQuery = true)
List<UserInfoEntity> findByFirstName(String firstName,String sort);
//调用的地方写法last_name是数据里面的字段名,不是对象的字段名
repository.findByFirstName("jackzhang","last_name");
  • JPQL排序
  @Query("select u from User u where u.lastname like ?1%")
  List<User> findByAndSort(String lastname, Sort sort);
  @Query("select u.id, LENGTH(u.firstname) as fn_len from User u where u.lastname like ?1%")
  List<Object[]> findByAsArrayAndSort(String lastname, Sort sort);

//调用方的写法,如下:
repo.findByAndSort("lannister", new Sort("firstname"));
repo.findByAndSort("stark", new Sort("LENGTH(firstname)"));
repo.findByAndSort("targaryen", JpaSort.unsafe("LENGTH(firstname)"));
repo.findByAsArrayAndSort("bolton", new Sort("fn_len"));
  • JQPl 的排序
  @Query(value = "select u from User u where u.lastname = ?1")
  Page<User> findByLastname(String lastname, Pageable pageable);
//调用者的写法
repository.findByFirstName("jackzhang",new PageRequest(1,10));
  • nativeQuery 的排序
   @Query(value = "select * from user_info where first_name=?1 /* #pageable# */",
         countQuery = "select count(*) from user_info where first_name=?1",
         nativeQuery = true)
   Page<UserInfoEntity> findByFirstName(String firstName, Pageable pageable);
}
//调用者的写法
return userRepository.findByFirstName("jackzhang",new PageRequest(1,10, Sort.Direction.DESC,"last_name"));
//打印出来的sql
select  *   from  user_info  where  first_name=? /* #pageable# */  order by  last_name desc limit ?, ?

这里需要注意:这个注释 / #pageable# / 必须有。

  • 根据 firstname 和 lastname 参数查询 user 对象
  @Query("select u from User u where u.firstname = :firstname or u.lastname = :lastname")
  User findByLastnameOrFirstname(@Param("lastname") String lastname,  @Param("firstname") String firstname);
  • 根据 firstname 和 lastname 参数查询 user 对象,并带上限制返回
  @Query("select u from User u where u.firstname = :firstname or u.lastname = :lastname")
  User findTop10ByLastnameOrFirstname(@Param("lastname") String lastname, @Param("firstname") String firstname);

@Param 注解指定方法参数的具体名称,通过绑定的参数名字指定查询条件,这样不需要关心参数的顺序。比较推荐这种做法,因为它比较利于代码重构

@Query最佳实践

使用场景:映射返回指定过的DTO
新增一个实体表

原来的用户表

当我们需要查询用户的名称、部队、主帅技能时应该如何操作?

  • 小白写法,查询获得的对象后再塞到DTO中
   @Query("select u.name,u.email,e.idCard from User u,UserExtend e where u.id= e.userId and u.id=:id")
   List<Object[]> findByUserId(@Param("id") Long id);
  • 进阶写法:定义个返回dto,@Query中构建返回dto直接返回

查询方法的实现,注意红色标注部分是实现关键

下面是测试代码:

注意:我们在构建返回的时候还可以使用CONCAT 的关键字做了一个字符串拼接,这对一些统一的返回处理还是有好处的,但不建议太复杂的计算。

我们可以在ParameterizedFunctionExpression 类中看到支持的关键字

  • 高阶写法:定义一个返回接口,@Query中构建返回dto直接返回

@Query的查询写法如下:

测试方法如下:

比起 DTO 我们不需要 new 了,并且接口只能读,那么我们返回的结果 DTO 的职责就更单一了,只用来查询。接口的方式是比较推荐的做法,因为它是只读的,对构造方法没有要求,返回的实际是 HashMap。

@Query动态查询

通过上面的实例可以看得出来,我们采用了 :email isnullor s.email = :email 这种方式来实现动态查询的效果,实际工作中也可以演变得很复杂。

总结

  • 能用方法名表示的,尽量用方法名表示,因为这样语义清晰、简单快速,基本上只要编译通过,一定不会有问题
  • 能用 @Query 里面的 JPQL 表示的,就用 JPQL,这样与 SQL 无关,万一哪天换数据库了,基本上代码不用改变
  • 最后实在没有办法了,可以选择 nativeQuery 写原始 SQL,特别是一开始从 MyBatis 转过来的同学,选择写 SQL 会更容易一些

人工智能系列(1)

hzqiuxm阅读(182)评论(0)

深度学习模型概述

深度学习特征

机器学习VS深度学习

  • 机器学习:低功率,简单模型
  • 深度学习:高功率,复杂模型

在拥有强大的处理能力之前,训练高功率模型将需要很长的时间;在拥有大量的数据之前,训练高功率模型会导致过度拟合问题。二者一些区别主要体现如下:

  • 数据依赖:深度学习需要大量数据,否则容易过度拟合
  • 硬件依赖:存在大量矩阵运算,对GPU依赖高
  • 执行时间:深度学习参数很多,需要更多时间
  • 领域知识依赖:机器学习障碍主要是特征工程步骤,需要领域专家和很多领域知识人工手动识别和标记特征;深度学习尝试从数据中直接获取更高等级的特征,减少对每个问题设计和构造特征的工作
  • 问题解决模式:传统会拆分为子问题,再合并;深度学习更加强调端到端问题解决
  • 可解释性:传统机器学习一般会给出很清楚的解释说明(决策树,线性/逻辑回归),但深度学习不会清楚告诉你神经网路协同具体是如何工作的,结果是如何一步步产生的

神经网络的学习任务

一般业界会把深度学习和神经网络作为同一个概念进行表达
神经网络学习任务分类和机器学习类似:

  • 监督学习:分类问题和回归问题
  • 非监督学习
  • 强化学习

【图形讲解】

监督学习回归预测:年龄和身高

黑点是样本点,三种颜色表示:预测拟合情况
红色:年龄无限接近1的时候是没有身高的,当年龄增大,会有一定波动,大部分样本点还是在红色线上的
蓝色:百分百拟合,所有样本点都在蓝色线上
绿色:最平滑,大约只有一半样本点在线上,但时不在线上的样本点,差距不大

结论:绿色的更加客观,泛化能力更强
新样本在预测模型的好坏程度,我们称之为:泛化能力

什么是神经网络

  • 神经元:承担计算的基本单元

每一个独立的神经元进行一个简单的函数运算
神经元的互相关系和连接促进了大脑的复杂功能

【神经元解构】

  • 树突:接收信号
  • 细胞体:运算主题
  • 轴突:连接其它神经元树突,传递信息

【实例解构神经网络】
想象你是一名银行职员,现在需要预测每个客户次年的交易额
首先这是一个回归任务

机器学习做法,用一个传统机器学习模型去拟合,学习样本特征

神经网络的做法,神经元之间计算传递信息


实际上整个神经网络会分多层(也不是越多越好),某个神经元计算结果会传递给下一层中每个神经元
随着层数和神经元个数越多,计算越复杂

搭建神经网络

常见工具包封装了常用的神经网络算法,我们只要调用它们提供的API,通过几行代码就能实现很复杂的神经网络构建

  • Tensorflow(google):初期是面向工程师的,随着迭代,易用性逐渐提高
  • PyTorch(facebook):与Tensorflow也越来越像,社群和资源二者都差不多
  • K:前端API,需要后端支持,比如Tensorflow,无法单独使用

这些工具框架,熟悉一个就行了,搭建神经网络的理论方法:

  • 选择使用合适工具包
  • 构造方式定义好三层:
    > 1 输入层:接收样本特征,一般个数和样本特征数一致
    2 隐藏层:除去输入和输出所有中间层,一般由工具构造产生
    3 输出层:分类任务的话,输出就等于总的标签数量;回归任务的话,一般只有一个输出

全连接神经网络:某神经元会和下一层所有神经元联接
卷积神经网络:计算和联结方式会有少许的不同
对于使用工具包的我们来说,只是调用不同的函数而已

深度学习框架

PyTorch介绍

为什么使用PyTorch(https://pytorch.org/)

  • 易用,所见即所得,动态计算
  • 与NumPy很相似,可以直接移植
  • 强大便利的GPU支持
  • 便捷的自动求导功能,对深度学习训练帮助很大

PyTorch基本操作

  • 创建张量(PyTorch中基础运算单位)

2X2的张量,0-1的均匀分布

张量拥有形状、数据类型属性

【矩阵乘法知识】

  • 矩阵操作:矩阵相乘方法:matmul

PyTorch的自动求导

在一维函数里求导,我们称之为导数;在多维函数里求导,我们称之为梯度

  • 自动求梯度
    通过randn创建了一个标准正态分布的张量,requires_grad表示是否要求梯度,会自动开启自动求梯度功能

    发现用户自己创建的Tensor,grad_fn梯度函数是None,这个函数表示某个变量的梯度是通过这个函数来求解的

    梯度传播性,x变量产出y也是有梯度的

    z代表的是一个乘法运算梯度函数,mean表示均值计算,这些基本加减乘除梯度算法,在PyTorch里都是固定写好的

  • 梯度计算(实际计算)

out.backward相当于对out进行求导,out涉及中间变量都会参与梯度计算,所以我们可以打印出x的梯度计算结果
这个过程称之为链式求导

  • 链式求导(梯度计算底层原理)

    out对x求导就是y,也就是x+2,最后结构符合预期

基于PyTorch的网络构建

自己构建三层做法示例:

使用PyTorch做法示例:利用new network(nn)
定义神经网络阶段

实际使用阶段

input_layer会直接作为参数调用forward函数

搭建神经网络

卷积层

  • 卷积 convolution
    计算机视觉领域,数据输入格式:B(图片数量) x C(通道数) x W(宽度) x H(高度),称作BCWH格式

    原始的5x5图片,提取特征值过程


这个filter称作卷积核,维度可以自己去设置
filter就是一个算子,传统机器学习中算子一般是人为设定的,在卷积神经网络里,算子一般是可变的,是学习出来的

卷积计算过程,就是使用算子覆盖原始图片,进行矩阵乘法计算出结果的过程


上面这个维度的滑动被称作WH维度,除此之外我们还要进行C(通道)维度的卷积计算
C维度卷积计算,此处C我们选取的是RGB颜色,值为3

W0是三个卷积核,对3个通道进行卷积核计算,得到一组值
W1也是三个卷积核,对3个通道进行卷积核计算,得到一组值
灰色叫做pading,补白扩大尺度,保证输入和输出是相同的
O:output,表示输出尺寸大小
I:input:表示输入尺寸大小
P:pading,补白尺度
K:卷积核
S:步长,滑动时移动的格数

转置卷积


输入是4x4的,卷积核是3x3的,计算之后得到一个2x2的正常卷积
将结果改成矩阵形式,将4x4的拉伸成16x1的矩阵


将3x3的卷积核变成4x16的稀疏矩阵

将稀疏矩阵和拉伸后的矩阵相乘计算,得到4x1的矩阵

对稀疏矩阵和原始输入拉伸矩阵进行转置,然后进行相乘(卷积计算),得到16x1矩阵

转置卷积,有的地方又叫反卷积,但是其实并不完全准确,数值上请其实并无法恢复

池化层

池化层和卷积层很类似,但是它的操作更加简单,它没有任何参数需要去学习

  • 平均池化:对kernel窗口里区域求平均值,只对当前通道做操作

  • 最大池化:对kernel窗口里区域取最大值,这个特性神经网络中还是比较有用的

  • 全局池化 :kernel大小对与整个数据块的大小
    全局平均池化 GAP

全局最大池化GMP

池化层作用和特点

  • 无参数,运算速度快
  • 下采样(不做参数运算),降低特征图大小,减小计算量
  • 最大池化具有一定的非线性
  • 变相扩大感受野
  • 不变性(平移、旋转、尺度),不能绝对保证

激活函数

sigmoid

是神经网络中很重要的一层,常用有:

  • SIgmoid:优雅曲线,存在梯度消失问题
  • ReLU:存在死亡区域和非零均值问题
  • Leaky ReLU /PReLU:非饱和
  • ELU:整体最优雅,但有额外的计算量

    sigmoid激活函数,表示一种概率,一般会放在最后一层,它的特点:

  • 非线性(拟合函数本身复杂,肯定不是线性能够表示的)

  • 双侧饱和(正负无穷有趋向的固定值)
  • 梯度消失(曲线斜率趋向于0,有一定风险)
ReLU

修正线性单元(ReLU):大于0去本身,小于0变为0

  • 非线性
  • 单测饱和:具有开关特性;特征选择(去掉不要特征参数);抑制噪声(噪声是高频信息)
  • 梯度0、1
  • 非零均值(数据分布是偏向正半轴的,但神经网络一般都是希望平均分布的)

    开关的作用未必能起到实际作用,是需要不断学习的,如果一开始参数设置错误,可能参数就失效了
PReLU

Leak ReLU/Patametrized ReLU(PReLU):改进ReLU死亡区域问题

  • 非线性
  • 无饱和性,无法做到完全关闭某些特征
  • 梯度:a和1,参数需要做实验(参数学习)去做选择
  • 接近零均值
ELU

ELU:优化了饱和性

  • 非线性
  • 单侧饱和
  • 梯度ae^x,1,有了梯度一些参数特征学习过程中还有机会调整回来
  • 接近零均值

    特点看起来很好,但是计算量是最大的,还是要根据具体实验来定,毕竟神经网络是经验型科学,常用的还是ReLU模型

激活函数的作用和特点:

  • 为网络提供非线性拟合能力
  • 具有特征选择和抑制噪声的能力
  • 相比sigmoid有效缓解了梯度消失的问题

其他功能层

归一化层(BN)

主要功能如下:
1 每一个通道求均值(BHW)
2 求方差
3 归一化
4 尺度和平移

可以合成一个表达公式:

练时采用当前均值方差,并更新移动平均的均值和方差
预测时采用移动平均的均值和方差

归化层的作用和特点:

  • 加快网络训练的收敛速度
  • 防止梯度消失和梯度爆炸
  • 降低过度拟合程度
Dropout层

为了正则化而诞生的,从稠密变成稀疏,具有随机性

  • 训练时以概率p抑制某些节点
  • 预测时所有节点乘p
  • 降低过度拟合程度
  • 防止单点依赖

正则化,也可以降低过度拟合程度,一般有两种:

  • L1正则化

  • L2正则化

一般我们会取离优化中心(彩色圈层部分)最近的切点作为结果值

SpringDataJPA系列(4)

hzqiuxm阅读(412)评论(0)

4 Repository 中的方法返回值使用

Repository 返回结构有哪些?

打开 SimpleJpaRepository 直接看它的 Structure 就可以知道,它实现的方法,以及父类接口的方法和返回类型包括:Optional、Iterable、List、Page、Long、Boolean、Entity 对象等,而实际上支持的返回类型还要多一些。

由于 Repository 里面支持 Iterable,所以其实 java 标准的 List、Set 都可以作为返回结果,并且也会支持其子类,Spring Data 里面定义了一个特殊的子类 Steamable,Streamable 可以替代 Iterable 或任何集合类型。

Steamable

这里单独介绍下Steamable,它是一个函数式接口,它继承了Iterable,所以使用它我们可以很方便的返回集合类型的数据。

官方给我们提供了自定义 Streamable 的方法,不过在实际工作中很少出现要自定义保证结果类的情况。其原理很简单,就是实现Streamable接口,自己定义自己的实现类即可

List/Stream/Page/Slice

  • List:根据条件纯列表式返回
  • Stream:通过使用 Java 8 Stream 作为返回类型来逐步处理查询方法的结果,要注意流的关闭
  • Page:带分页信息的返回,分页信息中包含:数据内容、分页数据、当前数据描述等信息。数据量大的时候避免使用,会隐含一次count(*)操作
  • Slice:只查询偏移量,不计算分页数据的返回,不关心总页数。这就是 Page 和 Slice 的主要区别,现代分页方式推荐这种方式的查询

Future /CompletableFuture 异步结果返回

@Async
Future<User> findByFirstname(String firstname); 
@Async
CompletableFuture<User> findOneByFirstname(String firstname); 
@Async
ListenableFuture<User> findOneByLastname(String lastname);

在实际工作中,直接在 Repository 这一层使用异步方法的场景不多,一般都是把异步注解放在 Service 的方法上面,这样的话,可以有一些额外逻辑,如发短信、发邮件、发消息等配合使用。使用异步的时候一定要配置线程池,这点切记,否则“死”得会很难看。万一失败我们会怎么处理?关于事务是怎么处理的呢?这种需要重点考虑的

Reactive 支持

其实Common里面提供的只是接口,而JPA里面没有做相关的Reactive 的实现,但是本身Spring Data Common里面对 Reactive 是支持的。如果我们引入spring-boot-starter-data-mongodb的依赖,会发现它天然地支持着 Reactive 这条线。这些内容会放在后续mongodb专题中讲解

返回结果小结

下表列出了 Spring Data JPA Query Method 机制支持的方法的返回值类型

DTO返回结果支持哪些

比较规范的开发团队中都会有这么一条规范:不能暴露底层数据结构,也就是不允许将实体entity直接返回给出去,所以就诞生出了VO/DTO/DO/PO等概念。

于是在开发中我们必须自己去写各种DO/DTO/VO之间转换,同时还产生了各种工具类:BeanUtil、Mapstruct、Dozer、Orika、ModelMapper、Jmapper等(比较推荐Mapstruct综合性能最佳)
Spring Data 正是考虑到了这一点,引入了Projections映射这一概念, 它指的是和 DB 的查询结果的字段映射关系。允许对专用返回类型进行建模,有选择地返回同一个实体的不同视图对象。
如果我们的User只需要返回name和email,那应该怎么做呢?

方法一:新建实体类和资源库

然后,新增一个 UserOnlyNameEmailEntityRepository,做单独的查询

很显然这种方法比较糟糕,当存在多种返回值的情况,我们难道要定义多个实体类和资源库吗?
当然这种方式也并非一无是处,对那些临时需求,奇葩需求,生命周期和演化方向不同需求,为了保证我们架构和代码的整洁性与业务可扩展性,我们这么做也不一种蛮不错的选择

方法二:定义DTO方式

然后定义单独的查询方法

下面是测试结果代码:

这里需要注意的是,如果我们去看源码的话,看关键的 PreferredConstructorDiscoverer 类时会发现,UserDTO 里面只能有一个全参数构造方法。
所以这种方式的优点就是返回的结果不需要是个实体对象,对 DB 不能进行除了查询之外的任何操作;缺点就是因为DTO要实现转化必须要有set方法,一旦有 set 方法就可以改变里面的值,构造方法不能更改,必须全参数,这样如果是不熟悉 JPA 的新人操作的时候很容易引发 Bug

方法三:POJO接口方式

这种方式与上面两种的区别是只需要定义接口,它的好处是只读,不需要添加构造方法,我们使用起来非常灵活,一般很难产生 Bug


仍然定义一个单独的查询方法:

下面是测试代码:

这个时候会发现我们的 userOnlyName 接口成了一个代理对象,里面通过 Map 的格式包含了我们的要返回字段的值(如:name、email),我们用的时候直接调用接口里面的方法即可,如 userOnlyName.getName() 即可;这种方式的优点是接口为只读,并且语义更清晰。(DTO/VO转换貌似有点不方便)

总结

如果不熟悉JPA各种返回值和特点,我们很难在实际项目中选择正确的使用方法。

越容易上手的技术其实越难以恰到好处的使用。就像Springboot出现后,许多不懂Spring的开发人员一旦遇到问题完全是只能百度和谷歌,如果搜不到,完全就处于懵逼状态,目前有太多的开发人员连二者的区别都弄不清楚。

JPA的使用看起来非常方便,如果没有进一步理解,很容易误用和乱用,从而导致性能和数据安全性方面的问题。使用SpringDataJPA非常简单,但是要用好SpringDataJPA我们还是需要花一些功夫的

SpringDataJPA系统(3)

hzqiuxm阅读(285)评论(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阅读(277)评论(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阅读(448)评论(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阅读(670)评论(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阅读(456)评论(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阅读(748)评论(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模块

欢迎加入极客江湖

进入江湖关于作者