一、前言

MobileIMSDK 是什么?

MobileIMSDK 是一套专门为挪动端开发的开源IM即时通讯框架,超轻量级、高度提炼,一套API优雅反对UDP 、TCP 、WebSocket 三种协定,反对iOS、Android、H5、规范Java平台,服务端基于Netty编写。

工程地址是:

1)Gitee码云地址:https://www.oschina.net/p/mob...
2)Github托管地址:https://github.com/JackJiang2...

本文将实现:

1)基于springboot 集成 MobileIMSDK;
2)开发IM服务端;
3)开发客户端;
4)实现Java客户端与客户端之间的通信。

  • 补充阐明:本文所示Demo源码,请从文末“本文小结”的最初链接中下载!

二、SpringBoot 集成 MobileIMSDK 筹备

2.1 MobileIMSDK下载
MobileIMSDK下载地址:

1)国外地址:MobileIMSDK的Github地址(最新版打包下载)
2)国内地址:MobileIMSDK的码云gitee地址(访问速度快!,最新版打包下载)

须要用到的lib包:

1)服务端所需jar包: sdk_binary/Server/
2)客服端所需jar包: sdk_binary/Client_TCP/java/

如下图所示:

2.2 pom.xml中引入相干依赖
因为这里是maven我的项目,其中一部分jar包可通过maven仓库间接引入,而其余的则通过内部jar包引入形式应用即可~

如下4个需作为内部jar包在pom.xml中引入 :
<!-- [url=https://mvnrepository.com/art...]https://mvnrepository.com/artifact/com.google.code.gson/gson[/url] -->
<dependency>

<groupId>com.google.code.gson</groupId><artifactId>gson</artifactId><version>2.8.5</version>

</dependency>

<!-- MobileIMSDK所需jar包依赖[注:这里是在本地lib中引入,maven地方仓库中暂无此jar包],要与<includeSystemScope>true</includeSystemScope>配合应用-->

<dependency>

<groupId>com.zhengqing</groupId><artifactId>MobileIMSDK4j</artifactId><scope>system</scope><systemPath>${project.basedir}/src/main/resources/lib/MobileIMSDK4j.jar</systemPath>

</dependency>

<dependency>

<groupId>com.zhengqing</groupId><artifactId>MobileIMSDKServerX_meta</artifactId><scope>system</scope><systemPath>${project.basedir}/src/main/resources/lib/MobileIMSDKServerX_meta.jar</systemPath>

</dependency>

<dependency>

<groupId>com.zhengqing</groupId><artifactId>swing-worker-1.2(1.6-)</artifactId><scope>system</scope><systemPath>${project.basedir}/src/main/resources/lib/swing-worker-1.2(1.6-).jar</systemPath>

</dependency>

<dependency>

<groupId>com.zhengqing</groupId><artifactId>MobileIMSDKServerX_netty</artifactId><scope>system</scope><systemPath>${project.basedir}/src/main/resources/lib/MobileIMSDKServerX_netty.jar</systemPath>

</dependency>

<plugins>

<!-- maven打包插件 -> 将整个工程打成一个 fatjar --><plugin>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-maven-plugin</artifactId>    <!-- 作用:我的项目打成jar,同时把本地jar包也引入进去 -->    <configuration>        <includeSystemScope>true</includeSystemScope>    </configuration></plugin>

</plugins>

三、开发服务端

3.1 与客服端的所有数据交互事件(实现ServerEventListener类)

public class ServerEventListenerImpl implements ServerEventListener {    private static Logger logger = LoggerFactory.getLogger(ServerEventListenerImpl.class);     /**     * 用户身份验证回调办法定义.     * <p>     * 服务端的应用层可在本办法中实现用户登陆验证。     * <br>     * 留神:本回调在一种非凡状况下——即用户理论未退出登陆但再次发起来登陆包时,本回调是不会被调用的!     * <p>     * 依据MobileIMSDK的算法实现,本办法中用户验证通过(即办法返回值=0时)后     * ,将立刻调用回调办法 {@link #onUserLoginAction_CallBack(int, String, IoSession)}。     * 否则会将验证后果(本办法返回值错误码通过客户端的 ChatBaseEvent.onLoginMessage(int dwUserId, int dwErrorCode)     * 办法进行回调)告诉客户端)。     *     * @param userId  传递过去的准一id,保障惟一就能够通信,可能是登陆用户名、也可能是任意不反复的id等,具体意义由业务层决定     * @param token   用于身份甄别和合法性检查的token,它可能是登陆密码,也可能是通过前置单点登陆接口拿到的token等,具体意义由业务层决定     * @param extra   额定信息字符串。本字段目前为保留字段,供下层利用自行搁置须要的内容     * @param session 此客户端连贯对应的 netty “会话”     * @return 0 示意登陆验证通过,否则能够返回用户自已定义的错误码,错误码值应为:>=1025的整数     */    @Override    public int onVerifyUserCallBack(String userId, String token, String extra, Channel session) {        logger.debug("【DEBUG_回调告诉】正在调用回调办法:OnVerifyUserCallBack...(extra="+ extra + ")");        return 0;    }     /**     * 用户登录验证胜利后的回调办法定义(可了解为上线告诉回调).     * <p>     * 服务端的应用层通常可在本办法中实现用户上线告诉等。     * <br>     * 留神:本回调在一种非凡状况下——即用户理论未退出登陆但再次发起来登陆包时,回调也是肯定会被调用。     *     * @param userId  传递过去的准一id,保障惟一就能够通信,可能是登陆用户名、也可能是任意不反复的id等,具体意义由业务层决定     * @param extra   额定信息字符串。本字段目前为保留字段,供下层利用自行搁置须要的内容。为了丰盛应用层解决的伎俩,在本回调中也把此字段传进来了     * @param session 此客户端连贯对应的 netty “会话”     */    @Override    public void onUserLoginAction_CallBack(String userId, String extra, Channel session) {        logger.debug("【IM_回调告诉OnUserLoginAction_CallBack】用户:"+ userId + " 上线了!");    }     /**     * 用户退出登录回调办法定义(可了解为下线告诉回调)。     * <p>     * 服务端的应用层通常可在本办法中实现用户下线告诉等。     *     * @param userId  下线的用户user_id     * @param obj     * @param session 此客户端连贯对应的 netty “会话”     */    @Override    public void onUserLogoutAction_CallBack(String userId, Object obj, Channel session) {        logger.debug("【DEBUG_回调告诉OnUserLogoutAction_CallBack】用户:"+ userId + " 离线了!");    }     /**     * 通用数据回调办法定义(客户端发给服务端的(即接管user_id="0")).     * <p>     * MobileIMSDK在收到客户端向user_id=0(即接管指标是服务器)的状况下通过     * 本办法的回调告诉下层。下层通常可在本办法中实现如:增加好友申请等业务实现。     *     * <p style="background:#fbf5ee;border-radius:4px;">     * <b><font color="#ff0000">【版本兼容性阐明】</font></b>本办法用于代替v3.x中的以下办法:<br>     * <code>public boolean onTransBuffer_CallBack(String userId, String from_user_id     * , String dataContent, String fingerPrint, int typeu, Channel session);     * </code>     *     * @param userId       接管方的user_id(本办法接管的是发给服务端的音讯,所以此参数的值必定==0)     * @param from_user_id 发送方的user_id     * @param dataContent  数据内容(文本模式)     * @param session      此客户端连贯对应的 netty “会话”     * @return true示意本办法已胜利解决实现,否则示意未解决胜利。此返回值目前框架中并没有非凡意义,仅作保留吧     * @since 4.0     */    @Override    public boolean onTransBuffer_C2S_CallBack(Protocal p, Channel session) {        // 接收者uid        String userId = p.getTo();        // 发送者uid        String from_user_id = p.getFrom();        // 音讯或指令内容        String dataContent = p.getDataContent();        // 音讯或指令指纹码(即惟一ID)        String fingerPrint = p.getFp();        // 【重要】用户定义的音讯或指令协定类型(开发者可据此类型来辨别具体的音讯或指令)        inttypeu = p.getTypeu();         logger.debug("【DEBUG_回调告诉】[typeu="+ typeu + "]收到了客户端"+ from_user_id + "发给服务端的音讯:str="+ dataContent);        returntrue;    }     /**     * 通道数据回调函数定义(客户端发给客户端的(即接管方user_id不为“0”的状况)).     * <p>     * <b>留神:</b>本办法当且仅当在数据被服务端胜利在线发送进来后被回调调用.     * <p>     * 下层通常可在本办法中实现用户聊天信息的收集,以便前期监控剖析用户的行为等^_^。     * <p>     * 提醒:如果开启音讯QoS保障,因重传机制,本回调中的音讯实践上有反复的可能,请以参数 #fingerPrint     * 作为音讯的惟一标识ID进行去重解决。     *     * <p style="background:#fbf5ee;border-radius:4px;">     * <b><font color="#ff0000">【版本兼容性阐明】</font></b>本办法用于代替v3.x中的以下办法:<br>     * <code>public void onTransBuffer_C2C_CallBack(String userId, String from_user_id     * , String dataContent, String fingerPrint, int typeu);     *     * @param userId       接管方的user_id(本办法接管的是客户端发给客户端的,所以此参数的值必定>0)     * @param from_user_id 发送方的user_id     * @param dataContent     * @since 4.0     */    @Override    public void onTransBuffer_C2C_CallBack(Protocal p) {        // 接收者uid        String userId = p.getTo();        // 发送者uid        String from_user_id = p.getFrom();        // 音讯或指令内容        String dataContent = p.getDataContent();        // 音讯或指令指纹码(即惟一ID)        String fingerPrint = p.getFp();        // 【重要】用户定义的音讯或指令协定类型(开发者可据此类型来辨别具体的音讯或指令)        inttypeu = p.getTypeu();         logger.debug("【DEBUG_回调告诉】[typeu="+ typeu + "]收到了客户端"+ from_user_id + "发给客户端"+ userId + "的音讯:str="+ dataContent);    }     /**     * 通用数据实时发送失败后的回调函数定义(客户端发给客户端的(即接管方user_id不为“0”的状况)).     * <p>     * 留神:本办法当且仅当在数据被服务端<u>在线发送</u>失败后被回调调用.     * <p>     * <b>此办法存的意义何在?</b><br>     * 产生此种状况的场景可能是:对方的确不在线(那么此办法里就能够作为离线音讯解决了)、     * 或者在发送时判断对方是在线的但服务端在发送时却没有胜利(这种状况就可能是通信谬误     * 或对方非正常通出但尚未达到会话超时时限)。<br><u>应用层在此办法里实现离线音讯的解决即可!</u>     *     * <p style="background:#fbf5ee;border-radius:4px;">     * <b><font color="#ff0000">【版本兼容性阐明】</font></b>本办法用于代替v3.x中的以下办法:<br>     * <code>public boolean onTransBuffer_C2C_RealTimeSendFaild_CallBack(String userId     * , String from_user_id, String dataContent, String fingerPrint, int typeu);     * </code>     *     * @param userId       接管方的user_id(本办法接管的是客户端发给客户端的,所以此参数的值必定>0),此id在本办法中不肯定保障有意义     * @param from_user_id 发送方的user_id     * @param dataContent  音讯内容     * @param fingerPrint  该音讯对应的指纹(如果该音讯有QoS保障机制的话),用于在QoS重要机制下服务端离线存储时避免反复存储哦     * @return true示意应用层曾经解决了离线音讯(如果该音讯有QoS机制,则服务端将代为发送一条伪应答包     * (伪应答仅意味着不是接管方的实时应答,而只是存储到离线DB中,但在发送方看来也算是被对方收到,只是延     * 迟收到而已(离线音讯嘛))),否则示意应用层没有解决(如果此音讯有QoS机制,则发送方在QoS重传机制超时     * 后报出音讯发送失败的提醒)     * @see #onTransBuffer_C2C_CallBack(Protocal)     * @since 4.0     */    @Override    public boolean onTransBuffer_C2C_RealTimeSendFaild_CallBack(Protocal p) {        // 接收者uid        String userId = p.getTo();        // 发送者uid        String from_user_id = p.getFrom();        // 音讯或指令内容        String dataContent = p.getDataContent();        // 音讯或指令指纹码(即惟一ID)        String fingerPrint = p.getFp();        // 【重要】用户定义的音讯或指令协定类型(开发者可据此类型来辨别具体的音讯或指令)        inttypeu = p.getTypeu();         logger.debug("【DEBUG_回调告诉】[typeu="+ typeu + "]客户端"+ from_user_id + "发给客户端"+ userId + "的音讯:str="+ dataContent                + ",因实时发送没有胜利,须要下层利用作离线解决哦,否则此音讯将被抛弃.");        returnfalse;    }}

3.2 服务端被动发动音讯的QoS回调告诉(实现MessageQoSEventListenerS2C类)
public class MessageQoSEventS2CListnerImpl implements MessageQoSEventListenerS2C {

private static Logger logger = LoggerFactory.getLogger(MessageQoSEventS2CListnerImpl.class);@Overridepublic void messagesLost(ArrayList<Protocal> lostMessages) {    logger.debug("【DEBUG_QoS_S2C事件】收到零碎的未实时送达事件告诉,以后共有"            + lostMessages.size() + "个包QoS保障机制完结,断定为【无奈实时送达】!");}@Overridepublic void messagesBeReceived(String theFingerPrint) {    if(theFingerPrint != null) {        logger.debug("【DEBUG_QoS_S2C事件】收到对方已收到音讯事件的告诉,fp="+ theFingerPrint);    }}

}

3.3 服务端配置
public class ServerLauncherImpl extends ServerLauncher {

// 动态类办法:进行一些全局配置设置static{    // 设置MobileIMSDK服务端的网络监听端口    ServerLauncherImpl.PORT = 7901;    // 开/关Demog日志的输入    QoS4SendDaemonS2C.getInstance().setDebugable(true);    QoS4ReciveDaemonC2S.getInstance().setDebugable(true);    ServerLauncher.debug = true;    // TODO 与客户端协商一致的心跳敏感模式设置

// ServerToolKits.setSenseMode(SenseMode.MODE_10S);

    // 敞开与Web端的音讯互通桥接器(其实SDK中默认就是false)    ServerLauncher.bridgeEnabled = false;    // TODO 跨服桥接器MQ的URI(本参数只在ServerLauncher.bridgeEnabled为true时有意义)

// BridgeProcessor.IMMQ_URI = "amqp://js:19844713@192.168.31.190";

}// 实例构造方法public ServerLauncherImpl() throws IOException {    super();}/** * 初始化音讯处理事件监听者. */@Overrideprotected void initListeners() {    // ** 设置各种回调事件处理实现类    this.setServerEventListener(newServerEventListenerImpl());    this.setServerMessageQoSEventListener(newMessageQoSEventS2CListnerImpl());}

}

3.4 服务端启动类
舒适小提示:这里因为小编将服务端和客户端集成在同一个我的项目中,因而如下配置:

SpringBoot的CommandLineRunner接口次要用于实现在服务初始化后,去执行一段代码块逻辑(run办法),这段初始化代码在整个利用生命周期内只会执行一次!
@Order(value = 1) :依照肯定的程序去执行,value值越小越先执行
@Slf4j

@Component

@Order(value = 1)

public class ChatServerRunner implements CommandLineRunner {

@Overridepublic void run(String... strings) throws Exception {    log.info("================= ↓↓↓↓↓↓ 启动MobileIMSDK服务端 ↓↓↓↓↓↓ =================");    // 实例化后记得startup哦,独自startup()的目标是让调用者能够提早决定何时真正启动IM服务    final ServerLauncherImpl sli = new ServerLauncherImpl();    // 启动MobileIMSDK服务端的Demo    sli.startup();    // 加一个钩子,确保在JVM退出时开释netty的资源    Runtime.getRuntime().addShutdownHook(newThread(sli::shutdown));}

}

如果服务端与客户端不在同一个我的项目 ,服务端可间接通过如下形式启动即可~

四、开发客户端

4.1 客户端与IM服务端连贯事件
@Slf4j

public class ChatBaseEventImpl implements ChatBaseEvent {

@Overridepublic void onLoginMessage(int dwErrorCode) {    if(dwErrorCode == 0) {        log.debug("IM服务器登录/连贯胜利!");    } else{        log.error("IM服务器登录/连贯失败,错误代码:"+ dwErrorCode);    }}@Overridepublic void onLinkCloseMessage(int dwErrorCode) {    log.error("与IM服务器的网络连接出错敞开了,error:"+ dwErrorCode);}

}

4.2 接管音讯事件
@Slf4j

public class ChatTransDataEventImpl implements ChatTransDataEvent {

@Overridepublic void onTransBuffer(String fingerPrintOfProtocal, String userid, String dataContent, inttypeu) {    log.debug("[typeu="+ typeu + "]收到来自用户"+ userid + "的音讯:"+ dataContent);}@Overridepublic void onErrorResponse(int errorCode, String errorMsg) {    log.debug("收到服务端谬误音讯,errorCode="+ errorCode + ", errorMsg="+ errorMsg);}

}

4.3 音讯是否送达事件
@Slf4j

public class MessageQoSEventImpl implements MessageQoSEvent {

@Override// 对方未胜利接管音讯的回调事件 lostMessages:寄存音讯内容public void messagesLost(ArrayList<Protocal> lostMessages) {    log.debug("收到零碎的未实时送达事件告诉,以后共有"+ lostMessages.size() + "个包QoS保障机制完结,断定为【无奈实时送达】!");}@Override// 对方胜利接管到音讯的回调事件public void messagesBeReceived(String theFingerPrint) {    if(theFingerPrint != null) {        log.debug("收到对方已收到音讯事件的告诉,fp="+ theFingerPrint);    }}

}

4.4 MobileIMSDK初始化配置
public class IMClientManager {

private static IMClientManager instance = null;/** * MobileIMSDK是否已被初始化. true示意已初化实现,否则未初始化. */privatebooleaninit = false;public static IMClientManager getInstance() {    if(instance == null) {        instance = new IMClientManager();    }    return instance;}private IMClientManager() {    initMobileIMSDK();}public void initMobileIMSDK() {    if(!init) {        // 设置服务器ip和服务器端口        ConfigEntity.serverIP = "127.0.0.1";        ConfigEntity.serverPort = 8901;        // MobileIMSDK外围IM框架的敏感度模式设置

// ConfigEntity.setSenseMode(SenseMode.MODE_10S);

        // 开启/敞开DEBUG信息输入        ClientCoreSDK.DEBUG = false;        // 设置事件回调        ClientCoreSDK.getInstance().setChatBaseEvent(newChatBaseEventImpl());        ClientCoreSDK.getInstance().setChatTransDataEvent(newChatTransDataEventImpl());        ClientCoreSDK.getInstance().setMessageQoSEvent(newMessageQoSEventImpl());        init = true;    }}

}

4.5 连贯IM服务端,发送音讯
服务类:

public interface IChatService {

/** * 登录连贯IM服务器申请 * * @param username: 用户名 * @param password: 明码 * @return: void */void loginConnect(String username, String password);/** * 发送音讯 * * @param friendId: 接管音讯者id * @param msg:      音讯内容 * @return: void */void sendMsg(String friendId, String msg);

}

服务实现类:

@Slf4j

@Service

@Transactional(rollbackFor = Exception.class)

public class ChatServiceImpl implements IChatService {

@Overridepublic void loginConnect(String username, String password) {    // 确保MobileIMSDK被初始化哦(整个APP生生命周期中只需调用一次哦)    // 提醒:在不退出APP的状况下退出登陆后再从新登陆时,请确保调用本办法一次,不然会报code=203谬误哦!    IMClientManager.getInstance().initMobileIMSDK();    // * 异步提交登陆名和明码    new LocalUDPDataSender.SendLoginDataAsync(username, password) {        /**         * 登陆信息发送实现后将调用本办法(留神:此处仅是登陆信息发送实现,真正的登陆后果要在异步回调中解决哦)。         * @param code 数据发送返回码,0 示意数据胜利收回,否则是错误码         */        protected void fireAfterSendLogin(int code) {            if(code == 0) {                log.debug("数据发送胜利!");            } else{                log.error("数据发送失败。错误码是:"+ code);            }        }    }.execute();}@Overridepublic void sendMsg(String friendId, String msg) {    // 发送音讯(异步晋升体验,你也可间接调用LocalUDPDataSender.send(..)办法发送)    new LocalUDPDataSender.SendCommonDataAsync(msg, friendId) {        @Override        protected void onPostExecute(Integer code) {            if(code == 0) {                log.debug("数据已胜利收回!");            } else{                log.error("数据发送失败。错误码是:"+ code + "!");            }        }    }.execute();}

}

五、编写Controller进行测试

@RestController

@RequestMapping("/api")

@Api(tags = "聊天测试-接口")

public class ChatController {

@Autowiredprivate IChatService chatService;@PostMapping(value = "/loginConnect", produces = Constants.CONTENT_TYPE)@ApiOperation(value = "登陆申请", httpMethod = "POST", response = ApiResult.class)public ApiResult loginConnect(@RequestParamString username, @RequestParamString password) {    chatService.loginConnect(username, password);    return ApiResult.ok();}@PostMapping(value = "/sendMsg", produces = Constants.CONTENT_TYPE)@ApiOperation(value = "发送音讯", httpMethod = "POST", response = ApiResult.class)public ApiResult sendMsg(@RequestParam String friendId, @RequestParam String msg) {    chatService.sendMsg(friendId, msg);    return ApiResult.ok();}

}

启动我的项目,拜访:http://127.0.0.1:8080/swagger...

1) loginConnect接口:

任意输出一个账号密码登录连贯IM服务端:

控制台日志如下:

2)sendMsg接口:

给指定用户发送音讯:这里因为只有一个客户端,上一步登录了一个admin账号,因而小编给admin账号(也就是本人) 发送音讯

控制台日志如下:

六、本文小结

对于集成可参考MobileIMSDK给出的文档一步一步实现。

该开源工程对应的官网文档比拟齐全,须要哪个端,就去看对应端的手册就好了。

1)Demo装置和应用

客户端Demo装置和应用帮忙(Android) [1]
客户端Demo装置和应用帮忙(iOS) [2]
客户端Demo装置和应用帮忙(Java) [3]
客户端Demo演示和阐明(H5) [4]
服务端Demo装置和应用帮忙 [5] new

2)开发者指南

客户端开发指南(Android)
客户端开发指南(iOS)
客户端开发指南(Java)
客户端开发指南(H5)
服务端开发指南

3)API文档

客户端SDK API文档(Android):TCP版、UDP版
客户端SDK API文档(iOS):TCP版、UDP版
客户端SDK API文档(Java):TCP版、UDP版
客户端SDK API文档(H5):点此进入
服务端SDK API文档

另外:作者给出了通过Java GUI编程实现的一个小demo,咱们能够先将其运行起来,先体验一下性能,代码量也不是太多,咱们能够通过debug形式查看执行流程。

分明执行流程之后咱们就能够将demo中的代码移植到咱们本人的我的项目中加以批改使用于本人的业务中,切勿拿起就跑,否则一旦运气不好,将节约更多的工夫去集成,这样很不好!

最初:案例demo中相干代码正文都有,这里就简略说下整个流程吧:

1)首先启动IM服务端
2)用户在客户端登录一个用户与服务端建设连贯放弃通信( 客户端ChatServiceImpl中loginConnect办法为登录连贯服务端事件;服务端ServerEventListenerImpl中onUserLoginVerify办法为服务端接管的上线告诉事件);
3)客户端通过 ChatServiceImpl中sendMsg办法发送一条音讯,如果对方在线能接管音讯则走服务端ServerEventListenerImpl中onTransferMessage4C2C办法,否则走onTransferMessage_RealTimeSendFaild办法;如果对方胜利接管到音讯,客户端将走MessageQoSEventImpl中messagesBeReceived事件,否则走messagesLost事件;
4)客户端通过ChatMessageEvent中onRecieveMessage回调事件接管音讯。

附:本文案例demo源码下载:

1)主地址:https://gitee.com/zhengqingya...
2)备地址:https://gitee.com/instant_mes...

附录:更多IM聊天老手实际代码

《跟着源码学IM(一):手把手教你用Netty实现心跳机制、断线重连机制》
《跟着源码学IM(二):自已开发IM很难?手把手教你撸一个Andriod版IM》
《跟着源码学IM(三):基于Netty,从零开发一个IM服务端》
《跟着源码学IM(四):拿起键盘就是干,教你徒手开发一套分布式IM零碎》
《跟着源码学IM(五):正确理解IM长连贯、心跳及重连机制,并入手实现》
《跟着源码学IM(六):手把手教你用Go疾速搭建高性能、可扩大的IM零碎》
《跟着源码学IM(七):手把手教你用WebSocket打造Web端IM聊天》
《跟着源码学IM(八):万字长文,手把手教你用Netty打造IM聊天》
《跟着源码学IM(九):基于Netty实现一套分布式IM零碎》
《跟着源码学IM(十):基于Netty,搭建高性能IM集群(含技术思路+源码)》