开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Java调用链跟踪关键技术(二)Javaagent

发表于 2019-08-06

banner窄.png

铿然架构 | 作者 / 铿然一叶
这是铿然架构的第 4 篇原创文章


相关阅读:

萌新快速成长之路

如何编写软件设计文档

JAVA编程思想(一)通过依赖注入增加扩展性

JAVA编程思想(二)如何面向接口编程

JAVA编程思想(三)去掉别扭的if,自注册策略模式优雅满足开闭原则

JAVA编程思想(四)Builder模式经典范式以及和工厂模式如何选?

Java编程思想(七)使用组合和继承的场景

JAVA基础(一)简单、透彻理解内部类和静态内部类

JAVA基础(二)内存优化-使用Java引用做缓存

JAVA基础(三)ClassLoader实现热加载

JAVA基础(四)枚举(enum)和常量定义,工厂类使用对比

JAVA基础(五)函数式接口-复用,解耦之利刃

Seata源码(一)初始化

Seata源码(二)事务基础对象

Seata源码(三)事务处理类结构和流程

Seata源码(四)全局锁GlobalLock

Seata源码(五)Seata数据库操作

Seata源码(六)Seata的undo日志操作

Seata源码(七)Seata事务故障处理

Seata源码(八)Seata事务生命周期hook

Seata源码(九)TCC核心类和处理逻辑

Seata源码(十)RM接收到请求后的调用过程

Seata源码(十一)TC接收到请求后的处理过程\


一、Javaagent

网上关于Javaagent的介绍很多,请找度娘和谷兄。唯一提的一点是字节码注入比较好用的是bytebuddy,封装度很高,使用简单。

二、代码样例

以下为关键代码样例,可以依样画瓢自行改造。

1.编写agent入口

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
java复制代码package com.javashizhan.trace;

import static net.bytebuddy.matcher.ElementMatchers.isInterface;
import static net.bytebuddy.matcher.ElementMatchers.isSetter;
import static net.bytebuddy.matcher.ElementMatchers.nameContainsIgnoreCase;
import static net.bytebuddy.matcher.ElementMatchers.nameStartsWithIgnoreCase;
import static net.bytebuddy.matcher.ElementMatchers.not;

import java.lang.instrument.Instrumentation;

import com.javashizhan.trace.interceptor.AbstractJunction;
import com.javashizhan.trace.interceptor.ProtectiveShieldMatcher;
import com.javashizhan.trace.interceptor.TraceInterceptor;

import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.description.NamedElement;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.implementation.MethodDelegation;
import net.bytebuddy.matcher.ElementMatcher;
import net.bytebuddy.matcher.ElementMatchers;

public class TraceAgent {

public static void premain(String arguments, Instrumentation instrumentation) {
new AgentBuilder.Default()
.type(buildMatch())
.transform((builder, type, classLoader, module) ->
builder.method(ElementMatchers.any())
.intercept(MethodDelegation.to(TraceInterceptor.class)) // 拦截器
).installOn(instrumentation);
}

public static ElementMatcher<? super TypeDescription> buildMatch() {
ElementMatcher.Junction judge = new AbstractJunction<NamedElement>() {
@Override
public boolean matches(NamedElement target) {
return true;
}
};
judge = judge.and(not(isInterface())).and(not(isSetter()))
.and(nameStartsWithIgnoreCase("io.spring"))
.and(not(nameContainsIgnoreCase("util")))
.and(not(nameContainsIgnoreCase("interceptor")));
judge = judge.and(not(isSetter()));
return new ProtectiveShieldMatcher(judge);
}

}

2.拦截器类TraceInterceptor.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
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
java复制代码package com.javashizhan.trace.interceptor;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.List;
import java.util.concurrent.Callable;

import com.javashizhan.trace.domain.CallMethod;
import com.javashizhan.trace.TraceWrapper;
import com.javashizhan.trace.collector.DBCollector;
import com.javashizhan.trace.domain.Trace;
import com.javashizhan.trace.domain.TraceRecord;

import net.bytebuddy.implementation.bind.annotation.Origin;
import net.bytebuddy.implementation.bind.annotation.RuntimeType;
import net.bytebuddy.implementation.bind.annotation.SuperCall;

public class TraceInterceptor {

@RuntimeType
public static Object intercept(@Origin Method method,
@SuperCall Callable<?> callable) throws Exception {

before(method);

try {
return callable.call();
} finally {
after();
}
}

public static void after() {
Trace trace = TraceWrapper.getTrace(); //Trace类,可自行实现,不是关键

if (null != trace) {
if (trace.callMethodSize() > 0) {

CallMethod callMethod = trace.pop();

if (null != callMethod && callMethod.isTraceFlag()) {

callMethod.calculateCostTime();
trace.addTraceRecord(new TraceRecord(callMethod));

}

if (trace.callMethodSize() == 0) {
List<TraceRecord> traceRecordList = trace.getAllTraceRecord();
if (null != traceRecordList && traceRecordList.size() > 0) {
DBCollector collector = new DBCollector(traceRecordList);
new Thread(collector).start();
TraceWrapper.destory();
}
}
}
}
}

private static void before(Method method) {
Trace trace = TraceWrapper.getTrace();

CallMethod callMethod = new CallMethod(method);
if (isInnerClass(callMethod)) { //spring中有很多内部类,可以去掉
callMethod.setTraceFlag(false);
} else {
callMethod.setTraceFlag(true);
}

//不管是否跟踪都放进去
trace.push(callMethod);
}

private static boolean isInnerClass(CallMethod callMethod) {
return callMethod.getClassName().indexOf('$') > -1;
}


}

3.AbstractJunction.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码package com.javashizhan.trace.interceptor;

import net.bytebuddy.matcher.ElementMatcher;
import net.bytebuddy.matcher.ElementMatcher.Junction;
import net.bytebuddy.matcher.ElementMatcher.Junction.Conjunction;
import net.bytebuddy.matcher.ElementMatcher.Junction.Disjunction;

public abstract class AbstractJunction<V> implements ElementMatcher.Junction<V> {
@Override
public <U extends V> Junction<U> and(ElementMatcher<? super U> other) {
return new Conjunction<U>(this, other);
}

@Override
public <U extends V> Junction<U> or(ElementMatcher<? super U> other) {
return new Disjunction<U>(this, other);
}
}

4.ProtectiveShieldMatcher.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码package com.javashizhan.trace.interceptor;

import net.bytebuddy.matcher.ElementMatcher;

public class ProtectiveShieldMatcher<T> extends ElementMatcher.Junction.AbstractBase<T> {

private final ElementMatcher<? super T> matcher;

public ProtectiveShieldMatcher(ElementMatcher<? super T> matcher) {
this.matcher = matcher;
}

public boolean matches(T target) {
try {
return this.matcher.matches(target);
} catch (Throwable t) {
//logger.warn(t, "Byte-buddy occurs exception when match type.");
return false;
}
}
}

三、pom文件

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
102
103
104
105
106
107
108
109
110
111
xml复制代码<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>trace</groupId>
<artifactId>chain</artifactId>
<version>0.0.1-SNAPSHOT</version>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<java.version>1.8</java.version>
<!-- <spring-cloud.version>Finchley.SR1</spring-cloud.version> -->
</properties>

<dependencies>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.9.6</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.9.6</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>2.7.9</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.16</version>
</dependency>

</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<!-- <Premain-Class>com.undergrowth.secure.SecurityAgent</Premain-Class> -->
<!-- <Premain-Class>com.undergrowth.agent.AgentToString</Premain-Class>-->
<Premain-Class>com.javashizhan.trace.TraceAgent</Premain-Class>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
<pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
<plugins>
<!-- clean lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#clean_Lifecycle -->
<plugin>
<artifactId>maven-clean-plugin</artifactId>
<version>3.1.0</version>
</plugin>
<!-- default lifecycle, jar packaging: see https://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_jar_packaging -->
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.0.2</version>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.0</version>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.1</version>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.0.2</version>
</plugin>
<plugin>
<artifactId>maven-install-plugin</artifactId>
<version>2.5.2</version>
</plugin>
<plugin>
<artifactId>maven-deploy-plugin</artifactId>
<version>2.8.2</version>
</plugin>
<!-- site lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#site_Lifecycle -->
<plugin>
<artifactId>maven-site-plugin</artifactId>
<version>3.7.1</version>
</plugin>
<plugin>
<artifactId>maven-project-info-reports-plugin</artifactId>
<version>3.0.0</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>

四、在Java应用中添加启动参数

1.先将agent工程打成jar包

2.在要使用agent的Java应用中添加如下VM启动参数

1
diff复制代码-javaagent:D:\MyApp\apache-skywalking-apm-bin\agent\chain-0.0.1-SNAPSHOT.jar

注意自行替换jar包路径。

end.


本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

漫话:如何给女朋友解释什么是CDN?

发表于 2019-08-05

周六晚上七点多,我正在看书呢,突然女朋友跑过来问我她的IPAD去哪了,火急火燎的。

她拿到了IPAD之后就不再理我了,不过作为一个程序员,我还是比较好奇这么大的流量虎牙到底能不能扛得住,哈哈哈。于是我过去看了一下,结果看到了下面这一幕:

但是直播竟然并没有显得很卡顿,禁不住说了一段话:

据了解,2018年1月,阿里云为虎牙提供了边缘节点服务(ENS)。基于阿里云ENS,可以轻松地将业务模块放到边缘运行,在主播的推流时,实现就近节点进行转码和分发,同时支持了高并发实时弹幕的边缘分发。在获得网络低时延的同时,减少了对中心的压力,节省了30%以上的中心带宽成本,并且实现了边缘节点网络连接小于5毫秒延时,提升了主播上行质量,以及用户成功连接占比等数指标,有效提升了用户观看体验。ENS中最主要的技术就是CDN。

直播终于结束了,女朋友终于跑过来问我什么是CDN了…

什么是CDN

CDN的全称是Content Delivery Network,即内容分发网络。

我们都用过天猫超市,在上面买东西非常方便。天猫超市的模式是货品先入天猫超市(后文简称为”猫超”)的菜鸟仓,然后由猫超统一派送的。

为了缩短物流的时间,可以让消费者快速的收到货品,菜鸟在全国各地建了本地仓库,现在大多数情况下,在猫超下单,第二天都可以收到(楼主在江浙沪包邮区,其他地区可能稍有延迟)。

比如我在杭州市西湖区,下单购买了一箱零食,没过多久就可以看到猫超已经发货了,发货地址是杭州的萧山仓,从杭州的一个区运输到另外一个区,24小时怎么也到了。

猫超的配送采用的是智能仓配模式,菜鸟为天猫超市提供全国智能分仓,在商品销售前就已经来到距离消费者最近的仓储基地,下单购买后,由最近的仓发货,就近配送,速度比跨越多个省市跑过来的快多了。我们可以在菜鸟网络的官网上看到其全国各地的仓库情况,我们可以看到他目前覆盖了全国20哥省份,70个城市,共有327各仓库。这些仓库组合在一起被称之为”全国仓网”。

图:菜鸟全国仓配网络我们在浏览网络的时候,其实就和以上这个过程十分相似,我们访问一个页面的时候,会向服务器请求很多网络资源,包括各种图片、声音、影片、文字等信息。这和我们要购买的多种货物一样。

就像猫超会把货物提前存储在菜鸟建设在全国各地的本地仓库来减少物流时间一样,网站也可以预先把内容分发至全国各地的加速节点。这样用户就可以就近获取所需内容,避免网络拥堵、地域、运营商等因素带来的访问延迟问题,有效提升下载速度、降低响应时间,提供流畅的用户体验。

所以,”内容分发网络”就像前面提到的”全国仓配网络”一样,解决了因分布、带宽、服务器性能带来的访问延迟问题,适用于站点加速、点播、直播等场景。使用户可就近取得所需内容,解决 Internet网络拥挤的状况,提高用户访问网站的响应速度和成功率。

有了仓配网络之后,除了可以提升货物的配送效率,还有很多其他的好处:

1、首先通过预先做好了货物分发,使得最终货品从出仓到消费者手中的过程是比较短的,那么同城范围内可选择的配送公司就有很多选择,除了比较大的四通一达、顺丰以外,还可以选用一些小的物流公司、甚至菜鸟直接调用饿了么的蜂鸟配送也不是不可能。

CDN技术消除了不同运营商之间互联的瓶颈造成的影响,实现了跨运营商的网络加速,保证不同网络中的用户都能得到良好的访问质量

2、对于仓配系统来说,最大的灾难可能就是仓库发生火灾、水灾等自然灾害。如果把原来的一个集中式的大仓库打散成多个分布式的小仓库,分别部署在不同地区,就可以有效的减小自然灾害带来的影响。

广泛分布的CDN节点加上节点之间的智能冗余机制,可以有效地预防黑客入侵以及降低各种DDoS攻击对网站的影响,同时保证较好的服务质量

CDN的基本工作过程

传统快递企业采用的配送模式,通过”商家→网点→分拨→分拨→网点→客户”的环节进行配送。这个过程会有一些问题,如环节多、时效慢、易破损等。

上面这个过程和传统网站的请求响应过程类似,一般经历以下步骤:

  • 用户在自己的浏览器中输入要访问的网站域名。
  • 浏览器向本地DNS服务器请求对该域名的解析。
  • 本地DNS服务器中如果缓存有这个域名的解析结果,则直接响应用户的解析请求。
  • 本地DNS服务器中如果没有关于这个域名的解析结果的缓存,则以迭代方式向整个DNS系统请求解析,获得应答后将结果反馈给浏览器。
  • 浏览器得到域名解析结果,就是该域名相应的服务设备的IP地址 。
  • 浏览器获取IP地址之后,经过标准的TCP握手流程,建立TCP连接。
  • 浏览器向服务器发起HTTP请求。
  • 服务器将用户请求内容传送给浏览器。
  • 经过标准的TCP挥手流程,断开TCP连接。

电商自建物流之后,配送模式有所变化:提前备货将异地件转化成同城件,省去干线环节提升时效,仓储高自动化分拣保证快速出库的同时也保证了分拣破损率较低。

对于用户来说,购物过程并没有变化,唯一的感受就是物流好像是比以前快了。所以,引入CDN之后,用户访问网站一般经历以下步骤:

  • 当用户点击网站页面上的内容URL,先经过本地DNS系统解析,如果本地DNS服务器没有相应域名的缓存,则本地DNS系统会将域名的解析权交给CNAME指向的CDN专用DNS服务器。
  • CDN的DNS服务器将CDN的全局负载均衡设备IP地址返回给用户。
  • 用户向CDN的全局负载均衡设备发起URL访问请求。
  • CDN全局负载均衡设备根据用户IP地址,以及用户请求的URL,选择一台用户所属区域的区域负载均衡设备,并将请求转发到此设备上。
  • 基于以下这些条件的综合分析之后,区域负载均衡设备会选择一个最优的缓存服务器节点,并从缓存服务器节点处得到缓存服务器的IP地址,最终将得到的IP地址返回给全局负载均衡设备:
  • 根据用户IP地址,判断哪一个边缘节点距用户最近;
  • 根据用户所请求的URL中携带的内容名称,判断哪一个边缘节点上有用户所需内容;
  • 查询各个边缘节点当前的负载情况,判断哪一个边缘节点尚有服务能力。
  • 全局负载均衡设备把服务器的IP地址返回给用户。
  • 用户向缓存服务器发起请求,缓存服务器响应用户请求,将用户所需内容传送到用户终端。如果这台缓存服务器上并没有用户想要的内容,而区域均衡设备依然将它分配给了用户,那么这台服务器就要向它的上一级缓存服务器请求内容,直至追溯到网站的源服务器将内容拉到本地。

图:华为云全站加速示意图

CDN全局负载均衡设备与CDN区域负载均衡设备根据用户IP地址,将域名解析成相应节点中缓存服务器的IP地址,实现用户就近访问,从而提高服务端响应内容的速度。

CDN的组成

前面我们说过,一个仓配网络是由多个仓库组成的,同理,内容分发网络(CDN)是由多个节点组成的。一般来讲,CDN网络主要由中心节点、边缘节点两部分构成。

图:帝联云下载加速场景图### 中心节点

中心节点包括CDN网管中心和全局负载均衡DNS重定向解析系统,负责整个CDN网络的分发及管理。

边缘节点

CDN边缘节点主要指异地分发节点,由负载均衡设备、高速缓存服务器两部分组成。

负载均衡设备负责每个节点中各个Cache的负载均衡,保证节点的工作效率;同时还负责收集节点与周围环境的信息,保持与全局负载均衡DNS的通信,实现整个系统的负载均衡。

高速缓存服务器(Cache)负责存储客户网站的大量信息,就像一个靠近用户的网站服务器一样响应本地用户的访问请求。通过全局负载均衡DNS的控制,用户的请求被透明地指向离他最近的节点,节点中Cache服务器就像网站的原始服务器一样,响应终端用户的请求。因其距离用户更近,故其响应时间才更快。

中心节点就像仓配网络中负责货物调配的总仓,而边缘节点就是负责存储货物的各个城市的本地仓库。

目前,主要由很多提供CDN服务的云厂商在各地部署了很多个CDN节点,拿阿里云举例,我们可以在阿里云的官网上了解到:阿里云在全球拥有2500+节点。中国大陆拥有2000+节点,覆盖34个省级区域,大量节点位于省会等一线城市。海外和港澳台拥有500+节点,覆盖70多个国家和地区。

图:阿里云在中国大陆的CDN节点的分布情况有了如上图的阿里云在中国大陆的CDN节点的分布之后(这是不是也和我们前面看到的那张菜鸟网络的全国仓网很像),一个在杭州的电信网络用户,访问某个部署在阿里云上面的网站时,获取到的一些资源,如页面上的某个图片、某段影片或者某些文字,可能就是该网站预先分发到浙江的某个移动CDN存储节点提供的,这样就可以大大的减少网站的响应时间。

CDN相关技术

首先我们想一下,要想建设一个庞大的仓配网络都需要考虑哪些问题,需要哪些技术手段呢?

笔者认为主要是四个重要关注的点,分别是:

1、如何妥善的将货物分发到各个城市的本地仓。

2、如何妥善的各个本地仓存储货物。

3、如何根据用户的收货地址,智能的匹配出应该优先从哪个仓库发货,选用哪种物流方式等。

4、对于整个仓配系统如何进行管理,如整体货物分发的精确度、仓配的时效性、发货地的匹配度等。

图:菜鸟仓库智能机器人分拣货物这其实和CDN中最重要的四大技术不谋而合,那就是内容发布、内容存储、内容路由以及内容管理等。

内容发布

它借助于建立索引、缓存、流分裂、组播(Multicast)等技术,将内容发布或投递到距离用户最近的远程服务点(POP)处。

内容存储

对于CDN系统而言,需要考虑两个方面的内容存储问题。一个是内容源的存储,一个是内容在 Cache节点中的存储。

内容路由

它是整体性的网络负载均衡技术,通过内容路由器中的重定向(DNS)机制,在多个远程POP上均衡用户的请求,以使用户请求得到最近内容源的响应。

内容管理

它通过内部和外部监控系统,获取网络部件的状况信息,测量内容发布的端到端性能(如包丢失、延时、平均带宽、启动时间、帧速率等),保证网络处于最佳的运行状态。

参考资料:
https://www.gelonghui.com/p/140685https://blog.csdn.net/championhengyi/article/details/80726304https://www.gelonghui.com/p/140685![](https://gitee.com/songjianzaina/juejin_p14/raw/master/img/6636b4c29d58eb07f84a674429187b4808622c04014758c2f3f30063a11bc4f8)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

程序员,你心里就没点树吗? 什么是树? 二叉树 二叉查找树

发表于 2019-08-03

看官,不要生气,我没有骂你也没有鄙视你的意思,今天就是想单纯的给大伙分享一下树的相关知识,但是我还是想说作为一名程序员,自己心里有没有点树?你会没点数吗?言归正传,树是我们常用的数据结构之一,树的种类很多有二叉树、二叉查找树、平衡二叉树、红黑树、B树、B+树等等,我们今天就来聊聊二叉树相关的树。

什么是树?

首先我们要知道什么是树?我们平常中的树是往上长有分支的而却不会形成闭环,数据结构中的树跟我们我们平时看到的树类似,确切的说是跟树根长得类似,我画了一幅图,让大家更好的理解树。

图1、图2都是树,图3不是树。每个红色的圆圈我们称之为元素也叫节点,用线将两个节点连接起来,这两个节点就形成了父子关系,同一个父节点的子节点成为兄弟节点,这跟我们家族关系一样,同一个父亲的叫做兄弟姐妹,在家族里面最大的称为老子,树里面也是一样的,只是不叫老子,叫做跟节点,没有子节点的叫做叶子节点。我们拿图1来做示例,A为根节点,B、C为兄弟节点,E、F为叶子节点。
一颗树还会涉及到三个概念高度、深度、层,我们先来看看这三个名词的定义:

高度:节点到叶子节点的最长路径,从0开始计数

深度:跟节点到这个节点所经历的边数,从0开始计数

层:节点距离根节点的距离,从1开始计数

知道了三个名词的概念之后,我们用一张图来更加形象的表示这三个概念。

以上就是树的基本概念,树的种类很多,我们主要来学学二叉树。

二叉树

二叉树就像它的名字一样,每个元素最多有两个节点,分别称为左节点和右节点。当然并不是每个元素都需要有两个节点,有的可能只有左节点,有的可能只有右节点。就像国家开放二胎一样,也不是每个人都需要生两个孩子。下面我们来看看一颗典型的二叉树。

基于树的存储模式的不同,为了更好的利用存储空间,二叉树又分为完全二叉树和非完全二叉树,我们先来看看什么是完全二叉树、非完全二叉树?

完全二叉树的定义:叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大

也许单看定义会看不明白,我们来看几张图,你就能够明白什么是完全二叉树、非完全二叉树。

1、完全二叉树

完全二叉树

2、非完全二叉树

上面我们说了基于树的存储模式不同,而分为完全二叉树和非完全二叉树,那我们接下来来看看树的存储模式。

二叉树的存储模式

二叉树的存储模式有两种,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法

二叉链式存储法

链式存储法相对比较简单,理解起来也非常容易,每一个节点都有三个字段,一个字段存储着该节点的值,另外两个字段存储着左右节点的引用。我们顺着跟字节就可以很轻松的把整棵树串起来,链式存储法的结构大概长成这样。

顺序存储法

顺序存储法是基于数组实现的,数组是一段有序的内存空间,如果我们把跟节点的坐标定位i=1,左节点就是 2 * i = 2,右节点 2 * i+ 1 = 3,以此类推,每个节点都这么算,然后就将树转化成数组了,反过来,按照这种规则我们也能将数组转化成一棵树。看到这里我想你一定看出了一些弊端, 如果这是一颗不平衡的二叉树是不是会造成大量的空间浪费呢?没错,这就是为什么需要分完全二叉树和非完全二叉树。分别来看看这两种树基于数组的存储模式。

完全二叉树顺序存储法

非完全二叉树顺序存储法

从图中将树转化成数组之后可以看出,完全二叉树用数组来存储只浪费了一个下标为0的存储空间,二非完全二叉树则浪费了大量的空间。如果树为完全二叉树,用数组存储比链式存储节约空间,因为数组存储不需要存储左右节点的信息

上面我们了解了二叉树的定义、类型、存储方式,接下来我们一起了解一下二叉树的遍历,二叉树的遍历也是面试中经常遇到的问题。

二叉树遍历

要了解二叉树的遍历,我们首先需要实例化出一颗二叉树,我们采用链式存储的方式来定义树,实例化树需要树的节点信息,用来存放该节点的信息,因为我们才用的是链式存储,所以我们的节点信息如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码/**
* 定义一棵树
*/
public class TreeNode {
// 存储值
public int data;
// 存储左节点
public TreeNode left;
// 存储右节点
public TreeNode right;

public TreeNode(int data) {
this.data = data;
}
}

定义完节点信息之后,我们就可以初始化一颗树啦,下面是初始化树的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码public static TreeNode buildTree() {
// 创建测试用的二叉树
TreeNode t1 = new TreeNode(1);
TreeNode t2 = new TreeNode(2);
TreeNode t3 = new TreeNode(3);
TreeNode t4 = new TreeNode(4);
TreeNode t5 = new TreeNode(5);
TreeNode t6 = new TreeNode(6);
TreeNode t7 = new TreeNode(7);
TreeNode t8 = new TreeNode(8);

t1.left = t2;
t1.right = t3;
t2.left = t4;
t4.right = t7;
t3.left = t5;
t3.right = t6;
t6.left = t8;

return t1;
}

经过上面步骤之后,我们的树就长成下图所示的样子,数字代表该节点的值。

有了树之后,我们就可以对树进行遍历啦,二叉树的遍历有三种方式,前序遍历、中序遍历、后续遍历三种遍历方式,三种遍历方式与节点输出的顺序有关系。下面我们分别来看看这三种遍历方式。

前序遍历

前序遍历:对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。

为了方便大家的理解,我基于上面我们定义的二叉树,对三种遍历方式的执行流程都制作了动态图,希望对你的阅读有所帮助,我们先来看看前序遍历的执行流程动态图。

理解了前序遍历的概念和看完前序遍历执行流程动态图之后,你心里一定很想知道,在代码中如何怎么实现树的前序遍历?二叉树的遍历非常简单,一般都是采用递归的方式进行遍历,我们来看看前序遍历的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码// 先序遍历,递归实现 先打印本身,再打印左节点,在打印右节点
public static void preOrder(TreeNode root) {

if (root == null) {
return;
}
// 输出本身
System.out.print(root.data + " ");
// 遍历左节点
preOrder(root.left);
// 遍历右节点
preOrder(root.right);
}

中序遍历

中序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。

跟前序遍历一样,我们来看看中序遍历的执行流程动态图。

中序遍历的代码:

1
2
3
4
5
6
7
8
9
复制代码// 中序遍历 先打印左节点,再输出本身,最后输出右节点
public static void inOrder(TreeNode root) {
if (root == null) {
return;
}
inOrder(root.left);
System.out.print(root.data + " ");
inOrder(root.right);
}

后序遍历

后序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。

跟前两种遍历一样,理解概念之后,我们还是先来看张图。

后序遍历的实现代码:

1
2
3
4
5
6
7
8
9
复制代码// 后序遍历 先打印左节点,再输出右节点,最后才输出本身
public static void postOrder(TreeNode root) {
if (root == null) {
return;
}
postOrder(root.left);
postOrder(root.right);
System.out.print(root.data + " ");
}

二叉树的遍历还是非常简单的,虽然有三种遍历方式,但都是一样的,只是输出的顺序不一样而已,经过了上面这么多的学习,我相信你一定对二叉树有不少的认识,接下来我们来了解一种常用而且比较特殊的二叉树:二叉查找树

二叉查找树

二叉查找树又叫二叉搜索树,从名字中我们就能够知道,这种树在查找方面一定有过人的优势,事实确实如此,二叉查找树确实是为查找而生的树,但是它不仅仅支持快速查找数据,还支持快速插入、删除一个数据。那它是怎么做到这些的呢?我们先从二叉查找树的概念开始了解。

二叉查找树:在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

难以理解?记不住?没关系的,下面我定义了一颗二叉查找树,我们对着树,来慢慢理解。

根据二叉查找树的定义,每棵树的左节点的值要小于这父节点,右节点的值要大于父节点。62节点的 所有左节点的值都要小于 62 ,所有右节点 的值都要大于 62 。对于这颗树上的每一个节点都要满足这个条件,我们拿我们树上的另一个节点 35 来说,它的右子树上的节点值最大不能超过 47 ,因为 35 是 47 的左子树,根据二叉搜索树的规则,左子树的值要小于节点值。
二叉查找树既然名字中带有查找两字,那我们就从二叉查找树的查找开始学习二叉查找树吧。

二叉查找树的查找操作

由于二叉查找树的特性,我们需要查找一个数据,先跟跟节点比较,如果值等于跟节点,则返回根节点,如果小于根节点,则必然在左子树这边,只要递归查找左子树就行,如果大于,这在右子树这边,递归右子树即可。这样就能够实现快速查找,因为每次查找都减少了一半的数据,跟二分查找有点相似,快速插入、删除都是居于这个特性实现的。

下面我们用一幅动态图来加强对二叉查找树查找流程的理解,我们需要在上面的这颗二叉查找树中找出值等于 37 的节点,我们一起来看看流程图是怎么实现的。

二叉查找树树的查找操作

  • 1、先用 37 跟 62 比较,37 < 62 ,在左子树中继续查找
  • 2、左子树的节点值为 58,37 < 58 ,继续在左子树中查找
  • 3、左子树的节点值为 47,37 < 47,继续在左子树中查找
  • 4、左子树的节点值为 35,37 > 35,在右子树中查找
  • 5、右子树中的节点值为 37,37 = 37 ,返回该节点

讲完了查找的概念之后,我们一起来看看二叉查找树的查找操作的代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码/**
* 根据值查找树
* @param data&emsp;值
* @return
*/
public TreeNode find(int data) {
TreeNode p = tree;
while (p != null) {
if (data < p.data) p = p.left;
else if (data > p.data) p = p.right;
else return p;
}
return null;
}

二叉查找树的插入操作

插入跟查找差不多,也是从根节点开始找,如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。

假设我们要插入 63 ,我们用一张动态图来看看插入的流程。

  • 1、63 > 62 ,在树的右子树继续查找.
  • 2、63 < 88 ,在树的左子树继续查找
  • 3、63 < 73 ,因为 73 是叶子节点,所以 63 就成为了 73 的左子树。

我们来看看二叉查找树的插入操作实现代码

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
复制代码/**
* 插入树
* @param data
*/
public void insert(int data) {
if (tree == null) {
tree = new TreeNode(data);
return;
}

TreeNode p = tree;

while (p != null) {
// 如果值大于节点的值,则新树为节点的右子树
if (data > p.data) {
if (p.right == null) {
p.right = new TreeNode(data);
return;
}
p = p.right;
} else { // data < p.data
if (p.left == null) {
p.left = new TreeNode(data);
return;
}
p = p.left;
}
}
}

二叉查找树的删除操作

删除的逻辑要比查找和插入复杂一些,删除分一下三种情况:

第一种情况:如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为 null。比如图中的删除节点 51。

第二种情况:如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点 35。

第三种情况:如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点 88

前面两种情况稍微简单一些,第三种情况,我制作了一张动态图,希望能对你有所帮助。

我们来看看二叉查找树的删除操作实现代码

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
复制代码public void delete(int data) {
TreeNode p = tree; // p指向要删除的节点,初始化指向根节点
TreeNode pp = null; // pp记录的是p的父节点
while (p != null && p.data != data) {
pp = p;
if (data > p.data) p = p.right;
else p = p.left;
}
if (p == null) return; // 没有找到

// 要删除的节点有两个子节点
if (p.left != null && p.right != null) { // 查找右子树中最小节点
TreeNode minP = p.right;
TreeNode minPP = p; // minPP表示minP的父节点
while (minP.left != null) {
minPP = minP;
minP = minP.left;
}
p.data = minP.data; // 将minP的数据替换到p中
p = minP; // 下面就变成了删除minP了
pp = minPP;
}

// 删除节点是叶子节点或者仅有一个子节点
TreeNode child; // p的子节点
if (p.left != null) child = p.left;
else if (p.right != null) child = p.right;
else child = null;

if (pp == null) tree = child; // 删除的是根节点
else if (pp.left == p) pp.left = child;
else pp.right = child;
}

我们上面了解了一些二叉查找树的相关知识,由于二叉查找树在极端情况下会退化成链表,例如每个节点都只有一个左节点,这是时间复杂度就变成了O(n),为了避免这种情况,又出现了一种新的树叫平衡二叉查找树,由于本文篇幅有点长了,相信看到这里的各位小伙伴已经有点疲惫了,关于平衡二叉查找树的相关知识我就不在这里介绍了。

最后

打个小广告,欢迎扫码关注微信公众号:「平头哥的技术博文」,一起进步吧。

平头哥的技术博文

参考资料

  • 数据结构与算法之美(极客时间)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

后端面试知识点大串烧!(蚂蚁美团头条腾讯面试经历)

发表于 2019-08-02

更多Java面试资料(操作系统,网络,zk,mq,redis,java等) :github.com/yuhaqiang12…

笔者在面过 猿辅导,去哪儿,旷视, 陌陌,头条, 阿里, 快手, 美团, 腾讯之后,除了收获一大堆面试问题,还思考到如何成为面试官眼中的”爱技术,爱思考,靠谱,有潜力候选人的”一些”套路”.

1. 面试问题(Java 后端)

猿辅导

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码 1.八皇后问题
2.求二叉树的最长距离(任意两个节点的路径 中最长的)
3.lru 算法的实现
4.设计一个数据结构 满足 put 和 getMedium(中位数)两个方法.
(时间复杂度分析, getMedium 在常数,n,log n 时间复杂度返回如何实现)
5.rabbitmq 脑裂问题,rabbitmq 延迟队列实现, rabbitmq高可用策略
(因为项目中用到了 rabbitmq 和他们技术选型出现了重叠,问了这个问题)
6. 死磕项目细节其中包括:
设计方案时有没有比较多种方案,为什么选这个方案?
你个人最有成就感,最有挑战性的 工作是哪一个?

思考:
1. 猿辅导问的算法题属于 leetcode easy, medium 级别的,基本不会太难

旷视(Python)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
markdown复制代码   1. Python 如何实现多线程/多进程编程
2. Python GIL 锁是什么?为什么会出现 GIL
3. python 协程有么有用过? 有哪些常见的协程库,介绍一下
4. python 装饰器如何实现,原理,常见使用场景?
5. python 一堆我没听见的库,有没有用过.(我没记下来)
6. 给你一个 Linux 路径,求其最简化的路径,例如(/a/b/.. -> /a, a/b/./. -> a/b等)
7. 给你一台 16 核, 32G 的机器, 无限空间大的分布式存储. 对1 个 16P 大小的文本文件进行排序.
文件每行一条记录, 空格分割例如
key1
key2
8. 介绍一下什么是外部排序

思考:
1. 旷视的这位面试官 和我死磕 python. 一点项目经历没问.
2. 除了 leetcode 也要多看看高并发,大数据相关的 系统设计题.

去哪儿(Java)

1
2
3
4
5
6
7
8
markdown复制代码  1. 为什么使用 MQ, MQ 如何选型, 消息可靠性如何保证, 如何保证幂等
2. 用过 dubbo吗? 设计一个 rpc 框架.
3. 介绍一下 https
4. 数据库线程池, http 连接池有没有深入看过源码?介绍一下.(项目里用得到了 http client)
5. 给你十亿条数据,如何最快的添加到数据库中
6. 分布式锁的技术选型, 实现原理, 优劣势比较, zookeeper 的一致性协议原理
7. java 线程同步的几种方式, countdownlatch 和 栅栏的区别
8. synchronized和 aqs 如何实现可重入锁

陌陌

1
2
3
4
5
6
markdown复制代码  1.分布式锁的实现方案比较,为什么选择 zookeeper, zookeeper 一致性协议原理
2.一致性 Hash 原理,实现,项目中是如何使用一致性 Hash 的,引入了多少虚拟节点?
3.java synchronized和 AQS的原理,区别
4. redis 有序列表
5. redis 高可用架构是什么? codis 和 redis cluster 分片的区别
6. 两个线程如何交替打印 0到99

头条(Go)

1
2
3
4
5
6
css复制代码  1. 给你一个 Linux 路径,求其最简化的路径,例如(/a/b/.. -> /a,  a/b/./. -> a/b等) 和旷视问重了
2. top-k
3. 实现前缀树
4. 实现python装饰器.方法实现,和类实现, 带参数和不带参数.以及对装饰器的思考
5. 如何实现对 多机房,多机架 之前的网络健康情况监控.
6. 如何理解进程上下文切换, 进程地址空间,为什么需要进程地址空间, 系统调用实现原理, top 命令介绍.

阿里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
markdown复制代码  1. mq 消息可靠性,幂等如何保证
2. 分布式锁的实现方案比较,为什么选择 zookeeper, zookeeper 一致性协议原理
3. 线程池参数,阻塞队列实现.
4. 一致性 Hash解决什么问题, 如何实现? 虚拟节点的作用?
5. Java 锁的实现方式, 比较? AQS实现原理?公平非公平实现原理?
6. CAS 实现原理
7. volatile 实现原理, 单例模式
8. java 内存模型, gc 调优的经历. cms gc 的几个阶段, 为什么会出现 stop the world. 常见可优化参数有哪些.
为什么需要优化 gc, gc 会导致什么问题.
9. mysql 事务隔离级别. mvcc 实现原理
10. mysql 索引原理. 为什么使用 B+树. 及何时无法使用索引?
11. mysql 架构, 引擎层和 server层 各自负责什么.
12. hashmap 及 concurrenthashmap 实现原理
13. Spring aop原理,如何定义新的spring xml 标签
14. 合并两个有序链表
15. 如何设计一个 大型活动的安保系统(开放题)
16. 你平常都在哪些论坛上学习?
17. 如何学习一门未知的技术?

思考:
阿里面试官 虽然不面算法,但是面试考察点全方位打击,从浅入深,揪住不放,直到你不会为止.
是收获最多的面试,也是感受到自己差距的面试.

快手

1
2
3
4
5
6
7
8
9
10
11
markdown复制代码  1. 线程池实现原理,如何调优
2. 如何实现一个延迟队列
3. mysql 索引
4. mysql 事务隔离级别
5. java 锁和常见线程同步方式
6. zookeeper 分布式实现方式及优劣,如何避免 多个客户端同时获取到锁?
7. 求二叉树两个节点的共同节点
8. 求二叉树的深度(非递归)
9. java 集合常见类及原理
10. tcc 原理
11. netty 的请求处理流程.线程模型

美团

1
2
3
4
5
6
7
8
9
10
11
12
13
markdown复制代码  1. HashMap 的实现原理?扩容原理? 为什么 jdk 8修改了冲突链表的插入位置
2. mysql 的高可用架构.主从同步过程.
3. http 和rpc 调用的区别
4. redis 如何用单线程支撑高并发, redis 的常见使用场景
5. mq 如何选型. 为什么用 mq
6. 一致性 Hash 原理
7. 美团外卖的支付 ,要求在 15 分钟内取消未支付的订单. 如何实现
8. 打印 * 星号的等腰三角形
9. 项目的全链路架构, 有没有单点问题,解决单点问题有哪些常见的方案.
10. 项目中有哪些可以衡量工作产出的指标.
11. 说一下你负责的 最复杂,参与人数最多,周期对长.的项目是如何推进的
12. 你认为自己的优势,劣势在哪里.
13. 你对未来的职业规划,你期望的工作内容,方向是什么?

腾讯(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
erlang复制代码   1.如何在物理机和容器中获取 cpu 核数,如何设置线程数, 如何主动触发 GC
2.一致性 Hash, Hash 的作用, 为什么叫一致性 Hash,一致性体现在哪里.
HashMap中还可以使用什么方式处理 Hash 冲突
3.线程池参数,优化,原理
4.实现一个功能:
cat /usr/local/*.log|grep tencent
1. 并发 IO
2. 30 s 内必须返回结果
5. 如何理解 Future模式?java 的实现原理
6. Java 阻塞队列实现原理
7. java 锁 volatile 实现原理
8. mysql 索引原理,事务隔离级别, mysql 死锁的场景会有哪些, 内部如何检测死锁的?
9. java 线程同步共有哪几种工具?
10. 你认为 java 设计得比较优秀的地方有哪些?
11. 如何理解面向对象设计,能用你看过得开源代码或者实际项目介绍一下吗?
12. java 类加载器的原理及实际使用场景.
13. java 内存模型, 虚拟机栈默认大小.
14. 说一下高可用架构的常见解决思路

思考:
腾讯一面面试官考察点非常深入,要求你具有归纳能力.例如分布式中常见的负载策略,
分布式中数据同步备份的常见方案.线程同步的几种方式等等.
而且在你回答之后能继续深入逼问.
不像其他面试官抛出来问题,他就听你吹. 你能吹多少,他就认为你会多少.

思考

  1. 手撕算法需要准备,面试之前保证刷够 100 题,及部分设计题.
  2. 无论会不会,一定不能慌.无论会不会,一定要和面试官确认自己的理解是不是正确,这道题应该如何思考? 避免跑偏
  3. 面试重点在于沟通.

3.1 强行总结结论

一定要有条理性的和面试官沟通. 避免东一笤帚,西一扫帚.最好提前想好一些结论,重复几遍.这样面试官可能直接用你的”结论”来 写面试经过,结论,评价等. 说完一件事,最好有条理性的结论,让面试官印象深刻. 即使强行 1,2,3的划重点结论也比戛然而止强.

3.2 优雅的中断当前问题,只在检查点退出讨论,响应面试官的中断

当面试官出现了疑问,抛出了问题,中断你的回答,一定不要一味的回答,立即响应,把握自己的节奏,先面试官征求意见,能否把剩下的说完.继续快速的说完,记得总结结论
求同存异,避免争论

面试官没有你熟悉你的项目,你的经历. 说,听,讨论. 三个阶段都会存在沟通信息的损失. 给面试官讲清楚, 让他理解,认同你可能很难. 但是如果出现争论,会降低对你的好感,降低沟通效率.所以你有责任及时的终止争论.可以使用一下技巧:

1
2
3
4
5
6
7
8
9
markdown复制代码  1. 重申 上下文, 目的, 现状, 背景, 利弊抉择.
2. 坦诚的承认这块我们的设计,实现并不是完美的.甚至做得不好.我们已经提出了哪些优化点(提出了解决方案).
但是优先级并不是很高(优先级是最好的甩锅方式).当初由于更专注于业务目标.技术前瞻性做得不足.你的意见确实一针见血,
这个问题让我们头疼了很久.
3. "这块确实比较复杂,咱们总结一下,细化一下分歧再讨论" 面试官一般不会拒绝,面试方向主动权重新由你掌握.(要有主动权意识)
4. 细化分歧过程中, 其实就是取得共识, 某些细枝末节的争论,直接和面试官解释,忽略掉即可. 把你们的共识摆出来.然后说:
"咱们的分歧主要是什么什么,其实是我每说清楚,再针对面试官疑问解释一下,或者甩锅,我们想优化,因为优先级.我们想这么做,
但是由于时间来不及,或者当时 XXX,没有这么做.不过后来我们确实吃了亏,算是技术债".
(承认 low 没有问题,强项装逼不服输才是最二百五的)

面试三千问

简历重要吗? 随便写行不行

1
2
3
4
5
6
markdown复制代码 简历一定要认真写.面试官抛出的问题中,除了常见的高频面试题,就是简历中你写的东西.要保证简历中写的东西,
透彻理解! 无论写的是了解,还是精通都要精通. 否则别写
我认为不用写的
1.不熟悉的,仅仅是知晓的.
2.在学校整的东西,没啥知名度就不要写了
3.github 要有,但是我没有被问过github 中的项目

项目经历问吗?

1
2
3
erlang复制代码 项目经历一般是 面试必问的,重点问的. 所以第一步要优先发掘自己项目中的亮点, 把自己做的工作清晰的写出来.
自我介绍阶段重点介绍应该也是自己的项目经历,这时最好自己提前准备一份演讲稿把自己的项目亮点说出来.
多练几遍.避免不过脑子,黄河决堤式回答,想到哪里说哪里.

常见高频问题呢?

1
2
3
4
erlang复制代码 java 锁,线程同步,Juc 包.线程池
内存模型,gc 调优
mysql 索引,锁,事务隔离级别.
常见分布式高可用架构 redis, mysql, zk, mq等. 数据同步,数据分片,数据备份等

需要刷题吗?

1
2
3
4
5
6
7
8
erlang复制代码 阿里一般不会问太多算法题.
但是至少一半以上公司都会手撕算法. 把leetcode 各个类型的题都刷十道以上基本没太大问题. hard题一般不会问.
如果自己面试表现非常好,但是因为算法题被刷掉是不是会很遗憾呢?
算法题能扩展一个人的思路,还是有用的.也锻炼一个人编码能力. 个人建议白板算法用 python 非常简洁.更聚焦解题思路

如果自己面试表现特别好,职位匹配度非常高. 算法题是可以防水的,会挑简单题问. 目的就是 留下你,怕你答不上来,避免尴尬

但是自己面试比较差, 算法题答得特别好,会不会扭转面试结果呢? 基本不会, 手撕算法只是辅助.项目经历和基础面试题是核心.

面试结果可以问吗?

1
2
erlang复制代码 如果没有面试到 hr,或者 终面面试官没有明确 hr 会联系,我基本都会问. 或者问一下自己的不足. 一般都会告诉自己.
另外, 手撕算法OK,也会被刷掉. 我在陌陌就是 手撕算法非常完美,结果还是挂了.

一般几轮面试?

1
erlang复制代码不算 hr ,基本都是三轮. 但如果二轮面试官开始扯虚的,和你介绍项目,问你职业规划 也许二面就是终面. 不清楚就问一下面试官.

平时工作划水, 面试临时抱佛脚行不行?

1
2
3
4
5
6
7
8
9
ruby复制代码不行
当面试官逼问项目细节时, 如果平时没有对细节特别熟悉,做事马马虎虎,技术深度不够,例如 技术选型为什么这么做,其他方案?优劣势是什么?
如果没有调研,面试时,很快会露出马脚.
如果对项目的整体架构不熟悉,只熟悉自己的模块,也会可能被面试官问到关联的模块,项目如果自己不熟悉,马上就会支支吾吾.如果你回答,这块
不是我负责的,我不太熟悉,就会让面试官对你产生 没有大局观,主动意识不够的狐疑.评价时会被评"只能完成自己模块内的工作,对系统的全局
没有了解,主动意识不强.自我驱动意识差"
工作时,可能仅仅聚焦业务,对于项目中应用的技术关注不足. 面试时候就会被面试官揪住.如果当时不思考清晰,并且面试前没有意识到这块技术
风险, 就会给面试带来很大风险.美团面试官曾问我,介绍一个你主要负责的 参与方最多,周期最长,最复杂的一个项目如何推进的? 如果平时工
作不积极,不主动思考,面试被摊上这么一个问题.基本上哑口无言. (我就目瞪口呆了)

仅仅看博客,把高频面试题搞懂行不行? (问题驱动式准备面试)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
arduino复制代码这是必须要做的事情.但是仅仅做到这些还远远不够
例如:
阿里面试官问:gc 为什么一定要 stop the world? 一般博客没有给出明确清晰直观的原因.

一致性 Hash 如何实现? 手撕一下.为什么叫一致性 hash,一致性体现在哪里? 一般博客没有.

lru 算法手撕一下. 博客有,看一遍就能手撕了?

redis 和 zk 分布式锁实现如何选型? 各自缺点,优势?项目使用时如何避免缺点带来的负面影响?

如何基于 AQS实现获取锁的公平性非公平性?

面试官甚至给你埋坑,故意说一个错误的,看你能否反驳.

我之前被带坑过, 线程池问题
面试官问:是先到 max size 还是先添加到阻塞队列?
我说 阻塞队列满了才会继续创建线程到max size
面试官: 是这样吗? 那么如果是无界队列岂不是永远无法到达 max size
我心想: 是啊,有道理,我可能记错了.然后被面试官带偏了.

所以结论是,光看懂记下来,不够,要深刻理解.时刻带着问题去学习.问题驱动式学习.
最后你会发现,
过了许久,你印象最深刻的还是自己当初提出来的疑问及其解决思路和答案.

更多知识资料 :github.com/yuhaqiang12…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Serverless(无服务)基础知识

发表于 2019-08-01

作者:高露

凹凸实验室公众号:AOTULabs

Serverless 架构即“无服务器”架构,它是一种全新的架构方式,是云计算时代一种革命性的架构模式。与云计算、容器和人工智能一样,Serverless 是这两年IT行业的一个热门词汇,它在各种技术文章和论坛上都有很高的曝光度。

目前行业可能更多处在容器 Docker+Kubernetes, 利用 IaaS、PaaS和SaaS 来快速搭建部署应用

什么是Serverless

Serverless 圈内俗称为“无服务器架构”,Serverless 不是具体的一个编程框架、类库或者工具。简单来说,Serverless 是一种软件系统架构思想和方法,它的核心思想是用户无须关注支撑应用服务运行的底层主机。这种架构的思想和方法将对未来软件应用的设计、开发和运营产生深远的影响。

所谓“无服务器”,并不是说基于 Serverless 架构的软件应用不需要服务器就可以运行,其指的是用户无须关心软件应用运行涉及的底层服务器的状态、资源(比如 CPU、内存、磁盘及网络)及数量。软件应用正常运行所需要的计算资源由底层的云计算平台动态提供。

Serverless的技术实现

Serverless 的核心思想是让作为计算资源的服务器不再成为用户所关注的一种资源。其目的是提高应用交付的效率,降低应用运营的工作量和成本。以 Serverless 的思想作为基础实现的各种框架、工具及平台,是各种 Serverless 的实现(Implementation)。Serverless不是一个简单的工具或框架。用户不可能简单地通过实施某个产品或工具就能实现 Serverless 的落地。但是,要实现 Serverless 架构的落地,需要一些实实在在的工具和框架作为有力的技术支撑和基础。

随着 Serverless 的日益流行,这几年业界已经出现了多种平台和工具帮助用户进行 Serverless 架构的转型和落地。目前市场上比较流行的 Serverless 工具、框架和平台 有:

  • AWS Lambda,最早被大众所认可的 Serverless 实现。
  • Azure Functions,来自微软公有云的 Serverless 实现。
  • OpenWhisk,Apache 社区的开源 Serverless 框架。
  • Kubeless,基于 Kubernetes 架构实现的开源 Serverless 框架。
  • Fission,Platform9 推出的开源 Serverless 框架。
  • OpenFaaS,以容器技术为核心的开源 Serverless 框架。
  • Fn,来自 Oracle 的开源 Serverless 框架,由原 Iron Functions 团队开发。

列举的 Serverless 实现有的是公有云的服务,有的则是框架工具,可以被部署在私有数据中心的私有云中(私有云 Serverless 框架 OpenWhisk、Fission 及 OpenFaaS)。每个 Serverless 服务或框架的实现都不尽相同,都有各自的特点。

FaaS与BaaS

IT是一个永远都不消停的行业,在这个行业里不断有各种各样新的名词和技术诞生,云计算(Cloud Computing)的出现是21世纪IT业界最重大的一次变革。云计算的发展从基础架构即服务(Infrastructure as a Service, IaaS),平台即服务(Platform as a Service,PaaS),软件即服务(Software as a Service,SaaS),慢慢开始演变到函数即服务(Function as a Service,FaaS)以及后台即服务(Backend as a Service,BaaS),Serverless 无服务化。

云计算演变

目前业界的各类 Serverless 实现按功能而言,主要为应用服务提供了两个方面的支持:函数即服务(Function as a Service,FaaS)以及后台即服务(Backend as a Service,BaaS)。

serverless结构

1.FaaS

FaaS 提供了一个计算平台,在这个平台上,应用以一个或多个函数的形式开发、运行和管理。FaaS 平台提供了函数式应用的运行环境,一般支持多种主流的编程语言,如 Java、PHP 及 Python 等。FaaS 可以根据实际的访问量进行应用的自动化动态加载和资源的自动化动态分配。大多数 FaaS 平台基于事件驱动(Event Driven)的思想,可以根据预定义的事件触发指定的函数应用逻辑。

目前业界 FaaS 平台非常成功的一个代表就是 AWS Lambda 平台。AWS Lambda 是 AWS 公有云服务的函数式计算平台。通过 AWS Lambda,AWS 用户可以快速地在 AWS 公有云上构建基于函数的应用服务。

2.BaaS

为了实现应用后台服务的 Serverless 化,BaaS(后台即服务)也应该被纳入一个完整的 Serverless 实现的范畴内。通过 BaaS 平台将应用所依赖的第三方服务,如数据库、消息队列及存储等服务化并发布出来,用户通过向 BaaS 平台申请所需要的服务进行消费,而不需要关心这些服务的具体运维。

BaaS 涵盖的范围很广泛,包含任何应用所依赖的服务。一个比较典型的例子是数据库即服务(Database as a Service,DBaaS)。许多应用都有存储数据的需求,大部分应用会将数据存储在数据库中。传统情况下,数据库都是运行在数据中心里,由用户运维团队负责运维。在DBaaS的场景下,用户向 DBaaS 平台申请数据库资源,而不需要关心数据库的安装部署及运维。

Serverless的技术特点

为了实现解耦应用和服务器资源,实现服务器资源对用户透明,与传统架构相比,Serverless 架构在技术上有许多不同的特点。

  • 1.按需加载

在 Serverless 架构下,应用的加载(load)和卸载(unload)由 Serverless 云计算平台控制。这意味着应用不总是一直在线的。只有当有请求到达或者有事件发生时才会被部署和启动。当应用空闲至一定时长时,应用会到达或者有事件发生时才会被部署和启动。当应用空闲至一定时长时,应用会被自动停止和卸载。因此应用并不会持续在线,不会持续占用计算资源。

  • 2.事件驱动

Serverless 架构的应用并不总是一直在线,而是按需加载执行。应用的加载和执行由事件驱动,比如HTTP请求到达、消息队列接收到新的信息或存储服务的文件被修改了等。通过将不同事件来源(Event Source)的事件(Event)与特定的函数进行关联,实现对不同事件采取不同的反应动作,这样可以非常容易地实现事件驱动(Event Driven)架构。

  • 3.状态非本地持久化

云计算平台自动控制应用实例的加载和卸载,且应用和服务器完全解耦,应用不再与特定的服务器关联。因此应用的状态不能,也不会保存在其运行的服务器之上,不能做到传统意义上的状态本地持久化。

  • 4.非会话保持

应用不再与特定的服务器关联。每次处理请求的应用实例可能是相同服务器上的应用实例,也可能是新生成的服务器上的应用实例。因此,用户无法保证同一客户端的两次请求由同一个服务器上的同一个应用实例来处理。也就是说,无法做到传统意义上的会话保持(Sticky Session)。因此,Serverless架构更适合无状态的应用。

  • 5.自动弹性伸缩

Serverless 应用原生可以支持高可用,可以应对突发的高访问量。应用实例数量根据实际的访问量由云计算平台进行弹性的自动扩展或收缩,云计算平台动态地保证有足够的计算资源和足够数量的应用实例对请求进行处理。

  • 6.应用函数化

每一个调用完成一个业务动作,应用会被分解成多个细颗粒度的操作。由于状态无法本地持久化,这些细颗粒度的操作是无状态的,类似于传统编程里无状态的函数。Serverless 架构下的应用会被函数化,但不能说 Serverless 就是 Function as a Service(FaaS)。Serverless 涵盖了 FaaS 的一些特性,可以说 FaaS 是 Serverless 架构实现的一个重要手段。

Serverless的应用场景

通过将 Serverless 的理念与当前 Serverless 实现的技术特点相结合,Serverless 架构可以适用于各种业务场景。

  • 1.Web应用

Serverless 架构可以很好地支持各类静态和动态Web应用。如 RESTful API 的各类请求动作(GET、POST、PUT及DELETE等)可以很好地映射成 FaaS 的一个个函数,功能和函数之间能建立良好的对应关系。通过 FaaS 的自动弹性扩展功能,Serverless Web 应用可以很快速地构建出能承载高访问量的站点。

  • 2.移动互联网

Serverless 应用通过 BaaS 对接后端不同的服务而满足业务需求,提高应用开发的效率。前端通过FaaS提供的自动弹性扩展对接移动端的流量,开发者可以更轻松地应对突发的流量增长。在 FaaS 的架构下,应用以函数的形式存在。各个函数逻辑之间相对独立,应用更新变得更容易,使新功能的开发、测试和上线的时间更短。

  • 3.物联网(Internet of Things,IoT)

物联网(Internet of Things,IoT)应用需要对接各种不同的数量庞大的设备。不同的设备需要持续采集并传送数据至服务端。Serverless 架构可以帮助物联网应用对接不同的数据输入源。

  • 4.多媒体处理

视频和图片网站需要对用户上传的图片和视频信息进行加工和转换。但是这种多媒体转换的工作并不是无时无刻都在进行的,只有在一些特定事件发生时才需要被执行,比如用户上传或编辑图片和视频时。通过 Serverless 的事件驱动机制,用户可以在特定事件发生时触发处理逻辑,从而节省了空闲时段计算资源的开销,最终降低了运维的成本。

  • 5.数据及事件流处理

Serverless 可以用于对一些持续不断的事件流和数据流进行实时分析和处理,对事件和数据进行实时的过滤、转换和分析,进而触发下一步的处理。比如,对各类系统的日志或社交媒体信息进行实时分析,针对符合特定特征的关键信息进行记录和告警。

  • 6.系统集成

Serverless 应用的函数式架构非常适合用于实现系统集成。用户无须像过去一样为了某些简单的集成逻辑而开发和运维一个完整的应用,用户可以更专注于所需的集成逻辑,只编写和集成相关的代码逻辑,而不是一个完整的应用。函数应用的分散式的架构,使得集成逻辑的新增和变更更加灵活。

Serverless的局限

世界上没有能解决所有问题的万能解决方案和架构理念。Serverless 有它的特点和优势,但是同时也有它的局限。有的局限是由其架构特点决定的,有的是目前技术的成熟度决定的,毕竟 Serverless 还是一个起步时间不长的新兴技术领域,在许多方面还需要逐步完善。

  • 1.控制力

Serverless 的一个突出优点是用户无须关注底层的计算资源,但是这个优点的反面是用户对底层的计算资源没有控制力。对于一些希望掌控底层计算资源的应用场景,Serverless 架构并不是最合适的选择。

  • 2.可移植性

Serverless 应用的实现在很大程度上依赖于 Serverless 平台及该平台上的 FaaS 和 BaaS 服务。不同IT厂商的 Serverless 平台和解决方案的具体实现并不相同。而且,目前 Serverless 领域尚没有形成有关的行业标准,这意味着用户将一个平台上的 Serverless 应用移植到另一个平台时所需要付出的成本会比较高。较低的可移植性将造成厂商锁定(Vendor Lock-in)。这对希望发展 Serverless 技术,但是又不希望过度依赖特定供应商的企业而言是一个挑战。

  • 3.安全性

在 Serverless 架构下,用户不能直接控制应用实际所运行的主机。不同用户的应用,或者同一用户的不同应用在运行时可能共用底层的主机资源。对于一些安全性要求较高的应用,这将带来潜在的安全风险。

  • 4.性能

当一个 Serverless 应用长时间空闲时将会被从主机上卸载。当请求再次到达时,平台需要重新加载应用。应用的首次加载及重新加载的过程将产生一定的延时。对于一些对延时敏感的应用,需要通过预先加载或延长空闲超时时间等手段进行处理。

  • 5.执行时长

Serverless 的一个重要特点是应用按需加载执行,而不是长时间持续部署在主机上。目前,大部分 Serverless 平台对 FaaS 函数的执行时长存在限制。因此 Serverless 应用更适合一些执行时长较短的作业。

  • 6.技术成熟度

虽然 Serverless 技术的发展很快,但是毕竟它还是一门起步时间不长的新兴技术。因此,目前 Serverless 相关平台、工具和框架还处在一个不断变化和演进的阶段,开发和调试的用户体验还需要进一步提升。Serverless 相关的文档和资料相对比较少,深入了解 Serverless 架构的架构师、开发人员和运维人员也相对较少。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

订单模块数据库表解析(一)

发表于 2019-07-31

SpringBoot实战电商项目mall(18k+star)地址:github.com/macrozheng/…

摘要

本文主要对订单及订单设置功能的表进行解析,采用数据库表与功能对照的形式。

订单

相关表结构

订单表

订单表,需要注意的是订单状态:0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单。

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
复制代码create table oms_order
(
id bigint not null auto_increment comment '订单id',
member_id bigint not null comment '会员id',
coupon_id bigint comment '优惠券id',
order_sn varchar(64) comment '订单编号',
create_time datetime comment '提交时间',
member_username varchar(64) comment '用户帐号',
total_amount decimal(10,2) comment '订单总金额',
pay_amount decimal(10,2) comment '应付金额(实际支付金额)',
freight_amount decimal(10,2) comment '运费金额',
promotion_amount decimal(10,2) comment '促销优化金额(促销价、满减、阶梯价)',
integration_amount decimal(10,2) comment '积分抵扣金额',
coupon_amount decimal(10,2) comment '优惠券抵扣金额',
discount_amount decimal(10,2) comment '管理员后台调整订单使用的折扣金额',
pay_type int(1) comment '支付方式:0->未支付;1->支付宝;2->微信',
source_type int(1) comment '订单来源:0->PC订单;1->app订单',
status int(1) comment '订单状态:0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单',
order_type int(1) comment '订单类型:0->正常订单;1->秒杀订单',
delivery_company varchar(64) comment '物流公司(配送方式)',
delivery_sn varchar(64) comment '物流单号',
auto_confirm_day int comment '自动确认时间(天)',
integration int comment '可以获得的积分',
growth int comment '可以活动的成长值',
promotion_info varchar(100) comment '活动信息',
bill_type int(1) comment '发票类型:0->不开发票;1->电子发票;2->纸质发票',
bill_header varchar(200) comment '发票抬头',
bill_content varchar(200) comment '发票内容',
bill_receiver_phone varchar(32) comment '收票人电话',
bill_receiver_email varchar(64) comment '收票人邮箱',
receiver_name varchar(100) not null comment '收货人姓名',
receiver_phone varchar(32) not null comment '收货人电话',
receiver_post_code varchar(32) comment '收货人邮编',
receiver_province varchar(32) comment '省份/直辖市',
receiver_city varchar(32) comment '城市',
receiver_region varchar(32) comment '区',
receiver_detail_address varchar(200) comment '详细地址',
note varchar(500) comment '订单备注',
confirm_status int(1) comment '确认收货状态:0->未确认;1->已确认',
delete_status int(1) not null default 0 comment '删除状态:0->未删除;1->已删除',
use_integration int comment '下单时使用的积分',
payment_time datetime comment '支付时间',
delivery_time datetime comment '发货时间',
receive_time datetime comment '确认收货时间',
comment_time datetime comment '评价时间',
modify_time datetime comment '修改时间',
primary key (id)
);

订单商品信息表

订单中包含的商品信息,一个订单中会有多个订单商品信息。

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
复制代码create table oms_order_item
(
id bigint not null auto_increment,
order_id bigint comment '订单id',
order_sn varchar(64) comment '订单编号',
product_id bigint comment '商品id',
product_pic varchar(500) comment '商品图片',
product_name varchar(200) comment '商品名称',
product_brand varchar(200) comment '商品品牌',
product_sn varchar(64) comment '商品条码',
product_price decimal(10,2) comment '销售价格',
product_quantity int comment '购买数量',
product_sku_id bigint comment '商品sku编号',
product_sku_code varchar(50) comment '商品sku条码',
product_category_id bigint comment '商品分类id',
sp1 varchar(100) comment '商品的销售属性1',
sp2 varchar(100) comment '商品的销售属性2',
sp3 varchar(100) comment '商品的销售属性3',
promotion_name varchar(200) comment '商品促销名称',
promotion_amount decimal(10,2) comment '商品促销分解金额',
coupon_amount decimal(10,2) comment '优惠券优惠分解金额',
integration_amount decimal(10,2) comment '积分优惠分解金额',
real_amount decimal(10,2) comment '该商品经过优惠后的分解金额',
gift_integration int not null default 0 comment '商品赠送积分',
gift_growth int not null default 0 comment '商品赠送成长值',
product_attr varchar(500) comment '商品销售属性:[{"key":"颜色","value":"颜色"},{"key":"容量","value":"4G"}]',
primary key (id)
);

订单操作记录表

当订单状态发生改变时,用于记录订单的操作信息。

1
2
3
4
5
6
7
8
9
10
复制代码create table oms_order_operate_history
(
id bigint not null auto_increment,
order_id bigint comment '订单id',
operate_man varchar(100) comment '操作人:用户;系统;后台管理员',
create_time datetime comment '操作时间',
order_status int(1) comment '订单状态:0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单',
note varchar(500) comment '备注',
primary key (id)
);

管理端展现

订单列表

展示图片

查看订单

展示图片

展示图片

展示图片

订单发货

展示图片

移动端展现

不同状态下的订单

展示图片

展示图片

展示图片

订单详情

展示图片

展示图片

订单设置

相关表结构

订单设置表

用于对订单的一些超时操作进行设置。

1
2
3
4
5
6
7
8
9
10
复制代码create table oms_order_setting
(
id bigint not null auto_increment,
flash_order_overtime int comment '秒杀订单超时关闭时间(分)',
normal_order_overtime int comment '正常订单超时时间(分)',
confirm_overtime int comment '发货后自动确认收货时间(天)',
finish_overtime int comment '自动完成交易时间,不能申请售后(天)',
comment_overtime int comment '订单完成后自动好评时间(天)',
primary key (id)
);

管理端展现

展示图片

公众号

mall项目全套学习教程连载中,关注公众号第一时间获取。

公众号图片

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【NestJS】如何优雅地使用TypeOrm连接到数据库

发表于 2019-07-31

概述

  1. TypeORM 是一个使用装饰器,对TypeScript支持非常良好的ORM框架。在NestJS中,可通过@nestjs/typeorm,使用装饰器的方式优雅地使用TypeORM 。

使用示例

  1. 首先在config/database.ts导出数据库连接配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码export const DatabaseConfig = {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
username: 'root',
password: '123456',
database: 'example',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false,
migrations: ['database/migration/**/*.ts'],
cli: {
migrationsDir: 'database/migration/default',
},
}
  1. 然后在ormconfig.ts导入设置,作用是在使用typeorm migration:generate等TypeOrm命令时会使用到这个文件,作为连接数据库的配置。
    在TypeOrm官方文档中提到可以使用ormconfig.json,ormconfig.js或者ormconfig.ts的方式来设置数据库连接,此处我们选择灵活度更高的ts文件形式。
1
2
3
复制代码import { DataBaseConfig } from 'config/database'

module.exports = DataBaseConfig

注意:请保证ormconfig.ts也在tsconfig.json的编译范围内:

1
2
3
4
5
6
7
复制代码{
"include": [
"ormconfig.ts",
...
],
...
}
  1. 在app.module.ts导入设置,使用TypeOrmModule.forRoot(), 在NestJS框架中,连接到数据库。之后,Connection和EntityManager就可以注入到程序中。
    官方文档中提到,如果在ormconfig.json写入配置作为forRoot()的默认参数,由于我们使用的是ts文件,需要手动导入配置)
1
2
3
4
5
6
7
8
复制代码 import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { DataBaseConfig } from 'config/database'

@Module({
imports: [TypeOrmModule.forRoot(DataBaseConfig)],
})
export class ApplicationModule {}
  1. 在具体的模块中,通过以下代码,在当前模块下,注册Repository。(请先自行创建./example.entity文件)
1
2
3
4
5
6
7
8
复制代码import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Example } from './example.entity';

@Module({
imports: [TypeOrmModule.forFeature([Example])],
})
export class ExampleModule {}
  1. 随后,可以在service和controller等中用依赖注入的方式使用Repository。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码 import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Example } from './example.entity';

@Injectable()
export class ExampleService {
constructor(
@InjectRepository(Example)
public readonly repo: Repository<Example>,
) {}

findAll(): Promise<Example[]> {
return this.exampleRepo.find();
}
}

推荐规范

  1. 推荐将数据库连接配置写在.env文件中,而不是采用ormconfig.json的方式。这样做的好处是把敏感信息统一在.env中管理。另外也方便拓展连接到多个数据库。如何读取配置文件详见另一文章【NestJS】配置信息与环境变量

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

ThreeJs 认识材质

发表于 2019-07-31

一、前言

材质和纹理有那么一点微妙的关系,纹理决定了物体的表面,而材质则决定了物体的“气质”,比如说,反射度,光滑度,金属感,塑料感或者玻璃的模仿等。当然,在 ThreeJs 中,纹理想要被展示出来是要被依附在材质中的。

二、概述

ThreeJs 中定义了非常丰富的材质,其类图如下。

图片描述

从类图上看,定义了非常多的材质。

三、认识材质

1.Material

图片描述

2.LineBasicMaterial

图片描述

图片描述

3.LineDashedMaterial

图片描述

1
2
3
4
5
6
7
复制代码var material = new THREE.LineDashedMaterial( {
color: 0xffffff,
linewidth: 1,
scale: 1,
dashSize: 3,
gapSize: 1,
} );

图片描述

4.MeshBasicMaterial

图片描述

图片描述

5.MeshDepthMaterial

图片描述

图片描述

6.MeshLambertMaterial

图片描述

图片描述

7.MeshNormalMaterial

图片描述

图片描述

8.MeshPhongMaterial

图片描述

图片描述

9.MeshPhysicalMaterial

图片描述

图片描述

10.MeshStandardMaterial

图片描述

图片描述

11.MeshToonMaterial

图片描述

图片描述

12.PointsMaterial

图片描述

图片描述

13.RawShaderMaterial

图片描述

1
2
3
4
5
6
7
8
9
复制代码var material = new THREE.RawShaderMaterial( {

uniforms: {
time: { value: 1.0 }
},
vertexShader: document.getElementById( 'vertexShader' ).textContent,
fragmentShader: document.getElementById( 'fragmentShader' ).textContent,

} );

图片描述

14.ShaderMaterial

图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码var material = new THREE.ShaderMaterial( {

uniforms: {

time: { value: 1.0 },
resolution: { value: new THREE.Vector2() }

},

vertexShader: document.getElementById( 'vertexShader' ).textContent,

fragmentShader: document.getElementById( 'fragmentShader' ).textContent

} );

图片描述

15.ShadowMaterial

图片描述

1
2
3
4
5
6
7
8
9
10
复制代码var planeGeometry = new THREE.PlaneGeometry( 2000, 2000 );
planeGeometry.rotateX( - Math.PI / 2 );

var planeMaterial = new THREE.ShadowMaterial();
planeMaterial.opacity = 0.2;

var plane = new THREE.Mesh( planeGeometry, planeMaterial );
plane.position.y = -200;
plane.receiveShadow = true;
scene.add( plane );

图片描述

16.SpriteMaterial

图片描述

1
2
3
4
5
6
7
8
复制代码var spriteMap = new THREE.TextureLoader().load( 'textures/sprite.png' );

var spriteMaterial = new THREE.SpriteMaterial( { map: spriteMap, color: 0xffffff } );

var sprite = new THREE.Sprite( spriteMaterial );
sprite.scale.set(200, 200, 1)

scene.add( sprite );

图片描述

四、总结

ThreeJs 的内置材质非常的多,项目里可以根据需要来实际使用。如果内置的不能满足则使用 ShaderMaterial 来实现自定义的 Material。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

MyBatis在Spring环境下的事务管理

发表于 2019-07-31

MyBatis的设计思想很简单,可以看做是对JDBC的一次封装,并提供强大的动态SQL映射功能。但是由于它本身也有一些缓存、事务管理等功能,所以实际使用中还是会碰到一些问题——另外,最近接触了JFinal,其思想和Hibernate类似,但要更简洁,和MyBatis的设计思想不同,但有一点相同:都是想通过简洁的设计最大限度地简化开发和提升性能——说到性能,前段时间碰到两个问题:

  1. 在一个上层方法(DAO方法的上层)内删除一条记录,然后再插入一条相同主键的记录时,会报主键冲突的错误。
  2. 某些项目中的DAO方法平均执行时间会是其他一些项目中的 2倍 。

第一个问题是偶尔会出现,在实验环境无论如何也重现不了,经过分析MyBatis的逻辑,估计是两个DAO分别拿到了两个不同的Connection,第二个语句比第一个更早的被提交,导致了主键冲突,有待进一步的分析和验证。对于第二个问题,本文将尝试通过分析源代码和实验找到它的root cause,主要涉及到以下内容:

  1. 问题描述与分析
  2. MyBatis在Spring环境下的载入过程
  3. MyBatis在Spring环境下事务的管理
  4. 实验验证

项目环境

整个系统是微服务架构,这里讨论的「项目」是指一个单独的服务。单个项目的框架基本是Spring+MyBatis,具体版本如下:

Spring 3.2.9/4.3.5 + Mybatis 3.2.6 + mybatis-spring 1.2.2 + mysql connector 5.1.20 + commons-dbcp 1.4

与MyBatis和事务相关的配置如下:

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
复制代码//代码1
<!-- bean#1-->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"
destroy-method="close">
<!-- 一些数据库信息配置-->
<!-- 一些DBCP连接池配置 -->
//在这里设置是否自动提交
<property name="defaultAutoCommit" value="${dbcp.defaultAutoCommit}" />
</bean>
<!-- bean#2-->
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource" />
<property name="mapperLocations" value="classpath*:path/to/mapper/**/*.xml" />
</bean>
<!-- bean#3 -->
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource" />
</bean>
<!-- bean#4-->
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="basePackage" value=".path.to.mapper" />
<property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/>
</bean>
<!-- bean5 -->
<tx:annotation-driven transaction-manager="transactionManager" />

问题描述与分析

一倍的时间差挺严重的,平均到每次调用,正常的大约在6到10几 ms,慢的要近20 ms,由于调用次数很多,导致整体性能会有很大的差别。经过仔细比对这几个项目,发现DAO执行慢的项目的数据源配置(bean#1)中 defaultAutoCommit的配置都是 false。而且将此配置改为 true之后就恢复了正常。

由此推断是在MyBatis在执行「非自动提交」语句时,进行等待,或者多提交了一次,导致实际调用数据库API次数增多。但是这个推断也有个问题,由于整个项目是在Spring环境中运行的,而且也开启了Spring的事务管理,所以还是需要详细的看一下MyBatis到底是如何装配DAO方法与管理事务的,才能彻底解开谜团。

问题重现

首先写一个Service,其中调用了同一个mapper类的两个方法分别2次, insertModelList()会在数据库中插入两条记录, delModels()方法会删除这两条记录,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码//代码2
//@Transactional
public void testIS(){
List<Model> models= new ArrayList<>();
//省略一些数据工作。。。
modelMapper.insertModelList(50001l, models);
modelMapper.delModels(50001);
if (CollectionUtils.isNotEmpty(models))
modelMapper.insertModelList(50001, models);
modelMapper.delModels(50001);
}
public void testOther(){
System.out.println("加载类:");
System.out.println(modelMapper.getClass().getClassLoader());
modelMapper.delModels(50001);
}

实际项目中使用cat来进行执行时间的统计,这里也仿照cat,使用一个单独的AOP类实现时间的计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码//代码3
public class DaoTimeAdvice {

private long time = 0;
private long num = 0;

public Object calcTime(ProceedingJoinPoint joinPoint) throws Throwable {
long then = System.nanoTime();
Object object = joinPoint.proceed();
long now = System.nanoTime();
setTime(getTime() + (now-then));
setNum(getNum() + 1);
return object;
}
//省略getter & setter。。。
public void printInfo() {
System.out.println("总共次数:" + num);
System.out.println("总共时间:" + time);
System.out.println("平均时间:" + time / num);
}
}

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码//代码4
public static void test(){
System.out.println(new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss]").format(new Date())
+ " 开始测试!");
for (int i = 0; i < TEST_NUM; i++) {
ItemStrategyServiceTest ist = (ItemStrategyServiceTest) context.getBean("isTS");
ist.testIS();
if (i % 1000 == 0) {
System.out.println("1000次");
}
}
DaoTimeAdvice ad = (DaoTimeAdvice) context.getBean("daoTimeAdvice");
ad.printInfo();
ItemStrategyServiceTest ist = (ItemStrategyServiceTest) context.getBean("isTS");
ist.testOther();
System.exit(1);
}

测试结果:

defaultAutoCommit 循环次数 共消耗时间(ns) 平均时间(ns)
true 40000 17831088316 445777
true 40000 17881589992 447039
false 40000 27280458229 682011
false 40000 27237413893 680935

defaultAutoCommit为 false时的执行时间是 true的近1.5倍,并没有重现2倍的时间消耗,估计是在cat统计或者其他AOP方法的执行时还有其他消耗,从而扩大了 false和 true之间的区别。

MyBatis在Spring环境下的载入过程

按照第一节中的配置文件,整个MyBatis中DAO的bean的装配应该是这样的:

  1. 先使用BasicDataSource装配一个数据源的bean(bean#1),名字叫做 dataSource。

这个bean很简单,就是实例化并注册到Spring的上下文中。

  1. 使用 dataSource来创建 sqlSessionFactory(bean#2),这个bean创建时会扫描MyBatis的语句映射文件并解析。

在MyBatis中,真正的数据库读写操作是通过SqlSession的实例来实现的,而SqlSession要通过SQLSessionFactory来管理。这里的 org.mybatis.spring.SqlSessionFactoryBean实现了FactoryBean类(这个类比较特殊,与主题无关,这里不再赘述),Spring会从这个bean中会获取真正的SQLSessionFactory的实例,源代码中显示,实际返回的对象是DefaultSqlSessionFactory的实例。

  1. 使用 sqlSessionFactory这个工厂类来创建mapper扫描器(bean#4),并创建含有DAO方法的实例。

为了让上层方法可以通过普通的方法调用来使用DAO方法,需要往Spring上下文里注册相应的bean,而在MyBatis的普通使用场景中是没有mapper的实现类的(具体的SQL语句映射通过注解或者XML文件来实现),只有接口,在MyBatis中这些接口是通过动态代理实现的。这里使用的类是 org.mybatis.spring.mapper.MapperScannerConfigurer,它实现了 org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor接口,所以会在Spring中「所有的bean定义全部注册完成,但还没有实例化」之前,调用方法向Spring上下文注册mapper实现类(动态代理的对象)。具体代码如下:

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
复制代码    //代码5
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
if (this.processPropertyPlaceHolders) {
processPropertyPlaceHolders();
}

ClassPathMapperScanner scanner = new ClassPathMapperScanner(registry);
//设置一些属性

scanner.scan(StringUtils.tokenizeToStringArray(this.basePackage, ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS));
}

/**
* Perform a scan within the specified base packages.
* @param basePackages the packages to check for annotated classes
* @return number of beans registered
*/
public int scan(String... basePackages) {
int beanCountAtScanStart = this.registry.getBeanDefinitionCount();

doScan(basePackages);

// Register annotation config processors, if necessary.
if (this.includeAnnotationConfig) {
AnnotationConfigUtils.registerAnnotationConfigProcessors(this.registry);
}

return (this.registry.getBeanDefinitionCount() - beanCountAtScanStart);
}

在源代码里可以看到,真正的mapper实现类是 org.mybatis.spring.mapper.MapperFactoryBean,具体的逻辑在方法org.mybatis.spring.mapper.ClassPathMapperScanner.processBeanDefinitions(Set)里。最后,每一个方法的执行,最终落入了org.mybatis.spring.SqlSessionTemplate的某个方法中,并被如下这个拦截器拦截:

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
复制代码    //代码6
/**
* Proxy needed to route MyBatis method calls to the proper SqlSession got
* from Spring's Transaction Manager
* It also unwraps exceptions thrown by {@code Method#invoke(Object, Object...)} to
* pass a {@code PersistenceException} to the {@code PersistenceExceptionTranslator}.
*/
private class SqlSessionInterceptor implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
SqlSession sqlSession = getSqlSession(
SqlSessionTemplate.this.sqlSessionFactory,
SqlSessionTemplate.this.executorType,
SqlSessionTemplate.this.exceptionTranslator);
try {
Object result = method.invoke(sqlSession, args);
if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
// force commit even on non-dirty sessions because some databases require
// a commit/rollback before calling close()
sqlSession.commit(true);
}
return result;
} catch (Throwable t) {
//省略一些错误处理
throw unwrapped;
} finally {
if (sqlSession != null) {
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
}
}
}
}
  1. MyBatis在Spring环境下事务的管理

从源代码中知道真正的SqlSessionFactory使用的是 org.apache.ibatis.session.defaults.DefaultSqlSessionFactory的实例,同时,事务管理使用 org.mybatis.spring.transaction.SpringManagedTransactionFactory。但是在代码1的配置中,还添加了Spring事务管理的配置,就是在某个Service方法(或某个其他可被扫描到的方法)上加上 @Transactional注解,那么Spring的事务管理会自动创建事务,那么它和MyBatis的事务之间是怎么协作的呢?

可以看到在代码6中的方法 isSqlSessionTransactional(),它会返回上层代码中是否有Spring的事务,如果有,将不会执行下边的 commit()。在我的项目中的实际情况是没有Spring事务,所以肯定是走到了下面的 commit(),这个方法最终落到了 SpringManagedTransactionFactory中的 commit(),看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码    //代码7
private void openConnection() throws SQLException {
this.connection = DataSourceUtils.getConnection(this.dataSource);
this.autoCommit = this.connection.getAutoCommit();
this.isConnectionTransactional = DataSourceUtils.isConnectionTransactional(this.connection, this.dataSource);

}
public void commit() throws SQLException {
if (this.connection != null && !this.isConnectionTransactional && !this.autoCommit) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Committing JDBC Connection [" + this.connection + "]");
}
this.connection.commit();
}
}

可以看到,此处是否要执行 commit()操作是由3个变量决定的,如果DataSource的 autoCommit是 false,则其结果一定为 true,控制台也会看到一行日志: Committing JDBC Connection [xxxxxx],刚好与项目中遇到的情况相同。这个提交动作是需要和数据库交互的,比较耗时。

实验验证

由上一节分析得出,造成DAO方法执行时间变长的原因是会多执行一次提交,那么如果上层方法被Spring事务管理器托管(或者数据源的 defaultAutoCommit为 true,这个条件已经在刚开始的问题重现被验证),则不会执行MyBatis的提交动作,DAO方法应该相应的执行时间会变短。于是将Service方法加上 @transactional注解,分别测试 true和 false的情况。结果:

可以看到执行的时间已经基本接近,由此基本可以确定是这个原因造成的。这里仍然有几个疑点,尤其是问题重现时没有出现2倍的时间消耗,如果你有别的想法,也欢迎提出来讨论。`

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

后端开发实践系列——领域驱动设计(DDD)编码实践

发表于 2019-07-31

Martin Fowler在《企业应用架构模式》一书中写道:

I found this(business logic) a curious term because there are few things that are less logical than business logic.

初略翻译过来可以理解为:业务逻辑是很没有逻辑的逻辑。

的确,很多时候软件的业务逻辑是无法通过推理而得到的,有时甚至是被臆想出来的。这样的结果使得原本已经很复杂的业务变得更加复杂而难以理解。而在具体编码实现时,除了应付业务上的复杂性,技术上的复杂性也不能忽略,比如我们要讲究技术上的分层,要遵循软件开发的基本原则,又比如要考虑到性能和安全等等。

在很多项目中,技术复杂度与业务复杂度相互交错纠缠不清,这种火上浇油的做法成为不少软件项目无法继续往下演进的原因。然而,在合理的设计下,技术和业务是可以分离开来或者至少它们之间的耦合度是可以降低的。在不同的软件建模方法中,领域驱动设计(Domain Driven Design,DDD)尝试通过其自有的原则与套路来解决软件的复杂性问题,它将研发者的目光首先聚焦在业务本身上,使技术架构和代码实现成为软件建模过程中的“副产品”。

DDD总览

DDD分为战略设计和战术设计。在战略设计中,我们讲求的是子域和限界上下文(Bounded Context,BC)的划分,以及各个限界上下文之间的上下游关系。当前如此火热的“在微服务中使用DDD”这个命题,究其最初的逻辑无外乎是“DDD中的限界上下文可以用于指导微服务中的服务划分”。事实上,限界上下文依然是软件模块化的一种体现,与我们一直以来追求的模块化原则的驱动力是相同的,即通过一定的手段使软件系统在人的大脑中更加有条理地呈现,让作为“目的”的人能够更简单地了解进而掌控软件系统。

如果说战略设计更偏向于软件架构,那么战术设计便更偏向于编码实现。DDD战术设计的目的是使得业务能够从技术中分离并突显出来,让代码直接表达业务的本身,其中包含了聚合根、应用服务、资源库、工厂等概念。虽然DDD不一定通过面向对象(OO)来实现,但是通常情况下在实践DDD时我们采用的是OO编程范式,行业中甚至有种说法是“DDD是OO进阶”,意思是面向对象中的基本原则(比如SOLID)在DDD中依然成立。本文主要讲解DDD的战术设计。

本文以一个简单的电商订单系统为例,通过以下方式可以获取源代码:

git clone https://github.com/e-commerce-sample/order-backend
git checkout a443dace

实现业务的3种常见方式

在讲解DDD之前,让我们先来看一下实现业务代码的几种常见方式,在示例项目中有个“修改Order中Product的数量”的业务需求如下:

可以修改Order中Product的数量,但前提是Order处于未支付状态,Product数量变更后Order的总价(totalPrice)应该随之更新。

1. 基于“Service + 贫血模型”的实现

这种方式当前被很多软件项目所采用,主要的特点是:存在一个贫血的“领域对象”,业务逻辑通过一个Service类实现,然后通过setter方法更新领域对象,最后通过DAO(多数情况下可能使用诸如Hibernate之类的ORM框架)保存到数据库中。实现一个OrderService类如下:

1
2
3
4
5
6
7
8
9
10
11
复制代码@Transactional
public void changeProductCount(String id, ChangeProductCountCommand command) {
Order order = DAO.findById(id);
if (order.getStatus() == PAID) {
throw new OrderCannotBeModifiedException(id);
}
OrderItem orderItem = order.getOrderItem(command.getProductId());
orderItem.setCount(command.getCount());
order.setTotalPrice(calculateTotalPrice(order));
DAO.saveOrUpdate(order);
}

这种方式依然是一种面向过程的编程范式,违背了最基本的OO原则。另外的问题在于职责划分模糊不清,使本应该内聚在Order中的业务逻辑泄露到了其他地方(OrderService),导致Order成为一个只是充当数据容器的贫血模型(Anemic Model),而非真正意义上的领域模型。在项目持续演进的过程中,这些业务逻辑会分散在不同的Service类中,最终的结果是代码变得越来越难以理解进而逐渐丧失扩展能力。

2. 基于事务脚本的实现

在上一种实现方式中,我们会发现领域对象(Order)存在的唯一目的其实是为了让ORM这样的工具能够一次性地持久化,在不使用ORM的情况下,领域对象甚至都没有必要存在。于是,此时的代码实现便退化成了事务脚本(Transaction Script),也就是直接将Service类中计算出的结果直接保存到数据库(或者有时都没有Service类,直接通过SQL实现业务逻辑):

1
2
3
4
5
6
7
8
9
复制代码@Transactional
public void changeProductCount(String id, ChangeProductCountCommand command) {
OrderStatus orderStatus = DAO.getOrderStatus(id);
if (orderStatus == PAID) {
throw new OrderCannotBeModifiedException(id);
}
DAO.updateProductCount(id, command.getProductId(), command.getCount());
DAO.updateTotalPrice(id);
}

可以看到,DAO中多出了很多方法,此时的DAO不再只是对持久化的封装,而是也会包含业务逻辑。另外,DAO.updateTotalPrice(id)方法的实现中将直接调用SQL来实现Order总价的更新。与“Service+贫血模型”方式相似,事务脚本也存在业务逻辑分散的问题。

事实上,事务脚本并不是一种全然的反模式,在系统足够简单的情况下完全可以采用。但是:一方面“简单”这个度其实并不容易把握;另一方面软件系统通常会在不断的演进中加入更多的功能,使得原本简单的代码逐渐变得复杂。因此,事务脚本在实际的应用中使用得并不多。

3. 基于领域对象的实现

在这种方式中,核心的业务逻辑被内聚在行为饱满的领域对象(Order)中,实现Order类如下:

1
2
3
4
5
6
7
复制代码public void changeProductCount(ProductId productId, int count) {
if (this.status == PAID) {
throw new OrderCannotBeModifiedException(this.id);
}
OrderItem orderItem = retrieveItem(productId);
orderItem.updateCount(count);
}

然后在Controller或者Service中,调用Order.changeProductCount():

1
2
3
4
5
6
7
复制代码@PostMapping("/order/{id}/products")
public void changeProductCount(@PathVariable(name = "id") String id, @RequestBody @Valid ChangeProductCountCommand command) {
Order order = DAO.byId(orderId(id));
order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
order.updateTotalPrice();
DAO.saveOrUpdate(order);
}

可以看到,所有业务(“检查Order状态”、“修改Product数量”以及“更新Order总价”)都被包含在了Order对象中,这些正是Order应该具有的职责。(不过示例代码中有个地方明显违背了内聚性原则,下文会讲到,作为悬念读者可以先行尝试着找一找)

事实上,这种方式与本文要讲的DDD战术模式已经很相近了,只是DDD抽象出了更多的概念与原则。

基于业务的分包

在本系列的上一篇:Spring Boot项目模板文章中,其实我已经讲到了基于业务的分包,结合DDD的场景,这里再简要讨论一下。所谓基于业务分包即通过软件所实现的业务功能进行模块化划分,而不是从技术的角度划分(比如首先划分出service和infrastruture等包)。在DDD的战略设计中,我们关注于从一个宏观的视角俯视整个软件系统,然后通过一定的原则对系统进行子域和限界上下文的划分。在战术实践中,我们也通过类似的提纲挈领的方法进行整体的代码结构的规划,所采用的原则依然逃离不了“内聚性”和“职责分离”等基本原则。此时,首先映入眼帘的便是软件的分包。

在DDD中,聚合根(下文会讲到)是主要业务逻辑的承载体,也是“内聚性”原则的典型代表,因此通常的做法便是基于聚合根进行顶层包的划分。在示例电商项目中,有两个聚合根对象Order和Product,分别创建order包和product包,然后在各自的顶层包下再根据代码结构的复杂程度划分子包,比如对于product包:

1
2
3
4
5
6
7
8
9
10
11
复制代码└── product
├── CreateProductCommand.java
├── Product.java
├── ProductApplicationService.java
├── ProductController.java
├── ProductId.java
├── ProductNotFoundException.java
├── ProductRepository.java
└── representation
├── ProductRepresentationService.java
└── ProductSummaryRepresentation.java

可以看到,ProductRepository和ProductController等多数类都直接放在了product包下,而没有单独分包;但是展现类ProductSummaryRepresentation却做了单独分包。这里的原则是:在所有类已经被内聚在了product包下的情况下,如果代码结构足够的简单,那么没有必要再次进行子包的划分,ProductRepository和ProductController便是这种情况;而如果多个类需要做再次的内聚,那么需要另行分包,比如通过REST
API接口返回Product数据时,代码中涉及到了两个对象ProductRepresentationService和ProductSummaryRepresentation,这两个对象是紧密关联的,因此将他们放在representation子包下。而对于更加复杂的Order,分包如下:

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
复制代码├── order
│   ├── OrderApplicationService.java
│   ├── OrderController.java
│   ├── OrderPaymentProxy.java
│   ├── OrderPaymentService.java
│   ├── OrderRepository.java
│   ├── command
│   │   ├── ChangeAddressDetailCommand.java
│   │   ├── CreateOrderCommand.java
│   │   ├── OrderItemCommand.java
│   │   ├── PayOrderCommand.java
│   │   └── UpdateProductCountCommand.java
│   ├── exception
│   │   ├── OrderCannotBeModifiedException.java
│   │   ├── OrderNotFoundException.java
│   │   ├── PaidPriceNotSameWithOrderPriceException.java
│   │   └── ProductNotInOrderException.java
│   ├── model
│   │   ├── Order.java
│   │   ├── OrderFactory.java
│   │   ├── OrderId.java
│   │   ├── OrderIdGenerator.java
│   │   ├── OrderItem.java
│   │   └── OrderStatus.java
│   └── representation
│   ├── OrderItemRepresentation.java
│   ├── OrderRepresentation.java
│   └── OrderRepresentationService.java

可以看到,我们专门创建了一个model包用于放置所有与Order聚合根相关的领域对象;另外,基于同类型相聚原则,创建command包和exception包分别用于放置请求类和异常类。

领域模型的门面——应用服务

UML中有用例(Use Case)的概念,表示的是软件向外提供业务功能的基本逻辑单元。在DDD中,由于业务被提到了第一优先级,那么自然地我们希望对业务的处理能够显现出来,为了达到这样的目的,DDD专门提供了一个名为应用服务(ApplicationService)的抽象层。ApplicationService采用了门面模式,作为领域模型向外提供业务功能的总出入口,就像酒店的前台处理客户的不同需求一样。

在编码实现业务功能时,通常用2种工作流程:

  • 自底向上:先设计数据模型,比如关系型数据库的表结构,再实现业务逻辑。我在与不同的程序员结对编程的时候,总会是听到这么一句话:“让我先把数据库表的字段设计出来吧”。这种方式将关注点优先放在了技术性的数据模型上,而不是代表业务的领域模型,是DDD之反。
  • 自顶向下:拿到一个业务需求,先与客户方确定好请求数据格式,再实现Controller和ApplicationService,然后实现领域模型(此时的领域模型通常已经被识别出来),最后实现持久化。

在DDD实践中,自然应该采用自顶向下的实现方式。ApplicationService的实现遵循一个很简单的原则,即一个业务用例对应ApplicationService上的一个业务方法。比如,对于上文提到的“修改Order中Product的数量”业务需求实现如下:

实现OrderApplicationService:

1
2
3
4
5
6
复制代码@Transactional
public void changeProductCount(String id, ChangeProductCountCommand command) {
Order order = orderRepository.byId(orderId(id));
order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
orderRepository.save(order);
}

OrderController调用OrderApplicationService:

1
2
3
4
复制代码@PostMapping("/{id}/products")
public void changeProductCount(@PathVariable(name = "id") String id, @RequestBody @Valid ChangeProductCountCommand command) {
orderApplicationService.changeProductCount(id, command);
}

此时,order.changeProductCount()和orderRepository.save()都没有必要实现,但是由OrderController和OrderApplicationService所构成的业务处理的架子已经搭建好了。

可以看到,“修改Order中Product的数量”用例中的OrderApplicationService.changeProductCount()方法实现中只有不多的3行代码,然而,如此简单的ApplicationService却存在很多讲究。

ApplicationService需要遵循以下原则:

  • 业务方法与业务用例一一对应:前面已经讲到,不再赘述。
  • 业务方法与事务一一对应:也即每一个业务方法均构成了独立的事务边界,在本例中,OrderApplicationService.changeProductCount()方法标记有Spring的@Transactional注解,表示整个方法被封装到了一个事务中。
  • 本身不应该包含业务逻辑:业务逻辑应该放在领域模型中实现,更准确的说是放在聚合根中实现,在本例中,order.changeProductCount()方法才是真正实现业务逻辑的地方,而ApplicationService只是作为代理调用order.changeProductCount()方法,因此,ApplicationService应该是很薄的一层。
  • 与UI或通信协议无关:ApplicationService的定位并不是整个软件系统的门面,而是领域模型的门面,这意味着ApplicationService不应该处理诸如UI交互或者通信协议之类的技术细节。在本例中,Controller作为ApplicationService的调用者负责处理通信协议(HTTP)以及与客户端的直接交互。这种处理方式使得ApplicationService具有普适性,也即无论最终的调用方是HTTP的客户端,还是RPC的客户端,甚至一个Main函数,最终都统一通过ApplicationService才能访问到领域模型。
  • 接受原始数据类型:ApplicationService作为领域模型的调用方,领域模型的实现细节对其来说应该是个黑盒子,因此ApplicationService不应该引用领域模型中的对象。此外,ApplicationService接受的请求对象中的数据仅仅用于描述本次业务请求本身,在能够满足业务需求的条件下应该尽量的简单。因此,ApplicationService通常处理一些比较原始的数据类型。在本例中,OrderApplicationService所接受的Order
    ID是Java原始的String类型,在调用领域模型中的Repository时,才被封装为OrderId对象。
    应用服务(ApplicationService)是领域模型的门面

业务的载体——聚合根

接地气一点地讲,聚合根(Aggreate Root, AR)就是软件模型中那些最重要的以名词形式存在的领域对象,比如本文示例项目中的Order和Product。又比如,对于一个会员管理系统,会员(Member)便是一个聚合根;对于报销系统,报销单(Expense)便是一个聚合根;对于保险系统,保单(Policy)便是一个聚合根。聚合根是主要的业务逻辑载体,DDD中所有的战术实现都围绕着聚合根展开。

然而,并不是说领域模型中的所有名词都可以建模为聚合根。所谓“聚合”,顾名思义,即需要将领域中高度内聚的概念放到一起组成一个整体。至于哪些概念才能聚到一起,需要我们对业务本身有很深刻的认识,这也是为什么DDD强调开发团队需要和领域专家一起工作的原因。近年来流行起来的事件风暴建模活动,究其本意也是通过罗列出领域中发生的所有事件可以让我们全面的了解领域中的业务,进而识别出聚合根。

对于“更新Order中Product数量”用例,聚合根Order的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码public void changeProductCount(ProductId productId, int count) {
if (this.status == PAID) {
throw new OrderCannotBeModifiedException(this.id);
}

OrderItem orderItem = retrieveItem(productId);
orderItem.updateCount(count);
this.totalPrice = calculateTotalPrice();
}

private BigDecimal calculateTotalPrice() {
return items.stream()
.map(OrderItem::totalPrice)
.reduce(ZERO, BigDecimal::add);
}


private OrderItem retrieveItem(ProductId productId) {
return items.stream()
.filter(item -> item.getProductId().equals(productId))
.findFirst()
.orElseThrow(() -> new ProductNotInOrderException(productId, id));
}

在本例中,Order中的品项(orderItems)和总价(totalPrice)是密切相关的,orderItems的变化会直接导致totalPrice的变化,因此,这二者自然应该内聚在Order下。此外,totalPrice的变化是orderItems变化的必然结果,这种因果关系是业务驱动出来的,为了保证这种“必然”,我们需要在Order.changeProductCount()方法中同时实现“因”和“果”,也即聚合根应该保证业务上的一致性。在DDD中,业务上的一致性被称为
不变条件(Invariants)。

还记得上文中提到的“违背内聚性的悬念”吗?当时调用Order上的业务方式如下:

1
2
3
4
复制代码.....
order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
order.updateTotalPrice();
.....

为了实现“更新Order中Product数量”业务功能,这里先后调用了Order上的两个public方法changeProductCount()和updateTotalPrice()。虽然这种做法也能正确地实现业务逻辑,但是它将保证业务一致性的职责交给了Order的调用方(上文中的Controller)而不是Order自身,此时调用方需要确保在调用了changeProductCount()之后必须调用updateTotalPrice()方法,这一方面是Order中业务逻辑的泄露,另一方面调用方并不承担这样的职责,而Order才最应该承担这样的职责。

对内聚性的追求会自然地延伸出聚合根的边界。在DDD的战略设计中,我们已经通过限界上下文的划分将一个大的软件系统拆分为了不同的“模块”,在这样的前提下,再在某个限界上下文中来讨论内聚性将比在大泥球系统中讨论变得简单得多。

对聚合根的设计需要提防上帝对象(God Object),也即用一个大而全的领域对象来实现所有的业务功能。上帝对象的背后存在着一种表面上看似合理的逻辑:既然要内聚,那么让我们把所有相关的东西都聚到一起吧,比如用一个Product类来应付所有的业务场景,包括订单、物流、发票等等。这种机械的方式看似内聚,实则恰恰是内聚性的反面。要解决这样的问题依然需要求助于限界上下文,不同限界上下文使用各自的
通用语言(Ubiquitous Language),通用语言要求一个业务概念不应该有二义性,在这样的原则下,不同的限界上下文可能都有自己的Product类,虽然名字相同,却体现着不同的业务。

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

除了内聚性和一致性,聚合根还有以下特征:

  • 聚合根的实现应该与框架无关:既然DDD讲求业务复杂度和技术复杂度的分离,那么作为业务主要载体的聚合根应该尽量少地引用技术框架级别的设施,最好是POJO。试想一下,如果你的项目哪天需要从Spring迁移到Play,而你可以自信地给老板说,直接将核心Java代码拷贝过去即可,这将是一种多么美妙的体验。又或者说,很多时候技术框架会有“大步”的升级,这种升级会导致框架中API的变化并且不再支持向后兼容,此时如果我们的领域模与框架无关,那么便可做到在框架升级的过程中幸免于难。
  • 聚合根之间的引用通过ID完成:在聚合根边界设计合理的情况下,一次业务用例只会更新一个聚合根,此时你在该聚合根中去引用另外聚合根的整体有什么好处呢?在本文示例中,一个Order下的OrderItem引用了ProductId,而不是整个Product。
  • 聚合根内部的所有变更都必须通过聚合根完成:为了保证聚合根的一致性,同时避免聚合根内部逻辑向外泄露,客户方只能将整个聚合根作为统一调用入口。
  • 如果一个事务需要更新多个聚合根,首先思考一下自己的聚合根边界处理是否出了问题,因为在设计合理的情况下通常不会出现一个事务更新多个聚合根的场景。如果这种情况的确是业务所需,那么考虑引入消息机制和事件驱动架构,保证一个事务只更新一个聚合根,然后通过消息机制异步更新其他聚合根。
  • 聚合根不应该引用基础设施。
  • 外界不应该持有聚合根内部的数据结构。
  • 尽量使用小聚合。

实体 vs 值对象

软件模型中存在实体对象(Entity)和值对象(Value Object)之说,这种划分方式事实上并不是DDD的专属,但是在DDD中我们非常强调这两者之间的区别。

实体对象表示的是具有一定生命周期并且拥有全局唯一标识(ID)的对象,比如本文中的Order和Product,而值对象表示用于起描述性作用的,没有唯一标识的对象,比如Address对象。

聚合根一定是实体对象,但是并不是所有实体对象都是聚合根,同时聚合根还可以拥有其他子实体对象。聚合根的ID在整个软件系统中全局唯一,而其下的子实体对象的ID只需在单个聚合根下唯一即可。 在本文示例项目中,OrderItem是聚合根Order下的子实体对象:

1
2
3
4
5
复制代码public class OrderItem {
private ProductId productId;
private int count;
private BigDecimal itemPrice;
}

可以看到,虽然OrderItem使用了ProductID作为ID,但是此时我们并没有享受ProductID的全局唯一性,事实上多个Order可以包含相同ProductID的OrderItem,也即多个订单可以包含相同的产品。

区分实体和值对象的一个很重要的原则便是根据相等性来判断,实体对象的相等性是通过ID来完成的,对于两个实体,如果他们的所有属性均相同,但是ID不同,那么他们依然两个不同的实体,就像一对长得一模一样的双胞胎,他们依然是两个不同的自然人。对于值对象来说,相等性的判断是通过属性字段来完成的。比如,订单下的送货地址Address对象便是一个典型的值对象:

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
复制代码public class Address  {
private String province;
private String city;
private String detail;

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Address address = (Address) o;
return province.equals(address.province) &&
city.equals(address.city) &&
detail.equals(address.detail);
}

@Override
public int hashCode() {
return Objects.hash(province, city, detail);
}

}

在Address的equals()方法中,通过判断Address所包含的所有属性(province,city,detail)来决定两个Address的相等性。

值对象还有一个特点是不变的(Immutable),也就说一个值对象一旦被创建出来了便不能对其进行变更,如果要变更,必须重新创建一个新的值对象整体替换原有的。比如,示例项目有一个业务需求:

在订单未支付的情况下,可以修改订单送货地址的详细地址(detail)

由于Address是Order聚合根中的一个对象,对Address的更改只能通过Order完成,在Order中实现changeAddressDetail()方法:

1
2
3
4
5
6
7
复制代码public void changeAddressDetail(String detail) {
if (this.status == PAID) {
throw new OrderCannotBeModifiedException(this.id);
}

this.address = this.address.changeDetailTo(detail);
}

可以看到,通过调用address.changeDetailTo()方法,我们获取到了一个全新的Address对象,然后将新的Address对象整体赋值给address属性。此时Address.changeDetailTo()的实现如下:

1
2
3
复制代码public Address changeDetailTo(String detail) {
return new Address(this.province, this.city, detail);
}

这里的changeDetailTo()方法使用了新的详细地址detail和未发生变更的province、city重新创建出了一个Address对象。

值对象的不变性使得程序的逻辑变得更加简单,你不用去维护复杂的状态信息,需要的时候创建,不要的时候直接扔掉即可,使得值对象就像程序中的过客一样。在DDD建模中,一种受推崇的做法便是将业务概念尽量建模为值对象。

对于OrderItem来说,由于我们的业务需要对OrderItem的数量进行修改,也即拥有生命周期的意味,因此本文将OrderItem建模为了实体对象。但是,如果没有这样的业务需求,那么将OrderItem建模为值对象应该更合适一些。

另外,需要指明的是,实体和值对象的划分并不是一成不变的,而应该根据所处的限界上下文来界定,相同一个业务名词,在一个限界上下文中可能是实体,在另外的限界上下文中可能是值对象。比如,订单Order在采购上下文中应该建模为一个实体,但是在物流上下文中便可建模为一个值对象。

聚合根的家——资源库

通俗点讲,资源库(Repository)就是用来持久化聚合根的。从技术上讲,Repository和DAO所扮演的角色相似,不过DAO的设计初衷只是对数据库的一层很薄的封装,而Repository是更偏向于领域模型。另外,在所有的领域对象中,只有聚合根才“配得上”拥有Repository,而DAO没有这种约束。

实现Order的资源库OrderRepository如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public void save(Order order) {
String sql = "INSERT INTO ORDERS (ID, JSON_CONTENT) VALUES (:id, :json) " +
"ON DUPLICATE KEY UPDATE JSON_CONTENT=:json;";
Map<String, String> paramMap = of("id", order.getId().toString(), "json", objectMapper.writeValueAsString(order));
jdbcTemplate.update(sql, paramMap);
}

public Order byId(OrderId id) {
try {
String sql = "SELECT JSON_CONTENT FROM ORDERS WHERE ID=:id;";
return jdbcTemplate.queryForObject(sql, of("id", id.toString()), mapper());
} catch (EmptyResultDataAccessException e) {
throw new OrderNotFoundException(id);
}
}

在OrderRepository中,我们只定义了save()和byId()方法,分别用于保存/更新聚合根和通过ID获取聚合根。这两个方法是Repository中最常见的方法,有的DDD实践者甚至认为一个纯粹的Repository只应该包含这两个方法。

读到这里,你可能会有些疑问:为什么OrderRepository中没有更新和查询等方法?事实上,Repository所扮演的角色只是向领域模型提供聚合根而已,就像一个聚合根的“容器”一样,这个“容器”本身并不关心客户端对聚合根的操作到底是新增还是更新,你给一个聚合根对象,Repository只是负责将其状态从计算机的内存同步到持久化机制中,从这个角度讲,Repository只需要一个类似save()的方法便可完成同步操作。当然,这个是从概念的出发点得出的设计结果,在技术层面,新增和更新还是需要区别对待,比如SQL语句有insert和update之分,只是我们将这样的技术细节隐藏在了save()方法中,客户方并无需知道这些细节。在本例中,我们通过MySQL的
ON DUPLICATE KEY UPDATE特性同时处理对数据库的新增和更新操作。当然,我们也可以通过编程判断聚合根在数据库中是否已经存在,如果存在则update,否则insert。另外,诸如Hibernate这样的持久化框架自动提供saveOrUpate()方法可以直接用于对聚合根的持久化。

对于查询功能来说,在Repository中实现查询本无不合理之处,然而项目的演进可能导致Repository中充斥着大量的查询代码“喧宾夺主”似的掩盖了Repository原本的目的。事实上,DDD中读操作和写操作是两种很不一样的过程,笔者的建议是尽量将此二者分开实现,由此查询功能将从Repository中分离出去,在下文中我将详细讲到。

在本例中,我们在技术实现上使用到了Spring的JdbcTemplate和JSON格式持久化Order聚合根,其实Repository并不与某种持久化机制绑定,一个被抽象出来的Repository向外暴露的功能“接口”始终是向领域模型提供聚合根对象,就像“聚合根的家”一样。

好了,至此让我们来做个回顾,上文中我们以“更新Order中的Product数量”业务需求为例,讲到了应用服务、聚合根和资源库,对该业务需求的处理流程体现了DDD处理业务需求的最常见最典型的形式:

应用服务作为总体协调者,先通过资源库获取到聚合根,然后调用聚合根中的业务方法,最后再次调用资源库保存聚合根。

流程示意图如下:

DDD处理业务流程的典型流程

创生之柱——工厂

稍微提炼一下,我们便知道软件里面的写操作要么是修改既有数据,要么是新建数据。对于前者,DDD给出的答案已经在上文中讲到,接下来我们讲讲在DDD中如何新建聚合根。

创建聚合根通常通过设计模式中的工厂(Factory)模式完成,这一方面可以享受到工厂模式本身的好处,另一方面,DDD中的Factory还具有将“聚合根的创建逻辑”显现出来的效果。

创生之柱——恒星诞生的地方,距地球约6500光年,由哈勃太空望远镜于1995年拍摄

聚合根的创建过程可简单可复杂,有时可能直接调用构造函数即可,而有时却存在一个复杂的构造流程,比如需要调用其他系统获取数据等。通常来讲,Factory有两种实现方式:

  • 直接在聚合根中实现Factory方法,常用于简单的创建过程
  • 独立的Factory类,用于有一定复杂度的创建过程,或者创建逻辑不适合放在聚合根上

让我们先演示一下简单的Factory方法,在示例订单系统中,有个业务用例是“创建Product”:

创建Product,属性包括名称(name),描述(description)和单价(price),ProductId为UUID

在Product类中实现工厂方法create():

1
2
3
4
5
6
7
8
9
10
11
复制代码public static Product create(String name, String description, BigDecimal price) {
return new Product(name, description, price);
}

private Product(String name, String description, BigDecimal price) {
this.id = ProductId.newProductId();
this.name = name;
this.description = description;
this.price = price;
this.createdAt = Instant.now();
}

这里,Product中的create()方法并不包含创建逻辑,而是将创建过程直接代理给了Product的构造函数。你可能觉得这个create()方法有些多此一举,然而这种做法的初衷依然是:我们希望将聚合根的创建逻辑突显出来。构造函数本身是一个非常技术的东西,任何地方只要涉及到在计算机内存中新建对象都需要使用构造函数,无论创建的初始原因是业务需要,还是从数据库加载,亦或是从JSON数据反序列化。因此程序中往往存在多个构造函数用于不同的场景,而为了将业务上的创建与技术上的创建区别开来,我们引入了create()方法用于表示业务上的创建过程。

“创建Product”所设计到的Factory的确简单,让我们再来看看另外一个例子:“创建Order”:

创建Order,包含用户选择的Product及其数量,OrderId必须调用第三方的OrderIdGenerator获取

这里的OrderIdGenerator是具有服务性质的对象(即下文中的领域服务),在DDD中,聚合根通常不会引用其他服务类。另外,调用OrderIdGenerator生成ID应该是一个业务细节,如前文所讲,这种细节不应该放在ApplicationService中。此时,可以通过Factory类来完成Order的创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码@Component
public class OrderFactory {
private final OrderIdGenerator idGenerator;

public OrderFactory(OrderIdGenerator idGenerator) {
this.idGenerator = idGenerator;
}

public Order create(List<OrderItem> items, Address address) {
OrderId orderId = idGenerator.generate();
return Order.create(orderId, items, address);
}
}

必要的妥协——领域服务

前面我们提到,聚合根是业务逻辑的主要载体,也就是说业务逻辑的实现代码应该尽量地放在聚合根或者聚合根的边界之内。但有时,有些业务逻辑并不适合于放在聚合根上,比如前文的OrderIdGenerator便是如此,在这种“迫不得已”的情况下,我们引入领域服务(Domain Service)。还是先来看一个列子,对于Order的支付有以下业务用例:

通过支付网关OrderPaymentService完成Order的支付。

在OrderApplicationService中,直接调用领域服务OrderPaymentService:

1
2
3
4
5
6
复制代码@Transactional
public void pay(String id, PayOrderCommand command) {
Order order = orderRepository.byId(orderId(id));
orderPaymentService.pay(order, command.getPaidPrice());
orderRepository.save(order);
}

然后实现OrderPaymentService:

1
2
3
4
复制代码public void pay(Order order, BigDecimal paidPrice) {
order.pay(paidPrice);
paymentProxy.pay(order.getId(), paidPrice);
}

这里的PaymentProxy与OrderIdGenerator相似,并不适合于放在Order中。可以看到,在OrderApplicationService中,我们并没有直接调用Order中的业务方法,而是先调用OrderPaymentService.pay(),然后在OrderPaymentService.pay()中完成调用支付网关PaymentProxy.pay()这样的业务细节。

到此,再来反观在通常的实践中我们编写的Service类,事实上这些Servcie类将DDD中的ApplicationService和DomainService糅合在了一起,比如在”基于Service + 贫血模型”的实现“小节中的OrderService便是如此。在DDD中,ApplicationService和DomainService是两个很不一样的概念,前者是必须有的DDD组件,而后者只是一种妥协的结果,因此程序中的DomainService应该越少越好。

Command对象

通常来说,DDD中的写操作并不需要向客户端返回数据,在某些情况下(比如新建聚合根)可以返回一个聚合根的ID,这意味着ApplicationService或者聚合根中的写操作方法通常返回void即可。比如,对于OrderApplicationService,各个方法签名如下:

1
2
3
4
复制代码public OrderId createOrder(CreateOrderCommand command) ;
public void changeProductCount(String id, ChangeProductCountCommand command) ;
public void pay(String id, PayOrderCommand command) ;
public void changeAddressDetail(String id, String detail) ;

可以看到,在多数情况下我们使用了后缀为Command的对象传给ApplicationService,比如CreateOrderCommand和ChangeProductCountCommand。Command即命令的意思,也即写操作表示的是外部向领域模型发起的一次命令操作。事实上,从技术上讲,Command对象只是一种类型的DTO对象,它封装了客户端发过来的请求数据。在Controller中所接收的所有写操作都需要通过Command进行包装,在Command比较简单(比如只有1-2个字段)的情况下Controller可以将Command解开之后,将其中的数据直接传递给ApplicationService,比如changeAddressDetail()便是如此;而在Command中数据字段比较多时,可以直接将Command对象传递给ApplicationService。当然,这并不是DDD中需要严格遵循的一个原则,比如无论Command的简繁程度,统一将所有Command从Controller传递给ApplicationService,也不存在太大的问题,更多的只是一个编码习惯上的选择。不过有一点需要强调,即前文提到的“ApplicationService需要接受原始数据类型而不是领域模型中的对象”,在这里意味着Command对象中也应该包含原始的数据类型。

统一使用Command对象还有个好处是,我们通过查找所有后缀为Command的对象,便可以概览性地了解软件系统向外提供的业务功能。

阶段性小结一下,以上我们主要围绕着软件的“写操作”在DDD中的实现进行讨论,并且讲到了3种场景,分别是:

  • 通过聚合根完成业务请求
  • 通过Factory完成聚合根的创建
  • 通过DomainService完成业务请求

以上3种场景大致上涵盖了DDD完成业务写操作的基本方面,总结下来3句话:创建聚合根通过Factory完成;业务逻辑优先在聚合根边界内完成;聚合根中不合适放置的业务逻辑才考虑放到DomainService中。

DDD实现软件

DDD中的读操作

软件中的读模型和写模型是很不一样的,我们通常所讲的业务逻辑更多的时候是在写操作过程中需要关注的东西,而读操作更多关注的是如何向客户方返回恰当的数据展现。

在DDD的写操作中,我们需要严格地按照“应用服务 -> 聚合根 -> 资源库”的结构进行编码,而在读操作中,采用与写操作相同的结构有时不但得不到好处,反而使整个过程变得冗繁。这里介绍3种读操作的方式:

  • 基于领域模型的读操作
  • 基于数据模型的读操作
  • CQRS

首先,无论哪种读操作方式,都需要遵循一个原则:领域模型中的对象不能直接返回给客户端,因为这样领域模型的内部便暴露给了外界,而对领域模型的修改将直接影响到客户端。因此,在DDD中我们通常为读操作专门创建相应的模型用于数据展现。在写操作中,我们通过Command后缀进行请求数据的统一,在读操作中,我们通过Representation后缀进行展现数据的统一,这里的Representation也即REST中的“R”。

基于领域模型的读操作

这种方式将读模型和写模型糅合到一起,先通过资源库获取到领域模型,然后将其转换为Representation对象,这也是当前被大量使用的方式,比如对于“获取Order详情的接口”,OrderApplicationService实现如下:

1
2
3
4
5
复制代码@Transactional(readOnly = true)
public OrderRepresentation byId(String id) {
Order order = orderRepository.byId(orderId(id));
return orderRepresentationService.toRepresentation(order);
}

我们先通过orderRepository.byId()获取到Order聚合根对象,然后调用orderRepresentationService.toRepresentation()将Order转换为展现对象OrderRepresentation,OrderRepresentationService.toRepresentation()实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码public OrderRepresentation toRepresentation(Order order) {
List<OrderItemRepresentation> itemRepresentations = order.getItems().stream()
.map(orderItem -> new OrderItemRepresentation(orderItem.getProductId().toString(),
orderItem.getCount(),
orderItem.getItemPrice()))
.collect(Collectors.toList());

return new OrderRepresentation(order.getId().toString(),
itemRepresentations,
order.getTotalPrice(),
order.getStatus(),
order.getCreatedAt());
}

这种方式的优点是非常直接明了,也不用创建新的数据读取机制,直接使用Repository读取数据即可。然而缺点也很明显:一是读操作完全束缚于聚合根的边界划分,比如,如果客户端需要同时获取Order及其所包含的Product,那么我们需要同时将Order聚合根和Product聚合根加载到内存再做转换操作,这种方式既繁琐又低效;二是在读操作中,通常需要基于不同的查询条件返回数据,比如通过Order的日期进行查询或者通过Product的名称进行查询等,这样导致的结果是Repository上处理了太多的查询逻辑,变得越来越复杂,也逐渐偏离了Repository本应该承担的职责。

基于数据模型的读操作

这种方式绕开了资源库和聚合,直接从数据库中读取客户端所需要的数据,此时写操作和读操作共享的只是数据库。比如,对于“获取Product列表”接口,通过一个专门的ProductRepresentationService直接从数据库中读取数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码 @Transactional(readOnly = true)
public PagedResource<ProductSummaryRepresentation> listProducts(int pageIndex, int pageSize) {
MapSqlParameterSource parameters = new MapSqlParameterSource();
parameters.addValue("limit", pageSize);
parameters.addValue("offset", (pageIndex - 1) * pageSize);

List<ProductSummaryRepresentation> products = jdbcTemplate.query(SELECT_SQL, parameters,
(rs, rowNum) -> new ProductSummaryRepresentation(rs.getString("ID"),
rs.getString("NAME"),
rs.getBigDecimal("PRICE")));

int total = jdbcTemplate.queryForObject(COUNT_SQL, newHashMap(), Integer.class);
return PagedResource.of(total, pageIndex, products);
}

然后在Controller中直接返回:

1
2
3
4
5
复制代码@GetMapping
public PagedResource<ProductSummaryRepresentation> pagedProducts(@RequestParam(required = false, defaultValue = "1") int pageIndex,
@RequestParam(required = false, defaultValue = "10") int pageSize) {
return productRepresentationService.listProducts(pageIndex, pageSize);
}

可以看到,真个过程并没有使用到ProductRepository和Product,而是将SQL获取到的数据直接新建为ProductSummaryRepresentation对象。

这种方式的优点是读操作的过程不用囿于领域模型,而是基于读操作本身的需求直接获取需要的数据即可,一方面简化了整个流程,另一方面大大提升了性能。但是,由于读操作和写操作共享了数据库,而此时的数据库主要是对应于聚合根的结构创建的,因此读操作依然会受到写操作的数据模型的牵制。不过这种方式是一种很好的折中,微软也提倡过这种方式,更多细节请参考微软官网。

CQRS

CQRS(Command Query Responsibility Segregation),即命令查询职责分离,这里的命令可以理解为写操作,而查询可以理解为读操作。与“基于数据模型的读操作”不同的是,在CQRS中写操作和读操作使用了不同的数据库,数据从写模型数据库同步到读模型数据库,通常通过领域事件的形式同步变更信息。

CQRS架构

这样一来,读操作便可以根据自身所需独立设计数据结构,而不用受写模型数据结构的牵制。CQRS本身是一个很大的话题,已经超出了本文的范围,读者可以自行研究。

到此,DDD中的读操作可以大致分为3种实现方式:

DDD读操作的3种实现方式

总结

本文主要介绍了DDD中的应用服务、聚合、资源库和工厂等概念以及与它们相关的编码实践,然后着重讲到了软件的读写操作在DDD中的实现方式,其中写操作的3种场景为:

  • 通过聚合根完成业务请求,这是DDD完成业务请求的典型方式
  • 通过Factory完成聚合根的创建,用于创建聚合根
  • 通过DomainService完成业务请求,当业务放在聚合根中不合适时才考虑放在DomainService中

对于读操作,同样给出了3种方式:

  • 基于领域模型的读操作(读写操作糅合在了一起,不推荐)
  • 基于数据模型的读操作(绕过聚合根和资源库,直接返回数据,推荐)
  • CQRS(读写操作分别使用不同的数据库)

以上“3读3写”基本上涵盖了程序员完成业务功能的日常开发之所需,原来DDD就这么简单,不是吗?

Share

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…862863864…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%