前言
在之前的项目开发中,就有使用过增量抽取的经历;但因为并没有总结。
在实习过程中,参加的项目其中之一,就是数据增量抽取;虽然只是两个人开发;在开发时,并没有多大的参与感,而且比较当前的项目建设,觉得可以完成得更好;所以对整个项目进行重演,模拟真实的双方接口数据增量抽取。当然,会有以下几点工作内容:
- 所有的数据都是素材数据,这可以保证不违反公司的保密协议;使用的技术栈和处理方式也有所不同。
- 项目的工作量主要在于接口方,这也是参与感没那么强烈的原因之一。
- 接口方实现批处理
所以整个项目将会分为2部分,以接口方和接口调用方进行重演;
参考链接
https://developer.aliyun.com/article/464065
时间线
2021-01-08~2021-01-15—完成初稿
项目介绍
项目场景
一个系统A运行了一段时间,产生了大量的数据。在某个时间,另一个系统B在开发中,需要使用到系统A的某些数据。
所以联系系统A的开发人员,进行沟通后,确定某个时间段可以开放某个接口,用于接口增量抽取操作。
项目需求
维护系统A的开发者为接口方,维护系统B的开发者为接口调用方;
接口方根据接口调用方的数据需求进行接口设计,有以下工作:
- 接口的设计(开放时间段,接口参数,获取数据逻辑)
- 接口数据的加密和解密
接口调用方调用接口方提供的接口,有以下工作
- 编写定时任务,调用接口
- 对接口返回数据进行解密
- 持久化到数据库中
项目回顾
问题
增量抽取项目并不难,难的是双方的沟通,以及接口方的不规范的数据设计。
接口增量抽取有很多种方式进行处理,使用时间作为依据可以说是主流且简单的方式之一。
但是接口方对于时间的处理上存在很大的问题,以至于徒增麻烦。
例如在对时间的处理上,
- 问题:接口返回的数据关于时间的描述,有些是时间戳,有些是格式化时间字符串。
这个问题需要进行复杂操作(根据接口中时间字段的数据,进行不同的处理方式)才能够解决这个问题。
在接口调用方进行数据持久化时,需要进行相关映射,时间字符串可以插入数据库,而时间戳的数据只能转换后才可以插入数据库中。
- 问题:存储记录中对于时间的精确度仅仅只是精确到day
yyyy-MM-dd HH:mm:ss
精确到天,如果使用一天作为根据进行增量抽取。那么当一天的数据量过大时,接口返回的数据将会过多。而可行的解决方案是根据时间转换成时间戳后,进行分页。每获取一页数据后对当前节点进行标记,并将数据和标记进行持久化。
例如有1000条数据,每一页分100条数据,第一次使用
{timestamp:1xxx0000,page:0}
作为参数,调用接口;第100条数据对应的时间戳是1xxx0100 ,获取100条数据库后,再将1xxx0100持久化数据库中。
完成这100条数据的持久化后,再次使用
{timestamp:1xxx0100,page:0}
作为参数,调用接口。
这是由于精确度的问题而导致无法实施。
- 问题:没有统一的时间标记字段,例如createtime,updatetime,尤其是updatetime式的字段,没有记录跟踪数据是否更改
如果说以上2个问题可以通过复杂操作或者牺牲性能为代价来解决问题。这个问题是致命的。
在没有字段记录数据是否进行更改的情况下,当数据更改时,是无法跟踪的,而接口增量的数据只能是新增的数据。将会导致数据不一致的问题。
如果存在
updatetime
等字段时,接口只要根据该字段获取数据返回给调用方即可。当然,在不对原数据库进行修改的条件下,也存在解决方案,即对数据的操作进行记录,当数据增量抽取时,再根据记录进行处理。
新增操作,直接插入数据库。出现修改操作时,记录字段的主键,
其它
数据增量抽取与数据库同步的区别
数据增量抽取和数据库同步虽然区别很大,但还是存在许多共同点。
首先两者目的都是保持数据一致,数据增量抽取是从一个数据库中获取数据,存储到另一个数据库中,这个过程的基本要求就是保持数据一致。
数据库同步是数据库读写分离操作产生的名词。“读”的数据库和“写”的数据库的数据要保持一致。
管理权
两者也有很大的区别,数据库同步是一方维护的,即负责读和写的数据库的管理都在一方的开发者上。而数据增量抽取,并不是如此。
可能一位开发者开发的系统使用一个数据库正常运行,某一天,另一位开发者,觉得需要这个系统所产生的数据进行开发,经过与系统开发者沟通后,对数据进行增量抽取。
当然,如果没有经过沟通,对数据进行增量抽取,其实就是
数据爬虫
。因为出于安全等因素,数据增量抽取是无法通过数据库同步的解决方案完成的。
数据量
数据库同步是整个数据库的同步,即负责“写”的数据库产生了什么变化,负责“读”的数据库也需要进行相应的变化;
而数据增量抽取只是某些表的同步,甚至是某些需要的数据字段的同步。
项目架构图
接口方
任务
编写一个接口,返回接口调用方需要的数据,返回前进行加密,以保证接口数据的安全
步骤
使用MyBatisPlus对数据进行查询
使用的第三方库
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
相关操作
// mapper
public interface ExpressMapper extends BaseMapper<Express> {
}
// service
@Service
public class ExpressService {
@Autowired
private ExpressMapper expressMapper;
public IPage<Express> selectByPageAndTimeStamp(int size,String timeStamp){
QueryWrapper<Express> queryWrapper = new QueryWrapper<>();
queryWrapper.ge("update_time",timeStamp);
Page<Express> expressPage = new Page<>(0,size);
return expressMapper.selectPage(expressPage,queryWrapper);
}
}
对数据进行处理
使用stream对接口数据进行排序
expresses = expresses.stream()
.sorted(Comparator.comparing(Express::getUpdateTime).reversed())
.collect(Collectors.toList());
使用fastJson对数据进行序列化
String dataStr = JSON.toJSONString(res);
使用AESUtil对接口数据进行加密
AESUtil.encrypt(dataStr,passwd);
编写接口资料,提供给接口调用方
接口地址
{
"url" : "http://localhost:8080/express",
"params" :{
"size" : size,
"timestamp" : timestamp
},
"method" : Method.Get
}
加密解密工具
package zhj.pro.work.util;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
/**
* description AES加密解密工具
* author zhj1121
* createTime 2021/1/10 20:00
**/
public class AESUtil {
// 默认编码
private static final String CHARSET = "utf-8";
// AES算法
private static final String ALGORITHM = "AES";
// 密钥长度
private static final Integer KEY_LENGTH = 128;
/**
* description
* param [password]
* return javax.crypto.spec.SecretKeySpec
**/
private static SecretKeySpec generateKey(String password) throws NoSuchAlgorithmException {
// 创建AES的KEY生产者
KeyGenerator keyGenerator = KeyGenerator.getInstance(ALGORITHM);
// 利用密码参数作为随机数随机生成
keyGenerator.init(KEY_LENGTH,new SecureRandom(password.getBytes()));
// 返回byte类型的密钥
byte[] byteRes = keyGenerator.generateKey().getEncoded();
// 返回AES专用密钥
return new SecretKeySpec(byteRes,ALGORITHM);
}
/**
* description 二进制转换成十六进制
* param [buf]
* return java.lang.String
**/
private static String parseByte2HexStr(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte aByte : bytes) {
String hex = Integer.toHexString(aByte & 0xFF);
if (hex.length() == 1) {
hex = '0' + hex;
}
sb.append(hex.toUpperCase());
}
return sb.toString();
}
/**
* description 十六进制字符串转换成二进制byte
* param [hexStr]
* return byte[]
**/
private static byte[] parseHexStr2Byte(String hexStr) {
if (hexStr.length() < 1)
return null;
byte[] result = new byte[hexStr.length()/2];
for (int i = 0;i< hexStr.length()/2; i++) {
int high = Integer.parseInt(hexStr.substring(i*2, i*2+1), 16);
int low = Integer.parseInt(hexStr.substring(i*2+1, i*2+2), 16);
result[i] = (byte) (high * 16 + low);
}
return result;
}
/**
* description AES加密方法
* param [content, password]
* return java.lang.String 密文
**/
public static String encrypt(String content, String password) {
try {
SecretKeySpec key = generateKey(password);
// 创建密码器
Cipher cipher = Cipher.getInstance(ALGORITHM);
byte[] byteContent = content.getBytes(CHARSET);
// 初始化为加密模式的密码器
cipher.init(Cipher.ENCRYPT_MODE, key);
// 加密
byte[] result = cipher.doFinal(byteContent);
// 二进制转换成16进制字符串
return parseByte2HexStr(result);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* description AES解密方法
* param [content, password]
* return java.lang.String 明文
**/
public static String decrypt(String content, String password) {
try {
// 十六进制字符串转换成二进制字节数组
byte[] byteArr = parseHexStr2Byte(content);
SecretKeySpec key = generateKey(password);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, key);
// 解密
byte[] result = cipher.doFinal(byteArr != null ? byteArr : new byte[0]);
return new String(result,CHARSET);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
// public static void main(String[] args) throws Exception {
// String content = "测试一下";
// String encrypt = encrypt(content, KEY);
// System.out.println(encrypt);
// String decrypt = decrypt(encrypt, KEY);
// System.out.println(decrypt);
// }
}
加密解密密码
passwd1121
补充
当然,在安全方面,还可以增加header等验证,由于这里已经保证了接口安全,所以出于工作量考虑,不进行过多验证
接口调用方
任务
根据接口方提供的资料,对接口方提供的接口进行先全量,后增量调用,进行持久化。
并编写定时任务,在约定的时间进行调用
步骤
调用接口
使用Httpclient对接口进行调用
使用的第三方库
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.9</version>
</dependency>
相关调用工具代码
使用之前封装好的工具方法
public static String doGetByUrl(String url){
CloseableHttpClient closeableHttpClient = HttpClientBuilder.create().build();
//get or post block
HttpGet httpGet = new HttpGet(url);
//HttpPost httpPost = new HttpPost(url);
httpGet.setHeader("Content-Type", "application/json;charset=utf8");
CloseableHttpResponse closeableHttpResponse = null;
try {
closeableHttpResponse = closeableHttpClient.execute(httpGet);
if (closeableHttpResponse.getStatusLine().getStatusCode()==200){
// System.out.println("响应成功,进行相关处理");
closeableHttpResponse.getEntity().getContent();
return EntityUtils.toString(
closeableHttpResponse.getEntity(),"utf-8");//AESUtil.decrypt(res,passwd);
} else{
// System.out.println("响应失败,进行相关处理");
return null;
}
} catch (IOException e) {
e.printStackTrace();
return null;
} finally {
try {
if(closeableHttpClient != null) {
closeableHttpClient.close();
// System.out.println("关闭closeableHttpClient");
}
if(closeableHttpResponse != null) {
closeableHttpResponse.close();
// System.out.println("关闭closeableHttpResponse");
}
} catch (IOException e) {
e.printStackTrace();
// System.out.println("IO出现异常");
}
}
}
参数
size
记录条数,可以对请求数据量进行限定
由于考虑到接口方的请求资源问题,所以每次请求返回一定量的数据
updatetime
数据更新时间,当数据的值发生改变时,updatetime也需要进行相应的更新
可以保证前天的数据,如果在今天发生更新时,也可以对数据库进行更新,避免数据不一致性
使用AESUtil对接口数据进行解密
与接口方协调,接口方使用AESUtil对接口数据进行加密;接口调用方则使用AESUtil工具对接口返回的加密数据进行解密
详细见
接口方
模块笔记
使用fastjson对接口数据进行反序列化
这里使用的是阿里的fastjson
JSONObject jsonObject = JSON.parseObject(decrStr);\\decrStr是解密后的接口数据
String dataStr = jsonObject.get("data").toString();
List<Express> expresses = JSON.parseArray(dataStr,Express.class);
持久化
设计数据库
// 接口数据表
id int 主键
title varchar 标题
type varchar 类型(0-5)
pickupaddr varchar 取件地址
consigneeaddr varchar 收件地址
remark varchar 备注
createtime varchar 创建时间戳
updatetime varchar 修改时间戳
completeflag varchar 是否完成(0:未完成,1:完成)
deleteflag varchar 删除标记
// 工作表
id varchar 工作名
timestamp varchar 时间戳
字段映射
由于请求接口的数据,如下格式:
{
xxXxx : ""
}
即驼峰命名法,而mysql数据库是大小写不区分;
在工作中的使用的是jdbcTemplate,所以必须进行字段映射,有两种方式
-
对接口数据的字段进行处理,获取与数据库字段一致的字段
-
对接口数据的字段与数据库字段的关系配置在数据库中,持久化时,查询数据库即可
在这次总结中,由于使用的是MybatisPlus,所以只要在实体类,如下添加注解即可完成映射:
// 数据库
pickupaddr
// 实体类
@TableField("pickupaddr")
private String pickupAddr;
使用MyBatisPlus进行持久化工作
由于主要编码太多,所以对整个逻辑进行描述:
- 获取工作表的工作时间节点,使用该时间戳作为参数请求接口
- 对接口数据进行批量插入或者更新
- 获取接口数据的最新的一条的时间戳,并更新到工作表中,作为标记,用于下次查询
- 当请求的数据少于批量数时,停止请求;否则,重复以上步骤
使用定时任务Scheduled
与接口方进行协调,接口方定时开放接口;接口调用方定时请求,进行增量抽取
@Scheduled(cron="0 0 4 * * ?")//每日凌晨4点进行请求
public void express(){
expressJob.start(size,passwd);//作业
}
总结
主要技术栈
javafacker制造测试数据
制造测试数据。在之前的系统测试时,往往会手动加入测试数据。但当需要的数据量过大时,就非常麻烦;
使用facker+for就可以非常简单地制造数据。
AES加密解密
AES加密解密的使用,是为了保证接口数据的安全。当截获接口返回的数据,例如抓包等方式,得到的数据往往不是明文,而是密文;
当然,AES还可以对接口参数进行加密,当接口方获取接口参数与约定的参数相悖时,直接返回错误即可,而不会造成资源浪费;
在这一次项目中,也只是停留在用的层面;所以尝试了网上各种实现方式,最终选取了这一款,并进行改造,因为,这个工具类在以后也可以使用的。
之前,有过自己实现一款简单的加密解密算法的想法,但最终还是无法跳脱安全的顾虑。
MyBatisPlus批量更新插入
使用MyBatisPlus是因为想要尝试一下新的数据持久化工具。之前使用的是spring jpa,spring data jpa,hibernate和mybatis。在实际工作中的数据增量抽取项目中,使用的是jdbcTemplate。而使用MyBatisPlus,是因为想要尝试一下新的数据持久化工具。
关于MyBatisPlus的使用感受,感觉MyBatisPlus是MyBatis和Hibernate之间的结合体。在一篇博客后面,有个人说,“MyBatisPlus是要走hibernate的老路了”。的确,MyBatisPlus也走的是面向对象的思维。
其实,从学习数据持久化工具的历程上,就可以知道学习编程的成长历程。
首先是,jdbc,每一次使用,处于资源考虑,都需要连接,请求,释放连接等一系列操作。
当时,为了不重复造轮子,根据平常的使用习惯,封装了一个工具类,类似于jdbctemplate。当然,并没有做到jdbctemplate那样妥善处理。在之后,有很长的一段时间一直使用
在后来,学习到了Jpa,Hibernate等等,当时因为初学,所以对面向对象的持久化非常感兴趣,但也因为初学原因,在一些处理方式上,并不熟悉,导致只是学学而已;
因为当时对于SQL熟悉,所以相比之下,JdbcTemplate是在面向对象持久化的阶段的实战替代品。
然后,最后接触到了MyBatis,MyBatisPlus等等。
在各种博客中,对各种持久化工具进行各种比较;但才发现,原来工具只是一种工具而已,更重要的是“思想”。
HttpClient
其实有想过使用RestTemplate等等其它方式实现,但由于时间关系,在之前的笔记中,完成了对HttpClient的封装。所以这里使用了HttpClient;
项目总结
完成这一次数据增量抽取的重演与总结,花费的时间并不长,一方面是因为有之前的经验,有思路;另一方面,是因为接口方和接口调用方的工作量都在自己手里。但间隔时间比较久。最初想要完整的完成,但最后因为各种事情断断续续,所以有很多细节没来得及处理,就草草完工。
所以,一个项目切忌断断续续,要规划好日程
。
当然,在技术选型上,也要慎重考虑
。
例如,在工作中的增量抽取,当时使用的JdbcTemplate,在对象的处理上,不是构造对象,而是使用Object代替,大量的代码都是相关处理。当然,在编码方面,非常方便,但在维护上,需要很多的时间阅读代码;
又是使用数据库存储配置信息,完成相关映射;浪费资源不说,项目工作量也大;
另外,这一次项目总结的目的并不只是重演;重演只是验证想法的一个过程;
一个好的处理方式,好的想法往往是最重要的
。
例如以什么作为增量标记,在工作项目中,使用的是一天作为增量标记,即在今天获取昨天的所有记录进行。
这样做,主要是因为接口方的数据不规范,时间字段的精确度问题;
而在这里,使用数据的更新的时间戳作为增量标记,不仅可以控制接口的数据返回量,减少接口的调用压力压力;还可以保证数据的一致性。当数据发生更新时,数据将会因为更新时间戳而被返回。