軟體架構
目前市場上主要有兩種軟體架構:BS架構和CS架構
CS架構全稱為Client/Server架構,也就是客戶端/伺服器架構,常見的CS架構的程式有QQ、微信、迅雷、百度網盤等等,CS架構的特點是客戶端和伺服器是分開的,需要下載客戶端才能使用,對網路要求較低,開發和維護成本高,相對穩定。BS架構全稱為Broswer/Server架構,也就是瀏覽器/伺服器架構,常見的瀏覽器有Chrome、Firefox等等,其特點是沒有客戶端,也就不需要下載客戶端,只有伺服器,然後透過瀏覽器直接輸入域名地址(例如www.baidu.com)訪問,對網路要求高,開發和維護成本低,伺服器壓力大,相對不穩定。B/S架構還是C/S架構都各有優勢,但是都離不開網路的支援,而網路程式設計就是在指定的協議下程式設計實現兩臺計算機的通訊。Java主要是用於B/S架構的服務端應用開發。
網路程式設計三要素IP地址IP(Internet Protocol Address)地址指的是網際網路協議地址,俗稱IP,IP地址用來給一個網路中的計算機裝置做唯一的編號,類似人的身份證號。
目前IP地址分成IPv4和IPv6兩類
IPv4:是一個32位二進位制數,通常被分為4個位元組,表示成a.b.c.d的形式,例如192.168.0.102,其中a、b、c、d都表示十進位制的0-255之間的整數,那麼累計可以使用的IP是255*255*255*255約等於42億個IP地址。IPv6:由於網際網路、移動網際網路、物聯網的蓬勃發展,IP地址的需求越來越大,但是網路地址資源有限,使得IP地址的分配越發緊張,有資料顯示,全球IPV4地址在2011年2月就已經分配完畢。為了擴大地址空間,透過IPV6來重新定義地址空間,採用128位地址長度,每16個位元組一組,分成8組十六進位制數,表示成aaaa:aaaa:aaaa:aaaa:aaaa:aaaa:aaaa:aaaa,其中每個a表示十六進位制0-9,a-f之間的整數, IPv6號稱可以為全世界每一粒沙子編上一個IP地址,這樣就解決了IP地址資源數量不夠的問題。本機的IP地址可以使用127.0.0.1或者是localhost表示
Windows系統可以使用ipconfig命令檢視本機IP
macOS可以使用ifconfig命令檢視本機IP
在公司開發專案時,如果在開發環境和其他同事進行聯調,就需要保證兩臺機器網路通暢,可以使用ping 命令加上同事的ip地址來檢測網路是否暢通。這裡假設同事的機器IP是192.168.0.102,你的ip地址是192.168.0.122
而檢測電腦是否能連線外網,直接ping baidu.com即可,如果有資料返回則表示能夠連線外網。
在java.net包中提供InetAddress類提供了一些靜態方法用於獲取IP地址和主機資訊
public String getHostAddress() 獲取IP地址字串public static InetAddress getByName(String host) 根據主機名獲取IP地址public String getHostName() 獲取主機名public static InetAddress getLocalHost() throws UnknownHostException 獲取本機IP地址物件 /** * InetAddress獲取IP地址和主機名 */ @Test public void testInetAddress() { try { //獲取本機的IP地址物件 InetAddress localHost = InetAddress.getLocalHost(); //預設獲取主機名/IPv4地址 例如liuguangleideMacBook-Pro/192.168.0.102 // 192.168.0.102是區域網的IP地址 log.info("本機的主機/IP地址是{}", localHost); log.info("本機的IP地址是{}", localHost.getHostAddress()); //透過主機名獲取IP地址 InetAddress inetAddress = localHost.getByName("liuguangleideMacBook-Pro"); log.info("【透過主機名獲取IP地址】本機的主機/IP地址是{}", inetAddress); log.info("本機的主機名是{}", inetAddress.getHostName()); //透過百度的主機名獲取百度伺服器的IP地址 InetAddress baidu = InetAddress.getByName("www.baidu.com"); log.info("百度的主機/IP地址是{}", baidu); log.info("百度的主機名是{}", baidu.getHostName()); } catch (UnknownHostException e) { e.printStackTrace(); } }
程式執行結果
埠網路的通訊,本質上是兩個程序的通訊。每臺計算機上都會執行很多程序,那麼在網路通訊時如何區分這些程序?
如果說IP地址可以唯一 表示網路中的計算機裝置,那麼埠號就可以唯一標識裝置中的程序(執行中的程式)。埠號用兩個位元組的十進位制整數表示,取值範圍是0-65535,其中0-1023之間的埠號用於一些知名的網路服務和應用,普通的應用程式需要使用1024以上的埠號(例如Tomcat伺服器的預設埠是8080)。如果在同一臺機器上埠被另外一個服務佔用,會導致當前應用程式啟動失敗。
透過協議+IP+埠三個要素的組合,就可以標識網路中的程序,那麼程序間的通訊就可以利用這個標識進行程序的互動。不過由於IP地址比較難記,因此目前訪問網路服務時,通常使用域名(例如 www.baidu.com www.taobao.com)訪問網路服務。而域名是綁定了IP地址和埠號。
在使用ping www. baidu.com時,服務端會返回伺服器的IP地址
可以在瀏覽器的位址列輸入 http://220.181.38.148:80 埠訪問百度的首頁,80埠是HTTP服務的預設埠
協議網路通訊協議:通訊協議就是計算機必須遵守的規則,只有遵守這些規則,計算機之間才能進行通訊,這就好比在開車時需要遵守交通規則。網路通訊協議對資料的傳輸格式、傳輸速率、傳輸步驟做了統一的規定,通訊雙方必須同時遵守相同的協議,最終才能完成資料交換。
Java語言支援兩種網路中常見的協議支援:TCP協議和UDP協議。
TCP協議:TCP(Transmission Control Protocol)也就是傳輸控制協議,TCP協議是面向連線的通訊協議,即傳輸資料之前,在傳送端和接收端建立邏輯連線,然後再傳輸資料,它提供了兩臺計算機之間可靠無差錯的資料傳輸。TCP協議的特點是面向連線、傳輸資料安全、傳輸速度低。基於TCP協議客戶端和伺服器端通訊需要進行三次握手,只有三次握手成功才能建立連線
第一次握手,客戶端向伺服器發出請求,等待伺服器確認第二次握手,伺服器向客戶端回送一個響應,通知客戶端接收到了連線請求第三次握手,客戶端再次向伺服器傳送確認訊息,確認連線
TCP協議通常用於檔案下載等不能丟失資料的應用場景。
UDP協議:UDP(User Datagram Protocol) 也就是使用者資料報協議,UDP協議是一個面向無連線的協議,傳輸資料時不需要建立連線,不管對方服務是否啟動,直接將資料、資料來源和目的地都封裝咋一個數據包中直接傳送。每個資料包的大小限制在64K內,它是不可靠的協議,因為無連線,所以速度快,但是容易資料丟失。UDP協議的特點是 面向無連線、傳輸資料不安全、傳輸速度快。UDP協議的應用場景是影片會議、聊天等等。使用Java實戰基於TCP協議的程式小試牛刀Socket/ServerSocketjava.net包中提供了ServerSocket類,一個ServerSocket的物件就表示一個伺服器端的程式。
建立ServiceSocket物件必須指定埠號給客戶端連線,即構造方法ServerSocket(int port)需要傳遞一個埠引數,預設會使用本機的IP作為當前服務的IP,然後呼叫public Socket accept() throws IOException 方法等待客戶端連線並獲取與客戶端關聯Socket物件,如果沒有客戶端連線,該方法會一直阻塞。服務端處理客戶端請求結束後呼叫public void close() throws IOException關閉ServerSocket,伺服器一般都是7*24小時執行,不關閉。
java.net包中提供了Socket類,一個Socket的物件就表示一個客戶端程式。
在建立Socket物件時需要根據伺服器的IP地址和伺服器的埠號建立客戶端的Socket物件,即構造方法中需要Socket(String ip,int port)兩個引數,當執行該構造方法時,就會立即連線指定的伺服器程式,不過前提條件是伺服器程式在客戶端連線之前已經啟動成功了。如果連線不成功,則會丟擲異常,如果連線成功則表示三次握手透過。
客戶端(Socket)連線伺服器成功之後,透過public OutputStream getOutputStream() throws IOException方法獲取一個輸出流(OutputStream)將資料傳送伺服器端,伺服器端透過呼叫public Socket accept() throws IOException 方法返回的Socket物件的public OutputStream getInputStream() throws IOException拿到客戶端的資料後處理資料。
在瞭解ServerSocket和Socket後,就可以開發第一個網路程式
在測試類中定義兩個常量,分別表示伺服器的IP和伺服器的埠 /** * 伺服器的IP */ public static final String SERVER_IP = "127.0.0.1"; /** * 伺服器的埠 */ public static final int PORT = 8888;
在客戶端建立Socket物件,傳入伺服器的IP和埠建立TCP連線,並獲取Socket物件的OutuputStream,呼叫write()方法往服務端寫資料
/** * 基於TCP協議實現將跟光磊學Java開發訊息傳送給服務端 */ @Test public void testSocketV1() { //建立Socket物件指定伺服器的IP地址和埠號 try { Socket socket = new Socket(SERVER_IP, PORT); OutputStream outputStream = socket.getOutputStream(); String content = "跟光磊學Java開發"; //將 跟光磊學Java開發 字串轉換為字元陣列寫給伺服器 outputStream.write(content.getBytes()); socket.close(); } catch (IOException exception) { exception.printStackTrace(); } }
在服務端建立ServerSocket物件,傳入伺服器的埠,然後呼叫accept()方法等待客戶端連線,透過accept()方法返回的Socket物件呼叫getInputStream()獲取客戶端的請求資料 /** * 基於TCP協議實現接收客戶端訊息並輸出到終端 */ @Test public void testServerSocketV1() { try { //建立指定埠的服務端物件 ServerSocket serverSocket = new ServerSocket(SocketTest.PORT); //等待客戶端連線 Socket socket = serverSocket.accept(); log.info("服務端等待客戶端連線"); if (socket != null) { log.info("客戶端連線成功,客戶端資訊:{}", socket); } //獲取客戶端寫給伺服器的資料 InputStream inputStream = socket.getInputStream(); byte[] buffer = new byte[1024]; int length = inputStream.read(buffer); log.info(new String(buffer, 0, length)); socket.close(); } catch (IOException exception) { exception.printStackTrace(); } }
然後先後啟動testServerSocketV1()方法和testSocketV1()方法
程式執行結果
在服務端的終端可以看到客戶端傳送的資料。
伺服器(ServerSocket)處理完客戶端的資料後獲取輸出流(OutputStream)將資料傳送給客戶端,客戶端透過public OutputStream getInputStream() throws IOException方法獲取輸入流(InputStream)獲取服務端返回的資料。 客戶端處理完成後呼叫 void close()方法關閉Socket此時同時會關閉對應的流。
/** * 基於TCP協議實現接收客戶端的請求並處理後將結果返回給客戶端 */ @Test public void testServerSocketV2() { try { ServerSocket serverSocket = new ServerSocket(SocketTest.PORT); Socket socket = serverSocket.accept(); log.info("服務端啟動成功,等待接收客戶端的請求"); InputStream inputStream = socket.getInputStream(); byte[] buffer = new byte[1024]; int length = inputStream.read(buffer); String clientReqContent = new String(buffer, 0, length); log.info("客戶端傳送的請求內容是:{}", clientReqContent); //透過Socket物件獲取輸出流物件 OutputStream outputStream = socket.getOutputStream(); String responseContent = "跟光磊學Java開發很棒"; outputStream.write(responseContent.getBytes()); } catch (IOException exception) { exception.printStackTrace(); }
而客戶端可以透過Socket物件的getInputStream()方法獲取服務端返回的資料
/** * 基於TCP協議實現將跟光磊學Java開發訊息傳送給服務端 * 然後接收伺服器端返回的訊息並輸出在終端 */ @Test public void testSocketV2() { try { Socket socket = new Socket(SERVER_IP, PORT); OutputStream outputStream = socket.getOutputStream(); String content = "跟光磊學Java開發怎麼樣"; outputStream.write(content.getBytes()); //獲取服務端的響應流 InputStream inputStream = socket.getInputStream(); if (null != inputStream) { byte[] buffer = new byte[1024]; int length = inputStream.read(buffer); String responseContent = new String(buffer, 0, length); log.info("伺服器端返回的資料是{}", responseContent); } socket.close(); } catch (IOException exception) { exception.printStackTrace(); } }
程式執行結果
基於TCP協議實現檔案上傳剛才寫的程式的資料來源是寫死的字串,而要想實現檔案上傳,可以將寫死的字串替換成InputStream讀取到的資料即可。檔案上傳就是客戶端透過Socket的OutputStream將檔案透過網路傳輸到伺服器,而伺服器透過Socket的InputStream獲取客戶端上傳的檔案,然後使用OutputStream寫入到伺服器的本地磁碟。
客戶端發起上傳檔案的請求
/** * 客戶端使用TCP協議實現向伺服器端上傳檔案 */ @Test public void testSocketFileUploadV1() { String clientFileUploadPath = "/Users/liuguanglei/Downloads/go1.15.6.darwin-amd64.pkg"; //建立一個輸入流 try ( //建立一個客戶端物件 Socket socket = new Socket(SERVER_IP, PORT); //建立輸入流 InputStream inputStream = new BufferedInputStream(new FileInputStream(clientFileUploadPath)); //獲取客戶端的輸出流 OutputStream outputStream = socket.getOutputStream(); ) { //定義位元組陣列儲存讀取到的位元組資料 byte[] buffer = new byte[8192]; //定義變數儲存讀取到的位元組個數 int length = 0; while ((length = inputStream.read(buffer)) != -1) { //資料寫給伺服器 outputStream.write(buffer, 0, length); } } catch (IOException ex) { ex.printStackTrace(); } }
伺服器處理上傳檔案的請求
/** * 服務端使用TCP協議實現客戶端的上傳檔案請求 */ @Test public void testServerSocketFileUploadV1() { //服務端儲存客戶端上傳檔案的路徑 String serverFilePath = "/Users/liuguanglei/Desktop/go1.15.6.darwin-amd64.pkg"; try ( //建立指定埠的服務物件 ServerSocket serverSocket = new ServerSocket(SocketTest.PORT); //建立連線,接收客戶端請求,三次握手成功會返回socket,沒有成功則會一直阻塞 Socket socket = serverSocket.accept(); //透過Socket物件獲取輸入流 InputStream inputStream = socket.getInputStream(); ) { byte[] buffer = new byte[8192]; int length = 0; OutputStream outputStream = new BufferedOutputStream(new FileOutputStream(serverFilePath)); while ((length = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, length); } outputStream.close(); } catch (IOException exception) { exception.printStackTrace(); } }
程式執行結果
在執行程式時需要注意首先啟動服務端的程式,然後啟動客戶端,如果先啟動客戶端就會看到一個連線拒絕的異常
程式執行結果
在使用上傳檔案元件的應用時,當上傳成功之後,服務端通常會客戶端返回響應訊息。
不過在使用Socket的OutputStream往伺服器傳輸檔案結束後,需要呼叫Socket物件的public void shutdownOutput() throws IOException方法來關閉輸出流,以此來告訴伺服器檔案完成寫入,不然伺服器會一直處於等待狀態,不會給客戶端響應。
/** * 客戶端使用TCP協議實現向伺服器端上傳檔案 */ @Test public void testSocketFileUploadV2() { String clientFileUploadPath = "/Users/liuguanglei/Downloads/go1.15.6.darwin-amd64.pkg"; //建立一個輸入流 try ( //建立一個客戶端物件 Socket socket = new Socket(SERVER_IP, PORT); //建立輸入流關聯上傳的檔案 InputStream fileUploadInputStream = new BufferedInputStream(new FileInputStream(clientFileUploadPath)); //獲取服務端的響應 InputStream serverResponseInputStream = socket.getInputStream(); ) { //定義位元組陣列儲存讀取到的位元組資料 byte[] buffer = new byte[8192]; //定義變數儲存讀取到的位元組個數 int length = 0; log.info("客戶端開始上傳檔案給伺服器,上傳的本地路徑是{}", clientFileUploadPath); //獲取客戶端的輸出流 OutputStream outputStream = socket.getOutputStream(); while ((length = fileUploadInputStream.read(buffer)) != -1) { //資料寫給伺服器 outputStream.write(buffer, 0, length); } //客戶端檔案寫完後關閉輸出流,如果不關閉伺服器不知道客戶端的資料什麼時候寫完了,伺服器一直處於等待狀態無法給客戶端響應。 socket.shutdownOutput(); int responseLength = serverResponseInputStream.read(buffer); String content = new String(buffer, 0, responseLength); log.info("伺服器返回的內容是{}", content); } catch (IOException ex) { ex.printStackTrace(); } }
伺服器處理客戶端上傳的檔案後給客戶端響應資訊
/** * 服務端使用TCP協議實現客戶端的上傳檔案請求 * 請求處理成功後,給客戶端響應 */ @Test public void testServerSocketFileUploadV2() { //服務端儲存客戶端上傳檔案的路徑 String serverFilePath = "/Users/liuguanglei/Documents/go1.15.6.darwin-amd64.pkg"; try ( //建立指定埠的服務物件 ServerSocket serverSocket = new ServerSocket(SocketTest.PORT); //建立連線,接收客戶端請求,三次握手成功會返回socket,沒有成功則會一直阻塞 Socket socket = serverSocket.accept(); //透過Socket物件獲取輸入流 InputStream clientRequestInputStream = socket.getInputStream(); //透過Socket獲取物件的輸出流 OutputStream responseOutputStream = socket.getOutputStream(); ) { if (null != socket) { log.info("客戶端主動連線伺服器成功,客戶端資訊{}", socket); } else { log.info("伺服器等待客戶端連線中"); } byte[] buffer = new byte[8192]; int length = 0; OutputStream fileUploadOutputStream = new BufferedOutputStream(new FileOutputStream(serverFilePath)); //伺服器一直在讀取客戶端寫過來的資料,無法給客戶端響應 // 伺服器不知道客戶端的資料什麼時候寫完,雖然檔案上傳完成但是如果客戶端不關閉輸出流,服務端一直是等待狀態 while ((length = clientRequestInputStream.read(buffer)) != -1) { fileUploadOutputStream.write(buffer, 0, length); } fileUploadOutputStream.close(); //返回給伺服器提示資訊 String responseContent = "檔案上傳伺服器成功,檔案上傳的伺服器路徑是" + serverFilePath; responseOutputStream.write(responseContent.getBytes()); } catch (IOException ex) { ex.printStackTrace(); } }
程式執行結果
不過當前的檔案上傳程式有一些問題
/** * 服務端使用TCP協議實現客戶端的上傳檔案請求 * 請求處理成功後,給客戶端響應 * 基於死迴圈 處理多個客戶端請求 * 引入執行緒池同時處理多個客戶端請求 */ @Test public void testServerSocketFileUploadV3() { try { FileUploadConfig fileUploadConfig = new FileUploadConfig(); ServerSocket serverSocket = new ServerSocket(fileUploadConfig.getPort()); //死迴圈接收客戶端請求 while (true) { Socket socket = serverSocket.accept(); FileUploadServerRunnable fileUploadServerRunnable = new FileUploadServerRunnable(socket, fileUploadConfig); FileUploadServerHandler fileUploadServerHandler = new FileUploadServerHandler(); fileUploadServerHandler.doHandlerFileUpload(fileUploadServerRunnable); } } catch (IOException exception) { exception.printStackTrace(); } }
客戶端依然是單執行緒請求, 不過可以執行多次testSocketFileUploadV3()單元測試方法模擬多個請求
/** * */ @Test public void testSocketFileUploadV3() { String clientFileUploadPath = "/Users/liuguanglei/Downloads/go1.15.6.darwin-amd64.pkg"; //建立一個輸入流 try ( //建立一個客戶端物件 Socket socket = new Socket(SERVER_IP, PORT); //建立輸入流關聯上傳的檔案 InputStream fileUploadInputStream = new BufferedInputStream(new FileInputStream(clientFileUploadPath)); //獲取服務端的響應 InputStream serverResponseInputStream = socket.getInputStream(); ) { //定義位元組陣列儲存讀取到的位元組資料 byte[] buffer = new byte[8192]; //定義變數儲存讀取到的位元組個數 int length = 0; log.info("客戶端開始上傳檔案給伺服器,上傳的本地路徑是{}", clientFileUploadPath); //獲取客戶端的輸出流 OutputStream outputStream = socket.getOutputStream(); while ((length = fileUploadInputStream.read(buffer)) != -1) { //資料寫給伺服器 outputStream.write(buffer, 0, length); } //客戶端檔案寫完後關閉輸出流,如果不關閉伺服器不知道客戶端的資料什麼時候寫完了,伺服器一直處於等待狀態無法給客戶端響應。 socket.shutdownOutput(); int responseLength = serverResponseInputStream.read(buffer); if (responseLength != -1) { String content = new String(buffer, 0, responseLength); log.info("伺服器返回的內容是{}", content); } } catch (IOException ex) { ex.printStackTrace(); } }
程式執行結果服務端引入執行緒池後可以開啟多個執行緒同時處理多個客戶端發起的檔案上傳請求。
模擬B/S架構模擬網站伺服器,使用瀏覽器訪問自己編寫的伺服器程式,檢視網頁效果。
首先準備一個HTML5頁面 index.html,該頁面位於src/main/resource/static/index.html
在本地使用Safai瀏覽器檢視index.html
在本地使用Chrome瀏覽器檢視index.html
然後編寫服務端,服務端分為單執行緒版本和多執行緒版本
單執行緒版伺服器,如果有多個請求過來,永遠都只會有一個main執行緒處理請求
再來一個多執行緒版本的實現:多執行緒實現的思路和之前的檔案上傳類似。
為了防止埠衝突,將多執行緒版本的埠號改為了9999
程式執行結果
多執行緒版伺服器,每次請求過來都會使用執行緒池的執行緒去處理請求