注:此博文為本人學習過程中的筆記
1.socket?api
這是操作系統提供的一組api,由傳輸層向應用層提供。
2.傳輸層的兩個核心協議
傳輸層的兩個核心協議分別是TCP協議和UDP協議,它們的差別非常大,編寫代碼的風格也不同,因此socket提供了兩套api。TCP協議是有連接,可靠傳輸,面向字節流,全雙工。UDP協議是無連接,不可靠傳輸,面向數據報,全雙工。
1.有連接/無連接
這里的有無連接是抽象的概念,對于網絡通信來說,物理上(網線)的連接時必須的。這里的連接是指在通信的時候有沒有保存對方的信息。
對于TCP協議來說,A和B通信,會讓A保存B的信息,B保存A的信息,讓它們彼此知道誰是和它建立連接的那一個。
對于UDP協議來說,不保存對方的協議。當然程序員可以在自己的代碼中保存對方的信息,但這不屬于UDP協議的行為。
2.可靠傳輸/不可靠傳輸
網絡上,數據是非常容易出現丟失的情況,用來傳輸的光/電信號很容易受到外界的影響。所以我們不能指望一個數據包發送之后,能百分之一百到達對方。
可靠傳輸的意思不是保證數據包百分之一百到達,而是盡可能提高傳輸成功的概率,如果丟包了,可以感知到。
不可靠傳輸就是指數據包發送之后就不管了。
3.面向字節流/面向數據報
面向字節流是指讀寫數據的時候以字節為單位。它可以靈活地控制讀寫的長度,但是容易出現粘包問題。
面向數據報是指讀寫數據的時候以數據報為單位。一次必須讀取一個數據報的長度,但不容易出現粘包問題。
4.全雙工/半雙工
全雙工是指一個通信鏈路支持雙向通信
半雙工是指一個通信鏈路只支持單向通信
3.使用socket api進行編程
1.UDP服務器
這是操作系統提供的功能,Java進行了封裝。這里我們先講解基于UDP協議的寫法。
1.DatagramSocket
計算機中的文件廣義上還能代指硬件設備,將它們抽象成文件。這里我們將網卡抽象成socket文件,操作網卡的時候和普通的文件差不多,打開(也會在文件描述符表分配一個表項)->讀寫->關閉。直接操作網卡不好操作,把網卡操作轉換成socket文件操作更加方便。
1.構造方法
DatagramSocket()
創建一個UDP數據報套接字的Socket,綁定到本機任意一個隨機端口(一般用于客戶端)
DatagramSocket(int port)
創建一個UDP數據報套接字的Socket,綁定到本機指定的端口(一般用于服務器)
2.DatagramPacket
這個類表示一個完整的UDP數據報
1.構造方法
UDP數據報的載荷可以通過構造方法來指定
DatagramPacket(byte[] buf, int length)
構造一個DatagramPacket用來接收數據報,接收的數據報保存在字節數組,指定長度
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address)
構造一個DatagramPacket用來接收數據報,接收的數據報保存在字節數組,指定數組下標,數組長度,目的IP和端口號
2.receive/send
1.void receive(DatagramPacket p)
接收數據報,沒有接收到會阻塞等待
2.void send(DatagramPacket p)
發送數據報
3.代碼示例
這里我們使用回顯服務器,回顯服務器是指響應和請求都是相同的服務器。我們實現的功能是用戶輸入一個字符串,服務器返回這個字符串。
1.服務器代碼
代碼展示
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;public class UdpEchoServer {private DatagramSocket socket = null;public UdpEchoServer(int port) throws SocketException {//指定了一個固定端口號讓服務器使用socket = new DatagramSocket(port);}public void start() throws IOException {//啟動服務器System.out.println("服務器啟動");while(true) {//循環一次相當處理一次請求//處理請求的過程,典型的服務器分為三個步驟//1.讀取請求并解析//DatagramPacket就表示一個UDP數據報,此處傳入的字節數組保存UDP的載荷部分DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);socket.receive(requestPacket);//把讀取到的二進制數據轉化成字符串,只讀取有效的部分String request = new String(requestPacket.getData(), 0, requestPacket.getLength());//2.根據請求,計算響應(服務器最關鍵的邏輯)//因為我們寫的是回顯服務器,所以這個步驟省略了String response = process(request);//3.把響應返回給客戶端//根據response構造DatagramPacket返回給客戶端DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), 0, response.getBytes().length, requestPacket.getSocketAddress());socket.send(responsePacket);}}private String process(String request) {return request;}public static void main(String[] args) throws IOException {UdpEchoServer udpEchoServer = new UdpEchoServer(9090);udpEchoServer.start();}
}
解析
1. socket對象代表網卡文件,讀這個文件相當于從網卡收數據,寫這個文件相當于向網卡發數據
2.啟動服務器,在循環中做三件事
3. 讀取請求并解析
a)構造DatagramPacket對象,這個對象就代表UDP數據報,有表頭和載荷(創建字節數組來保存數據)?
b)調用receive接收數據,這里使用的是輸出型參數,所以雖然我們是接收一個UDP數據報,但我們還是創建了一個空的DatagramPacket對象。
c)根據字節數組構造純出一個String
4.根據請求計算響應
5.把響應返回給客戶端
這里構造響應的數據報時,傳入了字節數組作為載荷,指定了數組下標和有效長度,傳入了目的端口和IP。?
6.socket不用close
一個文件是否要關閉,需要考慮這個文件的生命周期,這里的socket對象自始至終都會伴隨整個UDP服務器,如果服務器關閉,會自動釋放PCB的文件描述附表里面的所有資源,所以就不用手動關閉了。
2.客戶端代碼
import java.io.IOException;
import java.net.*;
import java.util.Scanner;public class UdpEchoClient {private DatagramSocket socket = null;//UDP本身不保存對端的信息,所以我們在自己的代碼保存一下private String serverIp;private int serverPort;//和服務器不同,這里的構造方法需要指定訪問服務器的地址public UdpEchoClient(String serverIp, int serverPort) throws SocketException {this.serverIp = serverIp;this.serverPort = serverPort;//這里的DatagramSocket不推薦使用固定端口號,如果客戶端是固定端口,//很可能在這個程序運行的時候指定的端口被其他程序占用了,客戶端在用戶手上,//程序員不能控制socket = new DatagramSocket();}public void start() throws IOException {Scanner scanner = new Scanner(System.in);//1.從控制臺讀取用戶輸入的內容System.out.println("請輸入要發送的內容");String request = scanner.next();//2.把請求放松給服務器,需要構造DatagramPacket對象//構造過程中,不光要構造載荷,還要指定服務器的IP和端口號DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,InetAddress.getByName(serverIp), serverPort);//3.發送數據報socket.send(requestPacket);//4.接收服務器的響應DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);socket.receive(responsePacket);//5.把從服務器讀取出來的數據開始解析,打印出來String response = new String(responsePacket.getData(), 0, responsePacket.getLength());System.out.println(response);}public static void main(String[] args) throws IOException {//127.0.0.1是一個環回ip,非常特殊,無論你的主機是什么,都可以用這個ip表示當前主機,相當于thisUdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1", 9090);udpEchoClient.start();}
}
2.TCP服務器?
因為TCP服務器進行網絡通信的基本單位是字節,所以它不像UDP服務器的那樣有DatagramPacket類作為基本單位,可以直接使用InputStream/OutputStream。
1.ServerSocket
這個類是專門給服務器用的,作為一開始的牽頭人,和客戶端建立連接后,就會使用Socket和客戶端進行通信
ServerSocket(int port)
創建一個服務器端流套接字,并指定端口號
accept()
和客戶端進行連接
2.Socket
這個類服務器和客戶端都會使用
Socket(String host, int port)
這兩個參數指的是需要指定的服務器IP和端口號
InputStream getInputStream()/OutputStream getOutputStream()
這兩個方法是字節流對象
3.代碼解析和修改
初始服務器代碼
public class TcpEchoServer {private ServerSocket serverSocket= null;//這里和Udp服務器類似,也是在構造的時候指定端口號public TcpEchoServer(int port) throws IOException {serverSocket = new ServerSocket(port);}public void start() throws IOException {System.out.println("啟動服務器");while(true) {//對于tcp來說,需要向處理客戶端發過來的連接//通過讀寫clientSocket和客戶端進行通信//如果沒有客戶端發送連接,那么accept就會阻塞Socket clientSocket = serverSocket.accept();processConnection(clientSocket);}}//處理一個客戶端的連接//可能涉及多個客戶端的連接和響應,這里暫不涉及private void processConnection(Socket clientSocket) {try(InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {Scanner scanner = new Scanner(inputStream);PrintWriter writer = new PrintWriter(outputStream);//處理三個步驟while(true) {//1.讀取請求并解析,可以直接read,也可以借助scannerif(!scanner.hasNext()) {break;}String request = scanner.next();//2.根據請求計算響應String response = procoss(request);//3.返回響應到客戶端writer.println(response);}} catch (IOException e) {e.printStackTrace();}}private String procoss(String request) {return request;}public static void main(String[] args) throws IOException {TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);tcpEchoServer.start();}
}
初始客戶端代碼
public class TcpEchoClient {private Socket socket = null;public TcpEchoClient(String serverIp, int port) throws IOException {//這里可以直接使用字符串的ip作為參數socket = new Socket(serverIp, port);}public void start() {Scanner scanner = new Scanner(System.in);try(InputStream inputStream = socket.getInputStream();OutputStream outputStream = socket.getOutputStream()) {//為了方便使用,套殼操作Scanner scannerNet = new Scanner(inputStream);PrintWriter printWriter = new PrintWriter(outputStream);//從控制臺讀取請求String request = scanner.next();//發送給服務器printWriter.println(request);//獲取服務器的響應String response = scannerNet.next();//打印到控制臺System.out.println(response);} catch (IOException e) {e.printStackTrace();}}public static void main(String[] args) throws IOException {TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1", 9090);tcpEchoClient.start();}
}
問題1
當我們把客戶端關閉再啟動時,輸入數據服務器沒有響應。原因在于println只是把數據寫入“發送緩沖區”,并沒有真正寫入網卡,此時我們需要用flush方法來屬性緩沖區,讓數據真正寫入網卡。
問題2
這里的pringln里的ln是加上了換行,如果這里我們把ln刪去,那么數據能發送過去,而服務器接收不到。
因為hasNext是以空白符(換行,回車,制表符,翻頁符...)為基準,遇到空白符則是一個完整的next(),否則就會阻塞。?
編寫客戶端代碼的時候是需要約定請求和響應之間的分隔符的,這里我們使用的是\n
問題3
如果沒有客戶端連接,就會阻塞在accept這里
如果客戶端不發送請求, 就會阻塞在hasNext這里
我們的服務器無法同時等待accept和客戶端請求,當我們在等待客戶端發送請求時,如果這時有新的客戶端想要連接進來,就無法連接。
在這個場景下,我們就能引入多線程,讓一個線程專門負責連接服務器。同時可以引入線程池優化效率。
問題4
服務器的socket要記得及時關閉,因為這個socket的生命周期不再是跟隨整個服務器了
問題5
當我們的客戶端多到一定程度時,服務器無法承擔,此時我們就可以使用操作系統中內置的IO多路復用,這個操作本質上是讓一個線程處理多個客戶端的請求。多個客戶端發送數據大概率不是同時的,客戶端很可能在阻塞等待。
優化后服務器代碼
public class TcpEchoServer {private ServerSocket serverSocket= null;//這里和Udp服務器類似,也是在構造的時候指定端口號public TcpEchoServer(int port) throws IOException {serverSocket = new ServerSocket(port);}public void start() throws IOException {ExecutorService executorService = Executors.newCachedThreadPool();System.out.println("啟動服務器");while(true) {//對于tcp來說,需要向處理客戶端發過來的連接//通過讀寫clientSocket和客戶端進行通信//如果沒有客戶端發送連接,那么accept就會阻塞//主線程負責進行accept,每次accept到一個新客戶端,就創建一個新線程來處理客戶端的請求Socket clientSocket = serverSocket.accept();executorService.submit(() -> {processConnection(clientSocket);});}}//處理一個客戶端的連接//可能涉及多個客戶端的連接和響應,這里暫不涉及private void processConnection(Socket clientSocket) {try(InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {Scanner scanner = new Scanner(inputStream);PrintWriter writer = new PrintWriter(outputStream);//處理三個步驟while(true) {//1.讀取請求并解析,可以直接read,也可以借助scannerif(!scanner.hasNext()) {break;}String request = scanner.next();//2.根據請求計算響應String response = procoss(request);//3.返回響應到客戶端writer.println(response);writer.flush();}} catch (IOException e) {e.printStackTrace();}}private String procoss(String request) {return request;}public static void main(String[] args) throws IOException {TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);tcpEchoServer.start();}
}
優化后客戶端代碼
public class TcpEchoClient {private Socket socket = null;public TcpEchoClient(String serverIp, int port) throws IOException {//這里可以直接使用字符串的ip作為參數socket = new Socket(serverIp, port);}public void start() {Scanner scanner = new Scanner(System.in);try(InputStream inputStream = socket.getInputStream();OutputStream outputStream = socket.getOutputStream()) {//為了方便使用,套殼操作Scanner scannerNet = new Scanner(inputStream);PrintWriter printWriter = new PrintWriter(outputStream);//從控制臺讀取請求String request = scanner.next();//發送給服務器printWriter.println(request);//加上刷新緩沖區操作,才是真正寫入網卡printWriter.flush();//獲取服務器的響應String response = scannerNet.next();//打印到控制臺System.out.println(response);} catch (IOException e) {e.printStackTrace();}}public static void main(String[] args) throws IOException {TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1", 9090);tcpEchoClient.start();}
}