/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
body {
font-family: "Segoe WPC", "Segoe UI", "SFUIText-Light", "HelveticaNeue-Light", sans-serif, "Droid Sans Fallback";
font-size: 14px;
padding: 0 12px;
line-height: 22px;
word-wrap: break-word;
}
#code-csp-warning {
position: fixed;
top: 0;
right: 0;
color: white;
margin: 16px;
text-align: center;
font-size: 12px;
font-family: sans-serif;
background-color:#444444;
cursor: pointer;
padding: 6px;
box-shadow: 1px 1px 1px rgba(0,0,0,.25);
}
#code-csp-warning:hover {
text-decoration: none;
background-color:#007acc;
box-shadow: 2px 2px 2px rgba(0,0,0,.25);
}
body.scrollBeyondLastLine {
margin-bottom: calc(100vh - 22px);
}
body.showEditorSelection .code-line {
position: relative;
}
body.showEditorSelection .code-active-line:before,
body.showEditorSelection .code-line:hover:before {
content: "";
display: block;
position: absolute;
top: 0;
left: -12px;
height: 100%;
}
body.showEditorSelection li.code-active-line:before,
body.showEditorSelection li.code-line:hover:before {
left: -30px;
}
.vscode-light.showEditorSelection .code-active-line:before {
border-left: 3px solid rgba(0, 0, 0, 0.15);
}
.vscode-light.showEditorSelection .code-line:hover:before {
border-left: 3px solid rgba(0, 0, 0, 0.40);
}
.vscode-dark.showEditorSelection .code-active-line:before {
border-left: 3px solid rgba(255, 255, 255, 0.4);
}
.vscode-dark.showEditorSelection .code-line:hover:before {
border-left: 3px solid rgba(255, 255, 255, 0.60);
}
.vscode-high-contrast.showEditorSelection .code-active-line:before {
border-left: 3px solid rgba(255, 160, 0, 0.7);
}
.vscode-high-contrast.showEditorSelection .code-line:hover:before {
border-left: 3px solid rgba(255, 160, 0, 1);
}
img {
max-width: 100%;
max-height: 100%;
}
a {
color: #4080D0;
text-decoration: none;
}
a:focus,
input:focus,
select:focus,
textarea:focus {
outline: 1px solid -webkit-focus-ring-color;
outline-offset: -1px;
}
hr {
border: 0;
height: 2px;
border-bottom: 2px solid;
}
h1 {
padding-bottom: 0.3em;
line-height: 1.2;
border-bottom-width: 1px;
border-bottom-style: solid;
}
h1, h2, h3 {
font-weight: normal;
}
h1 code,
h2 code,
h3 code,
h4 code,
h5 code,
h6 code {
font-size: inherit;
line-height: auto;
}
a:hover {
color: #4080D0;
text-decoration: underline;
}
table {
border-collapse: collapse;
}
table > thead > tr > th {
text-align: left;
border-bottom: 1px solid;
}
table > thead > tr > th,
table > thead > tr > td,
table > tbody > tr > th,
table > tbody > tr > td {
padding: 5px 10px;
}
table > tbody > tr + tr > td {
border-top: 1px solid;
}
blockquote {
margin: 0 7px 0 5px;
padding: 0 16px 0 10px;
border-left: 5px solid;
}
code {
font-family: Menlo, Monaco, Consolas, "Droid Sans Mono", "Courier New", monospace, "Droid Sans Fallback";
font-size: 14px;
line-height: 19px;
}
body.wordWrap pre {
white-space: pre-wrap;
}
.mac code {
font-size: 12px;
line-height: 18px;
}
pre:not(.hljs),
pre.hljs code > div {
padding: 16px;
border-radius: 3px;
overflow: auto;
}
/** Theming */
.vscode-light,
.vscode-light pre code {
color: rgb(30, 30, 30);
}
.vscode-dark,
.vscode-dark pre code {
color: #DDD;
}
.vscode-high-contrast,
.vscode-high-contrast pre code {
color: white;
}
.vscode-light code {
color: #A31515;
}
.vscode-dark code {
color: #D7BA7D;
}
.vscode-light pre:not(.hljs),
.vscode-light code > div {
background-color: rgba(220, 220, 220, 0.4);
}
.vscode-dark pre:not(.hljs),
.vscode-dark code > div {
background-color: rgba(10, 10, 10, 0.4);
}
.vscode-high-contrast pre:not(.hljs),
.vscode-high-contrast code > div {
background-color: rgb(0, 0, 0);
}
.vscode-high-contrast h1 {
border-color: rgb(0, 0, 0);
}
.vscode-light table > thead > tr > th {
border-color: rgba(0, 0, 0, 0.69);
}
.vscode-dark table > thead > tr > th {
border-color: rgba(255, 255, 255, 0.69);
}
.vscode-light h1,
.vscode-light hr,
.vscode-light table > tbody > tr + tr > td {
border-color: rgba(0, 0, 0, 0.18);
}
.vscode-dark h1,
.vscode-dark hr,
.vscode-dark table > tbody > tr + tr > td {
border-color: rgba(255, 255, 255, 0.18);
}
.vscode-light blockquote,
.vscode-dark blockquote {
background: rgba(127, 127, 127, 0.1);
border-color: rgba(0, 122, 204, 0.5);
}
.vscode-high-contrast blockquote {
background: transparent;
border-color: #fff;
}
/* Tomorrow Theme */
/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */
/* Original theme - https://github.com/chriskempson/tomorrow-theme */
/* Tomorrow Comment */
.hljs-comment,
.hljs-quote {
color: #8e908c;
}
/* Tomorrow Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #c82829;
}
/* Tomorrow Orange */
.hljs-number,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #f5871f;
}
/* Tomorrow Yellow */
.hljs-attribute {
color: #eab700;
}
/* Tomorrow Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #718c00;
}
/* Tomorrow Blue */
.hljs-title,
.hljs-section {
color: #4271ae;
}
/* Tomorrow Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #8959a8;
}
.hljs {
display: block;
overflow-x: auto;
color: #4d4d4c;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
/*
* Markdown PDF CSS
*/
body {
font-family: "Meiryo", "Segoe WPC", "Segoe UI", "SFUIText-Light", "HelveticaNeue-Light", sans-serif, "Droid Sans Fallback";
}
pre {
background-color: #f8f8f8;
border: 1px solid #cccccc;
border-radius: 3px;
overflow-x: auto;
white-space: pre-wrap;
overflow-wrap: break-word;
}
pre:not(.hljs) {
padding: 23px;
line-height: 19px;
}
blockquote {
background: rgba(127, 127, 127, 0.1);
border-color: rgba(0, 122, 204, 0.5);
}
.emoji {
height: 1.4em;
}
/* for inline code */
:not(pre):not(.hljs) > code {
color: #C9AE75; /* Change the old color so it seems less like an error */
font-size: inherit;
}
/* Page Break : use
-------------------------------------------------------- */
.page {
page-break-after: always;
}
- 0.仿微信 IM 系统简介
- 1.Netty 是什么?
- 2.Netty 环境配置
- 3.服务端启动流程
- 4.客户端启动流程
- 5.实战:客户端与服务端双向通信
- 6.数据传输载体 ByteBuf 介绍
- 7.客户端与服务端通信协议编解码
- 8.实战:实现客户端登录
- 9.实战:实现客户端与服务端收发消息
- 10.pipeline 与 channelHandler
- 11实战:构建客户端与服务端 pipeline
- 12.实战:拆包粘包理论与解决方案
- 13.channelHandler 的生命周期
- 14实战:使用 channelHandler 的热插拔实现客户端身份校验
- 15.实战:客户端互聊原理与实现
- 16.实战:群聊的发起与通知
- 17.实战:群聊的成员管理(加入与退出,获取成员列表)
- 18实战:群聊消息的收发及 Netty 性能优化
- 19.实战:心跳与空闲检测
- 20.小册总结
- 21.小册读者总结
- 22.扩展:进阶学习 Netty 的方向与资料
群聊的发起与通知
这小节,我们来学习一下如何创建一个群聊,并通知到群聊中的各位成员
我们依然是先来看一下最终的效果是什么样的。
1. 最终效果
服务端
创建群聊的客户端
其他客户端
- 首先,依然是三位用户依次登录到服务器,分别是闪电侠、极速、萨维塔。
- 然后,我们在闪电侠的控制台输入
createGroup
指令,提示创建群聊需要输入 userId 列表,然后我们输入以英文逗号分隔的 userId。 - 群聊创建成功之后,分别在服务端和三个客户端弹出提示消息,包括群的 ID 以及群里各位用户的昵称。
2. 群聊原理
群聊的原理我们在 仿微信 IM 系统简介 已经学习过,我们再来重温一下
群聊指的是一个组内多个用户之间的聊天,一个用户发到群组的消息会被组内任何一个成员接收,下面我们来看一下群聊的基本流程。
如上图,要实现群聊,其实和单聊类似
- A,B,C 依然会经历登录流程,服务端保存用户标识对应的 TCP 连接
- A 发起群聊的时候,将 A,B,C 的标识发送至服务端,服务端拿到之后建立一个群聊 ID,然后把这个 ID 与 A,B,C 的标识绑定
- 群聊里面任意一方在群里聊天的时候,将群聊 ID 发送至服务端,服务端拿到群聊 ID 之后,取出对应的用户标识,遍历用户标识对应的 TCP 连接,就可以将消息发送至每一个群聊成员
这一小节,我们把重点放在创建一个群聊上,由于控制台输入的指令越来越多,因此在正式开始之前,我们先对我们的控制台程序稍作重构。
2. 控制台程序重构
2.1 创建控制台命令执行器
首先,我们把在控制台要执行的操作抽象出来,抽象出一个接口
ConsoleCommand.java
public interface ConsoleCommand {
void exec(Scanner scanner, Channel channel);
}
2.2 管理控制台命令执行器
接着,我们创建一个管理类来对这些操作进行管理。
ConsoleCommandManager.java
public class ConsoleCommandManager implements ConsoleCommand {
private Map<String, ConsoleCommand> consoleCommandMap;
public ConsoleCommandManager() {
consoleCommandMap = new HashMap<>();
consoleCommandMap.put("sendToUser", new SendToUserConsoleCommand());
consoleCommandMap.put("logout", new LogoutConsoleCommand());
consoleCommandMap.put("createGroup", new CreateGroupConsoleCommand());
}
public void exec(Scanner scanner, Channel channel) {
// 获取第一个指令
String command = scanner.next();
ConsoleCommand consoleCommand = consoleCommandMap.get(command);
if (consoleCommand != null) {
consoleCommand.exec(scanner, channel);
} else {
System.err.println("无法识别[" + command + "]指令,请重新输入!");
}
}
}
- 我们在这个管理类中,把所有要管理的控制台指令都塞到一个 map 中。
- 执行具体操作的时候,我们先获取控制台第一个输入的指令,这里以字符串代替,比较清晰(这里我们已经实现了上小节课后思考题中的登出操作),然后通过这个指令拿到对应的控制台命令执行器执行。
这里我们就拿创建群聊举个栗子:首先,我们在控制台输入 createGroup
,然后我们按下回车,就会进入 CreateGroupConsoleCommand
这个类进行处理
CreateGroupConsoleCommand.java
public class CreateGroupConsoleCommand implements ConsoleCommand {
private static final String USER_ID_SPLITER = ",";
public void exec(Scanner scanner, Channel channel) {
CreateGroupRequestPacket createGroupRequestPacket = new CreateGroupRequestPacket();
System.out.print("【拉人群聊】输入 userId 列表,userId 之间英文逗号隔开:");
String userIds = scanner.next();
createGroupRequestPacket.setUserIdList(Arrays.asList(userIds.split(USER_ID_SPLITER)));
channel.writeAndFlush(createGroupRequestPacket);
}
}
进入到 CreateGroupConsoleCommand
的逻辑之后,我们创建了一个群聊创建请求的数据包,然后提示输入以英文逗号分隔的 userId 的列表,填充完这个数据包之后,调用 writeAndFlush()
我们就可以发送一个创建群聊的指令到服务端。
最后,我们再来看一下经过我们的改造,客户端的控制台线程相关的代码。
NettyClient.java
private static void startConsoleThread(Channel channel) {
ConsoleCommandManager consoleCommandManager = new ConsoleCommandManager();
LoginConsoleCommand loginConsoleCommand = new LoginConsoleCommand();
Scanner scanner = new Scanner(System.in);
new Thread(() -> {
while (!Thread.interrupted()) {
if (!SessionUtil.hasLogin(channel)) {
loginConsoleCommand.exec(scanner, channel);
} else {
consoleCommandManager.exec(scanner, channel);
}
}
}).start();
}
抽取出控制台指令执行器之后,客户端控制台逻辑已经相对之前清晰很多了,可以非常方便地在控制台模拟各种在 IM 聊天窗口的操作,接下来,我们就来看一下如何创建群聊。
3. 创建群聊的实现
3.1 客户端发送创建群聊请求
通过我们前面讲述控制台逻辑的重构,我们已经了解到我们是发送一个 CreateGroupRequestPacket
数据包到服务端,这个数据包的格式为:
CreateGroupRequestPacket.java
public class CreateGroupRequestPacket extends Packet {
private List<String> userIdList;
}
它只包含了一个列表,这个列表就是需要拉取群聊的用户列表,接下来我们看下服务端如何处理的。
3.2 服务端处理创建群聊请求
我们依然是创建一个 handler 来处理新的指令。
NettyServer.java
.childHandler(new ChannelInitializer<NioSocketChannel>() {
protected void initChannel(NioSocketChannel ch) {
// ...
// 添加一个 handler
ch.pipeline().addLast(new CreateGroupRequestHandler());
// ...
}
});
接下来,我们来看一下这个 handler 具体做哪些事情
CreateGroupRequestHandler.java
public class CreateGroupRequestHandler extends SimpleChannelInboundHandler<CreateGroupRequestPacket> {
protected void channelRead0(ChannelHandlerContext ctx, CreateGroupRequestPacket createGroupRequestPacket) {
List<String> userIdList = createGroupRequestPacket.getUserIdList();
List<String> userNameList = new ArrayList<>();
// 1. 创建一个 channel 分组
ChannelGroup channelGroup = new DefaultChannelGroup(ctx.executor());
// 2. 筛选出待加入群聊的用户的 channel 和 userName
for (String userId : userIdList) {
Channel channel = SessionUtil.getChannel(userId);
if (channel != null) {
channelGroup.add(channel);
userNameList.add(SessionUtil.getSession(channel).getUserName());
}
}
// 3. 创建群聊创建结果的响应
CreateGroupResponsePacket createGroupResponsePacket = new CreateGroupResponsePacket();
createGroupResponsePacket.setSuccess(true);
createGroupResponsePacket.setGroupId(IDUtil.randomId());
createGroupResponsePacket.setUserNameList(userNameList);
// 4. 给每个客户端发送拉群通知
channelGroup.writeAndFlush(createGroupResponsePacket);
System.out.print("群创建成功,id 为[" + createGroupResponsePacket.getGroupId() + "], ");
System.out.println("群里面有:" + createGroupResponsePacket.getUserNameList());
}
}
整个过程可以分为以下几个过程
- 首先,我们这里创建一个
ChannelGroup
。这里简单介绍一下ChannelGroup
:它可以把多个 chanel 的操作聚合在一起,可以往它里面添加删除 channel,可以进行 channel 的批量读写,关闭等操作,详细的功能读者可以自行翻看这个接口的方法。这里我们一个群组其实就是一个 channel 的分组集合,使用ChannelGroup
非常方便。 - 接下来,我们遍历待加入群聊的 userId,如果存在该用户,就把对应的 channel 添加到
ChannelGroup
中,用户昵称也添加到昵称列表中。 - 然后,我们创建一个创建群聊响应的对象,其中
groupId
是随机生成的,群聊创建结果一共三个字段,这里就不展开对这个类进行说明了。 - 最后,我们调用
ChannelGroup
的聚合发送功能,将拉群的通知批量地发送到客户端,接着在服务端控制台打印创建群聊成功的信息,至此,服务端处理创建群聊请求的逻辑结束。
我们接下来再来看一下客户端处理创建群聊响应。
3.3 客户端处理创建群聊响应
客户端依然也是创建一个 handler 来处理新的指令。
NettyClient.java
.handler(new ChannelInitializer<SocketChannel>() {
public void initChannel(SocketChannel ch) {
// ...
// 添加一个新的 handler 来处理创建群聊成功响应的指令
ch.pipeline().addLast(new CreateGroupResponseHandler());
// ...
}
});
然后,在我们的应用程序里面,我们仅仅是把创建群聊成功之后的具体信息打印出来。
CreateGroupResponseHandler.java
public class CreateGroupResponseHandler extends SimpleChannelInboundHandler<CreateGroupResponsePacket> {
protected void channelRead0(ChannelHandlerContext ctx, CreateGroupResponsePacket createGroupResponsePacket) {
System.out.print("群创建成功,id 为[" + createGroupResponsePacket.getGroupId() + "], ");
System.out.println("群里面有:" + createGroupResponsePacket.getUserNameList());
}
}
在实际生产环境中,CreateGroupResponsePacket
对象里面可能有更多的信息,然后以上逻辑的处理也会更加复杂,不过我们这里已经能说明问题了。
到了这里,这小节的内容到这里就告一段落了,下小节,我们来学习群聊成员管理,包括添加删除成员,获取成员列表等等,最后,我们再对本小节内容做一下总结。
4. 总结
- 群聊的原理和单聊类似,无非都是通过标识拿到 channel。
- 本小节,我们重构了一下控制台的程序结构,在实际带有 UI 的 IM 应用中,我们输入的第一个指令其实就是对应我们点击 UI 的某些按钮或菜单的操作。
- 通过
ChannelGroup
,我们可以很方便地对一组 channel 进行批量操作。
5. 思考
如何实现在某个客户端拉取群聊成员的时候,不需要输入自己的用户 ID,并且展示创建群聊消息的时候,不显示自己的昵称?欢迎留言讨论。
一键复制
编辑
原始数据
按行查看
历史