0%

1. 快速失败(fail-fast)和安全失败(fail-safe)

快速失败:是Java集合支持的一种快速的失败检测机制,不能在并发情况下使用

原理:在遍历过程中,如果集合内元素发生过修改,则modCount将被改变,而每次遍历将检测modCount的值,如果发生变化,将抛出异常。

场景:java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改),比如ArrayList 类。

安全失败:在遍历时,不是直接在集合上进行遍历的,而是实现拷贝原有集合内容,在拷贝的集合上进行遍历的。

原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。

场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改,比如CopyOnWriteArrayList类。

2. 有哪些实现ArrayList线程安全的方法?

  • 使用CopyOnWriteArrayList代替。
  • 通过同步机制控制ArrayList的读写。
  • Vector:Vector是一个线程安全的List,但是它的线程安全实现方式是对所有操作都加上了synchronized关键字,这种方式严重影响效率.所以并不推荐使用Vector。
  • synchronizedList:示例如下
1
2
3
4
5
6
7
8
9
10
11
12
List<String> list = Collections.synchronizedList(new ArrayList<String>());
list.add("1");
list.add("2");
list.add("3");

synchronized (list) {
Iterator i = list.iterator(); // Must be in synchronized block
while (i.hasNext()) {
//foo(i.next());
System.out.println(i.next());
}
}

3. CopyOnWriteArrayList的原理

CopyOnWriteArrayList就是线程安全版本的ArrayList。CopyOnWriteArrayList采用了一种读写分离的并发策略。CopyOnWriteArrayList容器允许并发读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。

4. HashMap的put流程

HashMap插入数据流程图

5. HashMap查找操作

HashMap查找流程图

  1. 使用扰动函数,获取新的哈希值

  2. 计算数组下标,获取节点

  3. 当前节点和key匹配,直接返回

  4. 否则,当前节点是否为树节点,查找红黑树

  5. 否则,遍历链表查找

6. HashMap的哈希/扰动函数是怎么设计的?

HashMap的哈希函数是先拿到 key 的hashcode,是一个32位的int类型的数值,然后让hashcode的高16位和低16位进行异或操作。

1
2
3
4
5
static final int hash(Object key) {
int h;
// key的hashCode和key的hashCode右移16位做异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

让hashcode的高16位和低16位进行异或操作,目的是为了降低哈希碰撞的概率。

*7. 为什么哈希/扰动函数能降hash碰撞?

因为key.hashCode() 函数调用的是 key 键值类型自带的哈希函数,返回的是int散列值,而int值的范围是随编译器的位数变化的,在32位和64位编译器中,范围是 -2^32 - 1 —— 2^32 -1 ,这样的映射空间范围太大,内存根本存不下。所以需要对数组长度取模运算,得到的余数用来访问数组下标。

哈希/扰动函数降低hash碰撞,是通过自身的高16位和低16位进行异或操作,混合原始哈希码的高位和低位,以此来加大低位的随机性,从而降低随机性。

关于详细说明,访问 https://tobebetterjavaer.com/sidebar/sanfene/collection.html#_14-%E4%B8%BA%E4%BB%80%E4%B9%88%E5%93%88%E5%B8%8C-%E6%89%B0%E5%8A%A8%E5%87%BD%E6%95%B0%E8%83%BD%E9%99%8Dhash%E7%A2%B0%E6%92%9E

8. 为什么HashMap的容量是2的倍数呢?

  • 为了方便哈希取余,这样做可以方便位运算,效率也比 % 取余高。
  • 在扩容的时候,因为扩容的是2的倍数,可以使得添加的元素均匀分布在HashMap中的数组上,减少hash碰撞。

9. 为什么HashMap链表转换成红黑树的阈值为8?

因为和统计学相关,节点个数为8的情况,发生概率小。

至于红黑树回转成链表设置为6是因为,如果设置为8,那么如果发生碰撞,节点的增减恰好在8附近,那么链表和红黑树会不断相互转换,影响效率。

10. HashMap为什么扩容因子是0.75

这是一种折中的考虑设置。

如果扩容因子比较大,为1,那么空间利用率高了,但是时间成本就高了。

如果扩容因子比较小,为0.5,那么空间利用率就低了。

11. HashMap的扩容机制详解

https://tobebetterjavaer.com/sidebar/sanfene/collection.html#_21-%E9%82%A3%E6%89%A9%E5%AE%B9%E6%9C%BA%E5%88%B6%E4%BA%86%E8%A7%A3%E5%90%97

12. JDK1.8对HashMap做了哪些优化?

  • 数据结构由 数组 + 链表 改成 数组 + 链表 + 红黑树 ,时间复杂度由 o(n) 下降到 o(logn)
  • 链表由头插法改成尾插法。因为头插法在扩容时链表会发生反转,多线程环境下容易形成环。
  • 扩容rehash。1.7时需要重新hash计算位置,1.8则不用,新的位置不变或者使用索引+新增容量大小。

13. 手动实现一个HashMap

https://tobebetterjavaer.com/sidebar/sanfene/collection.html#_23-%E4%BD%A0%E8%83%BD%E8%87%AA%E5%B7%B1%E8%AE%BE%E8%AE%A1%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AAhashmap%E5%90%97

14. HashMap在多线程环境下会出现哪些问题?

  • 同时有get和put的时候,可能出现get为空。如果在put的时候,出现了扩容时,会导致rehash(1.7时重新计算位置,1.8时可能使用索引+新增容量)。
  • 在1.7时,链表的头插法可能在多线程环境下出现环形链表。
  • 同时put可能导致元素缺失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在 JDK 1.7 和 JDK 1.8 中都存在。

15. 如何解决HashMap线程不安全的问题?

  • 使用ConcurrentHashMap,是通过加synchronized 实现的,粒度比较粗。
  • Collections.synchronizedMap 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个 SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;
1
private Map map=Collections.synchronizedMap(new HashMap());
  • ConcurrentHashMap 在jdk1.7中使用分段锁,在jdk1.8中使用CAS+synchronized

详解ConcurrentHashMap 在1.7和1.8的区别的原理:https://blog.csdn.net/m0_55611144/article/details/126223849

https://tobebetterjavaer.com/sidebar/sanfene/collection.html#_26-%E8%83%BD%E5%85%B7%E4%BD%93%E8%AF%B4%E4%B8%80%E4%B8%8Bconcurrenthashmap%E7%9A%84%E5%AE%9E%E7%8E%B0%E5%90%97

16. HashMap 内部节点是有序的吗?

无序的。如果要有序,可以使用TreeMap或LinkedHashMap。

1. JVM、JDK 和 JRE 有什么区别

JVM是Java虚拟机,Java程序运行在JVM上。而正是因为JVM,使得Java具有了跨平台性。

JRE是Java运⾏时环境,包含在JDK内,它是运⾏已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,Java 命令和其他的⼀些基础构件。

JDK提供了开发Java程序所需的一系列类包、环境等,它拥有 JRE 所拥有的⼀切,还有编译器(javac)和⼯具(如 javadoc 和 jdb)。它能够创建和编译程序。

2. 为什么说Java语言“编译与解释并存”?

高级编程语言按照程序的执行方式分为编译型解释型两种。

说Java是编译型是因为,Java语言是经过编译器编译成字节码后在JVM上运行的。

说Java是解释型是因为,字节码需要在JVM上经过解释成操作系统能识别的语言(机器码),再由操作系统去执行。

3. Java 有哪些数据类型?

Java的数据类型分为基本数据类型引用类型

基本数据类型有:int,long,float,byte,short,double,char,boolean

引用类型有:class、interface、数组

引申:为什么Java里有基本数据类型和引用数据类型?

     存储方式:引用类型在堆里而基本类型在栈里。栈空间小且连续,存取速度比较快;在堆中则需要new,对基本数据类型来说空间浪费率太高;
 
 传值方式:基本类型是在方法中定义的非全局基本数据类型变量,调用方法时作为参数是按数值传递的;引用数据类型变量,调用方法时作为参数是按引用地址传递的;

如果不声明,默认小数为double类型,所以如果要用float的话,必须进行强转

例如:float a=1.3; 会编译报错,正确的写法 float a = (float)1.3;或者float a = 1.3f;(f或F都可以不区分大小写)

4. 详解装箱和拆箱

5. 抽象类(abstract class)和接口(interface)有什么区别?

  1. 接口的方法默认是 public ,所有方法在接口中不能有实现(Java 8 开始接口方法可以有默认实现),而抽象类可以有非抽象的方法。
  2. ⼀个类可以实现多个接口,但只能实现⼀个抽象类。接口自己本身可以通过 extends 关键字扩展多个接口。
  3. 从设计层⾯来说,抽象是对类的抽象,是⼀种模板设计,而接口是对行为的抽象,是⼀种行为的规范。
  4. 抽象类和接口都不能实例化。

总结⼀下 jdk7~jdk9 Java 中接口的变化:

  1. 在 jdk 7 或更早版本中,接口里面只能有常量变量和抽象方法。这些接口方法必须由选择实现接口的类实现。
  2. jdk 8 的时候接口可以有默认方法和静态方法功能。
  3. jdk 9 在接口中引入了私有方法和私有静态方法。

6. final关键字的作用

final关键字表示不可变,可以修饰类、属性和方法。

被final修饰的类不可继承。

被final修饰的属性不可变,被 final 修饰的变量必须被显式第指定初始值,还得注意的是,这里的不可变指的是变量的引用不可变,不是引用指向的内容的不可变。

被final修饰的方法不可被重写。

1
2
3
final StringBuilder sb = new StringBuilder("abc");
sb.append("d");
System.out.println(sb); //abcd

上面的例子则说明了变量不可变的含义是指引用不可变。

7. 重写和重载的区别

重载

方法重载是让类以统一的方式处理不同类型数据的一种手段。多个同名函数同时存在,具有不同的参数个数/类型。重载Overloading是一个类中多态性的一种表现。

重写:

1)参数列表必须完全与被重写的方法相同,否则不能称其为重写而是重载。

2)返回的类型必须一直与被重写的方法的返回类型相同,否则不能称其为重写而是重载。

3)访问修饰符的限制一定要大于被重写方法的访问修饰符(public>protected>default>private)

4)重写方法一定不能抛出新的检查异常或者比被重写方法申明更加宽泛的检查型异常。例如:

父类的一个方法申明了一个检查异常IOException,在重写这个方法是就不能抛出Exception,只能抛出IOException的子类异常,可以抛出非检查异常。

1
2
3
4
5
6
7
8
9
10
11
public int doSomething() {
return 0;
}
// 输入参数不同,意味着方法签名不同,重载的体现
public int doSomething(List<String> strs) {
return 0;
}
// return类型不一样,编译不能通过
public short doSomething() {
return 0;
}

8. final、finally、finalize 的区别?

final:见第6点。

finally:是和try、catch结合使用的。finally包含的代码块,一定会被执行。

finalize 是基础类 java.lang.Object 的一个方法,它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。finalize 机制现在已经不推荐使用,并且在 JDK 9 开始被标记为 deprecated。

9. ==和 equals 的区别?

对于基本数据类型,==比较的是他们的值,而基本数据类型没有equals方法。

对于引用类型,==比较的是他们的地址。如果没有对equals方法进行重写,则比较的是引用类型的变量所指向的对象的地址;

诸如String、Date等类对equals方法进行了重写的话,比较的是所指向的对象的内容。

10. 为什么重写 equals 时必须重写 hashCode 方法?

因为如果两个对象相等,那么他们的hash值一定相等,对两个对象分别调⽤ equals 方法都返回 true。反之如果两个对象hash值相等,他们不一定相等。因此,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖。

hashCode() 的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode() ,则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)。

11. Java 是值传递,还是引用传递?

Java 语言是值传递。JVM 的内存分为堆和栈,其中栈中存储了基本数据类型和引用数据类型实例的地址,也就是对象地址。

而对象所占的空间是在堆中开辟的,所以传递的时候可以理解为把变量存储的对象地址给传递过去,因此引用类型也是值传递。

12. Java如何实现浅拷贝和深拷贝?

浅拷贝:Object 类提供的 clone()方法可以非常简单地实现对象的浅拷贝。

深拷贝:

  • 重写克隆方法:重写克隆方法,引用类型变量单独克隆,这里可能会涉及多层递归。
  • 序列化:可以先将原对象序列化,再反序列化成拷贝对象。

13. Java创建对象有哪些方式?

  • new 创建新对象
  • 通过反射机制
  • 采用 clone 机制
  • 通过序列化机制

14. String 不是不可变类吗?字符串拼接是如何实现的?

jdk1.8 之前,a 和 b 初始化时位于字符串常量池,ab 拼接后的对象位于堆中。经过拼接新生成了 String 对象。如果拼接多次,那么会生成多个中间对象。

内存如下:

jdk1.8之前的字符串拼接

Java8 时JDK 对“+”号拼接进行了优化,上面所写的拼接方式会被优化为基于 StringBuilder 的 append 方法进行处理。Java 会在编译期对“+”号进行处理。

这样看,使用 + 号 和使用 StringBuilder 是没有区别的,在一般情况下是这样,但是在循环里面,建议使用 StringBuilder 。

举个例子,看一段这样的代码:

1
2
3
4
5
6
7
public static void main(String[] args) {
String s= "" ;
for(int i=1;i<10;i++){
s+=i;
}
System.out.println(s);
}
  • 使用jad工具反编译代码
1
2
3
4
5
6
7
public static void main(String args[]){
String s = "":
for(int i = 1; i < 10; i++) {
s = (new StringBuilder(String.valueOf(s))).append(i).tostring() ;
}
System.out.printin(s);
}

正确的做法应该是在循环外层先创建好StringBuilder 对象,然后在循环体内使用append 方法进行处理。

15. 什么是序列化?什么是反序列化?Serializable 接口有什么用?

序列化是将Java对象转换为二进制流,反序列化是将二进制流转换为对象。

这个接口只是一个标记,没有具体的作用,但是如果不实现这个接口,在诸如使用Dubbo等RPC调用场景的情况下,会抛出异常。

Dubbo RPC 方法类都要实现Serializable接口的原因

dubbo在使用hessian2协议序列化方式的时候,对象的序列化使用的是JavaSerializer

1
2
3
4
5
com.alibaba.com.caucho.hessian.io.SerializerFactory#getDefaultSerializer

com.alibaba.com.caucho.hessian.io.SerializerFactory#getSerializer

com.alibaba.com.caucho.hessian.io.Hessian2Output#writeObject

获取默认的序列化方式的时候,会判断该参数是否实现了Serializable接口

1
2
3
4
5
6
7
8
9
10
11
protected Serializer getDefaultSerializer(Class cl) {
if (_defaultSerializer != null)
return _defaultSerializer;
// 判断是否实现了Serializable接口
if (!Serializable.class.isAssignableFrom(cl)
&& !_isAllowNonSerializable) {
throw new IllegalStateException("Serialized class " + cl.getName() + " must implement java.io.Serializable");

}
return new JavaSerializer(cl, _loader);
}

16. Java 泛型了解么?什么是类型擦除?为什么要类型擦除?介绍一下常用的通配符?

Java泛型是JDK1.5的特性,泛型本质上是参数化类型,也就是说操作的数据类型被指定为一个参数。

类型擦除是指在编译时,所有的泛化类型被擦除,转换成实际需要的类型。

需要类型擦除主要是为了向下兼容,因为 JDK5 之前是没有泛型的,为了让 JVM 保持向下兼容,就出了类型擦除这个策略。

常见通配符有K、T、E、V。

17. 正向代理和反向代理的区别

网络代理分为正向代理和反向代理,代理其实就是一个中介,最初的时候,只有正向代理,是帮助内网客户端访问外网服务器的,后来出现了反向代理,是把外网客户端的请求转发给内网服务器。
其实最简单的区别,正向代理代理的是客户端,反向代理代理的是服务器。正向代理一般是客户端架设的,反向代理一般是服务器架设的。
1、代理对象不同。正向代理代理的是客户端,反向代理代理的是服务器。正向代理帮助客户访问其无法访问的服务器资源,反向代理帮助服务器做负载均衡,另外,由于客户端跟真实服务器不直接接触,能起到一定安全防护的作用。
2、架设主体不同。正向代理一般是客户端架设的,比如在自己的机器上装一个代理软件,反向代理一般是服务器架设的,通常是在机器集群中部署个反向代理服务器。
3、保护对象不同。正向代理保护对象是客户端,反向代理保护对象是原始资源服务器。
4、作用目的不同。正向代理主要目的是解决访问限制问题,而反向代理一方面是作为负载均衡,再就是起到安全防护的作用。

18. 在Java中,String为什么设置成不可变类型

在Java中,String被设计成不可变类型,是为了提高字符串的安全性和可靠性。具体来说,有以下几个原因:

  1. 线程安全:由于字符串是不可变的,所以多个线程可以同时访问同一个字符串对象,而不需要担心数据被篡改,从而提高了程序的线程安全性。
  2. 缓存哈希值:由于字符串的哈希值在创建时就被缓存起来了,所以不需要每次使用字符串时都重新计算哈希值,从而提高了程序的性能。
  3. 安全性:由于字符串是不可变的,所以在使用字符串时不需要担心数据被意外修改,从而提高了程序的安全性。
  4. 简化代码:由于字符串是不可变的,所以在使用字符串时可以省去一些代码,比如不需要手动处理字符串的长度、位置等信息,从而使代码更加简洁易懂。

一. 需求分析

用户提出需求

本系统是学院老师,希望给即将毕业的本科生,提供一个双选导师的平台,指导论文的编写,并希望该系统能够接入钉钉使用,用户提出的大致需求如下:

1、导入老师信息(系部,工号,姓名,研究方向,可指导本科生人数);
2、导入学生信息(学号,专业,姓名);
3、学生可以选择本系部的导师(先到先得,满8人后,此导师就不可以再被选择);
4、导师可以点是否同意接收此学生。如果不同意,取消被选,导师名额增加一个,该学生再不能选该导师;
5、后台管理员可以设置什么时候开始双选,什么时候截止。

补充需求

对于初次需求,用户进行了完善,新增需求点如下:

  1. 学生志愿可以填写3个,第一轮一志愿的老师选择确认,第二轮二志愿的老师选择,第三轮三志愿的老师选择。三轮结束后,学生和导师自动匹配。
  2. 老师的界面可以显示学生的绩点和100字以内的自荐信。

需求分析

在用户提出需求后,一般来说,都是会有不少瑕疵或者没考虑到的功能点的,所以需要开发人员细化需求,对其进一步拆解。

从上面的需求我们可以提炼出三个角色——管理员、学生、导师。而我们对功能点的分析也应该从这三个角色展开分析。

管理员

对于管理员角色,用户希望能够设置双选时间区间,并且能够导入学生和老师信息。

此时,我们对管理员的设置双选时间区间功能点应该拆分为两块,一个是在没有双选时间的时候,新增双选时间;另一块是在数据库中已经存在双选时间的时候,更新双选时间。对于导入学生老师信息功能相对明确。

从上面的需求看,管理员的功能用户只提出了两个功能,但是仔细分析,我们会发现这两个功能显然是不够的。

首先,双选完成后,双选结果管理员是不是也需要能看到?看到结果是不是也需要一个导出功能?如果仅仅只是能看,不能导出那这个系统就极大不便了,这也是后期用户肯定会提出来的。

其次,对于用户,管理员是不是需要能够对其进行管理?比如修改某个用户信息,去年的双选学生需要删除,获取今年新增的老师也要加进来等等,因此,管理员还需要用户管理功能,而不仅仅只是导入功能

这样,我们需要在用户提出需求的基础上,新增两个功能点:

  1. 双选结果查看并导出;
  2. 用户管理。
学生

从用户的初次需求和补充需求来看,用户提出的功能点是学生能够在系统内选择导师,补充需求提出学生能够填写自荐信。而系统涉及的功能点还包括对学生能够选择的导师进行筛选,这块我们后面分析。

这样,学生角色功能点如下:

  1. 选择导师
导师

从用户的初次需求和补充需求来看,提出的需求较为模糊。用户描述是“导师可以点是否同意接收此学生。如果不同意,取消被选,导师名额增加一个,该学生再不能选该导师”。

作为开发人员,对于前面一句话,其实要分析为:导师同意接收时名额减一,拒绝时流转到下一个流程。而对于学生不能再选择导师,即在学生填写志愿时判断不能有重复导师即可。

这样,导师角色功能点如下:

  1. 同意、拒绝接收
  2. 查看双选结果

得出流程图

二. 结合钉钉分析

看完上面的分析,应该会角色功能其实不多,应该也很容易可以开发完成,但是这也是很多开发者在开发初期容易产生的错觉。

所有的编程人员都是乐观主义者。 —-《人月神话》

我们来结合钉钉梳理一下需求,以及开发过程中我们需要考虑的问题。

应用类型

在开发初期,我们明确了要做能够接入钉钉的应用。而通过阅读文档会发现,钉钉的应用是分很多种的,每种的功能限制是不一样的,比如有些可以调用钉钉消息通知的接口,而有些不行。同时,不同应用虽然有些功能都可以做到,但是他们调用的API也是不一样的。

钉钉的应用类型如下:

应用类型 开发者 使用人员 支持的能力 是否支持上架到钉钉应用广场
企业内部应用 企业内部开发者或委托的服务商开发者 安装了该应用的企业内部人员 小程序支持移动端H5微应用支持移动端支持PC端机器人
第三方企业应用 产品方案商的开发者 购买开通该三方应用的企业内部人员 小程序支持移动端H5微应用支持移动端支持PC端 是,需要满足上架要求,上架流程请参考合作全流程指引
第三方个人应用 产品方案商的开发者 钉钉的个人用户 小程序支持移动端

进一步阅读各种类型的文档得出,企业内部应用最符合我们的需求,并且可以很方便调用大部分API,因此,类型我们选择为企业内部应用

权限控制

权限控制在没有开发经验的用户不会提出的需求点,但是开发人员是需要重点关注的。

既然我们要做的是钉钉小程序,那么权限控制就理应交给钉钉去控制,而我们去获取钉钉的数据,因此,就需要阅读文档,去了解钉钉是如何实现权限控制以及用户如何登录的。

通过阅读企业内部应用免登流程后,我们可以总结出,实现企业内部应用免登,是让企业员工在钉钉内使用企业内部应用时无需输入账号和密码,其登录流程如下:

  1. 获取免登授权码(AuthCode,客户端调用获取)。

  2. 获取AccessToken。

    调用接口获取access_token,详情参考获取企业内部应用的access_token

  3. 获取userid。

    调用接口获取用户的userid,详情参考通过免登码获取用户信息

  4. 获取用户详情。

    调用接口获取用户详细信息,详情参考根据userId获取用户详情

用户信息管理

对于用户信息,是开发过程中需要重点关注的问题。用户提出需求是系统管理员能够导入学生和导师信息。如果这里在师生双选系统中,单独做一个导入功能的话,那么就需要用户做一张excel表,核对各种信息后,然后去导入用户信息。

这样做确实不难,目前市面上非常多excel导入导出组件,可以很方便实现。但是,如果采用这种方式的话,做出来的系统是不是钉钉小程序区别不大。

因为这样做,你的用户信息没办法和钉钉交互,而你也没办法使用钉钉的消息通知,待办事项通知等等功能。学生选择完成导师后,导师需要进入应用才能看到学生的双选信息,然后再进行操作,这显然意义不大,也没有充分利用钉钉小程序的特性,我认为这样实现是一个失败的产品。

所以,对于用户管理,我们借助钉钉帮我们实现。但是,如果在原有用户基础上操作的话,容易影响其他功能使用,也不够安全。因此,我们在钉钉管理界面新建一个“师生双选部门”,并新增三个子部门:“导师”、“学生”、“管理员”,如下图所示:

对于用户信息,从需求看,还需要绩点和招生人数两个信息,因此,我们需要新增拓展字段,并对学生角色隐藏“招生人数”此类敏感信息,如下图所示:

API调用权限控制

对于每个钉钉应用,用户都可以以应用为维度控制调用权限,登录钉钉管理后台–》权限控制即可配置,如下图所示:

技术选型

前端我选用的框架是Vue、uniapp,组件库使用uview。原因是uview可以很方便打包成各种小程序,既可以当H5使用,也可以稍作修改,接入小程序。

后端则是常见的SpringBoot等。

真机调试

真机调试过程相对比较麻烦,需要先使用HBuilderX开发工具,将前端开发完成,可以测试后,打包成钉钉小程序,并导入小程序开发者工具,进行真机调试。此时,需要使电脑和手机在同一个局域网中,同时需要在钉钉小程序应用后台配置应用网关,添加局域网IP。

对此真机调试的缓存数据,由于不能手动删除,因此需要在前端首页mock数据,并调用钉钉或uniapp缓存api加载或删除缓存数据。

OA审批调用

OA审批调用是在代码实现过程中,工作量稍大的部分。此处也分官方OA审批和自有OA审批,这里不展开讲解两者区别,可进入钉钉开发者文档查看OA审批模块

通过分析后,我认为自有OA审批对于该需求会好做一些,更方便自己控制和数据调用,所以我采用的是自有OA审批,自己控制该流程。采用自由OA审批分以下几个步骤:

  1. 构造审批模板,需通过调用API构造。如果存在,则直接返回模板ID使用。
  2. 每次审批开始前,需要创建不带流程的审批实例。该实例会在申请人一端的OA审批模块看到,并传入URL,点击跳转查看该实例详情。
  3. 创建并拿到审批实例ID后,创建待办事项,待办事项在审批接收人一端的待办事项可以看到。并且传入URL,点击跳转该待办任务详情。
  4. 若导师同意接收,待办任务接收,审批实例也结束,结果导入双选结果表;若导师拒绝,则待办任务结束,审批实例不结束,后续流程仍复用该实例。其他流转逻辑则根据需求而流转,在代码内实现。
  5. 导师和学生匹配成功后,需要调用钉钉API的消息通知,通知导师和学生匹配成功。

通过分析以上逻辑,我们会发现有一块是需要传入URL的,因此,在测试阶段,我们需要做内网穿透,否则每次都需要部署到服务器测试,非常麻烦。钉钉在之前是提供了内网穿透测试工具的,但是在今年7月停止服务,因此,需要寻找另一款工具,这里不推荐使用ngrok。因为他们在免费版本中强制执行安全页面(浏览器警告),并且没办法调用后端的Get请求。作为替代方案,目前先使用花生壳看下效果怎样。

师生匹配

在师生匹配过程中,实际上还是碰到不少问题的。首先我们给予用户需求来分析一下。

在用户的首次需求中,用户提出需求说:导师是先选先得。

在补充需求中,用户提出需求说:学生可以选择三个志愿。

这样其实是有问题的。如果先到先得的话,可能有这种场景:一个学生选择三个志愿导师,一个人就占了三个名额了,其他学生没导师选了。这样其他人要选择导师的话,就要等先选的人流程走完,将名额空出来才可以选。这样的产品显示不够好。

所以,我们要结合用户需求,进行对接修改。在和用户对接后,我们将需求修改成这样:

不设置先到先得,所有学生都能选所有导师,把选择权给导师。假如有8个招生名额,有10个同学选择该导师,导师接收8个。其他两位同学走二志愿。二志愿没有名额或者拒绝走三志愿,以此类推,直到走到自动匹配阶段。

这样看似乎没有什么问题了,其实不然。

用上面的分析看,流程是这样的,假如到了导师选择的开放时间,学生进入系统选择导师。这样有些学生可能会先选择导师,有些选的慢一些。假如先选的学生直接开始流程,那么会有什么现象呢?

假如这样的话,我们没办法等到所有学生选择完成后再开始下一流程。因为学生数据都在钉钉内,我们不知道本次有多少学生参加导师选择。

那么有没有办法获取呢,其实也是有的,因为我们是在钉钉新建了一个部门,直接获取部门人数就好了。但是仍然会有一些问题,即使我们获取到所有学生数,假如只有几个学生没有提交双选意向,那么流程还是继续不下去。

所以,我们应该把选择权交给用户。在学生提交双选意向后,不直接开始流程,而是先存在数据库,并将当前提交的用户信息提交让系统管理员看到,由管理员选择是否开始流程。

总结

在一个项目开发前期,用户提出的需求往往都不够明确,需要需求人员反复对接完善需求,其实这也是一块非常大而且需求非常多时间的开发过程,在开发过程中也应该得到重视。总而言之,一切要从用户角度出发,用他们的想法,结合程序开发的思维去细化需求,思考问题。

上一篇系列文章DDD初探(1)简单介绍了领域、子域、限界上下文、上下文映射图、一些常见的系统架构、实体和值对象等等。对DDD基本概念以及部分核心思想进行阐述,这篇则是延续了上一篇,将对DDD的、领域服务、领域事件、聚合、工厂和资源库等等进行进一步讲解。

一. 领域服务

领域中的服务表示一个无状态的操作,它用于实现特定于某个领域的任务。当某个操作不适合放在聚合和值对象上时,最好的方式便是使用领域服务了。

在上一篇系列文章中,我们有讲到贫血模型的概念,同时也给出了避免贫血模型的方法。要想避免贫血模型,简单说,就是要给实体赋予行为,而领域服务也有些相似,他是用来处理一些包含业务逻辑的实体的行为。

那么,在什么情况下,一个操作不属于实体或者值对象呢?要给出一个全面的原因列表是困难的,这里罗列了以下几点。你可以使用领域服务来:

  1. 执行一个显著的业务操作过程。
  2. 对领域对象进行转换。
  3. 以多个领域对象作为输入进行计算,结果产生一个值对象。

要理解什么是领域服务其实是不难的,在业务处理过程,对于领域服务和实体行为的划分也没有一个严格的约束,取决于怎么设计更为合理。我们举个简单的例子对两者进行区分。

假如现在有个权限认证限界上下文,我们要做两件事,一件是检查用户是否是活跃状态一件是根据用户ID、账号、密码查出一个用户对象,并对某些敏感内容做数据脱敏处理。这两件事看上去也是相辅相成的,如果要检查用户状态的话,那么就要先把这个对象查出来,然后去查他的某个字段是否是活跃状态。

但是,假如要查出这个对象的话,就必然要从资源库(后面会单独介绍,暂时可以理解成数据库)中去取出来,并进行如数据脱敏等类似的业务逻辑的处理。

因此,我们较好的做法是将检查活跃状态这个完全和对象相关的行为放到实体或聚合里面去做,而和业务相关并且需要资源库参与的放在领域服务中去做。

其实解释到这里,大概以及可以对其有一个简单的概念了,个人认为这部分理解并不是很难,如果觉得有必要深入结合例子理解的话,可以结合一些相关书籍去看。

二. 领域事件

在DDD的最初形态中,是不包含领域事件这个概念的,随着DDD的发展,逐渐加上了这部分内容。目前业界对于领域事件褒贬不一,大部分人还是拒绝在微服务中引入领域事件,因为他是的系统间的调用关系变得复杂且不清晰,同时,他也并不是一个容易设计的模块。

在引入领域事件时,你需要对系统有个相对全面的理解,因为引入领域事件后,系统有了更为复杂的调用关系。同时,由于领域事件处理包括:事件构建和发布、事件数据持久化、事件总线、消息中间件、事件接收和处理等,所以你需要对这些技术都有较为深入的理解。但是,作为介绍DDD的文章,还是有必要介绍一下领域事件的,毕竟他也有他的好处——实现解耦。

概念

一个领域事件将导致进一步的业务操作,在实现业务解耦的同时,还有助于形成完整的业务闭环。

举例来说的话,领域事件可以是业务流程的一个步骤,比如学生学费缴费完成后,触发生成缴费凭证的动作或者触发发送缴费邮件通知操作;或者一个事件发生后触发的后续动作,比如密码连续输错三次,触发锁定账户的动作。

如何识别领域事件

在做场景分析时,我们要捕捉业务人员口中的关键词:“如果发生……,则……”“当做完……的时候,请通知……”“发生……时,则……”等。在这些场景中,如果发生某种事件后,会触发进一步的操作,那么这个事件很可能就是领域事件。

领域事件比较容易和传统 SOA 的直接调用混淆,所以有必要解释下两者的关系。首先我们要明白,一次事务最多只能更改一个聚合的状态。如果一次业务操作涉及多个聚合状态的更改,应采用领域事件的最终一致性。

领域事件驱动设计可以切断领域模型之间的强依赖关系,事件发布完成后,发布方不必关心后续订阅方事件处理是否成功,这样可以实现领域模型的解耦,维护领域模型的独立性和数据的一致性。在领域模型映射到微服务系统架构时,领域事件可以解耦微服务,微服务之间的数据不必要求强一致性,而是基于事件的最终一致性。

而领域事件也分为一个微服务内的领域事件和多个微服务之间的领域事件。

一个微服务内的领域事件

微服务内大部分事件的集成,都发生在同一个进程内,进程自身可以很好地控制事务,因此不一定需要引入消息中间件。但一个事件如果同时更新多个聚合,按照 DDD“一次事务只更新一个聚合”的原则,你就要考虑是否引入事件总线。但微服务内的事件总线,可能会增加开发的复杂度,因此你需要结合应用复杂度和收益进行综合考虑。

微服务之间的领域事件

领域事件发生在微服务之间的场景比较多,事件处理的机制也更加复杂。跨微服务的事件可以推动业务流程或者数据在不同的子域或微服务间直接流转。

跨微服务的事件机制要总体考虑事件构建、发布和订阅、事件数据持久化、消息中间件,甚至事件数据持久化时还可能需要考虑引入分布式事务机制等。

微服务之间的访问也可以采用应用服务直接调用的方式,实现数据和服务的实时访问,弊端就是跨微服务的数据同时变更需要引入分布式事务,以确保数据的一致性。分布式事务机制会影响系统性能,增加微服务之间的耦合,所以我们还是要尽量避免使用分布式事务。

领域事件总体架构

领域事件的执行需要一系列的组件和技术来支撑。我们来看一下这个领域事件总体技术架构图,领域事件处理包括:事件构建和发布、事件数据持久化、事件总线、消息中间件、事件接收和处理等。下面我们逐一讲一下。

1. 事件构建和发布

事件基本属性至少包括:事件唯一标识、发生时间、事件类型和事件源,其中事件唯一标识应该是全局唯一的,另外事件中还有一项更重要,那就是业务属性,用于记录事件发生那一刻的业务数据,这些数据会随事件传输到订阅方,以开展下一步的业务操作。

事件发布之前需要先构建事件实体并持久化。事件发布的方式有很多种,你可以通过应用服务或者领域服务发布到事件总线或者消息中间件,也可以从事件表中利用定时程序或数据库日志捕获技术获取增量事件数据,发布到消息中间件。

2. 事件数据持久化

事件数据持久化可用于系统之间的数据对账,或者实现发布方和订阅方事件数据的审计。当遇到消息中间件、订阅方系统宕机或者网络中断,在问题解决后仍可继续后续业务流转,保证数据的一致性。

3. 事件总线

事件总线是实现微服务内聚合之间领域事件的重要组件,它提供事件分发和接收等服务。事件总线是进程内模型,它会在微服务内聚合之间遍历订阅者列表,采取同步或异步的模式传递数据。

事件分发流程大致如下:如果是微服务内的订阅者(其它聚合),则直接分发到指定订阅者;如果是微服务外的订阅者,将事件数据保存到事件库(表)并异步发送到消息中间件;如果同时存在微服务内和外订阅者,则先分发到内部订阅者,将事件消息保存到事件库(表),再异步发送到消息中间件。

4. 消息中间件

跨微服务的领域事件大多会用到消息中间件,实现跨微服务的事件发布和订阅。消息中间件的产品非常成熟,市场上可选的技术也非常多,比如 Kafka,RabbitMQ 等。

5. 事件接收和处理

微服务订阅方在应用层采用监听机制,接收消息队列中的事件数据,完成事件数据的持久化后,就可以开始进一步的业务处理。领域事件处理可在领域服务中实现。

聚合

概念

在 DDD 中,实体和值对象是很基础的领域对象。实体一般对应业务对象,它具有业务属性和业务行为;而值对象主要是属性集合,对实体的状态和特征进行描述。但实体和值对象都只是个体化的对象,它们的行为表现出来的是个体的能力。

聚合的作用

社会是由一个个的个体组成的,象征着我们每一个人。随着社会的发展,慢慢出现了社团、机构、部门等组织,我们开始从个人变成了组织的一员,大家可以协同一致的工作,朝着一个最大的目标前进,发挥出更大的力量。

领域模型内的实体和值对象就好比个体,而能让实体和值对象协同工作的组织就是聚合,它用来确保这些领域对象在实现共同的业务逻辑时,能保证数据的一致性。

也可以这么理解,聚合就是由业务和逻辑紧密关联的实体和值对象组合而成的,聚合是数据修改和持久化的基本单元,每一个聚合对应一个仓储,实现数据的持久化。

聚合有一个聚合根和上下文边界,这个边界根据业务单一职责和高内聚原则,定义了聚合内部应该包含哪些实体和值对象,而聚合之间的边界是松耦合的。

按照这种方式设计出来的微服务很自然就是“高内聚、低耦合”的。聚合在 DDD 分层架构里属于领域层,领域层包含了多个聚合,共同实现核心业务逻辑。

聚合根

聚合根的主要目的是为了避免由于复杂数据模型缺少统一的业务规则控制,而导致聚合、实体之间数据不一致性的问题。

传统数据模型中的每一个实体都是对等的,如果任由实体进行无控制地调用和数据修改,很可能会导致实体之间数据逻辑的不一致。而如果采用锁的方式则会增加软件的复杂度,也会降低系统的性能。如果把聚合比作组织,那聚合根就是这个组织的负责人。

聚合根也称为根实体,它不仅是实体,还是聚合的管理者。首先它作为实体本身,拥有实体的属性和业务行为,实现自身的业务逻辑。其次它作为聚合的管理者,在聚合内部负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑。

最后在聚合之间,它还是聚合对外的接口人,以聚合根 ID 关联的方式接受外部任务和请求,在上下文内实现聚合之间的业务协同。也就是说,聚合之间通过聚合根 ID 关联引用,如果需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体。

聚合的一些设计原则

  1. 在一致性边界内建模真正的不变条件。聚合用来封装真正的不变性,而不是简单地将对象组合在一起。聚合内有一套不变的业务规则,各实体和值对象按照统一的业务规则运行,实现对象数据的一致性,边界之外的任何东西都与该聚合无关,这就是聚合能实现业务高内聚的原因。
  2. 设计小聚合。如果聚合设计得过大,聚合会因为包含过多的实体,导致实体之间的管理过于复杂,高频操作时会出现并发冲突或者数据库锁,最终导致系统可用性变差。而小聚合设计则可以降低由于业务过大导致聚合重构的可能性,让领域模型更能适应业务的变化。
  3. 通过唯一标识引用其它聚合。聚合之间是通过关联外部聚合根 ID 的方式引用,而不是直接对象引用的方式。外部聚合的对象放在聚合边界内管理,容易导致聚合的边界不清晰,也会增加聚合之间的耦合度。
  4. 在边界之外使用最终一致性。聚合内数据强一致性,而聚合之间数据最终一致性。在一次事务中,最多只能更改一个聚合的状态。如果一次业务操作涉及多个聚合状态的更改,应采用领域事件的方式异步修改相关的聚合,实现聚合之间的解耦(相关内容我会在领域事件部分详解)。
  5. 通过应用层实现跨聚合的服务调用。为实现微服务内聚合之间的解耦,以及未来以聚合为单位的微服务组合和拆分,应避免跨聚合的领域服务调用和跨聚合的数据库表关联。

聚合和聚合根有什么不同

如果把聚合比作组织,那聚合根就是这个组织的负责人。聚合根也称为根实体,它不仅是实体,还是聚合的管理者。它作为聚合的管理者,在聚合内部负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑。

在DDD的领域模型中聚合是一个逻辑边界概念,聚合本身没有业务逻辑实现相关的代码。聚合的业务逻辑是由聚合内的聚合根、实体、值对象和领域服务等来实现的。聚合没有ID,它不是实体,所以它没有class,就跟企业里面的部门一样,它只是一个组织名称上的概念,在部门内部的人员才是实体,部门内的若干人员一起构成聚合。每个聚合内有一个聚合根,多个实体、值对象和领域服务等领域对象,它们共同完成聚合的业务逻辑。

参考引用:

【1】 《实现领域驱动设计》 弗农

【2】 《DDD实战课》极客时间系列文章

前言

NotSingle项目已经做了挺久了,但是好像还没有好好介绍一下。

NotSingle是一个我一直在利用空闲时间推进的在线交友平台,分为后台管理端和用户端。目前一直在推进的一直是用户端,目前前端后台都是自己一个人在做的,所以开发周期拉的比较长。

为什么选择做这个类型的项目

首先,从大三开始就一直做的是后台管理类的项目,差不多就是“能跑就行”的开发状态,并没有经历太多性能方面的考虑需求。而这个平台在用户端,则需要大量考虑这方面的需求。因此相对来说,具有一定的挑战性。

前端技术选型

用户端前端技术选型主要就是Vueuniappuview等框架。选择这些技术,一方面是因为这些都是我比较熟悉的技术,另一方面,考虑到后期可能各种因素变动,可能会选择做成h5微信小程序等等,而uview可以很方便进行打包。

后台管理员技术选型和用户端一样,也是使用Vue,只是选择了elementui作为开发组件库。

后端技术选型

目前做的一版并不是微服务的,后期会进行拆分再做一版,并进行性能优化,目前在兼顾一些简单性能的基础上,先把功能做出来。为什么这样做呢?因为我想根据压测结果,能够清晰看到优化后的测试结果,进行对比,更能对性能优化有一个清晰直观的认识。

简单介绍之后就改步入正题了!

“动态”模块设计

因为这里看文字可能会有歧义,因此先简单介绍下。

“动态”模块,类似于发微博。也许后期会取一个好听点或者个性一点的名字,比如:唠唠…这里暂时称为动态吧。这个模块主要功能是:用户发布动态、查看他人动态、编辑动态(如有必要)、删除动态、点赞动态、评论动态。看上去功能不多是吧?但是经过思考后,发现要注意的地方还是不少的,我总结出一下值得思考的问题。

虽然看过《实现领域驱动设计》,认为最好不以数据库设计为导向去做项目,但毕竟真正完全按书中所说去做还是有难度的。目前对书中提到内容后期也会做业务领域的限界上下文划分,目前主要还是以功能实现、性能优化、简单系统设计解耦为主。因此后面讲到的内容,也会以这方面考虑为导向。

点赞数据该不该存?怎么存?

点赞数据,如果存的话,数据量过大,所以这个数据不将点赞人和被点赞人数据存进数据库,只存储每条动态点赞条数。而如果每有一个用户点赞一次便存进数据库的话,数据库肯定是撑不住的。因此引入Redis缓存,整体策略如下:

  1. 每次点赞时先查缓存中有没有,没有的话从数据库拉出来,并加1,若有直接加1;
  2. 每隔一段时间将点赞数据存储数据库;
  3. 每条动态失效时间为3天,即三天内没有人点赞,便将该条缓存数据失效,并在失效前存入数据库

评论数据表怎么设计好?

评论数据表,正常来讲,就是存id、评论对应的动态id、评论者的用户id、评论的内容、评论的时间。但是,还有一点需要考虑,用户评论时,分为评论该条动态以及回复该条动态底下的评论。因此,该表需要再加入一个区分这两种类型的字段,这里目前是准备用-1标识对该条动态的评论,而对于回复的评论,则存回复的评论id。

动态拉取如何设计

对于动态拉取设计是单独作为一个小节来介绍的,因为这块需要探讨的东西也很多,并且也有很多我需要去学习的地方。在设计前,首先要明确一个原则,设计没有好坏,对于我的系统来说,够用就好!

数据量分析

因为暂时只准备在一个学校进行推广,学校人数往上估计不会超过5w人,假设所有人都注册并使用该系统,MAU(月活)假设为1w(往高一点去设计),MAU(月活) 与 DAU(日活)的数量根据经验值约是两倍关系,那么DAU大概就是5k。

假设用户每天平均请求的次数为 100 次,可计算出平均每秒并发访问量约为:(5000*100)/(60 * 60 * 24) 约等于58qps,而通常每日访问的峰值大约在平均每秒并发访问量的 2~9 倍,因此,访问峰值的数值大概在 100qps ~ 540qps之间。

有了这些数据,我们的系统则至少要满足以上需求,同时也要兼顾可扩展性要求。

动态浏览有哪些需要关注的点

当你打开动态:

  • 打开动态页面,浏览自己关注的用户发布了哪些新内容
  • 打开某特定用户的时间轴,浏览该用户发布的内容
  • 查看推荐浏览的动态

因此需要聚焦信息流和时间线的数据存储数据访问,来权衡设计。

拉模型

流程分析

动态模块,分为三种动态:关注、推荐和同校。顾名思义,分别为关注的人的动态,推荐的动态和同校的动态。如果采取拉模型的话,那么以上三种动态就需要这样来做:

  1. 对于关注的人的动态,需要现在关注表查出用户所有关注的人,再根据这些人的id去动态表去查,查完后要根据时间排序,再返回。
  2. 对于推荐的动态,由于推荐系统目前来看工作量这块较为庞大,后期会引入。因此暂时就从动态列表拉出新发布的数据。
  3. 对于同校的动态,其实就是在同校里面筛选了。

我们重点看“关注”的动态,若采取主动型的模型。当你打开动态,查看自己的订阅的动态(其实就是关注的用户发的动态)时,系统会做以下几件事

1. 先去取出你所关注的的用户列表,

2. 分别把这些你所关注的的用户的时间线上的动态取出来,

3. 按时间执行归并排序,返回给你所需要的动态订阅。

时间复杂度

假设该用户关注了 N 个用户,每个用户拉20条动态,那么时间复杂度就是:

1
20*N次DB读 + N路归并 = 20*N次DB访问 + 20log(N)内存处理
缺陷

经过上面分析,缺陷显而易见。当用户想拉取自己订阅的动态时,需执行较多DB操作,用户需等待这一系列 DB 操作以及归并执行完成,系统才会将所有动态返给客户端显示,对于用户就得等待较长时间。

推模型

流程分析

对于动态拉模型,同样分三个类型:

  1. 对于关注的人的动态,使用观察者模式,每个用户维护数组,即在用户发布动态时便推送给关注的人,用户查数据也就是根据这个容器去查。
  2. 对于推荐的动态,就是直接查询动态表的数据。这里不用考虑脏读等等。
  3. 对于同校的动态,与第二种同理。

当用户打开动态时,系统会做以下几件事:

  1. 在系统启动时,为每个用户建立一个存储他人动态的列表,列表中只包含他所关注的用户发布的动态
  2. 当某个用户发布动态后,会将动态推送至关注了他的所有人维护的动态列表
  3. 当用户需要查看自己订阅的动态列表时,只需按时间顺序,从该动态列表中取出
复杂度分析

当用户在刷新自己订阅的动态列表时,只需1次DB读取。

当用户发一条动态,若该用户被 N 个用户关注,则需执行 N 次 DB 写入。

缺陷

浪费DB空间:需要给每个用户维护一个动态表,浪费内存或DB。
动态更新可能会不及时:如一个热门用户有1000粉丝,整个推送的过程可能要持续相当一段时间,有些粉丝可能已经收到这个用户发布的动态,但是有的粉丝可能5分钟后才收到,影响用户体验。

利弊对比:

模型 优点 缺点
pull模型 1. 实现简单
2.易于扩展
1. 数据量大拉动态较耗时
push模型 1. 拉取动态时更快 1. 需要更多空间
2. 不活跃用户会占用大量资源
3.动态推送有些快有些慢,不及时

分别优化

上面对两种模型进行了分析,并比较了两种模型的优缺点,那么应该选用哪种模型呢?我觉得仅凭上面的分析还不能下定义,因为上面的其实还是有一定的优化空间的,我们先分别对两种模型进行优化,再来比较看看。

推模型优化

对于推模型,由于发布动态后后处理消息有时间延迟。导致用户有可能在自己所关注的用户发布动态一段时间后,才能够在自己的动态列表里刷到该条消息。可采用一定方案提升用户体验,在消息分发时,先对粉丝按规则排序,如按用户活跃度(按最新登录系统时间排序),针对活跃度越高用户,优先进行推送,能在一定程度上缓解以上痛点。

拉模型优化

对于拉模型,一个比较有效的方法就是减少数据库操作,那么此时必然就要引入缓存了。

Pull 模型系统瓶颈在于用户请求动态列表时,且这一过程需消耗用户等待时间。可在访问DB前加个缓存层,若缓存命中,直接将数据返给用户,大大缩减用户等待时间。

加个缓存其实不难,需要思考的事,加完缓存之后有什么需要考虑?

首先我们来分析下,缓存怎么起作用?加入缓存后,拉取动态列表时,就仅需一次 Cache 访问:

  • 根据我们之前拉取20条的策略,无缓存动态列表的用户,归并 N 个关注好友的最新 20 条动态,取出前 20 条放入缓存,并返回给用户。后面查询时,若已做缓存,直接返回。
  • 已做缓存的用户,归并 N 个用户在某个时间之后的所有动态,加入缓存

注意上面,缓存是每个用户的动态列表(前20条),并不是每个用户查出来的所有关注用户的20条动态列表。

这时候,其实要考虑的问题很多。

假如采用拉模型+缓存,当一个用户拉取所有他关注的人的前20条动态时,缓存没有的话查库并加到缓存,缓存有直接返回。为了方便理解,我们举个例子。

一个用户关注了10个人,这时候我们获取到这10个人的id,根据个人id去动态表查询他发布了哪些动态。这时候就需要遍历这20个人,每次去查这个人的信息时,先去缓存中查询是否有这个人的动态,如果没有的话就查库,有的话就返回缓存中的信息。

那么,此时又有一个问题,假如现在用户阅读完了缓存中的所有数据,这时候再请求又要查库了吧,在查库前,又有新的用户更新了动态,这时候查的不能简单查每个用户再后面20条动态了,还要把新发布的加上。

这部分数据介于之前查出的20条之前面和后面,那么这时候,你就需要知道上次查库的时间了吧,这样才知道目前缓存中的数据是在哪个时间之后的20条数据

如何选择

关于如何选择,我认为要结合系统需求来看。因为在项目上线时,虽然仅仅只针对一个学校的用户。假如采用push模型时,相对来说对存储空间需求也是大了一些的,毕竟假如上线后,服务器等等都是自掏腰包。选择pull模型最为经济省力,所以我选择pull模型。

动态发布时需要做什么

如果采用拉模型的话,在动态发布时,是需要额外执行往缓存中添加数据的操作的。因为如果采用拉模型的话,我们逻辑是这样的:先从缓存中按发布时间查询出某个人的前10条动态信息,若缓存中没有,则从数据库中查询,若缓存中有,则查缓存中的信息。

问题就在这里了,假如缓存中数据还没有失效,那么我们查询的就是缓存的信息,假如在我们查之前,该用户发布了一条动态,进入的数据库,而此时缓存中便没有。因此,我们在发布信息时,要同步去查询缓存中是否有该用户的信息,假如有的话,那么就要将缓存中的信息取出来,加入新发布的动态。

总结

看似较为简单的模块其实要考虑的东西还是很多的,由于之前这方面经验较少,因此肯定还是有遗漏或考虑不完善的地方,后期有想到会继续补充。

从三年前就开始接触了Spring,当时也是看着视频教程一步步跟着写,也体会不到具体有什么用处。

直到接触了第一个项目,使用了Spring,大致了解他是做什么的,后面做的项目多了,也对有了一定的理解,可以进行简单的描述。

再后面,读了Spring官网的文档后,对其整体也有了一个简单的认知,也做过一些简单的摘录,但是感觉并没有经过自己思考进行一次真正的总结。

现在,在读过一些DDD和面向对象的书后,便开始对Spring做一个整体的总结。从两大核心IOCAOP入手,结合一些我认为通俗易懂的例子,探讨一下为什么要使用Spring,看看是否会有不一样的发现。

一. 为什么需要Spring IOC

场景举例

开始编码

假如,现在有一个新闻播报系统,需要从新闻社获取新闻源,来实现新闻播报。我们用一个NewsProvider类来做以上工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class NewsProvider{
private INewsListener newsListener; // 抓取新闻内容
private INewsPersister newPersistener; // 存储抓取的新闻
public void getAndPersistNews(){
String[] newsIds = newsListener.getAvailableNewsIds();
if(ArrayUtils.isEmpty(newsIds)){
return;
}
for(String newsId : newsIds){
NewsBean newsBean = newsListener.getNewsByPK(newsId);
newPersistener.persistNews(newsBean);
newsListener.postProcessIfNecessary(newsId);
}
}
}

其中,NewsProvider需要依赖INewsListener来帮助抓取新闻内容,并依赖INewsPersister存储抓取的新闻。

假如我们默认使用A新闻社的新闻,那么我们相应地提供了ANewsListenerANewsPersister两个实现,通常情况下,需要在构造函数中构造INewsProvider 依赖的这两个类(使用setter也是可以的,为了方便书写和看懂,使用构造方法注入):

1
2
3
4
public NewsProvider() {
newsListener = new ANewsListener();
newPersistener = new ANewsPersister();
}

那么,此时NewsProvider类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class NewsProvider{
private INewsListener newsListener; // 抓取新闻内容
private INewsPersister newPersistener; // 存储抓取的新闻

public NewsProvider() {
newsListener = new ANewsListener();
newPersistener = new ANewsPersister();
}

public void getAndPersistNews(){
String[] newsIds = newsListener.getAvailableNewsIds();
if(ArrayUtils.isEmpty(newsIds)){
return;
}
for(String newsId : newsIds){
NewsBean newsBean = newsListener.getNewsByPK(newsId);
newPersistener.persistNews(newsBean);
newsListener.postProcessIfNecessary(newsId);
}
}
}

Spring IoC的理念出现之前,就是这样做的。被注入对象(NewsProvider)会直接依赖于被依赖对象(ANewsListenerANewsPersister)。

但是,在IoC的场景中,二者之间通过IoC Service Provider来打交道,所有的被注入对象和依赖对象现在由IoC Service Provider统一管理。

被注入对象需要 什么,直接跟IoC Service Provider招呼一声,后者就会把相应的被依赖对象注入到被注入对象中,从而达到IoC Service Provider为被注入对象服务的目的。

IoC Service Provider在这里就是通常的IoC容器所充当的角色。

从被注入对象的角度看,与之前直接寻求依赖对象相比,依赖对象的取得方式发生了反转, 控制也从被注入对象转到了IoC Service Provider那里。

需求变更

对于前面例子中的NewsProvider来说,在使用IoC之前,如果没有其他需求或变动,用起来是没有问题的。但是,当系统中新增另一家新闻社的新闻来源时, 问题就来了。

假如我们新增了一家名为B的新闻社获取新闻来源,这个时候,你该如何处理呢?

首先,毫无疑问地,应该先根据B新闻社的服务 接口提供一个BNewsListener实现,用来接收新闻;其次,因为都是相同的数据访问逻辑, 所以原来的ANewsPersister可以重用做持久化,我们先放在一边不管。最后,就主要是业务处理对象 NewsProvider了。

因为我们之前没有用IoC,所以,现在的对象跟ANewsListener是绑定的,我们无法重用这个类了。为了解决问题,我们可能要重新实现一个继承自 NewsProviderBNewsProvider,或者干脆重新写一个类似的功能。

而使用IoC后,面对同样的需求,我们却完全可以不做任何改动,就直接使用NewsProvider。 因为不管是A新闻社还是B新闻社,对于我们的系统来说,处理逻辑实际上应该是一样的:根据 各个公司的连接接口取得新闻,然后将取得的新闻存入数据库。因此,我们只要根据B的新闻服务接口,为B的NewsProvider提供相应的BNewsListener注入就可以了,代码如下:

1
2
NewsProvider aNewsProvider = new NewsProvider(new ANewsListener(),new ANewsPersister());
NewsPrivider bNewsProvider = new NewsProvider(new BNewsListener(),new BNewsPersister());
1
2
3
4
5
6
7
8
9
10
11
12
13
public class NewsProvider{
private INewsListener newsListener; // 抓取新闻内容
private INewsPersister newPersistener; // 存储抓取的新闻

public NewsProvider(INewsListener newsListener, INewsPersister newPersistener) {
this.newsListener = newsListener;
this.newPersistener = newPersistener;
}

public void getAndPersistNews(){
....
}
}

其实,这就是NewsProvider依赖方向的转变,也是从主动获取到被动接收的转变。原来NewsProvider直接依赖于具体的ANewsListenerANewsPersister,通俗讲,从前是主动去 new 自己需要的对象,现在则是通过构造器被动的接收传递进来的对象,使得NewsProvider大大增强了可重用性。

IoC Service Provider

在上一节,我们有提到IoC Service Provider这个词,尽管这个词看到遍很容易猜到是做什么的,但是还是有必要讲解下。因为他涉及到理解IoC如何管理对象间的依赖关系。

虽然业务对象可以通过IoC方式声明相应的依赖,但是最终仍然需要通过某种角色或者服务将这些相互依赖的对象绑定到一起,而IoC Service Provider就对应IoC场景中的这一角色。

IoC Service Provider在这里是一个抽象出来的概念,它可以指代任何将IoC场景中的业务对象绑定到一起的实现方式。它可以是一段代码,也可以是一组相关的类,甚至可以是比较通用的IoC框架或 者IoC容器实现。比如,可以通过以下代码绑定与新闻相关的对象:

1
2
3
INewsListener newsListener = new ANewsListener(); 
INewsPersister newsPersister = new ANewsPersister();
NewsProvider newsProvider = new NewsProvider(newsListener,newsPersister); newsProvider.getAndPersistNews();

这段代码就可以认为是这个场景中的IoC Service Provider,他将ANewsListenerANewsPersister绑定到了一起,只不过这段代码比较简单,而且目的也过于单一罢了。

要将系统中几十、几百甚至数以千计的业务对象绑定到一起,采用这种方式显然是不切实际的。 通用性暂且不提,单单是写这些绑定代码看起来也很丑陋。

但是,现在许多开源产品通过各种方式为我们做了这部分工作。所以,目前来看,我们只需要使用这些产品提供的服务就可以了。Spring 的IoC容器就是一个典型的提供依赖注入服务的IoC Service Provider

IoC Service Provider的职责

IoC Service Provider的职责相对来说比较简单,主要有两个:业务对象的构建管理和业务对象间的依赖绑定。

  • 业务对象的构建管理。IoC场景中,业务对象无需关心所依赖的对象如何构建如何取得,但这部分工作始终需要有人来做。所以,IoC Service Provider需要将对象的构建逻辑从客户端对象(客户端是相对而言,比如A依赖于B,那么A此时就是客户端对象)那里剥离出来,以免这部分逻辑污染业务对象的实现。
  • 业务对象间的依赖绑定。对于IoC Service Provider来说,这个职责是最难以实现的,同时也是最重要的。IoC Service Provider通过结合之前构建和管理的所有业务对象,以及各个业务对象间可以识别的依赖关系。
如何管理对象间的依赖关系

有了IoC Service Provider前面的简单的创建功能,此时,则需要记录创建的对象之间的对应关系。记录的方式也有许多种:比如:

  • 可以通过最基本的文本文件来记录被注入对象和其依赖对象之间的对应关系;
  • 也可以通过描述性较强的XML文件格式来记录对应信息;
  • 还可以通过编写代码的方式来注册这些对应信息;

实际上,当前流行的IoC Service Provider产品使用的注册对象管理信息的方式主要有以下几种:

  1. 编码方式

    这种方式顾名思义,直接在代码中,管理对象间的依赖注入关系

    1
    2
    3
    4
    5
    6
    IoContainer container = ...;
    container.register(NewsProvider.class,new NewsProvider());
    container.register(INewsListener.class,new DowJonesNewsListener());
    ...
    NewsProvider newsProvider = (NewsProvider)container.get(NewsProvider.class);
    newProvider.getAndPersistNews();
  2. 配置文件方式

这种方式在Spring中也是很常见的,比如在xml文件中管理Bean对象等等。

  1. 元数据方式

这种方式,我们一般现在称为注解

二. IoC之BeanFactory

在上一节,我们提到了IoC Service Provider,Spring的IoC容器就是一个IoC Service Provider,但是,这只是它被冠以IoC之名的部分原因。

Spring的IoC容器是一个提供IoC支持的轻量级容器,除了基本的IoC支持,它作为轻量级容器还提供了IoC之外的支持。如在Spring的IoC容器之上,Spring还提供了 相应的AOP框架支持、企业级服务集成等服务。Spring的IoC容器和IoC Service Provider所提供的服务之间存在一定的交集,二者关系如下图所示:

Spring的两种容器:

Spring提供了两种容器类型:BeanFactoryApplicationContext

BeanFactory: 基础类型IoC容器,提供完整的IoC服务支持。如果没有特殊指定,默认采用延迟初始化策略(lazy-load)。只有当客户端对象需要访问容器中的某个受管对象的时候,才对该受管对象进行初始化以及依赖注入操作。所以,相对来说,容器启动初期速度较快,所需要的资源有限。对于资源有限,并且功能要求不是很严格的场景,BeanFactory是比较合适的 IoC 容器选择。

ApplicationContext: ApplicationContext在BeanFactory的基础上构建,是相对比较高级的容器实现,除了拥有BeanFactory的所有支持,ApplicationContext还提供了其他高级特性,比如事件发布、国际化信息支持。ApplicationContext所管理的对象,在该类型容器启动之后,默认全部初始化并绑定完成。所以,相对于BeanFactory来说,ApplicationContext要求更多的系统资源,同时,因为在启动时就完成所有初始化,容器启动时间较之BeanFactory也会长一些。在那些系统资源充足,并且要求更多功能的场景中, ApplicationContext类型的容器是比较合适的选择。

下图是BeanFactory和ApplicationContext继承关系,从图中可以看到,ApplicationContext除了间接继承了BeanFactory外,还继承了ApplicationEventPublisherResourceLoader,即事件发布和资源加载,后面还会详细讲到。

简单理解BeanFactory:

BeanFactory,顾名思义,就是生产Bean的工厂。当然,严格来说,这个“生产过程”可能不像 说起来那么简单。既然Spring框架提倡使用POJO,那么把每个业务对象看作一个JavaBean对象,或许更容易理解为什么Spring的IoC基本容器会起这么一个名字。

作为Spring提供的基本的IoC容器, BeanFactory可以完成作为IoC Service Provider的所有职责,包括业务对象的注册和对象间依赖关系的绑定。 所以,对于客户端来说,与BeanFactory交互其实很简单。

我们来看下BeanFactory接口里的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface BeanFactory {
String FACTORY_BEAN_PREFIX = "&";

Object getBean(String var1) throws BeansException;

<T> ObjectProvider<T> getBeanProvider(Class<T> var1);

boolean containsBean(String var1);

boolean isSingleton(String var1) throws NoSuchBeanDefinitionException;

boolean isPrototype(String var1) throws NoSuchBeanDefinitionException;

boolean isTypeMatch(String var1, Class<?> var2) throws NoSuchBeanDefinitionException;

@Nullable
Class<?> getType(String var1) throws NoSuchBeanDefinitionException;

String[] getAliases(String var1);
}

上面的源码之列出其中一部分能说明问题的代码,上面代码中的方法基本上都是查询相关的方法,例如,取得某个对象的方法(getBean)、查询某个对象是否存在于容器中的方法(containsBean),或者取得某个bean的状态或者类型的方法等。 因为通常情况下,对于独立的应用程序,只有主入口类才会跟容器的API直接耦合。

有了BeanFactory后,有什么不一样

我们继续接着上面的新闻社的例子来看,在BeanFactory出现之前,我们通常会直接在应用程序的入口类的main方法中,自己实例化相应的对象并调用之,如以下代码所示:

1
2
NewsProvider newsProvider = new NewsProvider(); 
newsProvider.getAndPersistNews();

这样,所有的这些Bean都将在程序中,并且由开发者来管理他们之间的关联关系,一旦Bean多起来,耦合将杂乱无章,假如我们使用了BeanFactory,我们可以在XML中配置或者使用注解把他们交给IoC管理,我们这里为了方便看,使用XML文件来说明:

1
2
3
4
5
6
7
8
9
10
11
<beans>
<bean id="ANewsProvider" class="..NewsProvider">
<constructor-arg index="0">
<ref bean="ANewsListener"/>
</constructor-arg>
<constructor-arg index="1"> 9
<ref bean="ANewsPersister"/>
</constructor-arg>
</bean>
...
</beans>

将他们注册到IoC容器并配置好了他们的关联关系之后,便可以加载配置文件路径并调用了:

1
2
3
BeanFactory container = new XmlBeanFactory(new ClassPathResource("上面配置文件路径"));
NewsProvider newsProvider = (NewsProvider)container.getBean("ANewsProvider");
newsProvider.getAndPersistNews();

当然,现在还有更简洁的使用注解的方式,其本质都是一样的,这里就不一一讲解。

BeanFactory的对象注册与依赖绑定方式

BeanFactory作为一个IoC Service Provider,为了能够明确管理各个业务对象以及业务对象之间的 依赖绑定关系,同样需要某种途径来记录和管理这些信息。

代码方式实现

虽然在实际操作中,不会有这种方式手动管理,但是了解这些代码可以让我们更加清楚BeanFactory在底层是如何运作的。

1
2
3
4
5
6
public static void main(String[] args){
DefaultListableBeanFactory beanRegistry = new DefaultListableBeanFactory();
BeanFactory container = (BeanFactory)bindViaCode(beanRegistry);
NewsProvider newsProvider = (NewsProvider)container.getBean("djNewsProvider");
newsProvider.getAndPersistNews();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static BeanFactory bindViaCode(BeanDefinitionRegistry registry){
AbstractBeanDefinition newsProvider = new RootBeanDefinition(NewsProvider.class,true);
AbstractBeanDefinition newsListener = new RootBeanDefinition(ANewsListener.class,true);
AbstractBeanDefinition newsPersister = new RootBeanDefinition(APersister.class,true);
// 将bean定义注册到容器中
registry.registerBeanDefinition("NewsProvider", newsProvider);
registry.registerBeanDefinition("AListener", newsListener);
registry.registerBeanDefinition("APersister", newsPersister);
// 指定依赖关系
// 1. 可以通过构造方法注入方式
ConstructorArgumentValues argValues = new ConstructorArgumentValues();
argValues.addIndexedArgumentValue(0, newsListener);
argValues.addIndexedArgumentValue(1, newsPersister);
newsProvider.setConstructorArgumentValues(argValues);
// 或者通过setter方法注入方式
MutablePropertyValues propertyValues = new MutablePropertyValues();
propertyValues.addPropertyValue(new propertyValue("newsListener",newsListener));
propertyValues.addPropertyValue(new PropertyValue("newPersistener",newsPersister));
newsProvider.setPropertyValues(propertyValues);
// 绑定完成
return (BeanFactory)registry;
}

BeanFactory只是一个接口,我们最终需要一个该接口的实现来进行实际的Bean的管理。

DefaultListableBeanFactory就是这么一个比较通用的BeanFactory实现类。DefaultListableBeanFactory除了间接地实现了BeanFactory接口,还实现了BeanDefinitionRegistry接口,该接口(BeanDefinitionRegistry)才是在BeanFactory的实现中担当Bean注册管理的角色。

我们来看下BeanDefinitionRegistry中的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface BeanDefinitionRegistry extends AliasRegistry {
void registerBeanDefinition(String var1, BeanDefinition var2) throws BeanDefinitionStoreException;

void removeBeanDefinition(String var1) throws NoSuchBeanDefinitionException;

BeanDefinition getBeanDefinition(String var1) throws NoSuchBeanDefinitionException;

boolean containsBeanDefinition(String var1);

String[] getBeanDefinitionNames();

int getBeanDefinitionCount();

boolean isBeanNameInUse(String var1);
}

从接口中方法命名其实就可以很容易看出具体是做什么用的,其实这也是好代码的一个评价标准。这些代码主要就是获取、判断或者删除BeanDefinitionBeanDefinition又是什么呢?

我们来看部分BeanDefinition的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public interface BeanDefinition extends AttributeAccessor, BeanMetadataElement {
String SCOPE_SINGLETON = "singleton";
String SCOPE_PROTOTYPE = "prototype";
int ROLE_APPLICATION = 0;
int ROLE_SUPPORT = 1;
int ROLE_INFRASTRUCTURE = 2;

void setParentName(@Nullable String var1);

@Nullable
String getParentName();

void setBeanClassName(@Nullable String var1);

@Nullable
String getBeanClassName();

void setScope(@Nullable String var1);

@Nullable
String getScope();

void setLazyInit(boolean var1);

boolean isLazyInit();

void setDependsOn(@Nullable String... var1);

@Nullable
String[] getDependsOn();

void setAutowireCandidate(boolean var1);

boolean isAutowireCandidate();
}

从源码可以看出来,BeanDefinition其实就是保存了一个Bean的所有必要信息。

基本上,BeanFactory接口只定义如何访问容器内管理的Bean的方法,各个BeanFactory的具体实现类负责具体Bean的注册以及管理工作。 BeanDefinitionRegistry接口定义抽象了Bean的注册逻辑。通常情况下,具体的BeanFactory实现类会实现这个接口来管理Bean的注册。它们之间的关系下图所示:

这样,我们可以总结得出BeanDefinitionBeanFactoryBeanDefinitionRegistry三者的关系。

BeanFactory:只是一个接口,我们最终需要一个该接口的实现来进行实际的Bean的管理,具体其他职责由具体实现类来完成,这里暂时还没有讨论到实现类。

BeanDefinition:保存了一个Bean的所有必要信息,注册Bean时注入,需要时再取出来。

BeanDefinitionRegistry:定义抽象了Bean的注册逻辑,获取、判断或者删除BeanDefinition等等。

现在,我们再来梳理一下。每一个受管的对象,在容器中都会有一个BeanDefinition的实例(instance)与之相对应,该 BeanDefinition的实例负责保存对象的所有必要信息,包括其对应的对象的class类型、是否是抽象类、构造方法参数以及其他属性等。

当客户端向BeanFactory请求相应对象的时候,BeanFactory会 通过这些信息为客户端返回一个完备可用的对象实例。RootBeanDefinitionChildBeanDefinition是BeanDefinition的两个主要实现类。

现在再来看下开头那部分代码实现:

main 方法中,首先构造一个 DefaultListableBeanFactory 作 为 BeanDefinitionRegistry,然后将其交给bindViaCode方法进行具体的对象注册和相关依赖管理,然后通过 bindViaCode返回的BeanFactory取得需要的对象,最后执行相应逻辑。在我们的实例里,就是取得NewsProvider进行新闻的处理。

在bindViaCode方法中,首先针对相应的业务对象构造与其相对应的BeanDefinition,使用 了 RootBeanDefinition 作为 BeanDefinition 的实现类。构造完成后,将这些 BeanDefinition注册到通过方法参数传进来的BeanDefinitionRegistry中。之后,因为我们的NewsProvider是采用的构造方法注入,所以,需要通过ConstructorArgumentValues为其注入相关依赖。在这里为了同时说明setter方法注入,也同时展示了在Spring中如 何使用代码实现setter方法注入。最后,以BeanFactory的形式返回已经注册并绑定了所有相关业务对象的BeanDefinitionRegistry实例。

配置文件实现

配置文件具体实现方式就不进行说明,这篇文章主要还是为了了解原理。

注解方式实现

因为之前看代码实现可能会觉得很复杂,这里为了说明其简洁性看下注解方式实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Component
public class NewsProvider{
@Autowired
private INewsListener newsListener;
@Autowired
private INewsPersister newPersistener;

public NewsProvider(INewsListener newsListner,INewsPersister newsPersister){
this.newsListener = newsListner;
this.newPersistener = newsPersister;
}
...
}

@Component
public class ANewsListener implements INewsListener{
...
}

@Component
public class ANewsPersister implements INewsPersister{
...
}

BeanFactory和FactoryBean

这是很容易混淆的两个概念,FactoryBean是Spring容器提供的一种可以扩展容器对象实例化逻辑的接口。FactoryBean,其主语是Bean,定语为Factory,也就是说,它本身与其他注册到容器的对象一样,只是一个Bean而已,只不过,这种类型的Bean本身就是生产对象的工厂 (Factory)。

其源码如下,只有三个方法:

1
2
3
4
5
6
7
8
9
10
11
12
public interface FactoryBean<T> {
@Nullable
T getObject() throws Exception;

@Nullable
Class<?> getObjectType();

default boolean isSingleton() {
return true;
}
}

getObject()方法会返回该FactoryBean“生产”的对象实例,我们需要实现该方法以给出自己 的对象实例化逻辑;其他两个方法看名字应该也能猜到,就不一一说明。

为什么要FactoryBean

当某些对象的实例化过程过于烦琐,通过XML配置过于复杂,使我们宁愿使用Java代码来完成这个实例化过程的时候,或者,某些第三方库不能直接注册到Spring容器的时候,就可以实现org.springframework.beans.factory.FactoryBean接口,给出自己的对象实例化逻辑代码。当然,不使用FactoryBean,而像通常那样实现自定义的工厂方法类也是可以的。

这样说可能不好理解,我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* Bean
*/
public class Mapper {
private Integer id;
public Mapper(Integer id) {
this.id = id;
}
public Integer getId() {
return id;
}
}

public class MapperFactoryBean implements FactoryBean<Mapper> {
private Integer id;
private Mapper mapper;
public void setId(Integer id) {
this.id = id;
}
@Override
public Mapper getObject() {
if (mapper == null) {
mapper = new Mapper(id);
}
return mapper;
}
// 这里是getObjectType() 和 isSingleton() 实现
}

1
2
3
<bean id="mapper" class="com.wangtao.spring.bean.MapperFactoryBean">
<property name="id" value="1"/>
</bean>
1
2
3
4
5
6
7
8
9
10
public class BaseTest {
@Test
public void application() {
ApplicationContext context = new ClassPathXmlApplicationContext("spring.xml");
// 下面这句将抛出异常
// MapperFactoryBean mapper = context.getBean("mapper", MapperFactoryBean.class);
Mapper mapper = context.getBean("mapper", Mapper.class);
Assert.assertEquals(1, mapper.getId().intValue());
}
}

从测试结果中得知,我们虽然配置的是MapperFactoryBean的实例,但是根据id拿到的是getObject方法创建的对象。其实在容器中创建的对象仍然是MapperFactoryBean的实例,只是在获取的时候会判断这个结果对象是不是派生于FactoryBean,如果是的话则返回getObject方法创建的对象,并且这个对象并不是容器初始化时创建的,而是使用context.getBean()方法时才创建。

如果想要获取FactoryBean实例,需要这样写:

MapperFactoryBean mapper = context.getBean("&mapper", MapperFactoryBean.class)

即在bean的名字ID前加上&符号。

容器功能实现的各个阶段

Spring的IoC容器启动过程中,它会以某种方式加载Configuration Metadata(通常也就是XML格式的配置信息),然后根据这些信息绑定整个系统的对象,最终组装成 一个可用的基于轻量级容器的应用系统。

Spring的IoC容器实现以上功能的过程,基本上可以按照类似的流程划分为两个阶段,即容器启动阶段和Bean实例化阶段,如下图所示:

容器启动阶段

容器启动伊始,首先会通过某种途径加载配置文件。

在大部分情况下,容器需要依赖某些工具类(BeanDefinitionReader)对加载的配置文件进行解析和分析,并将分析后的信息编组为相应BeanDefinition,最后把这些保存了bean定义必要信息的BeanDefinition,注册到相应的BeanDefinitionRegistry,这样容器启动工作就完成了。

总地来说,该阶段所做的工作可以认为是准备性的,重点更加侧重于对象管理信息的收集。当然, 一些验证性或者辅助性的工作也可以在这个阶段完成。

Bean实例化阶段

经过第一阶段,现在所有的bean定义信息都通过BeanDefinition的方式注册到了BeanDefinitionRegistry中。当某个请求方通过容器的getBean方法明确地请求某个对象,或者因依赖关系容器需要隐式地调用getBean方法时,就会触发第二阶段的活动。

该阶段,容器会首先检查所请求的对象之前是否已经初始化。如果没有,则会根据注册的 BeanDefinition所提供的信息实例化被请求对象,并为其注入依赖。如果该对象实现了某些回调接口,也会根据回调接口的要求来装配它。当该对象装配完毕之后,容器会立即将其返回请求方使用。

Bean的实例化与BeanWrapper

容器在内部实现的时候,采用“策略模式”来决定采用何种方式初始化bean实例。 通常,可以通过反射或者CGLIB动态字节码生成来初始化相应的bean实例或者动态生成其子类。

Aware接口

当对象实例化完成并且相关属性以及依赖设置完成之后,Spring容器会检查当前对象实例是否实现了一系列的以Aware命名结尾的接口定义。如果是,则将这些Aware接口定义中规定的依赖注入给当前对象实例。

这些Aware接口为如下几个:

  • org.springframework.beans.factory.BeanNameAware。如果Spring容器检测到当前对象实例实现了该接口,会将该对象实例的bean定义对应的beanName设置到当前对象实例。
  • org.springframework.beans.factory.BeanClassLoaderAware。如果容器检测到当前对象实例实现了该接口,会将对应加载当前bean的Classloader注入当前对象实例。默认会使用加载org.springframework.util.ClassUtils类的Classloader。
  • org.springframework.beans.factory.BeanFactoryAware。在介绍方法注入的时候,我们提到过使用该接口以便每次获取prototype类型bean的不同实例。如果对象声明实现了 BeanFactoryAware接口,BeanFactory容器会将自身设置到当前对象实例。这样,当前对象实例就拥有了一个BeanFactory容器的引用,并且可以对这个容器内允许访问的对象按照需要进行访问。

三. IoC之ApplicationContext

作为Spring提供的较之BeanFactory更为先进的IoC容器实现,ApplicationContext除了拥有 BeanFactory支持的所有功能之外,还进一步扩展了基本容器的功能,包括BeanFactoryPostProcessorBeanPostProcessor以及其他特殊类型bean的自动识别、容器启动后bean实例的自动初始化、 国际化的信息支持、容器内事件发布等。

统一资源加载策略

Spring为基本的BeanFactory类型容器提供了XmlBeanFactory实现。相应地,它也为ApplicationContext类型容器提供了以下几个常用的实现。

  • org.springframework.context.support.FileSystemXmlApplicationContext。在默认情况下,从文件系统加载bean定义以及相关资源的ApplicationContext实现。
  • org.springframework.context.support.ClassPathXmlApplicationContext。在默认情况下,从Classpath加载bean定义以及相关资源的ApplicationContext实现。
  • org.springframework.web.context.support.XmlWebApplicationContext。Spring提供的用于Web应用程序的ApplicationContext实现,
为什么需要

从某些程度上来说,资源查找后返回的形式多种多样,没有一个统一的抽象。理想情况下,资源查找 完成后,返回给客户端的应该是一个统一的资源抽象接口,客户端要对资源进行什么样的处理,应该 由资源抽象接口来界定,而不应该成为资源的定位者和查找者同时要关心的事情。 所以,在这个前提下 ,Spring提出了一套基于org.springframework.core.io.Resourceorg.springframework.core.io.ResourceLoader接口的资源抽象和加载策略。

Spring中的Resource

其实这个类,如果做过本地文件上传下载之类功能的话,多少应该是有接触过的,我们来举个例子。假如我们要加载一个配置文件的话,或许我们可以这样写:

1
BeanFactory beanFactory = new XmlBeanFactory(new ClassPathResource("..."));

其中ClassPathResource就是Resource的一个特定类型的实现,代表的是位于Classpath中的资源。 Resource接口可以根据资源的不同类型,或者资源所处的不同场合,给出相应的具体实现。Spring 框架在这个理念的基础上,提供了一些实现类(可以在org.springframework.core.io包下找到这 些实现类)。

  • ByteArrayResource。将字节(byte)数组提供的数据作为一种资源进行封装,如果通过 InputStream形式访问该类型的资源,该实现会根据字节数组的数据,构造相应的ByteArrayInputStream并返回。
  • ClassPathResource。该实现从Java应用程序的ClassPath中加载具体资源并进行封装,可以使用指定的类加载器(ClassLoader)或者给定的类进行资源加载。
  • FileSystemResource。对java.io.File类型的封装,所以,我们可以以文件或者URL的形 式对该类型资源进行访问,只要能跟File打的交道,基本上跟FileSystemResource也可以。
  • UrlResource。通过java.net.URL进行的具体资源查找定位的实现类,内部委派URL进行具 体的资源操作。
  • InputStreamResource。将给定的InputStream视为一种资源的Resource实现类,较为少用。 可能的情况下,以ByteArrayResource以及其他形式资源实现代之。

Resource接口代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public interface Resource extends InputStreamSource {
boolean exists();

default boolean isReadable() {
return this.exists();
}

default boolean isOpen() {
return false;
}

default boolean isFile() {
return false;
}

URL getURL() throws IOException;

URI getURI() throws IOException;

File getFile() throws IOException;

default ReadableByteChannel readableChannel() throws IOException {
return Channels.newChannel(this.getInputStream());
}

long contentLength() throws IOException;

long lastModified() throws IOException;

Resource createRelative(String var1) throws IOException;

@Nullable
String getFilename();

String getDescription();
}

这个接口从命名也可以看出来,它可以帮助我们查询资源状态、访问资源内容,甚至根据当前资源创建新的相对资源。

Spring中的ResourceLoader

有了Resource之后,如何去查找和定位这些资源,就是ResourceLoader的职责所在了。

ResourceLoader有一个默认的实现类,即org.springframework.core.io.DefaultResourceLoader,该类默认的资源查找处理逻辑如下。

(1) 首先检查资源路径是否以classpath:前缀打头,如果是,则尝试构造ClassPathResource类型资源并返回。

(2) 否则,

  • 尝试通过URL,根据资源路径来定位资源,如果没有抛出MalformedURLException, 有则会构造UrlResource类型的资源并返回;
  • 如果还是无法根据资源路径定位指定的资源,则委派 getResourceByPath(String) 方法来定位, DefaultResourceLoader 的 getResourceByPath(String)方法默认实现逻辑是,构造ClassPathResource类型的资源并返回。

说到这里,可能会勾起很多人初学Spring的回忆,当时对classpath:一直处于懵懂状态,大概能理解他的意思,就是在该项目中查找路径,知道看到这里,才能理解他的本质吧。

FileSystemResourceLoader

为了避免DefaultResourceLoader在最后getResourceByPath(String)方法上的不恰当处理, 我们可以使用org.springframework.core.io.FileSystemResourceLoader,它继承自DefaultResourceLoader,但覆写了getResourceByPath(String)方法,使之从文件系统加载资源并以 FileSystemResource类型返回。这样,我们就可以取得预想的资源类型。

小结

Spring最初并不支持基于注解的依赖注入方式。所以,在Spring 2.5中引入这一依赖注入方式的 候,肯定要在维护整个框架设计与实现的一致性和引入这种依赖注入方式对整个框架的冲击之间做出权衡。最终的结果我们已经看到了,Spring 2.5中引入的基于注解的依赖注入从整体上保持了框架内的一致性,同时又提供了足够的基于注解的依赖注入表达能力。

虽然我们还会部分地依赖于容器的配置文件,但通过20%的工作却可以带来80%的效果, 这本身已经是最好的结果了。 不过,从实际开发角度看,如果非要使用完全基于注解的依赖注入的话,或许会遇到一些难题。

比如,对于第三方提供的类库,肯定没法给其中的相关类标注@Component之类的注解。这 时,我们可以结合使用基于配置文件的依赖注入方式。毕竟,基于XML的依赖注入方式是Spring提供的最基本、也最为强大的表达方式了! 到目前为止,我们已经几乎讲到了Spring IoC的绝大部分核心内容了。

但是,要想真正会用,还是得动手实践才有效果。

参考引用:

【1】https://www.cnblogs.com/wt20/p/10470178.html

【2】《Spring揭秘》王福强

在之前两篇文章中,有一篇初步了解了什么是领域驱动设计(DDD)。这篇文章则尝试并进一步解释到底什么是面向对象,如何利用DDD进行真正面向对象的程序设计。

一. 再看面向对象

“面向对象程序设计”在所有人接触 Java 时一定听过,但是绝大部分初学者应该很难理解什么才是面向对象。书本给出的定义是:面向对象程序设计(Object Oriented Programming)其本质是以建立模型体现出来的抽象思维过程和面向对象的方法。

而考试的时候,也自然就是背好填上去了,想要真正理解,不是那么简单的。

对于究竟什么是面向对象,常见的说法是:数据和函数的组合。这种说法不准确,也很难让初学者理解。

另一种常见的说法是:面向对象编程时一种对真实世界进行建模的方式。这种说法则听起来有点抽象,如何对真实世界进行建模?为什么这样?有什么好处?全然不知。而且也没有回答出究竟什么是面向对象。

这时候,很多学过并且有一定经验的人便会抛出三个词:继承、封装、多态。

在初学的时候,教Java的老师也是这样和我们说的,但是也只是一带而过,考试的时候也只是背好但是无法理解。

后来,我也做过一些项目了,但是,在看过《实现领域驱动设计》这本书后,我发现:我的代码根本不是面向对象。

“习惯了先建表,再写实体类。在业务代码里面,所有的代码都是把字段取出来计算,然后,再塞回去,碰到需要的对象就在service里面new一下。各种业务代码全部叠加在Service里面,里面充满着各种各样的逻辑代码,也许setter\getter随处可见,再过几个月,我自己也许也不清楚这些逻辑是什么含义,将来有一点调整,所有的代码都得跟着变。”

那么什么是面向对象呢?用这三个词来解释其实没错,错的的没有理解这三个词。在后面学习过程中,每一次看这三个词以及相关解释,我都有不一样的理解,理解的深度自然也不同。在学习过Spring Data JPA和DDD后,我们再来看下这三个词。

封装

封装,是面向对象的根基。这个特性并不是Java独有的,C语言这种非面向对象的语言也有。

为了更好理解,我们先回到面向对象刚刚诞生的时候。

“面向对象”这个词是由 Alan Kay 创造的,他是 2003 年图灵奖的获得者。

在他最初的构想中,对象就是一个细胞。当细胞一点一点组织起来,就可以组成身体的各个器官,再一点一点组织起来,就构成了人体。而当你去观察人的时候,就不用再去考虑每个细胞是怎样的。所以,面向对象给了我们一个更宏观的思考方式。

但是,这一切的前提是,每个对象都要构建好,也就是封装要做好,这就像每个细胞都有细胞壁将它与外界隔离开来,形成了一个完整的个体。

在 Alan Kay 关于面向对象的描述中,他强调对象之间只能通过消息来通信。如果按今天程序设计语言的通常做法,发消息就是方法调用,对象之间就是靠方法调用来通信的。但这个方法调用并不是简单地把对象内部的数据通过方法暴露。

在 Alan Kay 的构想中,他甚至想把数据去掉。因为,封装的重点在于对象提供了哪些行为,而不是有哪些数据。

也就是说,即便我们把对象理解成数据加函数,数据和函数也不是对等的地位。函数是接口,而数据是内部的实现,正如我们一直说的那样,接口是稳定的,实现是易变的。

理解了这一点,我们来看一个很多人都有的日常编程习惯。他们编写一个类的方法是,把这个类有哪些字段写出来,然后,生成一大堆 getter 和 setter,将这些字段的访问暴露出去。

这种做法的错误就在于把数据当成了设计的核心,这一堆的 getter 和 setter,就等于把实现细节暴露了出去。

一个正确的做法应该是,我们设计一个类,先要考虑其对象应该提供哪些行为。然后,我们根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。

方法的命名,体现的是你的意图,而不是具体怎么做。

所以,getXXX 和 setXXX 绝对不是一个好的命名。举个例子,设计一个让用户修改密码的功能,有些人直觉的做法可能是这样:

1
2
3
4
5
6
7
8
class User { 
private String username;
private String password;
... // 修改密码
public void setPassword(final String password) {
this.password = password;
}
}

比较好的做法是,把意图表现出来:

1
2
3
4
5
6
7
8
9
class User {
private String username;
private String password;
...
// 修改密码
public void changePassword(final String password) {
this.password = password;
}
}

如果看过我的另一篇文章(DDD初探),应该会理解上述思想。而对于那些只有setter/getter的实体类,只能被称作“数据持有器”,而称不上是“对象”,这些实体类在《实现领域驱动设计》这本书中,也被称作是“贫血模型”。

这两段代码相比,只是修改密码的方法名变了,但二者更重要的差异是,一个在说做什么,一个在说怎么做,将意图与实现分离开来。

不过,在真实的项目中,有时确实需要暴露一些数据,所以,等到确实需要暴露的时候,再去写 getter 也不迟,但是写的时候一定要问问自己为什么要加 getter。如果出现set..()的方法,也是不应该的,正确的做法是用一个表示意图的名字;其次,setter 通常意味着修改,也是不建议的做法,具体可以怎样做,可以参考DDD初探

减少暴露接口

在Java中支持 public、private 这样的修饰符。程序员在日常开发中,经常会很草率地给一个方法加上 public,从而不经意间将一些本来应该是内部实现的部分暴露出去。举个例子,一个服务要停下来的时候,你可能要把一些任务都停下来,代码可能会这样写:

1
2
3
4
5
6
7
8
9
class Service {
public void shutdownTimerTask() {
// 停止定时器任务
}

public void shutdownPollTask() {
// 停止轮询服务
}
}

别人调用时,可能会这样调用这段代码:

1
2
3
4
5
6
7
class Application {
private Service service;
public void onShutdown() {
service.shutdownTimerTask();
service.shutdownPollTask();
}
}

突然有一天,你发现,停止轮询任务必须在停止定时器任务之前,你就不得不要求别人改代码。而这一切就是因为我们很草率地给那两个方法加上了 public,让别人有机会看到了这两个方法。

从设计的角度来说,我们必须谨慎地问一下,这个方法真的有必要暴露出去吗?就这个例子而言,我们可以仅仅暴露一个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Service {
private void shutdownTimerTask() {
// 停止定时器任务
}

private void shutdownPollTask() {
// 停止轮询服务
}

public void shutdown() {
this.shutdownTimerTask();
this.shutdownPollTask();
}
}

别人调用代码也会简单很多:

1
2
3
4
5
6
7
class Application {
private Service service;

public void onShutdown() {
service.shutdown();
}
}

封装的重点在于对象提供了哪些行为,而不是有哪些数据。设计一个类的方法,先要考虑其对象应该提供哪些行为,然后,根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。getter 和 setter 是暴露实现细节的,尽可能不提供,尤其是 setter。

封装,除了要减少内部实现细节的暴露,还要减少对外接口的暴露。一个原则是最小化接口暴露。有了对封装的理解,即便我们用的是 C 语言这样非面向对象的语言,也可以按照这个思路把程序写得更具模块性。

继承

说到继承,很多讲面向对象的教材一般会这么讲,给你画一棵树,父类是根节点,而子类是叶子节点,显然,一个父类可以有许多个子类。父类是干什么用的呢?就是把一些公共代码放进去,之后在实现其他子类时,可以少写一些代码。

所以,在很多人的印象中,继承就是一种代码复用的方式。但是,把实现继承当作一种代码复用的方式,并不是一种值得鼓励的做法。

一方面,继承是很宝贵的,尤其是 Java 这种单继承的程序设计语言。每个类只能有一个父类,一旦继承的位置被实现继承占据了,再想做接口继承就很难了。

另一方面,实现继承通常也是一种受程序设计语言局限的思维方式,有很多程序设计语言,即使不使用继承,也有自己的代码复用方式。所以,继承也并不是面向对象独有的。

在七大设计原则中,有一个合成复用原则,提倡尽量使用组合或者聚合关系实现代码复用,少使用继承。也就是说,如果一个方案既能用组合实现,也能用继承实现,那就选择用组合实现。

到这里已经清楚了,代码复用并不是使用继承的好场景。

所以,要写继承的代码时,先问自己,这是接口继承,还是实现继承?如果是实现继承,那是不是可以写成组合?

多态

在论述多态在面向对象中的作用前,我们先来回顾下什么是多态,举个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Shape {
// 绘图接口
void draw();
}

class Square implements Shape {
void draw() {
// 画一个正方形
}
}

class Circle implements Shape {
void draw() {
// 画一个圆形
}
}

顾名思义,一个接口,多种形态。同样是一个绘图(draw)的方法,如果以正方形调用,则绘制出一个正方形;如果以圆形调用,则画出的是圆形。

前面我们提到,继承有两种,实现继承和接口继承。其中,实现继承尽可能用组合的方式替代继承。而接口继承,主要是给多态用的。

这里面的重点在于,这个继承体系的使用者,主要考虑的是父类,而非子类。就像下面这段代码里,我们不必考虑具体的形状是什么,只要调用它的绘图方法即可。

1
2
Shape shape = new Squre();
shape.draw();

这种做法的好处就在于,一旦有了新的变化,比如,需要将正方形替换成圆形,除了变量初始化,其他的代码并不需要修改。不过,这是任何一本面向对象编程的教科书上都会讲的内容。

那么,问题来了。既然多态这么好,为什么很多程序员不能在自己的代码中很好地运用多态呢?因为多态需要构建出一个抽象。构建抽象,需要找出不同事物的共同点,而这是最有挑战的部分。

而遮住程序员们双眼的,往往就是他们眼里的不同之处。在他们眼中,鸡就是鸡,鸭就是鸭。

在构建抽象上,接口扮演着重要的角色。首先,接口将变的部分和不变的部分隔离开来。不变的部分就是接口的约定,而变的部分就是子类各自的实现。

在软件开发中,对系统影响最大的就是变化。有时候需求一来,你的代码就要跟着改,一个可能的原因就是各种代码混在了一起。

比如,一个通信协议的调整需要你改业务逻辑,这明显就是不合理的。对程序员来说,识别出变与不变,是一种很重要的能力。

其次,接口是一个边界。无论是什么样的系统,清晰界定不同模块的职责是很关键的,而模块之间彼此通信最重要的就是通信协议。这种通信协议对应到代码层面上,就是接口。

所以,要想理解多态,首先要理解接口的价值,而理解接口,最关键的就是在于谨慎地选择接口中的方法。

这样说就能很好地理解一个编程原则了:面向接口编程。面向接口编程的价值就在于多态,也正是因为有了多态,一些设计原则,比如,开闭原则、接口隔离原则才得以成立,相应地,设计模式才有了立足之本。

面向对象起源的宏观视角:细胞->器官->人体, 日常都是跟人宏观的沟通,不用跟这个人的细胞去沟通。

细胞有细胞壁隔离封装,细胞组成的器官有各自器官的功能和边界,器官之间通过接口来沟通而不是器官内的细胞,如各个器官有各自的对外连接的血管就是提供的接口。

每个器官对外暴露的接口而不是细胞,封装思想;

各种细胞可能继承了同样的一种细胞的特性,继承思想;

这些细胞同一个行为可能有有不同的实现方式,比如有的细胞吃饭靠吞噬,有的细胞吃饭靠供血,多态思想。

二. 模型分类

模型是一种知识形式,它对知识进行了选择性的简化和有意的结构化,从而解决信息超载的问题。模型便于人们理解信息的意义,并专注核心问题。

建模过程一般由分析活动设计活动实现活动组成。每一次建模活动都是一次对知识的提炼和转换,并产生相应的模型,即分析模型设计模型实现模型

建模过程并非是分析、设计和实现单向的前后串行过程,而是相互影响,不断切换和递进的关系。模型驱动设计的建模过程是:分析中蕴含了设计,设计中夹带了实现,甚至实现后还要回溯到设计和分析的一种迭代的螺旋上升的演进过程。

根据分解问题的视角不同,我们日常建立的模型可以大致分为以下三类:

  • 数据模型:将问题空间抽取出来的概念视为数据信息,在求解过程中关注数据实体的样式和它们之间的关系,由此建立的模型就是数据模型。
  • 服务模型:将每个问题视为目标系统为客户端提供的服务,在求解过程就会关注客户端发起的请求以及服务返回的响应,由此建立的模型就是服务模型。
  • 领域模型:围绕问题空间的业务需求,在求解过程中力求提炼出表达领域知识的逻辑概念,由此建立的模型就是领域模型。

三. 尝试采用DDD进行系统设计

本节中,将使用一个简单的例子,来论述如何采用DDD进行系统设计,并使用Sping Data JPA作为持久层,本例子中,仅仅属于入门级别的应用,不会涉及领域事件、领域服务等等。

在以往很多系统中,开发初期,往往是先根据数据库范式设计好数据库,并定义好每个表有哪些字段,定义字段的类型,大小,设计表与表之间如何关联等等,这属于典型的数据模型。但是在DDD中并不会这样做,初期并不关心数据库,而是关注如何进行领域建模,而这就是典型的面向对象思想,此时选择Spring Data JPA可以真正做到面向对象编程。

3.1 什么是领域模型

领域模型由领域分析模型领域设计模型以及领域实现模型共同组成,它们也分别是领域分析建模、领域设计建模和领域实现建模三个建模活动的产物。

领域模型并非由开发团队单方面输出的产物,而是由产品、领域专家和开发团队共同协作的结果。

领域专家通过领域模型能够判断系统所支持的领域能力,以及由此编排出来的上层业务能力;开发团队通过领域模型能够形成基本的代码框架(包括架构分层,每层需要定义的接口,接口的命名等)。

3.2 如何进行建模

要进行领域建模,首先就要明确限界上下文。在限界上下文内,以“领域”为中心,提炼业务服务中的领域概念,确定领域概念之间的关系,最终形成领域分析模型。领域分析模型描述了各个限界上下文中的领域概念,以及领域概念之间的关系。

3.2.1 名词建模

找到业务服务中的名词,在统一语言指导下将其映射为领域概念。

3.2.2 动词建模

识别动词并不是为领域模型对象分配职责、定义方法,而是将识别出来的动词当做一个领域行为,然后看它是否产生了影响系统计算的过程数据。若存在,则将这些过程数据作为领域概念放到领域分析模型中。

注意,这里的过程数据是要求会对企业运营和管理产生影响的数据,比如在常见的学生管理系统中学生提交请假申请,就会产生申请单这个过程数据,而请求流水记录、任务执行记录都不属于过程数据。因为只有申请单会对本次审批流程以及后续结果产生影响,其他不过是一次记录而已。

动词建模通过分析领域行为是否产生过程数据来找到隐藏的领域概念,弥补了名词建模的不足。对于会产生领域事件的动词,一般可以抽象出一个已完成该动作的状态,DDD拥有众多值得深入研究的方向,所以这里不继续深入探讨领域事件等。

3.2.3 提取隐式概念

除了“名词”和“动词”,概念中其他重要的类别也可以在模型中显式地表现出来,主要包括:约束规格

约束

约束一般是对领域概念的限制,我们可以将约束条件提取到自己的方法中,并通过方法名显式地表达约束的含义。比如学生管理系统中关于绩点运算的约束,绩点不能超过 5.0 等等。

规格

直接上概念可能不好理解,但是举个例子就很容易明白,规格一般有如下三种用法:

  • (验证)验证对象,检查它是否能满足某些标准,比如学生管理系统中成绩实体在修改分数时就需要通过规约判断当前是否满足打分标准,比如已经打过分了,这时候就不能打分,要进行修改操作;
  • (选择)从集合中选择一个符合要求的对象,可以搭配资源库使用(暂时可以理解为持久层,但是在DDD中DAO和资源库是不一样的);
  • (根据要求来创建)指定在创建新对象时必须满足某种要求(比如新建一个学生的成绩,必须保障数据库中没有成绩)。

3.2.4 归纳抽象

对于有定语修饰的名词,要注意分辨它们是类型的差异,还是值的差异。如配送地址和家庭地址,订单状态和商品状态。如果是值的差异,类型相同,应归并为一个领域概念(如,配送地址和家庭地址);而类型不同,则不能合并(如,订单状态和商品状态)。

当定语修饰的名词中,定语表示的是不同的限界上下文,且名词相同时(即名称相同、含义不同的领域概念),我们应该尽可能调整命名,确保含义不同的领域概念的名称不同,以避免不必要的歧义和沟通上的误解。比如:商品的订单和库存的订单在特定限界上下文内都可以命名为 order,但是如果把库存的订单改为库存的配送单 delivery 效果会更好。

3.2.5 确认关系

根据业务需求和领域知识,判断领域概念之间是否存在关联。且对于 1:N, N:1, M:N 的关联关系,我们需要判断是否可以为这些关联关系定义一个新的类型,比如作品与读者存在 1:N 的关系,我们可以定义“订阅”这个概念来描述这种关系。

但是,我们需要尽量避免对象中的双向关系,即对象 A 关联对象 B,而对象 B 关联对象 A。当两个对象存在双向关系时,会为管理他们的生命周期带来额外的复杂度。我们应该规定一个遍历方向,来表明一个方向的关联比另一个方向的关联更有意义且更重要,比如学生管理系统中,成绩会关联课程(成绩实例中包含课程 ID),而课程不会关联成绩。当然,当双向关系是领域的一个概念时,我们还是应该保留它。

3.2.6 学生管理系统的领域分析模型

通过名词建模,动词建模和归纳抽象后,可提炼出以下领域对象:成绩(Result)、绩点(gpa)、总成绩(total result)、总绩点(total gpa)、学年(school year)、学期(semester)、课程(course)、学分(credit)、申请单(application receipt),邮件(mail),排名(rank),申请单状态(application receipt status)

这就是一个简单的领域建模,但是,不知道为什么这样建模也是不行的,下面我们来分析下。

在此之前,由于上一篇文章详细介绍过实体、值对象、贫血模型等,但是没有结合例子来讲解,而对于聚合、工厂、资源库、领域服务、领域事件则完全没有介绍,虽然后续也会详细介绍,但是这里也有必要简单了解一下。

四. 领域设计建模

领域设计建模的核心工作就是设计聚合设计服务,在正式着手设计前,我们先简单了解一下一些术语。

4.1 设计要素

领域驱动设计强调以“领域”为核心驱动力。设计领域模型时应该尽量避免陷入到技术实现的细节约束中。但很多时候我们又不得不去思考一些非领域相关的问题:

  • 领域模型对象的加载以及对象间的关系如何处理?
  • 领域模型对象如何实现数据的持久化?
  • 领域模型对象彼此之间如何做到弱依赖地完成状态的变更通知?

这几个问题有些也是我在了解DDD时所困惑的,为了解答上述的四个问题,DDD 提供了很多的设计要素,它们能够帮助我们在不陷入到具体技术细节的情况下进行领域模型的设计

4.1.1 实体

实体的核心三要素:身份标识属性领域行为,其中唯一陌生的就是领域行为了。

领域行为:体现了实体的动态特征。实体具有的领域行为一般可以分为:

  • 变更状态的领域行为:变更状态的领域行为体现的是实体/值对象内部的状态转移,对应的方法入参为期望变更的状态。(有入参,无出参);
  • 自给自足的领域行为:自给自足意味着实体对象只操作了自己的属性,不外求于别的对象。(无入参);
  • 互为协作的领域行为:需要调用者提供必要的信息。(有入参,有出参);
  • 创建行为:代表了对象在内存的从无到有。创建行为由构造函数履行,但对于创建行为较为复杂或需要表达领域语义时,我们可以在实体中定义简单工厂方法,或使用专门的工厂类进行创建。(有出参,且出参为特定实体实例)。
4.1.2 值对象

在《实现领域驱动设计》一书中,其实并没有讲清楚或讲的很容易理解:一个领域概念到底该用值对象还是实体类型判断依据:

  • 业务的参与者对它的相等判断是依据值还是依据身份标识;
  • 确定对象的属性值是否会发生变化,如果变化了,究竟是产生一个完全不同的对象,还是维持相同的身份标识;
  • 生命周期的管理。值对象无需进行生命周期管理。

值对象具有不变性。值对象完成创建后,其属性和状态就不应该再进行变更了,如果需要更新值对象,则通过创建新的值对象进行替换。

由于值对象的属性是在其创建的时候就完成传入的,那么值对象所具有的领域行为大部分情况下都是“自给自足的领域行为”,即入参为空。这些领域行为一般提供以下的能力。

  • 自我验证:验证传入值对象的外部数据是否正确,一般在创建该值对象时进行验证。

  • 自我组合:当值对象涉及到数值运算时,可以定义相同类型值对象的方法,使值对象具有自我组合能力。比如学生管理系统中,在统计成绩时会涉及学分相加的运算,因此我们可以将相加运算定义为可组合的方法,便于调用者使用。

  • 自我运算:根据业务规则对属性值进行运算的行为。

在进行领域设计建模时,要善于运用值对象去表达细粒度的领域概念。值对象的优势有:

  • 值对象在类型层面就可以表达领域概念,而不仅仅依赖命名;
  • 值对象可以封装领域行为,进行自我验证,自我组合,自我运算。
4.1.3 聚合

聚合的基本特征:

  • 聚合是包含了实体和值对象的一个边界。
  • 聚合内包含的实体和值对象形成一棵树,只有实体才能作为这棵树的根,因此实体一般也称作根实体。
  • 外部对象只允许持有聚合根的引用,以起到边界控制作用。
  • 聚合作为一个完整的领域概念整体,其内部会维护这个领域概念的完整性。
  • 由聚合根统一对外提供履行该领域概念职责的行为方法,实现内部各个对象之间的行为协作。
4.1.4 工厂

聚合中的工厂:一个类或方法只要封装了聚合对象的创建逻辑,都可以认为是工厂。表现形式如下:

  • 引入专门的聚合工厂(尤其适合需要通过访问外部资源来完成创建的复杂创建逻辑)
  • 聚合自身担任工厂(简单工厂模式)
  • 使用构建者组装聚合

这里工厂创建的基本单元是聚合,而非实体,注意与实体中的创建行为区分。

4.1.5 资源库

资源库是对数据访问的一种业务抽象,用于解耦领域层与外部环境,使领域层变得更为纯粹。资源库可以代表任何可以获取资源的仓库,例如网络或其他硬件环境,而不局限于数据库。

一个聚合对应一个资源库。领域驱动设计引入资源库,主要目的是管理聚合的生命周期。资源库负责聚合记录的查询与状态变更,即“增删改查”操作。资源库分离了聚合的领域行为和持久化行为,保证了领域模型对象的业务纯粹性。

值得注意的是,资源库的操作单元是聚合。当我们定义资源库的接口时,接口的入参应该为聚合的根实体。如果要访问聚合内的非根实体,也只能通过资源库获得整个聚合后,将根实体作为入口,在内存中访问封装在聚合边界内的非根实体对象。

资源库与数据访问对象(DAO)的区别:

根本区别在于,数据访问对象在访问数据时,并无聚合的概念,也就是没有定义聚合的边界约束领域模型对象,使得数据访问对象的操作粒度可以针对领域层的任何模型对象。数据访问对象(DAO)可以自由地操作实体和值对象。没有聚合边界控制的数据访问,会在不经意间破坏领域概念的完整性,突破聚合不变量的约束,也无法保证聚合对象的独立访问与内部数据的一致性。

其次,资源库是基于领域模型对存储系统进行的抽象,因此资源库中的方法命名可以表达领域概念;而数据访问对象(DAO)是存储系统对外暴露的抽象,其方法命名更贴合数据库本身的操作。

4.1.6 领域服务

聚合通过聚合根的领域行为对外提供服务,而领域服务则是对聚合根的领域行为的补充。因此,我们应该尽量优先通过聚合根的领域行为来满足业务服务

那什么场景下我们会需要用到领域服务呢?有如下两个:

  • 生命周期管理。为了避免领域知识的泄露,应用服务不会直接引用聚合生命周期相关的服务(工厂、资源库接口),而聚合根实体一般不会依赖资源库接口,此时就需要领域服务进行组合对外暴露。
  • 依赖外部资源为了保证聚合的稳定性,聚合根实体不会依赖防腐层接口。因此,当聚合对外暴露的服务需要设计外部资源访问时,就需要通过领域服务来完成。
4.1.7 领域事件

领域事件属于领域层的领域模型对象,由限界上下文中的聚合发布,有需要的聚合(同一限界上下文/不同限界上下文)可以进行消费。而当一个事件由应用层发布,则该事件为应用事件。

引入领域事件首要目的是更好地跟踪实体状态的变更,并在状态变更时,通过事件消息的通知完成领域模型对象之间的协作。

领域事件的特征

  • 领域事件代表了领域的概念;
  • 领域事件是已经发生的事实(表示事件的名称应该是过去时,比如 Committed);
  • 领域事件是不可变的领域对象;
  • 领域事件会基于某个条件而触发。

领域事件的用途

  • 发布状态变更;
  • 发布业务流程中的阶段性成果;
  • 异步通信。

领域事件应该包含:

  • 身份标识,即事件 ID,为通用类型的身份标识;
  • 事件发生的时间戳,便于记录和跟踪;
  • 属性需要针对订阅者的需求,在增强事件反向查询之间进行权衡。增强事件指属性中包含订阅者所需的所有数据;反向查询则是属性包含事件 ID,当订阅者需要数据时通过事件 ID 进行反向查询。

4.2 设计聚合

在领域设计模型中,聚合是最小的设计单元。

4.2.1 设计的经验法则

这里有四条经验法则:

  1. 在聚合边界内保护业务规则不变性。
  2. 聚合要设计得小巧。
  3. 通过身份标识符关联关系其他聚合。
  4. 使用最终一致性更新其他聚合。

下面展开讲述法则 1 和法则 3。

法则 1 在聚合边界内保护业务规则不变性

法则 1 包含了两个关键点:

​ a) 参与维护业务规则不变性的领域概念应该置于同一个聚合内;

​ b) 在任何情况下都要保护业务规则不变性。比如,在学生管理系统中分数和绩点具有转换关系,这是业务规则的不变性,因此这两个概念被放在了同一个聚合边界内;当出现老师修改分数的场景时,需要保证绩点的换算同时被执行。由于这里绩点对象是值对象,不需要关心其生命周期管理的问题。当业务规则涉及到多个实体时,就需要通过本地事务来保证规则不变性(即实体间基于业务规则的数据一致性)。

法则 3 通过身份标识符关联其他聚合。

注意这里强调了关联关系,关联关系会涉及聚合 A 对聚合 B 的生命周期管理的问题,对于这种聚合间的关联关系,我们通过身份标识建立关联。而当聚合 A 引用聚合 B,但不需要对聚合 B 进行生命周期管理时,我们认为这是一种依赖关系(比如方法中的入参,而非类中的属性),对于聚合间的依赖关系,我们可以通过对象引用(聚合根实体的引用)的方式建立依赖。(PS:假设设计之初难以判断聚合之间到底是关联关系,还是依赖关系,我们就统一使用身份标识符作为关系引用即可)而这种关系,我们可以利用好Spring Data JPA作为持久层来处理,可以更方便对聚合内的实体和值对象状态进行管理。

4.3 设计步骤

4.3.1 设计对象图

分析对象是实体还是值对象。

4.3.2 分解关系薄弱处

聚合本质是一个高内聚的边界,因此我们可以根据领域对象之间关系的强弱来定义出聚合的边界。对象间的关系由强到弱可以分为:继承关系,关联关系和依赖关系。

4.3.3 调整聚合边界

根据业务规则调整聚合边界。为了维护业务规则的不变性,相关的实体应该至于同一个聚合边界内。

4.3.4 设计服务

这里的服务是对应用服务领域服务领域行为(实体提供的方法)和端口(资源库接口、防腐层接口)的统称。

4.3.5 分解任务

业务服务包含若干个组合服务,组合服务包含若干个原子服务领域行为端口都可以认为是原子服务。

4.3.6 分配职责

应用服务:匹配业务服务,提供满足业务需求的服务接口。应用服务自身并不包含任何领域逻辑,仅负责协调领域模型对象,通过它们的领域能力组合完整一个完整的应用目标。

领域服务:匹配组合服务,执行业务功能,若原子任务为无状态行为或独立变化的行为,也可以匹配领域服务。控制多个聚合与端口之间的协作,由它来承担组合任务的执行。

领域行为:匹配原子服务,提供业务功能的业务实现。强调无状态和独立变化,由实体提供。

端口:匹配原子服务,抽象对外资源的访问,主要的端口包括资源库接口和防腐层接口。

4.4 得出模型

经过上面分析,我们便得到上述模型。但是,这也并不是标准答案,软件设计本就是一个开放性的话题,可以拥有多种设计,合理即可。

4.5 服务设计

4.6 代码分层架构

4.7 代码骨架

用户接口层
1
2
3
4
5
6
7
8
9
10
11
├── controller                             //面向视图模型&资源
│ ├── ResultController.java
│ ├── assembler // 装配器,将VO转换为DTO,可以采用MapStruct实现
│ │ └── ResultAssembler.java
│ └── vo // VO(View Object)对象
│ ├── EnterResultRequest.java
│ └── ResponseVO.java
├── provider // 面向服务行为
├── subscriber // 面向事件
└── task // 面向策略
└── TotalResultTask.java
应用层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
├── assembler                              // 装配器,将DTO转换为DO
│ ├── ResultAssembler.java
│ └── TotalResultAssembler.java
├── dto // DTO(Data Transfer Object)对象
│ ├── cmd // 命令相关的DTO对象
│ │ ├── ComputeTotalResultCmd.java
│ │ ├── EnterResultCmd.java
│ │ └── ModifyResultCmd.java
│ ├── event // 应用事件相关的DTO对象, subscriber负责接收
│ └── qry // 查询相关的DTO对象
└── service // 应用服务
├── ResultApplicationService.java
├── event // 应用事件,用于发布
└── adapter
领域层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
├── result                                 // 成绩聚合
│ ├── entity // 成绩聚合内的实体
│ │ └── Result.java
│ ├── service // 领域服务
│ │ ├── ResultDomainService.java
│ │ ├── event // 领域事件
│ │ ├── adapter // 防腐层适配器接口
│ │ ├── factory // 工厂
│ │ └── repository // 资源库
│ │ └── ResultRepository.java
│ └── valueobject // 成绩聚合的值对象
│ ├── GPA.java
│ ├── ResultUK.java
│ ├── SchoolYear.java
│ └── Semester.java
基础设施实现层

该层主要提供领域层接口(资源库、防腐层接口)和应用层接口(防腐层接口)的实现。

代码组织基本以聚合为基本单元。对于应用层的防腐层接口,则直接以 application 作为包名组织。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
├── application                                  // 应用层相关实现
│ └── adapter // 防腐层适配器接口实现
│ ├── facade // 外观接口--统一接口
│ └── translator // 转换器,DO -> DTO
├── result // 成绩聚合相关实现
│ ├── adapter
│ │ ├── facade
│ │ └── translator
│ └── repository // 成绩聚合资源库接口实现
│ └── ResultRepositoryImpl.java
└── totalresult // 总成绩聚合相关实现
├── adapter
│ ├── CourseAdapterImpl.java
│ ├── facade
│ └── translator
└── repository
└── TotalResultRepositoryImpl.java
4.8 阿里COLA架构参考
  • COLA 是 Clean Object-Oriented and Layered Architecture的缩写,代表“整洁面向对象分层架构”。 目前COLA已经发展到COLA v4

  • COLA 作为应用架构,已经被选入阿里云的 Java 应用初始化的应用架构选项之一,可以使用阿里云的应用生成器:https://start.aliyun.com/bootstrap.html 生成cola应用。

  • CLOAGitHub地址

参考引用

1
2
3
【1】《架构整洁之道》Robert C.Martin
【2】https://time.geekbang.org/column/article/252598 极客时间《软件设计之美》系列文章
【3】https://mp.weixin.qq.com/s/BIYp9DNd_9sw5O2daiHmlA

1
2
3
4
5
Spring Data JPA 是一个非常常见的持久层框架,它和我们如今十分流程的DDD(Domain-Driven Design,即领域驱动设计)有着许多相同的思想。DDD是一种根据领域专家的输入对软件进行建模以匹配该领域的软件设计方法。

它主要是为了构建复杂领域,将业务的复杂性和技术的架构的实现解耦开来。DDD并不是一种具体的架构,而是一种方法论,通过边界的划分方法构建出清晰的领域和应用边界,让架构更加容易的进行演进。

DDD在软件工程领域并不是一个非常容易理解的名词,要理解DDD需要对软件设计和软件架构等领域有一定的理解,因此,我们需要先从软件设计谈起。

Eric Evans 2003 年写了《领域驱动设计》,向行业介绍了 DDD 这套方法论,立即在行业中引起广泛的关注。但Eric 在知识传播上的能力着实一般,这本 DDD 的开山之作写作质量难以恭维,想要通过它去学好 DDD,是非常困难的。

所以,在国外的技术社区中,有很多人是通过各种交流讨论逐渐认识到 DDD 的价值所在,而在国内 DDD 几乎没怎么掀起波澜。2013 年,在 Eric Evans 出版《领域驱动设计》十年之后,DDD 已经不再是当年吴下阿蒙,有了自己一套比较完整的体系。Vaughn Vernon 将十年的精华重新整理,写了一本《实现领域驱动设计》,普通技术人员终于有机会看明白 DDD 到底好在哪里了。

所以,最近几年,国内的技术社区开始出现了大量关于 DDD 的讨论。再后来,因为《实现领域驱动设计》实在太厚,Vaughn Vernon 又出手写了一本精华本《领域驱动设计精粹》,让人可以快速上手DDD

我先大致看了《领域驱动设计精粹》,总共160多页,看了大概50页的样子,感觉有点云里雾里的,没有什么实质的收获,后来就开始看《实现领域驱动设计》,这本讲的细致很多,个人感觉比精粹版的更好上手一点。

一 . 什么才是软件设计

在我们开发软件的过程中,经常会碰到许多问题,团队的成员在开发的同时也需要保证其稳定运行,但是,久而久之我们慢慢会发现软件设计的缺陷而引发的种种问题:

  • 开发人员热衷于技术并通过技术手段解决问题,而不是深入思考和设计,这会导致他们孜孜不倦地追逐技术上的新潮流。
  • 过于重视数据库,大多数解决方案的讨论都是围绕数据库和数据模型,而不是业务流程和运作方式。
  • 对于根据业务目标命名的对象和操作,开发人员没有给予应有的重视,这导致他们交付的软件和业务所拥有的心智模型之间产生巨大的分歧。
  • 开发人员在用户界面和持久层组件中构建业务逻辑,此外,开发人员也经常会在业务逻辑当中执行持久化操作。
  • 数据库查询会时常出现中断、延迟、死锁等问题,阻碍用户执行时间敏感型的业务操作。

这一切都似乎发生在“设计无法带来低成本的软件!”的观念下。这种现象在如今的软件开发大环境中屡见不鲜,而大多数软件开发人员也并不知道除此之外能否有更好的选择。

但是,臆想出来的“不做设计能省钱”的观念是一个谬论,许多程序员因为各种各样的原因而忽略了设计的重要性。

然而,在DDD项目的实施过程中,开发人员需要尽量克制这种“以技术为中心”的冲动,以防无法接受以业务为中心的核心战略举措。

“绝大部分的人错误地认为设计只关乎外观。人们只理解了表象—将这个盒子递给设计师,告诉他们:”把它变得好看一些!“这不是我们对设计的理解。

设计并不仅仅是感观,设计也是产品的工作方式。”我们不仅需要认识到设计对于产品重要性,更需要体会通过设计改变产品的内在运作方式可以有效地改善用户的体验。

我们期望团队不仅仅只是观察到它的表象,更是希望通过不断地协作认知更加清晰地描绘出其背后的运作逻辑。

二. 如何确定你需要DDD

以下是DDD的打分表,如果得分在七分以上,你或许得考虑使用DDD了

如果你的项目 得分 备注
如果你的软件完全以数据为中心,所有操作都通过对数据库的CRUD完成,那么你并不需要DDD。此时你的团队只需要一个漂亮的数据库表编辑器。换言之,你可以指望着用户对你的数据进行直接操作,包括更新和删除数据。你并不需要提供用户界面。如果你甚至可以用一个简单的数据库开发工具来完成开发,那么,你完全没有必要在DDD上浪费时间和金钱 0 这似乎是一个傻瓜化的问题,但是要分清简单和复杂的区别却不是那么容易的。并不是说只要不是纯粹的CRUD软件,便可以采用DDD。因此我们需要采用另外的方法来判别简单和复杂…
如果你的系统只有25到30个业务操作,这应该是相当简单的。这意味着你的程序中不会多于30个用例流(use case flow),并且每个用例流仅包含少量的业务逻辑。如果你可以使用Ruby on Rails或者Groovy和Grails来快速地开发出这样的系统,并且你没有感觉到由复杂性和业务变化所带来的痛苦,那么你是不需要使用DDD的。 1 这里想说的是25到30个业务方法,而不是说25到30个拥有多个方法的服务接口,后者可能是复杂的。
当你的系统中有30到40个用户故事或者用例流时,此时软件的复杂性便暴露出来了,你可以考虑采用DDD了。 2 通常情况下,复杂性并不能被及时发现。我们开发者很容易低估软件的复杂性。我们希望使用Ruby on Rails来开发软件并不代表我们就必须使用Ruby on Rails。而长远看来,这是不利的。
即便我们的软件目前并不复杂,但是之后呢?在真正的用户开始使用软件之前,我们是无法预测软件的复杂性的,但是在右边的“备注”栏中有一项可以帮助我们应对这种情况。请注意,如果有暗示说明系统已经足够复杂,这往往意味着我们的系统实际上比目前更加复杂,采用DDD吧。 3 这时我们有必要和领域专家一起探讨那些复杂的用例。如果领域专家:1.已经要求加入更复杂的功能。这表明软件已经开始变得复杂,此时单纯的CRUD是不能满足需求的。2.认为既有的功能没什么可以探讨的。此时我们的软件可能并不那么复杂。
软件的功能在接下来的几年里将不断变化,而你并不能预期这些变化只是些简单的改变。 4 DDD可以帮助你管理软件的复杂性,随着时间的推移,你可以对软件模型进行重构。
你不了解软件所要处理的领域。你的团队中也没有人曾经从事过该领域的开发工作。此时,软件很有可能是复杂的,因此你们应该讨论复杂等级。 5 你需要和领域专家一起工作了。你肯定也在前面的计分行中打了分,采用DDD吧。

通过对以上DDD计分卡打分,我们可以得出以下结论:

当我们在复杂性问题上犯错时,我们很难轻易地扭转颓势。

这意味着我们应该在项目计划早期便对简单性和复杂性做出判断,这将为我们节约很多时间和开销,并免除很多麻烦。

一旦我们做出了重要的架构决策,并且已经在该架构下进行了深入地开发,通常我们也被绑定在这个架构下了,所以在决定时一定要慎重。

如果你对以上几点产生了共鸣,表明你已经在认真地思考问题了。

三. 名词解释

总览图

领域

从广义上讲,领域(Domain)即是一个组织所做的事情以及其中所包含的一切。商业机构通常会确定一个市场,然后在这个市场中销售产品和服务。每个组织都有它自己的业务范围和做事方式。这个业务范围以及在其中所进行的活动便是领域。当你为某个组织开发软件时,你面对的便是这个组织的领域。这个领域对于你来说应该是明晰的,因为你在这个领域中工作。

在DDD中,一个领域被分成若干子域,领域模型在限界上下文中完成开发。

领域模型

领域模型是关于某个特定业务领域的软件模型。通常,领域模型通过对象模型来实现,这些对象同时包含了数据和行为,并且表达了准确的业务含义。

要真正理解领域模型没有这么简单,这里只给出一个定义,要理解这个概念需要看下面的章节

领域对象

领域对象的概念比较广泛,除了实体、值对象和聚合根外,服务也算是领域对象。领域层和应用层分别有领域服务和应用服务。

领域专家

领域专家并不是一个职位,他可以是精通业务的任何人。他们可能了解更多的关于业务领域的背景知识,他们可能是软件产品的设计者,甚至有可能是销售员。

贫血领域模型

参考第五小节

!充血模型

通用语言

通用语言是团队自己创建的公用语言,是团队共享的语言,团队中每个人都使用相同的通用语言。

通用语言也会随着时间推移而不断演化改变。通用语言也不是强加在开发者身上的晦涩业务术语,在开始的时候,通用语言可能只包含由领域专家使用的术语,但是随着时间推移,通用语言将不断壮大成长

理解通用语言要记住下面几点:

  • 这里的“通用”意思是“普遍的”,或者“到处都存在的”。
  • 通用语言在团队范围内使用,并且只表达一个单一的领域模型。
  • “通用语言”并不表示全企业、全公司或者全球性的万能的领域语言。
  • 限界上下文和通用语言间存在一对一的关系。
  • 限界上下文是一个相对较小的概念,通常比我们起初想象的要小。限界上下文刚好能够容纳下一个独立的业务领域所使用的通用语言。
  • 只有当团队工作在一个独立的限界上下文中时,通用语言才是“通用”的。
  • 虽然我们只工作在一个限界上下文中,但是通常我们还需要和其他限界上下文打交道,这时可以通过上下文映射图(后文会解释)对这些限界上下文进行集成。每个限界上下文都有自己的通用语言,而有时语言间的术语可能有重叠的地方。
  • 如果你试图将某个通用语言运用在整个企业范围之内,或者更大的、夸企业的范围内,你将失败。

限界上下文

就现在来说,可以将限界上下文看成是整个应用程序之内的一个概念性边界。这个边界之内的每种领域术语,词组或句子——也即通用语言,都有确定的上下文含义。在边界之外,这些术语可能表示不同的意思。

要真正理解限界上下文同样没有这么简单,这里只给出一个定义,要理解这个概念需要看下面的章节

子域

  • 核心子领域:能够体现系统愿景,具有产品差异化和核心竞争力的业务服务;
  • 通用子领域:包含的内容缺乏领域个性,具有较强的通用性,例如权限管理和邮件管理;
  • 支撑子领域:包含的内容多为“定制开发”,其为核心子领域的功能提供了支撑。

核心域

对于核心域,个人觉得要结合例子比较好理解,可参考第六小节的“子域和限界上下文”中提到的核心域

战略设计

在战略设计中最主要的工作只有两个:

  • 领域划分

    通过对业务的拆解以及公司团队的业务定位,将业务场景分解,识别出核心领域、通用域、支撑域。并确定领域的边界以及领域间关系。

  • 领域建模

    通过业务场景,对用户故事以及用例的分析,梳理限界上下文,确定领域边界以及上下文映射图(Context Map),建立领域模型,分析领域事件,聚合、实体、以及值对象。

战术设计

战术设计是DDD的最终落地实现的阶段:

  • 服务划分

    通过战略设计输出各个领域与限界上下文后,可以籍此进行微服务划分与设计,一个服务可以有多个聚合。

  • 领域模型

    通过战略设计中的领域建模,落地值对象、实体、领域服务、领域事件

  • 资源库

    确定聚合根之后,建立资源库,对领域对象的CRUD都通过资源库实现

  • 工厂

    负责领域对象的创建,用于封装复杂或者可能变化的创建逻辑

  • 聚合

    根据限界上下文,封装实体与值对象,并维持业务的完整性与统一性

  • 应用服务

    隔离防腐层与领域层,对领域进行服务编排与转发。

通常,战术建模比战略建模复杂。

问题空间

问题空间是领域的一部分,对问题空间的开发将产生一个新的核心域。对问题空间的评估应该同时考虑已有子域和额外所需子域。因此,问题空间是核心域和其他子域的组合。

问题空间中的子域通常随着项目的不同而不同,他们各自关注于当前的业务问题,这使得子域对于问题空间的评估非常有用。子域允许我们快速地浏览领域中的各个方面,这些方面对于解决特定的问题是必要的。

解决方案空间

解决方案空间包括一个或多个限界上下文,即一组特定的软件模型。这是因为限界上下文即是一个特定的解决方案,它通过软件的方式来实现解决方案。

SOAP

简单对象访问协议是交换数据的一种协议规范,是一种轻量的、简单的、基于XML的协议,它被设计成在WEB上交换结构化的和固化的信息。

敏捷开发流程

  1. 目标制定:通过市场调研、业务思路、风险评估制定公司规划和目标;
  2. 目标拆解:公司目标拆解到各个部门;
  3. 产品规划:产品研发部门根据目标制定产品关键路线图,这个路线图中分布着不同的产品特性和其完成时间;
  4. 组织产品待办列表:产品规划产生的需求、客户需求、市场人员收集到的缺陷等将组成产品待办列表;
  5. 需求梳理:然后产品负责人(Product Ower)对这个列表进行梳理,并在需求梳理会(Backlog Grooming Meeting)讲解具体每一个需求,团队成员根据需求的复杂程度评估每个任务的工作量,输出本次迭代的待办事项列表,完成优先级排序等工作;
  6. 迭代规划:通过Sprint计划会,明确要执行的工作、冲刺目标等,
  7. 迭代开发:期间会进行每日站会、性能测试、CodeReview、Demo、测试等工作;
  8. Sprint评审:由每个任务的负责人演示其完整的工作,由PO确定Sprint目标是否完成,版本什么时候对外发布,新增bug的紧急程度等等。
  9. 开回顾会议:回顾会议由Scrum团队检视自身在过去的Sprint的表现,包括人 、关系、过程、工具等,思考在下一个Sprint中怎么样可以表现得更好,更高效,怎么样可以和团队合作地更愉快。

四. 为什么需要DDD

第一小节总结了目前软件开发过程中常常会面临的问题,而DDD战略则可以有效解决这些问题,因此,我们需要DDD有如下原因:

  • 使领域专家和开发者在一起工作,这样开发出来的软件能够准确地传达业务规则。当然,对于领域专家和开发者来说,这并不表示单单地包容对方,而是将他们组成一个密切协作的团队。
  • “准确传达业务规则”的意思是说,此时的软件就像如果领域专家是编码人员时所开发出来的一样。
  • 可以帮助业务人员自我提高。没有任何一个领域专家或者管理者敢说他对业务已经了如指掌了,业务知识也需要一个长期的学习过程。在DDD中,每个人都在学习,同时每个人又是知识的贡献者。
  • 关键在于对知识的集中,因为这样可以确保软件知识并不只是掌握在少数人手中。
  • 在领域专家、开发者和软件本身之间不存在“翻译”,意思是当大家都使用相同的语言进行交流时,每人都能听懂他人所说。
  • 设计就是代码,代码就是设计。设计是关于软件如何工作的,最好的编码设计来自于多次试验,这得益于敏捷的发现过程。
  • DDD同时提供了战略设计和战术设计两种方式。战略设计帮助我们理解哪些投入是最重要的;哪些既有软件资产是可以重新拿来使用的;哪些人应该被加到团队中?战术设计则帮助我们创建DDD模型中各个部件。

4.1 业务价值

软件开发者不应该只是热衷于技术,而是应该将眼界放得更宽。不管使用什么技术,我们的目的都是提供业务价值。而如果我们采用的技术确实产生了业务价值,人们就没有理由拒绝我们在技术上的建议。如果我们提供的技术方案比其他方案更能够产生业务价值,那么我们的业务能力也将增强。

使用DDD能收获的:

  1. 一个非常有用的领域模型

  2. 你的业务得到了更准确的定义和理解

  3. 领域专家可以为软件设计做出贡献

  4. 更好的用户体验

  5. 清晰的模型边界

  6. 更好的企业架构

  7. 敏捷、选代式和持续建模

DDD强调将精力花在对业务最有价值的东西上。我们并不过度建模,而是关注业务的核心域

有些模型是用来支撑核心域的,它们同样是重要的。但是,这些起支撑作用的模型在优先级上没有核心域高。

4.2 通用语言的好处

当人们对自己的核心业务有了更深的了解时,业务价值自然就出来了。领域专家并不总是同意某些概念和术语,有时,分歧源自于领域专家们在其他公司工作时所积累起来的经验,而有时分歧则源自于公司内部。

不管如何,当领域专家们在起工作时,他们最终将达成一致意见,这对于整个公司来说都是件好事。开发者和领域专家共享同一套交流语言,领域专家将知识传递给开发者。

开发者总是会离开的。有可能去接触一个新的核心域。也有可能跳槽到其他公司。这时培训和工作移交也将变得更加简单。而“只有少数人才了解模型”的情况将大大减少。领域专家、剩下的开发者和新进人员可以继续使用通用语言进行交流。

五. 什么是贫血领域模型

5.1 贫血领域模型简介

领域对象病历表
软件组件经常使用的领域对象是否包含了系统主要的业务逻辑,并且多数情况下你需要调用那些getter和setter?你可能会将这样的客户代码称为服务层(Service Layer)或者应用层(Application Layer)代码。也或者,如果这描述的是你的用户界面,请回答“Yes”,然后好好反省一下,告诚自己一定不要再这么做了。
你的领域对象中是不是主要是些公有的getter和setter方法,并且几乎没有业务逻辑,或者甚至完全没有业务逻辑——对象嘛,主要就是用来容纳属性值的?
提示:正确的答案是:要么两项均为”Yes”,要么均为“No”

如果你对以上两个问题的回答都是“No”,表明你的领域对象是健康的。如果都是“Yes”,表明你的领域对象已经病得不轻了,这便是贫血对象

如果你对其中一个回答“Yes”,而另一个回答“No”,你可能是在自欺欺人。

正如[Fowler, Anemic]所说,贫血领域对象是不好的,因为你花了很大的成本来开发领域对象,但是从中却获益甚少。比如,由于存在对象-关系阻抗失配(Object-Relational Impedance) ,开发者需要将很多时间花在对象和数据存储之间的映射上。这样的代价太大,而收益太小。我得说,你所说的领域对象根本就不是领域对象,而只是将关系型数据库中的模型映射到了对象上而已。

这样的领域对象更像是活动记录(Active Record),此时你可以对架构做个简化,然后使用事务脚本进行开发

5.2 活动记录、事物脚本和领域模型的关系

历史上,事务脚本是第一个广泛应用的业务逻辑模式。后来出现了基于表数据的表模块模式,仍然属于过程式模式,但是加入了一些面向对象思维。

在面向对象开发兴起之后,出现了基于对象的业务逻辑模式,最简单的对象模型就像是数据库表的数据模型,这里的对象就是数据库中的记录,并加了一些额外的方法,这种模式通常叫做活动记录模式

随着业务逻辑的复杂性越大,软件的抽象程度越高,这时就应该从领域着眼,创建一个领域驱动的对象模型,这种模式通常叫做领域模型

事务脚本模式鼓励你放弃所有的面向对象设计,将业务组件直接映射到需要的用户操作上。该模式的关注点在于用于通过表现层所能执行的操作,并为每个操作编写一个专门的方法,这就是事务脚本。不过数据访问层通常被封装到另一些组件中,并不属于脚本的一部分。

事务脚本就是一个简单的过程式模型,简单是事务脚本最值得一提的优势,对于逻辑不多,时间紧迫且依赖于强大的IDE的项目,事务脚本是其理想的选择。简单既是事务脚本的最大优势,同时也成为了它最大的劣势。事务脚本有造成代码重复的潜质,你会很容易的得到一系列完成类似任务的事务,最终应用程序变成了一团混乱的子程序组合。

5.3 为什么会有贫血领域模型

如果说贫血领域对象是由设计不当造成的,为什么还有如此多的人认为他们的领域对象是健康的呢?其中一个原因是:贫血领域对象反映了一种自然的过程式的编程风格,但这并不认为这是首要原因。

软件业中有很多开发者都是学着示例代码做开发的,这并不是什么坏事,只要示例代码本身是好的。然而,通常情况是,示例代码只是用尽可能简单的方式来展示某个特定的概念或者API特性,而并不强调要遵循多好的设计原则。

一些极度简化的示例代码总是包含了大量的getter/setter,于是这些getter/setter随着示例代码每天被程序员们原封不动地来回复制。还有历史的影响。Microsoft的Visual Basic对我们现在的软件开发产生了很大的影响。并不是说Visual Basic是门不好的语言和集成开发环境(IDE),因为它的确是种高效的开发方式,并且在某些方面对软件开发产生过正面的影响。

当然,有些人可能会拒绝Visual Basic的直接影响,但是最终它却间接地影响着每一个程序员。再比如现在十分流行的IntelliJ IDEA 也可以十分便捷地生成getter/setter,再或者是如今十分流行的Lombok插件,只需要一个@Data注解便可以做到,但是也间接埋下了隐患。

那这和贫血领域对象有什么关系呢? JavaBean标准最早是用来辅助Java的可视化设计工具的旨在将Microsoft的Active X开发方式带到Java平台。Java此举希望开创一个第三方自定义控件市场,就像Visual Basic一样。此后不久,几乎所有的框架和类库都涌入到了JavaBean潮流中,其中包括Java本身的SDK/JDK和第三方类库,比如Hibernate。在.NET平台推出之后,这样的趋势还在继续。

在早期的Hibernate版本中,所有需要持久化的领域对象都必须暴露公有的getter/setter,不管是对于简单类型的属性,还是对复杂类型皆如此。

这意味着,即便你希望将自己的POJO (Plain Old Java Object)设计成富含行为的对象,你都必须将对象的内部暴露给Hibernate以保存或重建对象。诚然,你可以隐藏公有的JavaBean接口,但是多数开发者都懒得这样做,或者甚至都不知道为什么应该这样做。

此外,多数的Web框架依然只支持JavaBean规范。如果你想将一个Java对象显示在网页上,该Java对象最好是支持JavaBean规范的。如果你想将HTML表单中的数据传到一个Java对象中,该Java对象也最好是支持JavaBean规范的。市场上的许多框架都要求对象暴露公有属性。这样一来,多数开发者只能被动地接受那些贫血对象。于是我们便到了“到处都是贫血对象”的地步。

如今Hibernate可配置:

的属性access可以控制类属性的访问方式,缺省为property:

  1. access=”field”:表示让hibernate通过反射的方式直接访问field,丢失封装性;
  2. access=”property”:表示让hibernate通过类对外暴露的getter/setter访问field,推荐;

5.4 代码中的贫血对象

当你在阅读一个贫血领域对象的示例代码时,你通常会看到类似如下的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Transactional
public void saveCustomer(String customerId,String customerFirstName, String customerLastName,String streetAddressl,
String streetAddress2,String city, String stateorProvince,
String postalCode, String country,String home Phone,
String mobilePhone,String primaryEmailAddress,
String secondaryEmailAddress) {
Customer customer = customerDao.readCustomer(customerId);
if (customer == null) {
customer = new Customer();
customer.setCustomerId(customerId);
}
customer.setCustomerFirstName(customerFirstName);
customer.setCustomerLastName(customerLastName);
customer,setStreetAddress1(streetAddress1);
customer.setstreetAddress2(streetAddress2);
customer.setcity(city);
customer.setstateorProvince(stateorProvince);
customer.setPostalcode(postalCode);
customer.setCountry (country);
customer.setHomePhone(homePhone);
customer.setMobilePhone(mobilePhone);
customer.setPrimaryEmailAddress(primaryEmailAddress);
customer.setSecondaryEmailAddress(secondaryEmailAddress);
customerDao.saveCustomer(customer);
}

以上代码但是却帮助我们看到了一个欠妥的设计,我们可以将其重构成更好的模型。这里我们关注的并不是如何保存Customer数据,而是如何向模型中添加业务价值,即便就这个例子本身来说意义并不大。

以上代码完成了什么功能呢?事实上,以上代码的功能是相当强大的。

不管个Customer是新建的还是先前存在的;不管是Customer的名字变了还是他搬进了新家;不管是他的家用电话号码变了还是他有了新的移动电话;也不管他是改用Gmail还是有了新的E-mail地址,这段代码都会保存这个Customer。

但是,真是这样的吗?其实,我们并不知道saveCustomer()方法的业务场景。为什么一开始会创建这个方法?有人知道它的本来意图吗,还是它原本就是用来满足不同业务需求的?几周或几个月之后,我们便将这些忘得一干二净了。下面请看看该方法的下一个版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Transactional
public void savecustomer(String customerId,String customerFirstName,
String customerLastName,String streetAddressl,
String streetAddress2,String city, String stateorProvince,
String postalCode, String country,String home Phone,
String mobilePhone, String primaryEmailAddress,
String secondaryEmailAddressCustomer){
customer = customerDao.readCustomer(customerId)
if (customer == nul1) {
customer = new Customer();
customer.setCustomerId(customerId);
}
if (customerFirstName != null){
customer.setCustomerFirstName(customerFirstName);
}
if (customerLastName != null){
customer. setCustomerLastName(customerLastName);
}
if (streetAddressl != null){
customer.setstreetAddress1(streetAddress1);
}
if (streetAddress2 != null){
customer.setStreetAddress2(streetAddress2);
}
if (city != null){
customer.setcity(city);
}
if (stateorProvince != null){
customer.setStateOrProvince(stateorProvince);
}
if (postalCode != null){
customer.setPostalCode (postalCode);
}
if (country != null){
customer.setcountry (country);
}
if (home Phone != null){
customer.setHome Phone (home Phone);
}
if (mobilePhone != null){
customer. setMobilePhone (mobilePhone);
}
if (primaryEmailAddress != null){
customer.setPrimaryEmailAddress (primaryEmailAddress);
}
if (secondaryEmailAddress != null) {
customer.setsecondaryEmailAddress(secondaryEmailAddress);
}
customerDao.saveCustomer (customer);
}

以上方法还算不上糟糕到了极点。很多时候数据-映射(datamapping)代码将变得非常复杂,此时大量的业务逻辑便不能反映在代码里了。

现在,除了customerld之外,所有的参数都是可选的,我们可以在某些业务场景下使用该方法。但是,我们就能说这是好的代码吗?我们如何测试这段代码以保证在错误的业务场景下该段代码不应该保存一个Customer呢?都不用讨论过多的细节我们便知道,在很多情况下该方法是不能正常工作的。

可能数据库约束会防止对非法状态的保存,但你是不是又得去查看数据库啦?你会在Java对象属性和数据库表的列名之间辗转反侧,然后可能发现你缺少数据库约束或者约束并不完全。

你可能会查看很多客户代码,然后比较代码历史,找出saveCustomer()的来龙去脉。你会发现,没有人能够解释这个方法为什么会成为现在这个样子,也没有人知道究竟有多少客户代码在正确地使用saveCustomer()方法。要自己去搞明白这里,你需要花费大量的时间。

这个时候,领域专家是帮不上忙的,因为他们看不懂代码。即便领域专家能够看懂代码,他可能也会被这段代码搞得一头雾水。我们难道就不能用另外一种方式来改善这段代码吗?如果可以,怎么修改?

上面的saveCustomer()至少存在三大问题:

  1. saveCustomer()业务意图不明确。
  2. 方法的实现本身增加了潜在的复杂性。
  3. Customer领域对象根本就不是对象,而只是一个数据持有器(data holder)。

也许你会想,“我们的设计都是在白板上进行的啊。我们会绘制设计很多框图,只有大家都达成一致时,我们才开始编码实现。”

如果情况是这样,那么不要将设计和实现分开。在实施DDD时,设计就是代码,代码就是设计。换句话说,白板图并不是设计,而只是我们讨论模型的一种方式。

事实上,我平时采用的事物脚本模式是过程式编程,真正的面向对象编程是领域模型,而我之前一直认为将业务逻辑分层,创建几个类就是面向对象编程,其实不是这样的。

5.5 改造

现在,我们重新设计saveCustomer(),来看一下上述例子通过DDD改造之后的样子:

1
2
3
4
5
6
7
8
9
10
11
public interface Customer{
public void changePersonalName( String firstName, String lastName);
public void postalAddress(PostalAddress postalAddress);
public void relocateTo(PostalAddress changedPostalAddress);
public void changeHomeTelephone(Telephone telephone);
public void disconnectHomeTelephone();
public void changeMobileTelephone(Telephone telephone);
public void disconnectMobileTelephone();
public void primaryEmailAddress(EmailAddress emailAddress);
public void secondaryEmailAddress(EmailAddress emailAddress);
}

当然,以上的Customer并不是一个完美的模型,然而在实施DDD时,对设计的反思正是我们所期望的。

作为一个团队,我们可以自由地讨论什么样的模型才是最好的,在对通用语言达成了一致之后,才开始着手开发。然而,即便我们可以对通用语言进行一遍又一遍地提炼,此时上面的例子已经能够反映出一个Customer应该支持的业务操作了。

另外,我们还应该知道,对领域模型的修改也将导致对应用层的修改。每一个应用层的方法都对应着一个单一的用例流:

1
2
3
4
5
6
7
8
@Transactional
public void changeCustomerPersonalName(String customerId, String customerFirstName,String customerLastName){
Customer customer = customerRepository.customerofId (customerId);
if(customer null){
throw new IllegalstateException("Customer does notexist.");
}
customer.changePersonalName(customerFirstName, customerLastName);
}

这和最开始的saveCustomer()例子是不同的,在那个例子中,我们使用了同一个方法来处理多个用例流。

在这个新的例子中,我们只用一个应用层方法来修改Customer的姓名,除此之外,该方法别无其他业务功能。

因此,在使用DDD时,我们应该对照着模型的修改相应地修改应用层。同时,这也意味着用户界面所反映的用户操作也变得更加狭窄。但是无论如何,这个特定的应用层方法不再要求我们在用户姓名参数之后跟上10个null了。

5.6 常见写法举例并对其改造

如果我们只是对领域模型提供getter和setter会怎么样?

答案是,结果我们只是在创建纯数据模型。

看看下面的两个例子,思考一下,哪一个在设计上是欠妥的,哪一个对客户代码更有益。

在这两个例子中是一个Scrum(敏捷开发)模型,我们需要将一个待定项(Backlog Item)提交到冲刺(Sprint, 见第二节敏捷开发流程)中去。

第一个例子

实体类代码通常如下:

1
2
3
4
5
6
7
8
9
10
11
public class BacklogItem extends Entity {
private SprintId sprintId;
private BacklogItemStatusType status;
public void setsprintId(SprintId sprintId){
this.sprintId = sprintId;
}
public void setstatus(BacklogItemStatusType status){
this.status = status;
}
...
}

客户代码如下:

1
2
backlogItem.setSprintId(sprintId);
backlogItem.setStatus(BacklogItemStatusType.COMMITTED);

第二个例子:

实体类代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class BacklogItem extends Entity{
private SprintId sprintId;
private BacklogItemStatusType status;
public void commitTo (Sprint aSprint){
if (!this.isScheduledForRelease()){
throw new IllegalStateException(
"Must be scheduled for release to commit to sprint.");
}
if (this.isCommittedToSprint()) {
if (!aSprint.sprintId().equals(this.sprintId ())){
this.uncommitFromSprint();
}
}
this.elevateStatuswith(BacklogItemStatus.COMMITTED);
this.setSprintId(aSprint.sprintId());
...
}

客户代码如下:

1
backlogItem.commitTo (sprint);

第一个例子采用的是以数据为中心的方式,此时客户代码必须知道如何正确地将一个待定项提交到冲刺中,这样的模型是不能称为领域模型的。

如果客户代码错误地修改了sprintld,而没有修改status会发生什么呢?或者,如果在将来有另外个属性需要设值时又该怎么办?

我们需要认真分析客户代码来完成从客户数据到BacklogItem属性的映射。这种方式同时也暴露了BacklogItem的数据结构,并且将关注点集中在数据属性上,而不是对象行为。你可能会反驳道:” setSprintld()setStatus()就是行为啊。”

问题在于,这里的“行为”没有真正的业务价值,它并没有表明领域模型中的概念一一此处即“将待定项提交到冲刺中”。

开发者在开发客户代码时,他并不清楚到底需要为Backlogltem的哪些属性设值,而这样的属性有可能存在很多,因为这是一个以数据为中心的模型。现在,我们来看看第二个例子。有别于第一个例子,它将行为暴露给客户,行为方法的名字清楚地表明了业务含义。

这个领域的专家在建模时讨论了以下需求:

允许将每一个待定项提交到冲刺中。只有在一个待定项位于发布计划(Release)中时才能进行提交。

在第二个例子中,客户代码并不需要知道提交Backlogltem的实现细节。实现代码所表达的逻辑恰好能够描述业务行为。我们很容易地添加了几行代码,以确保在发布计划之外的待定项是不能被提交的。

虽然在第一个例子中,你可以修改getter以达到同样的目的,但此时该getter的职责便不单一了,它需要了解Backlogltem对象的内部状态,而不再只是对sprintldstatus属性赋值。

大白话讲我的理解:比如你需要判断这个代办项是否在发布计划中,那么你就需要使用getter取出这个“是否在发布计划的状态”判断,这样你就需要了解这个对象的内部有哪些属性,并了解属性的含义。

但是使用DDD你不需要,因为这些都是在领域建模阶段去做的。

六. 领域、子域和限界上下文

总览

从广义上讲,领域(Domain)即是一个组织所做的事情以及其中所包含的一切。商业机构通常会确定一个市场,然后在这个市场中销售产品和服务。每个组织都有它自己的业务范围和做事方式,这个业务范围以及在其中所进行的活动便是领域。当你为某个组织开发软件时,你面对的便是这个组织的领域。这个领域对于你来说应该是很清楚的,因为你在这个领域中工作。

在DDD中,一个领域被分为若干子域,领域模型在限界上下文中完成开发。事实上,在开发一个领域模型时,我们关注的通常只是这个业务系统的某个方面。试图创建一个全功能的领域模型是非常困难的,并且很容易导致失败。

其实,对领域的拆分将有助于我们成功。那么,既然领域模型不能包含整个业务系统,我们应该如何来划分领域模型?几乎所有软件的领域都包含多个子域,这和软件系统本身的复杂性没有太大关系。有时,一个业务系统的成功取决于它所提供的多种功能,而将这些功能分开对待是有好处的。

子域和限界上下文

对于如何使用子域,让我们先来看一个非常简单的例子——一个零售商在线销售产品的例子。要在这个领域中开展业务,该零售商必须向买家展示不同,类别的产品,允许买家下单和付款,还需要安排物流。

在这个领域中,零售商的领域可以分为4个主要的子域:产品目录(Product Catalog)、订单(Order)、发票(Invoicing)和物流(Shipping)。

图6.1的上半部分表示了这样一个电子商务系统。这看来是非常简单的,但是,如果我们再向其中加入一个额外的细节,以上这个例子将变得复杂起来。

思考一下,如果我们向以上的电子商务系统中再加入一个库存(Inventory)系统,如图6.1所示,情况会变得如何?我们先来看看图6.1所展示的物理子系统和逻辑子域。

该零售商的领域中只包含了三个物理系统(电子商务系统、库存系统和外部的预测系统),其中有两个是内部系统。这两个内部系统表示两个限界上下文,但是,由于现在多数软件系统并没有采用DDD,这导致了少数的几个子系统承担了太多的业务功能。

​ 图 6.1

在上面的电子商务限界上下文中,我们可以找出多个隐式的领域模型,因为它们并没有被很好地分离出来。这些领域模型被融合成了一个软件模型,这是不正确的做法。

对于该零售商来说,与其自己开发,还不如从第三方购买这么个限界上下文,因为这样所带来的问题可能会少一些。然而,不管是谁来维护这个系统,它都将承受这个大而全的电子商务模型所带来的负面影响。

随着各个逻辑模型中不断加人新的功能,它们之间的复杂关系对于每一个模型都将是阻碍,特别是需要引人另外一个逻辑模型的时候。这些问题的原因通常都是由于软件的关注点没有得到清晰的划分所致。

更不幸的是,很多软件开发者都认为将所有东西都放在一个系统里面是一件好事。他们会想:“我对电子商务系统了如指掌,我相信这个系统可以满足任何人的需求。”这是很有迷惑性的想法,因为不管你向系统中添加多少功能,你都无法满足每一个潜在客户的需求。

此外,如果不通过子域对软件模型进行划分,事情将变得更加烦琐,因为系统中的各个部分都是紧密联系在一起的。

然而,通过使用DDD战略设计工具,我们可以按照实际功能将这些交织的模型划分成逻辑上相互分离的子域,从而在一定程度上减少系统的复杂性。

逻辑子域的边界在图6.1中以虚线表示。这里,我们将第三方的模型也做了清晰地划分,但这不是我们的重点,我们的重点在于说明应该存在什么样的分离模型。

在不同的逻辑子域之间或者不同的物理限界上下文之间均画有连线,这表示它们之间存在集成关系。

现在,让我们将视线从技术复杂性转向这个零售商的业务复杂性。

该零售商的资金和仓库容量均有限。对于那些销量不佳的产品,该零售商不敢过量投人。如果有产品没有按照计划销售出去,那么该零售商的流动资金将出现问题。因此,它只能用有限的仓库来存储那些销量好的产品。事实上,导致库存清空的原因并不是产品销售得异常好,而是该零售商没有找到一种最优的库存管理方式。

零售商可以采用一个预测引擎,根据库存和销售历史来分析产品的需求量,从而达到优化库存系统的目的。

对于小型零售商来说,增加预测引擎可能意味着开发一个新的核心域,这并不是一个容易解决的问题,但是可以大大增加竞争优势。在图6.1中的第三个限界上下文便是一个外部预测系统。

订单子域和库存限界上下文向预测系统提供历史销售数据。此外,我们还需要产品目录子域来提供全局的产品条目,这将有助于预测系统在全球范围之内对产品的销售情况进行比较。这样,预测系统可以精确地计算出产品的需求量,并指导零售商制定正确的库存计划。

子域并不是一定要做得很大,也并不是需要并且包含很多功能。有时,子域可以简单到只包含一套算法,这套算法可能对于业务系统来说非常重要,但是并不包含在核心域之中。

在正确实施DDD的情况下,这种简单的子域可以以模块(Module)的形式从核心域中分离出来,而不需要包含在笨重的子系统组件中。在实施DDD的时候,我们致力于将限界上下文中领域模型所用到的每一个术语都进行限界划分。这种限界主要是语言层面上的上下文边界,也是实现DDD的关键。

其次,一个限界上下文并不一定只包含在一个子域中。在图6.1中,只有库存限界上下文包含在了一个子域中。显然,这表明这个电子商务系统在开发的时候并没有正确地采用DDD。

在上面的电子商务系统中,当我们谈到其中有4个子域时,我们可以看出有些术语在这些子域中是存在冲突的。比如,“顾客”这个术语可能有多种含义。在浏览产品目录的时候,,“顾客”表示一种意思;而在下单的时候,“顾客”又表示另一种意思。原因在于:当浏览产品目录时, “顾客”被放在了先前购买情况、忠诚度、折扣这样的上下文中。而在下单时, “顾客”的上下文包括名字、产品寄送地址、订单总价和一些付款术语。

如果不对”产品目录子域“和”订单子域“进行划分,那么在这个电子商务系统中,“顾客”并没有一个清晰的含义。我们甚至还可以找到很多像“顾客”这样拥有多重含义的术语。在一个好的限界上下文中,每一个术语应该仅表示一种领域概念。

同样的,我们看到图6.1中库存系统仅仅包含在”库存子域“中,然而,这里也存在有歧义的术语,因为库存件可能用在不同的环境下。

比如,有的库存件已经被订购了,有的正在运送途中,有的正保存在仓库中,而有的正被移出仓库。

已经被订购但还无法销售的产品称为延期订单件;保存在仓库中的产品称为积压件;刚被购买的产品称为即将发送件;而被损坏的库存产品称为无用件。

在图6.1中,我们看不出以上这些库存概念。在DDD中,我们不能靠猜测,而应该对每个概念都给出明确的定义,并将这些明确的定义用在交流和建模中。

图6.1进一步表明,一个企业的限界上下文并不是孤立存在的。即便有第三方的电子商务系统可以提供一个全方位式的模型,它也不能完全满足零售商的需求。不同子域之间的实线表示集成关系,这也表明不同的模型是需要协同工作的。集成的方式有很多种,我们将在后面的上下文映射图小节讲到不同的集成方案。

关注核心域

了解了子域和限界上下文,现在看看关于领域的另一个抽象视图,如图6.2所示。该抽象视图可以表示任何一个领域,甚至有可能是你正在工作的领域。和图6.1相比,这张图去除了那些具体的名字,你可以根据自己的项目情况进行填补。持续改进并且扩大业务目标将反映在不断变化的子域和子域模型中。图6.2仅仅表示某个时刻,从某个角度看的业务领域,这样的领域可能并不会驻留多久。

​ 图 6.2

在图6.2上半部分的领域边界,有一个叫核心域的子域。核心域是整个业务领域的一部分,也是业务成功的主要促成因素。在实施DDD的过程中,将主要关注于核心域。

图6.2中还展示了另外两种子域:支撑子域和通用子域。有时,我们会创建或者购买某个限界上下文来支撑我们的业务。如果这样的限界上下文对应着业务的某些重要方面,但却不是核心,那么它便是一个支撑子域。创建支撑子域的原因在于它们专注于业务的某个方面,否则,如果一个子域被用于整个业务系统,那么这个子域便是通用子域。我们并不能说支撑子域和通用子域是不重要的,它们是重要的,只是我们对它们的要求并不像核心域那么高。

理解限界上下文

在很多情况下,在不同模型中存在名字相同或相近的对象,但是它们的意思却不同。当模型被一个显式的边界所包围时,其中每个概念的含义便是确定的了。因此,限界上下文主要是一个语义上的边界,我们应该通过这一点来衡量对一个限界上下文的使用正确与否。

有些项目试图创建一个“大而全”的软件模型,其中每个概念在全局范围之内只有一种定义,这是一个陷阱。首先,要使所有人都对某个概念的定义达成一致几乎不可能。有些项目太庞大,太复杂,以致于你根本无法将所有的利益相关方聚集到一起,更不用提达成一致了。

即便是那些规模相对较小的公司,要维持一个全局性的,并且经得住时间考验的概念定义也是困难的。因此,最好的方法是去正视这种不同,然后使用限界上下文对领域模型进行分离。

限界上下文并不旨在创建单一的项目资产,它并不是一个单独的组件、文档、或者框图,它也并不一个是JAR包。

我们来看一个例子,假如有一家图书出版机构,他们在图书出版过程中,需要经历以下几个步骤:

  • 概念设计,计划出书
  • 联系作者,签订合同
  • 管理图书的编辑过程
  • 设计图书布局,包括插图
  • 将图书翻译成其他语言
  • 出版纸质版或电子版图书市场营销
  • 将图书卖给销售商或直接卖给读者
  • 将图书发送给销售商或读者

在以上所有阶段中,我们可以用一个单一的概念对图书建模吗?显然不行。在每个阶段中,“图书”都有不同的定义。一本书只有在和作者签订了合同之后才能拥有书名,而书名可能在编辑过程进行修改。在编辑过程中,图书包含了一系列的稿件,其中包括注释和校正等,之后会有一份最终稿件。页面布局由专门的图形设计师完成。图书印刷方使用页面布局和封面板式印制图书。市场营销员不需要编辑稿件或图书印制成品,他们可能只需要图书的简介即可。对于图书的售后物流,我们需要的是图书的标识码、物流目的地、数目、尺寸和重量等。

如果我们使用一个单一模型来处理所有这些阶段会发生什么?

概念混淆、意见分歧和争论是不可避免的,我们所交付的软件也没有多大价值。即便有时我们可能会得到一个正确的公共模型,但这种模型并不具有持久性。

为了解决这个问题,我们应该为每个阶段创建各自的限界上下文。在每个限界上下文中,都存在某种类型的图书。在几乎所有的上下文中,不同类型的图书对象将共享一个身份标识(identity),这个标识可能是在概念设计阶段创建的,

在使用显式限界上下文的情况下,我们可以定期地、增量式的交付软件,同时所交付的软件又能满足特定的业务需求。

限界上下文的大小

在使用Java时,我们可能从技术层面上将一个限界上下文放在一个JAR文件中,包括WAR或EAR文件。这种做法可能受到了模块化的影响。松耦合的领域模型应该放在不同的JAR文件中,这样我们可以按照版本号对领域模型进行单独部署。

对于大型的模型来说,这种做法是非常有用的。将单个大模型分成多个JAR文件也有助于版本管理.

因此,不同的高层模块,包括它们的版本和依赖都可以通过捆包/模块(bundles/modules)进行管理。

限界上下文的例子

​ 图 6.3

现在看不懂上图没关系,先继续往后看,将有助于理解本图。

七. 上下文映射图

集成关系

在DDD中,存在多种组织模式和集成模式,其中,有一种模式存在于任意两个限界上下文之间:

  • 合作关系(Partnership) :如果两个限界上下文的团队要么一起成功,要么一起失败,此时他们需要建立起一种合作关系。他们需要一起协调开发计划和集成管理。两个团队应该在接口的演化上进行合作以同时满足两个系统的需求。应该为相互关联的软件功能制定好计划表,这样可以确保这些功能在同一个发布中完成。
  • 共享内核(Shared Kernel):对模型和代码的共享将产生一种紧密的依赖性,对于设计来说,这种依赖性可好可坏。我们需要为共享的部分模型指定个显式的边界,并保持共享内核的小型化。共享内核具有特殊的状态,在没有与另一个团队协商的情况下,这种状态是不能改变的。我们应该引人种持续集成过程来保证共享内核与通用语言(1)的一致性。
  • 客户方-供应方开发(Customer-Supplier Development) :当两个团队处于种上游-下游关系时,上游团队可能独立于下游团队完成开发,此时下游团队的开发可能会受到很大的影响。因此,在上游团队的计划中,我们应该顾及到下游团队的需求。
  • 遵奉者(Conformist) :在存在上游-下游关系的两个团队中,如果上游团队已经没有动力提供下游团队之所需,下游团队便孤军无助了。出于利他主义,上游团队可能向下游团队做出种种承诺,但是有很大的可能是:这些承诺是无法实现的。下游团队只能盲目地使用上游团队的模型,
  • 防腐层(Anticorruption Layer) :在集成两个设计良好的限界上下文时,翻译层可能很简单,甚至可以很优雅地实现。但是,当共享内核、合作关系或客户方-供应方关系无法顺利实现时,此时的翻译将变得复杂。对于下游客户来说,你需要根据自己的领域模型创建一个单独的层,该层作为上游系统的委派向你的系统提供功能。防腐层通过已有的接口与其他系统交互,而其他系统只需要做很小的修改,甚至无须修改。在防腐层内部,它在你自己的模型和他方模型之间进行翻译转换。
  • 开放主机服务(Open Host Service) :定义一种协议,让你的子系统通过该协议来访问你的服务。你需要将该协议公开,这样任何想与你集成的人都可以使用该协议。在有新的集成需求时,你应该对协议进行改进或者扩展。对于一些特殊的需求,你可以采用一次性的翻译予以处理,这样可以保持协议的简单性和连贯性。
  • 发布语言(Published Language) :在两个限界上下文之间翻译模型需要种公用的语言。此时你应该使用一种发布出来的共享语言来完成集成交流。发布语言通常与开放主机服务一起使用。
  • 另谋他路(SeparateWay):在确定需求时,我们应该做到坚决彻底。如果两套功能没有显著的关系,那么它们是可以被完全解耦的。集成总是昂贵的,有时带给你的好处也不大。声明两个限界上下文之间不存在任何关系,这样使得开发者去另外寻找简单的、专门的方法来解决问题。
  • 大泥球(Big Ball of Mud):当我们检查已有系统时,经常会发现系统中存在混杂在一起的模型,它们之间的边界是非常模糊的。此时你应该为整个系统绘制一个边界,然后将其归纳在大泥球范围之列。在这个边界之内,不要试图使用复杂的建模手段来化解问题。同时,这样的系统有可能会向其他系统蔓延,应该对此保持警觉。

术语定义

在上下文映射图中,我们使用以下缩写来表示各种关系:

  • ACL表示防腐层
  • OHS表示开放主机服务
  • PL表示发布语言

简单的映射图

​ 图 7.1

从图7.1我们可以看到,该图有三种集成关系或模式,分别为防腐层、发布语言和开放主机服务。但是,仅仅从上面的术语定义并不能很好地理解这些含义,我们来详细解释下:

  • 开放主机服务:该模式可以通过REST实现。通常来讲,我们可以将开放主机服务看成是远程过程调用(Remote ProcedureCall, RPC)的API。同时,它也可以通过消息机制实现。
  • 发布语言:发布语言可以通过多种方式实现,比较常见的是使用XML Schema。在使用REST服务时,发布语言用来表示领域概念,此时可以使用XMLJSON。发布语言也可以使用Google的协议缓冲(Protocol Buffer)来表示。如果你打算发布Web用户界面,你也可以使用HTML。使用REST的好处在于每个客户端都可以指明使用哪种发布语言,同时还可以指明资源的展现方法。
  • 防腐层:在下游上下文中,我们可以为每个防腐层定义相应的领域服务(Domain Service)。同时,你也可以将防腐层用于资源库接口。在使用REST时,客户端的领域服务将访问远程的开放主机服务,远程服务器以发布语言的形式返回,下游的防腐层将返回内容翻译成本地上下文的领域对象。比如,协作上下文向身份与访问上下文请求“具有Moderator角色的用户”。所返回的数据可能是XML格式或JSON格式,然后防腐层将这些数据翻译成协作上下文中的Moderator对象,该对象是一个值对象。这个Moderator实例反映的是下游模型中的概念,而不是上游模型。

在图7.1中,身份与访问上下文通过REST的方式向外发布服务。作为该上下文的客户,协作上下文通过传统的类似于RPC的方式获取外部资源。

协作上下文并不会永久性地记录下从身份与访问上下文中获取来的数据,而是在每次需要数据时重新向远程系统发出请求。显然,协作上下文高度依赖于远程服务,它不具有自治性。

并且这还存在一个问题,如果由于远程系统不可用而导致同步请求失败,那么本地系统也将跟着失败。此时本地系统将通知用户所发生的问题,并告诉用户稍后重试。系统集成通常依赖于RPC。从高层面上看,RPC与编程语言中的过程调用非常相似。

然而,和在相同进程空间中进行过程调用不同的是,远程调用更容易产生有损性能的时间延迟,并且有可能导致调用彻底失败。网络和远程系统的加载过程都是RPC产生延迟的原因。当RPC的目标系统不可用时,用户对你系统的请求也将失败。虽然REST并不是真正意义上的RPC,但它却具有与RPC相似的特征。彻底的系统失败并不多见,但它却是一个潜在的问题

​ 图7.2 协作上下文和身份与访问上下文集成时的防腐层和开放主机服务

其中一种解决方案是将系统所依赖的状态存在本地,那么我们将获得更大的自治性。有人可能认为这只是对所有的依赖对象进行缓存,但这不是DDD的真正的做法。

DDD的做法是:在本地创建一些由外部模型翻译而成的领域对象,这些对象保留着本地模型所需的最小状态集。为了初始化这些对象,我们只需要有限的RPC调用或REST请求。然而,要与远程模型保持同步,最好的方式是在远程系统中采用面向消息的通知(notification)机制。消息通知可以通过服务总线进行发布,也可以采用消息队列或者REST。

举个简单的例子也许更好理解:假如有一个领域对象,他有一个属性,这个属性的值分为多种,每种描述的字符串都很长很长,这时候,我们可以用0、1、2…等等数字来代表这些不同的属性值并做好约定,这时候我们将这些数字代表的属性值的长长的字符串存在本地,只在RPC调用或REST请求中传递这些数字即可,大大降低开销。当然,这只是一个非常简单的例子帮助你理解DDD的做法。

而后一句提到的消息通知机制在我们目前的微服务框架中也是这样做的,目前由于只是DDD初步入门,不展开讲解。

八. 架构

DDD的一大好处便是它并不需要使用特定的架构。由于核心域位于限界上下文中,我们可以在整个系统中使用多种风格的架构。

在选择架构风格和架构模式时,我们应该将软件质量考虑在内,而同时,避免滥用架构风格和架构模式也是重要的。质量驱动的架构选择是种风险驱动方式[Fairbanks],即我们采用的架构是用来减少失败风险的,而不是增加失败风险。因此,我们必须对每种架构做出正确的评估。

分层

分层架构模式被认为是所有架构的鼻祖。它支持N层架构系统,因此被广泛地应用于Web、企业级应用和桌面应用。在这种架构中,我们将一个应用程序或者系统分为不同的层次。

在分层架构中,我们将领域模型和业务逻辑分离出来,并减少对基础设施、用户界面甚至应用层逻辑的依赖,因为它们不属于业务逻辑。将一个复杂的系统分为不同的层,每层都应该具有良好的内聚性,并且只依赖于比其自身更低的层。

分层架构的一个重要原则是:每层只能与位于其下方的层发生耦合。

​ 图 8.1 DDD的传统分层架构

分层架构也分为几种:在严格分层架构(Strict Layers Architecture)中,某层只能与直接位于其下方的层发生耦合;而松散分层架构(Relaxed Layers Architecture)则允许任意上方层与任意下方层发生耦合。

由于用户界面层和应用服务通常需要与基础设施打交道,许多系统都是基于松散分层架构的。但是,较低层也是可以和较高层发生耦合的,但这只局限于采用观察者(Observer)模式或者调停者(Mediator)模式的情况。较低层是绝对不能直接访问较高层的。

传统3层架构也是严格分层,controller-service-dao。 DDD相当于将service拆分成两层:应用层和领域层。领域内的业务逻辑在领域层里,而应用层负责跨领域逻辑处理、业务编排。

应用服务(Application Services)位于应用层中。应用服务和领域服务(Domain Services)是不同的,因此领域逻辑也不应该出现在应用服务中。

应用服务可以用于控制持久化事务和安全认证,或者向其他系统发送基于事件的消息通知,另外还可以用于创建邮件以发送给用户。应用服务本身并不处理业务逻辑,但它却是直接面向领域模型。应用服务是很轻量的,它主要用于协调对领域对象的操作,比如聚合等等。

一种比较好的应用服务例子是这样的:

1
2
3
4
5
6
7
@Transactional
public void commitBacklogI temToSprint(String aTenantId, String aBacklogItemId,
String aSprintId){
TenantId tenantId = new TenantId (aTenant Id);
BacklogItem backlogItem = backlogItemRepository.backlogitemofId(tenantId, new BacklogItemId (aBacklogItemId));
Sprint sprint = sprintRepository.sprintofId(tenantId, new SprintId(aSprintId));
backlogItem.commitTo (sprint);

如果应用服务比上述功能复杂许多,这通常意味着领域逻辑已经渗透到应用服务中了,此时的领域模型将变成贫血模型。

因此,最佳实践是将应用层做成很薄的一层。当需要创建新的聚合时,应用服务应该使用工厂或聚合的构造函数来实例化对象,然后采用资源库(也可以称作持久层)对其进行持久化。

在图8.1的传统分层架构中,基础设施层位于底层,持久化和消息机制便位于该层中。这里的消息包含了消息中间件所发的消息、基本的电子邮件(SMTP)或者文本消息(SMS)。可以将基础设施层中所有的组件和框架看作是应用程序的低层服务,较高层与该层发生耦合以使用这些技术。即便如此,我们依然应该避免核心的领域模型对象与基础设施层发生直接耦合。怎么办呢?

依赖倒置原则

它通过改变不同层的依赖关系达到目的,他的定义为:

高层模块不应该依赖于低层模块,两者都应该依赖于抽象。

抽象不应该依赖于细节,细节应该依赖于抽象。

根据该定义,低层服务(比如基础设施层)应该依赖于高层组件(比如用户界面层、应用层和领域层)所提供的接口。在架构中采用依赖倒置原则有很多种表达方式,这里我们将采用图8.2中的方式。

​ 图 8.2

我们应该将关注点放在领域层上,采用依赖倒置原则,使领域层和基础设施层都只依赖于由领域模型所定义的抽象接口。由于应用层是领域层的直接客户,它将依赖于领域层接口,并且间接地访问资源库(持久层)和由基础设施层提供的实现。

如果仔细想想,我们可能会发现,当我们在分层架构中采用依赖倒置原则时,事实上已经不存在分层的概念了。无论是高层还是低层,它们都只依赖于抽象,好像把整个分层架构给推平了一样。

这样讲也许还不是很明白,我们来讲解下DDD 各层的主要职责,帮助理解这种架构:

优化后的四层架构

​ 图 8.3

图8.3是依赖倒置后的四层架构,如果只看图8.2的话很难理解依赖倒置有什么作用,我初看也是如此,原因在于不能理解每层的职责,但是图8.3便可以将依赖倒置后的作用很好地体现出来,我们一层层来看。

用户接口层

用户接口层负责向用户显示信息和解释用户指令,一般是终端,比如web程序、批处理、接口等。大白话讲,可以看成用户在UI界面操作后,与用户操作进行交互的层,其实就相当于我们MVC三层架构的Controller层。

应用层

应用层是很薄的一层,理论上不应该有业务规则或逻辑,主要面向用例和流程相关的操作。但应用层又位于领域层之上,因为领域层包含多个聚合,所以它可以协调多个聚合的服务和领域对象完成服务编排和组合,协作完成业务操作。而我们MVC三层架构的应用层,往往包含了大量的业务逻辑,这是不符合DDD做法的。

此外,应用层也是微服务之间交互的通道,它可以调用其它微服务的应用服务,完成微服务之间的服务组合和编排。从DDD角度看微服务,其实一个领域就是一个服务。

另外,应用服务是在应用层的,它负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果的拼装,以粗粒度的服务通过 API 网关向前端发布。此外,应用服务还可以进行安全认证、权限校验、事务控制、发送或订阅领域事件等。

如果对应spring的约定来看,应用层是service,领域层是repository,repository其实不应该是完全的、直接的映射表的增删改查,而是应暴露聚合根,内部完成对实体、值对象的操作,但就目前而言,常见的都是把repository当成DAO在用了。

Repository又称作资源库,资源库是一种封装存储、查询和搜索行为的机制,它是一个模拟的对象集合

DAO是Data Access Object的缩写,是一种结构型设计模式,用于分离业务层(应用)和持久化层(数据库),DAO模式是一种分层的思想,可以理解为数据库操作的简单封装。

领域层(DDD中很重要的一层)

领域层的作用是实现企业核心业务逻辑,通过各种校验手段保证业务的正确性。领域层主要体现领域模型的业务能力,它用来表达业务概念、业务状态和业务规则

领域层包含聚合根、实体、值对象、领域服务等领域模型中的领域对象。这里我要特别解释一下其中几个领域对象的关系,以便你在设计领域层的时候能更加清楚。

首先,领域模型的业务逻辑主要是由实体和领域服务来实现的,其中实体会采用充血模型来实现所有与之相关的业务功能。其次,实体和领域服务在实现业务逻辑上不是同级的,当领域中的某些功能,单一实体(或者值对象)不能实现时,领域服务就会出马,它可以组合聚合内的多个实体(或者值对象),实现复杂的业务逻辑。(这里不懂没关系,我们后面还会讲到实体、值对象和聚合等)

基础层

基础层是贯穿所有层的,它的作用就是为其它各层提供通用的技术和基础服务,包括第三方工具、驱动、消息中间件、网关、文件、缓存以及数据库等。比较常见的功能还是提供数据库持久化。

基础层包含基础服务,它采用依赖倒置设计,封装基础资源服务,实现应用层、领域层与基础层的解耦,降低外部资源变化对应用的影响。

比如说,在传统架构设计中,由于上层应用对数据库的强耦合,很多公司在架构演进中最担忧的可能就是换数据库了,因为一旦更换数据库,就可能需要重写大部分的代码,这对应用来说是致命的。那采用依赖倒置的设计以后,应用层就可以通过解耦来保持独立的核心业务逻辑。当数据库变更时,我们只需要更换数据库基础服务就可以了,这样就将资源变更对应用的影响降到了最低。

对于依赖倒置设计可能不太好理解,我们来看一个例子:

现在有Person聚合根,Person聚合包括仓储接口和仓储实现。 通过增加仓储服务,使得应用逻辑和数据库逻辑的依赖关系剥离,当换数据库的时候,只需要将仓储实现替换就可以了,这样不会对核心的业务逻辑产生影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* Person聚合根
*/
public class Person{
private String id;
private String name;
private int age;
private boolean gender;

/**
* 其它方法
*/
}

/**
* Person仓储接口
*/
public interface PersonRepositoryInterface {
void save(Person person);
void delete(String id);
}

/**
*Person仓储实现
*/
@Repository
public class PersonRepositoryImp implements PersonRepositoryInterface {
private PersonMapper mapper;
public void save( Person person) {
mapper.create(person);
}
public void delete((String id) {
mapper.delete(id);
}
}

在应用逻辑中直接用仓储的接口就可以了,数据库相关的逻辑在PersonMapper里面实现。
PersonRepositoryInterface personRepos;
personRepos.save(person)

其实Spring Data JPA的思想也是这样的,不依赖于数据库,采用JPA的实现方式,使得更换数据库相较于强依赖 SQLMybatis 会方便很多。这样来看,Spring Data JPA就是一个实现依赖倒置的非常好的示例。

但是采用DDD的这种架构模式的话,当需要更换数据库的话,使用Mybatis时其实也是差不多的,因为他们都不依赖于数据库,区别在于使用Spring Data JPA的话,更换数据库不需要些SQL,而Mybatis需要重写SQL。

DDD在基础层是通过仓储的依赖倒置的方式来实现应用与基础资源来解耦的。也就是说应用逻辑里面不应该含有基础资源的实现代码,SQL语句等与数据相关的代码不应放在业务逻辑代码来实现。以后如果需要换数据库的话,对应用逻辑影响相对会小很多。目前来说持久化的工具Mybatis可能会好一些。

六边形架构

在六边形架构中, Alistair Cockburn提出了一种具有对称性特征的架构风格。在这种架构中,不同的客户通过“平等”的方式与系统交互。

当有一个新的访问客户时,只需要添加一个新的适配器将客户输入转化成能被系统API所理解的参数就行了。同时,系统输出,比如图形界面、持久化和消息等都可以通过不同方式实现,并且对于每种特定的输出,都有个新建的适配器负责完成相应的转化功能。

我们通常将客户与系统交互的地方称为“前端”;同样,我们将系统中获取、存储持久化数据和发送输出数据的地方称为“后端”。但是,六边形架构提倡种新的视角来看待整个系统,如图8.4所示,该架构中存在两个区域,分别是“外部区域”和“内部区域”。

在外部区域中,不同的客户均可以提交输入;而内部的系统则用于获取持久化数据,并对程序输出进行存储(比如数据库),或者在中途将输出转发到另外的地方(比如消息)

六边形架构图

​ 图 8.4

六边形架构的功能如此强大,以致于它可以用来支持系统中的其他架构。比如,我们可能采用SOA架构、REST或者事件驱动架构;也有可能采用CQRS;或者数据网织或基于网格的分布式缓存;还有可能采用Map-Reduce这种分布式并行处理方式。

这样设计的好处很明显了,就是可以保证领域层的核心业务逻辑不会因为外部需求和流程的变动而调整,对于建立前台灵活、中台稳固的架构很有帮助。

看到这里,也许你已经有可以看出对于中台和微服务设计的关键了:领域模型和微服务的合理分层设计。

面向服务架构

我们可能经常会听说一个词——SOA,其实这个词的意思就是面向服务架构(Service-Oriented Architecture,)对于不同的人来说具有不同的意思。我们先看看由 ThomasErl 所定义的一些SOA原则。服务除了拥有互操作性外,还具有以下8种设计原则:

服务设计原则 概述
服务契约 通过契约文档,服务阐述自身的目的与功能
松耦合 服务将依赖关系最小化
服务抽象 服务只发布契约,而向客户隐藏内部逻辑
服务重用性 一种服务可以被其他服务所重用
服务自治性 服务自行控制环境与资源以保持独立性,这有助于保持服务的一致性和可靠性
服务无状态性 服务负责消费方的状态管理,这不能与服务的自治性发生冲突
服务可发现性 客户可以通过服务元数据来查找服务和理解
服务组合性 服务一种服务可以由其他的服务组合而成,而不管其他服务的大小和复杂性如何

我们可以将这些原则和六边形架构结合起来,此时服务边界位于最左侧,而领域模型位于中心位置,如图8.4所示。消费方可以通过REST, SOAP和消息机制获取服务。一个六边形架构系统支持多种类型的服务端点(即图8.4左上角部分),这依赖于DDD是如何应用于SOA的。

在使用DDD时,我们所创建的限界上下文应该包含一个完整的,能很好表达通用语言的领域模型。在限界上下文中我们已经提到,我们并不希望架构对领域模型的大小产生影响。

但是,如果一个或多个技术服务端点,比如REST资源、SOAP接口或消息类型被用于决定限界上下文的大小,那么上述情况是有可能发生的。

根据SOA精神所在:

1.业务价值高于技术策略

2.战略目标高于项目利益

就像限界上下文中所讲到的,技术组件对于划分模型来说并没有那么重要

REST和DDD(重点)

有了上面的基础,那么在这一小节,我们就可以好好举个REST结合DDD的例子,来帮助理解什么才是DDD的思想了,相信看完这一小节,你又会有不一样的发现。

什么是REST应该不需要介绍了,一般程序员也都经常接触。使用REST,我们可以完成许多CRUD操作,我们就拿这里面的U来说吧。U代表的就是Update操作,通用更新方法允许客户端更新资源的任何字段,然后使用新版本覆盖现有版本。但是,如果允许客户端执行这样的操作,所能提供的价值其实是很小的。

服务层的关键增值之一就是在基础数据之上实施业务约束,资源总是最终要被业务约束才行。(在我看来,这就是DDD架构中的一大核心)

也许咋一看这句话不是很好理解,我们来看一个银行转账的例子:

比如你现在准备开发一个转账的接口,采用CRUD模式的方式,那便会发生许多问题。首先,客户端不应该调用一个API,然后就把账户余额更新为他们想要的数量,如果允许这样做,那么你的代码不仅会很混乱,而且难以维护。

比如帐户可能有最低余额,于是你对那些更新方法添加了一些校验代码,以便如果帐户余额值被更改,它必须在一个指定的范围内。这样问题解决了吗?没有。任何余额调整都应被作为某种类型交易事务被记录下来才对。比如这是充值?取钱?还是一次转账?如果客户端尝试更改帐号怎么办?这是否允许?会破坏其他数据关系吗?于是你的更新(update)方法实现逻辑将会快速变成逻辑流程异常复杂的代码。

在很多系统中,其实都存在这个问题,他们的代码试图推断客户端究竟把哪些字段改变了,里面有各种各样的逻辑,这些逻辑一起在判断用户的这次更新操作是在充值、取钱还是转账(因为这些操作涉及的代码往往不止一张表,可能需要多个表共同维护,而这些代码都混在一起),代码最终就是一团糟。

这时候,领域驱动设计(DDD)给出了一个解决方案。 DDD的思路是希望软件建模应该是基于解决现实世界的问题而去设计API,在我看来,这就是一种面向对象的架构思想。它创建了一种用于描述软件的语言,这种语言是基于被称为实体或聚合的关键的业务对象来描述软件的。它还定义了比如服务(Services),值对象(ValueObject)和存储库(Repositories)之类的术语,它们共同解决特定业务领域中的问题,或者在DDD术语中被叫做“限界上下文(Bounded Context)”。当然,并不是说必须使用DDD来设计你的REST,但是,由于REST资源可以很好地映射到DDD实体,因此我发现设计REST API特别适合使用DDD。

这是什么意思?这意味着我们的API应该围绕领域对象及其提供的业务操作。业务操作是通用更新方法及其所有陷阱的关键的替代方案。我们再用前面的银行示例来说明:

对于银行API,明显的领域对象(或DDD术语中的实体)是一个帐户,它为银行帐户建模。我们不应该按照帐户的CRUD模型来定义在银行账户上执行的具体业务操作。以下是一个写操作系列很好的示例:

  1. Open -开户

  2. Close -关闭账户

  3. Debit -从账户上取钱

  4. Credit -往账户上加钱

如果使用我们CRUD思维,可能就一个或两个操作实现上述四个操作——更新账户状态为开启或关闭(取决你对开户含义的理解,其实这也是DDD使用的一个体现,应该对术语进行规范,使得一个限界上下文拥有通用语言),账户余额的更新。

而我们上面四个操作是具体的,可以强制执行某些业务约束。例如,我们可能不想允许记入已关闭的账户,我们可以强制执行我们的最低余额检查作为借记操作(从账户扣除金额的操作)的一部分。在读操作方面,我们还可以提供与我们的客户用例相匹配的特定查询:

  1. Load -通过其帐户ID加载单个帐户。

  2. Transaction history - 列出帐户的交易记录。

  3. Customer accounts -列出给定客户ID的帐户。

现在我们知道我们的业务操作是什么了,下面是将它们映射到REST API的一个例子:

  1. POST /account – 开户

  2. PUT /account//close -关闭现有账户

  3. PUT /account//debit – 从账户上取钱

  4. PUT /account//credit – 往账户上充钱

  5. GET /account/ - 通过其帐户ID加载单个帐户。

  6. GET /account//transactions- 列出帐户的交易记录。

  7. GET /accounts/query/customerId/ -列出给定客户ID的帐户。

这看起来和基本的CRUD API有很大的不同,但关键是允许的操作是特定的和明确的。这为服务实现者以及客户端带来了更好的体验。服务实现不再需要基于哪些属性更新来猜测什么业务操作是隐含的。

相反,业务操作是明确的,这样我们的代码实现也更简单,更可维护。在客户端,将变得更加的明确,什么操作可以执行,什么操作不可以执行。如果API文档记录的很好的话,例如使用Swagger来定义文档,那么每个API的限制(或约束)将变得非常明确。

以这种方式定义你的API需要更多的前瞻性思考,要比简单的CRUD 生成器需要花费更多的思考,但我认为这是值得的也是必须的。

因此不应该按照CRUD模型来构建你的serviceAPI(REST 或其他),而应该是使用DDD,DDD可以根据领域对象和可对其执行的业务操作来定义API。

命令和查询职责分离——CQRS

这其实就是我们经常讲到的数据库读写分离了,这个问题在今天(写这篇文档时)都是一个十分热门的问题。

从资源库(DDD术语,和第八节依赖倒置原则基础层给出的仓储服务例子一样)中查询所有需要显示的数据是困难的,特别是在需要显示来自不同聚合类型与实例的数据时。

领域越复杂,这种困难程度越大。因此,我们并不期望单单使用资源库来解决这个问题。因为我们需要从不同的资源库获取聚合实例,然后再将这些实例数据组装成一个数据传输对象(DataTransfer Object, DTO) 。或者,我们可以在同一个查询中使用特殊的查找方法将不同资源库的数据组合在一起。如果这些办法都不合适,我们可能需要在用户体验上做出妥协,使界面显示生硬地服从于模型的聚合边界。

然而,很多人都认为,这种机械式的用户界面从长远看来是不够的。那么,有没有一种完全不同的方法可以将领域数据映射到界面显示中呢?答案是CQRS (Cammand-Query Responsibility Segregation) 。 CQRS是将紧缩(Stringent)对象(或者组件)设计原则和命令-查询分离(CQS)应用在架构模式中的结果。

Bertrand Meyer对CQRS模式有以下评述:

一个方法要么是执行某种动作的命令,要么是返回数据的查询,而不能两者皆是。

在对象层面,这意味着:

  1. 如果一个方法修改了对象的状态,该方法便是一个命令(Command),它不应该返回数据。在Java中,这样的方法应该声明为void

  2. 如果一个方法返回了数据,该方法便是一个查询(Query),此时它不应该通过直接的或间接的手段修改对象的状态。在Java中,这样的方法应该以其返回的数据类型进行声明。

这样的指导原则是非常直接明了的,同时具有实践和理论基础作为支撑。但是,在DDD的架构模式中,我们为什么应该使用CQRS呢,又如何使用呢?

在领域模型中——比如限界上下文中所讨论的领域模型——我们通常会看到同时包含有命令和查询的聚合。同时,我们也经常在资源库中看到不同的查找方法,这些方法对对象属性进行过滤。

但是在CQRS中,我们将忽略这些看似常态的情形,我们将通过不同的方式来查询用于显示的数据。现在,对于同一个模型,考虑将那些纯粹的查询功能从命令功能中分离出来。聚合将不再有查询方法,而只有命令方法。资源库也将变成只有add()或save()方法,(分别支持创建和更新操作),同时只有一个查询方法,比如fromld()。这个唯一的查询方法将聚合的身份标识作为参数,然后返回该聚合实例。资源库不能使用其他方法来查询聚合,比如对属性进行过滤等。

在将所有查询方法移除之后,我们将此时的模型称为命令模型(Command Model)。但是我们仍然需要向用户显示数据,为此我们将创建第二个模型,该模型专门用于优化查询,我们称之为查询模型(Query Model),如图8.5所示。

读写分离模型

​ 图 8.5

在CQRS中,来自客户端的命令通过单独的路径抵达命令模型,而查询操作则采用不同的数据源,这样的好处在于可以优化对查询数据的获取,比如用于展现、用于接口或报告的数据。

命令模型上每个方法在执行完成时都将发布领域事件(在后面会讲到)。这里举个小例子体会下:

1
2
3
4
5
6
7
8
9
	public class BacklogItem extends ConcurrencySafeEntity{
public void commitTo (Sprint aSprint){
...
DomainEvent Publisher.
instance()
.publish(new BacklogItemCommitted (this.tenant(),
this.backlogItemId(), this.sprintId ()));
}
}

这里的DomainEventPublisher是一个轻量级的基于观察者(Observer)模式的组件,更多的细节会在后面的领域事件部分讲到。

在命令模型更新之后,如果我们希望查询模型也得到相应的更新,那么从命令模型中发布的领域事件便是关键所在。

简单来说,你在命令模型发起一条更新操作的命令后,就得及时更新查查询模型,要不然查出来数据就不一致,这时候领域事件就可以看成用于通知更新查询模型的。

对于读写分离还有许多方面值得研究,比如保证实现数据一致性等等问题,这里就不展开讲解了。

九. 实体

因为在软件开发中,数据库依然占据着主导地位。我们首先考虑的是数据的属性(对应数据库的列)和关联关系(外键关联),而不是富有行为的领域概念,开发者趋向于将关注点放在数据上,而不是领域上。

这样做的结果是将数据模型直接反映在对象模型上,导致那些表示领域模型的实体(Entity)包含了大量的getter和setter方法。另外,还存在大量的工具可以帮助我们生成这样的实体模型。虽然在实体模型中加入getter和setter并不是什么大错,但这却不是DDD的做法。

三要素

实体的核心三要素:身份标识属性领域行为

身份标识:身份标识的主要目的是管理实体的生命周期。身份标识可分为:通用类型和领域类型。通用类型 ID 没有业务含义;而领域类型 ID 则组装了业务逻辑,建议使用值对象作为领域类型 ID。

属性:实体的属性用来说明主体的静态特征,并持有数据与状态。属性分为:原子属性和组合属性。组合属性可以是实体,也可以是值对象,取决于该属性是否需要身份标识。我们应该尽可能将实体的属性定义为组合属性,以便于在实体内部形成各自的抽象层次。

领域行为:体现了实体的动态特征。实体具有的领域行为一般可以分为:

  • 变更状态的领域行为:变更状态的领域行为体现的是实体/值对象内部的状态转移,对应的方法入参为期望变更的状态。(有入参,无出参);
  • 自给自足的领域行为:自给自足意味着实体对象只操作了自己的属性,不外求于别的对象。(无入参);
  • 互为协作的领域行为:需要调用者提供必要的信息。(有入参,有出参);
  • 创建行为:代表了对象在内存的从无到有。创建行为由构造函数履行,但对于创建行为较为复杂或需要表达领域语义时,我们可以在实体中定义简单工厂方法,或使用专门的工厂类进行创建。(有出参,且出参为特定实体实例)。

领域唯一标识

唯一标识从字面意思来看很好理解,比如我们的身份证号等等都可以作为唯一标识。

以下是一些常用的创建实体身份标识的策略,从简单到复杂依次为:

  • 用户提供一个或多个初始唯一值作为程序输人,程序应该保证这些初始值是唯一的。
  • 程序内部通过某种算法自动生成身份标识,此时可以使用一些类库或框架,当然程序自身也可以完成这样的功能。
  • 程序依赖于持久化存储,比如数据库,来生成唯一标识。
  • 另一个限界上下文(系统或程序)已经决定出了唯一标识,这作为程序的输入,用户可以在一组标识中进行选择。
用户提供唯一标识

这个其实很好理解,举个简单的例子,用户输入的自己的身份证号便可以作为唯一标识,当然,在用户输入后,身份证号和名字等肯定是要先去匹配验证是否正确的。

应用程序生成唯一标识

有很多可靠的方法都可以自动生成唯一标识,但是如果应用程序处于集群环境或者分布在不同的计算节点中,我们就需要额外小心了。有些方法可以生成完全唯一的标识,比如UUID (Universally Unique Identifier) 或者GUID (GloballyUnique Identifier) 。以下是生成唯一标识的另一种方法,其中每一步生成的结果都将添加到最终的文本标识中:

  1. 计算节点的当前时间,以毫秒记

  2. 计算节点的IP地址

  3. 虚拟机(Java)中工厂对象实例的对象标识

  4. 虚拟机(Java)中由同一个随机数生成器生成的随机数以上可以产生一个128位的唯一值。

通常该唯一值通过一个32字节或36字节的16进制数的字符串来表示。在使用36字对,我们可以用连字符(-)来连接以上各个步骤所生成的结果,比如f36ab21c-67dc-5274-c642-Ide2f4d5e72a。但无论如何,这都是个很大的唯一标识,并且不具有可读性。

在Java中,以上方法被标准的UUID生成器所替代了(自从Java 1.5),相应的Java类是java.util.UUID。该类支持4种不同的唯一标识生成算法,这些算法都基于Leach-Salz变量。使用Java标准API,我们可以简单地生成伪随机的唯一标识:

1
String rawId = java,util,UUID.randomUUID().toString ();

以上代码使用了第4类算法,该算法采用高度加密的伪随机数生成器,而该生成器又基于java.security.SecureRandom生成器。

除了上面几种,还有持久化机制生成唯一标识另一个限界上下文获取标识,这里就不一一讲解了。

委派标识

基于领域实体概念分析确定的唯一身份标识,我们可以称为领域实体标识

而在有些ORM工具,比如Hibernate、EF,它们有自己的方式来处理对象的身份标识。它们倾向于使用数据库提供的机制,比如使用一个数值序列来生成识。在ORM中,委派标识表现为int或long类型的实体属性,来作为数据库的主键。很显然,委派标识是为了迎合ORM而创建的,且委派标识和领域实体标识无任何关系。

那既然ORM需要委派标识,我们就可以创建一个实体基类来统一指定委派标识。而这个实体基类又被称为层超类型

日常中往往会不加思索地把一个自增ID或者GUID等当成实体ID,这其实是不好的。实体ID往往是具备业务意义上的唯一性,是负责与其它边界上下文内的实体(或聚类)进行关联的方式。
具备业务含义的唯一性很重要,它使得不同边界上下文之间的映射变得更加简单、直观,也更容易维护,同时唯一性也更具象化。
如交易所对订单ID的约定就非常的明确,看到ID就知道它代表啥了,比如:订单是从哪个证券通道过来的,是哪一天的订单。这样设计的好处有很多:

  1. 允许不同通道(证券公司)各自设计系统软件,但是这不会破坏交易所对订单号唯一性要求。
  2. 交易所通过订单ID就能根据事先约定的规则识别出是否是一个有效的订单号,对于不存在的或者无效的机构ID的订单号(注册制),可以快速进入异常处置流程。
  3. 清结算时也很容易进行核对。

层超类型

首先定义层超类型接口:

1
2
3
4
5
6
7
8
9
10
11
12
public abstract class IdentifiedDomainobject implements Serializable{
private long id = -1;
public IdentifiedDomainObject(){
super ();
}
protected long id (){
return this.id;
}
protected void setId(long anId){
this.id= anId;
}
}

这里的IdentifiedDomainObject便是层超类型,这是一个抽象基类,通过protected关键字,它向客户端隐藏了委派主键。所有实体都扩展自该抽象基类。

在实体所处的模块之外,客户端不用关心id这个委派标识。我们甚至可以将protected换为private,Hibernate既可以通过getter和setter方法来访问属性,也可以通过反射机制直接访问对象属性,故无论是使用protected还是private都是无关紧要的。

通过这样一种方式,我们进行约定,所有的实体必须继承自IdentifiedDomainObject,即可实现委托标识的统一定义。

可变性

解决了实体的唯一身份标识问题后,我们就可以保证其生命周期中的连续性,不管其如何变化。

那可变性说的是什么呢?可变性是实体的状态和行为。
而实体的状态和行为就要对具体的业务模型加以分析,提炼出通用语言,再基于通用语言来抽象成实体对应的属性或方法。

我们举一个例子:

当顾客从购物车点击结算时创建订单,初始状态为未支付状态,支付成功后切换到正常状态,此时可对订单做发货处理并置为已发货状态。当顾客签收后,将订单关闭。

从以上的通用语言的描述中(在通用语言的术语中,名词用于给概念命名,形容词用于描述这些概念,而动词则表示可以完成的操作。)
我们可以提取订单的相关状态和行为:

  • 订单状态:未支付、正常、已发货、关闭。针对状态,我们需定义一个状态属性即可。
  • 订单的行为:支付、发货和关闭。针对行为,我们可以在实体中定义方法或创建单独的领域服务来处理。

而这些行为和状态都是用于领域建模非常重要的组成部分。

实体既然存在状态和行为,就必然会与事件有所牵连。比如订单支付成功后,需要知会商家发货。这时我们就要追踪订单状态的变化,而追踪变化最实用的方法就是领域事件。关于领域事件,我们后续再讲。

十. 值对象

值对象虽然经常被掩盖在实体的阴影之下,但它却是非常重要的DDD部件。

值对象我们要分开来看,其包含两个词:值和对象。值是什么?比如,数字(1、2、3.14),字符串(“hello world”、“DDD”),金额(¥50、$50),地址(深圳市南山区科技园)它们都是一个值,这个值有什么特点呢,固定不变,表述一个具体的概念。对象又是什么?一切皆为对象,是对现实世界的抽象,用来描述一个具体的事物。那值对象=值+对象=将一个值用对象的方式进行表述,来表达一个具体的固定不变的概念

所以了解值对象,我们关键要抓住关键字——

认识值类型的优点值类型用于度量和描述事物,我们可以非常容易地对值对象进行创建、测试、使用,优化和维护。

我们应该尽量使用值对象来建模而不是实体对象,你可能对此非常惊讶。即便一个领域概念必须建模成实体,在设计时也应该更偏向于将其作为值对象容器,而不是子实体容器。这并不是源自于无端的偏好,而是因为我们可以非常容易地对值对象进行创建、测试、使用、优化和维护。

这样讲也许有点晦涩,初学想要理解并区分实体和值对象没有那么简单,我们就先来对比一下两者。

十一. 实体和值对象的区别

再看实体

前面对实体进行了一些讲解,在看完什么是值对象后,我们再从几个不同的角度来看实体,并将它于值对象加以区分。

实体的业务形态

在 DDD 不同的设计过程中,实体的形态是不同的。在战略设计时,实体是领域模型的一个重要对象。领域模型中的实体是多个属性、操作或行为的载体。你可以这么理解,实体和值对象是组成领域模型的基础单元。

实体的代码形态

在代码模型中,实体的表现形式是实体类,这个类包含了实体的属性和方法,通过这些方法实现实体自身的业务逻辑。在 DDD 里,这些实体类通常采用充血模型,与这个实体相关的所有业务逻辑都在实体类的方法中实现,跨多个实体的领域逻辑则在领域服务中实现。

实体的运行形态

实体以 DO(领域对象)的形式存在,每个实体对象都有唯一的 ID。我们可以对一个实体对象进行多次修改,修改后的数据和原来的数据可能会大不相同。但是,由于它们拥有相同的 ID,它们依然是同一个实体。比如商品是商品上下文的一个实体,通过唯一的商品 ID 来标识,不管这个商品的数据如何变化,商品的 ID 一直保持不变,它始终是同一个商品。

实体的数据库形态

与传统数据模型设计优先不同,DDD 是先构建领域模型,针对实际业务场景构建实体对象和行为,再将实体对象映射到数据持久化对象。

在领域模型映射到数据模型时,一个实体可能对应 0 个、1 个或者多个数据库持久化对象。大多数情况下实体与持久化对象是一对一。

在某些场景中,有些实体只是暂驻静态内存的一个运行态实体,它不需要持久化。比如,基于多个价格配置数据计算后生成的折扣实体。

而在有些复杂场景下,实体与持久化对象则可能是一对多或者多对一的关系。比如,用户 user 与角色 role 两个持久化对象可生成权限实体,一个实体对应两个持久化对象,这是一对多的场景。

再比如,有些场景为了避免数据库的联表查询,提升系统性能,会将客户信息 customer 和账户信息 account 两类数据保存到同一张数据库表中,客户和账户两个实体可根据需要从一个持久化对象中生成,这就是多对一的场景。

再看值对象

在《实现领域驱动设计》一书中对值对象的定义:通过对象属性值来识别的对象,它将多个相关属性组合为一个概念整体。在 DDD 中用来描述领域的特定方面,并且是一个没有标识符的对象,叫作值对象。

也就说,值对象描述了领域中的一件东西,这个东西是不可变的,它将不同的相关属性组合成了一个概念整体。当度量和描述改变时,可以用另外一个值对象予以替换。它可以和其它值对象进行相等性比较,且不会对协作对象造成副作用。

上面这两段对于定义的阐述,如果你还是觉得有些晦涩,我们不妨“翻译”一下,用更通俗的语言把定义讲清楚。

简单来说,值对象本质上就是一个集合。集合里面有若干个用于描述目的、具有整体概念和不可修改的属性。那这个集合存在的意义又是什么?在领域建模的过程中,值对象可以保证属性归类的清晰和概念的完整性,避免属性零碎。

这里举个简单的例子,先看下面这张图:

人员实体原本包括:姓名、年龄、性别以及人员所在的省、市、县和街道等属性。这样显示地址相关的属性就很零碎了对不对?现在,我们可以将“省、市、县和街道等属性”拿出来构成一个“地址属性集合”,这个集合就是值对象了。

值对象的业务形态

值对象是 DDD 领域模型中的一个基础对象,它跟实体一样都来源于事件风暴所构建的领域模型,都包含了若干个属性,它与实体一起构成聚合。我们对照实体,来看值对象的业务形态,这样更好理解。

本质上,实体是看得到、摸得着的实实在在的业务对象,实体具有业务属性、业务行为和业务逻辑。而值对象只是若干个属性的集合,只有数据初始化操作和有限的不涉及修改数据的行为,基本不包含业务逻辑。

值对象的属性集虽然在物理上独立出来了,但在逻辑上它仍然是实体属性的一部分,用于描述实体的特征。在值对象中也有部分共享的标准类型的值对象,它们有自己的限界上下文,有自己的持久化对象,可以建立共享的数据类微服务,比如数据字典。

值对象的代码形态

值对象在代码中有这样两种形态。如果值对象是单一属性,则直接定义为实体类的属性;如果值对象是属性集合,则把它设计为 Class 类,Class 将具有整体概念的多个属性归集到属性集合,这样的值对象没有 ID,会被实体整体引用。我们看一下下面这段代码,person 这个实体有若干个单一属性的值对象,比如 Id、name 等属性;同时它也包含多个属性的值对象,比如地址 address。

值对象的运行形态

实体实例化后的 DO 对象的业务属性和业务行为非常丰富,但值对象实例化的对象则相对简单和乏味。除了值对象数据初始化和整体替换的行为外,其它业务行为就很少了。

值对象嵌入到实体的话,有这样两种不同的数据格式,也可以说是两种方式,分别是属性嵌入的方式序列化大对象的方式。引用单一属性的值对象或只有一条记录的多属性值对象的实体,可以采用属性嵌入的方式嵌入。引用一条或多条记录的多属性值对象的实体,可以采用序列化大对象的方式嵌入。

比如,人员实体可以有多个通讯地址,多个地址序列化后可以嵌入人员的地址属性。值对象创建后就不允许修改了,只能用另外一个值对象来整体替换。如果听着有些晦涩,我们看看下面的例子。

案例 1:以属性嵌入的方式形成的人员实体对象,地址值对象直接以属性值嵌入人员实体中。

案例 2:以序列化大对象的方式形成的人员实体对象,地址值对象被序列化成大对象 Json 串后,嵌入人员实体中。

值对象的数据库形态

DDD 引入值对象是希望实现从“数据建模为中心”向“领域建模为中心”转变,减少数据库表的数量和表与表之间复杂的依赖关系,尽可能地简化数据库设计,提升数据库性能。

如何理解用值对象来简化数据库设计呢?传统的数据建模大多是根据数据库范式设计的,每一个数据库表对应一个实体,每一个实体的属性值用单独的一列来存储,一个实体主表会对应 N 个实体从表。

而值对象在数据库持久化方面简化了设计,它的数据库设计大多采用非数据库范式,值对象的属性值和实体对象的属性值保存在同一个数据库实体表中。

举个例子,还是基于上述人员和地址那个场景,实体和数据模型设计通常有两种解决方案:

  • 第一是把地址值对象的所有属性都放到人员实体表中,创建人员实体,创建人员数据表;
  • 第二是创建人员和地址两个实体,同时创建人员和地址两张表。

第一个方案会破坏地址的业务涵义和概念完整性,第二个方案增加了不必要的实体和表,需要处理多个实体和表的关系,从而增加了数据库设计的复杂性。

那到底应该怎样设计,才能让业务含义清楚,同时又不让数据库变得复杂呢?我们可以综合这两个方案的优势,扬长避短。

在领域建模时,我们可以把地址作为值对象,人员作为实体,这样就可以保留地址的业务涵义和概念完整性。而在数据建模时,我们可以将地址的属性值嵌入人员实体数据库表中,只创建人员数据库表。这样既可以兼顾业务含义和表达,又不增加数据库的复杂度。

值对象就是通过这种方式,简化了数据库设计,总结一下就是:在领域建模时,我们可以将部分对象设计为值对象,保留对象的业务涵义,同时又减少了实体的数量;在数据建模时,我们可以将值对象嵌入实体(而不是将所有属性嵌入),减少实体表的数量,简化数据库设计。

另外,也有 DDD 专家认为,要想发挥对象的威力,就需要优先做领域建模,弱化数据库的作用,只把数据库作为一个保存数据的仓库即可。即使违反数据库设计原则,也不用大惊小怪,只要业务能够顺利运行,就没什么关系。

值对象的优势和局限

值对象是一把双刃剑,它的优势是可以简化数据库设计,提升数据库性能。但如果值对象使用不当,它的优势就会很快变成劣势。

值对象采用序列化大对象的方法简化了数据库设计,减少了实体表的数量,可以简单、清晰地表达业务概念。

这种设计方式虽然降低了数据库设计的复杂度,但却无法满足基于值对象的快速查询,会导致搜索值对象属性值变得异常困难。

值对象采用属性嵌入的方法提升了数据库的性能,但如果实体引用的值对象过多,则会导致实体堆积一堆缺乏概念完整性的属性,这样值对象就会失去业务涵义,操作起来也不方便。所以,在使用值对象时,也要考虑他的劣势。

实体和值对象的关系

值对象和实体在某些场景下可以互换,很多 DDD 专家在这些场景下,其实也很难判断到底将领域对象设计成实体还是值对象。

可以说,值对象在某些场景下有很好的价值,但是并不是所有的场景都适合值对象。

其实,DDD 引入值对象还有一个重要的原因,就是DDD 提倡从领域模型设计出发,而不是先设计数据模型。前面讲过了,传统的数据模型设计通常是一个表对应一个实体,一个主表关联多个从表,当实体表太多的时候就很容易陷入无穷无尽的复杂的数据库设计,领域模型就很容易被数据模型绑架。

可以说,值对象的诞生,在一定程度上,和实体是互补的。我们还是以前面的图示为例:

在领域模型中人员是实体,地址是值对象,地址值对象被人员实体引用。

在数据模型设计时,地址值对象可以作为一个属性集整体嵌入人员实体中,组合形成上图这样的数据模型;也可以以序列化大对象的形式加入到人员的地址属性中,前面表格有展示。

从这个例子中,我们可以看出,同样的对象在不同的场景下,可能会设计出不同的结果。

有些场景中,地址会被某一实体引用,它只承担描述实体的作用,并且它的值只能整体替换,这时候你就可以将地址设计为值对象,比如收货地址。而在某些业务场景中,地址会被经常修改,地址是作为一个独立对象存在的,这时候它应该设计为实体,比如行政区划中的地址信息维护。

所有,这时候就不得不再提起一开始讲到的DDD中的限界上下文了,它就是对这些容易混淆的东西加以约束,也可以说是对领域进行区分了。

实体和值对象的目的都是抽象聚合若干属性以简化设计和沟通,有了这一层抽象,我们在使用人员实体时,不会产生歧义,在引用地址值对象时,不用列举其全部属性,在同一个限界上下文中,大幅降低误解、缩小偏差,两者的区别如下:

①两者都经过属性聚类形成,实体有唯一性,值对象没有。在本文案例的限界上下文中,人员有唯一性,一旦某个人员被系统纳入管理,它就被赋予了在事件、流程和操作中被唯一识别的能力,而值对象没有也不必具备唯一性。

②实体着重唯一性和延续性,不在意属性的变化,属性全变了,它还是原来那个它;值对象着重描述性,对属性的变化很敏感,属性变了,它就不是那个它了。

③战略上的思考框架稳定不变,战术上的模型设计却灵活多变,实体和值对象也有可能随着系统业务关注点的不同而更换位置。比如,如果换一个特殊的限界上下文,这个上下文更关注地址,而不那么关注与这个地址产生联系的人员,那么就应该把地址设计成实体,而把人员设计成值对象。

来源:极客时间《DDD实战课》下评论区,作者ID:DZ

参考引用:

【1】 《实现领域驱动设计》Vaughn Vernon

【2】https://time.geekbang.org/column/article/89049 “先做好DDD再谈微服务吧,那只是一种部署形式”

【3】https://www.cnblogs.com/kingofkai/p/5889099.html

【4】https://mp.weixin.qq.com/s/BIYp9DNd_9sw5O2daiHmlA

【5】https://time.geekbang.org/column/article/156849?utm_source=related_read&utm_medium=article&utm_term=related_read

【6】https://time.geekbang.org/column/article/158248?utm_source=related_read&utm_medium=article&utm_term=related_read 极客时间DDD实战课系列文章 欧创新

【7】https://cloud.tencent.com/developer/article/1082817

【8】 https://www.jianshu.com/p/ee2579d0000b

【9】 https://www.jianshu.com/p/f5e55e278f15

【10】https://www.jianshu.com/p/42fc274ff409

一. AOP的种类和关系

目前主流的AOP 框架有2个,分别是spring aop 和aspectJ,前者是纯Java 实现的,不需要专门的编译过程和类加载器,在运行期间可以通过代理的方式向目标内植入增强的代码。

而AspectJ是一个基于Java语言的AOP框架。在Spring 2.0 开始,引入了对AspectJ 的支持,并提供了一个专门的编译器在编译时提供横向代码的植入。

二. AOP常见术语

1. 通知(Advice)

切面在某个具体的连接点采取的行为或行动,称为通知。切面的核心逻辑代码都写在通知中,有人也称之为增强或者横切关注点。通知是切面功能的具体实现,通常是业务代码以外的需求,如日志、验证等,这些被模块化的特殊对象。

简单来说,假如你有某个你想要的功能,比如上面说的安全,事物,日志等。你给先定义好把,然后在想用的地方在某个地方用注解或者在xml中配置好,你想在哪里用这个事先定义好的模块化的内容。

前置通知:org.springframework.aop.MethodBeforeAdvice
后置通知:org.springframework.aop.AfterReturningAdvice
异常通知:org.springframework.aop.ThrowsAdvice
环绕通知:org.aopalliance.intercept.MethodInterceptor

2. 连接点(JoinPoint)

连接点就是 Spring AOP允许你使用通知(使用事先定义好的模块化的功能)的地方,基本每个方法的前,后(两者都有也行),或抛出异常时都可以是连接点,Spring AOP 只支持方法连接点

其他如 aspectJ 还可以让你在构造器或属性注入时都行,只要记住,和方法有关的前前后后(抛出异常),都是连接点。

3. 切入点(Pointcut)

切入点是一个连接点的过滤条件,AOP 通过切点定位到特定的连接点。每个类都拥有多个连接点:例如 UserService类中的所有方法实际上都是连接点,即连接点是程序类中客观存在的事物。

类比:连接点相当于数据库中的记录,切点相当于查询条件。切点和连接点不是一对一的关系,一个切点匹配多个连接点,切点通过 org.springframework.aop.Pointcut 接口进行描述,它使用类和方法作为连接点的查询条件。

比如你的一个类里,有15个方法,那就有几十个连接点了,但是你并不想在所有方法附近都使用通知(使用叫织入,后文会讲),你只想让其中的几个,在调用这几个方法之前,之后或者抛出异常时干点什么,那么就用切点来定义这几个方法,让切点来筛选连接点,选中那几个你想要的方法。

4. 切面(Aspect)

切面其实是通知和切入点的结合。现在发现了吧,没连接点什么事情,连接点就是为了让你好理解切入点,搞出来的,明白这个概念就行了。通知说明了干什么和什么时候干(什么时候通过方法名中的before,after,around等干什么事),而切入点说明了在哪干(指定到底是哪个方法),这就是一个完整的切面定义。

5. 引入(introduction)

允许我们向现有的类添加新方法属性。这不就是把切面(也就是新方法属性:通知定义的)用到目标类中吗?

6. 目标(target)

引入中所提到的目标类,也就是要被通知的对象,也就是真正的业务逻辑,他可以在毫不知情的情况下,被咱们织入切面。而自己专注于业务本身的逻辑。

7. 织入(weaving)

把切面应用到目标对象来创建新的代理对象的过程。

三. 基于XML方式的实现

切入点表达式

  作用: 知道对哪个类里面的哪个方法进行增强

  语法结构: execution([权限修饰符] [返回类型] [类全路径] 方法名称)

  举例说明:

  1. 对com.hznu.class 类里面的 add 方法进行增强:

    execution(* com.hznu.class.add(..))

    说明: *表示包括public, private等所有修饰符, (..)表示所有参数

  2. 对com.hznu.class 类里面的所有方法进行增强:

    execution(* com.hznu.class.*(..))

  3. 对com.hznu包里面的所有类中的所有方法进行增强:

    execution(* com.hznu..(..))

示例说明

  1. 创建xml文件: (创建context和aop的名称空间; 开启注解扫描; 开启生成Aspect代理对象)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">

<!--开启注解扫描-->
<context:component-scan base-package="com.hznu.aspect1"></context:component-scan>

<!--开启Aspect生成代理对象, 即找到带@aspect注解的类, 生成其代理对象-->
<aop:aspectj-autoproxy></aop:aspectj-autoproxy>
</beans>
  1. 创建被增强类 User.java:
1
2
3
4
5
6
7
//被增强的类
@Component
public class User {
public void basic(){
System.out.println("Basic method.");
}
}
  1. 创建增强类 UserPro.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//增强的类
@Component
//生成代理对象
@Aspect
public class UserPro {

//前置通知 使用切入点表达式, value可省略
@Before(value = "execution(* com.hznu.aspect1.User.basic(..))")
public void before(){
System.out.println("Before.");
}

//最终通知, 在方法调用之后执行, 不管有无异常都执行
@After(value = "execution(* com.hznu.aspect1.User.basic(..))")
public void After(){
System.out.println("After.");
}

//后置通知/返回通知, 在方法return值后执行, 有异常则不执行
@AfterReturning(value = "execution(* com.hznu.aspect1.User.basic(..))")
public void AfterReturning(){
System.out.println("AfterReturning.");
}

//异常通知, 在方法产生异常时执行(可在被增强方法中通过1/0手动触发异常来触发)
@AfterThrowing(value = "execution(* com.hznu.aspect1.User.basic(..))")
public void AfterThrowing(){
System.out.println("AfterThrowing.");
}

//环绕通知
@Around(value = "execution(* com.hznu.aspect1.User.basic(..))")
public void Around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
//方法之前执行内容
System.out.println("Around-Before.");
//被增强的方法执行
proceedingJoinPoint.proceed();
//方法之后执行内容
System.out.println("Around-After");
}
}
  1. 测试
1
2
3
4
5
6
7
public class Test {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("com/hznu/aspect1/bean.xml");
User user = context.getBean("user", User.class);
user.basic();
}
}
  1. 运行结果

四. 项目中利用AOP注解实现日志

Jar包一览

下图为AspectJ的主要一些注解,平时项目开发我一般都使用Spring + AspectJ

  1. 定义 SysOperLog 实体类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Data
public class SysOperLog
{
private static final long serialVersionUID = 1L;

/** 日志主键 */
@TableId(value = "id", type = IdType.AUTO)
private Long id;

/** 操作模块 */
private String title;

/** 业务类型(0其它 1新增 2修改 3删除) */
// "0=其它,1=新增,2=修改,3=删除,4=授权,5=导出,6=导入,7=强退,8=生成代码,9=清空数据"
private Integer businessType;

/** 业务类型数组 */
private Integer[] businessTypes;

/** 请求方法 */
private String method;

/** 请求方式 */
private String requestMethod;

/** 操作类别(0其它 1后台用户 2手机端用户) */
private Integer operatorType;

/** 操作人员 */
private String operName;

/** 部门名称 */
private String deptName;

/** 请求url */
private String operUrl;

/** 操作地址 */
private String operIp;

/** 请求参数 */
private String operParam;

/** 返回参数 */
private String jsonResult;

/** 操作状态(0正常 1异常) */
private Integer status;

/** 错误消息 */
private String errorMsg;

@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;

@TableField(fill = FieldFill.INSERT)
private Integer deleted;
}
  1. 定义自定义注解Log
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Target({ ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log
{
/**
* 模块
*/
public String title() default "";

/**
* 功能
*/
public BusinessType businessType() default BusinessType.OTHER;

/**
* 操作人类别
*/
public OperatorType operatorType() default OperatorType.MANAGE;

/**
* 是否保存请求的参数
*/
public boolean isSaveRequestData() default true;

/**
* 是否保存响应的参数
*/
public boolean isSaveResponseData() default true;
}
  1. 项目中定义日志切面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
/**
* 操作日志记录处理
*
* @author huangrui
*/
@Aspect
@Component
public class LogAspect
{
private static final Logger log = LoggerFactory.getLogger(LogAspect.class);

@Autowired
private AsyncLogService asyncLogService;

/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult)
{
handleLog(joinPoint, controllerLog, null, jsonResult);
}

/**
* 拦截异常操作
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e)
{
handleLog(joinPoint, controllerLog, e, null);
}

protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult)
{
try
{
// *========数据库日志=========*//
SysOperLog operLog = new SysOperLog();
operLog.set...(...); // 此处省略与本文无关代码
// 保存数据库
asyncLogService.saveSysLog(operLog);
}
catch (Exception exp)
{
// 记录本地异常日志
log.error("==前置通知异常==");
log.error("异常信息:{}", exp.getMessage());
exp.printStackTrace();
}
}

/**
* 获取注解中对方法的描述信息 用于Controller层注解
*
* @param log 日志
* @param operLog 操作日志
* @throws Exception
*/
public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog, Object jsonResult) throws Exception
{
// 设置action动作
operLog.setBusinessType(log.businessType().ordinal());
// 设置标题
operLog.setTitle(log.title());
// 设置操作人类别
operLog.setOperatorType(log.operatorType().ordinal());
// 是否需要保存request,参数和值
if (log.isSaveRequestData())
{
// 获取参数的信息,传入到数据库中。
setRequestValue(joinPoint, operLog);
}
// 是否需要保存response,参数和值
if (log.isSaveResponseData() && StringUtils.isNotNull(jsonResult))
{
operLog.setJsonResult(StringUtils.substring(com.alibaba.fastjson.JSON.toJSONString(jsonResult), 0, 2000));
}
}

/**
* 获取请求的参数,放到log中
*
* @param operLog 操作日志
* @throws Exception 异常
*/
private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog) throws Exception
{
String requestMethod = operLog.getRequestMethod();
if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod))
{
String params = argsArrayToString(joinPoint.getArgs());
operLog.setOperParam(StringUtils.substring(params, 0, 2000));
}
}

}
  1. controller中使用
1
2
3
4
5
 @Log(title = "查询信息", operatorType = OperatorType.MOBILE)
@GetMapping("list")
public Response list() {

}

核心大致流程其实就是这样:

  1. 定义好一个实体类,主要就是用于保存日志到数据库中。(比如我们上面第三步new SysOperLog(),并且set了一些参数)

  2. 定义自定义注解Log,包含的参数参考上述第二步。

  3. 定义一个切面类,用@Aspect标识。这里主要关注两个注解:@AfterReturning 和 @AfterThrowing。一个是在自定义注解@Log标识的方法结束返回后调用,一个是在异常抛出后调用。

    也就是说,在所有使用@Log标识的方法执行完后,都会调用 LogAspect 这个切面类中 被 @AfterReturning 注解作用的代码。@AfterThrowing也是同样的道理。

  4. 在Controller中使用注解@Log,并且定义好其参数。

五. JointPoint到底是什么

在上文中,我们会看的这样一段代码:

1
2
3
4
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult") 
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult){
handleLog(joinPoint, controllerLog, null, jsonResult);
}

在这里有一个JoinPoint类,JoinPoint是AOP的术语,称为“连接点”,而连接点在执行的时候可以看成当前请求类。什么意思呢?我们用上面的示例代码解释下:

比如现在有一个controller,里面有一个如下方法:

1
2
3
4
5
6
7
8
9
@Log(title = "查询信息", operatorType = OperatorType.MOBILE)
@GetMapping("list")
public Response list(@RequestParam(value = "phone", required = false) String phone,
@RequestParam(value = "nickName", required = false) String nickName,
@RequestParam(value = "schoolId", required = false) String schoolId,
@RequestParam(value = "gender", required = false) String gender,
@RequestParam(value = "stealth", required = false) String stealth) {
List<User> users = personalInformationService.list(phone, nickName, schoolId, gender, stealth);
return Response.success(users);

上面的方法被 @Log 注解标识了,那么他就会执行我们在上文第四部分代码中的 LogAspect 类,那么这个controller 方法在执行完成后,则会执行我们本部分开头的那段代码,然后我们就可以在这个切面中做我们想做的事情,正如第四部分一样,对日志信息进行了处理。

在执行本部分开头代码的时候,doAfterReturning 方法第一个参数是 JoinPoint joinPoint,而jointPoint 里面包含的,就是我们 controller 里的 list 方法。我们debug看下里面包含哪些内容:

我们一个个来看下:

  • joinPoint.getArgs();

此方法是获取 controller 里的 list 方法里的请求参数,比如我用 PostMan 发起一个请求,请求参数里面包含 phone 这个字段,如下:

那么我们的debug界面的 args 里就能看到这个参数,这里的args 是一个数组的形式

所以我们可以得出, joinPoint.getArgs() 获取的就是连接点(指的就是被注解标识,被请求 controller 里的 list 方法,后文统一称为连接点)的参数。

  • joinPoint.getSignature();

返回正在请求的方法的描述,可参考本部分第一张图

  • joinPoint.getTarget();

返回目标 object,可参考本部分第一张图

  • joinPoint.getThis();

返回代理 object,可参考本部分第一张图

  • joinPoint.toString();

打印建议方法的有用描述,可参考本部分第一张图

所以,经过debug分析来看,JoinPoint 类的主要作用就是可以让我们在Advice中获取被增强方法相关的所有信息。

六. 可能存在的疑惑

  1. LogAspect 类如何知道自定义注解 @Log 作用了哪些方法,又是如和获取他们的?

这里主要有两种方法,一种是通过反射,获取包含 @Log注解的类或方法。另一种就是通过 @annotation,比如

1
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
1
@annotation:就是用于匹配当前执行方法持有指定注解的方法

参考引用: