【教程】基于Socket的Java聊天室客户端程序

一、实验目的

本次实验中,我们通过Java语言编写一个简单的聊天室客户端程序,实现多用户群聊的功能。本次实验的基础是基于TCP协议的以套接字(Socket)为端点的端到端通信技术。其中,通信的一端是我们将要编写的聊天室客户端进程,另一端是服务端进程。

二、用户界面设计

按照如下的设计来实现聊天室客户端程序的用户界面:

  • 窗口的最上方是将要连接的服务器端地址和端口号。填写后点击“连接”按钮发起连接
  • 连接成功与否,都弹出对话框对用户进行提示
  • 连接成功后,按钮文字变成“断开”,点击后断开与服务器的连接,退出聊天。
  • 窗口的中间区域显示聊天室中当前各个用户的聊天内容
  • 窗口最下方是用户昵称,以及聊天内容编辑框,点击“发送”按钮后即将聊天消息发送到服务器端。然后服务器端再将这条消息下发到其它客户端,从而实现了群聊。除了了点击“发送”按钮,用户通过按下键盘上的回车键也可以触发消息的发送

三、程序编写

3.1 创建Java应用程序工程

启动Eclipse集成开发环境,选择菜单项“File -> New -> Java Project”,系统弹出类似下面的对话框:

在“Project name”一栏中填写你的项目名称,然后点击右下角的“Finish”按钮完成创建。新建的项目出现在左侧的“Package Explorer”视图中:

3.2 创建Java代码包

右键点击“src”文件夹,在弹出菜单中选择“New -> Package”,在弹出的对话框中填写包名:

点击“Finish”按钮,“src”目录下出现我们新建的包名:


3.3 创建UI视图类

3.3.1 建立ClientView类

UI视图类用来实现前文的用户界面,并且响应交互,例如输入文字、点击按钮、敲击键盘等。
在步骤3.2中创建的包名上右键单击鼠标,在弹出的菜单中选择“New -> Class”,在弹出的对话框中的“Name”栏中填写类名ClientView,在父类“Superclass”栏中填写“javax.swing.JFrame”:

点击“Finish”按钮完成创建。此时,在源代码目录中出现了源文件ClientView.java。双击打开,内容如下:

package com.simplechat;
import javax.swing.JFrame;

public class ClientView extends JFrame {

}

3.3.2 实现处理交互事件的接口

由于我们视图中将来要响应鼠标点击、敲击键盘等交互事件,需要分别实现对应的接口。在类定义上增加需要实现的接口如下:

public class ClientView extends JFrame implements 
        ActionListener, KeyListener {
}

其中,接口ActionListener用来处理鼠标点击事件,而接口KeyListener用来处理键盘事件。

此时,类名ClientView下面会出现红色波浪线提示出错。将光标定位到类名,按F2键,在弹出的菜单中选择“Add unimplemented methods”:

这时,开发环境会自动向类ClientView中添加两个接口要求实现的方法如下:

    @Override
    public void keyTyped(KeyEvent e) {
        // TODO Auto-generated method stub
        
    }
    @Override
    public void keyPressed(KeyEvent e) {
        // TODO Auto-generated method stub
        
    }
    @Override
    public void keyReleased(KeyEvent e) {
        // TODO Auto-generated method stub
        
    }
    @Override
    public void actionPerformed(ActionEvent e) {
        // TODO Auto-generated method stub
        
    }

我们重点关注两个方法:

  • keyPressed():处理键盘按下事件
  • actionPerformed():处理鼠标点击事件

稍后我们会在这两个方法中编写事件处理代码。

3.3.3 UI元素初始化

要达到前文设计的UI显示效果,需要编写代码添加各个窗口元素。对于需要响应交互事件的元素,还需要绑定对事件的监听
首先,在类中添加如下的成员变量:

    private JTextArea taChatList;   // 聊天内容区
    private JTextField tfMessage;   // 聊天输入框
    private JTextField tfName;      // 用户名输入框
    private JButton btnSend;        // 发送按钮
    private JLabel labelNick;        
    private JPanel jp1, jp2;

    private JScrollPane scrollPane;
    private JLabel labelHost;       
    private JLabel labelPort;
    private JTextField tfHost;      // 服务器地址输入框
    private JTextField tfPort;      // 服务器端口输入框
    private JButton btnConnect;     // 连接/断开服务器按钮

以上都是是用户界面中的UI元素对象,后面需要操作或访问的对象已经通过注释来标明用途。

接下来,编写一个用户界面初始化函数initView(),对各个UI元素对象分配存储空间,并按照设计要求添加到视图中。代码如下:

    private void initView() {
        taChatList = new JTextArea(20, 20);
        taChatList.setEditable(false);

        scrollPane = new JScrollPane(taChatList);
        tfMessage = new JTextField(15);
        btnSend = new JButton("发送");

        jp1 = new JPanel();
        labelHost = new JLabel("主机地址");
        tfHost = new JTextField(15);
        tfHost.setText("localhost");
        labelPort = new JLabel("端口号");
        tfPort = new JTextField(4);
        tfPort.setText("8765");
        btnConnect = new JButton("连接");

        jp1.add(labelHost);
        jp1.add(tfHost);
        jp1.add(labelPort);
        jp1.add(tfPort);
        jp1.add(btnConnect);

        labelNick = new JLabel("昵称:");
        tfName = new JTextField(8);
        jp2 = new JPanel();
        jp2.add(labelNick);
        jp2.add(tfName);
        tfName.setText("用户0");
        jp1.setLayout(new FlowLayout(FlowLayout.CENTER));
        jp2.add(tfMessage);
        jp2.add(btnSend);
        jp2.setLayout(new FlowLayout(FlowLayout.CENTER));

        add(jp1, BorderLayout.NORTH);
        add(scrollPane, BorderLayout.CENTER);
        add(jp2, BorderLayout.SOUTH);
        setTitle("聊天室");
        setSize(500, 500);
        setLocation(450, 150);
        setVisible(true);
        setDefaultCloseOperation(EXIT_ON_CLOSE);
        
        // 当光标定位在聊天输入框时监听回车键按下事件
        tfMessage.addKeyListener(this);
        // 为发送按钮增加鼠标点击事件监听
        btnSend.addActionListener(this);
        // 为连接按钮增加鼠标点击事件监听
        btnConnect.addActionListener(this);
         // 当窗口关闭时触发
        addWindowListener(new WindowAdapter() { // 窗口关闭后断开连接
            @Override
            public void windowClosing(WindowEvent e) {
                
            }
        });
    }

找到keyPressed()方法,添加对按下回车键事件的处理:

    @Override
    public void keyPressed(KeyEvent e) {
        // TODO Auto-generated method stub
        if (e.getKeyCode() == KeyEvent.VK_ENTER) {
            // 发送聊天消息
        }
        
    }

找到actionPerformed()方法,添加对两个按钮的响应:

    @Override
    public void actionPerformed(ActionEvent e) {
        // TODO Auto-generated method stub
        if (e.getSource() == btnSend) {
            // 响应发送按钮
        } else if (e.getSource() == btnConnect) {
            // 响应连接/断开按钮
        }
    }

最后,为ClientView创建一个构造方法,并且在构造方法中调用initView()方法对用户界面进行创建:

    public ClientView() {
        initView();
    }

3.3.4 运行程序查看主窗口

到此为止,我们的客户端程序的UI视图搭建完毕。在ClientView类的末尾增加主函数作为程序运行的入口:

    public static void main(String[] args) {
        ClientView view = new ClientView();
    }

在开发环境左侧Project Explorer中选中我们的项目,点右键在弹出菜单中选择“Run As -> Java Application”,如果一切整成,则会看到我们的应用程序窗口:

可以看到窗口能够展示出来了,但是还没有实现任何功能。接下来我们编写一个网络服务模块来提供程序所需要的网络功能。

3.4 编写网络服务模块

3.4.1 创建NetworkService类

我们将网络相关的处理全部放到一个专门的网络服务模块中实现,其它模块需要时进行调用即可。在这里,我将该类命名为NetworkService。右键单击刚才创建的包,在弹出的菜单中选择“New -> Class”。在出现的对话框中的“Name”栏目中填写类名:

点击“Finish”按钮完成创建。此时,对应的源代码文件NetworkService.java将出现在com.simplechat包下:

双击NetworkService.java打开,内容如下:

package com.simplechat;

public class NetworkService {

}

3.4.2 定义NetworkService模块的功能

NetworkService模块需要提供的功能包括:

  • connnect():连接到服务器
  • disconnect():断开与服务器的连接
  • isConnected():判断当前是否已经连接到服务器
  • sendMessage():发送聊天消息

因此,为NetworkService类添加与这些功能相对应的方法:

public class NetworkService {

    /**
     * 连接到服务器
     * @param host 服务器地址
     * @param port 服务器端口
     */
    public void connect(String host, int port) {
        
    }
    /**
     * 断开连接
     */
    public void disconnect() {
        
    }
    /**
     * 是否已经连接到服务器
     * @return true为已连接,false为未连接
     */
    public boolean isConnected() {
    }
    /**
     * 发送聊天消息
     * @param name 用户名
     * @param msg 消息内容
     */
    public void sendMessage(String name, String msg) {
    }
}

3.4.3 定义回调接口

同时,以上网络功能操作完成后,往往需要反馈一些状态,例如在收到消息后通知用户界面刷新聊天内容,使得用户能够看到新消息。而根据分层设计的原理,这样的代码放在专注于网络操作的NetworkService类中是非常丑陋的做法。因此,我们定义一个回调接口,其中定义各项处理结束的通知函数。稍后我们在用户界面类(即ClientView类)中实现这个接口,就可以实现UI对网络处理的响应了。

在NetworkService类的最前面中添加接口Callback的定义,同时用Callback类型定义一个成员变量,并创建setter方法:

public class NetworkService {
    public interface Callback {
        void onConnected(String host, int port);        //连接成功
        void onConnectFailed(String host, int port);    //连接失败
        void onDisconnected();                          //已经断开连接
        void onMessageSent(String name, String msg);    //消息已经发出
        void onMessageReceived(String name, String msg);//收到消息
    }
    
    private Callback callback;
    public void setCallback(Callback callback) {
        this.callback = callback;
    }
        ...
}

3.4.4 添加网络通信相关的成员变量

添加网络通信所需要的以下成员变量:

    // 套接字对象
    private Socket socket = null;
    // 套接字输入流对象,从这里读取收到的消息
    private DataInputStream inputStream = null;
    // 套接字输出流对象,从这里发送聊天消息
    private DataOutputStream outputStream = null;
    // 当前连接状态的标记变量
    private boolean isConnected = false;

3.4.5 实现connect()操作

connect()方法实现连接服务器的操作。它的逻辑如下:

  • 根据参数提供的服务器地址和端口创建套接字。创建套接字的过程即建立连接的过程。
  • 如果创建成功,记录已连接状态,同时通过回调函数通知外界连接成功。同时还要启动一个线程来监听是否有服务器发来的聊天消息。
  • 如果创建套件字失败,则记录未连接状态,通过回调函数通知外界连接失败

为connect()方法编写代码如下:

    public void connect(String host, int port) {
        try {
            // 创建套接字对象,与服务器建立连接
            socket = new Socket(host, port);
            isConnected = true;
            // 通知外界已连接
            if (callback != null) {
                callback.onConnected(host, port);
            }
            // 开始侦听是否有聊天消息到来
            beginListening();
        } catch (IOException e) {
            // 连接服务器失败
            isConnected = false;
            // 通知外界连接失败
            if (callback != null) {
                callback.onConnectFailed(host, port);
            }
            e.printStackTrace();
        }
    }

其中,用来监听聊天记录到来的方法beginListening()实现如下:

    private void beginListening() {
        Runnable listening = new Runnable() {
            @Override
            public void run() {
                try {
                    inputStream = new DataInputStream(socket.getInputStream());

                    while (true) {
                        String[] s = inputStream.readUTF().split("#");
                        if (callback != null) {
                            callback.onMessageReceived(s[0], s[1]);
                        }
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        };
        (new Thread(listening)).start();
    }

3.4.6 实现disconnect()操作

disconnect()的功能是断开与服务器的连接。在这里要关闭套接字,并且关闭所有的输入、输出流。实现代码如下:

    public void disconnect() {
        try {
            if (socket != null) {
                socket.close();
            }
            if (inputStream!= null) {
                inputStream.close();
            }
            if (outputStream != null) {
                outputStream.close();
            }
            isConnected = false;
            // 通知外界连接断开
            if (callback != null) {
                callback.onDisconnected();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

3.4.6 实现isConnected()

外界通过调用isConnected()方法来获知当前是否已经连接到服务器。简单的返回isConnected变量即可:

    public boolean isConnected() {
        return isConnected;
    }

3.4.7 实现sendMessage()操作

sendMessage()方法将参数传来的用户名和消息串按照一定的格式发送出去。操作的实质就是将消息写入到套接字对象的输出流。实现如下:

    public void sendMessage(String name, String msg) {
        // 检查参数合法性
        if (name == null || "".equals(name) || msg == null || "".equals(msg)) {
            return;
        }
        if (socket == null) {   //套接字对象必须已创建
            return;
        }
        
        try {
            // 将消息写入套接字的输出流
            outputStream = new DataOutputStream(socket.getOutputStream());
            outputStream.writeUTF(name + "#" + msg); 
            outputStream.flush();
            // 通知外界消息已发送
            if (callback != null) {
                callback.onMessageSent(name, msg);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

到此为止,我们的网络服务模块就编写完成了。下面,我们回到用户界面视图,即ClientView类中,创建网络服务模块对象,然后在相关的地方调用网络服务模块提供的功能。

3.5 用户界面与网络服务模块对接

3.5.1 为ClientView类增加NetworkService模块并实现回调

重新打开ClientView.java文件进行修改。
为了在ClientView类中对网络服务模块即NetworkService类进行调用,首先必须在ClientView类中增加一个NetworkService类型的成员:

    private NetworkService networkService;

然后专门写一个函数initNetworkService()来初始化这个networkService对象。这个函数主要做两件事:

  • 创建networkService对象(new)
  • 设置回调接口以处理NetworkService中各个网络操作发来的通知
    initNetworkService()函数实现如下:
    private void initNetworkService() {
        networkService = new NetworkService();
        networkService.setCallback(new Callback() {
            @Override
            public void onConnected(String host, int port) {
                // 连接成功时,弹对话框提示,并将按钮文字改为“断开”
                alert("连接", "成功连接到[" + host + ":" + port + "]");
                btnConnect.setText("断开");
            }

            @Override
            public void onConnectFailed(String host, int port) {
                // 连接失败时,弹对话框提示,并将按钮文字设为“连接”
                alert("连接", "无法连接到[" + host + ":" + port + "]");
                btnConnect.setText("连接");
            }

            @Override
            public void onDisconnected() {
                // 断开连接时,弹对话框提示,并将按钮文字设为“连接”
                alert("连接", "连接已断开");
                btnConnect.setText("连接");
            }

            @Override
            public void onMessageSent(String name, String msg) {
                // 发出消息时,清空消息输入框,并将消息显示在消息区
                tfMessage.setText("");
                taChatList.append("我(" + name + "):\r\n" + msg + "\r\n");
            }

            @Override
            public void onMessageReceived(String name, String msg) {
                // 收到消息时,将消息显示在消息区
                taChatList.append(name + ":\r\n" + msg + "\r\n");
            }
        });
    }

其中,alert()函数用来显示一个对话框以向用户通告某个信息,实现如下:

    // 显示标题为title,内容为message的对话框
    private void alert(String title, String message) {
        JOptionPane.showMessageDialog(this, message, title, JOptionPane.INFORMATION_MESSAGE);
    }

然后找到构造方法ClientView(),在其末尾调用这个函数如下:

    public ClientView() {
        initView();
        initNetworkService();
    }

3.5.2 实现用户交互

不同的用户交互将会触发相应的网络操作,包括:

  • 未连接状态下,点击连接/断开按钮执行连接操作
  • 已连接状态下,点击连接/断开按钮执行断开连接操作
  • 已连接状态下,关闭窗口执行断开连接操作
  • 按回车键发送消息
    下面分别调用NetworkService模块提供的功能来完成以上的交互操作。

3.5.2.1 处理关闭窗口操作

在ClientView类中找到如下代码:

         // 当窗口关闭时触发
        addWindowListener(new WindowAdapter() { // 窗口关闭后断开连接
            @Override
            public void windowClosing(WindowEvent e) {  
            }
        });

增加断开连接操作,如下:

         // 当窗口关闭时触发
        addWindowListener(new WindowAdapter() { // 窗口关闭后断开连接
            @Override
            public void windowClosing(WindowEvent e) {
                networkService.disconnect();
            }
        });

3.5.2.2 处理按钮点击操作

其中包括对连接/断开按钮的点击,以及对发送按钮的点击。前者连接或者断开服务器,后者将编辑框中的消息发送出去。
找到actionPerformed()方法,改写如下:

    @Override
    public void actionPerformed(ActionEvent e) {
        // TODO Auto-generated method stub
        if (e.getSource() == btnSend) {
            sendMessage();
        } else if (e.getSource() == btnConnect) {
            // 响应连接/断开按钮
            if (!networkService.isConnected()) {
                // 未连接状态下,执行连接服务器操作
                String host = tfHost.getText();
                int port = Integer.valueOf(tfPort.getText());
                networkService.connect(host, port);
            } else {
                // 已连接状态下,执行断开连接操作
                networkService.disconnect();
            }
        }
    }

其中,sendMessage()方法实现如下:

    private void sendMessage() {
        // 响应发送按钮
        String name = tfName.getText();
        String msg = tfMessage.getText();
        // 检查参数合法性
        if (name == null || msg == null || "".equals(name) || "".equals(msg)) {
            return;
        }
        // 发送消息
        networkService.sendMessage(name, msg);
    }

3.5.2.3 处理回车键按下操作

当按下回车键时,如果聊天输入框中有内容,就将其发送出去。
找到keyPressed()方法,改下如下:

    @Override
    public void keyPressed(KeyEvent e) {
        // TODO Auto-generated method stub
        if (e.getKeyCode() == KeyEvent.VK_ENTER) {
            // 发送聊天消息
            sendMessage();
        }
    }

到此为止,聊天室客户端程序编写完成。

四、 测试聊天室系统

4.1 运行服务器端程序

从以下链接下载已编写好的聊天室服务器端代码工程:

https://pan.baidu.com/s/1GHHWY3pv5DBrIQvSBqfl2Q

下载后解压。在eclipse中选择菜单项“File -> Import”,在弹出的对话框中选择“General -> Existing Project into Workspace”,点击“Next”按钮进入下一步对话框,在“Select Root Directory”项下选择刚才解压出来的目录:

点击“Finish”按钮完成导入。此时,服务器端项目出现在窗口左侧的列表中:

按照之前运行客户端程序同样的方法运行此项目,将会看到如下的窗口:

服务器端程序窗口

点击“打开服务器”按钮,使服务器进入运行状态:

4.2 运行客户端程序并连接服务器

连续运行两次我们编写的客户端程序,得到两个客户端程序窗口。分别点击各自的“连接按钮”,观察服务器程序窗口的变化。

分别在各个客户端窗口输入并发送消息,观察是否能够出现在另一客户端窗口。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,335评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,895评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,766评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,918评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,042评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,169评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,219评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,976评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,393评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,711评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,876评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,562评论 4 336
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,193评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,903评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,699评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,764评论 2 351

推荐阅读更多精彩内容