第11次文章:網路編程——聊天室構建

  • 2019 年 10 月 8 日
  • 筆記

這周的內容還是蠻有意思的!構建一個聊天室,如果我們20年前掌握了這篇文章的內容,那我們就離馬化騰不遠了!哈哈哈!

一、基本概念:

1、網路:將不同區域的電腦連接起來,比如:區域網、城域網、互聯網。

2、地址:IP地址 確定網路上的一個絕對地址類似於:房子。

3、埠號:區分電腦不同軟體,類似於房子的房門。埠號長度為2個位元組,範圍為:0---65535,共有65536個。

tips:在使用戶埠號的時候,在同一個協議下,埠號不能重複,如果在不同協議下,則埠號可以重複。1024以下的埠號不要使用,主要是留給設備服務商使用的固定埠號

4、資源定位:

URL:統一資源定位符

URI:統一資源

5、數據的傳輸:

TCP協議:類似於打電話,有三次握手機制,面向連接,安全可靠,效率低下。

UDP協議:類似於發簡訊,非面向連接,效率高,但是不可靠,可能存在資訊丟失的情況。

二、網路編程中的一些基本類

1、地址及埠:

(1)InetAddress:封裝電腦的ip地址和DNS,沒有埠

方法:

getLocalHost():獲取本地地址

getHostName():返回域名

getHostAddress():返回IP地址

getByName():通過域名或者IP來獲取地址

(2)InetSocketAddress:在InetAddress基礎上+埠

創建對象:

InetSocketAddress(String hostname, int port)

InetSocketAddress(InetAddress addr, int port)

方法:

getHostName():獲取域名

getPort():獲取埠號

getAddress():獲取InetAddress對象

2、URL:

四部分組成: 協議 存放資源的主機域名 埠 資源文件名(/)

(1)創建

URL(String spec):絕對路徑構建

URL(URL context, String spec):相對路徑構建

(2)方法

package com.peng.net.url;    import java.net.MalformedURLException;  import java.net.URL;    public class URLDemo01 {      public static void main(String[] args) throws MalformedURLException {      //絕對路徑構建      URL url = new URL("http://www.baidu.com:80/index.html#aa?uname=peng");      System.out.println("協議:"+url.getProtocol());      System.out.println("域名:"+url.getHost());      System.out.println("埠:"+url.getPort());      System.out.println("資源:"+url.getFile());      System.out.println("相對路徑:"+url.getPath());      System.out.println("錨點:"+url.getRef());      System.out.println("參數:"+url.getQuery());//如果存在錨點,則將參數視為錨點的一部分,返回null;如果不存在錨點,則返回參數      //相對路徑      url = new URL("http://www.baidu.com/a/");      url = new URL(url,"b/c.txt");      System.out.println(url.toString());    }  }

三、UDP編程,基本概念:

UDP:以數據為中心,非面向連接,不安全,數據可能丟失,效率高。

1、客戶端

1)創建客戶端 DatagramSocket 類 +指定發送埠

2)編輯數據 位元組數組

3)打包 DatagramPacket + 伺服器地址 + 指定的接收埠

4)發送數據

5)釋放資源

2、伺服器端

1)創建伺服器端 DatagramSocket 類 + 指定接收埠

2)創建接收容器 位元組數組

3)打包封裝 DatagramPacket

4)包 接收數據

5)分析

6)釋放資源

由於UDP協議編程是非面向連接的,TCP協議編程面向連接,相比之下TCP更加複雜,所以此處不放入UDP編程進行講解,我們結合後面的TCP編程進行解析UDP編程細節。

四、基於TCP編程:

面向連接 安全可靠 效率低,類似於打電話

1、面向連接:請求-響應 Request--Response

2、Socket編程

1)、伺服器:SeverSocket

2)、客戶端:Socket

基本的TCP相關協議我們在下面一個實例中進行講解——聊天室創建,其中包含有群聊和私聊功能。

基本的通訊思路如下圖所示:

在客戶端首先和伺服器端建立連接通道,也就是socket,然後在傳輸通道中進行數據的傳輸,每一個通道內的藍色箭頭,代表著數據的輸入和輸出流。並且在數據的發送和接收過程中,可以同時進行,不會受到彼此的影響。在同一個聊天室中,具有多個客戶端,他們需要同時連接在我們的伺服器端上,因此我們在設計的過程中需要進行多執行緒的應用。

第一步:我們首先對客戶端的接收數據進行封裝,創建接收通道。

package com.peng.net.tcp.chat.demo04;  /**   * 從伺服器接收數據   */    import java.io.DataInputStream;  import java.io.IOException;  import java.net.Socket;    public class Receive implements Runnable{    //管道輸入流    private DataInputStream dis ;    //執行緒標識符    private boolean isRunning = true;      //構造器    public Receive() {      }    public Receive(Socket client) {      try {        //獲取客戶端與伺服器之間的傳輸管道        dis = new DataInputStream(client.getInputStream());      } catch (IOException e) {        isRunning = false;        CloseUtil.closeAll(dis);      }    }      /**     * 獲取從伺服器發送到客戶端的數據     * @return     */    public String receive() {      String msg = "";      try {        msg = dis.readUTF();//從管道輸入流中讀取發送過來的數據內容      } catch (IOException e) {        isRunning = false;        CloseUtil.closeAll(dis);      }      return msg;    }      @Override    public void run() {      //執行緒體      while(isRunning) {        System.out.println(receive());//在客戶端的控制台上列印接收到的數據內容      }      }    }

解析:在接收數據的過程中,我們主要思路是,在構造器中對輸入流進行初始化操作,應用「DataInputStream」輸入流,然後加入一個接收方法,將管道中伺服器傳回來的數據進行讀取,最後在執行緒體中,將讀取到的內容傳輸到客戶端的介面上。

由於我們在多執行緒的使用中,頻繁使用關閉輸入輸出流的關閉操作,所以我們將輸入輸出流的關閉操作封裝成為一個單獨的類,這樣便於我們後期的調用和處理。

package com.peng.net.tcp.chat.demo04;    import java.io.Closeable;  import java.io.IOException;    /**   * 關閉流   */  public class CloseUtil {      public static void closeAll(Closeable... io) {      for (Closeable tem:io) {        try {          if(null !=tem ) {            tem.close();          }        } catch (IOException e) {          e.printStackTrace();        }      }    }  }

tips:在對其進行封裝的過程中,注意我們使用到了程式碼「Closeable...」,其中的運算符「...」相當於數組「[]」。

第二步:我們對客戶端的發送操作進行一個封裝操作。

package com.peng.net.tcp.chat.demo04;    import java.io.BufferedReader;  import java.io.DataOutputStream;  import java.io.IOException;  import java.io.InputStreamReader;  import java.net.Socket;    /**   * 發送數據執行緒   */  public class Send implements Runnable{    //控制台輸入流    private BufferedReader console ;    //管道輸出流    private DataOutputStream dos ;    //客戶端的名稱    private String name;    //執行緒標識符    private boolean isRunning = true;      //構造器:初始化輸入輸出流    public Send() {      console = new BufferedReader(new InputStreamReader(System.in));    }    public Send(Socket client,String name) {      this();      try {        //初始化客戶端向伺服器端發送資訊的管道        dos = new DataOutputStream(client.getOutputStream());        this.name = name;        this.send(this.name);//在接收到名稱時,就將客戶端的名稱發送出去      } catch (IOException e) {        isRunning = false;        CloseUtil.closeAll(dos,console);      }    }      /**     * 從控制台獲取數據     * @return     */    public String getMsgFromConsole() {      try {        return console.readLine();//獲取控制台輸入的數據      } catch (IOException e) {        isRunning = false;        CloseUtil.closeAll(dos,console);      }      return "";    }      /**     * 將客戶端的數據發送給伺服器     * @param info     */    public void send(String info) {      try {        if(null != info && !info.equals("")) {          dos.writeUTF(info);//寫出數據資訊          dos.flush();//強制刷新        }      } catch (IOException e) {        isRunning = false;        CloseUtil.closeAll(dos,console);      }      }      @Override    public void run() {      //執行緒體      while(isRunning) {        send(getMsgFromConsole());      }      }    }

解析:在進行發送操作的時候,我們需要有兩個流操作,一個是輸入流,主要負責從控制台上接收客戶端輸入的數據,另一個是輸出流,主要負責將從客戶端上獲取到的資訊發送到伺服器進行操作。所以我們為了降低方法之間的耦合性,使用了兩個方法,分別封裝其功能。在最後的執行緒體中,我們將接收到的數據直接發送給客戶端。注意,我們在構造器中發送了一個名稱給客戶端,這一點在我們創建客戶端的程式碼中會進行解釋。

第三步:創建客戶端

package com.peng.net.tcp.chat.demo04;    import java.io.BufferedReader;  import java.io.IOException;  import java.io.InputStreamReader;  import java.net.Socket;      /**   * 創建客戶端:發送數據+接收數據   * 寫出數據:輸出流   * 讀取數據:輸入流   *  同時需要將輸入流和輸出流分別封裝起來,彼此獨立,相互獨立處理   *  加入客戶端名稱   */  public class Client {      public static void main(String[] args) throws IOException {      System.out.println("請輸入名稱:");      //從控制台輸入客戶端名稱      BufferedReader br = new BufferedReader(new InputStreamReader(System.in));//新建輸入流      String name = br.readLine();//獲取客戶端名稱      if("" == name) {//如果名字為空,則退出        return;      }        //創建客戶端,與伺服器進行連接,並指定埠號      Socket client = new Socket("localhost",9999);      new Thread(new Send(client,name)).start();//發送路徑      new Thread(new Receive(client)).start();//接受路徑      }    }

解析:在創建客戶端的時候,我們首先需要獲取每一個客戶端的名稱,在獲取到名稱之後,我們立刻將客戶端的名稱發送給伺服器後,伺服器會進行一定的回饋,返回給客戶端的消息為:「歡迎加入聊天室」,然後在其他客戶端的介面上,輸出「XXX加入了聊天室」。

tips:在UDP協議中,客戶端發送數據的時候,需要指定客戶端發送埠,以及伺服器的接收埠,這一點與TCP協議編程中有所不同。在TCP編程中,客戶端不需要指定對應的發送埠,系統會自動分配給客戶端埠,但是並非TCP不要需要埠,只是開發者在編程的時候可以省略而已。

第四步:創建伺服器

package com.peng.net.tcp.chat.demo04;    import java.io.DataInputStream;  import java.io.DataOutputStream;  import java.io.IOException;  import java.net.ServerSocket;  import java.net.Socket;  import java.util.ArrayList;  import java.util.List;    /**   * 創建伺服器 加入多執行緒   * 實現多個不同的客戶端可以同時進行發送接收數據   */  public class Server {    //存儲所有客戶端與伺服器建立的管道    private List<MyChannel> all = new ArrayList<MyChannel>();      public static void main(String[] args) throws IOException  {      new Server().start();    }      public void start() throws IOException {      //創建伺服器,指定埠號      ServerSocket server = new ServerSocket(9999);      while(true) {        Socket client = server.accept();//接收客戶端請求,並與客戶端建立連接        MyChannel channel = new MyChannel(client);        all.add(channel);//向容器中加入客戶端通道,便於統一管理        new Thread(channel).start();//一條道路      }    }      /**     * 定義匿名內部類,便於調用類的屬性,此匿名類相當於客戶端和伺服器之間建立的道路     * 一個客戶,一條道路     * 1、輸入流:接收數據     * 2、輸出流:發送數據     */    private class MyChannel implements Runnable{      //輸入流:接收數據      private DataInputStream dis;      //輸出流:發送數據      private DataOutputStream dos;      //客戶端的名稱      private String name;      //執行緒運行標識符      private boolean isRunning = true;        //構造器      public MyChannel(Socket client) {        try {          dis = new DataInputStream(client.getInputStream());          dos = new DataOutputStream(client.getOutputStream());            this.name = dis.readUTF();//獲取客戶端名稱          this.send("歡迎加入聊天室");//向本客戶端發送此資訊          this.sendAll(this.name+"加入了聊天室",true);//向其他的客戶端發送該資訊          } catch (IOException e) {          isRunning = false;          CloseUtil.closeAll(dis,dos);          all.remove(this);//移除通道自身        }      }        /**       * 接收從客戶端發送過來的資訊       * @return       */      public String receive () {        String msg = "";        try {          msg = dis.readUTF();//獲取讀入的資訊          send("you say :"+msg);        } catch (IOException e) {          isRunning = false;          CloseUtil.closeAll(dis,dos);          all.remove(this);//移除通道自身        }        return msg;      }        /**       * 向本客戶端發送相關資訊       * @param msg       */      public void send(String msg) {        try {          if(null != msg && !msg.equals("")) {            dos.writeUTF(msg);            dos.flush();          }else {            return;          }        } catch (IOException e) {          isRunning = false;          CloseUtil.closeAll(dis,dos);          all.remove(this);//移除通道自身        }      }        /**       * 向除本客戶端以外的其他客戶端發送資訊       * 根據發送的消息區分是私聊還是群聊       * 在群發的消息中,使用flag區分該消息是伺服器的系統消息,還是用戶群聊的資訊       * @param msg       * @param flag       */      public void sendAll(String msg,boolean flag) {        //約定,如果發送的資訊中包含有「@name:.....」,則將name取出,然後與該用戶進行私聊        if(msg.startsWith("@") && msg.indexOf(":")>-1) {//私聊          String name = msg.substring(1,msg.indexOf(":"));//獲取私聊對象的名稱          String contents = msg.substring(msg.indexOf(":")+1);//獲取私聊的內容          for(MyChannel other:all) {//遍歷所有客戶端            if(other.name.equals(name)) {//存在將要私聊的對象              other.send(this.name+"對您悄悄說:"+contents);              return;            }          }          this.send("當前聊天室中不存在此用戶");        }else {//群聊          if(flag) {//屬於系統消息            for(MyChannel other:all) {              if(this == other) {//跳過本客戶端自身                continue;              }              //將本客戶端發送的數據,發送給其他已經加入聊天的客戶              other.send("系統消息:"+msg);            }          }else {            for(MyChannel other:all) {              if(this == other) {//跳過本客戶端自身                continue;              }              //將本客戶端發送的數據,發送給其他已經加入聊天的客戶              other.send(this.name+"對大家說:"+msg);            }          }          }      }        @Override      public void run() {        //執行緒體        while(isRunning) {          sendAll(receive(),false);        }        }      }    }

解析:

1、正如我們對聊天室的功能分析上,聊天室應該具有群聊和私聊的基本功能。所以我們根據客戶端發送的消息進行區分是私聊還是群聊,具體的規則為:伺服器獲取到客戶端發送進來的數據,然後如果該消息以「@XXX:」開頭,則獲取「@」和「:」中間的名稱"XXX",然後在所有客戶端中進行搜索名稱為「XXX」的客戶端,伺服器將該消息僅僅轉發給客戶」XXX「。

2、在我們管理聊天室中的所有客戶的時候,我們使用了容器List進行統一管理。但是這裡在導入包的時候,一定要注意,此處導入的是容器類包java.util.List。在我們使用自動導包過程時,eclipse給我們的提示中,還有一個是java.awt.List,這個包是java中GUI介面操作的工具類包,千萬要注意此處的導包,一旦導錯之後,很難檢查出錯誤。

tips:查看源碼,可以對比出兩個包繼承關係以及實現介面之間的差別,進入源碼中查詢可以看出:

java.util.List中繼承關係為:interface List<E> extends Collection<E>,主要是實現相應的容器類;

而java.awt.List繼承和實現關係為:List extends Component implements ItemSelectable, Accessible,主要是實現GUI圖形介面的工具類

第五步:運行查看一下相關的結果

a客戶端控制台資訊:

b客戶端控制台資訊:

c客戶端控制台資訊:

解析:由於我們使用的是tcp協議,需要客戶端先建立連接之後,才可以進行相互通訊傳輸數據。所以在測試的時候,需要我們首先需要運行伺服器,使伺服器處於就緒狀態,隨時接受來自客戶端的請求,然後再創建客戶端進行操作,否則會報錯。我們在測試的時候,創建了3個客戶端,分別是「a」、「b」、「c"。在測試的時候,我們使用a客戶端給b發送了hello,然後可以在b客戶端看到a發送過來的私聊資訊,而c客戶端介面上沒有出現這條資訊,所以完成了私發消息的功能。然後使用c客戶端發送了資訊a beautiful world,該資訊屬於群發資訊,所以出現在了a和b客戶端的窗口。然後在c窗口中,對一個不存在的對象d進行發送資訊,可以看到伺服器返回的資訊:當前聊天室中不存在該用戶。系統具有一定的容錯率。

Tags: