IM インスタント メッセージング システム [SpringBoot+Netty] - コーミング (1)

記事ディレクトリ

プロジェクトのソースコード

目次
IM インスタント メッセージング システム [SpringBoot+Netty] - コーミング (2)
IM インスタント メッセージング システム [SpringBoot+Netty] - コーミング (3)
IM インスタント メッセージング システム [SpringBoot+Netty] - Combing (4)
IM インスタント メッセージング システム [SpringBoot+Netty] - Combing (5)

1. 自社開発のインスタント メッセージング システムを開発する理由

1. インスタント メッセージング システムを実装するにはどのような方法がありますか


まず、市場にある im システムを見てください (これらの 3 つの方法以外は何もありません)。

  1. オープンソース製品を二次開発または直接使用するために使用する
  2. 有料のクラウドサービスプロバイダーを利用する
  3. 独学

1.1. オープンソース製品を二次開発または直接使用するために使用する


优点: すぐに始めることができます。

缺点:機能が不足している、持続可能性が低い、後のメンテナンスや拡張を行うチームがない、自社のテクノロジースタックと一致するかどうか

1.2. 有料クラウドサービスプロバイダーの利用


优点: imシステムの開発や運用保守サーバーが不要 大規模なサービスプロバイダーは技術が比較的成熟しており、メッセージ送信の信頼性が高い サービスプロバイダーの公式sdkやuiライブラリによると、 im 関数を独自のサービスに追加するのは簡単です

缺点: サービスプロバイダーのソースコードを盗み見ることは不可能(クローズドソース)、カスタマイズされたニーズに応えることは困難 公式拡張機能がニーズを満たさない場合、基本的に解決策はありません 情報とデータは重要です他人の手があまり良くなく、サービスのコストが高い

1.3. 自社開発


优点: 会社のテクノロジースタックに沿って開発し、後のメンテナンスについて心配する必要はなく、独自のニーズをカスタマイズでき、データのセキュリティが保護されます。

缺点:imシステムに特に精通した人が開発する必要があり、一定の技術レベルが求められ、人件費が嵩む



2. 自社開発インスタント メッセージング システムの開発方法

2.1. 初期のインスタント メッセージング システムがどのように実現されたか

ここに画像の説明を挿入

これは初期の京東顧客サービスによって実装された技術アーキテクチャです

このアーキテクチャではリソースの無駄が発生し、送信するメッセージがない場合でもポーリングは停止しません。

2.2. インスタントメッセージングシステムの基本構成


ここに画像の説明を挿入

  • 客户端:PC端末(MAC、WINDOS)、モバイル端末(Android、Apple)、WEB端末

  • 服务层

    • 接入层: im システムのポータルは核心、クライアントとサーバー間の長いリンクを維持する im システムの比較モジュールです. メッセージはクライアントからアクセス層に送信され、アクセス層はそれをロジックに渡します処理用のレイヤー; : 1 つ目接入层主要分为四个功能は長いリンクの維持、2 つ目はプロトコル分析、3 つ目はセッションの維持、4 つ目はメッセージのプッシュです。メッセージの処理が完了すると、メッセージもクライアントに配信されます。アクセス層; アクセス層とクライアントの間に必须プロトコルが必要です (アプリケーション層プロトコル: テキスト プロトコルとバイナリ プロトコル - MQTT、XMPP、HTPP およびその他のプロトコル、プライベート プロトコル)
    • 逻辑层: ビジネス システムの次から次へとモジュール: ユーザー、関係チェーン、グループ、メッセージ
  • 存储层:MySQL、Redis

2.3. インスタント メッセージング システムの現在の一般的なアーキテクチャ


ここに画像の説明を挿入

  • ロングコネクションはメッセージの送受信を即座に行うため、ロングポーリングに比べてメッセージを直接ユーザーに届けることができ、ロングポーリングに比べて多くの空ループが回避されます(参考:Webコミュニケーションの4つの方法

  • アクセス層とロジック層には、rpc调用次のいずれかを介してアクセスできます。mq解耦

  • ロジック層によって接続された主要な永続化層によって永続化作業が完了します

2.4. 概要

接入层: クライアントの長時間の接続とメッセージの送受信を維持するために、プロトコルは TCP プロトコル (信頼性の高い) の使用を検討できます。適切なプロトコル (MQTT、XMPP、プライベート プロトコル) を選択します。アクセス層应用层协议もユーザー セッションと接続 アクセス層は従来の Web 開発とは異なり、アクセス層はステートフル サービスであるのに対し、従来の http はステートレス サービスです。

逻辑层: メッセージの送受信のコア ロジックを処理し、アクセス層とストレージ層と連携して、メッセージが失われたり、漏洩したり、結合されたりしないことを確実にします。

存储层:合理的な設計を持ち、ロジック層にデータ サービスを提供し、大量のチャット レコード データを保持できる必要があります。



2. 基礎データの整備

1. ユーザー情報のインポート、ユーザー情報の削除、ユーザー情報の変更、ユーザー情報の照会


ここは、デモンストレーションとしてユーザー データをインポートするロジックを使用するのに適した場所だと思います。

ここに画像の説明を挿入

ここに画像の説明を挿入

次に、追加、削除、変更、確認のロジックをいくつか示します。ここでは書きません。自分で試してみると大まかな意味がわかります。また、後でも同様です。

2. インスタント メッセージングにおける最も貴重なデータ — リレーション チェーン モジュールのビジネス分析とデータベース設計


2.1. 最も価値のあるデータ - 友情の連鎖

       なぜそんなことを言うのですか?ご存知のとおり、なぜ WeChat と QQ はそれほど強いのでしょうか? それは、その中にあなたの友達がいるからで、別のチャットソフトに変えるとその友達がいなくなってしまいますが、これには価値があると思いますか?

2.2. 友情

  1. 弱い友情: Weibo を購読する
  2. 強い友情: WeChat のような (このシステムで使用されている方法)

2.3. データベース設計

  • 弱い友好関係の設計:
    ここに画像の説明を挿入

  • 強い友情のデザイン:

ここに画像の説明を挿入

  • 最終デザイン
    ここに画像の説明を挿入

3. フレンドのインポート、追加、更新、フレンドの削除、フレンド全削除、フレンド指定のプル、フレンド全体のプルの機能を実現


ここでは友達を追加するための具体的なロジック コードを示します。他のコードもこの一般的なアイデアに似ています。

// 添加好友的逻辑
@Transactional
public ResponseVO doAddFriend(RequestBase requestBase, String fromId, FriendDto dto, Integer appId){
    
    
    // A-B
    // Friend表插入 A 和 B 两条记录
    // 查询是否有记录存在,如果存在则判断状态,如果是已经添加,则提示已经添加了,如果是未添加,则修改状态

    // 第一条数据的插入
    LambdaQueryWrapper<ImFriendShipEntity> lqw = new LambdaQueryWrapper<>();
    lqw.eq(ImFriendShipEntity::getAppId, appId);
    lqw.eq(ImFriendShipEntity::getFromId, fromId);
    lqw.eq(ImFriendShipEntity::getToId, dto.getToId());
    ImFriendShipEntity entity = imFriendShipMapper.selectOne(lqw);

    long seq = 0L;
    // 不存在这条消息
    if(entity == null){
    
    
        // 直接添加
        entity = new ImFriendShipEntity();
        seq = redisSeq.doGetSeq(appId + ":" + Constants.SeqConstants.Friendship);
        entity.setAppId(appId);
        entity.setFriendSequence(seq);
        entity.setFromId(fromId);
        BeanUtils.copyProperties(dto, entity);
        entity.setStatus(FriendShipStatusEnum.FRIEND_STATUS_NORMAL.getCode());
        entity.setCreateTime(System.currentTimeMillis());
        int insert = imFriendShipMapper.insert(entity);
        if(insert != 1){
    
    
            // TODO 添加好友失败
            return ResponseVO.errorResponse(FriendShipErrorCode.ADD_FRIEND_ERROR);
        }
        writeUserSeq.writeUserSeq(appId, fromId, Constants.SeqConstants.Friendship, seq);
    }else{
    
    
        // 存在这条消息,去根据状态做判断
        // 他已经是你的好友了
        if(entity.getStatus() == FriendShipStatusEnum.FRIEND_STATUS_NORMAL.getCode()){
    
    
            // TODO 对方已经是你的好友
            return ResponseVO.errorResponse(FriendShipErrorCode.TO_IS_YOUR_FRIEND);
        }else{
    
    
            ImFriendShipEntity update = new ImFriendShipEntity();
            if(StringUtils.isNotEmpty(dto.getAddSource())){
    
    
                update.setAddSource(dto.getAddSource());
            }
            if(StringUtils.isNotEmpty(dto.getRemark())){
    
    
                update.setRemark(dto.getRemark());
            }
            if(StringUtils.isNotEmpty(dto.getExtra())){
    
    
                update.setExtra(dto.getExtra());
            }
            seq = redisSeq.doGetSeq(appId + ":" + Constants.SeqConstants.Friendship);
            update.setFriendSequence(seq);
            update.setStatus(FriendShipStatusEnum.FRIEND_STATUS_NORMAL.getCode());

            int res = imFriendShipMapper.update(update, lqw);
            if(res != 1){
    
    
                // TODO 添加好友失败
                return ResponseVO.errorResponse(FriendShipErrorCode.ADD_FRIEND_ERROR);
            }
            writeUserSeq.writeUserSeq(appId, fromId, Constants.SeqConstants.Friendship, seq);
        }
    }

    // 第二条数据的插入
    LambdaQueryWrapper<ImFriendShipEntity> lqw1 = new LambdaQueryWrapper<>();
    lqw1.eq(ImFriendShipEntity::getAppId, appId);
    lqw1.eq(ImFriendShipEntity::getFromId, dto.getToId());
    lqw1.eq(ImFriendShipEntity::getToId, fromId);
    ImFriendShipEntity entity1 = imFriendShipMapper.selectOne(lqw1);

    // 不存在就直接添加
    if(entity1 == null){
    
    
        entity1 = new ImFriendShipEntity();
        entity1.setAppId(appId);
        entity1.setFromId(dto.getToId());
        BeanUtils.copyProperties(dto, entity1);
        entity1.setToId(fromId);
        entity1.setFriendSequence(seq);
        entity1.setStatus(FriendShipStatusEnum.FRIEND_STATUS_NORMAL.getCode());
        entity1.setCreateTime(System.currentTimeMillis());
        int insert = imFriendShipMapper.insert(entity1);
        if(insert != 1){
    
    
            // TODO 添加好友失败
            return ResponseVO.errorResponse(FriendShipErrorCode.ADD_FRIEND_ERROR);
        }
        writeUserSeq.writeUserSeq(appId, dto.getToId(), Constants.SeqConstants.Friendship, seq);
    }else{
    
    
        // 存在就判断状态
        if(FriendShipStatusEnum.FRIEND_STATUS_NORMAL.getCode() != entity1.getStatus()){
    
    
            // TODO 对方已经是你的好友
            return ResponseVO.errorResponse(FriendShipErrorCode.TO_IS_YOUR_FRIEND);
        }else{
    
    
            ImFriendShipEntity entity2 = new ImFriendShipEntity();
            entity2.setFriendSequence(seq);
            entity2.setStatus(FriendShipStatusEnum.FRIEND_STATUS_NORMAL.getCode());
            imFriendShipMapper.update(entity2, lqw1);
            writeUserSeq.writeUserSeq(appId, dto.getToId(), Constants.SeqConstants.Friendship, seq);
        }
    }

    // TODO TCP通知
    // A B 添加好友,要把添加好友的信息,发送给除了A其他的端,还要发送给B的所有端

    // 发送给from
    AddFriendPack addFriendPack = new AddFriendPack();
    BeanUtils.copyProperties(entity, addFriendPack);
    addFriendPack.setSequence(seq);
    if(requestBase != null){
    
    
        messageProducer.sendToUser(fromId, requestBase.getClientType(), requestBase.getImei(),
                FriendshipEventCommand.FRIEND_ADD, addFriendPack, requestBase.getAppId());
    }else{
    
    
        messageProducer.sendToUser(fromId,
                FriendshipEventCommand.FRIEND_ADD, addFriendPack, requestBase.getAppId());
    }

    // 发送给to
    AddFriendPack addFriendToPack = new AddFriendPack();
    BeanUtils.copyProperties(entity1, addFriendToPack);
    messageProducer.sendToUser(entity1.getFromId(), FriendshipEventCommand.FRIEND_ADD, addFriendToPack,
            requestBase.getAppId());

    // 之后回调
    if(appConfig.isDestroyGroupAfterCallback()){
    
    
        AddFriendAfterCallbackDto addFriendAfterCallbackDto = new AddFriendAfterCallbackDto();
        addFriendAfterCallbackDto.setFromId(fromId);
        addFriendAfterCallbackDto.setToItem(dto);
        callbackService.callback(appId, Constants.CallbackCommand.AddFriendAfter,
                JSONObject.toJSONString(addFriendAfterCallbackDto));
    }

    return ResponseVO.successResponse();
}

次のシーケンス、コールバック、および TCP 通知は無視できます。

4. 友情の確認は実際には思っているよりもはるかに複雑です


ここでの友達認証は一方向の友達認証と双方向の友達認証の2種類に分けられます。コードはここに掲載されています。

// 校验好友关系
@Override
public ResponseVO checkFriendShip(CheckFriendShipReq req) {
    
    
    // 双向校验的修改
    // 1、先是把req中的所有的toIds都转化为key为属性,value为0的map
    Map<String, Integer> result
            = req.getToIds().stream().collect(Collectors.toMap(Function.identity(), s-> 0));

    List<CheckFriendShipResp> resp = new ArrayList<>();

    if(req.getCheckType() == CheckFriendShipTypeEnum.SINGLE.getType()){
    
    
        resp = imFriendShipMapper.checkFriendShip(req);
    }else{
    
    
        resp = imFriendShipMapper.checkFriendShipBoth(req);
    }

    // 2、将复杂sql查询出来的数据转换为map
    Map<String, Integer> collect = resp.stream()
            .collect(Collectors.toMap(CheckFriendShipResp::getToId,
                    CheckFriendShipResp::getStatus));

    // 3、最后比对之前result中和collect是否完全相同,collect中没有的话,就将这个数据封装起来放到resp中去
    for (String toId : result.keySet()){
    
    
        if(!collect.containsKey(toId)){
    
    
            CheckFriendShipResp checkFriendShipResp = new CheckFriendShipResp();
            checkFriendShipResp.setFromId(req.getFromId());
            checkFriendShipResp.setToId(toId);
            checkFriendShipResp.setStatus(result.get(toId));
            resp.add(checkFriendShipResp);
        }
    }

    return ResponseVO.successResponse(resp);
}

这里还要一个点,就是那个result最后和collect 里面的做一下对比,如果我们要校验的用户,不存在于数据库(双向校验在下面出现status=4的情况是,那个用户存在于数据库但是它的status为0),collect就查询不出来,也就要把那个数据也要加到resp中去,此时它的status=0

重要な点は、imFriendShipMapper の 2 つの SQL ステートメントです。

checkFriendShip (一方向チェック)

@Select("<script>" +
            "select from_id as fromId, to_id as toId, if(status = 1, 1, 0) as status from im_friendship where from_id = #{fromId} and to_id in " +
            "<foreach collection='toIds' index = 'index' item = 'id' separator = ',' close = ')' open = '('>" +
            "#{id}" +
            "</foreach>" +
            "</script>")
    public List<CheckFriendShipResp> checkFriendShip(CheckFriendShipReq req);

ここに画像の説明を挿入

つまり、fromIdとtoIdで分かる限り、検証が成功しても検証結果はステータスとしてif(status = 1, 1, 0)で判定され、最終的に前に戻されます。

checkFriendShip Both (双方向チェック)

@Select("<script>" +
        "select a.fromId, a.toId, ( " +
        "case " +
        "when a.status = 1 and b.status = 1 then 1 " +
        "when a.status = 1 and b.status != 1 then 2 " +
        "when a.status != 1 and b.status = 1 then 3 " +
        "when a.status != 1 and b.status != 1 then 4 " +
        "end" +
        ")" +
        "as status from " +
        "(select from_id as fromId, to_id as toId, if(status = 1, 1, 0) as status from im_friendship where app_id = #{appId} and from_id = #{fromId} and to_id in " +
        "<foreach collection='toIds' index='index' item='id' separator=',' close=')' open='('>" +
        "#{id}" +
        "</foreach>" +
        ") as a inner join" +
        "(select from_id as fromId, to_id as toId, if(status = 1, 1, 0) as status from im_friendship where app_id = #{appId} and to_id = #{fromId} and from_id in " +
        "<foreach collection='toIds' index='index' item='id' separator=',' close=')' open='('>" +
        "#{id}" +
        "</foreach>" +
        ") as b " +
        "on a.fromId = b.toId and a.toId = b.fromId" +
        "</script>")
public List<CheckFriendShipResp> checkFriendShipBoth(CheckFriendShipReq req);

ここに画像の説明を挿入

5. ブラックリストのビジネス実現の追加、削除、検証


ここでの認証ブラックリスト ビジネスは、上記の認証フレンド ビジネスに似ており、コードもここに掲載されています。

// 校验黑名单
@Override
public ResponseVO checkFriendBlack(CheckFriendShipReq req) {
    
    
    Map<String, Integer> toIdMap
            = req.getToIds().stream().collect(Collectors.toMap(Function.identity(),s -> 0));

    List<CheckFriendShipResp> resp = new ArrayList<>();

    if(req.getCheckType() == CheckFriendShipTypeEnum.SINGLE.getType()){
    
    
        resp = imFriendShipMapper.checkFriendShipBlack(req);
    }else {
    
    
        resp = imFriendShipMapper.checkFriendShipBlackBoth(req);
    }

    Map<String, Integer> collect
            = resp.stream().collect(Collectors.toMap(CheckFriendShipResp::getToId, CheckFriendShipResp::getStatus));

    for (String toId : toIdMap.keySet()) {
    
    
        if(!collect.containsKey(toId)){
    
    
            CheckFriendShipResp checkFriendShipResp = new CheckFriendShipResp();
            checkFriendShipResp.setToId(toId);
            checkFriendShipResp.setFromId(req.getFromId());
            checkFriendShipResp.setStatus(toIdMap.get(toId));
            resp.add(checkFriendShipResp);
        }
    }
    return ResponseVO.successResponse(resp);
}

checkFriendShipBlack (一方向小切手)

@Select("<script>" +
       " select from_id AS fromId, to_id AS toId , if(black = 1,1,0) as status from im_friendship where app_id = #{appId} and from_id = #{fromId} and to_id in " +
        "<foreach collection='toIds' index='index' item='id' separator=',' close=')' open='('>" +
        " #{id} " +
        "</foreach>" +
        "</script>"
)
List<CheckFriendShipResp> checkFriendShipBlack(CheckFriendShipReq req);

checkFriendShipBlack Both (双方向チェック)

@Select("<script>" +
        " select a.fromId,a.toId , ( \n" +
        " case \n" +
        " when a.black = 1 and b.black = 1 then 1 \n" +
        " when a.black = 1 and b.black != 1 then 2 \n" +
        " when a.black != 1 and b.black = 1 then 3 \n" +
        " when a.black != 1 and b.black != 1 then 4 \n" +
        " end \n" +
        " ) \n " +
        " as status from "+
        " (select from_id AS fromId , to_id AS toId , if(black = 1,1,0) as black from im_friendship where app_id = #{appId} and from_id = #{fromId} AND to_id in " +
        "<foreach collection='toIds' index='index' item='id' separator=',' close=')' open='('>" +
        " #{id} " +
        "</foreach>" +
        " ) as a INNER join" +
        " (select from_id AS fromId, to_id AS toId , if(black = 1,1,0) as black from im_friendship where app_id = #{appId} and to_id = #{fromId} AND from_id in " +
        "<foreach collection='toIds' index='index' item='id' separator=',' close=')' open='('>" +
        " #{id} " +
        "</foreach>" +
        " ) as b " +
        " on a.fromId = b.toId AND b.fromId = a.toId "+
        "</script>"
)
List<CheckFriendShipResp> checkFriendShipBlackBoth(CheckFriendShipReq toId);

6.友達申請リストの取得、新規友達申請、友達申請承認、友達申請リスト読み取り業務実現


     ここでの新しい友達アプリケーションは友達追加の業務に実装されており、ユーザーの分野に応じて友達追加にアプリケーションが必要かどうかに応じて、コードは次のようになります

ここに画像の説明を挿入

そして、アプリケーションを承認するためのコード

// 审批好友请求
@Override
@Transactional
public ResponseVO approverFriendRequest(ApproverFriendRequestReq req) {
    
    

    ImFriendShipRequestEntity imFriendShipRequestEntity = imFriendShipRequestMapper.selectById(req.getId());
    if(imFriendShipRequestEntity == null){
    
    
        throw new ApplicationException(FriendShipErrorCode. FRIEND_REQUEST_IS_NOT_EXIST);
    }

    if(!req.getOperater().equals(imFriendShipRequestEntity.getToId())){
    
    
        //只能审批发给自己的好友请求
        throw new ApplicationException(FriendShipErrorCode.NOT_APPROVER_OTHER_MAN_REQUEST);
    }

    long seq = redisSeq.doGetSeq(req.getAppId() + ":" + Constants.SeqConstants.FriendshipRequest);

    ImFriendShipRequestEntity update = new ImFriendShipRequestEntity();
    // 这里审批是指同意或者拒绝,所以要写活
    update.setApproveStatus(req.getStatus());
    update.setUpdateTime(System.currentTimeMillis());
    update.setId(req.getId());
    update.setSequence(seq);
    imFriendShipRequestMapper.updateById(update);

    writeUserSeq.writeUserSeq(req.getAppId(),req.getOperater(), Constants.SeqConstants.FriendshipRequest,seq);

    // 如果是统一的话,就可以直接调用添加好友的逻辑了
    if(ApproverFriendRequestStatusEnum.AGREE.getCode() == req.getStatus()){
    
    
        FriendDto dto = new FriendDto();
        dto.setAddSource(imFriendShipRequestEntity.getAddSource());
        dto.setAddWorking(imFriendShipRequestEntity.getAddWording());
        dto.setRemark(imFriendShipRequestEntity.getRemark());
        dto.setToId(imFriendShipRequestEntity.getToId());
        ResponseVO responseVO = imFriendShipService.doAddFriend(req
                , imFriendShipRequestEntity.getFromId(), dto, req.getAppId());
        if(!responseVO.isOk() && responseVO.getCode() != FriendShipErrorCode.TO_IS_YOUR_FRIEND.getCode()){
    
    
            return responseVO;
        }
    }

    // TODO TCP通知
    // 通知审批人的其他端
    ApproverFriendRequestPack approverFriendRequestPack = new ApproverFriendRequestPack();
    approverFriendRequestPack.setStatus(req.getStatus());
    approverFriendRequestPack.setId(req.getId());
    approverFriendRequestPack.setSequence(seq);
    messageProducer.sendToUser(imFriendShipRequestEntity.getToId(), req.getClientType(), req.getImei(),
            FriendshipEventCommand.FRIEND_REQUEST_APPROVER, approverFriendRequestPack, req.getAppId());

    return ResponseVO.successResponse();
}

7.フレンズグループの事業紹介とデータベース設計


ここに画像の説明を挿入

上の図の左側は WeChat 用です。ユーザーは複数のグループに所属できます。右側は QQ 用です。1 人のユーザーは 1 つのグループにのみ所属できます。このシステムは左側に実装されているため、データベースを設計するには

ここに画像の説明を挿入

ここに画像の説明を挿入

8. メンバーの作成・取得・追加・削除、フレンドグループのグループ削除の実現


      このパートではユニオンに焦点を当てます。たとえば、フレンド グループを作成するには、メンバーを追加し、フレンド グループを削除する必要があります。また、グループ内のメンバーをクリアする必要もあります。グループ メンバーを追加する場合は、グループを取得する必要もあります、非常に結合しています。

ここに画像の説明を挿入
ここに画像の説明を挿入

9. インスタント メッセージングの最も複雑なモジュール - グループ モジュール ビジネス分析とデータベース設計


個人チャットはグループ チャットほど活発にできないため、グループ チャットを実装する必要があります


下面是腾讯云

ここに画像の説明を挿入

このシステムでは、この 2 種類のグループを実装します。

ここに画像の説明を挿入
ここに画像の説明を挿入

ここに画像の説明を挿入

ここに画像の説明を挿入
ここに画像の説明を挿入

10.輸入グループおよびグループメンバーのビジネス実現


ここでは何も言うことはありません

11. グループの作成、グループ情報の変更、およびグループ情報の取得のビジネス機能


複雑で高度に結合されている

12. ユーザーが参加しているグループの一覧を取得する業務機能の実現


ここには何もありません。この group_member をクエリして、ユーザーが参加したグループを見つけるだけです

@Select("select group_id from im_group_member where app_id = #{appId} and member_id = #{memberId}")
List<String> getJoinedGroupId(Integer appId, String memberId);

13. グループ解散及びグループ譲渡の業務機能の実現


少し

14. グループへの参加、グループチャットからの移動、グループチャットからの退出といったビジネス機能を実現


少し

15. グループメンバー情報の取得、グループメンバー情報の変更業務機能実現


少し

16. ミュートグループおよびミュートグループのメンバーの業務機能の実現


少し

3. BIO、NIO、Netty の概要

1、バイオ、ニオ


これは私の他の記事IO スレッド モデルで見ることができます。

2、ネット


これは非常に重要です。ここで少し基本的な説明をします。


官网:
Nettyは、保守可能な高性能プロトコル サーバーとクライアントを迅速に開発するための非同期イベント駆動型ネットワーク アプリケーション フレームワークです。

     Netty は、非同期のイベント駆動型ネットワーク アプリケーション フレームワークです。保守可能な高性能プロトコル サーバーとクライアントを迅速に開発します。


官网:Netty は、プロトコル サーバーやクライアントなどのネットワーク アプリケーションの迅速かつ簡単な開発を可能にする NIO クライアント サーバー フレームワークです。TCP や UDP ソケット サーバーなどのネットワーク プログラミングを大幅に簡素化および合理化します。

      Netty は、プロトコル サーバーやクライアントなどのネットワーク アプリケーションの迅速かつ簡単な開発を可能にする NIO クライアント/サーバー フレームワークです。TCP や UDP ソケット サーバーなどのネットワーク プログラミングを大幅に簡素化し、最適化します。


ここに画像の説明を挿入


Netty はどのようなアプリケーション シナリオで使用されますか?

  1. ネットワーク プログラミングを開発し、独自の RPC フレームワークを実装します。
  2. mqtt、http などの一部のパブリック プロトコルのブローカー コンポーネントとして使用できます。
  3. 多くのオープンソース フレームワークやビッグ データ フィールド間の通信でも netty が使用されます。

4. Netty エンタープライズ開発で習得する必要があるスキル

1. nettyを使って簡単なチャットルームを実装する


DiscardServerHandler

public class DiscardServerHandler extends ChannelInboundHandlerAdapter {
    
    

    static Set<Channel> channelList = new HashSet<>();

    // 有客户端连接进来就触发
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
    
    
        // 通知其他人我上线了
        channelList.forEach((e)->{
    
    
            e.writeAndFlush("[客户端]" + ctx.channel().remoteAddress() + "上线了");
        });
        channelList.add(ctx.channel());
    }

    // 有读写事件发生的时候触发这个方法
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    
    
        String message = (String) msg;

        System.out.println("收到数据: " + message);
//        // 通知分发给聊天室内所有的客户端
//        channelList.forEach((e)->{
    
    
//            if(e == ctx.channel()){
    
    
//                e.writeAndFlush("[自己]: " + message);
//            }else{
    
    
//                e.writeAndFlush("[客户端]:" + ctx.channel().remoteAddress() + "     " + message);
//            }
//        });
    }

    /**
     *  channel 处于不活跃的时候会调用
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
    
    
        // 通知其他客户端 我下线了
        channelList.remove(ctx.channel());
        // 通知其他人我上线了
        channelList.forEach((e)->{
    
    
            e.writeAndFlush("[客户端]" + ctx.channel().remoteAddress() + "下线了");
        });
    }
}

主なことはハンドラーを書くことであり、複雑なロジックはいくつかの API で実行できます。

サーバーの破棄

public class DiscardServer {
    
    

    private int port;

    public DiscardServer(int port){
    
    
        this.port = port;
    }

    public void run(){
    
    
        EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 线程池
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
    
    
            ServerBootstrap b = new ServerBootstrap(); // (2)
            b.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class) // (3)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
    
     // (4)
                        @Override
                        public void initChannel(SocketChannel ch) throws Exception {
    
    
                            ch.pipeline().addLast(new DiscardServerHandler());
                        }
                    })
                    .option(ChannelOption.SO_BACKLOG, 128)          // (5)
                    .childOption(ChannelOption.SO_KEEPALIVE, true); // (6)

            // Bind and start to accept incoming connections.
            System.out.println("tcp start success");
            ChannelFuture f = b.bind(port).sync(); // (7)


            // Wait until the server socket is closed.
            // In this example, this does not happen, but you can do that to gracefully
            // shut down your server.
            f.channel().closeFuture().sync();
        } catch (InterruptedException e) {
    
    
            throw new RuntimeException(e);
        } finally {
    
    
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
}

スターター

public class Starter {
    
    
    public static void main(String[] args) {
    
    
        new DiscardServer(8001).run();
    }
}

2.Nettyコーデック


ネットワークデバッグアシスタント—「オペレーティングシステム—」ネットワーク—「相手のオペレーティングシステム—」に対応するプロセスを検索(過去に渡されたものは文字列ではありません)

ここではネットワーク デバッグ アシスタントが使用されます

Netty の最下層は ByteBuf のみを認識します。文字列をクライアントに直接送信できないため、サーバーにエンコード コードとデコード コードを追加する必要があります。そうすれば、メッセージを受信するときに自分でコードをデコードする必要がなくなります。そしてそれらを直接使用することができます。

ここに画像の説明を挿入

ここに画像の説明を挿入

3. 基盤となるデータ フローの核心 - パイプライン メカニズム


ここに画像の説明を挿入

public class DiscardServer {
    
    
    private int port;
    public DiscardServer(int port){
    
    
        this.port = port;
    }

    public void run(){
    
    
        EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 线程池
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
    
    
            ServerBootstrap b = new ServerBootstrap(); // (2)
            b.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class) // (3)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
    
     // (4)
                        @Override
                        public void initChannel(SocketChannel ch) throws Exception {
    
    
                            Charset gbk = Charset.forName("GBK");
                            ch.pipeline().addLast("decoder", new StringDecoder(gbk));
                            ch.pipeline().addLast("encoder", new StringEncoder(gbk));
                            ch.pipeline().addLast(new DiscardServerHandler());
                        }
                    })
                    .option(ChannelOption.SO_BACKLOG, 128)          // (5)
                    .childOption(ChannelOption.SO_KEEPALIVE, true); // (6)

            // Bind and start to accept incoming connections.
            System.out.println("tcp start success");
            ChannelFuture f = b.bind(port).sync(); // (7)


            // Wait until the server socket is closed.
            // In this example, this does not happen, but you can do that to gracefully
            // shut down your server.
            f.channel().closeFuture().sync();
        } catch (InterruptedException e) {
    
    
            throw new RuntimeException(e);
        } finally {
    
    
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
}

ここに画像の説明を挿入

里面的这些Handler都要注意位置

4. トランスポート層プロトコル TCP が残した問題 - Netty はハーフパケットとスティッキーパケットの問題を解決します


4.1. TCP伝送の問題点(ハーフパケット、スティッキーパケット)


ここでチャット ルームのプログラムを起動し、ループでサーバーにメッセージを送信する Python スクリプトを開始します。

パイソン

import socket

s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(("127.0.0.1",8001))

for i in range(100):
    print(i)
    string = "hello1哈"
    body = bytes(string, 'gbk')
    s.sendall(body)

       このスクリプトを実行すると、サーバーのコンソールに情報が表示されます。表示されるのは 100 行の hello であるはずですが、実行後は 100 個のメッセージがすべて同じ行に表示されていることがわかります。 、2 回目は同じ行にあるものと、同じ行にあるものがあります。

ここに画像の説明を挿入

最初に送信する

ここに画像の説明を挿入

2回目の送信

      この現象は、TCP 送信がストリーミング形式で送信されるため、完全なセットが送信される場合もあれば、データが部分的に送信される場合もあります。

4.2 Netty はハーフパッケージとスティッキーパッケージをどのように解決するか


最初の解決策

サーバーのパイプラインに何かを追加して、読み取られるバイト数を制限できますが、欠点は、データのサイズを考慮する必要があることです。

ここに画像の説明を挿入

2番目の解決策

この分割記号を追加します。これの欠点は、本格的に読み込むデータに分割文字列が現れないことです。

ここに画像の説明を挿入

5. トランスポート層プロトコル TCP が残した問題 - プライベート プロトコルを使用して、基礎となる API であるハーフパケット、スティッキー パケット、および byteBuf を解決する


これを解決するためのプライベート プロトコルは次のとおりです。つまり、たとえば6123456、最初の 6 は次の 6 つの数字を読み取ることになります。

ここに画像の説明を挿入

ここではまず、ByteBuf のコア API について説明します。

public class NettyByteBuf {
    
    
    public static void main(String[] args) {
    
    
        // 创建byteBuf对象,该对象内部包含一个字节数组byte[10]
        ByteBuf byteBuf = Unpooled.buffer(10);
        System.out.println("byteBuf=" + byteBuf);

        for (int i = 0; i < 8; i++) {
    
    
            byteBuf.writeByte(i);
        }
        System.out.println("byteBuf=" + byteBuf);

        for (int i = 0; i < 5; i++) {
    
    
            System.out.println(byteBuf.getByte(i));
        }
        System.out.println("byteBuf=" + byteBuf);

        for (int i = 0; i < 5; i++) {
    
    
            System.out.println(byteBuf.readByte());
        }
        System.out.println("byteBuf=" + byteBuf);

        System.out.println(byteBuf.readableBytes());
    }
}

ここに画像の説明を挿入

上記のコンソール結果から、ridx がどこで読み取られたか、widx がどれだけ占有したか、cap が総容量を意味することを理解するのは難しくありません。

ここに画像の説明を挿入

Ridx は読み取りインデックス、widx は書き込みインデックスです

共通API 効果
Unpooled.buffer(10) バイト配列を作成する[10]
byteBuf.writeByte(i) i を byteBuf に書き込みます
byteBuf.getByte(i) btyeBuf の i 番目のバイトを取得します。読み取りインデックスは変更されません
byteBuf.readByte() 先頭からバイトを読み取り、読み取りインデックスは自動的に後方に移動します
byteBuf.readableBytes() byteBufで読み込まれていないバイトを取得する
byteBuf.markReaderIndex() 読み取りインデックスの場所を記録します。
byteBuf.resetReaderIndex() レコードの読み取りインデックスの位置を返します。
// 继承了这个类就可以去 自定义协议了
public class MyDecodecer extends ByteToMessageDecoder {
    
    

    // 数据长度 + 数据
    @Override
    protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf, List<Object> list) throws Exception {
    
    

        // 一个int是4字节,可读长度要大于4才可以继续执行
        if(byteBuf.readableBytes() < 4){
    
    
            return;
        }

        // 数据长度
        int i = byteBuf.readInt();
        if(byteBuf.readableBytes() < i){
    
    
            byteBuf.resetReaderIndex();
            return;
        } 

        // 开辟一个byte数组去接收数据
        byte[] data = new byte[i];
        byteBuf.readBytes(data);
        System.out.println(new String(data));
        byteBuf.markReaderIndex();
    }
}

したがって、プライベート プロトコルをカスタマイズして、ルールに従ってデータを読み取ることができます。これをパイプラインに忘れずに入れてください。

ここに画像の説明を挿入

これで半パックやベタつきパックの問題も解決できます

6. IdleStateHandlerのハートビート機構のソースコード詳細説明


まずは短い接続と長い接続について理解しましょうHTTP 長い接続と短い接続

public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
    
    
    int readTimeout = 0;

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
    
    
//        // IdleStateEven 超时类型
        IdleStateEvent event = (IdleStateEvent) evt;
        // ALL_IDLE : 一段时间内没有数据接收或者发送
        // READER_IDLE : 一段时间内没有数据接收
        // WRITER_IDLE : 一段时间内没有数据发送
        if(event.state() == IdleState.READER_IDLE){
    
    
            readTimeout++;
        }

        if(readTimeout >= 3){
    
    
            System.out.println("超时超过3次,断开连接");
            ctx.close();
        }

        System.out.println("触发了:" + event.state() + "事件");
    }
}

ここに画像の説明を挿入

この実装の効果は、読み取りタイムアウトが 3 秒になるとハートビート検出がトリガーされ、そのタイムアウトが 3 秒を超えると接続が切断されるというロジックです。

7. Nettyを使用してファイルをアップロードおよびアップロードします

==UploadFileDecodecer ==

public class UploadFileDecodecer extends ByteToMessageDecoder {
    
    

    // 数据长度 + 数据
    @Override
    protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf, List<Object> list) throws Exception {
    
    

        // 一个int是4字节,可读长度要大于4才可以继续执行
        if(byteBuf.readableBytes() < 8){
    
    
            return;
        }

        // 数据长度
        int command = byteBuf.readInt();

        FileDto fileDto = new FileDto();
        fileDto.setCommand(command);

        // 文件名长度
        int fileNameLen = byteBuf.readInt();
        if(byteBuf.readableBytes() < fileNameLen){
    
    
            byteBuf.resetReaderIndex();
            return;
        }

        // 开辟一个byte数组去接收数据
        byte[] data = new byte[fileNameLen];
        byteBuf.readBytes(data);
        String fileName = new String(data);
        fileDto.setFileName(fileName);

        if(command == 2){
    
    
            int dataLen = byteBuf.readInt();
            if(byteBuf.readableBytes() < dataLen){
    
    
                byteBuf.resetReaderIndex();
                return;
            }
            byte[] fileData = new byte[dataLen];
            byteBuf.readBytes(fileData);
            fileDto.setBytes(fileData);
        }

        byteBuf.markReaderIndex();
        list.add(fileDto);
    }
}

この部分をパイプラインに配置し、UploadFileHandler の前に配置します。ここで、ファイルのコマンドとファイル名、ファイルの特定のデータがカスタム プロトコルを通じて解析され、FileDto にカプセル化され、最後にパイプラインに配置されます。後で使用するために

アップロードファイルハンドラ

public class UploadFileHandler extends ChannelInboundHandlerAdapter {
    
    

    // 有客户端连接进来就触发
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
    
    
    }

    // 有读写事件发生的时候触发这个方法
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    
    
        if(msg instanceof FileDto){
    
    
            FileDto fileDto = (FileDto) msg;
            if(fileDto.getCommand() == 1){
    
    
                // 创建文件
                File file = new File("E://" + fileDto.getFileName());
                if(!file.exists()){
    
    
                    file.createNewFile();
                }
            }else if(fileDto.getCommand() == 2){
    
    
                // 写入文件
                save2File("E://" + fileDto.getFileName(), fileDto.getBytes());
            }
        }
    }

    public static boolean save2File(String fname, byte[] msg){
    
    
        OutputStream fos = null;
        try{
    
    
            File file = new File(fname);
            File parent = file.getParentFile();
            boolean bool;
            if ((!parent.exists()) &
                    (!parent.mkdirs())) {
    
    
                return false;
            }
            fos = new FileOutputStream(file,true);
            fos.write(msg);
            fos.flush();
            return true;
        }catch (FileNotFoundException e){
    
    
            return false;
        }catch (IOException e){
    
    
            File parent;
            return false;
        }
        finally{
    
    
            if (fos != null) {
    
    
                try{
    
    
                    fos.close();
                }catch (IOException e) {
    
    }
            }
        }
    }
}

ここではデコード先から取得したFileDtoに使用し、無い場合は作成、あれば書き込みます。

ここに画像の説明を挿入

次の Python スクリプトを使用してテストできます。

#-*- coding: UTF-8 -*-
import socket,os,struct
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(("127.0.0.1",8001))

filepath = "D://txt.txt"
if os.path.isfile(filepath):
    
    filename = os.path.basename(filepath).encode('utf-8')

    # 请求传输文件
    command = 1
    
    body_len = len(filename)
    fileNameData = bytes(filename)
    i = body_len.to_bytes(4, byteorder='big')
    c = command.to_bytes(4, byteorder='big')

    s.sendall(c + i + fileNameData) 

    fo = open(filepath,'rb')
    while True:
      command = 2;
      c = command.to_bytes(4, byteorder='big')
      filedata = fo.read(1024)
      print(len(filedata))
      b = len(filedata).to_bytes(4, byteorder='big')
      if not filedata:
        break
      s.sendall(c + i + fileNameData + b + filedata)
    fo.close()
    #s.close()
else:
    print(False)

おすすめ

転載: blog.csdn.net/weixin_52487106/article/details/130538843