Unicode起源#

ASCII#

ASCII(American Standard Code for Information Interchange)是一种字符编码标准,它使用7位二进制数来表示128个字符,包括大小写字母、数字、标点符号、控制字符等。ASCII编码是由美国国家标准协会(ANSI)制定的,于1963年发布,是最早的字符编码标准之一。

ASCII不够用了#

随着计算机不仅仅用于英文,而是用于全球各种语言,ASCII编码已经不能满足需求,针对不同语言的编码方案也应运而生,这其中诞生了很多编码方案,比如GB2312、BIG5、ISO-8859等,这些字符集典型的就是将ASCII的最高位利用起来,将7位扩展到8位,这样就可以表示256个字符。比如ISO-8859-1就是将ASCII的最高位利用起来,表示了拉丁字母。ISO-8859-5表示了西里尔字母。

这些字符集各自不包含全部的字符,而且不兼容,这就导致了字符集混乱。这导致在一个文件中混用多种字符成为了不可能完成的事情。而Unicode改变了这一切,它的愿景就是Unicode官网中说到的。

1
Everyone in the world should be able to use their own language on phones and computers.

在早期,Unicode曾想过固定使用16位来表示字符,这就是UCS-2编码,也是UTF-16的前身,后面发现固定16位字符还是不够用,这才发展成了我们现在熟知的Unicode。

Unicode介绍#

Unicode是一个文本编码标准。Unicode通过一个唯一的数字来定义每个字符,不管平台、程序或语言。这个数字叫做码点(code point)。Unicode码点是从0x000000到0x10FFFF(十六进制),书写上通常使用U+打头,跟上至少4位十六进制数(不足则补0),如U+0041(字母A)、U+1F600(emoji 😀),理论上,Unicode可以定义1114112个字符。

Unicode的码点跟字符是怎么对应的呢?Unicode将这些码点分成了若干个区段,每个区段称为一个平面(plane),每个平面包含65536(对应低位的0x0000~0xffff)个码点。Unicode总共有17个平面,编号从0到16。Unicode的码点分布如下:

平面编号 码点区间 英文缩写 英文名 中文名
0 号平面 U+000000 - U+00FFFF BMP Basic Multilingual Plane 基本多文种平面
1 号平面 U+010000 - U+01FFFF SMP Supplementary Multilingual Plane 多文种补充平面
2 号平面 U+020000 - U+02FFFF SIP Supplementary Ideographic Plane 表意文字补充平面
3 号平面 U+030000 - U+03FFFF TIP Tertiary Ideographic Plane 表意文字第三平面
4 号平面 ~ 13 号平面 U+040000 - U+0DFFFF / 已分配,但尚未使用 /
14 号平面 U+0E0000 - U+0EFFFF SSP Supplementary Special-purpose Plane 特别用途补充平面
15 号平面 U+0F0000 - U+0FFFFF PUA-A Private Use Area-A 保留作为私人使用区 (A区)
16 号平面 U+100000 - U+10FFFF PUA-B Private Use Area-B 保留作为私人使用区 (B区)

中文、英文均在0号平面,详细的分配可以参考Unicode的RoadMap

那么Unicode先定义了码点和字符之间的对应关系,但是如何存储在磁盘上,如何在网络中传输,这就引入了编码方式,编码方式决定了Unicode的码点如何转换为字节流。这就是Unicode定义的三种编码方式:UTF-32、UTF-16、UTF-8。

UTF-32(32-bit Unicode Transformation Format)#

在介绍完Unicode之后,UTF-32是最简单、最容易想到的一种编码方式,直接将Unicode的码点以32位整数的方式存储起来。其中Rust的字符类型char,就使用32位值来表示Unicode字符。

但是这种方式也有很显然的缺点,就是浪费空间,实际Unicode的范围,只需要21位就可以表示了,变长编码就应运而生。

UTF-16(16-bit Unicode Transformation Format)#

这里我想给大家讲一个背景知识,编码方案的扩展,通常会尝试去兼容旧的编码方案,这使得新的编辑器可以打开旧的文件,如果没有用到新的字符,那么新的文件也可以被旧的编辑器打开。这使得演进更加平滑,更易落地。

那就不得不先说一下UCS-2编码方案,如前所述,UCS-2想通过固定16位来表示字符,虽然它最终失败了,但是也影响了很多的系统,比如Windows、Jdk。

UTF-16编码就以兼容UCS-2编码、变长为两个目标,UTF-16的编码规则

  • ① 对于码点小于等于U+FFFF的字符,直接使用16位表示,兼容UCS-2
  • ② 对于码点小于等于U+10FFFF的字符,使用两个16位表示

对于变长编码来说,对于文件中的任意一个字符,怎么能判断出来这是场景①的字符,还是场景②的第一个字符?抑或是场景②的第二个字符?

Unicode给出的答案是,通过在BMP中舍弃U+D800到U+DFFF的码点,这个区间被称为代理对(surrogate pair),这个区间的码点不会被分配给字符,这样就可以通过这个区间来判断是场景①还是场景②。如果读取的时候,发现前两个字节是D8到DB,那么就是场景②的第一个字符;如果是DC到DF,那么就是场景②的第二个字符;否则就是场景①的字符。

  • 高代理(High-half surrogates):范围是0xD800~0xDBFF,二进制范围为1101 1000 0000 ~ 1101 1111 1111,这也代表着高代理的前六位一定是110110。
  • 低代理(Low-half surrogates):范围是0xDC00~0xDFFF,二进制范围为1101 1100 0000 ~ 1101 1111 1111,这也代表着低代理的前六位一定是110111。

那么聪明的读者应该分析出来了,使用两个16位表示,由于存在代理对的固定部分,剩余的有效位还剩下20位。这20位恰好可以覆盖从U+010000到U+10FFFF的码点范围。由于U+0000-U+FFFF已经在场景①中覆盖,通过将码点减去0x10000,范围就变成了0x000000~0x0FFFFF,恰好是20位整数。

UTF-8(8-bit Unicode Transformation Format)#

UTF-8的编码规则

  • ① 对于码点小于等于U+007F的字符,直接使用8位表示,兼容ASCII。
  • ② 对于码点小于等于U+07FF的字符,使用两个8位表示,其中有效位为11位。
  • ③ 对于码点小于等于U+FFFF的字符,使用三个8位表示,其中有效位为16位。
  • ④ 对于码点小于等于U+10FFFF的字符,使用四个8位表示,其中有效位为21位。
  • ⑤ 使用n个字节(n>1)来表示一个字符时,第一个字节的前n位都是1,第n+1位是0,后面的字节的前两位都是10

那么对于一个字节,就可以通过首位是不是1,来判断是1个字节还是n个字节,再通过第二个字节判断是否是首位,最后通过首位来判断字节的个数。

由于UTF-8的有效位最大可达21位,这也就使得UTF-8不用像UTF-16那样减去0x10000。

通过兼容ASCII,最短只用1个字节,这使得UTF-8成为了堪称最流行的编码方式,如果不需要兼容UCS-2,那么几乎可以说UTF-8是最好的选择,堪称当前事实上的标准。值得一提的是,UTF-8的主要设计者,也是Unix的创始人之一,Go语言的设计者之一,Ken Thompson

扩展知识#

JDK17中英文字符集内存占用量降低了一半#

读者可能会觉得JDK17中中文字符内存占用降低一半是从UTF-16切换到UTF-8导致的,但实则不然,对于JDK来说,切换一种编码方式可谓是伤筋动骨,JDK17通过了JEP254提案,通过添加一个标志位,如果字符串的字符都是ISO-8859-1/Latin-1字符,那么就使用一个字节进行存储。

本文包含,Gin项目推荐布局,一些最佳实践等等。

Gin项目推荐布局#

假设项目名称叫Hearth

  • xxx、yyy代表大块的业务区分:如用户、订单、支付
  • aaa、bbb代表小块的业务区分:如(用户的)登录、注册、查询
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
example/
|-- cmd/
| |-- production/
| |-- hearth.go
| |-- local/
| |-- hearth_local.go
|-- pkg/
| |-- apimodel/ 存放所有的ApiModel,用oapi-codegen解析uadp yaml来生成
| |-- boot/
| |-- boot.go //装备Struct,用于Lauch整个项目
| |-- handler/
| |-- xxx/
| |-- xxx_aaa_handler.go
| |-- xxx_bbb_handler.go
| |-- yyy/
| |-- yyy_model.go
| |-- yyy_aaa_handler.go
| |-- yyy_bbb_handler.go
| |-- xxx/
| |-- xxx_aaa_model.go // 存放持久化model,如数据库表,消息中间件结构,redis结构等
| |-- xxx_aaa_service.go
| |-- yyy/
| |-- yyy_bbb_model.go
| |-- yyy_bbb_service.go
| |-- ignite/
| |-- ignite.go
| |-- ignite_test.go
| |-- influx/
| |-- influx.go
| |-- influx_test.go
|-- docker-build/
| |-- scripts/
| |-- start.sh
|-- Dockerfile

放弃的布局方式#

此种布局比较适合独立的包,对api结构体的操作复用较差#

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
example/
|-- cmd/
| |-- production/
| |-- hearth.go
| |-- local/
| |-- hearth_local.go
|-- pkg/
| |-- boot/
| |-- boot.go //装备Struct,用于Lauch整个项目
| |-- handler/
| |-- xxx/
| |-- xxx_model.go // 将大块业务的model也放在这里,可以使用oapi-codegen来生成结构体
| |-- xxx_aaa_handler.go
| |-- xxx_bbb_handler.go
| |-- yyy/
| |-- yyy_model.go
| |-- yyy_aaa_handler.go
| |-- yyy_bbb_handler.go
| |-- xxx/
| |-- xxx_aaa_model.go // 存放持久化model,如数据库表,消息中间件结构,redis结构等
| |-- xxx_aaa_service.go
| |-- yyy/
| |-- yyy_bbb_model.go
| |-- yyy_bbb_service.go
| |-- ignite/
| |-- ignite.go
| |-- ignite_test.go
| |-- influx/
| |-- influx.go
| |-- influx_test.go
|-- docker-build/
| |-- scripts/
| |-- start.sh
|-- Dockerfile

SaaS服务全局级别的功能#

前端调用SaaS服务一个全局级别的接口

sequenceDiagram
    participant User as 用户
    participant Frontend as 前端
    participant Backend as 后端

    User->>Frontend: 点击页面
    Frontend->>Backend: 请求当前功能集
    Backend->>Backend: 返回当前功能集
    alt 用户有权限
        Backend->>Frontend: 返回全局数据
        Frontend->>User: 显示数据
    else 用户无权限
        Backend->>Frontend: 返回错误信息
        Frontend->>User: 显示错误信息
    end

SaaS服务用户级别的功能#

前端调用SaaS服务一个用户权限的接口

sequenceDiagram
    participant User as 用户
    participant Frontend as 前端
    participant Backend as 后端

    User->>Frontend: 点击页面
    Frontend->>Backend: 查看用户权限
    Backend->>Backend: 验证用户权限
    alt 用户有权限
        Backend->>Frontend: 返回用户项目数据
        Frontend->>User: 显示数据
    else 用户无权限
        Backend->>Frontend: 返回错误信息
        Frontend->>User: 显示错误信息
    end

前提#

Mysql8.0.X版本,且核心配置如下

1
2
3
gtid_mode=ON
binlog_format=row
slave_skip_errors=all

数据不一致的根本原因在于MySQL在设计上不具备分布式系统的完整语义,这导致主从复制在面对网络分区和延迟时无法保持数据一致性。(又不可能采取全同步的模式,那就变成一个CP系统了)。根据数据冲突的内容,如果是”不同主键,不触发唯一键约束的数据冲突”,那么后续很容易可以同步到一致。如果触发了主键或者唯一键的冲突,无法互相同步,场景会变得复杂一些,简而言之,只有当后续的操作可以同时在主/备两个数据库中抹平这个差距,数据才能恢复,并且约束越多,抹平也就变得愈困难。举例

  • 仅存在主键约束,数据内容不同,通过下次操作主键(update/delete),则可以恢复
  • 数据库自增主键(两条数据主键不同),触发了唯一字段约束,后续的操作要同时抹平主键、唯一字段、其他内容才能恢复一致(比如根据相同的条件删除掉这条数据等)

下文将分别以插入为例讨论这几个场景,用红色叉号代表同步延迟或者断开。

注:由于Mysql主备同步时会将upsert类的sql转换为实际执行的insert、update语句,也就是说upsert的语义在主备同步不稳定/切换时,容易丢失。

不同主键,不触发唯一键约束的数据冲突#

设想表结构,仅有一个name字段,且name为主键。比如我们先在MysqlA中插入了数据name=tt,假设发生了切换,又向MysqlB插入了数据name=wtt。

mysql-case1-insert-data

这就导致MysqlA与MysqlB里面的数据存在着不一致,但是一旦同步恢复,数据就会一致。

mysql-case1-sync-success

仅主键约束,内容不一致冲突#

表结构,拥有两个字段,name为主键,age为字段。

同样,插入了两条数据,导致冲突。

mysql-case2-insert-data

即使MysqlA和MysqlB之间同步恢复,后续insert语句也会由于主键冲突同步失败。

mysql-case2-sync-fail

这种不一致要等到后续对主键进行update操作后,才能恢复一致

mysql-case2-recovery

包含主键、唯一约束在内的冲突场景#

主键为数据库自增主键,其中一个库为奇数,另一个库为偶数。同时还有唯一约束name

mysql-case3-insert-data

这时候插入数据,就会导致不一致,并且主键也不相同,由于业务不感知主键,使用不存在则更新的语法也会导致主键不一致。

mysql-case3-upsert-data

可以预想到即使恢复同步,MysqlA和MysqlB数据也无法一致。

mysql-case3-sync-fail

在这种场景下,任何针对id的SQL操作都无法在双方数据库中成功同步。例如,MysqlB数据库中不存在id为0的记录,而MysqlA中不存在id为1的记录,导致同步操作失败。

想要恢复一致,可以通过业务唯一约束来删除记录或者是根据业务约束把Mysql主键id也一并更新(不过这很困难,一般这种业务是不会直接操作id的)

那么可能会有人有疑问,为什么不像之前那样,用name作为唯一主键呢?

答:业务的需求多种多样,而且如果唯一约束由多个字段组成,使用Mysql自增主键是唯一的选择。

总结#

本文探讨了Mysql异步复制模式下的数据不一致问题,容易在什么时候产生,什么时候恢复。总的来说,业务如果只有一个唯一主键,出现不一致的概率更小。如果业务用数据库自增作为主键,同时伴有唯一约束的插入操作(如upsert等),更容易出现长期的不一致。

WebFlux是Spring 5引入的新的响应式编程框架,它提供了一种基于反应式流的编程模型,可以用于构建高性能、高吞吐量的Web应用程序。

防止大量请求堆积#

限制同一时间的并发处理个数#

由于WebFlux可以处理大量的请求,如果后端处理较慢(如写db较慢等),可能会导致大量的请求堆积,可以通过限制同一时间的并发处理个数来防止请求堆积。

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
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

import java.util.concurrent.Semaphore;

@Component
public class ConcurrencyLimitingFilter implements WebFilter {
private final Semaphore semaphore;

public ConcurrencyLimitingFilter() {
this.semaphore = new Semaphore(10);
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
if (semaphore.tryAcquire()) {
return chain.filter(exchange)
.doFinally(sig -> semaphore.release());
} else {
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return exchange.getResponse().setComplete();
}
}
}

配置超时时间#

网络编程中,任何操作都应该有超时时间。WebFlux允许大量的请求进入,如果不设置超时时间,可能会导致大量的请求排队处理(可能客户端早已放弃),可以通过统一Filter来设置最大超时时间。

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
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

import java.time.Duration;
import java.util.concurrent.TimeoutException;

@Slf4j
@Component
public class WebRequestTimeoutFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return chain.filter(exchange)
.timeout(Duration.ofSeconds(10))
.onErrorResume(TimeoutException.class, e -> {
log.error("Request timeout", e);
return Mono.error(new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "Request timeout"));
});
}
}

问题现象#

在一个使用Spring R2dbc与Mysql8.x的项目中,当创建 一个REST资源,进行创建,返回的毫秒精度时间戳,和下一瞬间查询的时间戳不一致。sql及代码大概如下

1
2
3
4
5
6
CREATE TABLE person (
id INT PRIMARY KEY,
name VARCHAR(255),
created_time DATETIME(3),
updated_time DATETIME(3)
);

实体类定义

1
2
3
4
5
6
7
8
9
10
@Entity
class PersonEntity {
@Id
private Long id;
private String name;
@CreatedDate
private LocalDateTime createdTime;
@LastModifiedDate
private LocalDateTime updatedTime;
}

这里使用了@CreatedDate@LastModifiedDate注解,并在Application类上配置了@EnableR2dbcAuditing注解用于在Repo操作实体的时候,自动更新时间戳。

1
2
public interface PersonRepo extends ReactiveCrudRepository<PersonEntity, Long> {
}

创建代码类比如下,大概就是使用r2dbc操作数据,并将r2dbc返回的实体用于转换毫秒时间戳

1
2
3
4
5
6
7
8
9
10
11
12
13
return createPersonReq
.flatMap(req -> {
PersonPo personPo = new PersonPo();
personPo.setAge(18);
personPo.setName(req.getName());
return personRepo.save(personPo);
})
.map(person -> {
PersonResp personResp = new PersonResp();
personResp.setName(person.getName());
personResp.setCreatedTime(TimeUtil.format(person.getCreatedTime()));
return new ResponseEntity<>(personResp, null, HttpStatus.CREATED);
});

然而创建的时候返回的时间戳和查询的时间戳不一致,现象举例:
创建的时候返回:2024-05-08T08:11:47.333Z
查询的时候却返回:2024-05-08T08:11:47.334Z

走读代码,发现代码基本上万无一失,那么问题出在哪里呢?

通过仔细观察时间戳的区别,发现时间戳的变化都在最后一位,且相差为一,醒悟到这估计是由于内存中纳秒时间戳精度在转化为数据库毫秒时间戳的时候,部分库的行为是截断,部分库的行为是四舍五入,导致了这个问题。

最终通过写demo,docker抓包复现了这个问题,如下图所示,mysql server会将接收的时间戳进行四舍五入,而java常见的format工具类都是截断,导致了这一不一致。同时,这也体现了,r2dbc返回的entity可能并不是实际存入数据的内容,而是”原始”的entity。

r2dbc-weird-timestamp-change.jpeg

r2dbc与mysql的时间精度失调问题#

在这个问题里面,存在三个时间精度:

  • 内存中的时间精度
  • r2dbc发给mysql的时间精度,有趣的是,r2dbc发给mysql的时间精度,并不是sql中列定义的精度,而是mysql server所能支持的最高精度即微秒精度。
  • mysql实际存储的时间精度

r2dbc返回的entity可能并不是实际存入数据的内容,而是经过r2dbc处理之后,发送到数据库之前的entity。问题的关键就在r2dbc并不根据列定义的精度处理数据,而是根据mysql server支持的最高精度处理数据。

解决问题的方式有几种:

  • 将mysql列定义到微秒级别精度,优选方案
  • 在进入r2dbc之前,将时间戳截断到mysql列定义的精度
  • 在r2dbc返回的entity中,将时间戳截断到mysql支持的精度。这其实对开发者的心智负担较重,返回的entity并不是实际存储的,使用前要做进位,限制也比较大。

在进入r2dbc之前,将时间戳截断到数据库表定义的精度,也有两种方式

  • 不使用@CreatedDate@LastModifiedDate注解,而是在应用程序中手动设置时间戳
  • 继续使用@CreatedDate@LastModifiedDate注解,通过拦截器统一进位

通过拦截器的代码如下,定义基类,不然每个实体类都要书写拦截器。一般来说,一个项目里,时间戳的精度都应该统一,所以可以定义一个统一的拦截器。

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

import lombok.ToString;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;

import java.time.LocalDateTime;

@ToString
public abstract class AuditableEntity {
@CreatedDate
protected LocalDateTime createdTime;

@LastModifiedDate
protected LocalDateTime updatedTime;

public LocalDateTime getCreatedTime() {
return createdTime;
}

public void setCreatedTime(LocalDateTime createdTime) {
this.createdTime = createdTime;
}

public LocalDateTime getUpdatedTime() {
return updatedTime;
}

public void setUpdatedTime(LocalDateTime updatedTime) {
this.updatedTime = updatedTime;
}
}
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
import org.reactivestreams.Publisher;
import org.springframework.data.r2dbc.mapping.OutboundRow;
import org.springframework.data.r2dbc.mapping.event.BeforeSaveCallback;
import org.springframework.data.relational.core.mapping.event.BeforeConvertCallback;
import org.springframework.data.relational.core.sql.SqlIdentifier;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;

@Component
public class AuditableEntityCallback implements BeforeSaveCallback<AuditableEntity>, BeforeConvertCallback<AuditableEntity> {

@Override
public Publisher<AuditableEntity> onBeforeSave(AuditableEntity entity, OutboundRow row, SqlIdentifier table) {
System.out.println("before save " + entity.getCreatedTime());
entity.setCreatedTime(roundToMilliseconds(entity.getCreatedTime()));
entity.setUpdatedTime(roundToMilliseconds(entity.getUpdatedTime()));
System.out.println("before save " + entity.getCreatedTime());
return Mono.just(entity);
}

@Override
public AuditableEntity onBeforeConvert(AuditableEntity entity) {
System.out.println("before convert " + entity.getCreatedTime());
entity.setCreatedTime(roundToMilliseconds(entity.getCreatedTime()));
entity.setUpdatedTime(roundToMilliseconds(entity.getUpdatedTime()));
System.out.println("before convert " + entity.getCreatedTime());
return entity;
}

private static LocalDateTime roundToMilliseconds(LocalDateTime dateTime) {
LocalDateTime localDateTime = dateTime.truncatedTo(ChronoUnit.MILLIS);
int dateTimeNano = dateTime.getNano() % 1000_000;
if (dateTimeNano >= 500_000) {
localDateTime = localDateTime.plusNanos(1_000_000);
}
return localDateTime;
}
}

jpa有没有问题呢?#

出于好奇,我也做了jpa的尝试,jpa也是一样的行为

jpa-weird-timestamp-change.jpeg

对于一个组件来说,日志打印常见的有三种选择:

  1. 不打印日志,只提供回调函数。将打印日志还是忽略的权利交给组件的使用者
  2. 可以由使用者设置一些参数,但组件自己管理整个日志的生命周期
  3. 适配生态内的日志框架,组件打印日志,但将输出的控制权控制反转给使用者

java生态slf4j已经成为事实上的标准,像Apache Ignite在最开始的时候也将日志作为自己的Spi定义,是向着2来发展的,但在Ignite3版本也去掉。Go生态由于去没有这样的标准,很多library只能选择2,导致引入了一个go library,它的日志会怎么出来,对于使用者来说是一个未知数。

java生态的基本原则如下:

  1. library,不会独立部署的组件,只引入slf4j-api,不引入具体的实现,可以在单元测试里面引入某个实现,用于测试打印日志。
  2. 简单的sdk不打印日志,复杂的sdk可以打印一些关键日志,但QPS级别的日志不要打印,不要替用户做选择。

如果在一个高度一致的团队内,可以无视上面两条

错误信息无模板变量#

假设我们的错误信息返回如下

1
2
HTTP/1.1 200 OK
{"error_code": "IEEE.754", "error_msg": "IEE 754 error"}

无模板变量的错误信息国际化,可以直接在前端对整体字符串根据错误码进行静态国际化。

1
2
3
4
5
6
7
8
9
10
11
// catch the error code first
const error_code = body.error_code

const error_msg_map = {
"IEEE.754": {
"en": "IEE 754 error",
"zh": "IEE 754 错误"
}
}

const error_msg = error_msg_map[error_code][lang]

错误信息包含模板变量#

假设我们的错误信息返回如下

1
2
HTTP/1.1 200 OK
{"error_code": "IEEE.754", "error_msg": "IEE 754 NbN error, do you mean Nan?"}

包含模板变量的错误信息国际化,可以在前端通过正则表达式提取,并代入到中文字符串模板中实现。如示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// catch the error code first
const error_code = body.code

const error_msg_capture_map = {
"IEEE.754": "/IEE 754 (\w+) error, do you mean (\w+)?/"
};

const error_msg_template_map = {
"IEEE.754": {
"en": "IEE 754 {{var1}} error, do you mean {{var2}}?",
"zh": "IEE 754 {{var1}} 错误,你是指 {{var2}} 吗?"
}
};

const matches = error_msg_capture_map[error_code].exec(body.error_msg);
const variables = matches.slice(1);

let error_msg = error_msg_template_map[error_code][lang];
variables.forEach((value, index) => {
error_msg = error_msg.replace(`{{var${index + 1}}}`, value);
});

从Spring的新版本开始,推荐使用构造函数的注入方式,通过构造函数注入有很多优点,诸如不变性等等。同时在构造函数上,也不需要添加@Autowire
注解就可以完成注入

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
// Before
public class ABC {
private final A a;

private final B b;

private final C c;

public ABC(@Autowire A a, @Autowire B b, @Autowire C c) {
this.a = a;
this.b = b;
this.c = c;
}
}

// After
public class ABC {
private final A a;

private final B b;

private final C c;

public ABC(A a, B b, C c) {
this.a = a;
this.b = b;
this.c = c;
}
}

但是,这种注入方式会导致变动代码的时候,需要同时修改field以及构造函数,在项目早期发展时期,这种变动显得有一些枯燥,再加上已经不需要@Autowire
注解。这时,我们可以用Lombok的@RequiredArgsConstructor来简化这个流程。

Lombok的@RequiredArgsConstructor会包含这些参数:

  • 所有未初始化的 final 字段
  • 被标记为 @NonNull 但在声明时未初始化的字段。

对于那些被标记为 @NonNull
的字段,还会生成一个显式的空检查(不过在Spring框架里这个没什么作用)。通过应用@RequiredArgsConstructor
,代码可以简化为如下模样,同时添加新的字段也不需要修改多行。

1
2
3
4
5
6
7
8
9

@RequiredArgsConstructor
public class ABC {
private final A a;

private final B b;

private final C c;
}
0%