socket的封包骡苞、粘包、解包楷扬,做一個即時通訊項目

這些天項目不是很急解幽,自己研究了一下socket,感覺收獲頗豐烘苹,真的很高興躲株。

  • http :
    它是超文本傳輸協(xié)議,對應(yīng)于應(yīng)用層镣衡,它主要強調(diào)的是對數(shù)據(jù)的封裝霜定。它是所謂的“短鏈接”档悠,一般都是客服端發(fā)送請求 到服務(wù)器 服務(wù)給了客戶端應(yīng)答過后即斷開鏈接,我們平時做的項目中 只要不是客戶端和服務(wù)器保持長期的鏈接的情況下 基本都用的是http
  • socket :
    它是我們所謂的“長鏈接”望浩,它是一個套接字 實際上我覺得它是支持傳輸協(xié)議的的一個基本的單元辖所,它支持tcp/ip 協(xié)議 也支持udp協(xié)議。
  • tcp/ip :
    我們一般使用的是socket的協(xié)議都是tcp/ip協(xié)議磨德,這種協(xié)議我們認為是絕對安全的為什么呢 因為他是確認建立完整的通道之后才進行傳輸數(shù)據(jù) 所以只要鏈接的通道在數(shù)據(jù)一定是能從A ->B 或者 B->A的 我們即時通訊的基本上都是這種協(xié)議缘回,它建立鏈接需要“三次握手”,斷開鏈接需要“四次握手”典挑。
    “三次握手”:
    1.客戶端發(fā)送syn(ack = j)包給服務(wù)端酥宴,并自己進入syn_send狀態(tài),等待服務(wù)端確認搔弄。
    2.服務(wù)端收到客戶端的syn包幅虑,必須確認客戶端的包即(ack = j+1),同時自己也發(fā)送一個syn(ack = k)的一個包 ,它發(fā)給客戶端的是 自己的syn包和確認的客戶端的包顾犹,并且自己進入syn_recv狀態(tài)倒庵。
    3.客戶端收到服務(wù)器給的包,并且發(fā)送服務(wù)端一個確認包及(ack = k+1)的包炫刷,這個包發(fā)送完畢 雙方都進入ESTABLISHED狀態(tài)擎宝,三次握手成功。
    斷開的“四次握手”:
    實話說這個我說不很清楚浑玛,但是我查了一些資料有些形象的比喻绍申,我基本明白了。
    1. a 告訴b要斷開鏈接
    2.b收到a的消息顾彰,并且告訴a等待极阅,等待自己發(fā)送未完成的數(shù)據(jù)包。
    3.b告訴a發(fā)送數(shù)據(jù)完成涨享,a可以關(guān)閉了筋搏。
    4.a知道b發(fā)送完成數(shù)據(jù)了,并且a等待一下(這個我不知道a為什么不馬上關(guān)閉)厕隧,a關(guān)閉鏈接奔脐。
    我在網(wǎng)絡(luò)上找的一個圖片形象的描述了這一個過程。


    image.png

    其實我們正常寫代碼根本就是不知道這些的吁讨。因為我們基本不基于蘋果原聲的api 那個是純c的我們一般不用 用框架的話框架內(nèi)部都給我們做好了髓迎,我們只需要調(diào)用方法就好了,但是我們應(yīng)該明白原理建丧。

  • udp:
    說實話我工作中根本沒用用到這個排龄,但是它是非安全的,舉個簡單的例子 tcp/ip就是打電話茶鹃,雙方信號通了才能說話涣雕,udp是相當于別人給你說話艰亮,那你有可能聽到也有可能聽不到,只是知道說話的人說話了挣郭。但是udp的效率還是比tcp相對來說要高迄埃。我們一般用到的udp一般是廣播等等的吧。
  • 做一個及時通訊的聊天
    我們做一個及時通訊的聊天需要準備什么呢 兑障?對于我們客戶端而言我覺得需要準備數(shù)據(jù)格式侄非、協(xié)議、還有及時通訊的框架流译。
    1.數(shù)據(jù)格式:
    我理解的是我們是用json 逞怨、protobuf還是xml。
    1.json:我們現(xiàn)在一般的公司有用到的因為它簡單福澡,直接將我們的數(shù)據(jù)轉(zhuǎn)換成json 在將 json轉(zhuǎn)換成data 完成我們數(shù)據(jù)的封裝叠赦。
    2.protobuf 相信很多人可能第一次聽說,它是谷歌寫的一套框架用來我們封裝數(shù)據(jù)的 里面定義的都是模型 除秀,用起來非常的方便,不好的地方就是安裝環(huán)境不是很好安裝算利。
    3.xml我沒用用過這種方式 缎患,如果有的公司用的話請查查資料吧 我這個不是很了解。
    上面這三種方式最好的是protobuf 因為它壓縮包的大小大概是json的10分之一蚂蕴,大概是xml的20分之一。所以我重點介紹的是protobuf.為什么數(shù)據(jù)包越小越好俯邓,比如我們經(jīng)常玩的王者榮耀骡楼,那里面的幾乎每一個操作都是socket通信,如果數(shù)據(jù)包比較大在網(wǎng)絡(luò)不是很通暢的情況下稽鞭,那人家本來不來不該死的是不是就死了鸟整,人家該放出技能的情況下 是不是有可能放不出來了。所以數(shù)據(jù)包小 很重要朦蕴。
  • protobuf :
    1.安裝:這里用的是cocoapod的方式安裝篮条,因為這個是最簡單的弟头,如果自己手動倒入絕對是缺爹少娘的。
    pod 'Protobuf', '~> 3.1.0'
    2.創(chuàng)建一個proto的文件 涉茧,最簡單的辦法是我們 用終端命令 赴恨,touch xx.proto.
    里面的內(nèi)容具體書寫:
syntax = "proto2";

message UserInfo {
required string name = 1;
required int64 level = 2;
}

message TextMessage {
required UserInfo user = 1;
required string text = 2;
}

message GiftMessage {
required UserInfo user = 1;
required string giftname = 2;
required string giftURL = 3;
required string giftCount = 4;
}

syntax = "proto2";好像說的是包名,這個我們不用糾結(jié)等下下面會說伴栓。
required 是必須要傳的參數(shù)伦连,如果這個不傳 會導(dǎo)致protobuf 沒有數(shù)據(jù)。
我們現(xiàn)在建立的protobuf的proto文件寫好了 下面將我們寫的proto文件倒入到我們的工程钳垮。順便說一下 我們不能這樣寫

message GiftMessage {
required UserInfo user = 1;
required string giftname = 1;
}

因為后面的那個數(shù)字是它的一個標示惑淳,標示不能重復(fù)。我給大家看一下我的proto文件的位置


image.png

到這一步是沒有這兩個文件的


image.png

下面我們用命令生成這兩個文件
首先我們用命令切換到我們proto所在的文件的位置 饺窿,比如我的文件的位置是:
image.png

這時候我們執(zhí)行這個命令:
protoc --objc_out=. *.proto
我們回到工程點擊我們的proto文件然后show in finder 會看到這時候生成兩個文件
將這兩個文件拖到我們的工程中 變成這個樣子:


image.png

我們編譯 發(fā)現(xiàn)會報錯歧焦,這是非arc的原因 我們只需要這樣操作 我用圖片來進行演示:
image.png

只時候進行編譯 發(fā)現(xiàn)成功了 我們這時候應(yīng)該默默的高興。
指的說明的是protobuf 安裝網(wǎng)上怎么說的都有 我也嘗試了 但是我不知道什么原因基本上都不能成功肚医,我現(xiàn)在這個是沒有問題的绢馍,大家可以試試。
下面我們就可以使用了忍宋,具體怎么使用:
這是一個protobuf付值并且轉(zhuǎn)換成data的例子痕貌。
GiftMessage *giftM = [[GiftMessage alloc] init];
    giftM.user = self.userInfo;
    giftM.giftname = giftName;
    giftM.giftURL = imageUrl;
    giftM.giftCount = giftCount;
    NSMutableData *needSendData = [self calculatorData:MessageTypeGift bodyData:giftM.data maxM:2];

將protobuf轉(zhuǎn)換成我們的模型

 UserInfo *userI = [UserInfo parseFromData:bodyData error:nil];

我們平時用到的基本就兩個方法,還有其他的使用就到github上看下文檔就可以了糠排。注意服務(wù)端和我們的客戶端都用一套protobuf也就是proto文件是一樣的舵稠,而且proto支持多言語 什么java 、php入宦、 oc哺徊、swift、android等等的都支持乾闰。

  • 協(xié)議:
    其實我們做一個聊天的功能最重要的就是協(xié)議的定義和我們的封包和解包落追。
    協(xié)議我們一般都是自定義的協(xié)議,這個協(xié)議是我們和我們的后端商量好的
    我們正規(guī)公司做的協(xié)議大概是這樣的
version:版本(一般4個字節(jié))
type:類型(有的公司用一個也有的公司用 maintype 和 subtype共同決定涯肩,一般4個字節(jié))
lengthData: 消息體的長度(一般4個字節(jié))
messageData:消息體
salt:加密的鹽轿钠,需要加密才用,不加密的可能不用 具體根據(jù)公司來定病苗。(一般4個字節(jié))

具體說明:
1.version的作用 一般來說 比如 我們的qq 假如新用戶升級了一個版本疗垛,但是老用戶還沒有升級到最新的版本,那么我們新用戶給老用戶發(fā)送消息的時候老用戶可能就解析不了(比如你公司的加密升級)所以需要version硫朦。
2.type:顧名思義就是 比如我們的文本 圖片 音頻 視頻等等的 用來做區(qū)分的.
3.消息體的長度:這個需要客戶端提前計算好贷腕,因為你丟過去一個數(shù)據(jù)包 服務(wù)器怎么解呢 它怎么知道那塊是消息體的長度呢 有的人說去掉前面的就是消息體 那么這個包是不完整的呢 比如 完整的包是1024個字節(jié) 實際山服務(wù)端收到的是800字節(jié) 服務(wù)端怎么知道這個是不是完整的呢。所以消息體的長度很重要.
4.salt 顧名思義 這個不在多說 具體有你公司而定.

  1. 我們按照順序?qū)⑽覀冞@些每一個組成一個data 然后進行拼接 最后將一個整個的data發(fā)送出去。注意順序不能錯 公司怎么定義你需要怎么拼接 否則服務(wù)端解析會報錯泽裳。
  • socket用什么框架 oc的話 我用的是CocoaAsyncSocket瞒斩,swift也有很多 我具體不說了,我用的oc 所以大家查一下就可以了涮总。
    說實話這個框架很簡單 具體怎么使用大家具體百度一下 我覺得太簡單了 所以我就不說了胸囱。
  • socket的封包:
    我覺得從現(xiàn)在以后的都很重要 ,何謂封包就是我們按照我們的協(xié)議將我們的數(shù)據(jù)封裝起來妹卿,將一個完整的包發(fā)給服務(wù)端旺矾。我下面說一下我自己簡單定的協(xié)議
lengthData:消息體的長度 占4個字節(jié)
type:類型 占2個字節(jié)
data :消息體

因為服務(wù)器是我自己寫的(網(wǎng)上查了一點資料) 我以我的電腦當作服務(wù)器
下面是我具體的封包的代碼,我拿文本消息進行舉例 代碼寫了很詳細的注釋

 GiftMessage *giftM = [[GiftMessage alloc] init];
    giftM.user = self.userInfo;
    giftM.giftname = giftName;
    giftM.giftURL = imageUrl;
    giftM.giftCount = giftCount;
    NSMutableData *needSendData = [self calculatorData:MessageTypeGift bodyData:giftM.data maxM:2];
    [self.socket writeData:needSendData withTimeout:TIME_OUT tag:MessageTypeGift];
/**
 這個方法是封包    組裝數(shù)據(jù)的格式是夺克。type + length + bodyData
 
 @param type 類型
 @param bodyData 要發(fā)送的數(shù)據(jù)
 @param maxM 最大的發(fā)送數(shù)據(jù)M數(shù)量
 @return 整理好的data
 */
- (NSMutableData *)calculatorData:(MessageType)type bodyData:(NSData *)bodyData maxM:(int)maxM{
    // 需要返回的data
    NSMutableData *needSendData = [[NSMutableData alloc] init];
    // 類型的data 固定2個字節(jié)
    int typeInt = (int)type;
    NSMutableData *typeData = [NSMutableData dataWithBytes:&typeInt length:2];
    // 發(fā)送數(shù)據(jù)的長度data 也是固定4個字節(jié)
    int lenth = (int)bodyData.length;
    NSMutableData *legthData = [NSMutableData dataWithBytes:&lenth length:4];
    // 進行拼接,注意順序不能錯
    [needSendData appendData:legthData];
    [needSendData appendData:typeData];
    [needSendData appendData:bodyData];
    return needSendData;
}

再次強調(diào)順序不能錯
順便說一下 :我們封裝的包是完整的 但是我們服務(wù)器收到的包不一定就是完整的為什么因為tcp/ip 協(xié)議是有優(yōu)化的算法的它可能會分批發(fā)送也有可能是發(fā)送一個整個數(shù)據(jù)包箕宙,這樣的話就會導(dǎo)致粘包 服務(wù)器發(fā)送給我們的也有可能是這種情況,下面我舉一下導(dǎo)致沾包的原因


image.png

假設(shè)有兩個數(shù)據(jù)包
1.發(fā)送a是完整的b不完整
2.假設(shè)發(fā)送a是不完整的铺纽,b是完整的
3.假設(shè)a柬帕、b都是完整的
等等還有其他的情況 我大致就是據(jù)這個三個例子
其實第三個情況是沒有問題的,1狡门、2 等的不完整的數(shù)據(jù)包就會導(dǎo)致沾包問題

  • socket的解包陷寝,下面是我的解包(很重要 ,解包不了導(dǎo)致直接數(shù)據(jù)解析失斊淞蟆)
/**
 拆除包  防止沾包

 @param data data
 @param sock sock
 */
- (void)parseData:(NSData *)data socket:(GCDAsyncSocket *)sock{
    // 首先付給要處理的data
    [self.cacheParseData appendData:data];
    // 找到我們當初存儲的長度
    NSData *lengthData = [self.cacheParseData subdataWithRange:NSMakeRange(0, 4)];
    int shouldLength = 0;
    [lengthData getBytes:&shouldLength length:4];
    shouldLength += 6;
    while (self.cacheParseData.length > 6) {
        if (shouldLength > self.cacheParseData.length) { // 說明這個包是不完整的
            [sock readDataWithTimeout:TIME_OUT tag:0];
            break;
        }else{  // 說明這個包至少是大于等于一個完整包的長度
            NSData *needParseData = [self.cacheParseData subdataWithRange:NSMakeRange(0, shouldLength)];
            // 在這里開始正式解決這個包
            [self parseData:needParseData dataLength:shouldLength - 6];
            [self.cacheParseData replaceBytesInRange:NSMakeRange(0, shouldLength) withBytes:nil length:0];
            [sock readDataWithTimeout:TIME_OUT tag:0];
        }
    }
}
/**
 進入到這個方法說明是一個完整的包凤跑,并且是能夠解析的

 @param data data
 @param shouldHaveLength 消息的長度
 */
- (void)parseData:(NSData *)data dataLength:(int)shouldHaveLength{
    
    // 類型
    NSData *typeData = [data subdataWithRange:NSMakeRange(4, 2)];
    int type = 0;
    [typeData getBytes:&type length:2];
    // 消息體
    NSData *bodyData = [data subdataWithRange:NSMakeRange(6, shouldHaveLength)];
    switch (type) {
        case MessageTypeHeart:
        {
            LDGLog(@"心跳包的消息");
        }
            break;
        case MessageTypeJoinRoom:
        {
            UserInfo *userI = [UserInfo parseFromData:bodyData error:nil];
            if ([self.toolDelegate respondsToSelector:@selector(haveJoinRoom:)]) {
                [self.toolDelegate haveJoinRoom:userI];
            }
        }
            break;
        case MessageTypeLeaveRoom:
        {
            UserInfo *userI = [UserInfo parseFromData:bodyData error:nil];
            if ([self.toolDelegate respondsToSelector:@selector(haveLeaveRoom:)]) {
                [self.toolDelegate haveLeaveRoom:userI];
            }
        }
            break;
        case MessageTypeText:
        {
            TextMessage *textM = [TextMessage parseFromData:bodyData error:nil];
            if ([self.toolDelegate respondsToSelector:@selector(haveAcceptTextMessage:)]) {
                [self.toolDelegate haveAcceptTextMessage:textM];
            }
        }
            break;
        case MessageTypeGift:
        {
            GiftMessage *giftM = [GiftMessage parseFromData:bodyData error:nil];
            if ([self.toolDelegate respondsToSelector:@selector(haveAcceptGiftMessage:)]) {
                [self.toolDelegate haveAcceptGiftMessage:giftM];
            }
        }
            break;
        default:
        {
            LDGLog(@"不知道是啥子消息");
        }
            break;
    }
}
  • 至此 我們socket的簡單的封包和解包就做完了,我們基本有一點基礎(chǔ)了叛复,但是我們現(xiàn)在做的遠遠不夠仔引,因為有的公司這個需求 就是我要求你客戶端每一次發(fā)送的包不能大于2M?我們該怎么辦?還有我們做socket 聽過心跳包 那個是怎么回事褐奥?好 下面我會針對不同的問題說幾點注意點咖耘。我覺得很重要。
  • 個人覺得非常重要的幾點說明:
    1.假如公司要求你每次發(fā)送的數(shù)據(jù)包不能大于2M,我覺得我們在封裝包的情況下應(yīng)該這樣寫撬码,因為我屬于自己寫的服務(wù)器 儿倒,沒有那么復(fù)雜,我下面的代碼我沒有驗證呜笑,但是我覺得大致思路是對的夫否。
/**
 這個方法是封包    組裝數(shù)據(jù)的格式是。type + length + bodyData
 
 @param type 類型
 @param bodyData 要發(fā)送的數(shù)據(jù)
 @param maxM 最大的發(fā)送數(shù)據(jù)M數(shù)量
 @return 整理好的data
 */
- (NSMutableData *)calculatorData:(MessageType)type bodyData:(NSData *)bodyData maxM:(int)maxM{
    // 需要返回的data
    NSMutableData *needSendData = [[NSMutableData alloc] init];
    // 類型的data 固定2個字節(jié)
    int typeInt = (int)type;
    NSMutableData *typeData = [NSMutableData dataWithBytes:&typeInt length:2];
    // 發(fā)送數(shù)據(jù)的長度data 也是固定4個字節(jié)
    int lenth = (int)bodyData.length;
    NSMutableData *legthData = [NSMutableData dataWithBytes:&lenth length:4];
    // 進行拼接,注意順序不能錯
    [needSendData appendData:legthData];
    [needSendData appendData:typeData];
    [needSendData appendData:bodyData];
    
    if (needSendData.length > 2 * 1024 *1024) { // 說明數(shù)據(jù)包大于2M
        // 計算countNumber是一個小算法叫胁,就是計算我們的數(shù)據(jù)包有(2*1024*1024)慷吊,最后一個不足也加1
        NSInteger countNumber = (needSendData.length - 1)/ (2*1024*1024) + 1;
        for (NSInteger index = 0; index < countNumber; index ++) {
            NSData *perData = [[NSData alloc] init];
            if (index == countNumber -1 ) {
                perData = [needSendData subdataWithRange:NSMakeRange(index *(2*1024*1024) , needSendData.length - index *(2*1024*1024))];
            }else{
                perData = [needSendData subdataWithRange:NSMakeRange(index *(2*1024*1024) , 2*1024*1024)];
            }
            [self.sectionArray addObject:perData];
        }
    }
    return needSendData;
}
/**
 發(fā)送禮物的消息
 
 @param imageUrl 圖片的url
 @param giftName 圖片的名字
 @param giftCount 禮物的數(shù)量
 */
- (void)sendGiftMessage:(NSString *)imageUrl giftName:(NSString *)giftName giftCount:(NSString *)giftCount{
    
    GiftMessage *giftM = [[GiftMessage alloc] init];
    giftM.user = self.userInfo;
    giftM.giftname = giftName;
    giftM.giftURL = imageUrl;
    giftM.giftCount = giftCount;
    NSMutableData *needSendData = [self calculatorData:MessageTypeGift bodyData:giftM.data maxM:2];
    if (self.sectionArray.count) {
        for (NSData *data in self.sectionArray) {
           [self.socket writeData:data withTimeout:TIME_OUT tag:MessageTypeGift];
        }
        [self.sectionArray removeAllObjects];
    }else{
     [self.socket writeData:needSendData withTimeout:TIME_OUT tag:MessageTypeGift];
    }
}
  • 解釋疑問:我開始可能會產(chǎn)生這樣的一個疑問,我現(xiàn)在是把一個完整的包分開了 那會不會出現(xiàn)這樣的一個問題呢 會不會導(dǎo)致我把一個漢字或者一個字母分成兩半了呢曹抬,后來我想了一下不會 因為我們在解包的情況下 我判斷只有完整的包才會解析,所以在界面顯示的時候不會出現(xiàn)。
    2.什么心跳包谤民,心跳包有什么作用堰酿?
    心跳包就是在間隔相同的時間內(nèi),客戶端像服務(wù)端發(fā)送你們規(guī)定好的一個數(shù)據(jù)包张足,用來判斷客戶端與服務(wù)端一直保持鏈接的触创。下面是我寫的心跳包,我在子線程開辟了一個定時器为牍。
/**
 * Called when a socket connects and is ready for reading and writing.
 * The host parameter will be an IP address, not a DNS name.
 **/
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port{
    LDGLog(@"說明已經(jīng)鏈接成功了");
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        if (!self.heartTimer) {
            self.heartTimer = [NSTimer timerWithTimeInterval:HEART_TIME target:self selector:@selector(heartAction) userInfo:nil repeats:YES];
            [[NSRunLoop currentRunLoop] addTimer:self.heartTimer forMode:NSDefaultRunLoopMode];
            [[NSRunLoop currentRunLoop] run];
        }
    });
    [sock readDataWithTimeout:TIME_OUT tag:0];
}
/**
 心跳包的事件  為了保持客戶端和服務(wù)端長期的鏈接
 */
-(void)heartAction{
    NSData *heartData = [@"my heart is very bad" dataUsingEncoding:NSUTF8StringEncoding];
    NSMutableData *needSendData = [self calculatorData:MessageTypeHeart bodyData:heartData maxM:2];
    [self.socket writeData:needSendData withTimeout:TIME_OUT tag:MessageTypeHeart];
}

解釋:其中為什么在[[NSRunLoop currentRunLoop] run];而沒有用[NSTimer scheduledTimerWithTimeInterval:<#(NSTimeInterval)#> target:<#(nonnull id)#> selector:<#(nonnull SEL)#> userInfo:<#(nullable id)#> repeats:<#(BOOL)#>]這是runloop相關(guān)的知識哼绑,我們不細說因為 runloop在子線程默認是不開啟的 在主線程默認是開啟的。順便說一句 每個心跳包的時間是不一樣的碉咆,一般根據(jù)公司規(guī)定抖韩, 一般來說10秒、20秒 疫铜、30秒茂浮。心跳包能接收到消息說明是鏈接著的。反之說明斷開鏈接壳咕。
3.說明一下 一般而言 根本不會寫服務(wù)器的代碼席揽,我也是在網(wǎng)上找的 自己修改了一丟丟,那我們?nèi)绻粫懛?wù)器代碼谓厘,我們自己做socket項目怎么演示呢幌羞,我想到一個不算太好的辦法,可以驗證我們解析包的時候是否解析出來


image.png
  1. CocoaAsyncSocket大致說一下:
    4.1)#import "GCDAsyncSocket.h"
    4.2)鏈接主機和端口號竟稳,端口號不能小于等于1024 因為這些端口都被系統(tǒng)或者什么的占領(lǐng)了属桦,為什么還需要端口號 因為一個ip地址下可以有多個服務(wù)器,怎樣找到我們的那臺服務(wù)器就是需要端口號住练。端口號和ip地址確定唯一的服務(wù)器地啰。
 self.socket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)];
 [self.socket connectToHost:@"192.168.100.193" onPort:7878 error:nil];

4.3)已經(jīng)鏈接成功的delegate回調(diào)

/**
 * Called when a socket connects and is ready for reading and writing.
 * The host parameter will be an IP address, not a DNS name.
 **/
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port

4.3)已經(jīng)讀取成功的回調(diào)

/**
 * Called when a socket has completed reading the requested data into memory.
 * Not called if there is an error.
 **/
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag

4.4)斷開鏈接的回調(diào) 被動斷開

/**
 已經(jīng)斷開鏈接了 被動斷開鏈接

 @param sock socket
 @param err 錯誤信息
 */
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(nullable NSError *)err

4.5) 客戶端主動斷開鏈接

[sock disconnect]

4.6)鏈接主機成功了

/**
 * Called when a socket connects and is ready for reading and writing.
 * The host parameter will be an IP address, not a DNS name.
 **/
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port

大致就是這些常用的 還有需要的 我們看一下文檔就可以了說的很清楚,大家肯定也會讲逛。

  1. 如果公司用的json 封裝數(shù)據(jù)的怎么辦亏吝,我在網(wǎng)上找了一段很好的代碼,我直接粘貼了盏混,大家看一下就很明白了 很簡單
NSMutableDictionary *dictTemp = [NSMutableDictionary dictionary];
    dictTemp[@"username"]         = @"LD";
    
    //先創(chuàng)建模型 --> 轉(zhuǎn)Json -->轉(zhuǎn)字符串
    TestModel *model = [TestModel new];
    model.type       = 1;
    model.userName   = @"LD";
    model.age        = @"18";
    model.message    = @"Hellow";
    model.Content    = dictTemp;
    
    //先將模型轉(zhuǎn)換成Json格式的數(shù)據(jù)這里根據(jù)自己項目情況來看是否需要轉(zhuǎn)成Json格式  使用到了MJExtension蔚鸥,
    NSString * strJson  = [[NSString alloc] initWithData :model.mj_JSONData encoding :NSUTF8StringEncoding];
    Cs_Connect *connect = [Cs_Connect new];
    connect.serverID    = 1;
    connect.message     = strJson;
    connect.length      = (int)connect.message.length;

    //將數(shù)據(jù)傳換成二進制數(shù)據(jù),轉(zhuǎn)換之后的數(shù)據(jù)和協(xié)議順序是一致的(為什么不需要調(diào)整順序我也不知道,有興趣的的同學(xué)自己去研究下這個方法)
    NSMutableData *dataModel =  [socket RequestSpliceAttribute:connect];
    
    // 通過Socket發(fā)出去
    [socket sendMessage:dataModel];
//  將模型數(shù)據(jù)轉(zhuǎn)換成二進制數(shù)據(jù)
-(NSMutableData *)RequestSpliceAttribute:(id)obj{

    _data = nil;//記得清空不然數(shù)據(jù)包會越來越大
    if (obj == nil) {
        self.object = self.data;
        
        NSLog(@"傳入需轉(zhuǎn)二進制的數(shù)據(jù)為空");
        return nil;
     }
    unsigned int numIvars; //成員變量個數(shù)
    objc_property_t *propertys = class_copyPropertyList(NSClassFromString([NSString stringWithUTF8String:object_getClassName(obj)]), &numIvars);
    NSString *type = nil;
    NSString *name = nil;
    
    for (int i = 0; i < numIvars; i++) {
        objc_property_t thisProperty = propertys[i];
        
        name = [NSString stringWithUTF8String:property_getName(thisProperty)];
//                NSLog(@"%d.name:%@",i,name);
        type = [[[NSString stringWithUTF8String:property_getAttributes(thisProperty)] componentsSeparatedByString:@","] objectAtIndex:0]; //獲取成員變量的數(shù)據(jù)類型
//                NSLog(@"%d.type:%@",i,type);
        id propertyValue = [obj valueForKey:[(NSString *)name substringFromIndex:0]];
//                NSLog(@"%d.propertyValue:%@",i,propertyValue);
        
        if ([type isEqualToString:TYPE_UINT8]) {
            uint8_t i = [propertyValue charValue];// 8位
            [self.data appendData:[DLSocketDataUtils byteFromUInt8:i]];
        }else if([type isEqualToString:TYPE_UINT16]){
            uint16_t i = [propertyValue shortValue];// 16位
            [self.data appendData:[DLSocketDataUtils bytesFromUInt16:i]];
        }else if([type isEqualToString:TYPE_UINT32]){
            uint32_t i = [propertyValue intValue];// 32位
            [self.data appendData:[DLSocketDataUtils bytesFromUInt32:i]];
        }else if([type isEqualToString:TYPE_UINT64]){
            uint64_t i = [propertyValue longLongValue];// 64位
            [self.data appendData:[DLSocketDataUtils bytesFromUInt64:i]];
        }else if([type isEqualToString:TYPE_STRING]){
            NSData *data = [(NSString*)propertyValue \
                            dataUsingEncoding:NSUTF8StringEncoding];// 通過utf-8轉(zhuǎn)為data
            [self.data appendData:data];
            
        }else {
            NSLog(@"RequestSpliceAttribute:未知類型");
            NSAssert(YES, @"RequestSpliceAttribute:未知類型");
        }
    }
    
    // hy: 記得釋放C語言的結(jié)構(gòu)體指針
    free(propertys);
    self.object = _data;
    return _data;
}
  • 解釋:其中作者有一段 為什么不需要調(diào)整順序我也不知道许赃,有興趣的的同學(xué)自己去研究下這個方法這個寫的那個人不明白 止喷,其實很簡單,是不是運行時Cs_Connect這個模型的順序是一定的 所以順序不會錯對吧 很簡單的混聊。
    6 .這是我封裝消息轉(zhuǎn)發(fā)的工具類
LDGMessageTransformTool.h 文件
//
//  LDGMessageTransformTool.h
//  ZhiBo
//
//  Created by apple on 2018/5/23.
//  Copyright ? 2018年 apple. All rights reserved.
//

#import <Foundation/Foundation.h>
#import "GCDAsyncSocket.h"

typedef NS_ENUM(NSUInteger,MessageType){
    MessageTypeJoinRoom   = 0,
    MessageTypeLeaveRoom  = 1,
    MessageTypeText       = 2,
    MessageTypeGift       = 3,
    MessageTypeHeart      = 100
};

#define TIME_OUT 20
#define HEART_TIME 10
@protocol LDGMessageTransformToolDelegate<NSObject>


/**
  已經(jīng)接收到進入到 房間消息了

 @param userI userI
 */
- (void)haveJoinRoom:(UserInfo *)userI;

/**
 已經(jīng)接收到推出到 房間消息了

 @param userI userI
 */
- (void)haveLeaveRoom:(UserInfo *)userI;
/**
 已經(jīng)接收到收到禮物的消息了

 @param giftM giftM
 */
- (void)haveAcceptGiftMessage:(GiftMessage *)giftM;

/**
 已經(jīng)接受到文本消息了弹谁。大師兄

 @param textM 文本消息的model
 */
- (void)haveAcceptTextMessage:(TextMessage *)textM;
@end

@interface LDGMessageTransformTool : NSObject

@property (weak, nonatomic) id<LDGMessageTransformToolDelegate> toolDelegate;
/**
 進入房間的消息是  : 0
 離開房間的消息是  : 1
 發(fā)送文本消息是   : 2
 發(fā)送禮物消息是   : 3
 */
/**
 鏈接上服務(wù)器
 */
- (instancetype)initWithConnectServer;
/**
 進入房間
 */
- (void)joinRoom;
/**
 離開房間
 */
- (void)leaveRoom;
/**
 發(fā)送文本消息

 @param text text
 */
- (void)sendTextMessage:(NSString *)text;

/**
 發(fā)送禮物的消息

 @param imageUrl 圖片的url
 @param giftName 圖片的名字
 @param giftCount 禮物的數(shù)量
 */
- (void)sendGiftMessage:(NSString *)imageUrl giftName:(NSString *)giftName giftCount:(NSString *)giftCount;

@end
LDGMessageTransformTool.m 文件
//
//  LDGMessageTransformTool.m
//  ZhiBo
//
//  Created by apple on 2018/5/23.
//  Copyright ? 2018年 apple. All rights reserved.
//

#import "LDGMessageTransformTool.h"

@interface LDGMessageTransformTool ()<GCDAsyncSocketDelegate>

@property (strong, nonatomic) GCDAsyncSocket *socket;
@property (strong, nonatomic) UserInfo *userInfo;
@property (strong, nonatomic) NSTimer *heartTimer;
@property (strong, nonatomic) NSMutableData *cacheParseData;



@end

@implementation LDGMessageTransformTool
-(NSMutableData *)cacheParseData {
    if (!_cacheParseData) {
        _cacheParseData = [[NSMutableData alloc] init];
    }
    return _cacheParseData;
}

/**
 鏈接上服務(wù)器,創(chuàng)建
 */
- (instancetype)initWithConnectServer{
    if (self = [super init]) {
        UserInfo *userInfo = [[UserInfo alloc] init];
        userInfo.name = @"liudiange";
        userInfo.level = 1;
        self.userInfo = userInfo;
        self.socket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)];
        [self.socket connectToHost:@"192.168.100.193" onPort:7878 error:nil];
    }
    return self;
}

#pragma mark - 發(fā)送消息的方法
/**
 進入房間的消息是 : 0
 離開房間的消息是 : 1
 發(fā)送文本消息是 : 2
 發(fā)送禮物消息是 : 3
 */
/**
 進入房間
 */
- (void)joinRoom{
    
    NSMutableData *needSendData = [self calculatorData:MessageTypeJoinRoom bodyData:self.userInfo.data maxM:2];
    // 沒有超時時間 -1 代表沒有超時時間
    [self.socket writeData:needSendData withTimeout:TIME_OUT tag:MessageTypeJoinRoom];
}
/**
 離開房間
 */
- (void)leaveRoom {
    
    NSMutableData *needSendData = [self calculatorData:MessageTypeLeaveRoom bodyData:self.userInfo.data maxM:2];
    
    // 沒有超時時間 -1 代表沒有超時時間
    [self.socket writeData:needSendData withTimeout:TIME_OUT tag:MessageTypeLeaveRoom];
    
}
/**
 發(fā)送文本消息
 
 @param text text
 */
- (void)sendTextMessage:(NSString *)text{
    
    TextMessage *textM = [[TextMessage alloc] init];
    textM.user = self.userInfo;
    textM.text = text;
    NSMutableData *needSendData = [self calculatorData:MessageTypeText bodyData:textM.data maxM:2];
    [self.socket writeData:needSendData withTimeout:TIME_OUT tag:MessageTypeText];
    
//    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
//        [self parseData:needSendData socket:self.socket];
//    });
}

/**
 發(fā)送禮物的消息
 
 @param imageUrl 圖片的url
 @param giftName 圖片的名字
 @param giftCount 禮物的數(shù)量
 */
- (void)sendGiftMessage:(NSString *)imageUrl giftName:(NSString *)giftName giftCount:(NSString *)giftCount{
    
    GiftMessage *giftM = [[GiftMessage alloc] init];
    giftM.user = self.userInfo;
    giftM.giftname = giftName;
    giftM.giftURL = imageUrl;
    giftM.giftCount = giftCount;
    NSMutableData *needSendData = [self calculatorData:MessageTypeGift bodyData:giftM.data maxM:2];
    [self.socket writeData:needSendData withTimeout:TIME_OUT tag:MessageTypeGift];

}

/**
 這個方法是封包    組裝數(shù)據(jù)的格式是。type + length + bodyData
 
 @param type 類型
 @param bodyData 要發(fā)送的數(shù)據(jù)
 @param maxM 最大的發(fā)送數(shù)據(jù)M數(shù)量
 @return 整理好的data
 */
- (NSMutableData *)calculatorData:(MessageType)type bodyData:(NSData *)bodyData maxM:(int)maxM{
    // 需要返回的data
    NSMutableData *needSendData = [[NSMutableData alloc] init];
    // 類型的data 固定2個字節(jié)
    int typeInt = (int)type;
    NSMutableData *typeData = [NSMutableData dataWithBytes:&typeInt length:2];
    // 發(fā)送數(shù)據(jù)的長度data 也是固定4個字節(jié)
    int lenth = (int)bodyData.length;
    NSMutableData *legthData = [NSMutableData dataWithBytes:&lenth length:4];
    // 進行拼接,注意順序不能錯
    [needSendData appendData:legthData];
    [needSendData appendData:typeData];
    [needSendData appendData:bodyData];
    return needSendData;
}
/**
 心跳包的事件  為了保持客戶端和服務(wù)端長期的鏈接
 */
-(void)heartAction{
    NSData *heartData = [@"my heart is very bad" dataUsingEncoding:NSUTF8StringEncoding];
    NSMutableData *needSendData = [self calculatorData:MessageTypeHeart bodyData:heartData maxM:2];
    [self.socket writeData:needSendData withTimeout:TIME_OUT tag:MessageTypeHeart];
}
#pragma mark - 接受到消息的方法
/**
 * Called when a socket has completed writing the requested data. Not called if there is an error.
 **/
- (void)socket:(GCDAsyncSocket *)sock didWriteDataWithTag:(long)tag{
    
    [sock readDataWithTimeout:TIME_OUT tag:tag];
}
/**
 * Called when a socket connects and is ready for reading and writing.
 * The host parameter will be an IP address, not a DNS name.
 **/
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port{
    LDGLog(@"說明已經(jīng)鏈接成功了");
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        if (!self.heartTimer) {
            self.heartTimer = [NSTimer timerWithTimeInterval:HEART_TIME target:self selector:@selector(heartAction) userInfo:nil repeats:YES];
            [[NSRunLoop currentRunLoop] addTimer:self.heartTimer forMode:NSDefaultRunLoopMode];
            [[NSRunLoop currentRunLoop] run];
        }
    });
    [sock readDataWithTimeout:TIME_OUT tag:0];
}
/**
 * Called when a socket has completed reading the requested data into memory.
 * Not called if there is an error.
 **/
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag{
    
    [self parseData:data socket:sock];
}

/**
 已經(jīng)斷開鏈接了 被動斷開鏈接

 @param sock socket
 @param err 錯誤信息
 */
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(nullable NSError *)err{
    if (self.heartTimer) {
        [self.heartTimer invalidate];
        self.heartTimer = nil;
    }
}
/**
 拆除包  防止沾包

 @param data data
 @param sock sock
 */
- (void)parseData:(NSData *)data socket:(GCDAsyncSocket *)sock{
    // 首先付給要處理的data
    [self.cacheParseData appendData:data];
    // 找到我們當初存儲的長度
    NSData *lengthData = [self.cacheParseData subdataWithRange:NSMakeRange(0, 4)];
    int shouldLength = 0;
    [lengthData getBytes:&shouldLength length:4];
    shouldLength += 6;
    while (self.cacheParseData.length > 6) {
        if (shouldLength > self.cacheParseData.length) { // 說明這個包是不完整的
            [sock readDataWithTimeout:TIME_OUT tag:0];
            break;
        }else{  // 說明這個包至少是大于等于一個完整包的長度
            NSData *needParseData = [self.cacheParseData subdataWithRange:NSMakeRange(0, shouldLength)];
            // 在這里開始正式解決這個包
            [self parseData:needParseData dataLength:shouldLength - 6];
            [self.cacheParseData replaceBytesInRange:NSMakeRange(0, shouldLength) withBytes:nil length:0];
            [sock readDataWithTimeout:TIME_OUT tag:0];
        }
    }
}

/**
 進入到這個方法說明是一個完整的包预愤,并且是能夠解析的

 @param data data
 @param shouldHaveLength 消息的長度
 */
- (void)parseData:(NSData *)data dataLength:(int)shouldHaveLength{
    
    // 類型
    NSData *typeData = [data subdataWithRange:NSMakeRange(4, 2)];
    int type = 0;
    [typeData getBytes:&type length:2];
    // 消息體
    NSData *bodyData = [data subdataWithRange:NSMakeRange(6, shouldHaveLength)];
    switch (type) {
        case MessageTypeHeart:
        {
            LDGLog(@"心跳包的消息");
        }
            break;
        case MessageTypeJoinRoom:
        {
            UserInfo *userI = [UserInfo parseFromData:bodyData error:nil];
            if ([self.toolDelegate respondsToSelector:@selector(haveJoinRoom:)]) {
                [self.toolDelegate haveJoinRoom:userI];
            }
        }
            break;
        case MessageTypeLeaveRoom:
        {
            UserInfo *userI = [UserInfo parseFromData:bodyData error:nil];
            if ([self.toolDelegate respondsToSelector:@selector(haveLeaveRoom:)]) {
                [self.toolDelegate haveLeaveRoom:userI];
            }
        }
            break;
        case MessageTypeText:
        {
            TextMessage *textM = [TextMessage parseFromData:bodyData error:nil];
            if ([self.toolDelegate respondsToSelector:@selector(haveAcceptTextMessage:)]) {
                [self.toolDelegate haveAcceptTextMessage:textM];
            }
        }
            break;
        case MessageTypeGift:
        {
            GiftMessage *giftM = [GiftMessage parseFromData:bodyData error:nil];
            if ([self.toolDelegate respondsToSelector:@selector(haveAcceptGiftMessage:)]) {
                [self.toolDelegate haveAcceptGiftMessage:giftM];
            }
        }
            break;
        default:
        {
            LDGLog(@"不知道是啥子消息");
        }
            break;
    }
}
@end
  • 最后我目前總結(jié)大概就是這么多沟于,其實到公司用到應(yīng)該比這還多,那時候我們只能現(xiàn)場發(fā)揮了見招拆招了植康,我寫到這的時候真的很興奮旷太,因為研究出來有成果我們正常人都會感到高興,還有我以后會更加深入的研究socket 销睁,我們作為一個程序員不能滿足現(xiàn)狀供璧,因為知識是無止境的。
  • 最后為我寫的一個下載的框架做一個小小的宣傳冻记,大家覺得我寫的還行的話給個星睡毒。
    https://github.com/liudiange/DGDownloadManager
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市檩赢,隨后出現(xiàn)的幾起案子吕嘀,更是在濱河造成了極大的恐慌,老刑警劉巖贞瞒,帶你破解...
    沈念sama閱讀 219,427評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件偶房,死亡現(xiàn)場離奇詭異,居然都是意外死亡军浆,警方通過查閱死者的電腦和手機棕洋,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,551評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來乒融,“玉大人掰盘,你說我怎么就攤上這事≡藜荆” “怎么了愧捕?”我有些...
    開封第一講書人閱讀 165,747評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長申钩。 經(jīng)常有香客問我次绘,道長,這世上最難降的妖魔是什么撒遣? 我笑而不...
    開封第一講書人閱讀 58,939評論 1 295
  • 正文 為了忘掉前任邮偎,我火速辦了婚禮,結(jié)果婚禮上义黎,老公的妹妹穿的比我還像新娘禾进。我一直安慰自己,他們只是感情好廉涕,可當我...
    茶點故事閱讀 67,955評論 6 392
  • 文/花漫 我一把揭開白布泻云。 她就那樣靜靜地躺著艇拍,像睡著了一般。 火紅的嫁衣襯著肌膚如雪宠纯。 梳的紋絲不亂的頭發(fā)上淑倾,一...
    開封第一講書人閱讀 51,737評論 1 305
  • 那天,我揣著相機與錄音征椒,去河邊找鬼。 笑死湃累,一個胖子當著我的面吹牛勃救,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播治力,決...
    沈念sama閱讀 40,448評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼蒙秒,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了宵统?” 一聲冷哼從身側(cè)響起晕讲,我...
    開封第一講書人閱讀 39,352評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎马澈,沒想到半個月后瓢省,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,834評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡痊班,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,992評論 3 338
  • 正文 我和宋清朗相戀三年勤婚,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片涤伐。...
    茶點故事閱讀 40,133評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡馒胆,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出凝果,到底是詐尸還是另有隱情祝迂,我是刑警寧澤,帶...
    沈念sama閱讀 35,815評論 5 346
  • 正文 年R本政府宣布器净,位于F島的核電站型雳,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏掌动。R本人自食惡果不足惜四啰,卻給世界環(huán)境...
    茶點故事閱讀 41,477評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望粗恢。 院中可真熱鬧柑晒,春花似錦、人聲如沸眷射。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,022評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至涌庭,卻和暖如春芥被,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背坐榆。 一陣腳步聲響...
    開封第一講書人閱讀 33,147評論 1 272
  • 我被黑心中介騙來泰國打工拴魄, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人席镀。 一個月前我還...
    沈念sama閱讀 48,398評論 3 373
  • 正文 我出身青樓匹中,卻偏偏與公主長得像,于是被迫代替她去往敵國和親豪诲。 傳聞我的和親對象是個殘疾皇子顶捷,可洞房花燭夜當晚...
    茶點故事閱讀 45,077評論 2 355

推薦閱讀更多精彩內(nèi)容

  • 前言 本文會用實例的方式,將iOS各種IM的方案都簡單的實現(xiàn)一遍屎篱。并且提供一些選型服赎、實現(xiàn)細節(jié)以及優(yōu)化的建議。 注:...
    maTianHong閱讀 2,375評論 4 12
  • 簡介 用簡單的話來定義tcpdump交播,就是:dump the traffic on a network重虑,根據(jù)使用者...
    保川閱讀 5,957評論 1 13
  • 去年有段時間得空,就把谷歌GAE的API權(quán)威指南看了一遍堪侯,收獲頗豐嚎尤,特別是在自己幾乎獨立開發(fā)了公司的云數(shù)據(jù)中心之后...
    騎單車的勛爵閱讀 20,537評論 0 41
  • 決定做免費的塔羅占卜,讓大家來體驗和感受神秘的力量伍宦。目前看芽死,我給大家占卜,選用最適合的經(jīng)典牌陣次洼,都是7关贵、8張牌,一...
    Molly郭兒閱讀 209評論 3 1
  • 時間的飛逝炭剪,讓人無可奈何。在歲月靜好翔脱,青春如歌的階段奴拦。我們在安靜歲月,尋找內(nèi)心的寄托届吁,安慰错妖。高考是人生中重要的轉(zhuǎn)...
    葉檸檬閱讀 272評論 0 0