0%


一、 Spring 对象拷贝的具体实现

Spring 对象拷贝,基于反射和内省,将源对象字段值装填到目标对象字段上。主要分以下两步:

  • 通过内省,获取源对象和目标对象的属性描述器;
  • 通过反射,解析源属性值,赋值到目标属性中;
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
/**
* Spring 对象拷贝基础方法
*
* @param source 源对象
* @param target 目标对象
* @param editable 限制目标 Class
* @param ignoreProperties 需要忽略的拷贝字段
*/
private static void copyProperties(Object source, Object target, Class<?> editable,
String... ignoreProperties) throws BeansException {

Assert.notNull(source, "Source must not be null");
Assert.notNull(target, "Target must not be null");

Class<?> actualEditable = target.getClass();
if (editable != null) {
// 如果 target 不是 editable 的实例, 则中断拷贝
if (!editable.isInstance(target)) {
throw new IllegalArgumentException("Target class [" + target.getClass().getName() +"] not assignable to Editable class [" + editable.getName() + "]");
}
actualEditable = editable;
}
// 内省目标对象, 获取其属性描述器列表
PropertyDescriptor[] targetPds = getPropertyDescriptors(actualEditable);
// 解析需要忽略拷贝的字段
List<String> ignoreList = (ignoreProperties != null ? Arrays.asList(ignoreProperties) : null);
// 遍历目标对象的属性描述器, 依次进行属性值的拷贝
for (PropertyDescriptor targetPd : targetPds) {
// 解析目标属性描述器的写入方法
Method writeMethod = targetPd.getWriteMethod();
// 如果目标属性可以写入且需要拷贝, 则内省源对象, 获取对应的属性描述器, 读取属性值并拷贝到目标属性中
if (writeMethod != null && (ignoreList == null || !ignoreList.contains(targetPd.getName()))) {
// 内省源对象, 缓存属性描述器, 并根据目标属性名称取出对应的源属性的描述器
PropertyDescriptor sourcePd = getPropertyDescriptor(source.getClass(), targetPd.getName());
if (sourcePd != null) {
// 解析源属性值的读取方法
Method readMethod = sourcePd.getReadMethod();
if (readMethod != null
&& ClassUtils.isAssignable(writeMethod.getParameterTypes()[0],
readMethod.getReturnType())) {

try {
if (!Modifier.isPublic(readMethod.getDeclaringClass().getModifiers())) {
readMethod.setAccessible(true);
}
// 读取源属性值
Object value = readMethod.invoke(source);
if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers())) {
writeMethod.setAccessible(true);
}
// 写入目标属性
writeMethod.invoke(target, value);
} catch (Throwable ex) {
throw new FatalBeanException("Could not copy property '" + targetPd.getName() + "' from source to target", ex);
}
}
}
}
}
}

二、 BeanUtils.copyProperties实现原理

根据以上分析,整合出 Spring 对象拷贝的实现原理:

通过内省机制,对 Bean 进行拆分,得到每个属性的描述器,缓存在 Map 中,Key为变量名,Value为属性描述器。属性描述器主要包括:属性名称、读取属性值的方法、设置属性值的方法。拷贝过程中,先获取目标属性的写入方法,再获取对应源属性的读取方法,最后通过反射拷贝属性值。

三、JavaBean内省机制

JavaBean 内省,是建立在反射基础上的,通过解析 Bean各个属性的描述器,以便通过属性描述器来操作 Bean 的一种机制。反射是将 Java 类中的各种成分映射成相应的 Java 类,可以获取所有属性以及调用任何方法。与反射不同的是,内省是通过属性描述器来暴露一个 Bean 的属性、方法和时间的,而且只有符合 JavaBean 规则的类的成员才可以调用内生 API 进行操作。

内省在 java.beans.Introspector中的具体实现:

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
// 获取所有的 public 方法
Method methodList[] = getPublicDeclaredMethods(beanClass);
// 循环遍历处理每一个 public 方法, 为方便讲解, 此处我们以第一个方法为例...

Method method = methodList[0];
if (method == null) {
continue;
}
// 跳过 static 方法
int mods = method.getModifiers();
if (Modifier.isStatic(mods)) {
continue;
}
// 获取该方法的名称, 如setAge、getAge
String name = method.getName();
// 获取该方法的入参和返参
Class<?>[] argTypes = method.getParameterTypes();
Class<?> resultType = method.getReturnType();
// 获取该方法的入参个数
int argCount = argTypes.length;
PropertyDescriptor pd = null;

if (argCount == 0) {
// 1. 没有入参: 说明是获取属性值的方法
if (name.startsWith(GET_PREFIX)) {
// 1.1 该方法名称以 get 开头, 如 getAge
pd = new PropertyDescriptor(this.beanClass, name.substring(3), method, null);
} else if (resultType == boolean.class && name.startsWith(IS_PREFIX)) {
// 1.2 该方法名称以 is 开头, 如 isMale, 只处理基本类型的布尔值
pd = new PropertyDescriptor(this.beanClass, name.substring(2), method, null);
}
} else if (argCount == 1) {
// 2. 有一个入参
if (int.class.equals(argTypes[0]) && name.startsWith(GET_PREFIX)) {
// 2.1 获取属性值的方法, 如 getChild(Integer index), 则封装成索引属性器
pd = new IndexedPropertyDescriptor(this.beanClass, name.substring(3), null, null, method, null);
} else if (void.class.equals(resultType) && name.startsWith(SET_PREFIX)) {
// 2.2 设置属性值的方法
pd = new PropertyDescriptor(this.beanClass, name.substring(3), null, method);
if (throwsException(method, PropertyVetoException.class)) {
pd.setConstrained(true);
}
}
} else if (argCount == 2) {
// 3. 有两个入参
if (void.class.equals(resultType) && int.class.equals(argTypes[0]) && name.startsWith(SET_PREFIX)) {
// 3.1 只处理设置属性值的方法, 如 setChild(Integer index, Child child), 则封装成索引属性器
pd = new IndexedPropertyDescriptor(this.beanClass, name.substring(3), null, null, null, method);
if (throwsException(method, PropertyVetoException.class)) {
pd.setConstrained(true);
}
}
}

return PropertyDescriptor;

由此可以看出,一个类的方法名称、入参个数、反参类型是JavaBean 内省的主要要素,可以总结为:

  • 只能内省一个类暴露的 public 非静态方法;
  • 可以内省标准化的 set 方法,如 void setAge(Integer age)
  • 可以内省标准化的 get 方法,如 ResultType getAge()
  • 可以内省设置索引属性的方法,如 setChild(Integer index, Child child)
  • 可以内省获取索引属性的方法,如 getChild(Integer index)
  • 可以内省获取基本类型布尔值的且以 is 开头的方法,如 boolean isMale()

五、总结

Spring 对象拷贝,基于反射和内省机制,通过属性描述器,将源属性值写入目标属性。如今 Spring 架构已被广泛使用,旗下各种好用的工具也是顺手拈来,但无端的滥用也潜藏着一些问题。比如 Spring 对象拷贝,要求操作的对象必须符合 JavaBean 规范,否则将无法拷贝。如拷贝包装类型的布尔值,其读取方法为 Boolean isMale ,不符合 JavaBean 规范,对应的目标属性值一定是 null


一、 携程 Dal 开源框架

Dal 是携程开源的数据库访问框架,为大规模的 DB 管理和使用提供一套优质的解决方案。

首先在 DB 管理方面,Dal 统一集成了主流程的数据访问:支持 JavaC# 客户端;支持 MySQLSQLServer 数据库;支持 ORM 和非 ORM 方式的数据访问;使用了 Emit 映射技术,提供高性能 ORM;多数据源访问和主从分离(读写分离);日志、监控集成。

其次在 DB 使用方面, Dal 支持代码生成。通过 Dal 平台,可一键生成 EntityDaoUnit Test。这不仅可以让开发者脱离 DB 编程、提升开发效率,还可以统一面向 DB 的代码风格和代码质量。

二、 DataXDal 的兼容

Dal 的主要特点是统一收口和集中管理,在 DB 连接方面,客户端无需配置 DB 的用户名和密码,只需要配置 Dal 提供的 TitanKeyClusterName 即可。简单来说,TitanKeyClusterName 就是 Dal 生成的,供客户端的访问 DB 的钥匙。也正因此,DataXDal 架构的系统上就不起作用了。需要解决两个问题:DataX 如何配置 TitanKeyClusterNameDataX 如何通过 Dal 获取数据源。

三、 Datax 配置 TitanKeyClusterName

这是 mysqlwriter 的配置信息模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "mysqlwriter",
"parameter": {
"username": "",
"password": "",
"writeMode": "",
"column": [],
"session": [],
"preSql": [],
"connection": [
{
"jdbcUrl": "",
"table": []
}
]
}
}

Dal 不关心 usernamepassword,不妨将 TitanKeyClusterName放在 jdbcurl处。

四、DataX 获取 Dal 数据源

DataX 通过如下方式获取数据连接:

1
2
3
4
5
6
7
8
9
private static synchronized Connection connect(DataBaseType dataBaseType, String url, Properties prop) {
try {
Class.forName(dataBaseType.getDriverClassName());
DriverManager.setLoginTimeout(Constant.TIMEOUT_SECONDS);
return DriverManager.getConnection(url, prop);
} catch (Exception e) {
throw RdbmsException.asConnException(dataBaseType, e, prop.getProperty("user"), null);
}
}

Dal 通过如下方式获取数据连接:

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
import javax.annotation.Resource;
import javax.sql.DataSource;
import java.sql.Connection;
import com.ctrip.datasource.configure.DalDataSourceFactory;

public final class DBUtil {

private DBUtil() {
}

@Resource
private DalDataSourceFactory dsFactory;

/**
* 数据源工厂
*/
@Bean
public DalDataSourceFactory getCtripDalDataSource() {
return new DalDataSourceFactory();
}

/**
* 通过 titanKey 获取数据连接
*/
public static Connection getConnectionByTitanKey(final String titanKey) {
try {
DataSource dataSource = dsFactory.createDataSource(titanKey);
return dataSource.getConnection();
} catch (Exception e) {
throw DataXException
.asDataXException(DBUtilErrorCode.CONN_DB_ERROR,
String.format("数据库连接失败. 因为根据您配置的连接信息:%s获取数据库连接失败. 请检查您的配置并作出修改.", titanKey), e);
}
}

/**
* 通过 clusterName 获取数据连接
*/
public static Connection getConnectionByClusterName(final String clusterName) {
try {
DataSource ds = dsFactory.getOrCreateDataSource(clusterName);
return dataSource.getConnection();
} catch (Exception e) {
throw DataXException
.asDataXException(DBUtilErrorCode.CONN_DB_ERROR,
String.format("数据库连接失败. 因为根据您配置的连接信息:%s获取数据库连接失败. 请检查您的配置并作出修改.", clusterName), e);
}
}
}

综上:借助 DataSource 工厂,就可以用 Dal 数据连接替换掉 DataX 的数据连接。

五、DataX 兼容 Dal 优化

从以上实现可以看出,获取 Dal 数据连接主要有两步:生成数据源和建立数据连接,并且每次数据同步都要重复一遍。既然 Dal 已经提供了 DataSource 工厂,是否可以考虑将数据源缓存下来呢?

所以,有一套更好的兼容方案:在工具启动过程中,加载数据源并缓存下来, dbName 作为查找数据源的 key

DataSourceConfiguration.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import com.alibaba.datax.plugin.rdbms.util.DBUtil;
import com.ctrip.datasource.configure.DalDataSourceFactory;
import com.google.common.base.Throwables;
import com.google.common.collect.Maps;
import org.apache.commons.lang.StringUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.Assert;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import javax.sql.DataSource;
import java.util.Map;

/**
* @author zourongsheng
*/
@Configuration
public class DataSourceConfiguration {

@Resource
private DalDataSourceFactory dsFactory;

/**
* 数据源工厂
*/
@Bean
public DalDataSourceFactory getCtripDalDataSource() {
return new DalDataSourceFactory();
}

private static final String CLUSTER_CONN_TYPE_FLAG = "cluster";

/**
* titan key 连接信息
*/
public static final String TITAN_KEY_TEST_DB = "test_titan_db";

/**
* cluster name 连接信息
*/
public static final String CLUSTER_NAME_TEST_DB = "test_cluster_db";

/**
* 根据 titan key 加载数据源
*/
private void fillDataSourceFromTitanKey(String titanKey) {
try {

Assert.hasText(titanKey,
"connect to db failed; titan key cannot be null or empty");

DataSource dataSource = dsFactory.createDataSource(titanKey);
// 缓存数据源
DBUtil.setDataSourceIfAbsent(titanKey, dataSource);
} catch (Exception t) {
throw Throwables.propagate(t);
}
}

/**
* 根据 cluster name 加载单库数据源
*/
private void fillDataSourceFromClusterName(String clusterName) {
try {
Assert.hasText(clusterName,
"connect to db failed; dal cluster cannot be null or empty");

// 判断是否为 clusterName 格式
Assert.isTrue(clusterName.contains(CLUSTER_CONN_TYPE_FLAG),
String.format("%s is not in a cluster format", clusterName));

DataSource dataSource = dsFactory.getOrCreateDataSource(clusterName);
// 缓存数据源
DBUtil.setDataSourceIfAbsent(clusterName, dataSource);
} catch (Exception t) {
throw Throwables.propagate(t);
}
}

@PostConstruct
public void initDataSource() {

// 通过 titan key 加载数据源
fillDataSourceFromTitanKey(TITAN_KEY_TEST_DB);

// 通过 cluster name 加载数据源
fillDataSourceFromClusterName(CLUSTER_NAME_TEST_DB);
}
}

DBUtil.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import com.alibaba.datax.common.exception.DataXException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.sql.DataSource;
import java.io.File;
import java.sql.*;
import java.util.*;
import java.util.concurrent.*;

public final class DBUtil {

private DBUtil() {
}

private static final Logger LOG = LoggerFactory.getLogger(DBUtil.class);
private static final Map<String, DataSource> DS_MAP = new ConcurrentHashMap<>();

/**
* 从外部将数据源放到Engine里
*
* @param dsName 数据源名称(后续根据名称取出数据源)
* @param ds 数据源
*/
public static void setDataSourceIfAbsent(String dsName, DataSource ds) {
if (DS_MAP.containsKey(dsName)) {
return;
}
synchronized (DS_MAP) {
if (!dsMap.containsKey(dsName)) {
DS_MAP.put(dsName, ds);
LOG.info("setDataSourceIfAbsent将数据源{}放入Engine", dsName);
}
}
}

/**
* 获取数据源
* @param dsName 数据源名称
* @return 数据源
*/
private static DataSource getDataSource(String dsName) {
// 处理 jdbc 格式的数据源名称
if (dsName.contains("?")) {
dsName = dsName.substring(0, dsName.indexOf("?"));
}

return DS_MAP.get(dsName);
}

/**
* 通过数据源名称获取数据库连接
* @param dsName 数据源名称
* @return 数据库连接
*/
public static Connection getConnection(String dsName) {
try {
// 获取数据源
DataSource dataSource = getDataSource(dsName);

Assert.notNull(dataSource, String.format("获取数据源%s失败", dsName));

return dataSource.getConnection();
} catch (Exception e) {
throw DataXException
.asDataXException(DBUtilErrorCode.CONN_DB_ERROR,
String.format("数据库连接失败. 因为根据您配置的连接信息:%s获取数据库连接失败. 请检查您的配置并作出修改.", dsName), e);
}
}
}


一、Spring Retry 使用场景之调用第三方服务

在很多应用中,都需要对接第三方平台,如调用支付平台、调用外部企业平台等。出于对接口通信的安全性考虑,第三方服务往往都会下发一个 token 给调用方,调用方只有通过这个 token 才能完成业务处理。但这个 token 是存在有效期的,因此调用方也会把 token 缓存一定的时间,在有效期内直接读取缓存,缓存过期便再次请求第三方平台下发一个新的 token,缓存起来。这样既可以减少对第三方服务的调用次数,也可以缩短接口的处理时间。

这样就存在一个问题:如果这个 token 因为种种原因在第三方是已过期的状态,而调用方的缓存未过期,那么调用方的所有业务操作都会出错。从接口逻辑上来说,报错是正确的,也是有必要的,但在系统设计层面上来看,因为 token 过期导致的所有接口报错,是不合理的。好的设计应该是在接口层重新获取 token,继续完成业务逻辑,对顶层调用无感。

因此,spring retry 在对接第三方服务的场景中,可以有效提升接口的容错性。

二、Spring Retry 在实际应用中的关注点

spring retry 本质上是代理逻辑的重复处理,每次处理的代码逻辑都是一样的,那么重复处理的意义何在?

我们以调用支付服务为例,当服务端告诉我们:“token 已过期,查询支付结果失败”,我们首先要做的事,就是把token已过期这个异常抛出去,让 spring retry 监听到这个异常,进行重试。重试的结果,必然还是“token 已过期,查询支付结果失败”,因为重试过程中使用的 token 还是已过期的 token。因此,不仅需要重试,还需要在重试之前清除 token 缓存。也就是说,spring retry 至少需要关注两点:

  • 监听异常:对于指定的异常,按照配置的策略,开启重试;
  • 清除缓存:对于指定的异常,清除缓存;

三、代码实现

在底层方法上,使用 @Retryable 和 @TokenExpiredExceptionCatch 两个注解。前者的作用是发起业务重试,后者的作用是清除 token 缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Retryable(value = {TokenExpiredException.class}, maxAttempts = 3, backoff = @Backoff(delay = 100L, multiplier = 1))
@TokenExpiredExceptionCatch
public Response invoke(Request request) {
// 从缓存中获取 token
String token = cache.getToken();
// 缓存不存在,则调用接口请求再次下发 token
if (StringUtils.isEmpty(token)) {
// 调用第三方平台,获取新的token,并缓存
token = newToken;
}
// 调用第三方服务
ResponseInfo responseInfo= Server.queryPayResult(token, request);
if (responseInfo.getCode == 1002) {
throw new TokenExpiredException(responseInfo.getMessage());
}
return responseInfo.getData();
}

自定义注解 @TokenExpiredExceptionCatch

1
2
3
4
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TokenExpiredExceptionCatch {
}

切面监听注解 @TokenExpiredExceptionCatch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Around("@annotation(com.aspects.TokenExpiredExceptionCatch)")
public Object handler(ProceedingJoinPoint joinPoint) throws Throwable {
try {
return joinPoint.proceed();
} catch (AbstractCustomException e) {
// 解析异常码值
ErrorCodeEnum ErrorCodeEnum = ErrorCodeEnum.getByCode(String.valueOf(e.getCode()));
// --------------处理 token 已过期异常--------------
if (ErrorCodeEnum.TOKEN_EXPIRED.equals(ErrorCodeEnum)) {
// 清除缓存 token
}
throw e;
}
}

四、Spring Retry 注意事项

spring retry 是一个很好用的重试工具,配置简单,对 Spring 项目兼容良好,但也不可无脑使用,让所有接口都在底层无感重试。如果支付/退款业务发生重试,极有可能引来客诉或发生资损。因此,在引入重试机制的接口上,一定要判断接口的幂等性。


一、Swicth 关键字

switchJava 中的选择语句,与 if/else 不同的是,switch 只支持常量表达式,包括 byte、short、int、char、枚举常量和字符串常量(From jdk1.7)。

通常说,引入 switch ,一是为了优化代码结构,让代码更简洁;二是为了优化性能,提升效率。为了进一步学习 switch 作用机制,本文将从字节码的角度来探索其底层实现。

二、Switch 的典型应用

2.1 对整型-int的支持
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void switchInt(int flag) {
switch (flag) {
case 1:
System.out.println("这是1");
break;
case 8:
System.out.println("这是8");
break;
case 3:
System.out.println("这是3");
break;
default:
System.out.println("这是未知数");
break;
}
}

使用 Jclasslib 查看字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 0 iload_0
1 lookupswitch 3
1: 36 (+35)
3: 58 (+57)
8: 47 (+46)
default: 69 (+68)
36 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
39 ldc #4 <这是1>
41 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
44 goto 77 (+33)
47 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
50 ldc #6 <这是8>
52 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
55 goto 77 (+22)
58 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
61 ldc #7 <这是3>
63 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
66 goto 77 (+11)
69 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
72 ldc #8 <这是未知数>
74 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
77 return

可以看出,switch 对整形-int类型的选择,是直接比较 int 值的。

2.2 对整型-byte的支持
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void switchByte(byte flag) {
switch (flag) {
case 1:
System.out.println("这是1");
break;
case 8:
System.out.println("这是8");
break;
case 3:
System.out.println("这是3");
break;
default:
System.out.println("这是未知数");
break;
}
}

使用 Jclasslib 查看字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 0 iload_0
1 lookupswitch 3
1: 36 (+35)
3: 58 (+57)
8: 47 (+46)
default: 69 (+68)
36 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
39 ldc #4 <这是1>
41 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
44 goto 77 (+33)
47 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
50 ldc #6 <这是8>
52 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
55 goto 77 (+22)
58 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
61 ldc #7 <这是3>
63 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
66 goto 77 (+11)
69 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
72 ldc #8 <这是未知数>
74 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
77 return

可以看出,switch 对整形-byte类型的选择,是先将其转化为 int 类型,再比较 int 值的。

2.3 对整型-char的支持
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void switchChar(char flag) {
switch (flag) {
case 'a':
System.out.println("这是a");
break;
case 'c':
System.out.println("这是c");
break;
case 'b':
System.out.println("这是b");
break;
default:
System.out.println("这是未知数");
break;
}
}

使用 Jclasslib 查看字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 0 iload_0
1 tableswitch 97 to 99
97: 28 (+27)
98: 50 (+49)
99: 39 (+38)
default: 61 (+60)
28 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
31 ldc #4 <这是a>
33 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
36 goto 69 (+33)
39 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
42 ldc #6 <这是c>
44 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
47 goto 69 (+22)
50 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
53 ldc #7 <这是b>
55 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
58 goto 69 (+11)
61 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
64 ldc #8 <这是未知数>
66 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
69 return

可以看出,switch 对整形-char类型的选择,是先将其转化为 int 类型,再比较 int 值的。此外,short 类型的选择也是先转化为 int 类型再做比较的,就不一一展开了。

2.4 对枚举类型的支持
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void switchEnum(PayStatusEnum flag) {
switch (flag) {
case INIT:
System.out.println("INIT");
break;
case PAYING:
System.out.println("PAYING");
break;
case PAID:
System.out.println("PAID");
break;
default:
System.out.println("非法状态");
break;
}
}

使用 Jclasslib 查看字节码:

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
0 getstatic #4 <com/example/springbatchdemo/job/Test$1.$SwitchMap$com$example$springbatchdemo$job$PayStatusEnum : [I>
3 aload_0
4 invokevirtual #5 <com/example/springbatchdemo/job/PayStatusEnum.ordinal : ()I>
7 iaload
8 tableswitch 1 to 3
1: 36 (+28)
2: 47 (+39)
3: 58 (+50)
default: 69 (+61)
36 getstatic #6 <java/lang/System.out : Ljava/io/PrintStream;>
39 ldc #7 <INIT>
41 invokevirtual #8 <java/io/PrintStream.println : (Ljava/lang/String;)V>
44 goto 77 (+33)
47 getstatic #6 <java/lang/System.out : Ljava/io/PrintStream;>
50 ldc #9 <PAYING>
52 invokevirtual #8 <java/io/PrintStream.println : (Ljava/lang/String;)V>
55 goto 77 (+22)
58 getstatic #6 <java/lang/System.out : Ljava/io/PrintStream;>
61 ldc #10 <PAID>
63 invokevirtual #8 <java/io/PrintStream.println : (Ljava/lang/String;)V>
66 goto 77 (+11)
69 getstatic #6 <java/lang/System.out : Ljava/io/PrintStream;>
72 ldc #11 <非法状态>
74 invokevirtual #8 <java/io/PrintStream.println : (Ljava/lang/String;)V>
77 return

可以看出,switch 对枚举类型的选择,是先将枚举项的ordinal()的返回值+1,再做比较的。

2.5 对字符串的支持
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void switchString(String flag) {
switch (flag) {
case "ONE":
System.out.println("这是ONE");
break;
case "TWO":
System.out.println("这是TWO");
break;
case "THREE":
System.out.println("这是THREE");
break;
default:
System.out.println("未知数");
break;
}
}

使用 Jclasslib 查看字节码:

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
  0 aload_0
1 astore_1
2 iconst_m1
3 istore_2
4 aload_1
5 invokevirtual #4 <java/lang/String.hashCode : ()I>
8 lookupswitch 3
78406: 44 (+36)
83500: 58 (+50)
79801726: 72 (+64)
default: 83 (+75)
44 aload_1
45 ldc #2 <ONE>
47 invokevirtual #5 <java/lang/String.equals : (Ljava/lang/Object;)Z>
50 ifeq 83 (+33)
53 iconst_0
54 istore_2
55 goto 83 (+28)
58 aload_1
59 ldc #6 <TWO>
61 invokevirtual #5 <java/lang/String.equals : (Ljava/lang/Object;)Z>
64 ifeq 83 (+19)
67 iconst_1
68 istore_2
69 goto 83 (+14)
72 aload_1
73 ldc #7 <THREE>
75 invokevirtual #5 <java/lang/String.equals : (Ljava/lang/Object;)Z>
78 ifeq 83 (+5)
81 iconst_2
82 istore_2
83 iload_2
84 tableswitch 0 to 2
0: 112 (+28)
1: 123 (+39)
2: 134 (+50)
default: 145 (+61)
112 getstatic #8 <java/lang/System.out : Ljava/io/PrintStream;>
115 ldc #9 <这是ONE>
117 invokevirtual #10 <java/io/PrintStream.println : (Ljava/lang/String;)V>
120 goto 153 (+33)
123 getstatic #8 <java/lang/System.out : Ljava/io/PrintStream;>
126 ldc #11 <这是TWO>
128 invokevirtual #10 <java/io/PrintStream.println : (Ljava/lang/String;)V>
131 goto 153 (+22)
134 getstatic #8 <java/lang/System.out : Ljava/io/PrintStream;>
137 ldc #12 <这是THREE>
139 invokevirtual #10 <java/io/PrintStream.println : (Ljava/lang/String;)V>
142 goto 153 (+11)
145 getstatic #8 <java/lang/System.out : Ljava/io/PrintStream;>
148 ldc #13 <未知数>
150 invokevirtual #10 <java/io/PrintStream.println : (Ljava/lang/String;)V>
153 return

可以看出,switch 对字符串类型的选择,是先比较其 hashCode,若出现哈希碰撞,再通过 String.equals做校验的。

三、Switch 的特点

3.1 本质上是 int 值的筛选

从以上使用场景和对应的字节码可知,不管 switch 筛选何种类型的数据,最终都都是将其转化为 int 类型,再做值的比较的。

3.2 不支持 long 类型

既然 switch 本质上是 int 值的筛选,那么就不支持 long 类型。在设计之初,switch 为什么不把筛选范围定到 long 呢?

首先,switch 的诞生也为了满足实际需求,而绝大多数的选择语句都是简单语句,int 值域已经可以满足绝大多数的场景了。另一方面,筛选值域越大,要求就越高,意味着底层设计也越复杂。因此,基于实际需求与设计复杂度两方面的平衡,switch 把筛选值域定为 int,是最佳实践。

3.3 对筛选值排序

以上案例出现一个很有意思的现象,如 switch 筛选1,8,3这个三个值,但字节码中的筛选顺序却是1,3,8,如果筛选值更多更乱,就更能验证这个机制——对筛选值排序。

说到排序查找,很容易想到二分查找。实际上,switch 确实引入了二分查找算法(时间复杂度:O(log2n))。在分支较多的情况下,使用二分查找,可以大大降低查找时间,提升筛选效率。

3.4 lookupswitch 和 tableswitch

从字节码可以看出,switch 的作用机制,是先比较int值,再映射到执行地址的。这种类似 map 的映射结构,专业名叫跳转表。那么 switch 的跳转表为什么会有两种呢?

从以上案例可以发现:

  • 筛选值是1,3,8时,使用的是 lookupswitch
  • 筛选值是字符串的哈希值时,使用的是 lookupswitch
  • 筛选值是a,b,c时,对应的 int 值分别是97,98,99,使用的是 tableswitch
  • 筛选值是枚举项的下标+1时,对应的 int 值分别是1,2,3,使用的是 tableswitch

阅相关文档可知:lookpswitch 应用于筛选值离散度比较高的场景,tableswitch 应用于筛选值离散度比较低的场景。这是由编译器在编辑阶段,根据分支的离散度决定的,本质上都是为了提升查找速度。

参考:stackoverflow: 为什么 switch 不支持 long 类型?


一、unsafe.Pointer 定义及使用背景

1
2
3
4
5
// ArbitraryType is here for the purposes of documentation only and is not actually
// part of the unsafe package. It represents the type of an arbitrary Go expression.
type ArbitraryType int

type Pointer *ArbitraryType

本质上,unsafe.Pointerint 类型的指针,用于各种类型指针转换的桥接。Go 语言有着严格的类型系统,弱化了指针的操作,所允许的操作仅仅操作其指向的对象,不能进行类似 C 语言的指针转换和运算。但在日常开发中,可能就需要打破这种强制限制,对内存执行任意的读写。因此,作为通用的指针类型,unsafe.Pointer 开启了一扇指针操作的“后门”。

二、unsafe.Pointer 特性

  • 任意类型的指针都可以转换为 unsafe.Pointer

  • unsafe.Pointer 可以转换为任意类型的指针;

  • uintptr 可以转换为 unsafe.Pointer

  • unsafe.Pointer 可以转换为 uintptr

    前面说到,unsafe.Pointer 是通用的指针类型,只能转换不同类型的指针,无法实现类似 C 语言的指针运算。因此 Go 引入内置类型 uintptr,以弥补类型系统带来的短板。uintptr 的官方定义:

    1
    2
    3
    // uintptr is an integer type that is large enough to hold the bit pattern of
    // any pointer.
    type uintptr uintptr

    本质上,uintptr 是一个足够大的无符号整型,可以表示任意指针的地址。相当于一个中介,可以完成指针运算或者数值类型到指针类型的转换。

三、unsafe.Pointer 应用

3.1 指针与指针之间的转换

作为通用的指针类型,unsafe.Pointer 最基本的功能就是转换不同类型的指针。从 *X 转到 *Y 要求 Y 不大于 X 且两者具有相同的内存布局。

例如 bytestring 互转。由于 Go 的类型系统限制,byte 指针是不可以直接转为 string 指针的,在编译阶段就会报错。我们需要借助 unsafe.Pointer 作为中间桥接类型来完成这个转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"fmt"
"unsafe"
)

func main() {
b := []byte{'a', 'b', 'c'}
fmt.Println(b)

// []byte -> string
s := *(*string)(unsafe.Pointer(&b))
fmt.Println(s)

// string -> []byte
bb := *(*[]byte)(unsafe.Pointer(&s))
fmt.Println(bb)
}

输出:
[97 98 99]
abc
[97 98 99]
3.2 数值与指针之间的转换

C 语言中,经常使用普通数值来表示指针,这也就意味着要完成数值与指针之间的互转。 unsafe.Pointer 是通用指针,已无能为力。因此中间人 uintptr 就派上用场了。我们借助 uintptr 先将数值转换为 unsafe.Pointer,然后再转换为任意类型的指针;或者将任意类型的指针,先转换为 unsafe.Pointer,再转换为 uintptr。实际上,数值与指针的互转也是 CGO 编程的要点之一。

例如 int64*C.char 互转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "C"
import (
"fmt"
"unsafe"
)

func main() {
var num = int64(12)

// int64 -> C.char
p := (*C.char)(unsafe.Pointer(uintptr(num)))

// C.char -> int64
num2 := int64(uintptr(unsafe.Pointer(p)))

fmt.Println(num2)
}

输出:
12
3.3 指针运算

Go 指针不仅不支持不同类型的转换,也不支持指针的运算。借助 uintptr 可以实现指针的移动和运算。

例如依次打印一个字节组信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"unsafe"
)

func main() {
data := []byte("1234")
for i := 0; i < len(data); i++ {
ptr := unsafe.Pointer(uintptr(unsafe.Pointer(&data[0])) + uintptr(i)*unsafe.Sizeof(data[0]))
fmt.Printf("%c\n", *(*byte)(ptr))
}
}

输出:
1
2
3
4

四、总结

unsafe.Pointer 的意义在于绕过 Go 的类型系统,直接读写内存,高效操作。正如字面理解那样,这是一种不安全的行为,如 uintptr 并没有指针的语义,所指向的对象存在被 GC 回收的风险。Go 是十分不鼓励这样操作的。


一、学习背景

Freemarker 是一款强大的模板引擎,可以用来生成网页、邮件、文档等。对于简单的 Word 文档导出,只需要手动编写 ftl 文件即可。但如果要导出复杂的文档,比如带有复杂样式、页眉页脚、内嵌图片、批注等,手动编写模板就行不通了。现在提出一个从目标文档出发的解决方案:先将目标 Word 模板文档转换为 xml 文档,然后将 xml 文档转换为 ftl 文档,手动替换模板中的变量之后即可导出复杂 Word

二、根据目标文档获取 ftl 文档

我们以导出房屋租赁合同文档为例,模板中有房东、租客信息、房屋信息等。

1. 将目标模板转换为 xml 文档

操作 Word 文档,点击【文件】,另存为 xml 文档。

NotePad++Sublime 打开 xml 文档,内容缺乏层次感,这里需要格式化一下。

2. 将 xml 文档转换为 ftl 文档

格式化之后的 xml 文档,选择【文件】,另存为 ftl 文档。接下来需要手动替换模板参数。

文本参数:根据模板中的默认值,找到其所在位置,直接替换。

图片参数:图片参数是对图片进行 Base64 加密之后的值,加密操作可以由 Java 来完成。

三、使用 Java 根据 ftl 模板导出 Word 文档

Resource 目录下新建文件夹 freemarker_template,将 ftl 文档粘贴进去。

图片 Base64 位编码:

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
import com.company.exception.ServiceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import sun.misc.BASE64Encoder;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

/**
* @author zourongsheng
* @version 1.0
* @date 2021/07/11 22:48
*/
@Service
public class ImageServiceImpl implements ImageService {

private static final Logger LOGGER = LoggerFactory.getLogger(ImageServiceImpl.class);

/**
* 【对图片进行 Base64 编码】
*
* @param fileSrc 图片的存储地址: filePath + fileName
* @return 图片 Base64 编码
*/
@Override
public String getImgBase64Data(String fileSrc) {

File img = new File(fileSrc);

if (!img.exists()) {
return null;
}

try (InputStream in = new FileInputStream(img)) {
byte[] data = new byte[in.available()];
in.read(data);
BASE64Encoder encoder = new BASE64Encoder();
return encoder.encode(data);
} catch (IOException e) {
LOGGER.error("invoke ImageService.getImgBase64Data error: {}", e.getMessage(), e);
throw new ServiceException(e.getMessage(), e);
}
}
}

解析模板内容实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import com.company.exception.ServiceException;
import freemarker.template.Configuration;
import freemarker.template.Template;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;

import java.io.File;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.Map;

/**
* @author zourongsheng
* @version 1.0
* @date 2021/07/11 16:14
*/
@Service
public class TemplateServiceImpl implements TemplateService {

private static final Logger LOGGER = LoggerFactory.getLogger(TemplateServiceImpl.class);

/**
* 【组装数据模板信息】
*
* @param templatePath 模板存放的根目录
* @param templateName 模板名称
* @param params 模板内容参数
* @return 数据模板信息
*/
@Override
public String getTemplateContent(String templatePath, String templateName, Map<String, Object> params) {
try {

LOGGER.info("start building template content. path: 【{}】; name: 【{}】; params: 【{}】", templatePath, templateName, params);

Assert.hasText(templatePath, "template path cannot be null or empty");

Assert.hasText(templateName, "template name cannot be null or empty");

// 获取资源目录
String resourcePath = TemplateServiceImpl.class.getResource(File.separator).getPath();

// 模板配置信息
Configuration configuration = new Configuration(Configuration.VERSION_2_3_28);
configuration.setDefaultEncoding(StandardCharsets.UTF_8.name());
String standardTemplatePath = templatePath.endsWith(File.separator) ? templatePath.concat(File.separator) : templatePath;
configuration.setDirectoryForTemplateLoading(new File(resourcePath.concat(standardTemplatePath)));

// 生成模板
Template template = configuration.getTemplate(templateName);

// 填充模板内容参数
StringWriter writer = new StringWriter();
template.process(params, writer);

String content = writer.toString();

LOGGER.info("finish building template content.");

return content;
} catch (Exception e) {
LOGGER.error("invoke TemplateService.getStringFromVm error: {}", e.getMessage(), e);
throw new ServiceException(e.getMessage(), e);
}
}
}

单元测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import ImageService;
import TemplateService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.io.IOUtils;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Resource;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

/**
* @author zourongsheng
* @version 1.0
* @date 2021/07/11 16:44
*/
@RunWith(SpringRunner.class)
@SpringBootTest
public class TemplateTest {

private static final Logger LOGGER = LoggerFactory.getLogger(TemplateTest.class);

@Resource
private TemplateService templateService;

@Resource
private ImageService imageService;

@Test
public void generateWordFromTemplate() {

String templatePath = "freemarker_template/";
String templateName = "contract.ftl";

ContractInfo contractInfo = new ContractInfo();
contractInfo.setLandlordName("地头蛇");
contractInfo.setLandlordIdNo("100011232132112");
contractInfo.setLandlordAddress("上海市青浦区");
contractInfo.setLandlordPhoneNo("13032389090");
contractInfo.setTenantName("打工人");
contractInfo.setTenantIdNo("340323199901013217");
contractInfo.setTenantAddress("安徽省蚌埠市");
contractInfo.setTenantPhoneNo("15656997878");
contractInfo.setYear("2020");
contractInfo.setMonth("01");
contractInfo.setDay("01");
// 图片 Base64 编码
String imgBase64Data = imageService.getImgBase64Data("C:\\house.jpg");
contractInfo.setImgBase64Data(imgBase64Data);

ObjectMapper objectMapper = new ObjectMapper();
Map<String, Object> params = objectMapper.convertValue(contractInfo, Map.class);

String content = templateService.getTemplateContent(templatePath, templateName, params);

File file = new File("租房合同-打工人.doc");

try (InputStream in = IOUtils.toInputStream(content, StandardCharsets.UTF_8);
OutputStream out = new FileOutputStream(file)) {

byte[] data = new byte[1024];

int len;
while (-1 != (len = in.read(data, 0, data.length))) {
out.write(data, 0, len);
}
out.flush();
} catch (Exception e) {
LOGGER.error("下载租房合同失败; errMsg: {}", e.getMessage(), e);
}
}
}

注意: 通过这种方式导出的 Word 文档,本质上还是 xml 文档,因此必须使用 .doc 后缀,具体请查看MsOffice Word docx 研究

运行起来,导出租房合同-打工人.doc

四、总结

通过将目标模板转换为 ftl 文档,再解析得到目标文档的办法,理论上可以应对任何复杂程度的文档导出需求。但这种好办法也有弊端:ftl 文档包含太多的内联样式、复杂标签等,可读性太差。当模板发生变化时,手动替换太多的模板参数将会是一种灾难。

参考:


一、docdocx 简介

doc 全程为 document,是常见的文件扩展名,也是 Word2003 及之前版本的文本文档格式,其基于二进制形式存储;docxWord2007 及之后版本的文本文档格式,其基于 Office Open XML 标准的压缩文件格式。

二、docxdoc 的区别

既然 docx 基于 ooxml 的格式,那么本质上就是一个 zip 文件。以下是内容相同的文档,分别以 docdocx 格式保存之后所占空间大小,可以看出 docx 文件明显比 doc 要小很多。


为了进一步了解 ooxml 结构,我们以一个含有页眉页脚、文本、图片的 docx 文件为例。

手动修改文件后缀为 .zip 后保存,然后解压得到文件结构:

  • rels
    • .rels: 指定主要信息、扩展信息、文档内容的引用 ID
  • docProps
    • app.xml: 扩展信息,包括字数、行数、段落数、页数等
    • core.xml:主要信息,包括创建人、修改人、创建时间、修改时间等
  • word:文档信息
    • _rels:文档引用信息
      • document.xml.rels:指定文档中的页眉页脚、主题样式、图片音视频等的引用 ID
    • media:存放文档中使用的图片、音频、视频等媒体文件
      • image1.jpg:文档中引用的图片
    • theme:文档主题信息
      • theme1.xml
    • document.xml:文档内容
    • endnotes.xml
    • fontTable.xml
    • footer1.xml:页脚信息
    • footnotes.xml
    • header1.xml:页眉信息
    • settings.xml:文档配置信息
    • styles.xml:文档样式信息
    • webSettings.xml:网页样式配置信息
  • [Content_Types].xml: 指定文件配置,包括图片类型、页眉页脚、主题样式、文档内容等

docxooxml 存储模式,将文档按照功能区划分为:配置信息、主题样式信息、页眉页脚信息、引用定义信息、媒体文件信息、文档内容信息等模块。这样便可将对应的信息抽离出来,存放在 xml 文件中。一方面,清晰的文档底层结构,方便查看内容细节,也方便进行二次开发;另一方面,文档信息分模块保存,好比把鸡蛋放在多个篮子里,可以增加容错性,使得文档修复更加方便。


综上,与 doc 相比,docx 主要有以下特点:

  • 压缩率高,存储相同内容所占空间更小;
  • 将文档信息拆分保存,方便查看或二次开发;
  • 多个 xml 文件打包,易于跨平台使用;
  • 增加文档容错性,方便修复损坏文档。

参考:


写在前面的话

最近在做数据迁移工具。经过多方调研,最后选择阿里巴巴开源工具 DataX。为了兼容携程 Dal 组件,对 DataX 连接源库和目标库的部分做了改造,以便通过 TitanKey 实现数据的同步。因此,关于数据同步的内容,计划分为三个章节:DataX 工具研究介绍、携程 Dal 组件研究介绍、DataX 整合 Dal。本篇介绍 DataX 的作用机制与使用。

一、DataX 介绍

DataX 是阿里巴巴开源出来的数据同步工具,主要解决解决各种异构数据源之间的同步难题。目前已经拥有比较完善的插件体系,包括常用的 RDBMS 数据库、NOSQL、大数据计算系统。良好的架构设计,方便开发者引入新插件,一步步构建起数据同步的生态圈。

二、目前支持的插件
类型 数据源 Reader(读) Writer(写)
RDBMS 关系型数据库 MySQL
Oracle
SQLServer
PostgreSQL
DRDS(分布式关系型数据库)
通用 RDBMS (支持所有关系型数据库)
阿里云数仓数据存储 ODPS
ADS ×
OSS
OCS
NoSQL数据存储 OTS
Hbase0.94
Hbase1.1
Phoenix4.x
Phoenix5.x
MongoDB
Hive
Cassandra
无结构化数据存储 TxtFile
FTP
HDFS
Elasticsearch
时间序列数据库 OpenTSDB ×
TSDB
三、DataX 同步机制

DataX 将数据同步过程中的读取和写入分别抽象为 Reader/Writer插件,并且以框架作为媒介,实现读取数据源和同步数据源的灵活组合。

简单来说,DataX 的设计愿景,是建立一个万能数据池(图中的 FrameWork):有无限根管道通往池子,负责导入数据;同时又有无限根管道负责向外导出数据。向池子里导入数据,依赖 Reader 插件;从池子向外导出数据,依赖 Writer 插件。这种设计的精妙之处,在于这个数据池子是万能的,就是说可以从任何一根管道导入数据,也可以将数据导出到任何一根管道。因此,我们只需要关心导入数据和导出数据的管道建设,也就是开发新的 Reader/Writer插件,便可实现多种数据源之间的同步。

四、DataX数据同步过程

名词解释:

JobDataX 执行数据同步任务的最小业务单元;

TaskDataX 执行数据同步任务的最小执行单元,由 Job 拆分而来,为实现最大的同步效率;

TaskGroup:包含一组 Task 的集合;

DataX 调度过程:

提交一个数据同步 JobDataX 后,DataX 会开启一个 Job 进程,然后根据拆分策略,将 Job 拆分为多个 Task。接下来,Job 调用 Scheduler,依据配置的并发数量,重新对拆分好的 Task 进行组合,这样组合叫做 TaskGroup。最后,TaskGroup 以一定的并发量(配置项: channel)来执行组内的 Task。任务执行过程中,DataX 框架会收集任务执行结果,并以报表的形式打印在日志中。

五、 DataX 的使用

为了演示方便,我们直接做 MySQLMySQL 库的数据同步。

首先需要获取 DataX 工具包。有两种途径:一是下载Datax=X 源码,通过 Maven 打包;二是直接下载官方工具包

然后获取数据同步配置模板。查看源码可知,为了操作方便,DataX 已经内嵌了 Python 执行脚本,可以通过脚本语言获取配置模板,以及执行后续的同步操作。这里我们执行(在 DataX 工具包的 bin 目录下):

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
python datax.py -r mysqlreader -w mysqlwriter

DataX (DATAX-OPENSOURCE-3.0), From Alibaba !
Copyright (C) 2010-2017, Alibaba Group. All Rights Reserved.


Please refer to the mysqlreader document:
https://github.com/alibaba/DataX/blob/master/mysqlreader/doc/mysqlreader.md

Please refer to the mysqlwriter document:
https://github.com/alibaba/DataX/blob/master/mysqlwriter/doc/mysqlwriter.md

Please save the following configuration as a json file and use
python {DATAX_HOME}/bin/datax.py {JSON_FILE_NAME}.json
to run the job.

{
"job": {
"content": [
{
"reader": {
"name": "mysqlreader",
"parameter": {
"column": [],
"connection": [
{
"jdbcUrl": [],
"table": []
}
],
"password": "",
"username": "",
"where": ""
}
},
"writer": {
"name": "mysqlwriter",
"parameter": {
"column": [],
"connection": [
{
"jdbcUrl": "",
"table": []
}
],
"password": "",
"preSql": [],
"session": [],
"username": "",
"writeMode": ""
}
}
}
],
"setting": {
"speed": {
"channel": ""
}
}
}
}

上面打印出来的 Json 串就是 MySQLMySQL数据同步的配置模板。将模板中的选项补充完成,mysql2mysql.json 样例如下:

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
{
"job": {
"content": [
{
"reader": {
"name": "mysqlreader",
"parameter": {
"column": [
"id",
"name"
],
"connection": [
{
"jdbcUrl": ["jdbc:mysql://127.0.0.1:3306/test?useSSL=false&zeroDateTimeBehavior=EXCEPTION&serverTimezone=UTC"],
"table": ["from_table"]
}
],
"password": "**********",
"username": "root",
"where": ""
}
},
"writer": {
"name": "mysqlwriter",
"parameter": {
"column": [
"id",
"name"
],
"connection": [
{
"jdbcUrl": "jdbc:mysql://127.0.0.1:3306/test?useSSL=false&zeroDateTimeBehavior=EXCEPTION&serverTimezone=UTC",
"table": ["to_table"]
}
],
"password": "**********",
"preSql": ["delete from to_table"],
"session": [],
"username": "root",
"writeMode": "insert"
}
}
}
],
"setting": {
"speed": {
"channel": 5
}
}
}
}

把这个文件放在 dataX 目录下,执行 Python 脚本(在 DataXbin 目录下):

1
2
3
4
5
6
7
8
9
10
11
python datax.py ./mysql2mysql.json

...
2021-04-08 11:20:25.263 [job-0] INFO JobContainer -
任务启动时刻 : 2021-04-08 11:20:15
任务结束时刻 : 2021-04-08 11:20:25
任务总计耗时 : 10s
任务平均流量 : 205B/s
记录写入速度 : 5rec/s
读出记录总数 : 50
读写失败总数 : 0

这样我们便可以将 from_table 中数据同步至 to_table 中。DataX 框架收集到的各项指标打印在最后。关于配置项中的各项指标,可参见源码的文档介绍。


1、拥有个人图床的必要性

图床是存储图片的服务器,可以生成外链供在线加载,被广泛用于网站建设。目前市面上主流的图床有 ImgURLSM.MS新浪微博路过图床七牛云等,主要有两个特点:免费图床广告多、不稳定;收费图床不适合打工人。作为搞技术的人,我们拥有开源精神的同时,也要利用好开源技术。GitHubGitLabGitee 就是最干净的、最稳定的图床。由于GitHubGitLab 在国内的访问速度远不如 Gitee,所以我们选择搭建 Gitee 图床。

2、图床工具-PicGo

即使有了 Gitee 图床,我们仍然需要借助 Git 将图片 Push 到远端,然后再到仓库中 Copy 在线图片的加载地址。跟那些主流图床比起来,这样做只会显得更加傻蛋。为此,我们要借助一款优秀的图床工具 PicGo

PicGo: 一个用于快速上传图片并获取图片 URL 链接的工具

简单地说,PicGo 可以主动 Push 图片到远端,并主动获取远端图片的外链,不用我们操心。

3、搭建基于 Gitee 的图床
3.1 创建 Gitee 图床仓库

创建图床仓库,需要注意两点:仓库需要开源,否则别人没有权限查看你的图片;分支只需要初始化 master ,图床操作都在这个分支上。

3.2 安装配置 PicGo

下载安装包:version: 2.3.0

windows平台请选择:

运行软件,可以看到【图床设置】一栏是没有 Gitee 的,这需要额外安装插件。操作【插件设置】,输入 gitee 查询相关插件,选择安装 gitee 2.0.5

插件安装基于 npm,需要先安装 Nodejs

插件安装成功后重启软件,打开【图床设置】,选择【Gitee图床】,各项配置如下:

owner: Gitee用户名 (donehub);

repo: Gitee 图片仓库名 (img-bed);

path: 图片存放的目录,可以不填写;

token: Gitee 上生成的私有令牌,用于授权 PicGo 操作 Gitee 图床;

message: 用默认证即可;

具体配置方法:

ownerrepo 容易写错,建议直接到 GiteeCopy

Gitee 生成私有令牌:

步骤:个人主页-》个人设置-》安全设置-》私有令牌-》配置权限-》提交;

注意:私有令牌只会展示一次,建议复制下来长久保存;

3.3 上传图片

打开【上传区】,可以看到 PicGo 支持四种图片上传方式:拖拽上传、选择上传、剪切上传、URL 上传。返回的在线图片链接格式有 MarkdownHTML 等。

我们选择一张图片上传,上传成功后,打开【相册】。在相册里,不仅可以看到已上传的所有图片,还可以拷贝、修改在线图片链接。

4. Typora 内嵌 PicGo

虽然 PicGo + Gitee 已经非常好用了,但我们依然需要手动上传图片并拷贝外链。既然图床是服务于内容,那么是否可以内嵌 PicGo 到编辑器内部呢?

Typora 是一款主流的 Markdown 编辑器, 0.9.98 及以上版本可以内嵌 PicGo 工具。配置完成之后,只需要将图片拖入页面,即可自动上传图床并插入外链。

配置方法:文件-》偏好设置-》图像-》插入图片时选择上传图片-》上传服务设定

打开 Test.md,将图片拖入页面,可以看到短暂的 loading 提示,然后上传成功并替换图片外链。


一、ForkJoinPool 介绍

ForkJoinPool 是 jdk 1.7 引入的一个线程池,其底层设计基于分治算法(Divide-and-Conquer)的并行实现,是一款可以获得良好并行性能的简单且高效的设计技术。通过任务分治,可以更好地利用多处理器,并行处理任务,提升计算效能。

Fork/Join 框架主要包含三个模块:

  • 线程池:ForkJoinPool
  • 执行 Fork/Join 任务的线程:ForkJoinWorkerThread
  • 任务对象:ForkJoinTask,继承者有 RecursiveTask、RecursiveAction、CountedCompleter

ForkJoinPool 通过 ForkJoinWorkerThread 来处理提交的 ForkJoinTask。通常不会直接创建 ForkJoinTask,而是借助其继承类,根据实际需要创建对应的分治任务。RecursiveTask 是一个可以递归执行的 ForkJoinTask;RecursiveAction 是一个没有返回值的 RecursiveTask;CountedCompleter 在完成任务执行后,回自动触发执行一个自定义的钩子函数。

二、工作窃取(Work-Stealing)算法

工作窃取 (work-stealing) 算法,是 Fork/Join 的设计原理,指线程池内的所有工作线程都尝试找到并执行已经提交的任务,或者是被其他活动任务创建的子任务。因此,在运行多个可以产生子任务的任务,或提交的许多小任务,ForkJoinPool 的效率非常高。

在 ForkJoinPool 中,每个工作线程 (ForkJoinWorkerThread) 都对应一个任务队列 (WorkQueue),工作线程优先处理自身队列的任务,然后以 FIFO 的顺序随机窃取其他队列中的任务。处理自身队列的任务的方式有两种:先进先出 (FIFO)、先进后出 (LIFO)。这由 ForkJoinPool 的构造参数 asyncMode 决定,默认先进先出 (FIFO)。

  • 每个工作线程 (ForkJoinWorkerThread) 都有自己的一个 WorkQueue,该工作队列是一个双端队列;
  • WorkQueue 支持三种操作 push、pop、poll;
  • push/pop 只能被队列的所有者线程调用,而 poll 可以被其他线程调用;
  • 划分的子任务调用 fork 方法时,都会被 push 到自己的 WorkQueue 中;
  • 一般情况下,工作线程 (ForkJoinWorkerThread) 从自己的双端队列获出任务并执行;
  • 当自己的队列为空时,工作线程 (ForkJoinWorkerThread) 便随机从其他 WorkQueue 末尾调用 poll 方法窃取任务并执行;

三、ForkJoinPool 使用

我们以 RecursiveTask 学习使用 ForkJoinPool,可递归分治任务实现类图:

计算1+2+3+…+10000的值:

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
public class SumTest extends RecursiveTask<Integer> {
final int start;
final int end;

SumTest(int start, int end) {
this.start = start;
this.end = end;
}

@Override
protected Integer compute() {

if (end - start < 100) {
System.out.println(Thread.currentThread().getName() + " 开始执行: " + start + "-" + end);
int sum = 0;
for (int i = start; i <= end; i++) {
sum += i;
}
return sum;
}

SumTest sumTest1 = new SumTest(start, (end + start) / 2);
SumTest sumTest2 = new SumTest((start + end) / 2 + 1, end);

sumTest1.fork();
sumTest2.fork();

return sumTest1.join() + sumTest2.join();
}

public static void main(String[] args) throws ExecutionException, InterruptedException {
ForkJoinPool pool = new ForkJoinPool();
ForkJoinTask<Integer> task = new SumTest(1, 1000);
pool.submit(task);
System.out.println(task.get());
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ForkJoinPool-1-worker-13 开始执行: 1-63
ForkJoinPool-1-worker-11 开始执行: 501-563
ForkJoinPool-1-worker-13 开始执行: 126-188
ForkJoinPool-1-worker-11 开始执行: 564-625
ForkJoinPool-1-worker-13 开始执行: 189-250
ForkJoinPool-1-worker-10 开始执行: 251-313
ForkJoinPool-1-worker-4 开始执行: 64-125
ForkJoinPool-1-worker-10 开始执行: 314-375
ForkJoinPool-1-worker-13 开始执行: 376-438
ForkJoinPool-1-worker-10 开始执行: 439-500
ForkJoinPool-1-worker-4 开始执行: 626-688
ForkJoinPool-1-worker-0 开始执行: 814-875
ForkJoinPool-1-worker-7 开始执行: 751-813
ForkJoinPool-1-worker-11 开始执行: 689-750
ForkJoinPool-1-worker-3 开始执行: 939-1000
ForkJoinPool-1-worker-15 开始执行: 876-938
500500

从执行结果来看,ForkJoinPool 通过分治算法,一级级拆分表达式,直到拆分的数据单元的差值小于100,接着分别计算各个小数据单元的和,提交汇总。

四、Fork/Join 任务提交方式

ForkJoinPool 支持三种任务提交方式:

  • submit: 异步执行,有返回值,通过 task.get() 获取执行结果;
  • invoke: 同步执行,等待任务执行完毕后,返回计算结果;
  • execute: 直接提交任务,同步执行,无返回结果。