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

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


  • 首页

  • 归档

  • 搜索

Spring JdbcTemplate详解,这都看不懂就安心

发表于 2021-04-20

JDBC 基础

Java程序使用JDBC接口访问关系数据库的时候,需要以下几步:

  • 创建全局DataSource实例,表示数据库连接池;
  • 在需要读写数据库的方法内部,按如下步骤访问数据库:
  • 从全局DataSource实例获取Connection实例;
  • 通过Connection实例创建PreparedStatement实例;
  • 执行SQL语句,如果是查询,则通过ResultSet读取结果集,如果是修改,则获得int结果。

正确编写JDBC代码的关键是使用try ... finally释放资源,涉及到事务的代码需要正确提交或回滚事务。

在Spring使用JDBC,首先我们通过IoC容器创建并管理一个DataSource实例,然后,Spring提供了一个JdbcTemplate,可以方便地让我们操作JDBC,因此,通常情况下,我们会实例化一个JdbcTemplate。顾名思义,这个类主要使用了Template模式。

编写示例代码或者测试代码时,我们强烈推荐使用HSQLDB这个数据库,它是一个用Java编写的关系数据库,可以以内存模式或者文件模式运行,本身只有一个jar包,非常适合演示代码或者测试代码,本文使用MySql

我们以实际工程为例,先创建Maven工程spring-data-jdbc,然后引入以下依赖:

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
java复制代码<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.2.0.RELEASE</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.15</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.2.0.RELEASE</version>
</dependency>
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>3.4.2</version>
</dependency>
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<version>2.5.0</version>
</dependency>
</dependencies>

在AppConfig中,我们需要创建以下几个必须的Bean:

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
java复制代码@Configuration
@ComponentScan
@PropertySource("jdbc.properties")
public class AppConfig {

@Value("${jdbc.url}")
String jdbcUrl;

@Value("${jdbc.username}")
String jdbcUsername;

@Value("${jdbc.password}")
String jdbcPassword;

@Bean
DataSource createDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(jdbcUrl);
config.setUsername(jdbcUsername);
config.setPassword(jdbcPassword);
config.addDataSourceProperty("autoCommit", "true");
config.addDataSourceProperty("connectionTimeout", "5");
config.addDataSourceProperty("idleTimeout", "60");
return new HikariDataSource(config);
}

@Bean
JdbcTemplate createJdbcTemplate(@Autowired DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}

在上述配置中:

  • 通过@PropertySource("jdbc.properties")读取数据库配置文件;
  • 通过@Value("${jdbc.url}")注入配置文件的相关配置;
  • 创建一个DataSource实例,它的实际类型是HikariDataSource,创建时需要用到注入的配置;
  • 创建一个JdbcTemplate实例,它需要注入DataSource,这是通过方法参数完成注入的。

最后,针对HSQLDB写一个配置文件jdbc.properties:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码# 数据库文件名为testdb:
jdbc.url=jdbc:hsqldb:file:testdb

# Hsqldb默认的用户名是sa,口令是空字符串:
jdbc.username=sa
jdbc.password=


# mysql配置
# 数据库文件名为testdb:
jdbc.driverClass=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/springjdbc?characterEncoding=utf-8&serverTimezone=GMT%2B8&useSSL=false

jdbc.user=root
jdbc.password=1234

可以通过HSQLDB自带的工具来初始化数据库表,这里我们写一个Bean,在Spring容器启动时自动创建一个users表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Component
public class DatabaseInitializer {
@Autowired
JdbcTemplate jdbcTemplate;

@PostConstruct
public void init() {
jdbcTemplate.update("CREATE TABLE IF NOT EXISTS users (" //
+ "id BIGINT IDENTITY NOT NULL PRIMARY KEY, " //
+ "email VARCHAR(100) NOT NULL, " //
+ "password VARCHAR(100) NOT NULL, " //
+ "name VARCHAR(100) NOT NULL, " //
+ "UNIQUE (email))");
}
}

现在,所有准备工作都已完毕。我们只需要在需要访问数据库的Bean中,注入JdbcTemplate即可:

1
2
3
4
5
6
java复制代码@Component
public class UserService {
@Autowired
JdbcTemplate jdbcTemplate;
...
}

JdbcTemplate 用法

Spring提供的JdbcTemplate采用Template模式,提供了一系列以回调为特点的工具方法,目的是避免繁琐的try...catch语句。

我们以具体的示例来说明JdbcTemplate的用法。

首先我们看T execute(ConnectionCallback<T> action)方法,它提供了Jdbc的Connection供我们使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码public User getUserById(long id) {
// 注意传入的是ConnectionCallback:
return jdbcTemplate.execute((Connection conn) -> {
// 可以直接使用conn实例,不要释放它,回调结束后JdbcTemplate自动释放:
// 在内部手动创建的PreparedStatement、ResultSet必须用try(...)释放:
try (var ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?")) {
ps.setObject(1, id);
try (var rs = ps.executeQuery()) {
if (rs.next()) {
return new User( // new User object:
rs.getLong("id"), // id
rs.getString("email"), // email
rs.getString("password"), // password
rs.getString("name")); // name
}
throw new RuntimeException("user not found by id.");
}
}
});
}

也就是说,上述回调方法允许获取Connection,然后做任何基于Connection的操作。

我们再看T execute(String sql, PreparedStatementCallback<'T'> action)的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public User getUserByName(String name) {
// 需要传入SQL语句,以及PreparedStatementCallback:
return jdbcTemplate.execute("SELECT * FROM users WHERE name = ?", (PreparedStatement ps) -> {
// PreparedStatement实例已经由JdbcTemplate创建,并在回调后自动释放:
ps.setObject(1, name);
try (var rs = ps.executeQuery()) {
if (rs.next()) {
return new User( // new User object:
rs.getLong("id"), // id
rs.getString("email"), // email
rs.getString("password"), // password
rs.getString("name")); // name
}
throw new RuntimeException("user not found by id.");
}
});
}

最后,我们看T queryForObject(String sql, Object[] args, RowMapper<'T'> rowMapper)方法:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public User getUserByEmail(String email) {
// 传入SQL,参数和RowMapper实例:
return jdbcTemplate.queryForObject("SELECT * FROM users WHERE email = ?", new Object[] { email },
(ResultSet rs, int rowNum) -> {
// 将ResultSet的当前行映射为一个JavaBean:
return new User( // new User object:
rs.getLong("id"), // id
rs.getString("email"), // email
rs.getString("password"), // password
rs.getString("name")); // name
});
}

RowMapper定义:

1
2
3
4
5
java复制代码@FunctionalInterface
public interface RowMapper<T> {
@Nullable
T mapRow(ResultSet var1, int var2) throws SQLException;
}

方法getUserByEmail中(ResultSet rs, int rowNum) -> {}语句实现一个RowMapper继承类的对象

在queryForObject()方法中,传入SQL以及SQL参数后,JdbcTemplate会自动创建PreparedStatement,自动执行查询并返回ResultSet,我们提供的RowMapper需要做的事情就是把ResultSet的当前行映射成一个JavaBean并返回。整个过程中,使用Connection、PreparedStatement和ResultSet都不需要我们手动管理。

RowMapper不一定返回JavaBean,实际上它可以返回任何Java对象。例如,使用SELECT COUNT(*)查询时,可以返回Long:

1
2
3
4
5
6
java复制代码public long getUsers() {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", null, (ResultSet rs, int rowNum) -> {
// SELECT COUNT(*)查询只有一列,取第一列数据:
return rs.getLong(1);
});
}

如果我们期望返回多行记录,而不是一行,可以用query()方法:

1
2
3
4
5
6
java复制代码public List<User> getUsers(int pageIndex) {
int limit = 100;
int offset = limit * (pageIndex - 1);
return jdbcTemplate.query("SELECT * FROM users LIMIT ? OFFSET ?", new Object[] { limit, offset },
new BeanPropertyRowMapper<>(User.class));
}

上述query()方法传入的参数仍然是SQL、SQL参数以及RowMapper实例。这里我们直接使用Spring提供的BeanPropertyRowMapper。如果数据库表的结构恰好和JavaBean的属性名称一致,那么BeanPropertyRowMapper就可以直接把一行记录按列名转换为JavaBean。

如果我们执行的不是查询,而是插入、更新和删除操作,那么需要使用update()方法:

1
2
3
4
5
6
java复制代码public void updateUser(User user) {
// 传入SQL,SQL参数,返回更新的行数:
if (1 != jdbcTemplate.update("UPDATE user SET name = ? WHERE id=?", user.getName(), user.getId())) {
throw new RuntimeException("User not found by id");
}
}

只有一种INSERT操作比较特殊,那就是如果某一列是自增列(例如自增主键),通常,我们需要获取插入后的自增值。JdbcTemplate提供了一个KeyHolder来简化这一操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public User register(String email, String password, String name) {
// 创建一个KeyHolder:
KeyHolder holder = new GeneratedKeyHolder();
if (1 != jdbcTemplate.update(
// 参数1:PreparedStatementCreator
(conn) -> {
// 创建PreparedStatement时,必须指定RETURN_GENERATED_KEYS:
var ps = conn.prepareStatement("INSERT INTO users(email,password,name) VALUES(?,?,?)",
Statement.RETURN_GENERATED_KEYS);
ps.setObject(1, email);
ps.setObject(2, password);
ps.setObject(3, name);
return ps;
},
// 参数2:KeyHolder
holder)
) {
throw new RuntimeException("Insert failed.");
}
// 从KeyHolder中获取返回的自增值:
return new User(holder.getKey().longValue(), email, password, name);
}

JdbcTemplate还有许多重载方法,这里我们不一一介绍。需要强调的是,JdbcTemplate只是对JDBC操作的一个简单封装,它的目的是尽量减少手动编写try(resource) {...}的代码,对于查询,主要通过RowMapper实现了JDBC结果集到Java对象的转换。

我们总结一下JdbcTemplate的用法,那就是:

  • 针对简单查询,优选query()和queryForObject(),因为只需提供SQL语句、参数和RowMapper;
  • 针对更新操作,优选update(),因为只需提供SQL语句和参数;
  • 任何复杂的操作,最终也可以通过execute(ConnectionCallback)实现,因为拿到Connection就可以做任何JDBC操作。

实际上我们使用最多的仍然是各种查询。如果在设计表结构的时候,能够和JavaBean的属性一一对应,那么直接使用BeanPropertyRowMapper就很方便。如果表结构和JavaBean不一致怎么办?那就需要稍微改写一下查询,使结果集的结构和JavaBean保持一致。

例如,表的列名是office_address,而JavaBean属性是workAddress,就需要指定别名,改写查询如下:

1
java复制代码SELECT id, email, office_address AS workAddress, name FROM users WHERE email = ?

RowMapper 的基本使用

使用过原生JDBC的朋友应该知道,从数据库查询出来的记录全都被保存在ResultSet结果集中,我们需要将结果集中的数据一条条地获取并设置到具体的实体类上,如此,该实体类才能在接下来的程序中使用。然而问题是,每次都要这么操作实在是太麻烦了,Spring就不应该提供什么功能来替我们做这些事情吗?答案当然是有的,那就是本文的主角——RowMapper。

Spring JDBC中目前有两个主要的RowMapper实现,使用它们应该能解决大部分的场景了。

SingleColumnRowMapper

通过名字我们就能大概了解,在查询返回单列数据的时候,就该使用这个RowMapper,下面我们来看看具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Override
public String getStudentNameById(String id) {
String sql = "select name from test_student where id = ?";
return this.jdbcTemplate.queryForObject(sql, new Object[]{id},
new SingleColumnRowMapper<>(String.class));
}

@Override
public List<String> getStudentNamesByGrade(Integer grade) {
String sql = "select name from test_student where grade = ?";
return this.jdbcTemplate.query(sql, new Object[]{grade},
new SingleColumnRowMapper<>(String.class));
}

测试:

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Test
public void getStudentNameById(){
String name = studentService.getStudentNameById("3");
assertEquals("李清照", name);
}

@Test
public void getStudentNamesByGrade(){
List<String> names = studentService.getStudentNamesByGrade(1);
assertTrue(2 == names.size());
}

BeanPropertyRowMapper

当查询数据库返回的是多列数据,且你需要将这些多列数据映射到某个具体的实体类上,那么就该使用这个RowMapper,下面是具体的使用代码:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@Override
public Student getStudentByName2(String name) {
String sql = "select name, gender from test_student where name = ?";
return this.jdbcTemplate.queryForObject(sql, new Object[]{name},
new BeanPropertyRowMapper<>(Student.class));
}

@Override
public List<Student> getStudentsByName2(String name) {
String sql = "select name, gender from test_student where name = ?";
return this.jdbcTemplate.query(sql, new Object[]{name},
new BeanPropertyRowMapper<>(Student.class));

这种使用方式有一个前提,那就是数据库SQL查出来的数据其列名与实体类中的属性名是一致的,当然个数和顺序可以不一致。比如数据库SQL查出来的姓名列叫name,那么对应的实体类中的姓名也必须叫name,而不能叫studentName或者其它。

定义自己的RowMapper

当然,如果你SQL查询出来的数据列名就是和实体类的属性名不一样,或者想按照自己的规则来装配实体类,那么就可以定义并使用自己的Row Mapper。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public class StudentRowMapper implements RowMapper<Student> {

@Override
public Student mapRow(ResultSet rs, int i) throws SQLException {
Student student = new Student();
student.setName(rs.getString("name"));
student.setGender(rs.getString("gender"));
student.setEmail(rs.getString("email"));
return student;
}
}

@Override
public Student getStudentByName3(String name) {
String sql = "select name, gender, email from test_student where name = ?";
return this.jdbcTemplate.queryForObject(sql, new Object[]{name}, new StudentRowMapper());
}

@Override
public List<Student> getStudentsByName3(String name) {
String sql = "select name, gender, email from test_student where name = ?";
return this.jdbcTemplate.query(sql, new Object[]{name}, new StudentRowMapper());
}

小结

  • Spring提供了JdbcTemplate来简化JDBC操作;
  • 使用JdbcTemplate时,根据需要优先选择高级方法;
  • 任何JDBC操作都可以使用保底的execute(ConnectionCallback)方法。

本文转载自: 掘金

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

DDD系列第五讲 聊聊如何避免写流水账代码 前言 案例简介

发表于 2021-04-20

3.gif

前言

向读者们道歉,由于工作太忙,又对文章质量有追求,所以这篇文章产出速度较慢,但可以向大家保证:文章中的内容都经过了反复实践和踩坑。DDD系列的前几篇文章可以点击文字下方阅读~

DDD系列第一讲

DDD系列第二讲

DDD系列第三讲

DDD系列第四讲

在过去一年里我们团队做了大量的老系统重构和迁移,其中有大量的代码属于流水账代码,通常能看到是开发在对外的API接口里直接写业务逻辑代码,或者在一个服务里大量的堆接口,导致业务逻辑实际无法收敛,接口复用性比较差。所以这讲主要想系统性的解释一下如何通过DDD的重构,将原有的流水账代码改造为逻辑清晰、职责分明的模块。

案例简介

这里举一个简单的常见案例:下单链路。假设我们在做一个checkout接口,需要做各种校验、查询商品信息、调用库存服务扣库存、然后生成订单:

image.png

一个比较典型的代码如下:

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
scss复制代码@RestController
@RequestMapping("/")
public class CheckoutController {

@Resource
private ItemService itemService;

@Resource
private InventoryService inventoryService;

@Resource
private OrderRepository orderRepository;

@PostMapping("checkout")
public Result<OrderDO> checkout(Long itemId, Integer quantity) {
// 1) Session管理
Long userId = SessionUtils.getLoggedInUserId();
if (userId <= 0) {
return Result.fail("Not Logged In");
}

// 2)参数校验
if (itemId <= 0 || quantity <= 0 || quantity >= 1000) {
return Result.fail("Invalid Args");
}

// 3)外部数据补全
ItemDO item = itemService.getItem(itemId);
if (item == null) {
return Result.fail("Item Not Found");
}

// 4)调用外部服务
boolean withholdSuccess = inventoryService.withhold(itemId, quantity);
if (!withholdSuccess) {
return Result.fail("Inventory not enough");
}

// 5)领域计算
Long cost = item.getPriceInCents() * quantity;

// 6)领域对象操作
OrderDO order = new OrderDO();
order.setItemId(itemId);
order.setBuyerId(userId);
order.setSellerId(item.getSellerId());
order.setCount(quantity);
order.setTotalCost(cost);

// 7)数据持久化
orderRepository.createOrder(order);

// 8)返回
return Result.success(order);
}
}

为什么这种典型的流水账代码在实际应用中会有问题呢?其本质问题是违背了SRP(Single Responsbility Principle)单一职责原则。这段代码里混杂了业务计算、校验逻辑、基础设施、和通信协议等,在未来无论哪一部分的逻辑变更都会直接影响到这段代码,长期当后人不断的在上面叠加新的逻辑时,会造成代码复杂度增加、逻辑分支越来越多,最终造成bug或者没人敢重构的历史包袱。

所以我们才需要用DDD的分层思想去重构一下以上的代码,通过不同的代码分层和规范,拆分出逻辑清晰,职责明确的分层和模块,也便于一些通用能力的沉淀。

主要的几个步骤分为:

  1. 分离出独立的Interface接口层,负责处理网络协议相关的逻辑
  2. 从真实业务场景中,找出具体用例(Use Cases),然后将具体用例通过专用的Command指令、Query查询、和Event事件对象来承接
  3. 分离出独立的Application应用层,负责业务流程的编排,响应Command、Query和Event。每个应用层的方法应该代表整个业务流程中的一个节点
  4. 处理一些跨层的横切关注点,如鉴权、异常处理、校验、缓存、日志等

下面会针对每个点做详细的解释。

Interface接口层

随着REST和MVC架构的普及,经常能看到开发同学直接在Controller中写业务逻辑,如上面的典型案例,但实际上MVC Controller不是唯一的重灾区。以下的几种常见的代码写法通常都可能包含了同样的问题:

  • HTTP 框架:如Spring MVC框架,Spring Cloud等
  • RPC 框架:如Dubbo、HSF、gRPC等
  • 消息队列MQ的“消费者”:比如JMS的 onMessage,RocketMQ的MessageListener等
  • Socket通信:Socket通信的receive、WebSocket的onMessage等
  • 文件系统:WatcherService等
  • 分布式任务调度:SchedulerX等

这些的方法都有一个共同的点就是都有自己的网络协议,而如果我们的业务代码和网络协议混杂在一起,则会直接导致代码跟网络协议绑定,无法被复用。
所以,在DDD的分层架构中,我们单独会抽取出来Interface接口层,作为所有对外的门户,将网络协议和业务逻辑解耦。

接口层的组成

接口层主要由以下几个功能组成:

  1. 网络协议的转化:通常这个已经由各种框架给封装掉了,我们需要构建的类要么是被注解的bean,要么是继承了某个接口的bean。
  2. 统一鉴权:比如在一些需要AppKey+Secret的场景,需要针对某个租户做鉴权的,包括一些加密串的校验
  3. Session管理:一般在面向用户的接口或者有登陆态的,通过Session或者RPC上下文可以拿到当前调用的用户,以便传递给下游服务。
  4. 限流配置:对接口做限流避免大流量打到下游服务
  5. 前置缓存:针对变更不是很频繁的只读场景,可以前置结果缓存到接口层
  6. 异常处理:通常在接口层要避免将异常直接暴露给调用端,所以需要在接口层做统一的异常捕获,转化为调用端可以理解的数据格式
  7. 日志:在接口层打调用日志,用来做统计和debug等。一般微服务框架可能都直接包含了这些功能。

当然,如果有一个独立的网关设施/应用,则可以抽离出鉴权、Session、限流、日志等逻辑,但是目前来看API网关也只能解决一部分的功能,即使在有API网关的场景下,应用里独立的接口层还是有必要的。
在interface层,鉴权、Session、限流、缓存、日志等都比较直接,只有一个异常处理的点需要重点说下。

返回值和异常处理规范,Result vs Exception

注:这部分主要还是面向REST和RPC接口,其他的协议需要根据协议的规范产生返回值。

在我见过的一些代码里,接口的返回值比较多样化,有些直接返回DTO甚至DO,另一些返回Result。
接口层的核心价值是对外,所以如果只是返回DTO或DO会不可避免的面临异常和错误栈泄漏到使用方的情况,包括错误栈被序列化反序列化的消耗。所以,这里提出一个规范:

规范:Interface层的HTTP和RPC接口,返回值为Result,捕捉所有异常

规范:Application层的所有接口返回值为DTO,不负责处理异常

Application层的具体规范等下再讲,在这里先展示Interface层的逻辑。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码@PostMapping("checkout")
public Result<OrderDTO> checkout(Long itemId, Integer quantity) {
try {
CheckoutCommand cmd = new CheckoutCommand();
OrderDTO orderDTO = checkoutService.checkout(cmd);
return Result.success(orderDTO);
} catch (ConstraintViolationException cve) {
// 捕捉一些特殊异常,比如Validation异常
return Result.fail(cve.getMessage());
} catch (Exception e) {
// 兜底异常捕获
return Result.fail(e.getMessage());
}
}

当然,每个接口都要写异常处理逻辑会比较烦,所以可以用AOP做个注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
less复制代码@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ResultHandler {

}

@Aspect
@Component
public class ResultAspect {
@Around("@annotation(ResultHandler)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
Object proceed = null;
try {
proceed = joinPoint.proceed();
} catch (ConstraintViolationException cve) {
return Result.fail(cve.getMessage());
} catch (Exception e) {
return Result.fail(e.getMessage());
}
return proceed;
}
}

然后最终代码则简化为:

1
2
3
4
5
6
7
less复制代码@PostMapping("checkout")
@ResultHandler
public Result<OrderDTO> checkout(Long itemId, Integer quantity) {
CheckoutCommand cmd = new CheckoutCommand();
OrderDTO orderDTO = checkoutService.checkout(cmd);
return Result.success(orderDTO);
}

接口层的接口的数量和业务间的隔离

在传统REST和RPC的接口规范中,通常一个领域的接口,无论是REST的Resource资源的GET/POST/DELETE,还是RPC的方法,是追求相对固定的,统一的,而且会追求统一个领域的方法放在一个领域的服务或Controller中。

但是我发现在实际做业务的过程中,特别是当支撑的上游业务比较多时,刻意去追求接口的统一通常会导致方法中的参数膨胀,或者导致方法的膨胀。举个例子:假设有一个宠物卡和一个亲子卡的业务公用一个开卡服务,但是宠物需要传入宠物类型,亲子的需要传入宝宝年龄。

1
2
3
4
5
6
7
8
9
10
11
12
13
csharp复制代码// 可以是RPC Provider 或者 Controller
public interface CardService {

// 1)统一接口,参数膨胀
Result openCard(int petType, int babyAge);

// 2)统一泛化接口,参数语意丢失
Result openCardV2(Map<String, Object> params);

// 3)不泛化,同一个类里的接口膨胀
Result openPetCard(int petType);
Result openBabyCard(int babyAge);
}

可以看出来,无论是怎么操作,都有可能导致CardService这个服务未来越来越难以维护,方法越来越多,一个业务的变更有可能会导致整个服务/Controller的变更,最终变得无法维护。我曾经参与过的一个服务,提供了几十个方法,上万行代码,可想而知无论是使用方对接口的理解成本还是对代码的维护成本都是极高的。
所以,这里提出另一个规范:

规范:一个Interface层的类应该是“小而美”的,应该是面向“一个单一的业务”或“一类同样需求的业务”,需要尽量避免用同一个类承接不同类型业务的需求。

基于上面的这个规范,可以发现宠物卡和亲子卡虽然看起来像是类似的需求,但并非是“同样需求”的,可以预见到在未来的某个时刻,这两个业务的需求和需要提供的接口会越走越远,所以需要将这两个接口类拆分开:

1
2
3
4
5
6
7
csharp复制代码public interface PetCardService {
Result openPetCard(int petType);
}

public interface BabyCardService {
Result openBabyCard(int babyAge);
}

这个的好处是符合了Single Responsibility Principle单一职责原则,也就是说一个接口类仅仅会因为一个(或一类)业务的变化而变化。一个建议是当一个现有的接口类过度膨胀时,可以考虑对接口类做拆分,拆分原则和SRP一致。

也许会有人问,如果按照这种做法,会不会产生大量的接口类,导致代码逻辑重复?答案是不会,因为在DDD分层架构里,接口类的核心作用仅仅是协议层,每类业务的协议可以是不同的,而真实的业务逻辑会沉淀到应用层。也就是说Interface和Application的关系是多对多的:

image.png

因为业务需求是快速变化的,所以接口层也要跟着快速变化,通过独立的接口层可以避免业务间相互影响,但我们希望相对稳定的是Application层的逻辑。所以我们接下来看一下Application层的一些规范。

Application层

Application层的组成部分

Application层的几个核心类:

  • ApplicationService应用服务:最核心的类,负责业务流程的编排,但本身不负责任何业务逻辑
  • DTO Assembler:负责将内部领域模型转化为可对外的DTO
  • Command、Query、Event对象:作为ApplicationService的入参
  • 返回的DTO:作为ApplicationService的出参

Application层最核心的对象是ApplicationService,它的核心功能是承接“业务流程“。但是在讲ApplicationService的规范之前,必须要先重点的讲几个特殊类型的对象,即Command、Query和Event。

Command、Query、Event对象

从本质上来看,这几种对象都是Value Object,但是从语义上来看有比较大的差异:

  • Command指令:指调用方明确想让系统操作的指令,其预期是对一个系统有影响,也就是写操作。通常来讲指令需要有一个明确的返回值(如同步的操作结果,或异步的指令已经被接受)。
  • Query查询:指调用方明确想查询的东西,包括查询参数、过滤、分页等条件,其预期是对一个系统的数据完全不影响的,也就是只读操作。
  • Event事件:指一件已经发生过的既有事实,需要系统根据这个事实作出改变或者响应的,通常事件处理都会有一定的写操作。事件处理器不会有返回值。这里需要注意一下的是,Application层的Event概念和Domain层的DomainEvent是类似的概念,但不一定是同一回事,这里的Event更多是外部一种通知机制而已。

简单总结下:

Command Query Event
语意 ”希望“能触发的操作 各种条件的查询 已经发生过的事情
读/写 写 只读 通常是写
返回值 DTO 或 Boolean DTO 或 Collection Void

为什么要用CQE对象?

通常在很多代码里,能看到接口上有多个参数,比如上文中的案例:

1
scss复制代码Result<OrderDO> checkout(Long itemId, Integer quantity);

如果需要在接口上增加参数,考虑到向前兼容,则需要增加一个方法:

1
2
sql复制代码Result<OrderDO> checkout(Long itemId, Integer quantity);
Result<OrderDO> checkout(Long itemId, Integer quantity, Integer channel);

或者常见的查询方法,由于条件的不同导致多个方法:

1
2
3
scss复制代码List<OrderDO> queryByItemId(Long itemId);
List<OrderDO> queryBySellerId(Long sellerId);
List<OrderDO> queryBySellerIdWithPage(Long sellerId, int currentPage, int pageSize);

可以看出来,传统的接口写法有几个问题:

  1. 接口膨胀:一个查询条件一个方法
  2. 难以扩展:每新增一个参数都有可能需要调用方升级
  3. 难以测试:接口一多,职责随之变得繁杂,业务场景各异,测试用例难以维护
    但是另外一个最重要的问题是:这种类型的参数罗列,本身没有任何业务上的”语意“,只是一堆参数而已,无法明确的表达出来意图。

CQE的规范:

所以在Application层的接口里,强力建议的一个规范是:

规范:ApplicationService的接口入参只能是一个Command、Query或Event对象,CQE对象需要能代表当前方法的语意。唯一可以的例外是根据单一ID查询的情况,可以省略掉一个Query对象的创建

按照上面的规范,实现案例是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码public interface CheckoutService {
OrderDTO checkout(@Valid CheckoutCommand cmd);
List<OrderDTO> query(OrderQuery query);
OrderDTO getOrder(Long orderId); // 注意单一ID查询可以不用Query
}

@Data
public class CheckoutCommand {
private Long userId;
private Long itemId;
private Integer quantity;
}

@Data
public class OrderQuery {
private Long sellerId;
private Long itemId;
private int currentPage;
private int pageSize;
}

这个规范的好处是:提升了接口的稳定性、降低低级的重复,并且让接口入参更加语意化。

CQE vs DTO

从上面的代码能看出来,ApplicationService的入参是CQE对象,但是出参却是一个DTO,从代码格式上来看都是简单的POJO对象,那么他们之间有什么区别呢?

  • CQE:CQE对象是ApplicationService的输入,是有明确的”意图“的,所以这个对象必须保证其”正确性“。
  • DTO:DTO对象只是数据容器,只是为了和外部交互,所以本身不包含任何逻辑,只是贫血对象。

但可能最重要的一点:因为CQE是”意图“,所以CQE对象在理论上可以有”无限“个,每个代表不同的意图;但是DTO作为模型数据容器,和模型一一对应,所以是有限的。

CQE的校验

CQE作为ApplicationService的输入,必须保证其正确性,那么这个校验是放在哪里呢?
在最早的代码里,曾经有这样的校验逻辑,当时写在了服务里:

1
2
3
kotlin复制代码if (itemId <= 0 || quantity <= 0 || quantity >= 1000) {
return Result.fail("Invalid Args");
}

这种代码在日常非常常见,但其最大的问题就是大量的非业务代码混杂在业务代码中,很明显的违背了单一职责原则。但因为当时入参仅仅是简单的int,所以这个逻辑只能出现在服务里。现在当入参改为了CQE之后,我们可以利用java标准JSR303或JSR380的Bean Validation来前置这个校验逻辑。

规范:CQE对象的校验应该前置,避免在ApplicationService里做参数的校验。可以通过JSR303/380和Spring Validation来实现

前面的例子可以改造为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
less复制代码@Validated // Spring的注解
public class CheckoutServiceImpl implements CheckoutService {
OrderDTO checkout(@Valid CheckoutCommand cmd) { // 这里@Valid是JSR-303/380的注解
// 如果校验失败会抛异常,在interface层被捕捉
}
}

@Data
public class CheckoutCommand {

@NotNull(message = "用户未登陆")
private Long userId;

@NotNull
@Positive(message = "需要是合法的itemId")
private Long itemId;

@NotNull
@Min(value = 1, message = "最少1件")
@Max(value = 1000, message = "最多不能超过1000件")
private Integer quantity;
}

这种做法的好处是,让ApplicationService更加清爽,同时各种错误信息可以通过Bean Validation的API做各种个性化定制。

避免复用CQE

因为CQE是有“意图”和“语意”的,我们需要尽量避免CQE对象的复用,哪怕所有的参数都一样,只要他们的语意不同,尽量还是要用不同的对象。

规范:针对于不同语意的指令,要避免CQE对象的复用

❌ 反例:一个常见的场景是“Create创建”和“Update更新”,一般来说这两种类型的对象唯一的区别是一个ID,创建没有ID,而更新则有。所以经常能看见有的同学用同一个对象来作为两个方法的入参,唯一区别是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
less复制代码public interface CheckoutService {
OrderDTO checkout(@Valid CheckoutCommand cmd);
OrderDTO updateOrder(@Valid UpdateOrderCommand cmd);
}

@Data
public class UpdateOrderCommand {

@NotNull(message = "用户未登陆")
private Long userId;

@NotNull(message = "必须要有OrderID")
private Long orderId;

@NotNull
@Positive(message = "需要是合法的itemId")
private Long itemId;

@NotNull
@Min(value = 1, message = "最少1件")
@Max(value = 1000, message = "最多不能超过1000件")
private Integer quantity;

}

ApplicationService

ApplicationService负责了业务流程的编排,是将原有业务流水账代码剥离了校验逻辑、领域计算、持久化等逻辑之后剩余的流程,是“胶水层”代码。

参考一个简易的交易流程:

image.png

在这个案例里可以看出来,交易这个领域一共有5个用例:下单、支付成功、支付失败关单、物流信息更新、关闭订单。这5个用例可以用5个Command/Event对象代替,也就是对应了5个方法。

我见过3种ApplicationService的组织形态:

  1. 一个ApplicationService类是一个完整的业务流程,其中每个方法负责处理一个Use Case。这种的好处是可以完整的收敛整个业务逻辑,从接口类即可对业务逻辑有一定的掌握,适合相对简单的业务流程。坏处就是对于复杂的业务流程会导致一个类的方法过多,有可能代码量过大。这种类型的具体案例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
less复制代码public interface CheckoutService {

// 下单
OrderDTO checkout(@Valid CheckoutCommand cmd);

// 支付成功
OrderDTO payReceived(@Valid PaymentReceivedEvent event);

// 支付取消
OrderDTO payCanceled(@Valid PaymentCanceledEvent event);

// 发货
OrderDTO packageSent(@Valid PackageSentEvent event);

// 收货
OrderDTO delivered(@Valid DeliveredEvent event);

// 批量查询
List<OrderDTO> query(OrderQuery query);

// 单个查询
OrderDTO getOrder(Long orderId);
}
  1. 针对于比较复杂的业务流程,可以通过增加独立的CommandHandler、EventHandler来降低一个类中的代码量:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
less复制代码@Component
public class CheckoutCommandHandler implements CommandHandler<CheckoutCommand, OrderDTO> {
@Override
public OrderDTO handle(CheckoutCommand cmd) {
//
}
}

public class CheckoutServiceImpl implements CheckoutService {
@Resource
private CheckoutCommandHandler checkoutCommandHandler;

@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
return checkoutCommandHandler.handle(cmd);
}
}
  1. 比较激进一点,通过CommandBus、EventBus,直接将指令或事件抛给对应的Handler,EventBus比较常见。具体案例代码如下,通过消息队列收到MQ消息后,生成Event,然后由EventBus做路由到对应的Handler:
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
typescript复制代码// Application层
// 在这里框架通常可以根据接口识别到这个负责处理PaymentReceivedEvent
// 也可以通过增加注解识别
@Component
public class PaymentReceivedHandler implements EventHandler<PaymentReceivedEvent> {
@Override
public void process(PaymentReceivedEvent event) {
//
}
}

// Interface层,这个是RocketMQ的Listener
public class OrderMessageListener implements MessageListenerOrderly {

@Resource
private EventBus eventBus;

@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {

PaymentReceivedEvent event = new PaymentReceivedEvent();
eventBus.dispatch(event); // 不需要指定消费者

return ConsumeOrderlyStatus.SUCCESS;
}
}

⚠️ 不建议:这种做法可以实现Interface层和某个具体的ApplicationService或Handler的完全静态解藕,在运行时动态dispatch,做的比较好的框架如AxonFramework。虽然看起来很便利,但是根据我们自己业务的实践和踩坑发现,当代码中的CQE对象越来越多,handler越来越复杂时,运行时的dispatch缺乏了静态代码间的关联关系,导致代码很难读懂,特别是当你需要trace一个复杂调用链路时,因为dispatch是运行时的,很难摸清楚具体调用到的对象。所以我们虽然曾经有过这种尝试,但现在已经不建议这么做了。

Application Service 是业务流程的封装,不处理业务逻辑

虽然之前曾经无数次重复ApplicationService只负责业务流程串联,不负责业务逻辑,但如何判断一段代码到底是业务流程还是逻辑呢?
举个之前的例子,最初的代码重构后:
判断是否业务流程的几个点:

  1. 不要有if/else分支逻辑:也就是说代码的Cyclomatic Complexity(循环复杂度)应该尽量等于1
    通常有分支逻辑的,都代表一些业务判断,应该将逻辑封装到DomainService或者Entity里。但这不代表完全不能有if逻辑,比如,在这段代码里:
    boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
    if (!withholdSuccess) {
    throw new IllegalArgumentException(“Inventory not enough”);
    }
    虽然CC > 1,但是仅仅代表了中断条件,具体的业务逻辑处理并没有受影响。可以把它看作为Precondition。
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
scss复制代码@Service
@Validated
public class CheckoutServiceImpl implements CheckoutService {

private final OrderDtoAssembler orderDtoAssembler = OrderDtoAssembler.INSTANCE;
@Resource
private ItemService itemService;
@Resource
private InventoryService inventoryService;
@Resource
private OrderRepository orderRepository;

@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
ItemDO item = itemService.getItem(cmd.getItemId());
if (item == null) {
throw new IllegalArgumentException("Item not found");
}

boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
if (!withholdSuccess) {
throw new IllegalArgumentException("Inventory not enough");
}

Order order = new Order();
order.setBuyerId(cmd.getUserId());
order.setSellerId(item.getSellerId());
order.setItemId(item.getItemId());
order.setItemTitle(item.getTitle());
order.setItemUnitPrice(item.getPriceInCents());
order.setCount(cmd.getQuantity());

Order savedOrder = orderRepository.save(order);

return orderDtoAssembler.orderToDTO(savedOrder);
}
}
  1. 不要有任何计算:
    在最早的代码里有这个计算:
1
2
3
ini复制代码// 5)领域计算
Long cost = item.getPriceInCents() * quantity;
order.setTotalCost(cost);

通过将这个计算逻辑封装到实体里,避免在ApplicationService里做计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码@Data
public class Order {

private Long itemUnitPrice;
private Integer count;

// 把原来一个在ApplicationService的计算迁移到Entity里
public Long getTotalCost() {
return itemUnitPrice * count;
}
}

order.setItemUnitPrice(item.getPriceInCents());
order.setCount(cmd.getQuantity());
  1. 一些数据的转化可以交给其他对象来做:
    比如DTO Assembler,将对象间转化的逻辑沉淀在单独的类中,降低ApplicationService的复杂度
1
ini复制代码OrderDTO dto = orderDtoAssembler.orderToDTO(savedOrder);

常用的ApplicationService“套路”

我们可以看出来,ApplicationService的代码通常有类似的结构:AppService通常不做任何决策(Precondition除外),仅仅是把所有决策交给DomainService或Entity,把跟外部交互的交给Infrastructure接口,如Repository或防腐层。

一般的“套路”如下:

  • 准备数据:包括从外部服务或持久化源取出相对应的Entity、VO以及外部服务返回的DTO。
  • 执行操作:包括新对象的创建、赋值,以及调用领域对象的方法对其进行操作。需要注意的是这个时候通常都是纯内存操作,非持久化。
  • 持久化:将操作结果持久化,或操作外部系统产生相应的影响,包括发消息等异步操作。

如果涉及到对多个外部系统(包括自身的DB)都有变更的情况,这个时候通常处在“分布式事务”的场景里,无论是用分布式TX、TCC、还是Saga模式,取决于具体场景的设计,在此处暂时略过。

DTO Assembler

一个经常被忽视的问题是 ApplicationService应该返回 Entity 还是 DTO?这里提出一个规范,在DDD分层架构中:

ApplicationService应该永远返回DTO而不是Entity

为什么呢?

  1. 构建领域边界:ApplicationService的入参是CQE对象,出参是DTO,这些基本上都属于简单的POJO,来确保Application层的内外互相不影响。
  2. 降低规则依赖:Entity里面通常会包含业务规则,如果ApplicationService返回Entity,则会导致调用方直接依赖业务规则。如果内部规则变更可能直接影响到外部。
  3. 通过DTO组合降低成本:Entity是有限的,DTO可以是多个Entity、VO的自由组合,一次性封装成复杂DTO,或者有选择的抽取部分参数封装成DTO可以降低对外的成本。

因为我们操作的对象是Entity,但是输出的对象是DTO,这里就需要一个专属类型的对象叫DTO Assembler。DTO Assembler的唯一职责是将一个或多个Entity/VO,转化为DTO。注意:DTO Assembler通常不建议有反操作,也就是不会从DTO到Entity,因为通常一个DTO转化为Entity时是无法保证Entity的准确性的。

通常,Entity转DTO是有成本的,无论是代码量还是运行时的操作。手写转换代码容易出错,为了节省代码量用Reflection会造成极大的性能损耗。所以这里我还是不遗余力的推荐MapStruct这个库。MapStruct通过静态编译时代码生成,通过写接口和配置注解就可以生成对应的代码,且因为生成的代码是直接赋值,其性能损耗基本可以忽略不计。

通过MapStruct,代码即可简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码import org.mapstruct.Mapper;
@Mapper
public interface OrderDtoAssembler {
OrderDtoAssembler INSTANCE = Mappers.getMapper(OrderDtoAssembler.class);
OrderDTO orderToDTO(Order order);
}

public class CheckoutServiceImpl implements CheckoutService {
private final OrderDtoAssembler orderDtoAssembler = OrderDtoAssembler.INSTANCE;

@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
// ...
Order order = new Order();
// ...
Order savedOrder = orderRepository.save(order);
return orderDtoAssembler.orderToDTO(savedOrder);
}
}

结合之前的Data Mapper,DTO、Entity和DataObject之间的关系如下图:

image.png

Result vs Exception

最后,上文曾经提及在Interface层应该返回Result,在Application层应该返回DTO,在这里再次重复提出规范:

Application层只返回DTO,可以直接抛异常,不用统一处理。所有调用到的服务也都可以直接抛异常,除非需要特殊处理,否则不需要刻意捕捉异常

异常的好处是能明确的知道错误的来源,堆栈等,在Interface层统一捕捉异常是为了避免异常堆栈信息泄漏到API之外,但是在Application层,异常机制仍然是信息量最大,代码结构最清晰的方法,避免了Result的一些常见且繁杂的Result.isSuccess判断。所以在Application层、Domain层,以及Infrastructure层,遇到错误直接抛异常是最合理的方法。

简单讲一下Anti-Corruption Layer防腐层

本文仅仅简单描述一下ACL的原理和作用,具体的实施规范可能要等到另外一篇文章。

在ApplicationService中,经常会依赖外部服务,从代码层面对外部系统产生了依赖。比如上文中的:

1
2
ini复制代码ItemDO item = itemService.getItem(cmd.getItemId());
boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());

会发现我们的ApplicationService会强依赖ItemService、InventoryService以及ItemDO这个对象。如果任何一个服务的方法变更,或者ItemDO字段变更,都会有可能影响到ApplicationService的代码。也就是说,我们自己的代码会因为强依赖了外部系统的变化而变更,这个在复杂系统中应该是尽量避免的。那么如何做到对外部系统的隔离呢?需要加入ACL防腐层。

ACL防腐层的简单原理如下:

  • 对于依赖的外部对象,我们抽取出所需要的字段,生成一个内部所需的VO或DTO类
  • 构建一个新的Facade,在Facade中封装调用链路,将外部类转化为内部类
  • 针对外部系统调用,同样的用Facade方法封装外部调用链路

无防腐层的情况:

image.png

有防腐层的情况:

image.png

具体简单实现,假设所有外部依赖都命名为ExternalXXXService:

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
kotlin复制代码// 自定义的内部值类
@Data
public class ItemDTO {
private Long itemId;
private Long sellerId;
private String title;
private Long priceInCents;
}

// 商品Facade接口
public interface ItemFacade {
ItemDTO getItem(Long itemId);
}
// 商品facade实现
@Service
public class ItemFacadeImpl implements ItemFacade {

@Resource
private ExternalItemService externalItemService;

@Override
public ItemDTO getItem(Long itemId) {
ItemDO itemDO = externalItemService.getItem(itemId);
if (itemDO != null) {
ItemDTO dto = new ItemDTO();
dto.setItemId(itemDO.getItemId());
dto.setTitle(itemDO.getTitle());
dto.setPriceInCents(itemDO.getPriceInCents());
dto.setSellerId(itemDO.getSellerId());
return dto;
}
return null;
}
}

// 库存Facade
public interface InventoryFacade {
boolean withhold(Long itemId, Integer quantity);
}
@Service
public class InventoryFacadeImpl implements InventoryFacade {

@Resource
private ExternalInventoryService externalInventoryService;

@Override
public boolean withhold(Long itemId, Integer quantity) {
return externalInventoryService.withhold(itemId, quantity);
}
}

通过ACL改造之后,我们ApplicationService的代码改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
less复制代码@Service
public class CheckoutServiceImpl implements CheckoutService {

@Resource
private ItemFacade itemFacade;
@Resource
private InventoryFacade inventoryFacade;

@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
ItemDTO item = itemFacade.getItem(cmd.getItemId());
if (item == null) {
throw new IllegalArgumentException("Item not found");
}

boolean withholdSuccess = inventoryFacade.withhold(cmd.getItemId(), cmd.getQuantity());
if (!withholdSuccess) {
throw new IllegalArgumentException("Inventory not enough");
}

// ...
}
}

很显然,这么做的好处是ApplicationService的代码已经完全不再直接依赖外部的类和方法,而是依赖了我们自己内部定义的值类和接口。如果未来外部服务有任何的变更,需要修改的是Facade类和数据转化逻辑,而不需要修改ApplicationService的逻辑。

Repository可以认为是一种特殊的ACL,屏蔽了具体数据操作的细节,即使底层数据库结构变更,数据库类型变更,或者加入其他的持久化方式,Repository的接口保持稳定,ApplicationService就能保持不变。

在一些理论框架里ACL Facade也被叫做Gateway,含义是一样的。

Orchestration vs Choreography

在本文最后想聊一下复杂业务流程的设计规范。在复杂的业务流程里,我们通常面临两种模式:Orchestration 和 Choreography。很无奈,这两个英文单词的百度翻译/谷歌翻译,都是“编排”,但实际上这两种模式是完全不一样的设计模式。Orchestration的编排(比如SOA/微服务的服务编排Service Orchestration)是我们通常熟悉的用法,Choreography是最近出现了事件驱动架构EDA才慢慢流行起来。网上可能会有其他的翻译,比如编制、编舞、协作等,但感觉都没有真正的把英文单词的意思表达出来,所以为了避免误解,在下文我尽量还是用英文原词。如果谁有更好的翻译方法欢迎联系我。

模式简介

Orchestration:通常出现在脑海里的是一个交响乐团(Orchestra,注意这两个词的相似性),如下图。交响乐团的核心是一个唯一的指挥家Conductor,在一个交响乐中,所有的音乐家必须听从Conductor的指挥做操作,不可以独自发挥。所以在Orchestration模式中,所有的流程都是由一个节点或服务触发的。我们常见的业务流程代码,包括调用外部服务,就是Orchestration,由我们的服务统一触发。

image.png

Choreography:通常会出现在脑海的场景是一个舞剧(来自于希腊文的舞蹈,Choros),如下图。其中每个不同的舞蹈家都在做自己的事,但是没有一个中心化的指挥。通过协作配合,每个人做好自己的事,整个舞蹈可以展现出一个完整的、和谐的画面。所以在Choreography模式中,每个服务都是独立的个体,可能会响应外部的一些事件,但整个系统是一个整体。

image.png

案例

用一个常见的例子:下单后支付并发货
如果这个案例是Orchestration,则业务逻辑为:下单时从一个预存的账户里扣取资金,并且生成物流单发货,从图上看是这样的:

如果这个案例是Choreography,则业务逻辑为:下单,然后等支付成功事件,然后再发货,类似这样:

模式的区别和选择

虽然看起来这两种模式都能达到一样的业务目的,但是在实际开发中他们有巨大的差异:

从代码依赖关系来看:

  • Orchestration:涉及到一个服务调用到另外的服务,对于调用方来说,是强依赖的服务提供方。
  • Choreography:每一个服务只是做好自己的事,然后通过事件触发其他的服务,服务之间没有直接调用上的依赖。但要注意的是下游还是会依赖上游的代码(比如事件类),所以可以认为是下游对上游有依赖。

从代码灵活性来看:

  • Orchestration:因为服务间的依赖关系是写死的,增加新的业务流程必然需要修改代码。
  • Choreography:因为服务间没有直接调用关系,可以增加或替换服务,而不需要改上游代码。

从调用链路来看:

  • Orchestration:是从一个服务主动调用另一个服务,所以是Command-Driven指令驱动的。
  • Choreography:是每个服务被动的被外部事件触发,所以是Event-Driven事件驱动的。

从业务职责来看:

  • Orchestration:有主动的调用方(比如:下单服务)。无论下游的依赖是谁,主动的调用方都需要为整个业务流程和结果负责。
  • Choreography:没有主动调用方,每个服务只关心自己的触发条件和结果,没有任何一个服务会为整个业务链路负责

总结下来一个比较:

Orchestration Choreography
驱动力 指令驱动Command-Driven 事件驱动Event-Driven
调用依赖 上游强依赖下游 无直接调用依赖 但是有代码依赖 可以认为是下游依赖上游
灵活性 较差 较高
业务职责 上游为业务负责 无全局责任人

另外需要重点明确的:“指令驱动”和“事件驱动”的区别不是“同步”和“异步”。指令可以是同步调用,也可以是异步消息触发(但异步指令不是事件);反过来事件可以是异步消息,但也完全可以是进程内的同步调用。所以指令驱动和事件驱动差异的本质不在于调用方式,而是一件事情是否“已经”发生。

所以在日常业务中当你碰到一个需求时,该如何选择是用Orchestration还是Choreography?

这里给出两个判断方法:

  1. 明确依赖的方向:

image.png

在代码中的依赖是比较明确的:如果你是下游,上游对你无感知,则只能走事件驱动;如果上游必须要对你有感知,则可以走指令驱动。反过来,如果你是上游,需要对下游强依赖,则是指令驱动;如果下游是谁无所谓,则可以走事件驱动。

  1. 找出业务中的“负责人”:

image.png

第二种方法是根据业务场景找出其中的“负责人”。比如,如果业务需要通知卖家,下单系统的单一职责不应该为消息通知负责,但订单管理系统需要根据订单状态的推进主动触发消息,所以是这个功能的负责人。
在一个复杂业务流程里,通常两个模式都要有,但也很容易设计错误。如果出现依赖关系很奇怪,或者代码里调用链路/负责人梳理不清楚的情况,可以尝试转换一下模式,可能会好很多。

哪个模式更好?

很显然,没有最好的模式,只有最合适自己业务场景的模式。

❌ 反例:最近几年比较流行的Event-Driven Architecture(EDA)事件驱动架构,以及Reactive-Programming响应式编程(比如RxJava),虽然有很多创新,但在一定程度上是“当你有把锤子,所有问题都是钉子”的典型案例。他们对一些基于事件的、流处理的问题有奇效,但如果拿这些框架硬套指令驱动的业务,就会感到代码极其“不协调”,认知成本提高。所以在日常选型中,还是要先根据业务场景梳理出来是哪些流程中的部分是Orchestration,哪些是Choreography,然后再选择相对应的框架。

跟DDD分层架构的关系

最后,讲了这么多O vs C,跟DDD有啥关系?很简单:

  • O&C其实是Interface层的关注点,Orchestration = 对外的API,而Choreography = 消息或事件。当你决策了O还是C之后,需要在interface层承接这些“驱动力”。
  • 无论O&C如何设计,Application层都“无感知”,因为ApplicationService天生就可以处理Command、Query和Event,至于这些对象怎么来,是Interface层的决策。

所以,虽然Orchestration 和 Choreography是两种完全不同的业务设计模式,但最终落到Application层的代码应该是一致的,这也是为什么Application层是“用例”而不是“接口”,是相对稳定的存在。

总结

只要是做业务的,一定会需要写业务流程和服务编排,但不代表这种代码一定质量差。通过DDD的分层架构里的Interface层和Application层的合理拆分,代码可以变得优雅、灵活,能更快的响应业务但同时又能更好的沉淀。本文主要介绍了一些代码的设计规范,帮助大家掌握一定的技巧。

Interface层:

  • 职责:主要负责承接网络协议的转化、Session管理等
  • 接口数量:避免所谓的统一API,不必人为限制接口类的数量,每个/每类业务对应一套接口即可,接口参数应该符合业务需求,避免大而全的入参
  • 接口出参:统一返回Result
  • 异常处理:应该捕捉所有异常,避免异常信息的泄漏。可以通过AOP统一处理,避免代码里有大量重复代码。

Application层:

  • 入参:具像化Command、Query、Event对象作为ApplicationService的入参,唯一可以的例外是单ID查询的场景。
  • CQE的语意化:CQE对象有语意,不同用例之间语意不同,即使参数一样也要避免复用。
  • 入参校验:基础校验通过Bean Validation api解决。Spring Validation自带Validation的AOP,也可以自己写AOP。
  • 出参:统一返回DTO,而不是Entity或DO。
  • DTO转化:用DTO Assembler负责Entity/VO到DTO的转化。
  • 异常处理:不统一捕捉异常,可以随意抛异常。

部分Infra层:

  • 用ACL防腐层将外部依赖转化为内部代码,隔离外部的影响

业务流程设计模式:

  • 没有最好的模式,取决于业务场景、依赖关系、以及是否有业务“负责人”。避免拿着锤子找钉子。

前瞻预告

  • CQRS是Application层的一种设计模式,是基于Command和Query分离的一种设计理念,从最简单的对象分离,到目前最复杂的Event-Sourcing。这个topic有很多需要深入的点,也经常可以被用到,特别是结合复杂的Aggregate。后面单独会拉出来讲,标题暂定为《CQRS的7层境界》
  • 在当今复杂的微服务开发环境下,依赖外部团队开发的服务是不可避免的,但强耦合带来的成本(无论是变更、代码依赖、甚至Maven Jar包间接依赖)是一个复杂系统长期不可忽视的点。ACL防腐层是一种隔离理念,将外部耦合去除,让内部代码更加纯粹。ACL防腐层可以有很多种,Repository是一种特殊的面相数据持久化的ACL,K8S-sidecar-istio 可以说是一种网络层的ACL,但在Java/Spring里可以有比Istio更高效、更通用的方法,待后文介绍。
  • 当你开始用起来DDD时,会发现很多代码模式都非常类似,比如主子订单就是总分模式、类目体系的CPV模式也可以用到一些活动上,ECS模式可以在互动业务上发挥作用等等。后面会尝试总结出一些通用的领域设计模式,他们的设计思路、可以解决的问题类型、以及实践落地的方法。

欢迎联系,持续求简历

欢迎看到这里的同学给我提任何关于DDD的问题,我会尽可能的回答。文章中的代码案例会稍后申请发布到github上,供大家参考。我的邮箱:guangmiao.lgm@alibaba-inc.com,也可以加我的钉钉号:luangm(殷浩)

同时,我们团队也在持续招聘。我团队负责淘系的行业和导购业务,包括天猫和淘宝的四大行业(服饰、快消、消电、家装)以及淘宝的几个大横向业务(企业服务、全球购、有好货等)的日常业务需求和创新业务(3D/AR、360全景视频、搭配、定制、尺码导购、SPU导购等)、前台场(iFashion、全球购、有好货等),以及一些复杂的金融、交易、履约链路(IP撮合、金融服务、交易定制、分销、CPS分佣、服务供应链对接等),总DAU(日均访问用户数)大概3000W左右。我们团队对接了大量的业务形态,从前台导购到后台履约,有极其丰富的应用场景。新的财年我们希望能深入行业,挖掘新的商业模式和履约链路,覆盖一些传统B2C模式无法覆盖到的商业模式,帮助商家在新的赛道成长。欢迎感兴趣的同学加盟。

作者|殷浩

编辑|橙子君

出品|阿里巴巴新零售淘系技术

本文转载自: 掘金

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

JDK 8安装配置教程 小册免费学

发表于 2021-04-20

大家好!之前介绍了些关于MySQL的知识,现在笔者开始进阶Spring Framework的学习啦。整个框架基于Java语言进行开发,因而语言工具包的安装至关重要,由于学习以及开发的大部分过程使用的都是JDK8,如果你还没安装,那么就和我一起火速安装配置,开启学习之旅吧!

JDK、JRE、JVM傻傻分不清?

JVM是Java虚拟机,Java的一大特征“一次编译,到处运行”的特征就是通过JVM来实现的,当.java文件编译成.class文件后,由不同平台上的JVM进行加载运行,使得同一个程序在各平台的运行结果相同。

而JRE顾名思义是java运行时环境,包含了java虚拟机,java基础类库。JDK则是在JRE的基础上添加了更多的工具包,不仅仅是类库,还包含了很多java程序调试和分析的工具:jconsole,jvisualvm等工具软件。具体关系如下图所示:

JDK-JRE-JVM关系图.png

搞清楚这个关系后,因为我们开发的过程中,没法保证编写的内容一定没有问题,所以软件工程师们都装的是JDK,目前JDK8使用最为广泛,因而这篇主要是带大家安装配置JDK8.

下载安装文件

安装网址在这,注意Windows x86是32位,x64是64位的,请根据自己的电脑配置选择相应的安装包。

JDK8安装包.png
这时候需要登录,没有账号可以注册一个,当然如果你不想的话,请百度云自取。

链接:pan.baidu.com/s/154vrncZo…

提取码:z7ld

此处提供了Windows10 x64系统下的 JDK8 和 JDK11, 请根据自己需求自取。

安装

下载好安装包后,可以看到是.exe文件,所以直接双击开始安装。一直点击下一步,当然如果你需要自定义安装路径,点击更改,选择你想要保存的路径即可。

自定义安装路径.png

配置系统环境

安装完后就需要配置系统环境了,右键点击此电脑,点击属性,剩下的操作如下图所示:

打开环境变量界面.png

1、新建环境变量JAVA_HOME

定义变量名JAVA_HOME,路径即为自己安装时自定义的安装路径。
JAVA_HOME.png

2、新建环境变量CLASSPATH

定义变量名CLASSPATH,变量值填入 (直接复制粘贴即可)。

.;%JAVA_HOME%\lib\dt.jar;%JAVA_HOME%\lib\tools.jar。
CLASSPATH.png

3、配置环境变量Path

点击创建,并填入下述内容,随后一直点击确认即可。

%JAVA_HOME%\bin

%JAVA_HOME%\jre\bin
Path配置.png

验证

Win+R键弹出运行窗口,输入cmd命令回车打开如下窗口,测试下自己的Java是否安装成功;输入命令java -version,若出现下图信息的话就证明JDK8环境已经配置成功了。

验证安装是否成功.png

JDK11的安装方式基本如上所示,大家可以根据自己的需求进行适当变更!

本文正在参与「掘金小册免费学啦!」活动, 点击查看活动详情

本文转载自: 掘金

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

低延时、高音质语音通话背后的音频技术解析——编解码原理

发表于 2021-04-20

语音社交已经出现了数十年,前不久的“互动播客”场景让音频互动再次成为业界焦点。但我们想聊的不是这个场景,而是底层那套汇聚了工程师们数十年经验的实时音频互动技术。

从软件算法到传输架构,有哪些因素会影响你的通话音质?为什么说不是延时越低越好?机器学习、大数据如何帮助优化传输、音质?我们将从今天开始通过四篇音频技术系列内容,从多个层面深入浅出逐一解答这些问题,并分享声网的实践经验。

我们曾在《详解低延时》中讲过这样一个音视频数据传输的流程,如下图。在这条端到端的链路上的每个环节,都存在影响延时、音质的技术难点。

音视频端到端传输流程

接下来四篇内容会从编解码、降噪与回声消除算法、网络传输、音质优化几方面,由浅入深地讲讲高音质、低延时背后的技术原理与“改造”思路。

本篇先来讲一下语音编解码器。不过在讲语音编解码器之前,我们需要先了解音频编解码的原理,才能更快地理解到底是什么在影响着音质体验。

语音编码与音乐编码

这里先给一些不了解编解码原理、作用的工程师,简短介绍下。

音频编码指的是把音频信号转化为数字码流的过程(如下图所示)。在这个过程中,音频信号会被分析从而产生特定参数。随后,这些参数会按照一定规则写入比特流。这个比特流也就是我们常说的码流。解码端接收到码流后,会按照约定好的规则将码流还原为参数,再使用这些参数构建出音频信号。

音频编解码原理

音频编解码器的发展历史非常悠久,早期编解码器的核心算法是非线性量化,这是一种现在看来比较简单的算法,其压缩效率并不算高,但适用于包括语音和音乐在内的绝大多数音频类型。之后,随着技术的发展和编解码分工的细化,编解码器的演进方向分成了两条路——语音编码器和音乐编码器。

主要用来编码语音信号的语音编解码器,开始逐渐向基于时域线性预测框架的方向演化。这种编解码器参考了声道的发音特性,将语音信号分解为主要的线性预测系数和次要的残差信号。线性预测系数编码所需的比特率非常少,却能高效的构建出语音信号的“骨骼”(可以想象为能听出这段语音大致在说话但听不出是谁在说);残差信号则像是“血肉”,能够补充出语音信号的细节(有了血肉,则可以想象为你可以听出这段语音是谁在说话了)。这种设计大幅提升了语音信号的压缩效率,但是这种基于时域的线性预测框架在有限复杂度下无法很好的编码音乐信号。

而针对音乐信号进行编码的音乐编解码器则走上了另一条演化的道路。因为相比时域信号,频域信号的信息更多的集中在少部分频点上,更利于编码器对其进行分析和压缩。所以音乐编解码器基本都会选择对在频域上对信号进行编码。

后来,随着技术日趋成熟,两种编解码架构又再次走到了一起,即语音音乐混合编码器,WebRTC 中默认使用的编解码器 Opus 就是这类编解码器。这类编解码器的特点是融合了两种编码框架,并针对信号类型自动切换合适的编码框架。一些国内外知名的产品都会用到 Opus ,比如 Discord。

语音编码中,什么在影响互动体验?

要评价音质,那么我们就需要先知道编解码器的一些技术指标。技术指标一般有采样率、码率、复杂度、抗丢包能力等,那这些技术指标分别代表什么,对音频体验的影响又是怎样的呢?

你可能看到过“采样率越高,音质越好”、“编码复杂度越高越好”的说法,但放在实时互动的场景下事实并非如此!

一、采样率

从人耳可以听到的模拟信号,转化到计算机可以处理的数字信号,需要一个采样的过程。声音可以被分解为不同频率不同强度正弦波的叠加。采样可以想象成在声波上采集了一个点。而采样率指的就是在这个过程中每秒采样的点数,采样率越高,表示在这个转化过程损失的信息越少,也就是越接近原声。

高、低采样率的差别示意图

采样率决定了音频信号的分辨率。在人耳可感知范围内,采样率越高,高频分量就被保留的越多,这段信号的听感就越清晰明亮。举个例子,我们打传统电话时,往往会感觉对方的声音比较沉闷,这是因为传统电话的采样率是 8kHz,只保留了能保证可懂度的低频信息,很多高频的分量被丢失了。所以想要音频互动体验越好,就需要在人耳可感知范围内尽量提高采样率。

二、码率

经过采样,声音从模拟信号转化为数字信号。码率表示的就是这个数字信号在单位时间内的数据量。

码率决定了音频信号经过编解码后的细节还原度。编解码器会把给定的码率按优先级分配给各个分析模块输出的参数。在编码码率有限的情况下,编解码器会优先保证对语音质量影响较大的参数进行编码,而放弃编码一些影响较小的参数。这样在解码端,因为使用的参数并不完整,所以其构建出的语音信号也会有难以避免的损伤。一般来说,同一款编解码器的码率越高,其编解码后的损伤就越小。但码率并不是越高越好,一方面,码率和编解码质量并不是线性关系,在超过“质量甜点”后,码率升高对质量的提升开始变得不明显;另一方面,在实时互动中,码率过高可能挤占带宽产生网络拥塞,从而引发丢包,反过来破坏了用户体验。

质量甜点:在视频领域,质量甜点指的是在既定的码率和屏幕大小下通过设定合理的分辨率和帖速率来得到最佳视频主观质量体验。在音频领域也有类似的情况。

三、编码复杂度

编码复杂度一般集中在编码端信号分析模块。一般来说,对语音信号分析的越详尽,其潜在压缩率可能就越高,所以编码效率和复杂度有一定相关性。同样的,编码复杂度和编解码质量亦不是线性关系,两者之间也存在一个“质量甜点”,能否在有限复杂度的前提下设计出高质量的编解码算法往往直接影响了编解码器的可用性。

四、抗丢包能力

首先,抗丢包的原理是什么?我们在传输音频数据的时候会遇到丢包,如果当前数据包丢失了,我们希望可以通过某种手段来猜出来或者得到当前帧大概的信息,然后利用这些不完全准确的信息,解码出一个和原始信号相近的语音帧。当然,只靠凭空猜一般是没什么好结果的,如果前一个数据包或者后一个数据包能告诉解码器当前丢失包的一些关键信息就好了,这个信息越多,越有利于解码端恢复出丢失的语音帧。这些包含在“前一个数据包”或“后一个数据包”中的“关键信息”,也就是我们之后要提到的“帧间冗余信息”。(往期我们讲过丢包对抗的更多知识)

所以,抗丢包能力和编码效率是相对互斥的,编码效率的提升往往需要尽量减少帧间的信息冗余,而抗丢包能力又依赖一定的帧间信息冗余,帧间信息冗余可以保证在当前数据包丢失时,通过前/后序语音帧恢复出当前语音帧。在实时互动场景下,因为用户的网络是非可靠网络,可能一个用户走着走着就进了电梯,或坐在高速行驶的车上。在这种网络里,充斥着丢包与延时抖动,所以编解码抗丢包能力又是不可或缺的。因此,如何平衡编码效率和抗丢包能力,也需要经过详尽的算法设计和打磨验证。

如何平衡音频体验与技术指标?

那声网是怎么做的呢?我们的工程师综合考虑了上述几点,打造了一款专为实时通讯使用的高清语音编解码器 Agora Nova(以下简称 Nova)。

32kHz 采样率

首先在采样率上的选择,Nova 并没有选择其他语音编解码器使用的8khz采样率或16khz采样率,而是选择了更高的 32kHz 采样率。这意味着 Nova 首先在通话音质的起跑线上就取得了较大的领先优势。虽然业内常用的 16kHz 采样率(备注:微信用的就是 16kHz)已经满足了语音可懂度的基本需求,但部分语音细节仍然需要更高的采样率才能捕捉到,我们希望提供更高清的语音通话能力,即既保证了可懂度,又提升了清晰度,这也是我们选择 32kHz 的原因。

优化编码复杂度

采样率越高,语音清晰度就越高,同时意味着单位时间内需要分析/编码/传输的采样点就越多,编码码率和复杂度都需要相应地增加。编码码率和复杂度的增加势必会给用户的带宽和设备性能功耗带来压力。但这不是我们想看到的。为此,我们经过理论推导和大量实验验证,设计了一套精简的语音高频分量编码系统,在分析复杂度增加很小前提下,最低使用 0.8kbps 即可实现高频信号的编码(基于不同技术,以往要表达高频信号,码率一般需要高于1~2kbps),极大增加了语音信号的清晰度。

平衡抗丢包性能与编码效率

在抗丢包能力的保障上,我们也在保证编码效率的前提下选择了最平衡的方案,经过实验验证,这种方案即又保证了编码压缩效率,又保证了丢包时的恢复率。此外,除了 Nova,针对不稳定的网络环境,我们还研发并开源了抗丢包能力更强的语音编解码器Agora Solo 和语音音乐混合编解码器 SoloX 等。

Agora Nova vs. Opus

Nova 有着丰富的模式选择以供不同场景选择,诸如可适应模式、高品质模式、低能耗高品质模式、超高频模式和超低比特率模式等。

如果把 Nova 和先进的开源编解码器 Opus 做对比,得益于 Nova 高效的信号处理算法,其在通用语音编码码率下,有效频谱信息要比同等码率下的 Opus 多 30%。在主、客观评价体系下,Nova 的语音编码质量高于 Opus:

  • 客观评价层面,使用 ITU-T P.863 标准定义的客观质量评估算法对两个编解码器的编码-解码语料进行打分,Nova 得分始终比 Opus 略高一筹;
  • 主观评价层面,经过 Nova 编解码的语音信号的还原度要高于经过 Opus 编解码的语音信号,反映在听感上就是更通透,量化噪音更小。

得益于这款高清的语音编解码器,声网Agora SDK 为全球用户提供了一致的高质量音频互动体验。其实一段语音通话体验的好坏,除了直接与编解码器的编码质量关联,也会极大地受到其他模块的影响,比如回声消除、降噪、网络传输等,我们将在下一篇介绍声网在回声消除与降噪算法方面的最佳实践。

想了解更多声网技术,🔎欢迎搜索并关注「声网 Agora 开发者」公众号。

本文转载自: 掘金

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

开源 1 年半 star 破 12 万的 Dapr 是如何

发表于 2021-04-20

头图.png

作者 | 敖小剑
来源 | 阿里巴巴云原生公众号

Dapr 是 2019 年 10 月微软开源的可移植、事件驱动分布式运行时,它使开发人员能够轻松地构建运行在云平台和边缘的弹性而微服务化的无状态和有状态的应用程序,从而降低基于微服务架构构建现代云原生应用的准入门槛。

在今年 2 月份刚刚发布了 v1.0 正式版本。虽然推出至今不过一年半时间,但 Dapr 发展势头十分迅猛,目前已经在 GitHub 上收获了 1.2w 星。阿里是 Dapr 开源项目的深度参与者和早期采用者,率先进行了生产落地,集团内部有十几个应用在使用 Dapr;目前已有 2 位 Dapr成员,是 Dapr 项目中除微软之外代码贡献最多的公司。

拉到文末可以了解 Dapr 入门教程体验方式

为什么阿里会选择Dapr?

在阿里巴巴,Java 使用非常广泛,不仅仅业务应用大量使用 Java,大量中间件和基础能力的服务器端也是使用 Java 开发。在过去十几年间,我们围绕 Java 建立了非常完备的生态体系,经历过各种严酷的考验。

而随着业务形态的日渐丰富,多语言的需求在不断的增加,如 nodejs / golang / c / c++ / rust 等。特别是在微服务流行之后,根据实际情况而选择使用不同的编程语言开发微服务成为趋势。但效仿 Java ,为每一种编程语言都打造一套功能完备的生态体系在成本上是不现实的。因此,需要一个成本可控的方案来解决多语言问题,让微服务开发能真正的实现“语言自由”。

随着云的采用,业务应用的形态也开始朝云原生方向发展,越来越多的业务应用(尤其是前台业务)开始拥抱 FaaS 和 Serverless 作为应用托管和资源调度的解决方案。而在 FaaS 和 Serverless 场景下,需要更轻量化的解决方案以满足快速启动和伸缩的需求 —— 传统类库模式下由于需要集成大量的 SDK,业务应用变得非常的臃肿。而在 Function 形态下更加的不协调,以 nodejs 为例:几百行的 nodejs Function 代码依然需要依赖多达几十兆的 node module。同时 FaaS 和 Serverless 也对多语言的支持提供了更高的要求。因此,在 FaaS 和 Serverless 这种新型形态下有必要提供有别于传统类库方式的、更轻量化的、支持多语言的解决方案。

显然,Servicemesh 倡导的 Sidecar 模式是解决上述问题的绝佳方案。在过去几年间,随着 Servicemesh 的发展和采用, Sidecar 模式已经得到充分验证:Sidecar 模式非常符合云原生的理念,特别是在多语言支持和应用轻量化方面具备天然优势。

我们非常认可 Bilgin Ibryam 在“Multi-Runtime Microservices Architecture” 一文中提出的 Multiple Runtime / Mecha Runtime 的理念,尤其是他对分布式应用需求的分析,很符合我们的实际情况:

1.png

而 Dapr 是第一个实践 Multiple Runtime 理念的开源项目,我们从这个项目发布开始就密切关注它,因为 Dapr 可以很好的解决我们面临的问题:Sidecar 模式天然提供了对多语言的支持,各种客户端 SDK 被 Dapr Runtime 替代之后应用也得以轻量化。

此外,从长期战略的角度考虑,我们在 2020 年提出了”三位一体”的理念,即将“自研技术”、“开源项目”、“商业产品”形成统一的技术体系,最大化技术的价值。而当前的实际情况是三者有完全不同的产品和技术方案,导致当我们需要将某个产品在阿里内部、公有云、客户私有云等不同的平台上进行迁移时,或者是跨多个平台部署时,就会遇到非常大的挑战。Dapr 面向能力编程的理念,强调可移植性和可扩展性的标准 API,平台中立、无供应商锁定的设计,深深的吸引了我们。

“在阿里云,我们相信 Dapr 将引领微服务的发展。通过采用 Dapr,我们的客户现在可以以更快的速度来构建可移植和健壮的分布式系统。”

—— 阿里云资深技术专家 李响

在 2020 年年中,我们开始基于 Dapr 项目进行了内部小规模的试点,在实际的落地过程中探索和验证 Dapr 的理念。我们也积极参与到 Dapr 开源项目的建设中,提交了大量的改进建议和代码。

下面我们将以 Dapr 在阿里的实际落地场景来具体说明 Dapr 是如何帮助我们解决上述问题的。

Dapr 在阿里的实践

  1. 概况

目前 Dapr 在阿里巴巴内部还处于实验阶段。

我们的首要工作是为内部的中间件开发 Dapr 组件,使业务应用程序可以与这些中间件和实现它们的 Java 语言/ Java Client SDK 解耦。然后通过小规模的业务应用落地,在各种场景下的对 Dapr 进行验证,在验证完成之后计划继续部署较大规模的业务应用。

截止到 2021 年 3 月,Dapr 在阿里内部落地的场景主要集中在 2 个方面:多语言支持和云间迁移。

  1. 多语言支持

1)Faas / Serverless 场景

背景:在阿里的电商系统中,存在大量活动和导购需求。

这些需求的特点是”短平快”:需要快速开发、快速迭代、生命周期相对比较短。因此这类需求非常适合通过采用 FaaS 的方式来落地。

Faas 对多语言支持有强烈的诉求,肯定不会局限于 Java。而阿里内部大部分应用都是 Java 体系,对多语言的支持比较弱,尤其是新兴语言(如 Dart)或者小众语言(如 Rust)。

而从需求上说,采用 FaaS 的应用也同样需要和内部运行的服务以及各种中间件/基础设施进行通讯,因此 FaaS 平台迫切的需要解决多语言支持问题。

通过 Dapr ,我们很好的解决了 FaaS 的多语言问题,从而使得客户通过 FaaS 实现了开发效率的大幅提升。

2)多语言应用的接入

背景:阿里收购有大量的公司。

这些收购的公司有大量的应用,而这些应用中很多不是 Java 体系,在接入阿里的技术体系时,对多语言支持有明确的需求。

另外,由于业务创新的需要,有些应用对 nodejs 和 golang 有强烈诉求,还有一些应用则需要使用到 Dart 和 C++。

但目前这些语言的生态系统并没有像 Java 那么完善,尤其部分中间件和基础设施已经发展的非常成熟,进入维护状态,不太可能在现在重新开发所有语言的客户端:成本上代价很高,时间上也来不及。

通过 Dapr ,我们可以为这些应用提供多语言解决方案。

3)复杂的 Java 遗留系统

背景:基于 Java ClassLoader 机制而设计的复杂系统。

为了解决类冲突问题,隔绝不同的业务模块,阿里针对 Java 系统设计了基于 ClassLoader 机制的复杂系统,这些系统的设计往往非常复杂,应用也非常臃肿。

此外,部分业务团队为了能和现有的中间件进行互通,自行维护了一套多语言的中间件 SDK,而这些 SDK 本来应该由中间件团队维护并保持同步更新。这也带来了稳定性方面的隐患和风险。

我们期望将这些遗留的系统迁移到 Dapr 中,统一实现中间件 SDK 的维护和更新。比较特殊的是这里存在一个需求:最好能让业务开发团队尽量不做代码层面的调整,以减少迁移时对业务应用的冲击。

所以针对 Java 遗留系统,在迁往 Dapr 时,我们额外设计了一个 Java 适配层:将原来的 Java 调用适配到 Dapr 的客户端 API 上。

以上三种多语言的落地实践场景,如下图所示:

2.png

  1. 云间迁移

背景:业务应用对外输出时有跨平台需求。

阿里的部分业务,如钉钉文档,原本是提供给阿里内部和外部用户直接使用的,此时钉钉文档只需要部署在阿里内部的业务集群里,直接访问阿里内部的生态体系。

但是随着 SaaS 业务的发展,以及部分信息安全敏感的用户对于数据安全的强烈诉求, 需要将钉钉文档部署到用户 VPC 下或者公有云下。

为此,我们需要将钉钉文档的系统从阿里内部迁移到公有云上进行部署,而钉钉文档使用的底层技术需要从阿里内部的技术体系迁移到使用开源技术或阿里云的商业化产品上。

借助 Dapr 的标准 API 和可扩展的组建模型,我们采取的策略是让用户不需要修改任何代码,直接通过 Dapr Runtime 屏蔽底层使用的中间件:部署在不同平台时,通过激活 Dapr 中的不同的 Component 来提供一致的能力。

以消息通讯威力,当应用需要访问消息系统时:

  • 在阿里内部:通过 Rocketmq.yaml 激活 Rocketmq 组件。
  • 在公有云上:通过 Kafka.yaml 激活 kafka 组件。

通过 Dapr 的可移植性,上层的钉钉文档应用现在可以和底层的基础设施(如消息系统)解耦,从而实现在不同的云平台之间平滑迁移:

3.png

最终帮助我们的业务团队实现了他们的业务目标:使 Dingtalk 在任何地方部署成为可能。

阿里的 Dapr 未来规划

未来我们将继续通过应用试点的方式对 Dapr 进行验证,包括:

  • 适用场景
  • 性能
  • 稳定性
  • 可移植性

同时我们将继续开发 Dapr 的组件,以集成更多的中间件和基础设施,包括内部产品和阿里云上支持的商业产品。其中对阿里云商业产品的集成代码,我们将在验证通过之后贡献给 Dapr 项目,从而为 Dapr 提供阿里云支持。这些项目预计将包括:

  • Apache Dubbo 的 RPC 支持
  • Apache RocketMQ 的消息传递支持
  • Nacos 的动态配置支持
  • 阿里云 RDS 的 MySQL 支持
  • 阿里云缓存服务的 Redis 支持

作为 Multiple Runtime 架构的先驱者和 Dapr 项目的早期采用者,我们将继续和 Dapr 社区合作,在落地的过程中努力完善 Dapr 的功能、性能、稳定性等关键指标,和社区一起联手打造云原生时代的DistributedAPplicationRuntime!

4.png

本文转载自: 掘金

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

微软称强制更换密码是 "古老而过时的" 微软称强制更换密码是

发表于 2021-04-20

微软称强制更换密码是 “古老而过时的”

微软终于抓住了安全专家们多年来几乎普遍接受的一句格言:定期更改密码可能弊大于利。

在上个月末发表的一篇基本被忽略的帖子中,微软表示,它正在从其推荐给客户和审计人员的安全基线设置中删除定期更改密码。在微软建议定期更换密码数十年后,微软员工Aaron Margosis表示,这一要求是一种 “古老而过时的缓解措施,价值很低”。

心态的改变主要是研究的结果,研究表明,当密码对终端用户来说很容易记住时,例如当他们使用最喜欢的电影或书籍中的名字或短语时,密码最容易被破解。在过去的十年里,黑客们挖掘了现实世界中的密码漏洞,组装了数百万字的字典。结合超快的显卡,黑客可以在离线攻击中进行大量的猜测,当他们窃取代表明文用户密码的加密加扰哈希时,就会发生这种攻击。

即使用户试图混淆他们易于记忆的密码–比如在单词中添加字母或符号,或者用0代替o或1代替l–黑客也可以使用编程规则来修改字典条目。因此,这些措施对现代破解技术的保护作用不大。

研究人员越来越多地达成共识,即最好的密码至少有11个字符长,随机生成,并由大小写字母、符号(如%、*或>)和数字组成。这些特征使它们对大多数人来说特别难以记住。同样的研究人员也警告说,强制要求每30天、60天或90天–或任何其他时间段–更改密码,会因为一系列原因而造成危害。其中最主要的原因是,这些要求鼓励终端用户选择比他们更弱的密码。原本是 “P@$$w0rd1 “的密码变成了 “P@$$w0rd2“,以此类推。同时,强制更改密码在安全方面的好处不大,因为一旦发生真正的违规事件,应立即更改密码,而不是在政策规定的一定时间后更改密码。

尽管研究人员之间的共识越来越多,但微软和其他大多数大型组织一直不愿意公开反对定期更改密码。其中一个明显的例外是在 2016 年,当时联邦贸易委员会的首席技术专家 Lorrie Cranor[呼吁自己的雇主给出的建议](arstechnica.com/information…

在上个月的博客文章中,微软的马戈西斯写道。

毫无疑问,密码安全的状态是有问题的,而且已经有很长一段时间了。当人类自己挑选密码时,往往容易被猜中或预测。当人类被分配或被迫创建难以记忆的密码时,他们往往会把密码写在别人能看到的地方。当人类被迫更改密码时,他们往往会对现有的密码做一些小的、可预测的修改,和/或忘记新密码。当密码或其相应的哈希值被窃取时,最多只能检测或限制其未经授权的使用。

最近的科学研究对许多长期存在的密码安全实践(如密码过期政策)的价值提出了质疑,并指出了更好的替代方案,如强制执行禁止密码列表(一个很好的例子是 Azure AD 密码保护)和多因素验证。虽然我们推荐这些替代方案,但不能用我们推荐的安全配置基线来表达或执行,因为这些基线建立在 Windows 的内置组策略设置上,不能包含客户特定的值。

他补充道。

周期性密码过期只是为了防止密码(或散列)在其有效期内被窃取并被未经授权的实体使用的可能性。如果一个密码永远不会被盗,就没有必要让它过期。而如果你有证据表明密码被盗,你大概会立即采取行动,而不是等待过期来解决问题.如果给定密码很可能被盗,那么继续允许小偷使用该被盗密码的时间长度是多少天?Windows的默认值是42天。这看起来是不是太长了?嗯,确实如此,但我们目前的基准是60天–以前是90天–因为强制频繁过期会带来自己的问题。如果不考虑到密码会被盗用,那么你就会获得这些问题,而没有任何好处。此外,如果你的用户是那种愿意在停车场回答调查问卷,用糖果棒换取密码的人,那么没有密码过期政策会对你有所帮助。

Margosis很清楚,这些变化丝毫不影响推荐的最小密码长度、历史或复杂度。而且,他还指出,微软继续敦促人们使用多因素认证。

他对微软安全基线设置的变化不会改变Windows服务器版本中包含的默认值,Margosis说继续是42天,甚至少于旧基线设置中建议的60天。不过,基线的变化还是有可能给员工在自己的组织内部倡导变革时提供弹药。密码安全专家、Terahash的创始人兼CEO Jeremi Gosney表示,这也很可能帮助公司反击审计人员,因为审计人员经常会发现公司不符合规定,除非他们在规定的时间内颁布了密码变更。

“微软正式加入到反对强制修改密码的斗争中来,”Gosney说,”这将给企业提供更多的筹码来对抗Big Compliance。”

本篇文章的副标题已被更改。之前的内容是 “逆势而行 公司不再建议企业强制定期更改密码” “

本文翻译自:Microsoft says mandatory password changing is “ancient and obsolete”

作者:DAN GOODIN - 6/4/2019, 5:08 AM

本文转载自: 掘金

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

对Golang一知半解?这里是上百篇掘金Golang优质文章

发表于 2021-04-20

一、Go语言的起源、发展以及普及

  1. 学习Go语言

译 Golang 介绍和环境安装 | Golang 教程系列| Go 主题月](juejin.cn/post/694249…)

1.1 Go语言起源

1.2 Go语言的主要特征与发展的环境

1.3 Go语言常犯错误汇总

1.4 Golang书籍推荐

  • 1.4.1 Go入门指南 比较适合新手,内容相对基础一些
  • 1.4.2 Go语言圣经 书如其名
  • 1.4.3 Go语言中文网 找对圈子,学的更快
  • 1.4.4 菜鸟教程 这个网站非常适合快速上手某门语言
  • 1.4.5 Go语言高级编程 内容适合进阶
  • 1.4.6 go语言原本 欧神出品,虽然号称进度只有9.9%/100%,但不妨碍它的优秀,值得一看
  • 1.4.7 golang设计模式 设计模式 Golang实现,《研磨设计模式》的golang实现
  • 1.4.8 Go实战开发 作者是著名的 Go 开源项目 beego 的作者,他的最佳实践非常值得阅读
  • 1.4.9 Go palyground 不用搭建本地 Go 环境,在线就编写 Go 的代码

1.5 Go语言开源项目

  1. xgen-编写XSD工具基础库,可将XML模式定义为多语言类型或声明的代码
  2. GQLEngine-高性能Go语言的GraphQL服务端落地框架
  3. Orange 一款基于Golang语言的Web开发框架
  4. Go-admin -基于Golang快速搭建可视化数据管理后台的框架
  5. Go-snowflake Go语言实现的snowflake算法,为分布式系统实现唯一ID,单机测试1s可生成20id
  6. KubeVela 一个简单易用且高度可扩展的应用管理平台与核心引擎
  7. TiDB 见识过mysql性能瓶颈之后你会想要选择的一款数据库
  8. EasyMIDI EasyMidi是一个简单可靠的库,用于处理标准Midi文件(SMF)。

1.6 Go语言环境安装

下载地址: www.golangtc.com/download

GOPATH

二、Go工作环境设置

  1. 编辑器

1
2
3
4
5
6
diff复制代码- Jetbrains GoLand  强烈推荐
- Jetbrains IDEA + go插件
- VS Code
- Atom
- liteide
- Sublime Text
  1. 依赖管理

1
2
3
4
5
markdown复制代码- glide
- 安装:<https://glide.sh/>
- 初始化
- 依赖下载
- mod
  1. Go源码发布

三、包、函数、变量、常量、数据类型

  1. 包相关

Go语言json包的使用技巧

1.1 包

拜拜了,GOPATH君!新版本Golang的包管理入门教程

Go的包管理工具(一)

Go 语言标准库 text/template 包深入浅出

Go 语言闭包详解

Go 包管理工具 govendor 使用指南

1.2 第三方包

1.3 导入语句

1.3.1 分组导入语句

1.3.2 多个导入语句

1.4 导出名

Go语法之包、导入包、导出名

  1. 函数相关

Go语言从入门到精通:函数

2.1 函数操作

[译] 解析 Go 中的函数调用

[译] Go 函数调用 Redux

Go中的init函数

Golang中函数传参存在引用传递吗?

Golang 函数式编程简述

从内存分配策略(堆、栈)的角度分析,函数传递指针真的比传值效率高吗?

一篇文章 说清楚 Go语言里的函数

2.2 函数多返回值

通过汇编看golang函数的多返回值 | 🏆 技术专题第二期征文

2.3 函数值

2.3.1 函数的闭包

Go 语言闭包详解

go 学习笔记之仅仅需要一个示例就能讲清楚什么闭包

GO-三个方面理解闭包

Go 语言中的闭包实现

  1. 变量

3.1 变量简介

Golang环境变量设置详解

Go初始化变量的招式

Golang并发之共享内存变量

Golang从零开始:命名规范、变量和常量

【Go学习之路】Go变量

CGO_ENABLED环境变量对Go静态编译机制的影响

golang面试题:reflect(反射包)如何获取字段tag?为什么json包不能导出私有变量的tag?

3.2变量的初始化

3.3短变量声明

GO的短变量声明

3.4 零值

没有明确初始值的变量声明会被赋予他们的零值

编程书说的“Go程序员应该让聚合类型的零值也具有意义”是在讲什么

Golang 零值、空值与空结构

  • 零值是:
    • 数值类型为0
    • 布尔类型为false
    • 字符串为””(空字符串)
  • 零值和空值的关系
  • 零值的空值的区别
  1. 常量

4.1 常量

Golang从零开始(二):命名规范、变量和常量

Golang学习——常量const和iota

golang进阶一:类型比较,常量,nil

4.2 数值常量

  1. 基本类型

5.1 bool

5.2 string

Go之如何截取string字符串?截取英文与中文字符串

Go系列 string、bytes、rune的区别

详解Go regexp包中 ReplaceAllString 的用法

Go之int整数与string字符串相互转换

golang中你不知道的 string

Go 标准库介绍一: strings

golang的fmt包String(),Error(),Format(),GoString()的接口实现

5.3 int int8 int16 int32 int64uint uint8 uint16 uint32 uint64 uintptr

Golang 中 int,int64 和字符串互转(译文)

原来这才是 Go Interface

Golang interface接口深入理解

从goim定制, 浅谈 go interface 解耦合与gRPC

golang面试题:能说说uintptr和unsafe.Pointer的区别吗?

Golang中MulUintptr实现原理

5.4 byte // uint8 的别名

Go系列 string、bytes、rune的区别

Go之[]byte字节数组与string字符串相互转换

Strings、bytes and runes – 就要学习 Go 语言

go中的strings, bytes, runes 和 characters

5.5 rune unicode码点

Go系列 string、bytes、rune的区别

Golang 中 runes 和 字符串互转(译文)

Strings、bytes and runes – 就要学习 Go 语言

Golang 中[]byte, string和[]rune的相互转化的底层原理和剖析

5.6 float32 float64

5.7complex64 complex128

5.8 类型转换

Go基础类型转换

golang中的四种类型转换总结

Golang 中一个 time.Duration 相关类型转换问题

5.9 类型推倒

四、流程控制语句

流程控制语句:for、if、else、switch、defer

  1. 循环语句

1.1 for

1.1.1 for循环

昨天那个在for循环里append元素的同事,今天还在么?

Golang 高并发编程For循环中使用Goroutine最容易犯的错误

Go语言性能优化- For Range 性能研究

[Golang]这几个for-range的坑,你必须要会呀,铁汁

1.1.2 初始化语句

1.1.3 条件表达式

关于变量在 if-else 条件表达式里的作用域范围

1.1.4 循环条件

1.1.5 后置语句

  1. 判断语句

2.1 IF

2.1.1 if 的简短语句

2.1.2 if 和 else

2.2 switch

Go 语言流程控制:switch-case

[译] part 10: golang switch 语句

switch 的求值顺序

  1. 后置调用 - defer

3.1 defer

包含该defer语句的函数执行完毕时,defer后的函数才会被执行 -推迟调用

   在一个函数中执行多条defer语句,它们的执行顺序与声明顺序相反。

3.1.1 原理

推迟的函数调用会被压入一个栈中。当外层函数返回时,被推迟的函数会按照后进先出的顺序调用。

Go 延迟函数 defer 详解

go 学习笔记之咬文嚼字带你弄清楚 defer 延迟函数

五、底层数据结构

  1. 指针

Go 拥有指针。指针保存了值的内存地址。

Golang研学:在用好Golang指针类型

彻底学会 Go 指针 – 就要学习 Go 语言

Golang中range指针数据的坑

Golang 指针:使用方法、特点 和 运算

Go之反射实现类型与指针拷贝

  1. 结构体

一个结构体(struct)就是一组字段(field)。

golang | Go语言入门教程——结构体初始化与继承

15. 理解 Go 语言面向对象编程:结构体与继承

包罗万象的结构体 – 就要学习 Go 语言

Golang自定义结构体转map

2.1 结构体字段

结构体字段使用点号来引用

Golang自定义结构体转map

2.2 结构体指针

结构体字段可以通过结构体指针来访问

2.3 结构体声明

结构体声明可以通过直接列出字段的值来新分配一个结构体。

  1. 数组

类型[n]T表示拥有n个T类型的值的数组。

Go如何对数组切片进行去重

《快学 Go 语言》第 4 课 —— 低调的数组

Go切片与C数组转换

Go之[]byte字节数组与string字符串相互转换juejin.cn/post/684490…

  1. 切片

每个数组的大小都是固定的。而切片则为数组元素提供动态大小的、灵活的视角。在实践中,切片比数组更常用。

深度解析 Go 语言中「切片」的三种特殊状态

(正经版)面试官:切片作为函数参数是传值还是传引用?

Go 切片使用注意事项

如何在Go中使用切片容量和长度

4.1 切片定义

切片就像引用的数组,切片并不直接存储数据,它只是描述了底层数组中的一段。

4.2 切片文法

切片文法类似于没有长度的数组文法。

4.3 切片的默认行为

4.4 切片的长度与容量

4.5 nil切片

连nil切片和空切片一不一样都不清楚?那BAT面试官只好让你回去等通知了。

4.6 用make创建切片

4.7 切片的切片

切片可包含任何类型,甚至包括其它的切片。

4.8 向切片追加元素

4.9 range

for循环的range形式可遍历切片或映射。

  1. 映射(map)

gin 自动映射参数及自动校验

PHP转Go系列:map映射

5.1 映射的文法

5.2 修改映射

五、方法和接口

  1. 方法

  • 指针接收者
  • 方法与指针重定向
  • 选择值或指针作为接收者
  1. 接口

2.1 接口理解

Golang interface接口深入理解

Go 语言接口详解(一)

Go 语言接口详解(二)

2.2 接口与隐式形式

2.3 接口值

2.3.1 nil 的接口值

Go “一个包含nil指针的接口不是nil接口”踩坑

2.3.2 空接口

31. 说说 Go 语言中的空接口

2.4 接口类型断言

14. Go 语言中的类型断言是什么?

聊聊golang的类型断言

2.5 接口类型选择

2.6 Stringer

六、错误异常

  1. 错误

Go语言(golang)的错误(error)处理的推荐方案

Golang error 的突围

[译] Part 31: golang 中的自定义 error

[译] Go 1.13 errors 包错误处理

七、IO读取

  1. Reader

Golang 最细节篇 —— Reader,ReaderAt 的区别,你如果是做存储的,可千万别搞错了;

  1. 图像

golang 图像验证码 转载

  1. 阻塞&非阻塞

在Golang中各种永远阻塞的姿势

Golang 实现轻量、快速的基于 Reactor 模式的非阻塞 TCP 网络库

  1. 同步vs异步

面试官让我用channel实现sync包里的同步锁,是不是故意为难我?

Visual Studio Live Share - 和你的队友同步共享代码,即时编辑

kingtask:一个由 Go 开发的轻量级的异步定时任务系统

用一个简易的 web chat 说说 Python、Golang、Nodejs 的异步

  1. Select

深入理解go-channel和select的原理

八、并发

  1. 协程相关

Golang 的 协程调度机制 与 GOMAXPROCS 性能调优

Go并发调度器解析之实现一个协程池 转载

  1. 原子性、可见性、有序性

Golang 并发编程核心—内存可见性

  1. 并发控制

深入golang之—goroutine并发控制与通信

Go 译文之通过 context 实现并发控制

go并发之goroutine和channel,并发控制入门篇

九、语言特性

  1. Goroutine

  1. Channel

go语言之行–golang核武器goroutine调度原理、channel详解

Golang —— goroutine(协程)和channel(管道)

深入理解Golang之channel

  1. GMP模型

深入理解Golang之channel

Golang调度器的GMP模型

go并发奥秘:GMP模型

动图图解!GMP模型里为什么要有P?

十、框架

  1. Web框架

慢聊Go之Go常见的Web 开发框架|Go主题月

1.1 Revel

1.2 Beego

1.3 Martini

1.4 Gin Gonic

1.5 Buffalo

1.6 Goji

1.7 Tiger Tonic

1.8 Gocraft

1.9 Mango

1.10 GORM

  1. 微服务框架

2.1 go-kit

2.2 Micro

2.3 go-zero

2.4 gRPC

Go RPC入门指南1:RPC的使用边界在哪里?如何实现跨语言调用

十一、性能剖析

  1. Go语言性能分析

go pprof 性能分析

Go 程序性能分析 101

golang 使用pprof和go-torch做性能分析

多维度思考:如何提高项目的开发时间、提高安全性、提高运行速度,从多个维度带来的一些思考。

十二、问题排查

十三、Golang面试

Go语言控制CPU占用率呈正弦曲线 | Go主题月

本文转载自: 掘金

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

Spring Cloud 最强注册中心 Nacos! 1

发表于 2021-04-20

大家好,我是艿艿~

这周,咱们一起注册到 Nacos,化为打工(干饭)人节点。

本文在提供完整代码示例,可见 github.com/YunaiV/Spri… 的labx-01-spring-cloud-alibaba-nacos-discovery 目录。

原创不易,给点个 Star 嘿,一起冲鸭!

  1. 概述

本文我们来学习 Spring Cloud Alibaba 提供的 Spring Cloud Alibaba Nacos Discovery 组件,基于 Spring Cloud 的编程模型,接入 Nacos 作为注册中心,实现服务的注册与发现。

服务注册/发现: Nacos Discovery

服务发现是微服务架构体系中最关键的组件之一。如果尝试着用手动的方式来给每一个客户端来配置所有服务提供者的服务列表是一件非常困难的事,而且也不利于服务的动态扩缩容。

Nacos Discovery 可以帮助您将服务自动注册到 Nacos 服务端并且能够动态感知和刷新某个服务实例的服务列表。

除此之外,Nacos Discovery 也将服务实例自身的一些元数据信息-例如 host,port, 健康检查URL,主页等内容注册到 Nacos。

在开始本文之前,胖友需要对 Nacos 进行简单的学习。可以阅读《Nacos 极简入门》文章,将第一二小节看完,在本机搭建一个 Nacos 服务。

  1. 注册中心原理

在开始搭建 Nacos Discovery 的示例之前,我们先来简单了解下注册中心的原理。

在使用注册中心时,一共有三种角色:服务提供者(Service Provider)、服务消费者(Service Consumer)、注册中心(Registry)。

在一些文章中,服务提供者被称为 Server,服务消费者被称为 Client。胖友们知道即可。

三个角色交互如下图所示:注册中心原理

① Provider:

  • 启动时,向 Registry 注册自己为一个服务(Service)的实例(Instance)。
  • 同时,定期向 Registry 发送心跳,告诉自己还存活。
  • 关闭时,向 Registry 取消注册。

② Consumer:

  • 启动时,向 Registry 订阅使用到的服务,并缓存服务的实例列表在内存中。
  • 后续,Consumer 向对应服务的 Provider 发起调用时,从内存中的该服务的实例列表选择一个,进行远程调用。
  • 关闭时,向 Registry 取消订阅。

③ Registry:

  • Provider 超过一定时间未心跳时,从服务的实例列表移除。
  • 服务的实例列表发生变化(新增或者移除)时,通知订阅该服务的 Consumer,从而让 Consumer 能够刷新本地缓存。

当然,不同的注册中心可能在实现原理上会略有差异。例如说,Eureka 注册中心,并不提供通知功能,而是 Eureka Client 自己定期轮询,实现本地缓存的更新。

另外,Provider 和 Consumer 是角色上的定义,一个服务同时即可以是 Provider 也可以作为 Consumer。例如说,优惠劵服务可以给订单服务提供接口,同时又调用用户服务提供的接口。

  1. 快速入门

示例代码对应仓库:

  • 服务提供者:labx-01-sca-nacos-discovery-demo01-provider
  • 服务消费者:labx-01-sca-nacos-discovery-demo01-consumer

本小节,我们来搭建一个 Nacos Discovery 组件的快速入门示例。步骤如下:

  • 首先,搭建一个服务提供者 demo-provider ,注册服务到 Nacos 中。
  • 然后,搭建一个服务消费者 demo-consumer,从 Nacos 获取到 demo-provider 服务的实例列表,选择其中一个示例,进行 HTTP 远程调用。

3.1 搭建服务提供者

创建 labx-01-sca-nacos-discovery-demo01-provider 项目,作为服务提供者 demo-provider。最终项目代码如下图所示:demo-provider 项目

3.1.1 引入依赖

在 pom.xml 文件中,主要引入 Spring Cloud Nacos Discovery 相关依赖。代码如下:

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
XML复制代码<?xml version="1.0" encoding="UTF-8"?>
<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">
<parent>
<artifactId>labx-01</artifactId>
<groupId>cn.iocoder.springboot.labs</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>labx-01-sca-nacos-discovery-demo01-provider</artifactId>

<properties>
<spring.boot.version>2.2.4.RELEASE</spring.boot.version>
<spring.cloud.version>Hoxton.SR1</spring.cloud.version>
<spring.cloud.alibaba.version>2.2.0.RELEASE</spring.cloud.alibaba.version>
</properties>

<!--
引入 Spring Boot、Spring Cloud、Spring Cloud Alibaba 三者 BOM 文件,进行依赖版本的管理,防止不兼容。
在 https://dwz.cn/mcLIfNKt 文章中,Spring Cloud Alibaba 开发团队推荐了三者的依赖关系
-->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring.cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring.cloud.alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<!-- 引入 SpringMVC 相关依赖,并实现对其的自动配置 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 引入 Spring Cloud Alibaba Nacos Discovery 相关依赖,将 Nacos 作为注册中心,并实现对其的自动配置 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
</dependencies>

</project>

友情提示:有点小长,不要慌~

在 <dependencyManagement /> 中,我们引入了 Spring Boot、Spring Cloud、Spring Cloud Alibaba 三者 BOM 文件,进行依赖版本的管理,防止不兼容。在《Spring Cloud 官方文档 —— 版本说明》文档中,推荐了三者的依赖关系。如下表格:

Spring Cloud Version Spring Cloud Alibaba Version Spring Boot Version
Spring Cloud Greenwich 2.1.1.RELEASE 2.1.X.RELEASE
Spring Cloud Finchley 2.0.1.RELEASE 2.0.X.RELEASE
Spring Cloud Edgware 1.5.1.RELEASE 1.5.X.RELEASE
  • 这里,我们选择了 Spring Cloud Alibaba 版本为 2.2.0.RELEASE。
  • 当前版版本下,我们使用的 Nacos 版本为 1.1.4。

引入 spring-cloud-starter-alibaba-nacos-discovery 依赖,将 Nacos 作为注册中心,并实现对它的自动配置。

3.1.2 配置文件

创建 application.yaml 配置文件,添加 Nacos Discovery 配置项。配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
YAML复制代码spring:
application:
name: demo-provider # Spring 应用名
cloud:
nacos:
# Nacos 作为注册中心的配置项,对应 NacosDiscoveryProperties 配置类
discovery:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
service: ${spring.application.name} # 注册到 Nacos 的服务名。默认值为 ${spring.application.name}。

server:
port: 18080 # 服务器端口。默认为 8080

重点看 spring.cloud.nacos.discovery 配置项,它是 Nacos Discovery 配置项的前缀,对应 NacosDiscoveryProperties 配置项。

3.1.3 DemoProviderApplication

创建 DemoProviderApplication 类,创建应用启动类,并提供 HTTP 接口。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Java复制代码@SpringBootApplication
@EnableDiscoveryClient
public class DemoProviderApplication {

public static void main(String[] args) {
SpringApplication.run(DemoProviderApplication.class, args);
}

@RestController
static class TestController {

@GetMapping("/echo")
public String echo(String name) {
return "provider:" + name;
}

}

}

① @SpringBootApplication 注解,被添加在类上,声明这是一个 Spring Boot 应用。Spring Cloud 是构建在 Spring Boot 之上的,所以需要添加。

② @EnableDiscoveryClient 注解,开启 Spring Cloud 的注册发现功能。不过从 Spring Cloud Edgware 版本开始,实际上已经不需要添加 @EnableDiscoveryClient 注解,只需要引入 Spring Cloud 注册发现组件,就会自动开启注册发现的功能。例如说,我们这里已经引入了 spring-cloud-starter-alibaba-nacos-discovery 依赖,就不用再添加 @EnableDiscoveryClient 注解了。

拓展小知识:在 Spring Cloud Common 项目中,定义了 DiscoveryClient 接口,作为通用的发现客户端,提供读取服务和读取服务列表的 API 方法。而想要集成到 Spring Cloud 体系的注册中心的组件,需要提供对应的 DiscoveryClient 实现类。

例如说,Spring Cloud Alibaba Nacos Discovery 提供了 NacosDiscoveryClient 实现,Spring Cloud Netflix Eureka 提供了 EurekaDiscoveryClient 实现。

如此,所有需要使用到的地方,只需要获取到 DiscoveryClient 客户端,而无需关注具体实现,保证其通用性。

③ TestController 类,提供了 /echo 接口,返回 provider:${name} 结果。

3.1.4 简单测试

① 通过 DemoProviderApplication 启动服务提供者,IDEA 控制台输出日志如:

1
2
Java复制代码// ... 省略其它日志
2020-02-08 15:25:57.406 INFO 27805 --- [ main] c.a.c.n.registry.NacosServiceRegistry : nacos registry, DEFAULT_GROUP demo-provider 10.171.1.115:18080 register finished
  • 服务 demo-provider 注册到 Nacos 上的日志。

② 打开 Nacos 控制台,可以在服务列表看到服务 demo-provider。如下图:服务列表

3.2 搭建服务消费者

创建 labx-01-sca-nacos-discovery-demo01-consumer 项目,作为服务提供者 demo-consumer。最终项目代码如下图所示:demo-consumer 项目

整个项目的代码,和服务提供者是基本一致的,毕竟是示例代码 😜

3.2.1 引入依赖

和「3.1.1 引入依赖」一样,只是修改 Maven <artifactId /> 为 labx-01-sca-nacos-discovery-demo01-consumer,见 pom.xml 文件。

3.2.2 配置文件

创建 application.yaml 配置文件,添加相应配置项。配置如下:

1
2
3
4
5
6
7
8
9
10
11
YAML复制代码spring:
application:
name: demo-consumer # Spring 应用名
cloud:
nacos:
# Nacos 作为注册中心的配置项,对应 NacosDiscoveryProperties 配置类
discovery:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址

server:
port: 28080 # 服务器端口。默认为 8080

和「3.1.2 配置文件」基本一致,主要是将配置项目 spring.application.name 修改为 demo-consumer。

3.2.3 DemoConsumerApplication

创建 DemoConsumerApplication 类,创建应用启动类,并提供一个调用服务提供者的 HTTP 接口。代码如下:

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
Java复制代码@SpringBootApplication
// @EnableDiscoveryClient
public class DemoConsumerApplication {

public static void main(String[] args) {
SpringApplication.run(DemoConsumerApplication.class, args);
}

@Configuration
public class RestTemplateConfiguration {

@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}

}

@RestController
static class TestController {

@Autowired
private DiscoveryClient discoveryClient;
@Autowired
private RestTemplate restTemplate;
@Autowired
private LoadBalancerClient loadBalancerClient;

@GetMapping("/hello")
public String hello(String name) {
// <1> 获得服务 `demo-provider` 的一个实例
ServiceInstance instance;
if (true) {
// 获取服务 `demo-provider` 对应的实例列表
List<ServiceInstance> instances = discoveryClient.getInstances("demo-provider");
// 选择第一个
instance = instances.size() > 0 ? instances.get(0) : null;
} else {
instance = loadBalancerClient.choose("demo-provider");
}
// <2> 发起调用
if (instance == null) {
throw new IllegalStateException("获取不到实例");
}
String targetUrl = instance.getUri() + "/echo?name=" + name;
String response = restTemplate.getForObject(targetUrl, String.class);
// 返回结果
return "consumer:" + response;
}

}

}

① @EnableDiscoveryClient 注解,因为已经无需添加,所以我们进行了注释,原因在上面已经解释过。

② RestTemplateConfiguration 配置类,创建 RestTemplate Bean。RestTemplate 是 Spring 提供的 HTTP 调用模板工具类,可以方便我们稍后调用服务提供者的 HTTP API。

③ TestController 提供了 /hello 接口,用于调用服务提供者的 /demo 接口。代码略微有几行,我们来稍微解释下哈。

discoveryClient 属性,DiscoveryClient 对象,服务发现客户端,上文我们已经介绍过。这里我们注入的不是 Nacos Discovery 提供的 NacosDiscoveryClient,保证通用性。未来如果我们不使用 Nacos 作为注册中心,而是使用 Eureka 或则 Zookeeper 时,则无需改动这里的代码。

loadBalancerClient 属性,LoadBalancerClient 对象,负载均衡客户端。稍后我们会使用它,从 Nacos 获取的服务 demo-provider 的实例列表中,选择一个进行 HTTP 调用。

拓展小知识:在 Spring Cloud Common 项目中,定义了LoadBalancerClient 接口,作为通用的负载均衡客户端,提供从指定服务中选择一个实例、对指定服务发起请求等 API 方法。而想要集成到 Spring Cloud 体系的负载均衡的组件,需要提供对应的 LoadBalancerClient 实现类。

例如说,Spring Cloud Netflix Ribbon 提供了 RibbonLoadBalancerClient 实现。

如此,所有需要使用到的地方,只需要获取到 DiscoveryClient 客户端,而无需关注具体实现,保证其通用性。😈 不过貌似 Spring Cloud 体系中,暂时只有 Ribbon 一个负载均衡组件。

当然,LoadBalancerClient 的服务的实例列表,是来自 DiscoveryClient 提供的。

/hello 接口,示例接口,对服务提供者发起一次 HTTP 调用。

  • <1> 处,获得服务 demo-provider 的一个实例。这里我们提供了两种方式的代码,分别基于 DiscoveryClient 和 LoadBalancerClient。
  • <2> 处,通过获取到的服务实例 ServiceInstance 对象,拼接请求的目标 URL,之后使用 RestTemplate 发起 HTTP 调用。

3.2.4 简单测试

① 通过 DemoConsumerApplication 启动服务消费者,IDEA 控制台输出日志如:

1
2
Java复制代码// ... 省略其它日志
2020-02-08 18:05:35.810 INFO 35047 --- [ main] c.a.c.n.registry.NacosServiceRegistry : nacos registry, DEFAULT_GROUP demo-consumer 10.171.1.115:28080 register finished
  • 服务 demo-consumer 注册到 Nacos 上的日志。

注意,服务消费者和服务提供是一种角色的概念,本质都是一种服务,都是可以注册自己到注册中心的。

② 打开 Nacos 控制台,可以在服务列表看到服务 demo-consumer。如下图:服务列表

③ 访问服务消费者的 http://127.0.0.1:28080/hello?name=yudaoyuanma 接口,返回结果为 "consumer:provider:yudaoyuanma"。说明,调用远程的服务提供者成功。

④ 打开 Nacos 控制台,可以在订阅者列表看到订阅关系。如下图:订阅者列表

⑤ 关闭服务提供者后,再次访问 http://127.0.0.1:28080/hello?name=yudaoyuanma 接口,返回结果为报错提示 "获取不到实例",说明我们本地缓存的服务 demo-provider 的实例列表已刷新,没有任何实例。

😈 这里我们并没有演示启动多个服务提供者的测试,胖友可以自己尝试下哟。

  1. Nacos 概念详解

友情提示:本小节的内容,基于如下两篇文档梳理,推荐胖友后续也看看:

  • 《Nacos 官方文档 —— 概念》
  • 《Nacos 官方文档 —— 架构》

4.1 数据模型

Nacos 数据模型 Key 由三元组唯一确认。如下图所示:Nacos 数据模型

  • 作为注册中心时,Namespace + Group + Service
  • 作为配置中心时,Namespace + Group + DataId

我们来看看 Namespace、Group、Service 的概念。

4.1.1 Namespace 命名空间

用于进行租户粒度的配置隔离。默认为 public(公共命名空间)。

不同的命名空间下,可以存在相同的 Group 或 Data ID 的配置。Namespace 的常用场景之一是不同环境的配置的区分隔离,例如开发测试环境和生产环境的资源(如配置、服务)隔离等。

稍后在「6. 多环境配置」小节中,我们会通过 Namespace 隔离不同环境的服务。

4.1.2 Group 服务分组

不同的服务可以归类到同一分组。默认为 DEFAULT_GROUP(默认分组)。

4.1.3 Service 服务

例如说,用户服务、订单服务、商品服务等等。

4.2 服务领域模型

Service 可以进一步细拆服务领域模型,如下图:Nacos 服务领域模型

我们来看看图中的每个“节点”的概念。

4.2.1 Instance 实例

提供一个或多个服务的具有可访问网络地址(IP:Port)的进程。

我们以「3.1 搭建服务提供者」小节来举例子:

  • 如果我们启动一个 JVM 进程,就是服务 demo-provider 下的一个实例。
  • 如果我们启动多个 JVM 进程,就是服务 demo-provider 下的多个实例。

4.2.2 Cluster 集群

同一个服务下的所有服务实例组成一个默认集群(Default)。集群可以被进一步按需求划分,划分的单位可以是虚拟集群。

例如说,我们将服务部署在多个机房之中,每个机房可以创建为一个虚拟集群。每个服务在注册到 Nacos 时,设置所在机房的虚拟集群。这样,服务在调用其它服务时,可以通过虚拟集群,优先调用本机房的服务。如此,在提升服务的可用性的同时,保证了性能。

4.2.3 Metadata 元数据

Nacos 元数据(如配置和服务)描述信息,如服务版本、权重、容灾策略、负载均衡策略、鉴权配置、各种自定义标签 (label)。

从作用范围来看,分为服务级别的元信息、集群的元信息及实例的元信息。如下图:元数据

FROM 《Dubbo 官方文档 —— 多版本》

以 Nacos 元数据的服务版本举例子。当一个接口实现,出现不兼容升级时,可以用版本号过渡,版本号不同的服务相互间不引用。

可以按照以下的步骤进行版本迁移:

  1. 在低压力时间段,先升级一半提供者为新版本
  2. 再将所有消费者升级为新版本
  3. 然后将剩下的一半提供者升级为新版本

FROM 《Dubbo 官方文档 —— 令牌验证》

再次 Nacos 元数据的鉴权配置举例子。通过令牌验证在注册中心控制权限,以决定要不要下发令牌给消费者,可以防止消费者绕过注册中心访问提供者。另外,通过注册中心可灵活改变授权方式,而不需修改或升级提供者。

令牌验证

4.2.4 Health Check 健康检查

以指定方式检查服务下挂载的实例的健康度,从而确认该实例是否能提供服务。根据检查结果,实例会被判断为健康或不健康。

对服务发起解析请求时,不健康的实例不会返回给客户端。

健康保护阈值

为了防止因过多实例不健康导致流量全部流向健康实例,继而造成流量压力把健康实例实例压垮并形成雪崩效应,应将健康保护阈值定义为一个 0 到 1 之间的浮点数。

当域名健康实例占总服务实例的比例小于该值时,无论实例是否健康,都会将这个实例返回给客户端。这样做虽然损失了一部分流量,但是保证了集群的剩余健康实例能正常工作。

4.3 小结

为了让胖友更好理解,我们把数据模型和服务领域模型整理如下图所示:Nacos 概念小结

  1. 更多的配置项信息

在「3. 快速入门」小节中,我们为了快速入门,只使用了 Nacos Discovery Starter 两个配置项。实际上,Nacos Discovery Starter 提供的配置项挺多的,我们参考文档将配置项一起梳理下。

Nacos 服务器相关

配置项 Key 说明
服务端地址 spring.cloud.nacos.discovery.server-addr Nacos Server 启动监听的ip地址和端口
AccessKey spring.cloud.nacos.discovery.access-key 当要上阿里云时,阿里云上面的一个云账号名
SecretKey spring.cloud.nacos.discovery.secret-key 当要上阿里云时,阿里云上面的一个云账号密码

服务相关

配置项 Key 说明
命名空间 spring.cloud.nacos.discovery.namespace 常用场景之一是不同环境的注册的区分隔离,例如开发测试环境和生产环境的资源(如配置、服务)隔离等
服务分组 spring.cloud.nacos.discovery.group 不同的服务可以归类到同一分组。默认为 DEFAULT_GROUP
服务名 spring.cloud.nacos.discovery.service 注册的服务名。默认为 ${spring.application.name}
集群 spring.cloud.nacos.discovery.cluster-name Nacos 集群名称。默认为 DEFAULT
权重 spring.cloud.nacos.discovery.weight 取值范围 1 到 100,数值越大,权重越大。默认为 1
Metadata spring.cloud.nacos.discovery.metadata 使用Map格式配置,用户可以根据自己的需要自定义一些和服务相关的元数据信息
是否开启Nacos Watch spring.cloud.nacos.discovery.watch.enabled 可以设置成 false 来关闭 watch。默认为 true

网络相关

配置项 Key 说明
网卡名 spring.cloud.nacos.discovery.network-interface 当 IP未配置时,注册的 IP 为此网卡所对应的 IP 地址,如果此项也未配置,则默认取第一块网卡的地址
注册的IP地址 spring.cloud.nacos.discovery.ip 优先级最高
注册的端口 spring.cloud.nacos.discovery.port 默认情况下不用配置,会自动探测。默认为 -1

其它相关

配置项 Key 说明
是否集成 Ribbon ribbon.nacos.enabled 一般都设置成true 即可。默认为 true
日志文件名 spring.cloud.nacos.discovery.log-name
接入点 spring.cloud.nacos.discovery.endpoint 地域的某个服务的入口域名,通过此域名可以动态地拿到服务端地址
  1. 多环境配置

示例代码对应仓库:

  • 服务提供者:labx-01-sca-nacos-discovery-demo02-provider
  • 服务消费者:labx-01-sca-nacos-discovery-demo02-consumer

同一个服务,我们会部署到开发、测试、预发布、生产等环境中,那么我们需要在项目中,添加不同环境的 Nacos 配置。一般情况下,开发和测试使用同一个 Nacos,预发布和生产使用另一个 Nacos。那么针对相同的 Nacos,我们怎么实现不同环境的隔离呢?

实际上,Nacos 开发者已经告诉我们如何实现了,通过 Nacos Namespace 命名空间。文档说明如下:

FROM 《Nacos 文档 —— Nacos 概念》

命名空间,用于进行租户粒度的配置隔离。不同的命名空间下,可以存在相同的 Group 或 Data ID 的配置。Namespace 的常用场景之一是不同环境的配置的区分隔离,例如开发测试环境和生产环境的资源(如配置、服务)隔离等。

下面,我们来搭建一个多环境配置的示例。步骤如下:

  • 首先,我们会在 Nacos 中创建开发环境使用的 Namespace 为 dev,测试环境使用的 Namespace 为 uat。
  • 然后,搭建一个服务提供者 demo-provider,使用开发环境配置,注册服务到 Nacos 的 dev Namespace 下。
  • 之后,搭建一个服务消费者 demo-consumer,调用服务提供者 demo-provider 提供的 HTTP 接口。
    • 先使用开发环境配置,因为服务 demo-provider 是在 Nacos dev Namespace 下注册,所以调用它成功。
    • 后使用测试环境配置,因为服务 demo-provider 不在 Nacos uat Namespace 下注册,所以调用它失败,

友情提示:在 Spring Boot(Spring Cloud)项目中,可以使用 Profiles 机制,基于 spring.profiles.active 配置项,实现不同环境读取不同的配置文件。

不了解的胖友,可以看看《芋道 Spring Boot 配置文件入门》的「6. 多环境配置」小节。

6.1 创建 Nacos 命名空间

① 打开 Nacos UI 界面的「命名空间」菜单,进入「命名空间」功能。如下图所示:命名空间

② 点击列表右上角的「新建命名空间」按钮,弹出「新建命名空间」窗口,创建一个 dev 命名空间。输入如下内容,并点击「确定」按钮,完成创建。如下图所示:新建命名空间

③ 重复该操作,继续创建一个 uat 命名空间。最终 dev 和 uat 信息如下图:命名空间列表

6.2 搭建服务提供者

从「3.1 搭建服务提供者」小节的 labx-01-sca-nacos-discovery-demo01-provider 项目,复制出 labx-01-sca-nacos-discovery-demo02-provider 项目。然后在其上进行修改,方便搭建~

6.2.1 配置文件

修改 application.yaml 配置文件,将 Nacos Discovery 配置项删除,稍后添加在不同环境的配置文件中。配置如下:

1
2
3
4
5
6
YAML复制代码spring:
application:
name: demo-provider # Spring 应用名

server:
port: 18080 # 服务器端口。默认为 8080

创建开发环境使用的 application-dev.yaml 配置文件,增加 Namespace 为 dev 的 Nacos Discovery 配置项。配置如下:

1
2
3
4
5
6
7
YAML复制代码spring:
cloud:
nacos:
# Nacos 作为注册中心的配置项,对应 NacosDiscoveryProperties 配置类
discovery:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
namespace: 14226a0d-799f-424d-8905-162f6a8bf409 # Nacos 命名空间 dev 的编号

创建测试环境使用的 application-uat.yaml 配置文件,增加 Namespace 为 uat 的 Nacos Discovery 配置项。配置如下:

1
2
3
4
5
6
7
YAML复制代码spring:
cloud:
nacos:
# Nacos 作为注册中心的配置项,对应 NacosDiscoveryProperties 配置类
discovery:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
namespace: bc8c8c2d-bd85-42bb-ada3-1a8f940ceb20 # Nacos 命名空间 uat 的编号

6.2.2 简单测试

下面,我们使用命令行参数进行 --spring.profiles.active 配置项,实现不同环境,读取不同配置文件。

① 先配置 --spring.profiles.active 为 dev,设置 DemoProviderApplication 读取 application-dev.yaml 配置文件。如下图所示:IDEA 配置 - dev

之后通过 DemoProviderApplication 启动服务提供者。

② 打开 Nacos 控制台,可以在服务列表看到服务 demo-provider 注册在命名空间 dev 下。如下图:服务列表

6.3 搭建服务消费者

从「3.2 搭建服务消费者」小节的 labx-01-sca-nacos-discovery-demo01-consumer 项目,复制出 labx-01-sca-nacos-discovery-demo02-consumer 项目。然后在其上进行修改,方便搭建~

6.3.1 配置文件

友情提示:和「6.2.1 配置文件」小节的内容是基本一致的,重复唠叨一遍。

修改 application.yaml 配置文件,将 Nacos Discovery 配置项删除,稍后添加在不同环境的配置文件中。配置如下:

1
2
3
4
5
6
YAML复制代码spring:
application:
name: demo-consumer # Spring 应用名

server:
port: 28080 # 服务器端口。默认为 8080

创建开发环境使用的 application-dev.yaml 配置文件,增加 Namespace 为 dev 的 Nacos Discovery 配置项。配置如下:

1
2
3
4
5
6
7
YAML复制代码spring:
cloud:
nacos:
# Nacos 作为注册中心的配置项,对应 NacosDiscoveryProperties 配置类
discovery:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
namespace: 14226a0d-799f-424d-8905-162f6a8bf409 # Nacos 命名空间 dev 的编号

创建测试环境使用的 application-uat.yaml 配置文件,增加 Namespace 为 uat 的 Nacos Discovery 配置项。配置如下:

1
2
3
4
5
6
7
YAML复制代码spring:
cloud:
nacos:
# Nacos 作为注册中心的配置项,对应 NacosDiscoveryProperties 配置类
discovery:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
namespace: bc8c8c2d-bd85-42bb-ada3-1a8f940ceb20 # Nacos 命名空间 uat 的编号

6.2.3 简单测试

下面,我们使用命令行参数进行 --spring.profiles.active 配置项,实现不同环境,读取不同配置文件。

① 先配置 --spring.profiles.active 为 dev,设置 DemoConsumerApplication 读取 application-dev.yaml 配置文件。如下图所示:IDEA 配置 - dev

之后通过 DemoConsumerApplication 启动服务消费者。

访问服务消费者的 http://127.0.0.1:28080/hello?name=yudaoyuanma 接口,返回结果为 "consumer:provider:yudaoyuanma"。说明,调用远程的服务提供者【成功】。

② 再配置 --spring.profiles.active 为 uat,设置 DemoConsumerApplication 读取 application-uat.yaml 配置文件。如下图所示:IDEA 配置 - uat

之后通过 DemoConsumerApplication 启动服务消费者。

访问服务消费者的 http://127.0.0.1:28080/hello?name=yudaoyuanma 接口,返回结果为 报错提示 "获取不到实例"。说明,调用远程的服务提供者【失败】。

原因是,虽然说服务 demo-provider 已经启动,因为其注册在 Nacos 的 Namespace 为 dev,这就导致第 ① 步启动的服务 demo-consumer 可以调用到该服务,而第② 步启动的服务 demo-consumer 无法调用到该服务。

即,我们可以通过 Nacos 的 Namespace 实现不同环境下的服务隔离。未来,在开源版本 Nacos 权限完善之后,每个 Namespace 提供不同的 AccessKey、SecretKey,保证只有知道账号密码的服务,才能连到对应的 Namespace,进一步提升安全性。

  1. 监控端点

示例代码对应仓库:

  • 服务提供者:labx-01-sca-nacos-discovery-demo01-provider
  • 服务消费者:labx-01-sca-nacos-discovery-demo03-consumer

Nacos Discovery 基于 Spring Boot Actuator,提供了自定义监控端点 nacos-discovery,获取 Nacos Discovery 配置项,和订阅的服务信息。

同时,Nacos Discovery 拓展了 Spring Boot Actuator 内置的 health 端点,通过自定义的 NacosDiscoveryHealthIndicator,获取和 Nacos 服务器的连接状态。

友情提示:对 Spring Boot Actuator 不了解的胖友,可以后续阅读《芋道 Spring Boot 监控端点 Actuator 入门》文章。

下面,我们来搭建一个 Nacos Discovery 监控端点的示例。步骤如下:

  • 首先,搭建一个服务提供者 demo-provider ,注册服务到 Nacos 中。
  • 然后,搭建一个服务消费者 demo-consumer,调用服务提供者 demo-provider 提供的 HTTP 接口。同时,配置开启服务消费者的 Nacos Discovery 监控端点。
  • 最后,访问服务消费者的 Nacos Discovery 监控端点,查看下返回的监控数据。

7.1 搭建服务提供者

直接复用「3.1 搭建服务提供者」小节的 labx-01-sca-nacos-discovery-demo01-provider 项目即可。

因为 labx-01-sca-nacos-discovery-demo01-provider 项目没有从 Nacos 订阅任何服务,无法完整看到 nacos-discovery 端点的完整效果,所以我们暂时不配置该项目的 Nacos Discovery 监控端点。

不过实际项目中,配置下开启 Nacos Discovery 监控端点 还是可以的,至少可以看到 Nacos Discovery 配置项。

7.2 搭建服务消费者

从「3.2 搭建服务消费者」小节的 labx-01-sca-nacos-discovery-demo01-consumer 项目,复制出 labx-01-sca-nacos-discovery-demo03-consumer 项目。然后在其上进行修改,方便搭建~

7.2.1 引入依赖

在 pom.xml 文件中,额外引入 Spring Boot Actuator 相关依赖。代码如下:

1
2
3
4
5
XML复制代码<!-- 实现对 Actuator 的自动化配置 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

7.2.2 配置文件

修改 application.yaml 配置文件,增加 Spring Boot Actuator 配置项。配置如下:

1
2
3
4
5
6
7
8
9
10
YAML复制代码management:
endpoints:
web:
exposure:
include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。
endpoint:
# Health 端点配置项,对应 HealthProperties 配置类
health:
enabled: true # 是否开启。默认为 true 开启。
show-details: ALWAYS # 何时显示完整的健康信息。默认为 NEVER 都不展示。可选 WHEN_AUTHORIZED 当经过授权的用户;可选 ALWAYS 总是展示。

每个配置项的作用,胖友看下艿艿添加的注释。如果还不理解的话,后续看下《芋道 Spring Boot 监控端点 Actuator 入门》文章。

7.3 简单测试

① 通过 DemoProviderApplication 启动服务提供者,通过 DemoConsumerApplication 启动服务消费者。

之后,访问服务消费者的 http://127.0.0.1:28080/hello?name=yudaoyuanma 接口,返回结果为 "consumer:provider:yudaoyuanma"。a说明,调用远程的服务提供者成功。

② 访问服务消费者的 nacos-discovery 监控端点 http://127.0.0.1:28080/actuator/nacos-discovery,返回结果如下图:nacos-discovery 监控端点

理论来说,"subscribe" 字段应该返回订阅的服务 demo-provider 的信息,结果这里返回的是空。后来翻看了下源码,是需要主动向 Nacos EventDispatcher 注册 EventListener 才可以。咳咳咳,感觉这个设定有点神奇~

③ 访问服务消费者的 health 监控端点 http://127.0.0.1:28080/actuator/health,返回结果如下图:health 监控端点

  1. 彩蛋

至此,我们已经完成 Spring Cloud Alibaba Nacos Discovery 的学习。如下是 Nacos 相关的官方文档:

  • 《Nacos 官方文档》
  • 《Spring Cloud Alibaba 官方文档 —— Nacos Discovery》
  • 《Spring Cloud Alibaba 官方示例 —— Nacos Discovery》

另外,想要在 Spring Boot 项目中使用 Nacos 作为注册中心的胖友,可以阅读《芋道 Spring Boot 注册中心 Nacos 入门》文章。

本文转载自: 掘金

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

什么是 Go context Go主题月

发表于 2021-04-19

作为一名 Gopher,怎么可以不知道 Go context 呢?快来了解一下吧!

介绍

Go 1.7 标准库引入 context,中文译作“上下文”,准确说它是 goroutine 的上下文,包含 goroutine 的运行状态、环境、现场等信息。

context 主要用来在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。

随着 context 包的引入,标准库中很多接口因此加上了 context 参数,例如 database/sql 包。context 几乎成为了并发控制和超时控制的标准做法。

使用场景

在 Go http 包的 Server 中,每一个请求在都有一个对应的 goroutine 去处理。请求处理函数通常会启动额外的 goroutine 用来访问后端服务,比如数据库和 RPC 服务。用来处理一个请求的 goroutine 通常需要访问一些与请求特定的数据,比如终端用户的身份认证信息、验证相关的 token、请求的截止时间。当一个请求被取消或超时时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统才能释放这些 goroutine 占用的资源。

使用规范

  • 不要将 Context 放入结构体,相反 Context 应该作为第一个参数传入,命名为 ctx,例如:
1
2
3
go复制代码func DoSomething (ctx context.Context,arg Arg) error {
// use ctx
}
  • 即使函数允许,也不要传入 nil 的 Context。如果不知道用哪种 Context,可以使用context.TODO()。
  • 使用 Context 的 Value 相关方法只应该用于在程序和接口中传递的和请求相关的元数据,不要用它来传递一些可选的参数。
  • 相同的 Context 可以传递给在不同的 goroutine,因为 Context 是并发安全的。

Context 结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
go复制代码// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
// Done returns a channel that is closed when this Context is canceled
// or times out.
Done() <-chan struct{}

// Err indicates why this context was canceled, after the Done channel
// is closed.
Err() error

// Deadline returns the time when this Context will be canceled, if any.
Deadline() (deadline time.Time, ok bool)

// Value returns the value associated with key or nil if none.
Value(key interface{}) interface{}
}
  • Done() 返回一个channel。当times out或者调用cancel方法时,将会close掉。
  • Err() 返回一个错误。该context为什么被取消掉。
  • Deadline() 返回截止时间和 ok。
  • Value() 返回 Key 值。

context 包方法

1
2
3
4
5
6
7
go复制代码func Background() Context
func TODO() Context

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context
  • Background 和 TODO 都是返回空的 Context。
  • WithCancel 以一个新的 Done channel 返回一个父 Context 的拷贝。
  • WithDeadline 的最后期限调整为不晚于 deadline 返回父上下文的副本。
  • WithTimeout 返回 WithDeadline(parent, time.Now().Add(timeout))。
  • WithValue 返回的父与键关联的值在 val 的副本。

总结

整个 context 包的源码非常短,很适合学习,一定要去读一下。除了使用 context 控制并发,我们还可以使用 WaitGroup。

本文转载自: 掘金

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

MySQL的半同步是什么?

发表于 2021-04-19

前言

年后在进行腾讯二面的时候,写完算法的后问的第一个问题就是,MySQL的半同步是什么?我当时直接懵了,我以为是问的MySQL的两阶段提交的问题呢?结果确认了一下后不是两阶段提交,然后面试官看我连问的是啥都不知道,就直接跳过这个问题,直接聊下一个问题了。所以这次总结一下这部分的知识内容,文字内容比较多,可能会有些枯燥,但对于这方面感兴趣的人来说还是比较有意思的。

MySQL的主从复制

我们的一般在大规模的项目上,都是使用MySQL的复制功能来创建MySQL的主从集群的。主要是可以通过为服务器配置一个或多个备库的方式来进行数据同步。复制的功能不仅有利于构建高性能应用,同时也是高可用、可扩展性、灾难恢复、备份以及数据仓库等工作的基础。

说的通俗一点,通过MySQL的主从复制来实现读写分离,相比单点数据库又读又写来说,提升了业务系统性能,优化了用户体验。另外通过主从复制实现了数据库的高可用,当主节点MySQL挂了的时候,可以用从库来顶上。

MySQL支持的复制方式

MySQL支持三种复制方式:

  • 基于语句的复制(也称为逻辑复制)主要是指,在主数据库上执行的SQL语句,在从数据库上会重复执行一遍。MySQL默认采用的就是这种复制,效率比较高。但是也是有一定的问题的,如果SQL中使用uuid()、rand()等函数,那么复制到从库的数据就会有偏差。
  • 基于行的复制,指将更新处理后的数据复制到从数据库,而不是执行一边语句。从MySQL5.1的版本才被支持。
  • 混合复制,默认采用语句复制,当发现语句不能进行精准复制数据时(例如语句中含有uuid()、rand()等函数),采用基于行的复制。

主从复制原理

MySQL的复制原理概述上来讲大体可以分为这三步

  1. 在主库上把数据更改,记录到二进制日志(Binary Log)中。
  2. 从库将主库上的日志复制到自己的中继日志(Relay Log)中。
  3. 备库读取中继日志中的事件,将其重放到备库数据之上。

主要过程如下图:
MySQL复制过程
下面来详细说一下复制的这三步:

第一步:是在主库上记录二进制日志,首先主库要开启binlog日志记录功能,并授权Slave从库可以访问的权限。这里需要注意的一点就是binlog的日志里的顺序是按照事务提交的顺序来记录的而非每条语句的执行顺序。

第二步:从库将binLog复制到其本地的RelayLog中。首先从库会启动一个工作线程,称为I/O线程,I/O线程跟主库建立一个普通的客户端连接,然后主库上启动一个特殊的二进制转储(binlog dump)线程,此转储线程会读取binlog中的事件。当追赶上主库后,会进行休眠,直到主库通知有新的更新语句时才继续被唤醒。
这样通过从库上的I/O线程和主库上的binlog dump线程,就将binlog数据传输到从库上的relaylog中了。

第三步:从库中启动一个SQL线程,从relaylog中读取事件并在备库中执行,从而实现备库数据的更新。

==这种复制架构实现了获取事件和重放事件的解耦,运行I/O线程能够独立于SQL线程之外工作。但是这种架构也限制复制的过程,最重要的一点是在主库上并发运行的查询在备库中只能串行化执行,因为只有一个SQL线程来重放中继日志中的事件。==

说到这个主从复制的串行化执行的问题,我就想到了一个之前在工作中遇到的一个问题,就是有这么一个业务场景,我们有一个操作是初始化一批数据,数据是从一个外部系统的接口中获取的,然后我是通过线程池里的多个线程并行从外部系统的接口中获取数据,每个线程获取到数据后,直接插入到数据库中。然后在数据全部入库完成后,然后去执行批量查询,将刚插入到数据库中的数据查询出来,放到ElasticSearch中。结果每次放入到ES中的数据总是不完整,后来研究了半天都不行,最终是让查询也走的主库才解决的问题。当时不知道是MySQL主从复制的串行化从而导致的这个问题。

MySQL主从复制模式

MySQL的主从复制其实是支持,异步复制、半同步复制、GTID复制等多种复制模式的。

异步模式

MySQL的默认复制模式就是异步模式,主要是指MySQL的主服务器上的I/O线程,将数据写到binlong中就直接返回给客户端数据更新成功,不考虑数据是否传输到从服务器,以及是否写入到relaylog中。在这种模式下,复制数据其实是有风险的,一旦数据只写到了主库的binlog中还没来得急同步到从库时,就会造成数据的丢失。

但是这种模式确也是效率最高的,因为变更数据的功能都只是在主库中完成就可以了,从库复制数据不会影响到主库的写数据操作。
异步复制
上面我也说了,这种异步复制模式虽然效率高,但是数据丢失的风险很大,所以就有了后面要介绍的半同步复制模式。

半同步模式

MySQL从5.5版本开始通过以插件的形式开始支持半同步的主从复制模式。什么是半同步主从复制模式呢?
这里通过对比的方式来说明一下:

  • 异步复制模式:上面我们已经介绍了,异步复制模式,主库在执行完客户端提交的事务后,只要将执行逻辑写入到binlog后,就立即返回给客户端,并不关心从库是否执行成功,这样就会有一个隐患,就是在主库执行的binlog还没同步到从库时,主库挂了,这个时候从库就就会被强行提升为主库,这个时候就有可能造成数据丢失。
  • 同步复制模式:当主库执行完客户端提交的事务后,需要等到所有从库也都执行完这一事务后,才返回给客户端执行成功。因为要等到所有从库都执行完,执行过程中会被阻塞,等待返回结果,所以性能上会有很严重的影响。
  • 半同步复制模式:半同步复制模式,可以说是介于异步和同步之间的一种复制模式,主库在执行完客户端提交的事务后,要等待至少一个从库接收到binlog并将数据写入到relay log中才返回给客户端成功结果。半同步复制模式,比异步模式提高了数据的可用性,但是也产生了一定的性能延迟,最少要一个TCP/IP连接的往返时间。

半同步复制模式,可以很明确的知道,在一个事务提交成功之后,此事务至少会存在于两个地方一个是主库一个是从库中的某一个。主要原理是,在master的dump线程去通知从库时,增加了一个ACK机制,也就是会确认从库是否收到事务的标志码,master的dump线程不但要发送binlog到从库,还有负责接收slave的ACK。当出现异常时,Slave没有ACK事务,那么将自动降级为异步复制,直到异常修复后再自动变为半同步复制

MySQL半同步复制的流程如下:

MySQL 半同步复制模式

半同步复制的隐患

半同步复制模式也存在一定的数据风险,当事务在主库提交完后等待从库ACK的过程中,如果Master宕机了,这个时候就会有两种情况的问题。

  • 事务还没发送到Slave上:若事务还没发送Slave上,客户端在收到失败结果后,会重新提交事务,因为重新提交的事务是在新的Master上执行的,所以会执行成功,后面若是之前的Master恢复后,会以Slave的身份加入到集群中,这个时候,之前的事务就会被执行两次,第一次是之前此台机器作为Master的时候执行的,第二次是做为Slave后从主库中同步过来的。
  • 事务已经同步到Slave上:因为事务已经同步到Slave了,所以当客户端收到失败结果后,再次提交事务,你那么此事务就会再当前Slave机器上执行两次。

为了解决上面的隐患,MySQL从5.7版本开始,增加了一种新的半同步方式。新的半同步方式的执行过程是将“Storage Commit”这一步移动到了“Write Slave dump”后面。这样保证了只有Slave的事务ACK后,才提交主库事务。MySQL 5.7.2版本新增了一个参数来进行配置:rpl_semi_sync_master_wait_point,此参数有两个值可配置:

  • AFTER_SYNC:参数值为AFTER_SYNC时,代表采用的是新的半同步复制方式。
  • AFTER_COMMIT:代表采用的是之前的旧方式的半同步复制模式。

新的半同步复制
MySQL从5.7.2版本开始,默认的半同步复制方式就是AFTER_SYNC方式了,但是方案不是万能的,因为AFTER_SYNC方式是在事务同步到Slave后才提交主库的事务的,若是当主库等待Slave同步成功的过程中Master挂了,这个Master事务提交就失败了,客户端也收到了事务执行失败的结果了,但是Slave上已经将binLog的内容写到Relay Log里了,这个时候,Slave数据就会多了,但是多了数据一般问题不算严重,多了总比少了好。MySQL,在没办法解决分布式数据一致性问题的情况下,它能保证的是不丢数据,多了数据总比丢数据要好。

这里说几个的半同步复制模式的参数:

1
2
3
4
5
6
7
8
9
10
11
12
sql复制代码mysql> show variables like '%Rpl%';
+-------------------------------------------+------------+
| Variable_name | Value |
+-------------------------------------------+------------+
| rpl_semi_sync_master_enabled | ON |
| rpl_semi_sync_master_timeout | 10000 |
| rpl_semi_sync_master_trace_level | 32 |
| rpl_semi_sync_master_wait_for_slave_count | 1 |
| rpl_semi_sync_master_wait_no_slave | ON |
| rpl_semi_sync_master_wait_point | AFTER_SYNC |
| rpl_stop_slave_timeout | 31536000 |
+-------------------------------------------+------------+
1
2
3
4
5
6
7
8
9
10
sql复制代码-- 半同步复制模式开关
rpl_semi_sync_master_enabled
-- 半同步复制,超时时间,单位毫秒,当超过此时间后,自动切换为异步复制模式
rpl_semi_sync_master_timeout
-- MySQL 5.7.3引入的,该变量设置主需要等待多少个slave应答,才能返回给客户端,默认为1。
rpl_semi_sync_master_wait_for_slave_count
-- 此值代表当前集群中的slave数量是否还能够满足当前配置的半同步复制模式,默认为ON,当不满足半同步复制模式后,全部Slave切换到异步复制,此值也会变为OFF
rpl_semi_sync_master_wait_no_slave
-- 代表半同步复制提交事务的方式,5.7.2之后,默认为AFTER_SYNC
rpl_semi_sync_master_wait_point
GTID模式

MySQL从5.6版本开始推出了GTID复制模式,GTID即全局事务ID (global transaction identifier)的简称,GTID是由UUID+TransactionId组成的,UUID是单个MySQL实例的唯一标识,在第一次启动MySQL实例时会自动生成一个server_uuid, 并且默认写入到数据目录下的auto.cnf(mysql/data/auto.cnf)文件里。TransactionId是该MySQL上执行事务的数量,随着事务数量增加而递增。这样保证了GTID在一组复制中,全局唯一。

这样通过GTID可以清晰的看到,当前事务是从哪个实例上提交的,提交的第多少个事务。

来看一个GTID的具体形式:

1
2
3
4
5
6
7
sql复制代码mysql> show master status;
+-----------+----------+--------------+------------------+-------------------------------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+-----------+----------+--------------+------------------+-------------------------------------------+
| on.000003 | 187 | | | 76147e28-8086-4f8c-9f98-1cf33d92978d:1-322|
+-----------+----------+--------------+------------------+-------------------------------------------+
1 row in set (0.00 sec)

GTID:76147e28-8086-4f8c-9f98-1cf33d92978d:1-322
UUID:76147e28-8086-4f8c-9f98-1cf33d92978d
TransactionId:1-322

GTID的工作原理

由于GTID在一组主从复制集群中的唯一性,从而保证了每个GTID的事务只在一个MySQL上执行一次。
那么是怎么实现这种机制的呢?GTID的原理又是什么样的呢?

当从服务器连接主服务器时,把自己执行过的GTID(Executed_Gtid_Set: 即已经执行的事务编码)以及获取到GTID(Retrieved_Gtid_Set: 即从库已经接收到主库的事务编号)都传给主服务器。主服务器会从服务器缺少的GTID以及对应的transactionID都发送给从服务器,让从服务器补全数据。当主服务器宕机时,会找出同步数据最成功的那台conf服务器,直接将它提升为主服务器。若是强制要求某一台不是同步最成功的一台从服务器为主,会先通过change命令到最成功的那台服务器,将GTID进行补全,然后再把强制要求的那台机器提升为主。

主要数据同步机制可以分为这几步:

  • ==master更新数据时,在事务前生产GTID,一同记录到binlog中。==
  • ==slave端的i/o线程,将变更的binlog写入到relay log中。==
  • ==sql线程从relay log中获取GTID,然后对比Slave端的binlog是否有记录。==
  • ==如果有记录,说明该GTID的事务已经执行,slave会忽略该GTID。==
  • ==如果没有记录,Slave会从relay log中执行该GTID事务,并记录到binlog。==
  • ==在解析过程中,判断是否有主键,如果没有主键就使用二级索引,再没有二级索引就扫描全表。==

初始结构如下图
GTID
当Master出现宕机后,就会演变成下图。
GTID复制
通过上图我们可以看出来,当Master挂掉后,Slave-1执行完了Master的事务,Slave-2延时一些,所以没有执行完Master的事务,这个时候提升Slave-1为主,Slave-2连接了新主(Slave-1)后,将最新的GTID传给新主,然后Slave-1就从这个GTID的下一个GTID开始发送事务给Slave-2。这种自我寻找复制位置的模式减少事务丢失的可能性以及故障恢复的时间。

GTID的优劣势

通过上面的分析我们可以得出GTID的优势是:

  • ==每一个事务对应一个执行ID,一个GTID在一个服务器上只会执行一次;==
  • ==GTID是用来代替传统复制的方法,GTID复制与普通复制模式的最大不同就是不需要指定二进制文件名和位置;==
  • ==减少手工干预和降低服务故障时间,当主机挂了之后通过软件从众多的备机中提升一台备机为主机;==

GTID的缺点也很明显:

  • ==首先不支持非事务的存储引擎;==
  • ==不支持create table … select 语句复制(主库直接报错);(原理: 会生成两个sql, 一个是DDL创建表SQL, 一个是insert into 插入数据的sql; 由于DDL会导致自动提交, 所以这个sql至少需要两个GTID, 但是GTID模式下, 只能给这个sql生成一个GTID)==
  • ==不允许一个SQL同时更新一个事务引擎表和非事务引擎表;==
  • ==在一个MySQL复制群组中,要求全部开启GTID或关闭GTID。==
  • ==开启GTID需要重启 (mysql5.7除外);==
  • ==开启GTID后,就不再使用原来的传统复制方式(不像半同步复制,半同步复制失败后,可以降级到异步复制);==
  • ==对于create temporary table 和 drop temporary table语句不支持;==
  • ==不支持sql_slave_skip_counter;==

其实GTID的这部分内容挺多的,如果有想深入研究的可以去看看这篇文章。
最后说几个开启GTID的必备条件:

  • MySQL 5.6 版本,在my.cnf文件中添加:
1
2
3
4
sql复制代码gtid_mode=on (必选)                    #开启gtid功能
log_bin=log-bin=mysql-bin (必选) #开启binlog二进制日志功能
log-slave-updates=1 (必选) #也可以将1写为on
enforce-gtid-consistency=1 (必选) #也可以将1写为on
  • MySQL 5.7或更高版本,在my.cnf文件中添加:
1
2
3
4
sql复制代码gtid_mode=on    (必选)
enforce-gtid-consistency=1 (必选)
log_bin=mysql-bin (可选) #高可用切换,最好开启该功能
log-slave-updates=1 (可选) #高可用切换,最好打开该功能

参考:

《高性能MySQL》

MySQL 基于GTID复制模式

本文转载自: 掘金

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

1…683684685…956

开发者博客

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