目錄
- 前言
- TCP通道的建立
- 自定義應用層協議
- 請求體
- 響應體
- 請求和響應的序列化
- 序列化器
- 請求的序列化
- 響應的序列化
- 任務機制
- KTTCPSocketTask
- 任務超時
- 管理器
- KTTCPSocketManager
- 請求的發送
- 響應的接收
- 將響應派發給對應任務
- Demo
- 參考資料
前言
本文的起因是希望像《美團點評移動網絡優化實踐》中的方案一樣、建設一個可以將HTTP請求轉化成二進制數據包、並且在自建的TCP長連接通道上傳輸。當然、直接TCP雙向通訊也是沒有問題的。
以前用的Websocket
、簡單粗暴。如果你只想要一個全雙工的TCP長連接、Websocket
作爲和HTTP
一樣的應用層協議
完全夠用。
但本文主要是嘗試自己用socket
(雖然並不是完全原生)構建一個能夠像HTTP請求一樣使用的TCP通道
。並且最終、將HTTP請求放在自建的TCP加密通道上傳輸。
關於網絡層一些基礎知識、或許《當被尬聊網絡協議、我們可以侃點什麼?》可以幫到你。
自己對Socket通道的建設一開始也不太懂、所以有很多地方借鑑了《一步一步構建你的iOS網絡層 - TCP篇》的思路。十分感謝
TCP通道的建立
首先、我們需要一個類似
websocket
的應用層協議。
參照SRWebSocket來看、除了全雙工通信之外。我們還需要處理心跳
、重連
、粘包
這三個特殊的概念(SSL在CocoaAsyncSocket下已經封裝了實現
)。
此外。由於原生socket比較麻煩、所以藉助了一個開源框架CocoaAsyncSocket來操作scoket(類似
NSLayoutConstraint
與Masonry
的關係)。具體使用的是基於GCD的GCDAsyncSocket
(似乎以前還有個基於Runloop的)。AsyncSocket
、但是我用的時候已經沒有了。大概和NSURLCollection被NSURLSession淘汰了一樣
CocoaAsyncSocket初始狀態下就具備連接、斷開、發送以及讀取等基本功能。
這裏主要對CocoaAsyncSocket添加了重連、專屬線程等易用性的封裝、並且將scoket事件通過代理進行回調。
頭文件
@class KTTCPSocket;
@protocol KTTCPSocketDelegate <NSObject>
@optional
/**
鏈接成功
@param sock KTTCPSocket
@param host 主機
@param port 端口
*/
- (void)socket:(KTTCPSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port;
/**
最終鏈接失敗
連接失敗 + N次重連失敗
@param sock KTTCPSocket
*/
- (void)socketCanNotConnectToService:(KTTCPSocket *)sock;
/**
鏈接失敗並重連
@param sock KTTCPSocket
@param error error
*/
- (void)socketDidDisconnect:(KTTCPSocket *)sock error:(NSError *)error;
/**
接收到了數據
@param sock KTTCPSocket
@param data 二進制數據
*/
- (void)socket:(KTTCPSocket *)sock didReadData:(NSData *)data;
@end
/**
對GCDAsyncSocket進行封裝的工具類。
具備自動重連、讀寫數據等基礎操作
*/
@interface KTTCPSocket : NSObject
@property (nonatomic,readonly) NSString *host;//主機
@property (nonatomic,readonly) uint16_t port;//端口
@property (nonatomic) NSUInteger maxRetryCount;//重連次數
@property (nonatomic, weak) id<KTTCPSocketDelegate> delegate;
- (instancetype)init NS_UNAVAILABLE;
/**
構造方法
@param host 主機號
@param port 端口號
@return KTTCPSocket實例
*/
- (instancetype)initSocketWithHost:(NSString *)host port:(uint16_t)port NS_DESIGNATED_INITIALIZER;
/**
關閉連接--注意關閉之後就沒辦法再次開啓了。不然沒辦法判斷socke對象該何時銷燬
*/
- (void)close;
/**
連接
*/
- (void)connect;
/**
重連並且重置次數
*/
- (void)reconnect;
/**
鏈接狀態
@return 是否已經鏈接
*/
- (BOOL)isConnected;
/**
寫入數據
@param data 二進制數據
*/
- (void)writeData:(NSData *)data;
@end
業務代碼
-
寫入數據
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
由於TCP面向字節流、所以並不需要我們調用發送
之類的方法、他會按照順序一個字節一個字節的把數據進行傳輸。
-
讀取數據
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
if ([self.delegate respondsToSelector:@selector(socket:didReadData:)]) {
[self.delegate socket:self didReadData:data];
}
[self.socket readDataWithTimeout:-1 tag:socketTag];
}
readDataWithTimeout
方法會持續監聽一次緩存區、當接收到數據立刻通過代理交付。這裏也就相當於遞歸調用了。
-
重連
鏈接失敗的重連:
//鏈接失敗
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)error {
// NSLog(@"TCPSocket--連接已斷開.error:%@", error);
if ([self.delegate respondsToSelector:@selector(socketDidDisconnect:error:)]) {
[self.delegate socketDidDisconnect:self error:error];
}
[self tryToReconnect];
}
//嘗試自動重連
- (void)tryToReconnect {
if (self.isConnecting || !self.isNetworkReachable) {
return;
}
self.currentRetryCount -= 1;
//如果還有嘗試次數就自動重連
if (self.currentRetryCount >= 0) {
NSLog(@"嘗試重連");
[self connect];
} else if ([self.delegate respondsToSelector:@selector(socketCanNotConnectToService:)]) {
//自動重連失敗
NSLog(@"重連失敗");
[self.delegate socketCanNotConnectToService:self];
}
}
連接失敗會監聽重連次數、超過次數則宣告失敗
網絡波動的重連:
//網絡波動
- (void)didReceivedNetworkChangedNotification:(NSNotification *)notif {
[self reconnectIfNeed];
}
//切換到後臺
- (void)didReceivedAppBecomeActiveNotification:(NSNotification *)notif {
[self reconnectIfNeed];
}
- (void)reconnectIfNeed {
if (self.isConnecting || self.isConnected) { return; }
[self reconnect];
}
網絡波動會重置連接次數並重連
-
線程的常駐
- (void)socketWillBeConnect {
if (self.socketThread == nil) {
//保存異步線程
self.socketThread = [NSThread currentThread];
[[NSRunLoop currentRunLoop] addPort:self.machPort forMode:NSDefaultRunLoopMode];
while (self.machPort) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
}
}
}
由於爲長連接新開闢了一個線程、所以需要使用Runloop來維持線程的生存。
自定義通訊協議報文
這裏需要解釋一下TCP的兩個概念
面向字節流傳輸
TCP協議將數據看做有序排列的二進制位、並按照8位分割成有序的字節流。
就像之前在談到寫入數據的時候說的一樣、你並不需要主動調用發送函數。Socket在接收到數據的時候就會直接按照流的模式
發送以數據段
。
TCP緩衝區
應用層提供給TCP協議的數據會被先放入緩衝區中、並沒有真正的發送。只有在合適的時候或者應用程序顯示地要求將數據發送時、TCP纔會將數據組織成合適的數據段發送出去。
對於接收方、在正式交付給上層應用之前、接收到的數據也會被放在緩衝區備用。
上圖中、"未發送"部分的數據、就是存放在緩衝區的
總之、接收方的socket永遠不可能知道“發送端發送的數據包長”
如果發送方這樣發送:
while (1) {
[self writeData:@"123"];
}
假設接收方緩衝區爲10個長度
那麼他將接收到1231231231
、2312312312
、3123123123
。
這也就是我們所說的粘包
。
什麼是報文
我們可以先來看看TCP數據段的報文格式
簡而言之。報文是0000040200000401000000287b226d736d73223
這樣的16進制字符串、而報文格式也就是關於報文該如何解釋的一套規定。
自定義通訊協議的報文格式
以本文的Demo舉個例子:
#define ReqTypeLengthForDemo (4)/** 消息類型的長度 */
#define IdentifierLengthForDemo (4)/** 消息序號的長度 */
#define ContentLengthForDemo (4)/** 消息有效載荷的長度 */
#define HeaderLengthForDemo (ReqTypeLengthForDemo + IdentifierLengthForDemo + ContentLengthForDemo)/** Demo消息響應的頭部長度 */
當然、你也可以設計的再複雜一些。包括協議版本、內容類型、校驗和等等元素:
#define ReqTypeLength (4)/** 消息類型的長度 */
#define VersionLength (4)/** 協議版本號的長度 */
#define IdentifierLength (4)/** 消息序號的長度 */
#define ContentTypeLength (4)/** 內容類型的長度 */
#define VerifyLength (32)/** 校驗和的長度 */
#define ContentLength (4)/** 消息有效載荷的長度 */
#define HeaderLength (ReqTypeLength + VersionLength + IdentifierLength + ContentTypeLength + VerifyLength + ContentLength)/** 消息響應的頭部長度 */
請求體
這裏我仿造了
NSURLRequest
進行設計、希望通過KTTCPSocketRequest
可以直接進行TCP通信。在極簡狀態下他應該長這樣:
//通訊類型標識符
typedef enum : NSUInteger {
// 心跳
KTTCP_type_heatbeat = 0x00000001,
KTTCP_type_notification_xxx = 0x00000002,
KTTCP_type_notification_yyy = 0x00000003,
KTTCP_type_notification_zzz = 0x00000004,
// 通知類型最多到400
KTTCP_type_max_notification = 0x00000400,
KTTCP_type_dictionary = 0x00000402,//內容爲字典類型
KTTCP_type_http_get = 0x00000403//內容爲字典類型
} KTTCPSocketRequestType;
/**
將單次TCP需要發送的資源進行整合、類似NSURLRequest的作用
*/
@interface KTTCPSocketRequest : NSObject
@property (nonatomic, assign) NSUInteger timeoutInterval;//超時
/**
請求構造方法
@param type 請求類型
@param parameters 內容數據
@return 請求實例
*/
+(instancetype)requestWithType:(KTTCPSocketRequestType)type parameters:(NSDictionary *)parameters;
@end
一個超時時間屬性、一個根據參數以及請求類型實例化的構造方法。
響應體
爲了適應不同的通訊協議類型、我使用了基類和繼承的方式:
/**
響應體基類、不提供使用
*/
@interface KTTCPSocketResponse : NSObject
@property (nonatomic,readonly) KTTCPSocketRequestType type;//響應類型
@property (nonatomic,readonly) NSNumber *requestIdentifier;//序列號
@property (nonatomic,readonly) NSData *content;//內容
@end
/**
某一應用協議的響應體
*/
@interface KTTCPSocketResponseForXXX : KTTCPSocketResponse
@property (nonatomic,readonly) KTTCPSocketContentType contentType;//內容類型
@property (nonatomic,readonly) BOOL verify;//校驗和情況
@property (nonatomic,readonly) KTTCPSocketVersion version;//協議版本號
/**
對響應體進行初始化
@param data 數據包
@param ipAddress 數據包源地址
@return 響應體
*/
+ (instancetype)responseWithData:(NSData *)data ipAddress:(NSString *)ipAddress;
@end
/****************<# Demo #>********************/
/**
某一應用協議的響應體
*/
@interface KTTCPSocketResponseForDemo : KTTCPSocketResponse
/**
對響應體進行初始化
@param data 數據包
@return 響應體
*/
+ (instancetype)responseWithData:(NSData *)data;
@end
針對不同的通訊協議結構、使用不同的響應體進行解析。
請求和響應的序列化
通俗來講、就是將請求體對象轉化成需要發送的數據包、以及將接收到的數據包解析成的響應體對象。
在這裏我依舊參考AFNNetworking
的AFURLResponseSerialization
採用了協議+繼承的方式進行設計。
-
序列化器
首先、我們需要一個協議、讓所有序列化器各自實現請求和響應的序列化動作。
@protocol KTTCPSocketSerializerDelegate <NSObject>
/**
根據不同的策略將請求體格式化成數據包
@param req 請求體
*/
- (void)configRequestDataWithSerializerWithRequest:(KTTCPSocketRequest *)req;
/**
嘗試根據不同的策略將響應數據包格式化成響應體
@return 響應體
*/
- (KTTCPSocketResponse *)tryGetResponseDataWithSerializer;
@end
-
請求的序列化
調用通過上面的代理進行
- (void)configRequestDataWithSerializerWithRequest:(KTTCPSocketRequest *)req {
if (req.type == KTTCP_type_heatbeat) {
[req setKTRequestIdentifier:@(KTTCP_identifier_heatbeat)];
req.formattedData = configFormattedDataForDemo(KTTCP_type_heatbeat, KTTCP_identifier_heatbeat, req.parameters);
return;
}
uint32_t requestIdentifier = [self.manager.socket currentRequestIdentifier];//獲取唯一序列號
[req setKTRequestIdentifier:@(requestIdentifier)];//設置標識符
req.formattedData = configFormattedDataForDemo(req.type, requestIdentifier, req.parameters);//根據協議配置數據包
}
最終需要發送的數據包formattedData
通過configFormattedDataForDemo
方法進行生成
/**
生成二進制請求包
@param type 通訊類型
@param requestIdentifier 序列號
@param parameters 內容
@return 請求包
*/
NSMutableData * configFormattedDataForDemo(KTTCPSocketRequestType type,uint32_t requestIdentifier,NSDictionary *parameters) {
NSMutableData * formattedData = [NSMutableData new];
//內容轉data
NSData * encodingContent = [ConvertToJsonStr(parameters) dataUsingEncoding:NSUTF8StringEncoding];
//協議拼接--類型標識符
[formattedData appendData:DataFromInteger(type)];
//協議拼接--序列號
[formattedData appendData:DataFromInteger(requestIdentifier)];
//協議拼接--請求體長度
uint32_t contengtLength = (uint32_t)encodingContent.length;
[formattedData appendData:DataFromInteger(contengtLength)];
//協議拼接--請求體
if (encodingContent != nil) { [formattedData appendData:encodingContent]; }
return formattedData;
}
這裏、就是按照我們剛纔制定的通訊協議格式進行拼接。
-
響應的序列化
在接收到TCP協議呈遞上來的數據之後調用代理由序列化器處理
KTTCPSocketResponse *response = [self.serializer tryGetResponseDataWithSerializer];
序列化器內部對數據包進行拆分
- (KTTCPSocketResponse *)tryGetResponseDataWithSerializer {
NSData *totalReceivedData = self.manager.buffer;
//1.頭部 -- 每個Response報文必有的16個字節(url+serNum+respCode+contentLen)
if (totalReceivedData.length < HeaderLengthForDemo) { return nil; }
//2.內容
NSData *responseData;
//根據定義的協議讀取出Response.content的長度
uint32_t responseContentLength = IntegerFromData([self.manager.buffer subdataWithRange:NSMakeRange(HeaderLengthForDemo - ContentLengthForDemo, ContentLengthForDemo)]);
//3.單個響應包長度 Response.content的長度加上必有的16個字節即爲整個Response報文的長度
uint32_t responseLength = HeaderLengthForDemo + responseContentLength;
if (totalReceivedData.length < responseLength) { return nil; }
//4. 根據上面解析出的responseLength截取出單個Response報文
if (self.manager.buffer.length < responseLength) { return nil; }//如果緩存池的長度不足一個數據包則不讀取
responseData = [totalReceivedData subdataWithRange:NSMakeRange(0, responseLength)];
//更新緩存池 源緩存池-已經獲取的長度
self.manager.buffer = [[totalReceivedData subdataWithRange:NSMakeRange(responseLength, totalReceivedData.length - responseLength)] mutableCopy];
KTTCPSocketResponseForDemo * response = [KTTCPSocketResponseForDemo responseWithData:responseData];
return response;//校驗和通過則返回、否則部分返回
}
可以看到、通過對協議每個字段的解析、進而確定單個數據包應有的長度並進行截取。這也是粘包
問題的解決辦法。
單個數據包的解析、由響應體根據自身的數據包自行解析
- (KTTCPSocketRequestType)type {
if (!_type) {
_type = IntegerFromData([self.data subdataWithRange:NSMakeRange(0, ReqTypeLengthForDemo)]);
}
return _type;
}
- (NSNumber *)requestIdentifier {
if (!_requestIdentifier) {
_requestIdentifier = @(IntegerFromData([self.data subdataWithRange:NSMakeRange(ReqTypeLengthForDemo , IdentifierLengthForDemo)]));
}
return _requestIdentifier;
}
- (uint32_t)contentLength {
if (!_contentLength) {
_contentLength = IntegerFromData([self.data subdataWithRange:NSMakeRange(ReqTypeLengthForDemo + IdentifierLengthForDemo, ContentLengthForDemo)]);
}
return _contentLength;
}
- (NSData *)content {
if (!_content) {
_content = [self.data subdataWithRange:NSMakeRange(HeaderLengthForDemo, self.contentLength)];
}
return _content;
}
任務機制
你可以參考
NSURLSessionTask
的作用來理解。
-
KTTCPSocketTask
@interface KTTCPSocketTask : NSObject
@property (nonatomic,readonly) KTTCPSocketTaskState state;//任務狀態
@property (nonatomic,readonly) NSNumber *taskIdentifier;//任務ID
- (void)cancel;
- (void)resume;
@end
其中taskIdentifier
與請求時的序列號進行綁定、並且在收到服務器消息時通過序列號匹配是否有對應的task需要被處理。
-
任務超時
- (void)resume {
if (self.state != KTTCPSocketTaskState_Suspended) { return; }
//發起Request的同時也啓動一個timer timer超時直接返回錯誤並忽略後續的Response
self.timer = [NSTimer scheduledTimerWithTimeInterval:self.request.timeoutInterval target:self selector:@selector(requestTimeout) userInfo:nil repeats:NO];
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
self.state = KTTCPSocketTaskState_Running;
[self.manager resumeTask:self];//通知manager將task.request的數據寫入Socket
}
#pragma mark - Private method
- (void)requestTimeout {
if (![self canResponse]) { return; }
self.state = KTTCPSocketTaskState_Completed;
[self completeWithResult:nil error:taskError(KTNetworkTaskError_TimeOut)];
}
任務開始時會啓動一個定時器、當到達超時時間則將超時錯誤加入回調執行。
管理器
同樣、可以參照
AFURLSessionManager
來理解
-
KTTCPSocketManager
負責將請求(KTTCPSocketRequest
)發送、以及當收到響應時將數據派發給對應的task。
@interface KTTCPSocketManager : NSObject
@property (nonatomic) NSUInteger timeoutInterval;//超時
@property (nonatomic,readonly) KTTCPSocket *socket;
@property (nonatomic,readonly) NSArray<KTTCPSocketTask *> *tasks;//當前在執行的任務
/**
通過指定協議的序列化方案進行初始化
@param serializer 指定協議
@return manager
*/
- (instancetype)initWithTCPSocketSerializer:(id<KTTCPSocketSerializerDelegate>)serializer;
/**
用指定地址去連接
@param host 主機
@param port 端口
@param block 回調
*/
- (void)contentWithHost:(NSString *)host port:(uint16_t)port blcok:(KTTCPSocketManagerContentBlock)block;
/**
發送信息
任務會自動開始
@param request 請求體
@param completionHandler 回調
@return 任務
*/
- (KTTCPSocketTask *)sendMsgWithRequest:(KTTCPSocketRequest *)request completionHandler:(KTNetworkTaskCompletionHander)completionHandler;
/**
創建任務
任務不會自動開始 需要自己[task resume];
@param request 請求體
@param completionHandler 回調
@return 任務
*/
- (KTTCPSocketTask *)TaskWithRequest:(KTTCPSocketRequest *)request completionHandler:(KTNetworkTaskCompletionHander)completionHandler;
@end
-
請求的發送
- (KTTCPSocketTask *)sendMsgWithRequest:(KTTCPSocketRequest *)request completionHandler:(KTNetworkTaskCompletionHander)completionHandler {
if (!request.timeoutInterval) { request.timeoutInterval = self.timeoutInterval; }
[self.serializer configRequestDataWithSerializerWithRequest:request];
KTTCPSocketTask *task = [self dataTaskWithRequest:request completionHandler:completionHandler];
[task resume];
return task;
}
//新建數據請求任務 調用方通過此接口定義Request的收到響應後的處理邏輯
- (KTTCPSocketTask *)dataTaskWithRequest:(KTTCPSocketRequest *)request completionHandler:(KTNetworkTaskCompletionHander)completionHandler {
__block NSNumber *taskIdentifier;
//1. 根據Request新建Task
KTTCPSocketTask *task = [KTTCPSocketTask taskWithRequest:request completionHandler:^(NSError *error, id result) {
//4. Request已收到響應 從派發表中刪除
[self.tableLock lock];
[self.mutableTaskByTaskIdentifier removeObjectForKey:taskIdentifier];
[self.tableLock unlock];
!completionHandler ?: completionHandler(error, result);
}];
//2. 設置Task.manager 後續會通過Task.manager向Socket中寫入數據
task.manager = self;
taskIdentifier = task.taskIdentifier;
//3. 將Task保存到派發表中
[self.tableLock lock];
[self.mutableTaskByTaskIdentifier setObject:task forKey:taskIdentifier];
[self.tableLock unlock];
return task;
}
//用socket發送數據包
- (void)resumeTask:(KTTCPSocketTask *)task {
if (self.socket.isConnected) {
[self.socket writeData:task.request.requestData];
}else {
KTError(@"TCP通道不通", KTNetworkTaskError_SocketNotConnect);
}
}
這裏通過[self.mutableTaskByTaskIdentifier setObject:task forKey:taskIdentifier];
將任務與對應序列號綁定備用。
-
響應的接收
//接收到數據--放入緩存池並解析數據
- (void)socket:(KTTCPSocket *)sock didReadData:(NSData *)data {
[self.lock lock];
[self.buffer appendData:data];//加入緩存池
[self.lock unlock];
// [self.heatbeat reset];
[self readBuffer];//解析數據
}
//遞歸截取Response報文 因爲讀取到的數據可能已經"粘包" 所以需要遞歸
- (void)readBuffer {
if (self.isReading) { return; }
self.isReading = YES;
[self.lock lock];
KTTCPSocketResponse *response = [self.serializer tryGetResponseDataWithSerializer];//截取單個響應報文
[self.lock unlock];
[self dispatchResponse:response];//將報文派發給對應的task
self.isReading = NO;
if (!response) { return; }
[self readBuffer];//繼續解析
}
這裏通過協議方法tryGetResponseDataWithSerializer
讓代理器生成對應的響應體、具體過程上文已經說過了。
-
將響應派發給對應任務
//將Response報文解析Response 然後交由對應的Task進行派發
- (void)dispatchResponse:(KTTCPSocketResponse *)response {
if (response == nil) { return; }
//根據報文類型標識符進行分發
if (response.type > KTTCP_type_max_notification) {/** 請求響應 */
//根據序列號取出指定的task
KTTCPSocketTask *task = self.mutableTaskByTaskIdentifier[response.requestIdentifier];
//通過task將響應報文回調
[task completeWithResponse:response error:nil];
} else if (response.type == KTTCP_type_heatbeat) {/** 心跳 */
NSLog(@"接收到心跳");
[self.heatbeat handleServerAckNum:response.requestIdentifier.intValue];
} else {/** 推送 */
//自行處理
}
}
通過不同的請求類型決定不同的動作、如果是響應報文則派發給對應序列號的任務。
Demo
這裏我用的Node.js搭建的服務器、並且支持通過TCP讓Node代替我們進行HTTP請求(雖然只寫了Get)。
這樣我們就可以大概實現美團這種客戶端向長連接服務器發送TCP請求、長連接服務器向業務服務器發送HTTP請求的基本操作。
這樣做除了提高請求的成功率以及速度之外。還有一個很重要的作用就是可以很大程度上免去被抓包以及篡改的擔心(自定義通訊協議)。
不過、加密通道以及UDP/HTTP降級策略Demo裏並沒有寫。因爲不難麼難了~(其中加密通道可以借鑑HTTPS的方案、用公鑰來協商祕鑰就好)。
Demo用起來也沒啥問題、親切可用
客戶端
服務器
Deme可以《自取》
參考資料
一步一步構建你的iOS網絡層 - TCP篇
iOS使用AsyncSocket循環接收消息的問題
iOS使用GCDAsyncSocket實現消息推送
AsyncSocket中tag參數的用處
最後
本文主要是自己的學習與總結。如果文內存在紕漏、萬望留言斧正。如果不吝賜教小弟更加感謝。