运行须知:

  • 关闭电脑端的防火墙(来宾或公用网络)
  • 电脑与手机处于同一局域网内(开热点,或连同一个网络)
  • 前端访问的ip地址为热点网络的ip地址
  • 如果前端需要访问电脑虚拟机中的服务,需要在虚拟机:编辑-网络编辑器-NAT设置-端口转发 中设置主机到虚拟机的端口转发。

IM服务端-理论实现

消息通信

服务端负责转发用户消息,以及消息回执(确认应答)。

image-20230518150747693

文本消息类ChatMsg:

1
2
3
4
5
6
7
message ChatMsg{
id = 1; //消息id
fromId = Alice //发送者userId
destId = Bob //接收者userId
msgBody = hello
//消息体
}

消息回执类AckMsg:

1
2
3
4
5
6
7
8
9
10
11
12
message AckMsg {
id; //消息id
fromId; //发送者id
destId; //接收者id
msgType; //消息类型
ackMsgId; //确认的消息id
}

enum MsgType {
DELIVERED;
READ;
}

注: 服务器不区分ChatMsg和AckMsg,处理过程都是一样的,即解析消息的destId并进行转发。

水平拓展

管理用户状态

当用户量越来越大,必然需要增加服务器的数量,用户的连接被分散在不同的机器上。此时,就需要存储用户连接在哪台机器上。

此时引入一个模块来管理用户的连接信息,并用Redis以key-value形式存储 userID和connectorID:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface UserStatusService {

/**
* 用户上线,存储userId与机器id的关系
* @return 如果当前用户在线,则返回他连接的机器id,否则返回null
*/
String online(String userId, String connectorId);

/**
* 用户下线
*/
void offline(String userId);

/**
* 通过用户id查找他当前连接的机器id
*/
String getConnectorId(String userId);
}

消息转发

此外,还需要一个模块在不同机器上转发消息,结构如下:

image-20230518151947724

此时服务被拆分成了connector和transfer两个模块,connector模块用于维持用户的长链接,而transfer的作用是将消息在多个connector之间转发。

消息传递流程:

  1. 假设 Alice上线,连接到机器[1]上:

    机器1将Alice和它的连接存入内存,调用user status的online方法记录Alice上线。

  2. Alice 发送一条消息给 Bob:

    1. 机器1收到消息后,解析destId,在内存中查找是否有Bob。
    2. 如果没有,代表Bob未连接到这台机器,则转发给transfer。
  3. transfer调用user status的getConnectorId(Bob) 方法找到Bob所连接的connector,返回机器[2]

image-20230518152922282

总结:

引入user status模块管理用户连接,transfer模块在不同的机器之间转发,使服务可以水平扩展。为了满足实时转发,transfer需要和每台connector机器都保持长链接。

离线消息

如果用户当前不在线,就必须把消息持久化下来,等待用户下次上线再推送,这里使用mysql存储离线消息。

为了方便地水平扩展,可以使用消息队列进行解耦:

  • transfer接收到消息后如果发现用户不在线,就发送给消息队列入库;
  • 用户登录时,服务器从库里拉取离线消息进行推送。

离线消息表:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `im_offline` (
`id` int(11) COMMENT '主键',
`msg_id` bigint(20) COMMENT '消息id',
`msg_type` int(2) COMMENT '消息类型',
`content` varbinary(5000) COMMENT '消息内容',
`to_user_id` varchar(100) COMMENT '收件人id',
`has_read` tinyint(1) COMMENT '是否阅读',
`gmt_create` timestamp COMMENT '创建时间',
PRIMARY KEY (`id`)
);

msg_type用于区分消息类型(chat,ack),content加密后的消息内容以byte数组的形式存储。

用户上线时按照条件to_user_id=用户id拉取记录即可。

  • 防止离线消息重复推送:

    1. 首先取出所有has_read=false的字段;

    2. 检查每条消息的has_read值是否为false,如果是,则改为true。这是原子操作。

      1
      update im_offline set has_read = true where id = ${msg_id} and has_read = false
    3. 修改成功则推送,失败则不推送。

用户登录、好友关系等

用户的注册登录、账户管理、好友关系链等功能更适合使用http协议,因此将这个模块做成一个restful服务,对外暴露http接口供客户端调用。

基本架构

至此,基本架构就完成了,如下图:

image-20230518154511321

可靠性

对于IM系统来说,可靠是指 不丢消息,消息不重复,不乱序。

  • 不丢消息: 加入ack机制,确认对方收到了消息。

    具体实现可以模仿tcp协议做一个应用层的ack机制:

    • tcp以字节为单位,而这里用message为单位。
    • 发送方每次发送一个消息,就要等待对方的ack回应,在ack确认消息中应带有收到的 id 以便发送方识别。
    • 发送方需要维护一个等待ack的队列。每次发送一个消息后,就将消息和一个计时器入队。
    • 在另外一个线程一直轮询队列,如果有超时未收到ack的,就去除消息重发。

    超时未收到ack的话,有两种处理:

    • 和tcp一样,不断发送知道收到ack为止;

    • 设定一个最大重试次数,超过这个次数还没收到ack,就使用失败机制处理,节约资源。

      例如如果是connector长时间未收到client的ack,那么可以主动断开和客户端的连接,剩下未发送的消息就作为离线消息入库,客户端断连后尝试重连服务器即可。

  • 不重复、不乱序:

    有的时候因为网络原因可能导致ack收到较慢,发送方就会重复发送,那么接收方必须有一个去重机制。

    去重的方式是给每个消息增加一个唯一id。这个唯一id只需要在一个会话中唯一即可,例如某两个人的会话,或者某一个群。如果网络断连了,重新连接后,就是新的会话了,id会重新从0开始。

    • 接收方需要在当前会话中维护收到的最后一个消息的id,叫做lastId。

    • 每次收到一个新消息, 就将id与lastId作比较看是否连续,如果不连续,就放入一个暂存队列 queue中稍后处理。

    还有一点,当客户端上线时,应该先推送完离线消息,再更新session。否则,先更新了session的话,在推送离线消息时,可能有新消息进来,导致乱序。

安全性

客户的隐私需要保护,因此所有的消息都必须加密处理。

可以将通信密钥保存在用户关系表relation中,当客户端登录时,就会从数据库中获取该用户所有的relation,存在内存中,以便后续加密解密。 当客户端给某个好友发送消息时,取出内存中该关系的密钥,加密后发送。同样,当收到一条消息时,取出相应的密钥解密。

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `im_relation` (
`id` bigint(20) COMMENT '关系id',
`user_id1` varchar(100) COMMENT '用户1id',
`user_id2` varchar(100) COMMENT '用户2id',
`encrypt_key` char(33) COMMENT 'aes密钥',
`gmt_create` timestamp DEFAULT CURRENT_TIMESTAMP,
`gmt_update` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `USERID1_USERID2` (`user_id1`,`user_id2`)
);

项目描述

  • 该项目是一个即时聊天通信项目,用于和好友一对一聊天或加群群聊,以及传输文件。

技术栈: Springboot + Netty + Redis + RabbitMQ + MinIO

实现功能:

  1. 实现token方式的登录注册,避免跨域和session共享问题。

    登录时返回随机UUID作为token,redis存储token 【key: login:token:token值;value:用户简略信息】, 使用拦截器对所有页面进行token持续时间刷新,

  2. 实现点对点聊天,群聊,消息未读标记,上线消息推送,好友管理,好友在线查看等功能。(netty + redis + mysql)

    好友管理:

    1. 搜索用户
    2. 发送好友请求: 添加好友请求记录到数据库
    3. 接收好友请求: 查询数据库,有没有发给自己的好友请求
    4. 处理好友请求: 拒绝=直接删除数据库信息,接收=相互增加好友记录到双方数据库表
    5. 查询好友列表: 数据库查询即可

    聊天:

    有最新消息的聊天排在前面, 使用redis中SortedSet来存储会话列表(单聊群聊都一样)

    key:prefix_session_list:{sessionID};聊天ID

    value:{chatCount}; 聊天信息总数

    score:{msgID}; 最新聊天信息ID

    用户对每一个聊天的进度,未读信息数 = chatCount - readCount;

    key:prefix_read:{uid}:{sessionID}; 用户所有的聊天

    value:{readCount}; 已读信息总数

    score:{msgID}; 已读最新聊天ID

    (新入群的成员不能看到入群前的信息,可以维护一个入群时的msgID,群中大于msgID的msg才能被看到)

    • 单聊: 收到消息,先保存到mysql数据库并标为“未签收”,如果接收者在线则直接发送,不在线则推送(未实现)。 接收者收到消息会发送ack,于是将此消息标为“已签收”。

    • 群聊:

      字段名 注释
      gid 群id
      uid 用户id
      last_ack_msgid 最后接收的消息id
      字段名 注释
      gid 群id
      msgid 消息id
      sender_uid 发送者id
      time 发送时间
      content 内容

      消息只存一份,使用读扩散。 用户刚加入群时,给当前群的最大msgid作为 最后接收的消息id,用户看群消息默认拉取最新30条消息,每次向上拉取都拿30条消息。

      • 新消息数量 = 群最大msgid - last_ack_msgid, 点击有x条新消息,直接拉取msgid > last_ack_msgid 的该群消息。

    好友在线查看:

    redis中存储用户在线信息: 【key:online_user:{uid};value:1/0】

    登录上线时value置为1,退出或心跳读写都超时时置为0;

  3. 基于netty实现心跳机制,无读写时保持连线。利用RabbitMQ有序处理离线消息。

    netty心跳实现: IdleStateHandler设置读写超时时间,自定义的HeartBeatHandler()执行超时处理,如关闭channel,设置用户下线状态等

    上线推送:根据redis中已读的聊天信息ID和已读数,判断是否有新消息。

  4. 基于MinIO实现文件存储功能,支持断点续传。

    使用minIO进行分布式存储;

    断点续传: 在上传前先进行分块, 每个分块上传时标记上传中,上传完成后标记已上传。

Hello Netty服务器

  • 构建一对主从线程组
  • 定义服务器启动类
  • 为服务器设置Channel
  • 设置处理从线程池的助手类初始化器
  • 监听启动和关闭服务器

HelloServer类, 搭建线程组和配置启动引导类:

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
// 实现客户端发送一个请求,服务端会返回hello netty
public class HelloServer {
public static void main(String[] args) {
// bossGroup负责接收请求,workGroup负责请求的处理
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workGroup = new NioEventLoopGroup(1);
// 配置启动引导类
ServerBootstrap serverBootstrap = new ServerBootstrap();
try {
serverBootstrap
.group(bossGroup, workGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new HelloServerInitializer()); // Channel初始化
// 绑定端口, sync()会阻塞直到绑定完成
ChannelFuture future = serverBootstrap.bind(8088).sync();
// 关闭Channel, sync()会阻塞直到Channel关闭
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 关闭线程组
bossGroup.shutdownGracefully();
workGroup.shutdownGracefully();
}
}
}

HelloServerInitializer类,初始化Channel:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Channel初始化器
public class HelloServerInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();

// handler可以理解为是拦截器
// HttpServerCodec是netty提供的,用于请求的编解码
pipeline.addLast("HttpServerCodec", new HttpServerCodec());
// 自定义的handler,返回“hello netty”
pipeline.addLast("customHandler", new CustomHandler());
}
}

CustomHandler类,具体的Handler处理器,可以看作是拦截器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 创建自定义助手类
// SimpleChannelInboundHandler用于处理入站消息,并自动释放内存。
public class CustomHandler extends SimpleChannelInboundHandler<HttpObject> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, HttpObject msg) throws Exception {
// 1.从上下文获取Channel
Channel channel = ctx.channel();
System.out.println(channel.remoteAddress()); // 显示客户端远程地址
// 2.定义要发送的数据,存放到ByteBuf
ByteBuf content = Unpooled.copiedBuffer("Hello netty!", CharsetUtil.UTF_8);
// 3.构建一个response
DefaultFullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, // HTTP版本
HttpResponseStatus.OK, // HTTP状态码
content // 内容
);
// 4. 为响应体添加数据类型和长度
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain");
// ByteBuf的readableBytes()方法返回该缓冲区的可读字节长度
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes());
// 将响应写入并刷新到客户端
ctx.writeAndFlush(response);
}
}

springboot整合netty + 通信逻辑

服务端

springboot监听器

ApplicationListener是一个事件监听、订阅的实现, 此处在根上下文处,即程序初始化时启动netty

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class NettyBooter implements ApplicationListener<ContextRefreshedEvent> {

@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
if (event.getApplicationContext().getParent() == null) {
// 当前的 ApplicationContext 是根上下文,此时程序处于根上下文初始化阶段
try {
WSServer.getInstance().start(); // 启动netty
} catch (Exception e) {
e.printStackTrace();
}
}
}

}

netty服务端启动类

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
@Component
public class WSServer {

private static class SingletionWSServer {
static final WSServer instance = new WSServer();
}

// 单例模式
public static WSServer getInstance() {
return SingletionWSServer.instance;
}

private EventLoopGroup mainGroup; // 主线程
private EventLoopGroup subGroup; // 工作线程
private ServerBootstrap server; // 服务端启动引导类
// netty的IO操作是异步, 在IO调用之后, 会返回一个ChannelFuture实例, 用于提供IO操作的结果和状态信息
private ChannelFuture future;

// 初始化
public WSServer() {
mainGroup = new NioEventLoopGroup();
subGroup = new NioEventLoopGroup();
server = new ServerBootstrap();
server.group(mainGroup, subGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new WSServerInitialzer());
}

// 绑定端口, 开始提供服务
public void start() {
this.future = server.bind(8088);
System.err.println("netty websocket server 启动完毕...");
}
}

Channel初始化类

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 WSServerInitialzer extends ChannelInitializer<SocketChannel> {

// Channel初始化, 给ChannelPipeline添加需要的handler处理器
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();

// ====================== 以下是用于支持http协议 ======================
// websocket 基于http协议,所以要有http编解码器
pipeline.addLast(new HttpServerCodec());
// 对写大数据流的支持
pipeline.addLast(new ChunkedWriteHandler());
// 对httpMessage进行聚合,聚合成FullHttpRequest或FullHttpResponse
// 几乎在netty中的编程,都会使用到此hanler
pipeline.addLast(new HttpObjectAggregator(1024*64));

// ====================== 以下用于增加心跳支持 ======================
// 针对客户端,如果在1分钟时没有向服务端发送读写心跳(ALL),则主动断开
// 如果是读空闲或者写空闲,不处理
pipeline.addLast(new IdleStateHandler(8, 10, 12));
// 自定义的空闲状态检测
pipeline.addLast(new HeartBeatHandler());

// ====================== 以下是支持httpWebsocket ======================
/**
* websocket 服务器处理的协议,用于指定给客户端连接访问的路由 : /ws
* 这个handler会帮你处理一些繁重的复杂的事
* 能帮你处理握手动作: handshaking(close, ping, pong) ping + pong = 心跳
* 对于websocket来讲,都是以frames进行传输的,不同的数据类型对应的frames也不同
*/
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));

// 自定义的handler
pipeline.addLast(new ChatHandler());
}

}

自定义的Handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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
91
92
93
94
95
96
97
98
99
100
101
/**
* @Description: 处理消息的handler
* TextWebSocketFrame: 在 netty中,该frame是用于为websocket专门处理文本的对象,frame是消息的载体
*/
// SimpleChannelInboundHandler是入站处理器, 具体的处理逻辑在channelRead0中
public class ChatHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

// 用于记录和管理所有客户端的channel
public static ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);

// 具体的消息处理
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
// 获取客户端传输过来的消息
String content = msg.text();
Channel currentChannel = ctx.channel();

// 1. 获取客户端发来的消息
DataContent dataContent = JsonUtils.jsonToPojo(content, DataContent.class);
Integer action = dataContent.getAction();

// 2. 判断消息类型,根据不同的类型来处理不同的业务
if (action == MsgActionEnum.CONNECT.type) {
// 2.1 当websocket 第一次open的时候,初始化channel,把当前的channel和userid关联起来
String senderId = dataContent.getChatMsg().getSenderId(); // 发送者id
UserChannelRel.put(senderId, currentChannel);

} else if (action == MsgActionEnum.CHAT.type) {
// 2.2 聊天类型的消息,把聊天记录保存到数据库,同时标记消息的签收状态[未签收]
.....

// 发送消息
// 从全局用户Channel关系中获取接受方的channel
Channel receiverChannel = UserChannelRel.get(receiverId);
if (receiverChannel == null) {
// TODO channel为空代表用户离线,推送消息(JPush,个推,小米推送)
} else {
// 当receiverChannel不为空的时候,从ChannelGroup去查找对应的channel是否存在
Channel findChannel = clients.find(receiverChannel.id());
if (findChannel != null) {
// 用户在线
receiverChannel.writeAndFlush(
new TextWebSocketFrame(JsonUtils.objectToJson(dataContentMsg))
);
} else {
// 用户离线 TODO 推送消息
}
}
} else if (action == MsgActionEnum.SIGNED.type) {
// 2.3 签收消息类型,针对具体的消息进行签收,修改数据库中对应消息的签收状态[已签收]
UserService userService = (UserService) SpringUtil.getBean("userServiceImpl");
// 扩展字段在signed类型的消息中,代表需要去签收的消息id,逗号间隔
String msgIdsStr = dataContent.getExtand();
String msgIds[] = msgIdsStr.split(",");

List<String> msgIdList = new ArrayList<>();
for (String mid : msgIds) {
if (StringUtils.isNotBlank(mid)) {
msgIdList.add(mid);
}
}

if (!msgIdList.isEmpty()) {
// 批量签收
userService.updateMsgSigned(msgIdList);
}

} else if (action == MsgActionEnum.KEEPALIVE.type) {
// 2.4 心跳类型的消息
System.out.println("收到来自channel为[" + currentChannel + "]的心跳包...");
}
}

/**
* 当客户端连接服务端之后(打开连接)
* 获取客户端的channle,并且放到 ChannelGroup中去进行管理
*/
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
clients.add(ctx.channel());
}

@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {

String channelId = ctx.channel().id().asShortText();
System.out.println("客户端被移除,channelId为:" + channelId);

// 当触发handlerRemoved,ChannelGroup会自动移除对应客户端的channel
clients.remove(ctx.channel());
}

// 客户端与服务端连接过程中发生异常的话, 如网络问题等, 需要关闭连接,并从group移除
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
// 发生异常之后关闭连接(关闭channel),随后从ChannelGroup中移除
ctx.channel().close();
clients.remove(ctx.channel());
}
}

心跳机制

添加心跳相关的处理器,自定义心跳处理器

1
2
3
4
5
6
// 增加心跳支持
// 针对客户端,如果在1分钟时没有向服务端发送读写心跳(ALL),则主动断开
// 如果是读空闲或者写空闲,不处理
pipeline.addLast(new IdleStateHandler(8, 10, 12));
// 自定义的空闲状态检测
pipeline.addLast(new HeartBeatHandler());
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
public class HeartBeatHandler extends ChannelInboundHandlerAdapter {

@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {

// 判断evt是否是IdleStateEvent(用于触发用户事件,包含 读空闲/写空闲/读写空闲 )
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent)evt; // 强制类型转换

if (event.state() == IdleState.READER_IDLE) {
System.out.println("进入读空闲...");
} else if (event.state() == IdleState.WRITER_IDLE) {
System.out.println("进入写空闲...");
} else if (event.state() == IdleState.ALL_IDLE) {

System.out.println("channel关闭前,users的数量为:" + ChatHandler.clients.size());

Channel channel = ctx.channel();
// 关闭无用的channel,以防资源浪费
channel.close();

System.out.println("channel关闭后,users的数量为:" + ChatHandler.clients.size());
}
}

}

}

客户端

本项目客户端在前端逻辑中使用了 WebSocket协议发送消息, 所以客户端不需要再用Netty。 而服务端的netty处理器会接收一个TextWebSocketFrame,其中frame是消息的载体,它是用于为websocket专门处理文本的对象。

文件服务器

FastDFS

FastDFS是用C语言编写的分布式文件系统。 拥有冗余备份,负载均衡,线性扩容等机制,并注重高可用、高性能等指标。

组成: 客户端,Tracker Server,Storage Server

文件上传流程:

  1. Storange Server定时向 tracker 汇报自己的状态。
  2. 客户端向Tracker Server提出上传请求;
  3. Tracker Server 检查是否有可用的storage, 返回可用的storange给客户端;
  4. 客户端向指定的 Tracker Server上传文件;
  5. Storage Server 将文件写入,并返回文件相对路径。

image-20230418152152519

需要安装:libfastcommo ,fastdfs,fastdfs-nginx-module,nginx,

springboot整合fastDFS

依赖:

1
2
3
4
5
<dependency>
<groupId>com.github.tobato</groupId>
<artifactId>fastdfs-client</artifactId>
<version>1.26.2</version>
</dependency>

将fdfs配置引入项目: @Import(FdfsClientConfig.class)

1
2
3
4
5
6
7
@Configuration
@Import(FdfsClientConfig.class)
// 解决jmx重复注册bean的问题
@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
public class FastdfsImporter {
// 导入依赖组件
}

在配置文件中配置:

1
2
3
4
5
6
7
8
9
10
11
12
# ============================
# 分布式文件系统FDFS配置
# ============================
fdfs:
so-timeout: 1501
connect-timeout: 601
thumb-image: #缩略图生成参数
width: 80
height: 80
tracker-list: #TrackerList参数,支持多个
- 192.168.205.129:22122
- 192.168.205.130:22122

或者properties格式:

1
2
3
4
5
fdfs.soTimeout=1501
fdfs.connectTimeout=601
fdfs.thumbImage.width=80
fdfs.thumbImage.height=80
fdfs.trackerList[0]=192.168.1.70:22122

例子: 使用FastDFS的工具类

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.Charset;

import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import com.github.tobato.fastdfs.domain.StorePath;
import com.github.tobato.fastdfs.exception.FdfsUnsupportStorePathException;
import com.github.tobato.fastdfs.service.FastFileStorageClient;

@Component
public class FastDFSClient {

@Autowired
private FastFileStorageClient storageClient;

// @Autowired
// private AppConfig appConfig; // 项目参数配置

/**
* 上传文件
*
* @param file
* 文件对象
* @return 文件访问地址
* @throws IOException
*/
public String uploadFile(MultipartFile file) throws IOException {
StorePath storePath = storageClient.uploadFile(
file.getInputStream(),
file.getSize(),
FilenameUtils.getExtension(file.getOriginalFilename()),
null);

return storePath.getPath();
}

/**
* 上传文件,第2种方式
* @param file
* @return
* @throws IOException
*/
public String uploadFile2(MultipartFile file) throws IOException {
StorePath storePath = storageClient.uploadImageAndCrtThumbImage(
file.getInputStream(),
file.getSize(),
FilenameUtils.getExtension(file.getOriginalFilename()),
null);

return storePath.getPath();
}

/**
* 上传二维码
* @param file
* @return
* @throws IOException
*/
public String uploadQRCode(MultipartFile file) throws IOException {
StorePath storePath = storageClient.uploadFile(file.getInputStream(), file.getSize(),
"png", null);

return storePath.getPath();
}

/**
* 上传头像
* @param file
* @return
* @throws IOException
*/
public String uploadFace(MultipartFile file) throws IOException {
StorePath storePath = storageClient.uploadImageAndCrtThumbImage(file.getInputStream(), file.getSize(),
"png", null);

return storePath.getPath();
}

/**
* 上传base64字符串
* @param file
* @return
* @throws IOException
*/
public String uploadBase64(MultipartFile file) throws IOException {
StorePath storePath = storageClient.uploadImageAndCrtThumbImage(file.getInputStream(), file.getSize(),
"png", null);

return storePath.getPath();
}

/**
* 将一段字符串生成一个文件上传
*
* @param content
* 文件内容
* @param fileExtension
* @return
*/
public String uploadFile(String content, String fileExtension) {
byte[] buff = content.getBytes(Charset.forName("UTF-8"));
ByteArrayInputStream stream = new ByteArrayInputStream(buff);
StorePath storePath = storageClient.uploadFile(stream, buff.length, fileExtension, null);
return storePath.getPath();
}

// 封装图片完整URL地址
// private String getResAccessUrl(StorePath storePath) {
// String fileUrl = AppConstants.HTTP_PRODOCOL + appConfig.getResHost() + ":" + appConfig.getFdfsStoragePort()
// + "/" + storePath.getFullPath();
// return fileUrl;
// }

/**
* 删除文件
*
* @param fileUrl
* 文件访问地址
* @return
*/
public void deleteFile(String fileUrl) {
if (StringUtils.isEmpty(fileUrl)) {
return;
}
try {
StorePath storePath = StorePath.praseFromUrl(fileUrl);
storageClient.deleteFile(storePath.getGroup(), storePath.getPath());
} catch (FdfsUnsupportStorePathException e) {
e.getMessage();
}
}
}

文件处理工具类:

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;

import org.springframework.mock.web.MockMultipartFile;
import org.springframework.stereotype.Service;
import org.springframework.util.Base64Utils;
import org.springframework.web.multipart.MultipartFile;

@Service
public class FileUtils {
/**
* 根据url拿取file
*
* @param url
* @param suffix
* 文件后缀名
*/
public static File createFileByUrl(String url, String suffix) {
byte[] byteFile = getImageFromNetByUrl(url);
if (byteFile != null) {
File file = getFileFromBytes(byteFile, suffix);
return file;
} else {
return null;
}
}

/**
* 根据地址获得数据的字节流
*
* @param strUrl
* 网络连接地址
* @return
*/
private static byte[] getImageFromNetByUrl(String strUrl) {
try {
URL url = new URL(strUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(5 * 1000);
InputStream inStream = conn.getInputStream();// 通过输入流获取图片数据
byte[] btImg = readInputStream(inStream);// 得到图片的二进制数据
return btImg;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

/**
* 从输入流中获取数据
*
* @param inStream
* 输入流
* @return
* @throws Exception
*/
private static byte[] readInputStream(InputStream inStream) throws Exception {
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len = 0;
while ((len = inStream.read(buffer)) != -1) {
outStream.write(buffer, 0, len);
}
inStream.close();
return outStream.toByteArray();
}

// 创建临时文件
private static File getFileFromBytes(byte[] b, String suffix) {
BufferedOutputStream stream = null;
File file = null;
try {
file = File.createTempFile("pattern", "." + suffix);
System.out.println("临时文件位置:" + file.getCanonicalPath());
FileOutputStream fstream = new FileOutputStream(file);
stream = new BufferedOutputStream(fstream);
stream.write(b);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (stream != null) {
try {
stream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return file;
}

public static MultipartFile createImg(String url) {
try {
// File转换成MutipartFile
File file = FileUtils.createFileByUrl(url, "jpg");
FileInputStream inputStream = new FileInputStream(file);
MultipartFile multipartFile = new MockMultipartFile(file.getName(), inputStream);
return multipartFile;
} catch (IOException e) {
e.printStackTrace();
return null;
}
}

public static MultipartFile fileToMultipart(String filePath) {
try {
// File转换成MutipartFile
File file = new File(filePath);
FileInputStream inputStream = new FileInputStream(file);
MultipartFile multipartFile = new MockMultipartFile(file.getName(), "png", "image/png", inputStream);
return multipartFile;
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
return null;
}
}

public static void main(String[] args) {
// WebFileUtils.createFileByUrl("http://122.152.205.72:88/group1/M00/00/01/CpoxxFr7oIaAZ0rOAAC0d3GKDio580.png",
// "png");
// WebFileUtils.createImg("http://122.152.205.72:88/group1/M00/00/01/CpoxxFr7oIaAZ0rOAAC0d3GKDio580.png");
}

public static boolean base64ToFile(String filePath, String base64Data) throws Exception {
String dataPrix = "";
String data = "";

if(base64Data == null || "".equals(base64Data)){
return false;
}else{
String [] d = base64Data.split("base64,");
if(d != null && d.length == 2){
dataPrix = d[0];
data = d[1];
}else{
return false;
}
}

// 因为BASE64Decoder的jar问题,此处使用spring框架提供的工具包
byte[] bs = Base64Utils.decodeFromString(data);
// 使用apache提供的工具类操作流
org.apache.commons.io.FileUtils.writeByteArrayToFile(new File(filePath), bs);

return true;
}
}

MinIO

MinIO 适合存储大容量非结构化的数据,例如图片,视频,日志文件,备份数据和容器/虚拟机镜像等。

MinIO集群采用去中心化共享架构,每个节点是对等关系,通过Nginx可对MinIO进行负载均衡访问。

  • MinIO的去中心化和分布式,可以用于搭建一个高可用的对象存储服务: MinIO将分布在不同服务器上的多块硬盘组成一个对象存储服务。由于硬盘分布在不同的节点上,分布式MinIO避免了单点故障。
  • MinIO使用纠删码技术来保护数据。 纠删码是一种恢复丢失和损坏数据的数学算法, 它将数据分块冗余地分散在各个节点的磁盘上,所有可用磁盘组成一个集合,当上传一个文件时会通过纠删码算法计算 ,将文件进行分成n块,还会生成n个校验码,分散存储在n个硬盘上。 如果丢失了一半以下数量的硬盘,仍然可以恢复数据,不过在恢复重构时需要消耗CPU资源且有延时。 (类似 abc 分成了 ab,bc,ac,损失一半以下仍能恢复)

使用docker启动:

  • 拉取镜像: docker pull minio/minio

  • 启动运行: 单一服务器,4个磁盘挂载点

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    docker run -p 9000:9000 -p 9090:9090 \
    --net=host \
    --name minio \
    -d --restart=always \
    -e "MINIO_ACCESS_KEY=minioadmin" \
    -e "MINIO_SECRET_KEY=minioadmin" \
    -v /home/minio/data1:/data1 \
    -v /home/minio/data2:/data2 \
    -v /home/minio/data3:/data3 \
    -v /home/minio/data4:/data4 \
    -v /home/minio/config:/root/.minio \
    minio/minio server \
    /data{1...4} --console-address ":9090" -address ":9000"

    或者4个服务器,每个服务器有2个磁盘挂载点:

    所有服务器配置主机名解析:

    1
    2
    3
    4
    192.168.205.129 minio1
    192.168.205.130 minio2
    192.168.205.131 minio3
    192.168.205.132 minio4

    minio1

    1
    2
    3
    4
    5
    6
    7
    8
    9
    docker run -d --name minio \
    --restart=always --net=host \
    -e "MINIO_ACCESS_KEY=minioadmin" \
    -e "MINIO_SECRET_KEY=minioadmin" \
    -v /home/minio/data1:/data1 \
    -v /home/minio/data2:/data2 \
    minio/minio server \
    --address 192.168.205.129:9000 \
    http://minio{1...4}/data{1...2}

    minio2

    1
    2
    3
    4
    5
    6
    7
    8
    9
    docker run -d --name minio \
    --restart=always --net=host \
    -e "MINIO_ACCESS_KEY=minioadmin" \
    -e "MINIO_SECRET_KEY=minioadmin" \
    -v /home/minio/data1:/data1 \
    -v /home/minio/data2:/data2 \
    minio/minio server \
    --address 192.168.205.130:9000 \
    http://minio{1...4}/data{1...2}

    minio3

    1
    2
    3
    4
    5
    6
    7
    8
    9
    docker run -d --name minio \
    --restart=always --net=host \
    -e "MINIO_ACCESS_KEY=minioadmin" \
    -e "MINIO_SECRET_KEY=minioadmin" \
    -v /home/minio/data1:/data1 \
    -v /home/minio/data2:/data2 \
    minio/minio server \
    --address 192.168.205.131:9000 \
    http://minio{1...4}/data{1...2}

    minio4

    1
    2
    3
    4
    5
    6
    7
    8
    9
    docker run -d --name minio \
    --restart=always --net=host \
    -e "MINIO_ACCESS_KEY=minioadmin" \
    -e "MINIO_SECRET_KEY=minioadmin" \
    -v /home/minio/data1:/data1 \
    -v /home/minio/data2:/data2 \
    minio/minio server \
    --address 192.168.205.132:9000 \
    http://minio{1...4}/data{1...2}
  • 访问: h ttp://192.168.205.129:9090/ 即可。

spirngboot整合MinIO

minio管理界面Buckets里你要用的bucket的Manage里的 Access Policy改为public。(访问策略改为public)

添加依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 低版本的okhttp会报错提示 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.9.0</version>
</dependency>

<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.4.2</version>
<exclusions>
<exclusion>
<artifactId>okhttp</artifactId>
<groupId>com.squareup.okhttp3</groupId>
</exclusion>
</exclusions>
</dependency>

使用测试

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import com.j256.simplemagic.ContentInfo;
import com.j256.simplemagic.ContentInfoUtil;
import io.minio.*;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FilterInputStream;


@SpringBootTest
class MinioTestApplicationTests {

// 创建一个 minioClient
static MinioClient minioClient = MinioClient.builder()
.endpoint("http://192.168.205.129:9000")
.credentials("minioadmin", "minioadmin")
.build();

@Test
public void upload() throws Exception {
//根据扩展名取出mimeType
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(".mp4");
String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流
if (extensionMatch != null) {
mimeType = extensionMatch.getMimeType();
}

// 上传文件'/home/user/Photos/asiaphotos.zip'到bucket, 并改名为'test-1.zip'
UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
.bucket("testbucket1")
.filename("D:/Download/edge/wallhaven-2em38y_2560x1440.png")
.object("test-1.png")
.contentType(mimeType) //默认根据扩展名确定文件内容类型,也可以指定
.build();
minioClient.uploadObject(uploadObjectArgs);

System.out.println("success");
}

// 查询上传文件路径
@Test
public void query() throws Exception{
String path = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.bucket(bucketName)
.object("aa/test-1.png")
.method(Method.GET)
.expiry(5, TimeUnit.DAYS) // 有效期5天
.build()
);
System.out.println(path);
}

// 删除文件
@Test
public void delete() throws Exception {
RemoveObjectArgs removeObjectArgs = RemoveObjectArgs.builder()
.bucket("testbucket1")
.object("test-1.png")
.build();
minioClient.removeObject(removeObjectArgs);
}

// 根据url删除文件
@Test
public void deleteFile(String fileUrl) {
if (StringUtils.isEmpty(fileUrl)) {
return;
}
try {
URL url = new URL(fileUrl);
String pathStr = url.getPath(); // 获取路径名
// 将路径名转换成Path对象: 桶名/文件路径, 并去掉 “桶名/”
String filePath = Paths.get(pathStr).toString().substring(bucket_medisfiles.length() + 1);
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucket_medisfiles)
.object(filePath)
.build()
);
} catch (Exception e) {
e.printStackTrace();
}
}

// 查询文件
@Test
public void getFile() {
GetObjectArgs fileObject = GetObjectArgs.builder()
.bucket("testbucket1")
.object("test-1.png")
.build();
// 获取minio上的文件作为输入流,和本地该文件的数据流做MD5校验
try (FilterInputStream filterInputStream = minioClient.getObject(fileObject);
FileOutputStream outputStream = new FileOutputStream(new File("D:/Desktop/Workspace/_project/test/file/wallhaven-2em38y_2560x1440.png"));
) {
IOUtils.copy(filterInputStream, outputStream);
//校验文件的完整性对文件的内容进行md5 (流只能用一次,所以上面的网络流不能拿来做校验, 下面这个方法也不合理, 本地原本是没有该文件)
FileInputStream fileInputStream1 = new FileInputStream(new File("D:/Download/edge/wallhaven-2em38y_2560x1440.png"));
String source_md5 = DigestUtils.md5Hex(fileInputStream1);
FileInputStream fileInputStream = new FileInputStream(new File("D:/Desktop/Workspace/_project/test/file/wallhaven-2em38y_2560x1440.png"));
String local_md5 = DigestUtils.md5Hex(fileInputStream);
if (source_md5.equals(local_md5)) {
System.out.println("下载成功");
}else {
System.out.println("校验失败");
}
} catch (Exception e) {
e.printStackTrace();
}
}

}

断点续传

分块文件和合并文件:

对于大文件传输,分块可以在中断传输后,不必重传一整个大文件。

在本地进行文件的分块和合并练习:

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import org.apache.commons.codec.digest.DigestUtils;
import org.junit.jupiter.api.Test;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

public class BigFileTest {

//测试文件分块方法
@Test
public void testChunk() throws IOException {
File sourceFile = new File("d:/develop/bigfile_test/nacos.mp4");
String chunkPath = "d:/develop/bigfile_test/chunk/";
File chunkFolder = new File(chunkPath);
if (!chunkFolder.exists()) {
chunkFolder.mkdirs();
}
//分块大小
long chunkSize = 1024 * 1024 * 1;
//分块数量
long chunkNum = (long) Math.ceil(sourceFile.length() * 1.0 / chunkSize);
System.out.println("分块总数:" + chunkNum);
//缓冲区大小
byte[] b = new byte[1024];
//使用RandomAccessFile访问文件
RandomAccessFile raf_read = new RandomAccessFile(sourceFile, "r");
//分块
for (int i = 0; i < chunkNum; i++) {
//创建分块文件
File file = new File(chunkPath + i);
if (file.exists()) {
file.delete();
}
boolean newFile = file.createNewFile();
if (newFile) {
//向分块文件中写数据
RandomAccessFile raf_write = new RandomAccessFile(file, "rw");
int len = -1;
while ((len = raf_read.read(b)) != -1) {
raf_write.write(b, 0, len);
if (file.length() >= chunkSize) {
break;
}
}
raf_write.close();
System.out.println("完成分块" + i);
}

}
raf_read.close();
}

//测试文件合并方法
@Test
public void testMerge() throws IOException {
//块文件目录
File chunkFolder = new File("d:/develop/bigfile_test/chunk/");
//原始文件
File originalFile = new File("d:/develop/bigfile_test/nacos.mp4");
//合并文件
File mergeFile = new File("d:/develop/bigfile_test/nacos01.mp4");
if (mergeFile.exists()) {
mergeFile.delete();
}
//创建新的合并文件
mergeFile.createNewFile();
//用于写文件
RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw");
//指针指向文件顶端
raf_write.seek(0);
//缓冲区
byte[] b = new byte[1024];
//分块列表
File[] fileArray = chunkFolder.listFiles();
// 转成集合,便于排序
List<File> fileList = Arrays.asList(fileArray);
// 从小到大排序
Collections.sort(fileList, Comparator.comparingInt(o -> Integer.parseInt(o.getName())));
//合并文件
for (File chunkFile : fileList) {
RandomAccessFile raf_read = new RandomAccessFile(chunkFile, "rw");
int len = -1;
while ((len = raf_read.read(b)) != -1) {
raf_write.write(b, 0, len);

}
raf_read.close();
}
raf_write.close();

//校验文件
try (
FileInputStream fileInputStream = new FileInputStream(originalFile);
FileInputStream mergeFileStream = new FileInputStream(mergeFile);
) {
//取出原始文件的md5
String originalMd5 = DigestUtils.md5Hex(fileInputStream);
//取出合并文件的md5进行比较
String mergeFileMd5 = DigestUtils.md5Hex(mergeFileStream);
if (originalMd5.equals(mergeFileMd5)) {
System.out.println("合并文件成功");
} else {
System.out.println("合并文件失败");
}
}
}
}

实际应用

前端对文件分块的大小为5MB,SpringBoot web默认上传文件的大小限制为1MB,这里需要在media-api工程修改配置如下:

1
2
3
4
5
spring:    
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB

max-file-size:单个文件的大小限制

Max-request-size: 单次请求的大小限制

流程:

  1. 前端对文件进行分块。

  2. 前端上传分块文件前请求媒资服务检查文件是否存在,如果已经存在则不再上传。

    • 在数据库中有一张文件表记录minio中存储的文件信息,在文件上传开始时会写入状态为上传中,上传完成后更新状态为上传完成。

      如果是有分块文件上传一半中断了, 则表中记录为上传中,会有定时任务去清除minio中没有上传成功的文件目录。

  3. 如果分块文件不存在则前端开始上传。

  4. 后端服务将分块文件上传至MinIO。

  5. 前端将分块上传完毕后,会请求后端合并分块。

  6. 后端判断分块上传完成则请求MinIO合并文件。

  7. 合并完成,校验合并后的文件是否完整,如果不完整则删除文件。

具体代码:

BigFilesController类:

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
@Api(value = "大文件上传接口", tags = "大文件上传接口")
@RestController
public class BigFilesController {

@Autowired
MediaFileService mediaFileService;


@ApiOperation(value = "文件上传前检查文件")
@PostMapping("/upload/checkfile")
public RestResponse<Boolean> checkfile(@RequestParam("fileMd5") String fileMd5) throws Exception {
return mediaFileService.checkFile(fileMd5);
}


@ApiOperation(value = "分块文件上传前的检测")
@PostMapping("/upload/checkchunk")
public RestResponse<Boolean> checkchunk(@RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") int chunk) throws Exception {
// 查询minio中分块文件目录是否存在
return mediaFileService.checkChunk(fileMd5, chunk);
}

@ApiOperation(value = "上传分块文件")
@PostMapping("/upload/uploadchunk")
public RestResponse uploadchunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {

//创建一个临时文件
File tempFile = File.createTempFile("minio", ".temp");
file.transferTo(tempFile);
//文件路径
String localFilePath = tempFile.getAbsolutePath();

return mediaFileService.uploadChunk(fileMd5, chunk, localFilePath);
}

@ApiOperation(value = "合并文件")
@PostMapping("/upload/mergechunks")
public RestResponse mergechunks(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("chunkTotal") int chunkTotal) throws Exception {
Long companyId = 1232141425L;
//文件信息对象
UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
uploadFileParamsDto.setFilename(fileName);
uploadFileParamsDto.setTags("视频文件");
uploadFileParamsDto.setFileType("001002");

return mediaFileService.mergechunks(1232141425L, fileMd5, chunkTotal, uploadFileParamsDto);
}
}

MediaFileServiceImpl类:

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
@Slf4j
@Service
public class MediaFileServiceImpl implements MediaFileService {

@Autowired
MediaFilesMapper mediaFilesMapper;

@Autowired
MinioClient minioClient;

@Autowired
MediaFileService currentProxy;

//存储视频
@Value("${minio.bucket.videofiles}")
private String bucket_video;

//根据扩展名获取mimeType
private String getMimeType(String extension) {
if (extension == null) {
extension = "";
}
//根据扩展名取出mimeType
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流
if (extensionMatch != null) {
mimeType = extensionMatch.getMimeType();
}
return mimeType;

}

/**
* 将文件上传到minio
*
* @param localFilePath 文件本地路径
* @param mimeType 媒体类型
* @param bucket 桶
* @param objectName 对象名
* @return
*/
public boolean addMediaFilesToMinIO(String localFilePath, String mimeType, String bucket, String objectName) {
try {
UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
.bucket(bucket)//桶
.filename(localFilePath) //指定本地文件路径
.object(objectName)//对象名 放在子目录下
.contentType(mimeType)//设置媒体文件类型
.build();
//上传文件
minioClient.uploadObject(uploadObjectArgs);
log.debug("上传文件到minio成功,bucket:{},objectName:{},错误信息:{}", bucket, objectName);
return true;
} catch (Exception e) {
e.printStackTrace();
log.error("上传文件出错,bucket:{},objectName:{},错误信息:{}", bucket, objectName, e.getMessage());
}
return false;
}

/**
* @param companyId 机构id
* @param fileMd5 文件md5值
* @param uploadFileParamsDto 上传文件的信息
* @param bucket 桶
* @param objectName 对象名称
* @description 将文件信息添加到文件表
* @author Mr.M
* @date 2022/10/12 21:22
*/
@Transactional
public MediaFiles addMediaFilesToDb(Long companyId, String fileMd5, UploadFileParamsDto uploadFileParamsDto, String bucket, String objectName) {
//将文件信息保存到数据库
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
if (mediaFiles == null) {
mediaFiles = new MediaFiles();
BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
//文件id
mediaFiles.setId(fileMd5);
//机构id
mediaFiles.setCompanyId(companyId);
//桶
mediaFiles.setBucket(bucket);
//file_path
mediaFiles.setFilePath(objectName);
//file_id
mediaFiles.setFileId(fileMd5);
//url
mediaFiles.setUrl("/" + bucket + "/" + objectName);
//上传时间
mediaFiles.setCreateDate(LocalDateTime.now());
//状态
mediaFiles.setStatus("1");
//审核状态
mediaFiles.setAuditStatus("002003");
//插入数据库
int insert = mediaFilesMapper.insert(mediaFiles);
if (insert <= 0) {
log.debug("向数据库保存文件失败,bucket:{},objectName:{}", bucket, objectName);
return null;
}
return mediaFiles;

}
return mediaFiles;

}

// 分块文件目录是否存在
@Override
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex) {

//根据md5得到分块文件所在目录的路径
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);

//如果数据库存在再查询 minio
GetObjectArgs getObjectArgs = GetObjectArgs.builder()
.bucket(bucket_video)
.object(chunkFileFolderPath + chunkIndex)
.build();
//查询远程服务获取到一个流对象
try {
FilterInputStream inputStream = minioClient.getObject(getObjectArgs);
if (inputStream != null) {
//文件已存在
return RestResponse.success(true);
}
} catch (Exception e) {
e.printStackTrace();
}

//文件不存在
return RestResponse.success(false);
}

// 上传一个分块文件
@Override
public RestResponse uploadChunk(String fileMd5, int chunk, String localChunkFilePath) {
//分块文件的路径
String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
//获取mimeType
String mimeType = getMimeType(null);
//将分块文件上传到minio
boolean b = addMediaFilesToMinIO(localChunkFilePath, mimeType, bucket_video, chunkFilePath);
if (!b) {
return RestResponse.validfail(false, "上传分块文件失败");
}
//上传成功
return RestResponse.success(true);
}

// 合并分块文件
@Override
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
//分块文件所在目录
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//找到所有的分块文件
List<ComposeSource> sources =
Stream.iterate(0, i -> ++i).limit(chunkTotal)
.map(i -> ComposeSource.builder().bucket(bucket_video).object(chunkFileFolderPath + i).build())
.collect(Collectors.toList());
//源文件名称
String filename = uploadFileParamsDto.getFilename();
//扩展名
String extension = filename.substring(filename.lastIndexOf("."));
//合并后文件的objectname
String objectName = getFilePathByMd5(fileMd5, extension);
//指定合并后的objectName等信息
ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
.bucket(bucket_video)
.object(objectName)//合并后的文件的objectname
.sources(sources)//指定源文件
.build();
//===========合并文件============
//报错size 1048576 must be greater than 5242880,minio默认的分块文件大小为5M
try {
minioClient.composeObject(composeObjectArgs);
} catch (Exception e) {
e.printStackTrace();
log.error("合并文件出错,bucket:{},objectName:{},错误信息:{}", bucket_video, objectName, e.getMessage());
return RestResponse.validfail(false, "合并文件异常");
}

//===========校验合并后的和源文件是否一致,视频上传才成功===========
//先下载合并后的文件
File file = downloadFileFromMinIO(bucket_video, objectName);
try (FileInputStream fileInputStream = new FileInputStream(file)) {
//计算合并后文件的md5
String mergeFile_md5 = DigestUtils.md5Hex(fileInputStream);
//比较原始md5和合并后文件的md5
if (!fileMd5.equals(mergeFile_md5)) {
log.error("校验合并文件md5值不一致,原始文件:{},合并文件:{}", fileMd5, mergeFile_md5);
return RestResponse.validfail(false, "文件校验失败");
}
//文件大小
uploadFileParamsDto.setFileSize(file.length());
} catch (Exception e) {
return RestResponse.validfail(false, "文件校验失败");
}

//==============将文件信息入库============
MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_video, objectName);
if (mediaFiles == null) {
return RestResponse.validfail(false, "文件入库失败");
}
//==========清理分块文件=========
clearChunkFiles(chunkFileFolderPath, chunkTotal);

return RestResponse.success(true);
}

/**
* 清除分块文件
*
* @param chunkFileFolderPath 分块文件路径
* @param chunkTotal 分块文件总数
*/
private void clearChunkFiles(String chunkFileFolderPath, int chunkTotal) {
Iterable<DeleteObject> objects =
Stream.iterate(0, i -> ++i).limit(chunkTotal)
.map(i -> new DeleteObject(chunkFileFolderPath + i)).collect(Collectors.toList());
RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket(bucket_video).objects(objects).build();
Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);
//要想真正删除
results.forEach(f -> {
try {
DeleteError deleteError = f.get();
} catch (Exception e) {
e.printStackTrace();
}
});

}

/**
* 从minio下载文件
*
* @param bucket 桶
* @param objectName 对象名称
* @return 下载后的文件
*/
public File downloadFileFromMinIO(String bucket, String objectName) {
//临时文件
File minioFile = null;
FileOutputStream outputStream = null;
try {
InputStream stream = minioClient.getObject(GetObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.build());
//创建临时文件
minioFile = File.createTempFile("minio", ".merge");
outputStream = new FileOutputStream(minioFile);
IOUtils.copy(stream, outputStream);
return minioFile;
} catch (Exception e) {
e.printStackTrace();
} finally {
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}

/**
* 得到合并后的文件的地址
*
* @param fileMd5 文件id即md5值
* @param fileExt 文件扩展名
* @return
*/
private String getFilePathByMd5(String fileMd5, String fileExt) {
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + fileExt;
}

//得到分块文件的目录
private String getChunkFileFolderPath(String fileMd5) {
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
}

}

用户登录注册

Session实现

session的工作原理:

  1. 打开浏览器,在浏览器上发送首次请求
  2. 服务器会创建一个HttpSession对象,该对象代表一次会话
  3. 同时生成HttpSession对象对应的Cookie对象,并且Cookie对象的name是jsessionid,Cookie的value是32位长度的字符串(jsessionid=xxxx)
  4. 服务器将Cookie的value和HttpSession对象绑定到session列表中
  5. 服务器将Cookie完整发送给浏览器客户端
  6. 浏览器客户端将Cookie保存到缓存中
  7. 当再次发送请求的时候,会自动提交缓存中当的Cookie
  8. 服务器接收到Cookie,验证该Cookie的name是否是jsessionid,然后获取该Cookie的value
  9. 通过Cookie的value去session列表中检索对应的HttpSession对象

基于Session的登录:

  • 发送验证码
    1. 校验手机号是否合法,不合法重新输入,合法就生成验证码;
    2. 将验证码保存到session;
    3. 发送验证码;
  • 登录、注册
    1. 用户提交手机号和验证码,后台从session中拿到当前验证码,进行校验,不一致则返回;
    2. 如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号,保存到数据库。
    3. 保存用户信息到session中,方便后续获得登录信息。
  • 验证登录状态(再次访问,无需登录)
    1. 用户发起请求并携带cookie, 后台从session中获取用户信息;
    2. 判断用户是否存在,如果没有session信息,则进行拦截,
    3. 如果有session信息,则将用户信息保存到threadLocal中,并且放行。

image-20230502095650746

代码:

  • 发送验证码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Override
    public Result sendCode(String phone, HttpSession session) {
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
    // 2.如果不符合,返回错误信息
    return Result.fail("手机号格式错误!");
    }
    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);

    // 4.保存验证码到 session
    session.setAttribute("code",code);
    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{}", code);
    // 返回ok
    return Result.ok();
    }
  • 登录注册

    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
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
    // 2.如果不符合,返回错误信息
    return Result.fail("手机号格式错误!");
    }
    // 3.校验验证码
    Object cacheCode = session.getAttribute("code");
    String code = loginForm.getCode();
    if(cacheCode == null || !cacheCode.toString().equals(code)){
    //3.不一致,报错
    return Result.fail("验证码错误");
    }
    //一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();

    //5.判断用户是否存在
    if(user == null){
    //不存在,则创建
    user = createUserWithPhone(phone);
    }
    //7.保存用户信息到session中(应该隐藏用户保密信息,将User转化为UserDTO)
    session.setAttribute("user", BeanUtils.copyProperties(user,UserDTO.class));

    return Result.ok();
    }
  • 验证登录状态(再次访问,无需登录)

    拦截器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //1.获取session
    HttpSession session = request.getSession();
    //2.获取session中的用户
    Object user = session.getAttribute("user");
    //3.判断用户是否存在
    if(user == null){
    //4.不存在,拦截,返回401状态码
    response.setStatus(401);
    return false;
    }
    //5.存在,保存用户信息到Threadlocal
    UserHolder.saveUser((UserDTO)user);
    //6.放行
    return true;
    }
    }

    ThreadLocal工具类,保存线程私有变量:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class UserHolder {
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

    public static void saveUser(UserDTO user){
    tl.set(user);
    }

    public static UserDTO getUser(){
    return tl.get();
    }

    public static void removeUser(){
    tl.remove();
    }
    }

    配置拦截器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Configuration
    public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    // 登录拦截器
    registry.addInterceptor(new LoginInterceptor())
    .excludePathPatterns(
    "/shop/**",
    "/voucher/**",
    "/shop-type/**",
    "/upload/**",
    "/blog/hot",
    "/user/code",
    "/user/login"
    ).order(1);
    // token刷新的拦截器
    registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
    }

session共享问题:

一般服务器会以集群的形式存在,即每次访问不一定是访问同一台服务器,当seesion信息只保存在上次登录的服务器中,所以访问到其他服务器时就会被拦截。

早期的解决方案是session拷贝,就是说虽然每个tomcat服务器上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了。

  • 缺点: 每台服务器都有完整的一份session数据,服务器压力过大。session拷贝数据时,可能会出现延迟

现在的解决方案:redis数据本身就是共享的,使用redis来完成信息保存。

Redis实现

session实现方案中,session保存了验证码和用户信息, 同样的,Redis也保存这些信息,key-value形式如下:

key value
phone:手机号 验证码
token:xxxxx 用户信息
  • 登录注册

    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
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
    // 2.如果不符合,返回错误信息
    return Result.fail("手机号格式错误!");
    }
    // 3.从redis获取验证码并校验
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
    // 不一致,报错
    return Result.fail("验证码错误");
    }

    // 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
    User user = query().eq("phone", phone).one();

    // 5.判断用户是否存在
    if (user == null) {
    // 6.不存在,创建新用户并保存
    user = createUserWithPhone(phone);
    }

    // 7.保存用户信息到 redis中
    // 7.1.随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);
    // 7.2.将User对象转为HashMap存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
    CopyOptions.create()
    .setIgnoreNullValue(true)
    .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 7.3.存储
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 7.4.设置token有效期
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

    // 8.返回token
    return Result.ok(token);
    }
  • 验证登录状态(再次访问,无需登录)

    因为需要刷新token有效时间,所以拦截器里刷新token应该面向所有页面,对此,添加一个拦截器用于刷新token。

    刷新token的拦截器:RefreshTokenInterceptor

    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
    public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
    this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    // 1.获取请求头中的token
    String token = request.getHeader("authorization");
    if (StrUtil.isBlank(token)) {
    return true;
    }
    // 2.基于TOKEN获取redis中的用户
    String key = LOGIN_USER_KEY + token;
    Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
    // 3.判断用户是否存在
    if (userMap.isEmpty()) {
    return true;
    }
    // 5.将查询到的hash数据转为UserDTO
    UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
    // 6.存在,保存用户信息到 ThreadLocal
    UserHolder.saveUser(userDTO);
    // 7.刷新token有效期
    stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 8.放行
    return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    // 移除用户
    UserHolder.removeUser();
    }
    }

    判断是否拦截: LoginInterceptor

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    // 1.判断是否需要拦截(ThreadLocal中是否有用户)
    if (UserHolder.getUser() == null) {
    // 没有,需要拦截,设置状态码
    response.setStatus(401);
    // 拦截
    return false;
    }
    // 有用户,则放行
    return true;
    }
    }