目标1:CEPH Swift 服务配置与实践
目标2:CEPH文件操作接口封装与集成使用
目标3:基于SnowFlake算法的全局唯一ID的使用
目标4:统一认证的高性能方案Token增强技术使用
目标5:文件上传与下载接口实现
目标6:Spring Boot多环境配置与打包实践
3.1 Ceph Swift Api 实践说明
前面我们讲过ceph的集群安装与使用,集群有一定操作难度,大家要通过集群安装加强对Ceph的理解与运用,ceph是一个分布式文件存储系统,需要至少准备三至四台虚拟机, 之前有过详细介绍,可参考之前的讲义, 就不再赘述。
在ceph的使用上, 之前我们是采用fs文件系统, 并编写了相应的api来操作演示。 但互联网大规模的文件场景下, fs并不能满足生产的使用要求,rados本地化操作也不便于服务的接入与使用, 这里我们就要采用Ceph Swift Api 来实现文件的存储管理。
3.2 Ceph Swift Api 特点
Swift是由Rackspace开发的用来为云计算提供可扩展存储的项目。专注于对象存储, 并提供一套REST风格的Api来访问, 与Ceph强一致性不同, 它是最终一致性。两者都是优秀的开源项目, 并无明显优劣之分,在使用场景上有所不同, 如果是专注于对象存储, 那么可以选择swift即可满足需要, 如果还有块存储要求, 那么选择Ceph更为合适。这里选择Ceph, 因为通过网关可以适配兼容swift api, 同时在数据访问上具有较强的扩展性:
在一些大型互联网公司也开始采用 Ceph Swift 接口来处理海量文件信息, 参考:携程网的Ceph实践之路
3.3 Ceph RGW 介绍
Ceph可以提供块、文件和对象三种形态的存储。RGW就是提供对象存储的网关,也即对象存储网关。所谓对象存储网关,也就是对象存储的入口,本质上是一个HTTP服务器,与Nginx和Apache无特殊差别。通过这个网关入口,用户可以采用HTTP协议,以RESTful的方式访问Ceph的对象存储。Ceph网关能够适配兼容AWS S3和OpenStack Swift 的API接口。
3.4 Ceph 存储结构
在使用对象存储之前, 先要了解桶(container容器)概念及其存储结构:
Ceph Swift Api的调用, 需要先创建相应用户进行认证才能操作, 每个用户下面可以创建多个桶, 桶里面可以存储对象,对象就是各种数据文件, 包括文档, 图片等。传统上传文件的使用, 我们往往会指定路径信息, 在这里, 桶和对象的关系好比文件夹与文件的概念, 不同之处是桶不能再嵌套桶, 也就是没有层级路径的概念。
3.5 Ceph Swift Api 服务端的配置
[root@CENTOS7-1 ceph-cluster]# ceph -s
cluster:
id: 0ec99aa9-e97e-43d3-b5b9-90eb21c4abff
health: HEALTH_OK
services:
mon: 3 daemons, quorum CENTOS7-1,CENTOS7-2,CENTOS7-3
mgr: centos7-1(active), standbys: centos7-3, centos7-2
mds: fs_test-1/1/1 up {0=centos7-1=up:active}
osd: 3 osds: 3 up, 3 in
rgw: 3 daemons active
data:
pools: 9 pools, 128 pgs
objects: 257 objects, 166 KiB
usage: 3.0 GiB used, 57 GiB / 60 GiB avail
pgs: 128 active+clean
sudo radosgw-admin user create --subuser="cephtester:subtester" --uid="cephtester" --display-name="cephtester" --key-type=swift --secret="654321" --access=full
uid 为主用户, subuser为子用户信息, secret指定密钥, 不指定则随机生成, access拥有权限设定。
返回结果:
{
"user_id": "cephtester",
"display_name": "cephtester",
"email": "",
"suspended": 0,
"max_buckets": 1000,
"auid": 0,
"subusers": [
{
"id": "cephtester:subtester",
"permissions": "full-control"
}
],
"keys": [],
"swift_keys": [
{
"user": "cephtester:subtester",
"secret_key": "654321"
}
],
"caps": [],
"op_mask": "read, write, delete",
"default_placement": "",
"placement_tags": [],
"bucket_quota": {
"enabled": false,
"check_on_raw": false,
"max_size": -1,
"max_size_kb": 0,
"max_objects": -1
},
"user_quota": {
"enabled": false,
"check_on_raw": false,
"max_size": -1,
"max_size_kb": 0,
"max_objects": -1
},
"temp_url_keys": [],
"type": "rgw",
"mfa_ids": []
}
记住swift_keys下面的user和secret_key信息, 代码中需使用。
激活管理后台的对象存储模块:
创建一个管理用户:
radosgw-admin user create --uid=mgruser --display-name=mgruser --system
返回结果:
{
"user_id": "mgruser",
"display_name": "mgruser",
"email": "",
"suspended": 0,
"max_buckets": 1000,
"auid": 0,
"subusers": [],
"keys": [
{
"user": "mgruser",
"access_key": "XZDC4Y0AORE01NMYU3VV",
"secret_key": "me0Z80HLvctkzzBs74ufXS0Wi947NBe6Wpj3MxKP"
}
],
"swift_keys": [],
"caps": [],
"op_mask": "read, write, delete",
"system": "true",
"default_placement": "",
"placement_tags": [],
"bucket_quota": {
"enabled": false,
"check_on_raw": false,
"max_size": -1,
"max_size_kb": 0,
"max_objects": -1
},
"user_quota": {
"enabled": false,
"check_on_raw": false,
"max_size": -1,
"max_size_kb": 0,
"max_objects": -1
},
"temp_url_keys": [],
"type": "rgw",
"mfa_ids": []
}
根据生成的access_key与secret_key, 执行:
ceph dashboard set-rgw-api-access-key XZDC4Y0AORE01NMYU3VV
ceph dashboard set-rgw-api-secret-key me0Z80HLvctkzzBs74ufXS0Wi947NBe6Wpj3MxKP
打开管理界面,http://10.10.20.11:18843 可以查看到我们刚才创建的两个用户:
3.6 Ceph Swift Api 调用验证
增加SwiftOperator接口:
@Component
@Log4j2
public class SwiftOperator {
/**
* 用户名信息, 格式: 主用户名:子用户名
*/
private String username ="cephtester:subtester";
/**
* 用户密码
*/
private String password = "654321";
/**
* 接口访问地址
*/
private String authUrl = "http://10.10.20.11:7480/auth/1.0";
/**
* 默认存储的容器名称
*/
private String defaultContainerName = "user_datainfo";
/**
* Ceph的账户信息
*/
private Account account = null;
/**
* Ceph的容器信息
*/
private Container container;
/**
* 进行Ceph的初始化配置
*/
public SwiftOperator() {
// 1. Ceph的账户信息配置
AccountConfig config = new AccountConfig();
config.setUsername(username);
config.setPassword(password);
config.setAuthUrl(authUrl);
config.setAuthenticationMethod(AuthenticationMethod.BASIC);
account = new AccountFactory(config).createAccount();
// 2.获取容器信息
Container newContainer = account.getContainer(defaultContainerName);
if(!newContainer.exists()) {
container = newContainer.create();
log.info("container create ==> " + defaultContainerName);
}else {
container = newContainer;
}
}
/**
* 文件上传处理
* @param remoteName
* @param filePath
*/
public void createObject(String remoteName, String filePath) {
// 1. 从容器当中获取远程存储对象信息
StoredObject object = container.getObject(remoteName);
// 2. 执行文件上传处理
object.uploadObject(new File(filePath));
}
/**
* 文件的下载处理
* @param objectName
* @param outPath
*/
public void retrieveObject(String objectName, String outPath) {
// 1. 从容器当中获取远程存储对象信息
StoredObject object = container.getObject(objectName);
// 2. 执行文件的下载方法
object.downloadObject(new File(outPath));
}
/**
* 获取用户下面的所有容器信息
* @return
*/
public List listContainer() {
List list = new ArrayList();
Collection containers = account.list();
for(Container container : containers) {
list.add(container.getName());
log.info("current container name : " + container.getName());
}
return list;
}
}
这里的用户名和密码填写上面我们所生成的信息。注意路径地址后缀为: /auth/1.0
CephDemoApplication启动类重新封装:
@SpringBootApplication
@ComponentScan(basePackages = {"com.itcast"})
public class CephDemoApplication {
public static void main(String[] args) throws Exception {
// Swift Api接口调用验证
swiftApi();
}
/**
* Rados Api的封装处理
* @throws Exception
*/
public static void radosApi(String[] args) throws Exception {
System.out.println("start....");
String username = "admin";
String monIp = "10.10.20.11:6789;10.10.20.12:6789;10.10.20.13:6789";
String userKey = "AQBZBypdMchvBRAAbWVnIGyYNvxWQZ2UkuiYew==";
String mountPath = "/";
CephOperator cephOperate = null;
try {
String opt = (args == null || args.length < 1)? "" : args[0];
cephOperate = new CephOperator(username, monIp, userKey, mountPath);
if("upload".equals(opt)) {
cephOperate.uploadFileByPath("/temp_upload_fs", args[1]);
}else if("download".equals(opt)) {
cephOperate.downloadFileByPath("/temp_download_fs", args[1]);
}else {
System.out.println("Unrecognized Command! Usage opt[upload|download] filename[path]!");
}
}catch(Exception e) {
e.printStackTrace();
}finally {
if(null != cephOperate) {
cephOperate.umount();
}
}
System.out.println("end....");
}
/**
* 通过Swift接口操作ceph集群
* @throws Exception
*/
public static void swiftApi() throws Exception {
ConfigurableApplicationContext appContext = SpringApplication.run(CephDemoApplication.class);
// 1. 先打印出用户的容器信息
SwiftOperator swiftOperator = appContext.getBean(SwiftOperator.class);
swiftOperator.listContainer();
String objName = "test_ceph";
// 2. 上传指定的文件
swiftOperator.createObject(objName, "d:/test_swift_ceph.txt");
// 3. 从ceph下载文件到指定的路径下面
swiftOperator.retrieveObject(objName, "e:/test.txt");
System.out.println("complete");
}
}
测试验证
测试思路, 在d盘创建一个文件, 并上传到ceph系统, 然后从ceph系统下载到指定路径下面。
这里要注意,我们默认的容器配置的是”user_datainfo“, 从ceph系统上传和下载的文件名称要一致, 启动打印"complete" , 无异常代表执行成功。
这里简要演示, 接下来会讲解如何在项目中集成使用。
3.1 功能流程
在用户开户功能中, 需要填写用户资料, 上传身份证等证明文件, 业务上步骤是分离处理, 系统需提供用户的文件上传 与下载接口。
3.2 Ceph封装与自动化装配
新建stock-starter工程, 用于封装自动化组件。
pom文件依赖:
com.ceph
rados
0.6.0
com.ceph
libcephfs
0.80.5
org.javaswift
joss
0.10.2
直接采用目前的最新版, 加入以上三个依赖, fs依赖不需要也可以去除。
代码实现
封装Ceph操作接口, CephSwiftOperator类:
public class CephSwiftOperator {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 认证接入地址
*/
private String authUrl;
/**
* 默认容器名称
*/
private String defaultContainerName;
/**
* Ceph账户对象
*/
private Account account;
/**
* Ceph容器对象
*/
private Container container;
public CephSwiftOperator(String username, String password, String authUrl, String defaultContainerName) {
// 初始化配置信息
this.username = username;
this.password = password;
this.authUrl = authUrl;
this.defaultContainerName = defaultContainerName;
init();
}
/**
* 初始化建立连接
*/
public void init() {
try {
// Ceph用户认证配置
AccountConfig config = new AccountConfig();
config.setUsername(username);
config.setPassword(password);
config.setAuthUrl(authUrl);
config.setAuthenticationMethod(AuthenticationMethod.BASIC);
account = new AccountFactory(config).createAccount();
// 获取容器
Container newContainer = account.getContainer(defaultContainerName);
if (!newContainer.exists()) {
container = newContainer.create();
log.info("account container create ==> " + defaultContainerName);
} else {
container = newContainer;
log.info("account container exists! ==> " + defaultContainerName);
}
}catch(Exception e) {
// 做异常捕获, 避免服务不能正常启动
log.error("Ceph连接初始化异常: " + e.getMessage());
}
}
/**
* 上传对象
* @param remoteName
* @param filepath
*/
public void createObject(String remoteName, String filepath) {
StoredObject object = container.getObject(remoteName);
object.uploadObject(new File(filepath));
}
/**
* 上传文件对象(字节数组形式)
* @param remoteName
* @param inputStream
*/
public void createObject(String remoteName, byte[] inputStream) {
StoredObject object = container.getObject(remoteName);
object.uploadObject(inputStream);
}
/**
* 获取指定对象
* @param containerName
* @param objectName
* @param outpath
*/
public void retrieveObject(String objectName,String outpath){
StoredObject object = container.getObject(objectName);
object.downloadObject(new File(outpath));
}
/**
* 下载文件, 转为文件流形式
* @param objectName
* @return
*/
public InputStream retrieveObject(String objectName){
StoredObject object = container.getObject(objectName);
return object.downloadObjectAsInputStream();
}
/**
* 删除指定文件对象
* @param containerName
* @param objectName
* @return
*/
public boolean deleteObject(String objectName){
try {
StoredObject object = container.getObject(objectName);
object.delete();
return !object.exists();
}catch(Exception e) {
log.error("Ceph删除文件失败: " + e.getMessage());
}
return false;
}
/**
* 获取所有容器
* @return
*/
public List listContainer() {
List list = new ArrayList();
Collection containers = account.list();
for (Container currentContainer : containers) {
list.add(currentContainer.getName());
System.out.println(currentContainer.getName());
}
return list;
}
}
此封装接口将纳入Spring 容器管理, 即为单例, 构造函数会初始化Ceph认证连接等信息
将Account 与Container 设为成员变量, 便于复用, 减少开销。
初始化会默认一个容器名称, 一般每个服务设置一个容器名称, 如果业务功能比较庞杂, 可以每个业务模块设置一个容器。
Swift Api已经做了较完善的封装, 我们内部使用比较简单, 主要封装上传和下载接口, 为便于调用处理, 做了进一步封装。
AutoCephSwiftConfiguration自动化配置类:
@Configuration
@EnableAutoConfiguration
@ConditionalOnProperty(name = "ceph.authUrl")
public class AutoCephSwiftConfiguration {
@Value("${ceph.username}")
private String username;
@Value("${ceph.password}")
private String password;
@Value("${ceph.authUrl}")
private String authUrl;
@Value("${ceph.defaultContainerName}")
private String defaultContainerName;
@Bean
public CephSwiftOperator cephSwiftOperator() {
return new CephSwiftOperator(username, password, authUrl, defaultContainerName);
}
}
ConditionalOnProperty根据ceph.authUrl属性来决定是否加载配置,如果配置文件中没有设置Ceph相关属性, 即使maven中引用, 启动也不会报错。 该自动化配置, 负责初始化一个Ceph Swift 接口操作实例。
工程配置:
要让自定义Ceph Starter真正生效, 必须遵循Spring boot 的SPI扩展机制, 在resources环境中, META-INF目录下, 创建spring.factories文件:
# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.itcast.stock.starter.ceph.AutoCephSwiftConfiguration
指定我们上面所写的自动化配置类。
用户服务工程配置:
bootstrap.yml增加:
# ceph swift 认证信息配置
ceph:
username: cephtester:subtester
password: 654321
authUrl: http://10.10.20.11:7480/auth/1.0
defaultContainerName: user_datainfo
3.3 基于snowflake的全局分布式唯一ID
snowflake算法:
snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。
第一位:
占用1bit,其值始终是0,预置为0。
时间戳
占用41bit,精确到毫秒,总共可以容纳约140年的时间。
工作机器ID
占用10bit,其中高位5bit是数据中心ID(datacenterId),低位5bit是工作节点ID(workerId),做多可以容纳1024个节点。
序列号
占用12bit,这个值在同一毫秒同一节点上从0开始不断累加,最多可以累加到4095。
了解snowflake的原理, 那么同一毫秒, 能生成的全局唯一不重复ID?
计算公式, 2^10 * 2^12 = 1024 * 4096 = 4194304。 完全可以满足高并发的场景使用要求。
Java代码实现:
/**
* 名称:GlobalIDGenerator.java
* 描述:分布式自增长ID
*
* Twitter的 Snowflake JAVA实现方案
*
* 核心代码为其GlobalIDGenerator这个类实现,其原理结构如下,我分别用一个0表示一位,用—分割开部分的作用:
* 1||0---0000000000 0000000000 0000000000 0000000000 0 --- 00000 ---00000 ---000000000000
* 在上面的字符串中,第一位为未使用(实际上也可作为long的符号位),接下来的41位为毫秒级时间,
* 然后5位datacenter标识位,5位机器ID(并不算标识符,实际是为线程标识),
* 然后12位该毫秒内的当前毫秒内的计数,加起来刚好64位,为一个Long型。
* 这样的好处是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由datacenter和机器ID作区分),
* 并且效率较高,经测试,snowflake每秒能够产生26万ID左右,完全满足需要。
*
* 64位ID (42(毫秒)+5(机器ID)+5(业务编码)+12(重复累加))
* 生成ID示例;
* 1154628864413139070
* 1154628864413139071
* 1154628864413139072
* 1154628864413139073
*/
public class GlobalIDGenerator {
// 时间起始标记点,作为基准,一般取系统的最近时间(一旦确定不能变动)
private final static long twepoch = 1288834974657L;
// 机器标识位数
private final static long workerIdBits = 5L;
// 数据中心标识位数
private final static long datacenterIdBits = 5L;
// 机器ID最大值
private final static long maxWorkerId = -1L ^ (-1L << workerIdBits);
// 数据中心ID最大值
private final static long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
// 毫秒内自增位
private final static long sequenceBits = 12L;
// 机器ID偏左移12位
private final static long workerIdShift = sequenceBits;
// 数据中心ID左移17位
private final static long datacenterIdShift = sequenceBits + workerIdBits;
// 时间毫秒左移22位
private final static long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private final static long sequenceMask = -1L ^ (-1L << sequenceBits);
/* 上次生产id时间戳 */
private static long lastTimestamp = -1L;
// 0,并发控制
private long sequence = 0L;
// 机器标识id, 分布式服务需设置不同编号, 不能超过31
// @Value("${snowflake.workerId:1}")
private final long workerId;
// 数据标识id部分, 业务编码, 不能超过31
// @Value("${snowflake.datacenterId:1}")
private final long datacenterId;
public GlobalIDGenerator() {
this.datacenterId = getDatacenterId(maxDatacenterId);
this.workerId = getMaxWorkerId(datacenterId, maxWorkerId);
}
/**
* @param workerId 工作机器ID
* @param datacenterId 序列号
*/
public GlobalIDGenerator(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
/**
* 获取下一个ID
*
* @return
*/
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
if (lastTimestamp == timestamp) {
// 当前毫秒内,则+1
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
// 当前毫秒内计数满了,则等待下一秒
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
// ID偏移组合生成最终的ID,并返回ID
long nextId = ((timestamp - twepoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift) | sequence;
return nextId;
}
/**
* 获取下一个ID, 字符形式
* @return
*/
public synchronized String nextStrId(){
return String.valueOf(nextId());
}
private long tilNextMillis(final long lastTimestamp) {
long timestamp = this.timeGen();
while (timestamp <= lastTimestamp) {
timestamp = this.timeGen();
}
return timestamp;
}
private long timeGen() {
return System.currentTimeMillis();
}
/**
*
* 获取 maxWorkerId
*
*/
protected static long getMaxWorkerId(long datacenterId, long maxWorkerId) {
StringBuffer mpid = new StringBuffer();
mpid.append(datacenterId);
String name = ManagementFactory.getRuntimeMXBean().getName();
if (!name.isEmpty()) {
/*
* GET jvmPid
*/
mpid.append(name.split("@")[0]);
}
/*
* MAC + PID 的 hashcode 获取16个低位
*/
return (mpid.toString().hashCode() & 0xffff) % (maxWorkerId + 1);
}
/**
*
* 数据标识id部分
*
*/
protected static long getDatacenterId(long maxDatacenterId) {
long id = 0L;
try {
InetAddress ip = InetAddress.getLocalHost();
NetworkInterface network = NetworkInterface.getByInetAddress(ip);
if (network == null) {
id = 1L;
} else {
byte[] mac = network.getHardwareAddress();
id = ((0x000000FF & (long) mac[mac.length - 1])
| (0x0000FF00 & (((long) mac[mac.length - 2]) << 8))) >> 6;
id = id % (maxDatacenterId + 1);
}
} catch (Exception e) {
System.out.println(" getDatacenterId: " + e.getMessage());
}
return id;
}
public static void main(String[] args) {
GlobalIDGenerator id = new GlobalIDGenerator(0, 1);
for (int i = 0; i < 10000; i++) {
System.err.println(id.nextId());
}
}
}
通过nextId方法, 直接生成全局唯一ID,该方法是有加锁处理,基于内存计算,不易产生阻塞, 实测每秒能产生26万ID(不同机器配置存在差别), 这里模拟一万条, 执行非常快。
采用SnowFlake方案, 实现与配置简单, 能保证全局唯一性, 同时还具有ID序列特征(单节点的有序性)。
把它作为一个工具类, 放在stock-common-utils工程中。
在GlobalSystemConfiguration中, 配置bean, 纳入容器管理, 不用每次调用创建新对象, 减少开销:
...
@Value("${snowflake.workerId}")
private Long workerId;
@Value("${snowflake.datacenterId}")
private Long datacenterId;
/**
* 全局ID生成器
* @return
*/
@Bean
public GlobalIDGenerator globalIDGenerator() {
if(null == workerId || null == datacenterId) {
return new GlobalIDGenerator();
}
return new GlobalIDGenerator(workerId, datacenterId);
}
如果没有配置workerId与datacenterId, 内部将会自动生成对应值。
bootstrap.yml配置:
# 全局唯一ID分布式标识配置
snowflake:
workerId: 1
datacenterId: 1
如果服务需要部署集群,按序号配置对应值, 不能随便配置, 每个服务配置的值不能重复,并且不能超过32, 因为workerId和datacenterId只有5bit,最大值为2^5。
3.4 文件上传功能
先创建实体:
@Data
public class TradeUserFile extends BaseEntity {
/**
* 主键标识
*/
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 业务类型(0:身份证, 1:银行卡, 2:信用卡)
*/
private int bizType;
/**
* 文件ID
*/
private String fileId;
/**
* 文件名称
*/
private String filename;
/**
* 文件类型
*/
private String fileType;
/**
* 文件路径
*/
private String filePath;
/**
* 状态(0:有效, 1:无效)
*/
private int status;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
}
与用户ID关联, 文件ID需保持全局唯一(支持分布式场景), 每个文件都有所属业务类型, 可以扩展。
Dao数据层实现
@Repository
public interface ITradeUserFileDao {
/**
* 新增用户文件
* @param record
* @return
*/
int insert(TradeUserFile record);
/**
* 更新用户文件
* @param record
* @return
*/
int update(TradeUserFile record);
/**
* 根据用户ID获取对象
* @param userId
* @return
*/
List getByUserId(Long userId);
/**
* 根据文件标识ID获取对象
* @param fileId
* @return
*/
TradeUserFile getByUserIdAndBizType(@Param("userId") Long userId, @Param("bizType")Integer bizType);
/**
* 根据文件ID获取对象
* @param userId
* @param bizType
* @return
*/
TradeUserFile getByFileId(@Param("fileId") Long fileId);
}
有五个数据层操作接口, 支持新增, 修改, 查询功能。 注意如果存在多个参数, 需要加上@Param注解,该注解全路径为org.apache.ibatis.annotations.Param,不要引用同名的其他路径注解。
TradeUserFileMapper文件:
SELECT LAST_INSERT_ID()
insert into t_trade_user_file (userId, bizType, fileId,
filename, fileType, filePath,
status, createTime)
values (#{userId,jdbcType=BIGINT}, #{bizType,jdbcType=TINYINT}, #{fileId,jdbcType=VARCHAR},
#{filename,jdbcType=VARCHAR}, #{fileType,jdbcType=VARCHAR}, #{filePath,jdbcType=VARCHAR},
#{status,jdbcType=TINYINT}, #{createTime,jdbcType=TIMESTAMP})
update t_trade_user_file
set
fileId = #{fileId},
filename = #{filename},
fileType = #{fileType}
where
id = #{id}
insert 采用 selectKey方式获取主键, 插入完成之后, 会将ID保存至参数对象TradeUserFile的ID中。
Service层实现:
@Service
public class StockUserFileServiceImpl implements IStockUserFileService {
@Autowired
private CephSwiftOperator cephSwiftOperator;
@Autowired
private GlobalIDGenerator globalIDGenerator;
@Autowired
private ITradeUserFileDao tradeUserFileDao;
/**
* 上传用户文件
* @return
*/
@Override
public String uploadUserIdCard(Long userId, MultipartFile file) throws Exception {
if(null == file) {
// 文件不能为空
throw new ComponentException(ApplicationErrorCodeEnum.PARAMS_FILE_NOT_NULL);
}
// 获取唯一文件ID标识
String remoteFileId = globalIDGenerator.nextStrId();
// 上传文件至CEPH
cephSwiftOperator.createObject(remoteFileId, file.getBytes());
// 查找对应用户文件
TradeUserFile dbTradeUserFile = tradeUserFileDao.getByUserIdAndBizType(userId, GlobalConstants.FILE_BIZ_TYPE_IDCARD);
if(null == dbTradeUserFile){
// 新增用户文件
dbTradeUserFile = new TradeUserFile();
dbTradeUserFile.setBizType(GlobalConstants.FILE_BIZ_TYPE_IDCARD);
dbTradeUserFile.setFilename(file.getOriginalFilename());
dbTradeUserFile.setFileType(file.getContentType());
dbTradeUserFile.setFileId(remoteFileId);
dbTradeUserFile.setUserId(userId);
dbTradeUserFile.setCreateTime(new Date());
tradeUserFileDao.insert(dbTradeUserFile);
}else {
// 清理原有文件对象
cephSwiftOperator.deleteObject(dbTradeUserFile.getFileId());
// 更新用户文件
dbTradeUserFile.setFileId(remoteFileId);
dbTradeUserFile.setFilename(file.getOriginalFilename());
dbTradeUserFile.setFileType(file.getContentType());
tradeUserFileDao.update(dbTradeUserFile);
}
return remoteFileId;
}
}
先做校验, 获取全局文件ID标识, 采用雪花算法实现, 支持分布式, 每秒可以产生26万ID, 能够满足高并发场景要求。
上传文件至Ceph系统, 通过封装的cephSwiftOperator实现, ceph 可以支持文件流和字节数组方式传递, 文件不是很大的情况, 可以直接采用字节数组方式。
在保存数据库之前, 先要查找用户之前是否已经上传过该业务类型文件, 如果为空, 则新增;
如果之前已经上传, 则覆盖更新, 每个用户的每个业务类型, 只能存在一条数据。 在覆盖更新之前,
要将原来上传的物理文件删除, 再做更新。
注意整体的实现顺序,是有考究的。 这里实现了多个功能, 为尽量保证一致性, 我们把容易出现问题的文件上传功能放在前面执行, 因为依赖第三方ceph服务, 且持续时间较长; 把SQL操作放在最后。 这样就不会出现有数据库记录, 但没有文件存在的情况。 当然, 弊端是如果数据库保存失败, 上传的文件就成了临时文件, 但这种情况不影响业务, 是可以接收的, 因为业务数据的一致性就更为重要, 后面也可以通过补偿机制, 清理无效的临时文件。
Controller层实现:
在StockUserController下面, 增加用户身份证上传接口:
@PostMapping("/uploadIdCard")
public ApiRespResult uploadIdCard(@RequestParam("file") MultipartFile file) {
ApiRespResult result = null;
try {
// 获取用户ID
Long userId = getUserId();
// 保存用户上传文件
String userFileId = stockUserFileService.uploadUserIdCard(userId, file);
result = ApiRespResult.success(userFileId);
}catch(ComponentException e) {
log.error(e.getMessage(), e);
result = ApiRespResult.error(e.getErrorCodeEnum());
}catch(Exception e) {
log.error(e.getMessage(), e);
result = ApiRespResult.sysError(e.getMessage());
}
return result;
}
通过Spring boot实现文件上传, 注意以表单form形式提交, 类型为multipart/form-data, 参数名为file, 非直接数据流方式上传, 不然服务不能正常识别接收文件。
文件上传还有个必要参数是用户ID, 但这里没有设置参数来接收, 我们直接通过增强的TOKEN获取到用户ID, 减少不必要的参数传递。
getUserId()方法,可以先作为空实现, 完成下面token增强的封装后再做完善.
3.5 利用增强Token高效获取信息
方案说明
在微服务中, OATUH2的TOKEN处理方案有多种, 有通过统一网关加工处理, 其他服务不参与, 直接通过网关获取用户信息, 但网关职能变得就不再单一, 且其他服务安全性薄弱;有通过JWT TOKEN方式, 各服务直接从JWT中获取用户信息, 但附带信息有限, 且要加解密处理,占用计算成本, 传递JWT的长度比之前token增加数倍; 还有方案是, 各服务直接拿token远程调用认证服务, 获取用户信息,远程连接开销非常高, 且增加认证服务的压力。
用户认证, 生成增强Token(增加用户ID,账号等主要信息), 存储至Redis缓存中。
在接下来的接口请求当中, 通过Token去Redis缓存里面直接获取增强Token信息, 从而获取用户ID等主要信息。
这种方案相比以上方案, 能减少交互次数,不再需要请求统一认证服务, 减少加密解密等计算成本, 同时减少数据传递, 只要最基本的Token即可。但此方案的弊端, 必须认证服务和各个服务都能直接访问Redis缓存, 如果是跨项目, 跨区域, 需要暴露外网的情形下, 那么不推荐此方案, 毕竟还是要保障数据的安全性。
项目集成使用
这里, 需要通过增强token来获取用户的ID信息, 便于我们在接入层使用。
修改用户服务工程stock-user:
封装BaseController, 作为所有Controller的基类, 这里处理一些公用逻辑,便于复用。
getUserId() 获取用户ID方法实现:
/**
* 获取当前请求的token
* @return
*/
protected String getCurrentToken() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
.getRequest();
String token = request.getHeader("Authorization");
if(null != token) {
token = token.replace(OAuth2AccessToken.BEARER_TYPE, "").trim();
}
return token;
}
/**
* 返回用户ID
* @return
*/
protected Long getUserId() throws ComponentException {
// 获取TOKEN信息
OAuth2AccessToken auth2AccessToken = tokenStore.readAccessToken(getCurrentToken());
if(null == auth2AccessToken) {
throw new ComponentException(ApplicationErrorCodeEnum.SYS_NOT_VALID_TOKEN);
}
// 获取TOKEN的增强信息
Map additionalInfos = auth2AccessToken.getAdditionalInformation();
if(null == additionalInfos) {
throw new ComponentException(ApplicationErrorCodeEnum.SYS_NOT_ACCESS_USER);
}
// 获取用户ID信息
Long userId= (Long)additionalInfos.get(GlobalConstants.OAUTH_DETAILS_USER_ID);
if(null == userId) {
throw new ComponentException(ApplicationErrorCodeEnum.SYS_NOT_ACCESS_USER);
}
return userId;
}
TokenStore从哪来? 这里要做相应配置
在GlobalSystemConfiguration中配置:
...
@Autowired
private RedisConnectionFactory redisConnectionFactory;
/**
* TokenStore实现方式, 采用Redis缓存
* @return
*/
@Bean
public TokenStore tokenStore() {
RedisTokenStore tokenStore = new RedisTokenStore(redisConnectionFactory);
tokenStore.setPrefix(GlobalConstants.OAUTH_PREFIX_KEY);
return tokenStore;
}
这里要与认证服务的TokenStore的配置保持一致,否则不能正常拿到Token缓存。
注意增加依赖:
org.springframework.boot
spring-boot-starter-data-redis
${spring-cloud-starter.version}
+ 最后, 在工程配置中增加Redis配置信息:
```yml
spring:
# Redis 缓存配置
redis:
host: 127.0.0.1
password:
port: 6379
这样, 启动工程之后, 就可以直接BaseController的getUserId方法获取用户ID, 在上面的上传文件功能controller层实现就可以可调用封装的方法。
3.6 上传功能使用验证
启动服务
注意构造参数, 表单为form形式, 参数名称为file, 类型要选择File, 才能选择文件。
可以看到接口成功返回了文件ID: 1155121848879550464。
查看t_trade_user_file表, 数据成功保存。
接下来实现文件下载功能, 验证文件能否通过ceph成功获取。
3.7 文件下载功能
新增一个接口, 根据文件ID标识下载文件。
Dao层:
ITradeUserFileDao接口:
/**
* 根据文件ID获取对象
* @param userId
* @param bizType
* @return
*/
TradeUserFile getByFileId(@Param("fileId") Long fileId);
增加根据文件ID获取文件信息的接口。
Service层:
StockUserFileServiceImpl:
/**
* 根据文件ID查找文件对象
* @param fileId
* @return
*/
@Override
public TradeUserFile getTradeUserFile(String fileId) {
return tradeUserFileDao.getByFileId(Long.valueOf(fileId));
}
Controller层:
@RequestMapping(value = "/downloadFile", method = {RequestMethod.GET, RequestMethod.POST} )
public ApiRespResult downloadFile(@NotBlank(message = "文件ID不能为空!") String fileId) {
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
.getResponse();
// 获取用户文件
TradeUserFile tradeUserFile = stockUserFileService.getTradeUserFile(fileId);
if (null == tradeUserFile) {
return ApiRespResult.error(ApplicationErrorCodeEnum.USER_FILE_NOT_FOUND);
}
BufferedInputStream bis = null;
OutputStream os = null;
try {
// 获取文件流
InputStream inputStream = cephSwiftOperator.retrieveObject(fileId);
if(null == tradeUserFile.getFileType()) {
response.setContentType("application/force-download");// 设置强制下载不打开
}
// 设置文件响应类型
response.setContentType(tradeUserFile.getFileType());
// 设置文件名
response.addHeader("Content-Disposition", "attachment;fileName=" + tradeUserFile.getFilename());
// 流式缓冲下载处理
byte[] buffer = new byte[1024];
bis = new BufferedInputStream(inputStream);
os = response.getOutputStream();
int i = bis.read(buffer);
while (i != -1) {
os.write(buffer, 0, i);
i = bis.read(buffer);
}
os.flush();
return null;
} catch (Exception e) {
log.error(e.getMessage(), e);
} finally {
if (bis != null) {
try {
// 关闭输入流
bis.close();
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
}
return ApiRespResult.sysError("下载失败!");
}
为便于演示, RequestMapping中增加get方法支持。 获取HttpServletResponse对象, 也可通过参数方式获取。 参数@NotBlank做了自动化校验。
需要增加约束性异常检查, 先检查类头部是否有@Validated注解:
@Validated
public class StockUserController extends BaseController
再检查拦截器中是否存在对ConstraintViolationException异常处理:
/**
* 拦截约束性校验异常
* @param cve
* @return
*/
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
public ApiRespResult handleConstraintViolationException(ConstraintViolationException cve){
String msg = "";
Set> cves = cve.getConstraintViolations();
for (ConstraintViolation> constraintViolation : cves) {
msg += constraintViolation.getMessage();
}
// 组装错误提示返回
ApiRespResult errorWebResult = ApiRespResult.validError(msg);
return errorWebResult;
}
通过service方法, 获取用户文件信息, 如果未找到, 抛出异常。
根据文件ID, 从ceph服务器获取文件流,直接输出至客户端,不用再通过临时文件方式输出, 提升处理效率, 上百兆的大文件就不要采用此方式。
设置响应类型与文件名称, 便于浏览器及客户端正确识别处理。
通过流式缓冲下载处理, 如果网络带宽, 服务器内存配置比较高, 可以将缓冲扩大。
最后, 不要忘记关闭流式操作对象, 所有流式操作, 都要养成良好操作习惯, 使用完毕及时释放资源。
不关闭流不会产生内存泄露, 但是会产生系统资源泄露(file descriptor );虽然finalize方法有调用close来释放fd, 但是执行时间点是存在不确定性, 在高并发场景下, 极易出现问题。
3.8 文件下载功能验证
重新启动用户服务, 访问新增的用户下载接口, 组装参数:
http://127.0.0.1:10681/user/downloadFile?fileId=1155121848879550464&access_token=01a2a051-19ae-43d7-ab51-bab02c864974
填写上传返回的文件ID, 受认证权限控制, 需要附带access_token参数。
可以看到, 文件成功下载。
3.1 Spring Boot 多环境配置介绍
在项目开发过程中, 会存在多个环境, 每个环境的服务器地址不一样,比如redis,数据库等, 在敏捷迭代式交付中, 需要频发更新打包, 完成新需求与版本缺陷。这就需要根据不同环境编写不同配置, 通过MAVEN生成支持各种环境的运行包。
大型互联网微服务项目, 一般会采用统一配置中心来解决此问题, 循序渐进, 先通过Spring Boot本地化多版本配置文件方式来实现不同环境的配置管理, 后面会通过配置中心方式做生产级的集成实践方案。
3.2 Spring Boot 多配置规则
一般Spring Boot 项目, 支持的配置文件多种多样, 比如application.yml、application.properties、优先级更高的bootstrap.yml和bootstrap.properties等。
多环境配置文件名需要满足application-{profile}.properties的格式,其中{profile}对应的是环境标识, 可以自定义填写。 一般定义规则:
application-dev.properties:开发环境 application-uat.properties:测试集成环境 application-prd.properties:生产环境
定义这么多配置文件,那么Spring boot 怎么知道是读取哪个配置文件?
这就还需再定义一个配置文件, 名称为application.properties(用其他application.yml也可以),通过spring.profiles.active属性来设置对应的{profile}值。
也可以通过参数-Dspring.profiles.active来指定{profile}值, 从易读与维护性考虑,建议采用默认配置文件方式。
在本地环境中, 建立了两套配置, 一套是本地的dev环境, 另一套是用于联调集成测试的uat环境。
bootstrap.yml配置:
spring:
profiles:
active: ${package.environment}
这里没有明确指定一个环境,设置的是一个MAVEN变量。
最新版本的Idea是可以识别MAVEN变量, 老版本是不能识别, 解决方法:
打开Run->Edit Configurations, 在Active Profile输入框中指定配置环境。
这样, 每次运行就不用再去指定配置文件, 本地环境专门维护bootstrap-dev.yml即可。
接下来如何打包处理? MAVEN是怎么识别? 是否会存在冲突?
3.4 多环境打包配置
修改pom.xml文件, 增加以下内容:
dev
true
dev
uat
uat
prd
prd
stock-user-${package.environment}
org.apache.maven.plugins
maven-resources-plugin
default-resources
validate
copy-resources
target/classes
false
${*}
src/main/resources/
true
src/main/java
**/*.xml
false
org.springframework.boot
spring-boot-maven-plugin
repackage
profiles标签为配置文件标识。这里设置了三个环境, dev, uat和prd。通过activeByDefault标签, 默认激活的是dev环境, 下面的 properties定义了变量package.environment,如果是最新版的IDEA, 可直接运行程序, 会自动识别dev配置文件。
buid标签是打包配置, 要注意resources下面增加src/main/java目录扫描:
src/main/java
**/*.xml
false
此项配置是打包环境变量下, mapper目录下的xml文件, 防止启动报错。
配置打包命令:
打开maven视图,找到stock-user用户服务工程, 创建打包命令:
填写clean install -P uat 这里的-P参数是指定profile环境
接下来执行新创建的命令:
打包成功, 并生成jar包:
如果打包失败,根据maven错误提示, 检查所有依赖的组件是否已执行clean install, 在打包之前, 最好将所有模块都编译打包一遍, 在最顶级工程下面, 执行clean install命令进行打包,避免编译过程中, 出现的组件依赖问题。
如果要打包生产环境的运行包, 按照上面配置, 再建立一个名为prd的Profile 和对应的bootstrap-prd.yml配置文件, 通过maven执行命令即可。通过这种方式,开发完成后, 不再需要反复修改配置文件, 每个环境维护独立一套配置文件, 不会产生冲突错误, 便于开发管理。
实际项目中, 往往是多人协作,负责不同模块开发, 在打包发布的时候, 会增加deploy命令,
例: clean install deploy -P uat
该命令作用是, 不仅安装到本地MAVEN库, 还会发布到远程MAVEN仓库中,保证每个人生成的都是最新的包, 集成的是最新版本功能。