第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进行发送信息,可以看到服务器返回的信息:当前聊天室中不存在该用户。系统具有一定的容错率。