1. 程式人生 > >Android 進階12:程序通訊之 Socket (順便回顧 TCP UDP)

Android 進階12:程序通訊之 Socket (順便回顧 TCP UDP)

  • 不要害怕困難,這是你進步的機會!

讀完本文你將瞭解:

前面幾篇文章我們介紹了 AIDLBinderMessenger 以及 ContentProvider 實現程序通訊的方式,這篇文章將介紹“使用 Socket 進行跨程序通訊”。

在介紹 Socket 之前我們先來回顧一下網路基礎知識,有的知識需要經常回顧一下加深印象。

OSI 七層網路模型

為了使不同廠家生產的計算機可以相互通訊,建立更大範圍的計算機網路,國際標準化組織(ISO)在 1978 年提出了“開放系統互聯參考模型”,即 OSI/RM 模型(Open System Interconnection/Reference Model)。

OSI 模型將計算機網路體系結構的通訊協議劃分為七層,每一層都建立在它的下層之上,同時向它的上一層提供一定服務。上層只管呼叫下層提供的服務,而不用關心具體實現細節,有些類似我們開發中對外暴露介面隱藏實現的思想。

七層模型自下而上分別為:物理層、資料鏈路層、網路層、傳輸層、會話層、表示層、應用層。其中低四層完成資料傳輸,高三層面向用戶。

各層的功能見下圖(圖片來自 維基百科):

shixinzhang

TCP/IP 四層模型

由於 OSI/RM 模型過於複雜難以實現,現實中廣泛使用的是 TCP/IP 模型。

TCP/IP 是一個協議集,是由 ARPA ( Advanced Research Projects Agency Network 高等研究計劃署網路 ) 於 1977 到 1979 年推出的一種網路體系結構和協議規範。

隨著 Internet 的發展,TCP/IP 得到進一步的研究和推廣,成為 Internet 上的 “通用模型”。

TCP/IP 模型在 OSI 模型的基礎上進行了簡化,變成了四層,從下到上分別為:網路介面層、網路層、傳輸層、應用層。與 OSI 體系結構對比如下:

這裡寫圖片描述

可以看到,TCP/IP 模型 的網路介面層對應 OSI 模型的物理層、資料鏈路層,應用層對應會話層、表示層和應用層每一層的功能如下:

  • 應用層:應用程式為了訪問網路所使用的一層
    • 資料以應用內部使用的格式進行傳送,然後被編碼成標準協議的格式
    • 比如全球資訊網使用的 HTTP 協議,傳輸檔案的 FTP 協議等等
  • 傳輸層:響應來自應用層的請求,並向網路層發出服務請求
    • 提供兩臺主機之間的資料傳輸,通常用於端到端連線、流量控制或者錯誤恢復
    • 最重要的兩個協議就是 TCP 和 UDP
  • 網路層:提供端到端的資料包交付
    • 負責資料包從源傳送到目的地
    • 任務包括網路路由、差錯控制和 IP 編制等
    • 重要的協議有 IP、ICMP 等
  • 網路介面層:負責通過網路傳送和接受 IP 資料包

每一層包括的協議如下圖:

這裡寫圖片描述

Socket 作為應用層和傳輸層之間的橋樑,與之關係最大的兩個協議就是傳輸層中的 TCP 和 UDP協議。

Socket 分為流式套接字和使用者資料報套接字,分別使用傳輸層中的 TCP 和 UDP 協議。

TCP 協議

TCP (Transmission Control Protocol 傳輸控制協議),是一種面向連線的、可靠的、基於位元組流的傳輸層通訊協議。

TCP 協議被認為是穩定的協議,因為它有以下特點:

  • 面向連線,“三次握手”
  • 雙向通訊
  • 保證資料按序傳送,按序到達
  • 超時重傳

要使用 TCP 傳輸資料,必須先建立連線,傳輸完成後釋放連線。分別對應常說的“三次握手”、“四次揮手”。

TCP 的三次握手

在 socket 程式設計中,客戶端執行 connect() 時。將觸發三次握手。

TCP 的三次握手流程圖如下:

這裡寫圖片描述

解釋如下:

  1. 客戶端傳送一個建立 C 到 S 連線的請求報文,其中同步標誌位(SYN)置 1。然後進入 SYN_SEND 狀態,等待服務端確認
  2. 服務端返回確認資料報文,將 ACK 置為 1,同時也將 SYN 置為 1,請求建立 S 到 C 的連線
  3. 客戶端返回確認資料報文,ACK 遞增,這時雙方連線建立成功

雙向連線都建立成功後就可以收發資料了。

為什麼是三次呢?

為了防止已經失效的連線請求報文突然又傳送到服務端,因而產生錯誤。
減小因延遲高擁塞大對報文傳輸的影響。

在這三次握手過程中,任何一次未收到對面回覆都要重發,保證請求報文的及時性。

建立連線是需要耗費資源的,就像打電話一樣,只有在雙方都確認後才等待通話,只要有一方沒有及時響應就結束通話,而不是一方確認後就等著,這樣會浪費資源,甚至可能導致其他問題。

一副圖簡化理解三次握手:

這裡寫圖片描述

TCP 的四次揮手

TCP 協議中,在通訊結束後,需要斷開連線,這需要通過四次揮手,客戶端或伺服器均可主動發起,主動的一方先斷開

在 socket 程式設計中,任何一方執行 close() 操作即可產生揮手操作。

這裡寫圖片描述

解釋如下:

  1. 客戶端 C 傳送 FIN 的報文,表示沒有資料要傳送給服務端了,請求關閉 C 到 S 的連線
  2. 服務端確認這個報文,發回一個 ACK,關閉它的 Receive 通道;客戶端收到 ACK 後關閉它的 Send 通道
  3. 服務端 S 發出 FIN ,表示沒有資料傳送給客戶端了,請求斷開連線
  4. 客戶端確認這個報文,發回 ACK,等待 2MSL 後關閉 Receive 通道;S 收到後關閉 Send 通道

注意第三步,S 發出 FIN 後還沒有斷開!

為什麼是四次呢?

TCP 連線是全雙工的,每一端都可以同時傳送和接受資料,關閉的時候兩端都要關閉各自兩個方向的通道,總共相當於要關閉四個。

(假設以客戶端先發起斷開請求)

  • 在客戶端傳送 FIN 報文時,僅代表客戶端沒有資料傳送了
  • 這時服務端可能還是有資料要傳送,因此不會馬上關閉服務端到客戶端的傳送通道,而是先回答 ACK “哦知道了,我先不接收你的資料,你先斷了傳送通道吧”;客戶端收到服務端的確認訊息後,斷開到服務端的傳送通道
  • 等服務端沒有資料傳送時,向客戶端傳送 FIN 報文,說“我沒啥發的了,請求斷開”
  • 客戶端收到後回覆 “好的你斷吧”,同時斷開到服務端的接受通道;服務端得到確認後斷開到客戶端的傳送通道

至此,四個通道全部關閉。

第四步客戶端為什麼要等待 2MSL?

首先,MSL(Maximum Segment Life),是 TCP 對 TCP Segment 生存時間的限制。

客戶端在發出確認服務端關閉的 ACK 後,它沒有辦法知道對方是否收到這個訊息,於是需要等待一段時間,如果服務端沒有收到關閉的訊息後會重新發出 FIN 報文,這樣客戶端就知道自己上條訊息丟了,需要再發一次;如果等待的這段時間沒有在收到 FIN 的重發報文,說明它的確已經收到斷開的訊息並且已經斷開了。

這個等待時間至少是:客戶端的 timeout + FIN 的傳輸時間,為了保證可靠,採用更加保守的等待時間 2MSL。

UDP 協議

UDP 協議沒有 TCP 協議穩定,因為它不建立連線,也不按順序傳送,可能會出現丟包現象,使傳輸的資料出錯。

但是有得就有失,UDP 的效率更高,因為 UDP 頭包含很少的位元組,比 TCP 負載消耗少,同時也可以實現雙向通訊,不管訊息送達的準確率,只負責無腦傳送。

UDP 服務於很多知名應用層協議,比如 NFS(網路檔案系統)、SNMP(簡單網路管理協議)

UDP 一般多用於 IP 電話、網路視訊等容錯率強的場景。

Socket 簡介

TCP 或者 UDP 的報文中,除了資料本身還包含了包的資訊,比如目的地址和埠,包的源地址和埠,以及其他附加校驗資訊。

由於包的長度有限,在傳輸的過程中還需要拆包,到達目的地後再重新組合。

如果有丟失或者損壞的包還需要重傳,有的在到達目的地後還需要重新排序。

這些工作是複雜且與業務無關的,Socket 為我們封裝了這些處理工作。

Socket 被稱為“套接字”,它把複雜的 TCP/IP 協議簇隱藏在背後,為使用者提供簡單的客戶端到服務端介面,讓我們感覺這邊輸入資料,那邊就直接收到了資料,像一個“管道”一樣。

這裡寫圖片描述

Socket 的基本操作

Socket 的基本操作有以下幾部分:

  1. 連線遠端機器
  2. 傳送資料
  3. 接收資料
  4. 關閉連線
  5. 繫結埠
  6. 監聽到達資料
  7. 在繫結的埠上接受來自遠端機器的連線

要實現客戶端與服務端的通訊,雙方都需要例項化一個 Socket。

在 Java 中,客戶端可以實現上面的 1、2、3、4、,服務端實現 5、6、7.

Java.net 中為我們提供了使用 TCP、UDP 通訊的兩種 Socket:

  • ServerSocket:流套接字,TCP
  • DatagramSocket:資料報套接字,UDP

使用 TCP 通訊的 Socket 流程

服務端:

  1. 呼叫 ServerSocket(int port) 建立一個 ServerSocket,繫結到指定埠
  2. 呼叫 accept() 監聽連線請求,如果客戶端請求連線則接受,返回通訊套接字
  3. 呼叫 Socket 類的 getOutputStream()getInputStream() 獲取輸出和輸入流,進行網路資料的收發
  4. 關閉套接字

客戶端:

  1. 呼叫 Socket() 建立一個流套接字,連線到服務端
  2. 呼叫 Socket 類的 getOutputStream()getInputStream() 獲取輸出和輸入流,進行網路資料的收發
  3. 關閉套接字

使用 UDP 通訊的 Socket 流程

服務端:

  1. 呼叫 DatagramSocket(int port) 建立一個數據報套接字,繫結到指定埠
  2. 呼叫 DatagramPacket(byte[] buf, int length) 建立一個位元組陣列,以接受 UDP 包
  3. 呼叫 DatagramSocketreceive() 接收 UDP 包
  4. 呼叫 DatagramSocket.send() 傳送 UDP 包
  5. 關閉資料報套接字

客戶端:

  1. 呼叫 DatagramSocket() 建立一個數據報套接字
  2. 呼叫 DatagramPacket(byte buf[], int offset, int length,InetAddress address, int port) 建立要傳送的 UDP 包
  3. 呼叫 DatagramSocketreceive() 接收 UDP 包
  4. 呼叫 DatagramSocket.send() 傳送 UDP 包
  5. 關閉資料報套接字

使用 TCP 通訊的 Socket 實現跨程序聊天

我們使用流套接字實現一個跨程序聊天程式。

建立服務端 TCPServerService

public class TCPServerService extends BaseService {

    private final String TAG = this.getClass().getSimpleName();

    private boolean mIsServiceDisconnected;

    @Override
    public void onCreate() {
        super.onCreate();
        LogUtils.d(TAG, "服務已 create");
        new Thread(new TCPServer()).start();    //新開一個執行緒開啟 Socket
    }


    private class TCPServer implements Runnable {
        @Override
        public void run() {
            ServerSocket serverSocket;
            try {
                serverSocket = new ServerSocket(ConfigHelper.TEST_SOCKET_PORT);
                LogUtils.d(TAG, "TCP 服務已建立");
            } catch (IOException e) {
                e.printStackTrace();
                System.out.println("TCP 服務端建立失敗");
                return;
            }

            while (!mIsServiceDisconnected) {
                try {
                    Socket client = serverSocket.accept();  //接受客戶端訊息,阻塞直到收到訊息
                    //我這裡使用了執行緒池,也可以直接新建一個執行緒
//                    new Thread(responseClient(client)).start();    
                    ThreadPoolManager.getInstance()
                            .addTask(responseClient(client));    
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    //在這裡接受和回覆客戶端訊息
    private Runnable responseClient(final Socket client) {
        return new Runnable() {
            @Override
            public void run() {
                try {
                    //接受訊息
                    BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
                    //回覆訊息
                    PrintWriter out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(client.getOutputStream())), true);
                    out.println("服務端已連線 *****");

                    while (!mIsServiceDisconnected) {
                        String inputStr = in.readLine();
                        LogUtils.i(TAG, "收到客戶端的訊息:" + inputStr);
                        if (TextUtils.isEmpty(inputStr)) {
                            LogUtils.i(TAG, "收到訊息為空,客戶端斷開連線 ***");
                            break;
                        }
                        out.println("你這句【" + inputStr + "】非常有道理啊!");
                    }
                    out.close();
                    in.close();
                    client.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        };
    }

    @Override
    public void onDestroy() {
        mIsServiceDisconnected = true;
        super.onDestroy();
    }
}

然後在 AndroidManifest.xml 檔案中宣告 Service,放到另外一個程序:

<service
    android:name=".service.TCPServerService"
    android:exported="true"
    android:process=":socket"/>

在客戶端中建立連線,收發資料

這裡的客戶端就是我們的 Activity。

佈局程式碼:

<merge xmlns:tools="http://schemas.android.com/tools"
       xmlns:android="http://schemas.android.com/apk/res/android">


    <TextView
        android:id="@+id/tv_socket_message"
        android:layout_width="match_parent"
        android:layout_height="300dp"/>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:layout_marginBottom="50dp"
        android:background="#efefef"
        android:orientation="horizontal">

        <EditText
            android:id="@+id/et_client_socket"
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            />

        <Button
            android:id="@+id/bt_send_socket"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:enabled="false"
            android:text="向伺服器發訊息"/>
    </LinearLayout>
</merge>

然後 include 到 Activity 的佈局檔案中。

Activity 程式碼:


/**
 * 處理 Socket 執行緒切換
 */
@SuppressWarnings("HandlerLeak")
public class SocketHandler extends Handler {
    public static final int CODE_SOCKET_CONNECT = 1;
    public static final int CODE_SOCKET_MSG = 2;

    @Override
    public void handleMessage(final Message msg) {
        switch (msg.what) {
            case CODE_SOCKET_CONNECT:
                mBtSendSocket.setEnabled(true);
                break;
            case CODE_SOCKET_MSG:
                mTvSocketMessage.setText(mTvSocketMessage.getText() + (String) msg.obj);
                break;
        }
    }
}

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_ipc);
    ButterKnife.bind(this);
    bindSocketService();
}

private void bindSocketService() {
    //啟動服務端
    Intent intent = new Intent(this, TCPServerService.class);
    startService(intent);

    mSocketHandler = new SocketHandler();
    new Thread(new Runnable() {    //新開一個執行緒連線、接收資料
        @Override
        public void run() {
            try {
                connectSocketServer();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }).start();
}

private Socket mClientSocket;
private PrintWriter mPrintWriter;
private SocketHandler mSocketHandler;

/**
 * 通過 Socket 連線服務端
 */
private void connectSocketServer() throws IOException {
    Socket socket = null;
    while (socket == null) {    //選擇在迴圈中連線是因為有時請求連線時服務端還沒建立,需要重試
        try {
            socket = new Socket("localhost", ConfigHelper.TEST_SOCKET_PORT);
            mClientSocket = socket;
            mPrintWriter = new PrintWriter(new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())), true);
        } catch (IOException e) {
            SystemClock.sleep(1_000);
        }
    }

    //連線成功
    mSocketHandler.sendEmptyMessage(SocketHandler.CODE_SOCKET_CONNECT);

    //獲取輸入流
    BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
    while (!isFinishing()) {    //死迴圈監聽服務端傳送的資料
        final String msg = in.readLine();    
        if (!TextUtils.isEmpty(msg)) {
            //資料傳到 Handler 中展示
            mSocketHandler.obtainMessage(SocketHandler.CODE_SOCKET_MSG,
                    "\n" + DateUtils.getCurrentTime() + "\nserver : " + msg)
                    .sendToTarget();
        }
        SystemClock.sleep(1_000);
    }

    System.out.println("Client quit....");
    mPrintWriter.close();
    in.close();
    socket.close();
}

@OnClick(R.id.bt_send_socket)
public void sendMsgToSocketServer() {
    final String msg = mEtClientSocket.getText().toString();
    if (!TextUtils.isEmpty(msg) && mPrintWriter != null) {
        //傳送資料,這裡注意要線上程中傳送,不能在主執行緒進行網路請求,不然就會報錯
        ThreadPoolManager.getInstance().addTask(new Runnable() {
            @Override
            public void run() {
                mPrintWriter.println(msg);
            }
        });
        mEtClientSocket.setText("");
        mTvSocketMessage.setText(mTvSocketMessage.getText() + "\n" + DateUtils.getCurrentTime() + "\nclient : " + msg);
    }
}

執行結果

這裡寫圖片描述

程式碼地址

Thanks