第二十二章 聊天工具

今天我们来做一个聊天工具。在之前的Socket章节中,我们实现了客户端和服务器端的聊天通信。我们本章节要实现的是客户端与客户端的聊天通信,这需要服务器端充当桥接的作用。说白了,就是客户端A月客户端B聊天的话,是通过服务器进行转发的。本项目案例我们使用Java Swing进行图形化开发,同时使用多线程和NIO技术。

首先,我们先要规划以下客户端的程序设计。

  • 客户端需要登录,用来区分每个客户端的身份ID
  • 聊天主界面分为左右两部分,左边是通讯录列表,右边上下分开,上面是聊天记录,下面是聊天内容输入和发送按钮。
  • 我们使用MVC模式进行代码的拆分,其中View用来实现图形化部分,Model用来与服务器进行交互,Controller用来协调View和Model做用户交互事件响应。
  • 我们将整个项目分成两个模块,一个是登录模块,该模块由LoginModel,LoginView,LoginController组成,LoginModel采用UDP与服务器端通信。另一个就是聊天模块,该模块由ChatModel,ChatView,ChatController组成,LoginModel采用TCP与服务器端通信。
  • 关于登录用户内容,我们简单直接使用UserInfo和UserModel组成。其中UserInfo里面包含用户ID和昵称,UserModel里面由一个List固定存储了几个用户名单。
  • 关于Controller如何协调View和Model呢?登录的操作流程是,用户录入账号和密码,然后点击登录按钮,接着将账号和密码发送服务器端进行验证,服务端验证后返回客户端的结果(成功还是失败)。请注意,这个流程里面的操作是由不同的层来实现的。用户输入账号和密码是在View层实现的,点击登录按钮这个事件,也是在View层实现的,但是这个事件的处理则是交给Model层来实现的,因为需要将账号密码发送服务器端进行验证。View层不会直接调用Model层来实现这个过程,这就需要Controller层来协调。我们大部分情况使用“回调接口”来实现这个协调。首先,我们定义一个LoginViewEvent接口,里面只有一个handler方法。这个接口由LoginController来实现。我们在实例化LoginView的时候,将LoginViewEvent接口类型的LoginController传递给LoginView,这样,当点击登录按钮的时候,我们就可以调用LoginController里面的handler方法,在这个方法中,我们可以直接调用LoginModel的方法与服务端进行通信啦。服务端进行登录验证后需要返回客户端验证标识,我们同样需要在LoginView层来做出提示,这个过程也是通过“回调接口”来实现的。同理,我们先定义一个ServerCallback接口,该接口中只定义了一个response方法。这个接口同样由LoginController来实现。我们在实例化LoginModel的时候,将ServerCallback接口类型的LoginController传递给LoginModel。这样,当服务器端返回数据的时候,我们就可以调用LoginController的response方法啦,在这个方法中,我们可以调用LoginView的提示方法啦。这是重点,一定要能够理解。
  • 聊天数据包的设计,不管是TCP还是UDP通信,数据内容都是字节数组。如果我们仅仅只发送单一的数据类型,我们可以直接对字节数组进行数据类型转换。但是,我们的数据包中包含了很多类型的数据,除了字符串类型外,其他基本数据类型都有固定的大小。我们的聊天数据包只有两种数据,一个是用户ID,也就是占据4个字节的整型数据。另一个数据就是聊天文本,也就是字符串类型,这种数据类型我们需要给出字符串的字节长度。这样,我们发送的数据包内容如下:数据总长度+用户ID+聊天文本长度+聊天文本。同理,我们解析的时候,也按照这个格式来。这里需要大家注意的是用户ID这个数据。当我(张三)向李四发送聊天消息的时候,这个用户ID就是李四,服务器端接收到之后,需要把这个用户ID改成我(张三),然后发送给李四,这样李四就能收到消息后,就知道是我(张三)发的啦。

介绍完上面的设计注意点之后,我们再来看看客户端程序的文件结构:

ChatData:              聊天数据对象,里面就包含用户ID和聊天文本内容

ChatDataList:        待发送聊天数据列表,其实就是一个ArrayList存放字节数组

ChatView:             聊天窗口,包含通讯录列表,聊天记录,输入框和发送按钮

ChatViewEvent:   界面点击事件回调接口,里面包含发送方法和切换好友方法

ChatModel:            服务器TCP通信模型,发送聊天内容和接收聊天内容

ChatController:     通过接口来协调ChatView和ChatModel工作

LoginView:            登录窗口,包含账号,密码和登录按钮

LoginViewEvent: 界面点击事件回调接口,里面包含登录方法

LoginModel:          服务器UDP通信模型,发送账号密码,接收服务器验证

LoginController:   通过接口协调LoginView和LoginModel工作

ServerCallback:   服务器通信回调接口,里面就包含response方法

ServerConfig:      服务器配置类,包括IP地址和端口号等等

UserInfo:             用户数据对象,里面就包含用户ID和昵称

UserModel:         用户模型对象,里面硬代码写了通讯录种所有用户

Hello:                  程序入口

FileLogUtil:        日志类

接下来,我们就逐一介绍每一个类的重点代码,首先是LoginView的代码如下:

// 登录视图(窗口)
public class LoginView {
	
	// 窗口控件
	private JFrame frame;
	// 输入框UI控件
	private JTextField usernameTextField;
	private JPasswordField passwordField;
	// 按钮控件
	private JButton btn;
	
	// 视图事件回调
	private LoginViewEvent event;
	
	
	// 构造方法
	public LoginView(LoginViewEvent event) {
		// 登录按钮点击事件回调
		this.event = event;
	}
	
	// 显示登录窗口
	public void show() {
		
        // 添加按钮的点击事件监听器
        btn.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                login();
            }
        });

		// 显示窗体
		frame.setVisible(true);
	}
	
	// 登录事件处理
	public void login() {
		String username = usernameTextField.getText();
		String password = passwordField.getText();
		if(username.isEmpty() || password.isEmpty()) { return; }
		this.event.handler(username, password);
	}
}

以上代码只是该类的部分代码,主要内容除了界面布局之外,就是LoginViewEvent回调的使用,它其实就是LoginContrller类。接下来,我们看看LoginModel类的内容,如下:

// 登录模型(UDP通信)
public class LoginModel {

	// 回调方法
	private ServerCallback callback;
	
	// 构造方法
	public LoginModel(ServerCallback callback) {
		
		this.callback = callback;
	}
	
	
	// UDP请求服务器
	public void login(String username, String password) throws Exception {
		
		/****************向服务器端发送数据************************/
		
		// 数据统一转化成字节数组
		byte[] ubyte = username.getBytes();
		int ulen = ubyte.length;
		byte[] pbyte = password.getBytes();
		int plen = pbyte.length;
		
		// 数据包=数据体长度+数据体
		int blen = ulen + plen + 4+ 4; // int是4个字节
		ByteBuffer buffer = ByteBuffer.allocate(blen);
		buffer.putInt(ulen); // int是4个字节
		buffer.put(ubyte);
		buffer.putInt(plen); // int是4个字节
		buffer.put(pbyte);
		
		// 服务端地址
		int port = ServerConfig.UDP_PORT;
		InetAddress server = InetAddress.getByName(ServerConfig.IP);
		
		// 创建UDP数据包,包含目标计算机地址和端口
		byte[] data = buffer.array();
		int len = data.length;
		DatagramPacket packet = new DatagramPacket(data, len, server, port);
		
		// 创建DatagramSocket对象
		DatagramSocket socket = new DatagramSocket();
		
		// 发送消息
		FileLogUtil.log("发送账号密码:"+username+":"+password);
		socket.send(packet);
		
		/****************接收服务器端响应的数据*******************/
		
		// 要接收的数据包
		byte[] data2 = new byte[ServerConfig.PACKET_MAX];
		DatagramPacket packet2 = new DatagramPacket(data2, data2.length);
		
		// 阻塞式接收消息
		socket.receive(packet2);
		
		// 创建数据缓冲对象
		int len2 = packet2.getLength();
		ByteBuffer buffer2 = ByteBuffer.allocate(len2);
		buffer2.put(data2, 0, len2);
		
		// 开始读取缓冲对象
		buffer2.flip();
		buffer2.rewind();
		
		// 读取用户ID
		int id = buffer2.getInt();
		String result = String.valueOf(id);
		FileLogUtil.log("接收用户ID:"+result);
		
		// 回调函数
		callback.response(result);
		
		// 关闭socket
		socket.close();
	}

}

在上述代码中,一方面是UDP的通信过程,另一方面就是数据包的构建,最后就是服务返回用户ID后使用ServerCallback回调方法。接下来,我们看看LoginController的内容:

// 登录控制器,同时继承事件回调和网络回调
public class LoginController implements LoginViewEvent, ServerCallback {

	// 视图类和模型类
	private LoginView view;
	private LoginModel model;
	
	// 构造方法
	public LoginController() {
		
		// 实例化视图和模型,参数为自己
		view = new LoginView(this);
		model = new LoginModel(this);
	}
	
	// 开始方法
	public void start() {
		
		view.show();
	}
	
	// 登录事件处理,请求服务器,获取返回结果 
	@Override
	public void handler(String username, String password) {
		
		try {
			model.login(username, password);
		} catch (Exception e) { e.printStackTrace(); }
	}
	
	// 返回请求服务器结果(成功还是失败)
	@Override
	public void response(String result) {
		
		// 用户ID
		int uid = Integer.parseInt(result);
		if(uid == 0) {
			// 登录失败
			view.showMsg("登录失败");
			FileLogUtil.log("登录失败");
		}else {
			// 进入聊天界面
			view.close();
			new ChatController(uid).start();
		}
	}

}

由于我们的代码都集中在Mode和View中,因此Controller的代码并不多。重点说明的就是LoginController继承了两个接口LoginViewEvent和ServerCallback,一个是视图事件的处理,另一个就是服务器网络请求回调。在网络请求回调成功后,就会进入聊天界面。因此,整个客户端程序是从登录模块开始的,在我们的入口main方法中代码如下所示:

public class Hello {

	// 程序入口方法
	public static void main(String[] args) {
		
		// 进入登录模块
		LoginController loginController = new LoginController();
		loginController.start();
	}
}

登录模块运行是从LoginController的start方法开始的,它显示登录界面,然后输入账号密码后,点击登录,登录成功后进入聊天界面,运行如下:

如果输入正确的账号和密码就可以进入聊天页面了,先看看界面把,如下:

我们可以同时启动两个客户端程序,最简单的方式就是在“F:\workspace\Hello\bin”目录下启动两个Dos黑窗口,然后都执行“Java Hello”命令就能显示两个登录窗口,分别输入不同的账号和密码,就可以进入聊天界面,选中聊天对象,就可以与对方进行聊天了。

接下来,我们继续讲解聊天模块,首先是ChatView代码如下所示:

// 聊天视图类
public class ChatView {
	
	// 事件回调
	private ChatViewEvent event;
	
	// 构造方法
	public ChatView(ChatViewEvent event, List<UserInfo> friendArray) {
	
		// 传递对象
		this.event = event;
	}
	
	// 显示聊天窗口
	public void show() {
		// 聊天输入框和发送按钮
		btn.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
            	sendMsg(); 
            }
        });
		// 显示窗体
		frame.setVisible(true);
	}
	
	// 更新聊天记录
	public void updateChat(ChatData data) {}
	
	// 发送聊天文字
	public void sendMsg() {
		
		// 发送文本
		String txt = inputText.getText();
		if(txt.isEmpty()) return;
		
		// 上传服务端发送给对方
		event.sendHandler(txt);
		inputText.setText("");
	}

}

以上只是部分代码,主要是sendMsg方法用来发送消息,updateChat方法用来显示接收到的消息,这两个方法的本质都是在ChatController中实现和调用的。接下来,我们看看ChatModel中的代码,如下所示:

// 聊天模型线程类(TCP通信)
public class ChatModel extends Thread {

	// 定义Selector(选择区)对象
	private Selector selector = null;
	
	// 定义读写Buffer(缓冲区)对象
	private final int size = 10240;
	private ByteBuffer writeBuffer;
	private ByteBuffer readBuffer;
	
	// 服务器请求回调
	private ServerCallback callback;
	
	// 用户ID(必须优先发送给服务器)
	private int userID;
	
	// 构造方法
	public ChatModel(ServerCallback callback, int uid) {
		
		this.userID = uid;
		this.callback = callback;
		writeBuffer = ByteBuffer.allocate(size);
		readBuffer = ByteBuffer.allocate(size);
	}
	
	@Override
	public void run() {}
	
	// 处理连接事件
	private void connect(SelectionKey key) throws IOException {}
	
	// 处理读事件
	private void read(SelectionKey key) throws IOException {}
	
	// 处理写事件
	private void write(SelectionKey key) throws IOException {
		
		// 获取socket通道
		SocketChannel channel = (SocketChannel) key.channel();
		if(null == channel) return;
		
		// 要发送服务器的数据大小
		int size = ChatDataList.getInstance().getSize();
		if(size <= 0) return;
		
		// 写入缓冲区 
		writeBuffer.clear();
		writeBuffer.put(ChatDataList.getInstance().getByteData());
		
		// 写入socket通道
		writeBuffer.flip();
		channel.write(writeBuffer);
		FileLogUtil.log("向服务器发送数据......");
	}

}

以上只是部分代码,省略的部分基本上都是我们之前讲解过的内容。这里需要大家注意的是写事件的处理。我们将要发送的数据包先放置到了ChatDataList中,然后在写事件write方法处理的时候,去从ChatDataList获取就可以啦。ChatDataList相当于数据中介,连通View和Model,因为write方法的调用不是由我们的Controller来手动控制的。ChatModel本身是一个线程,它会不停的处理读写事件,读事件肯定是从服务器接收数据,而写事件则是根据用户的输入来决定的。因此在从ChatDataList中获取数据的时候,如果为空,则不需要写。接下来就是ChatController的控制器类,代码如下:
 

// 聊天控制器类
public class ChatController implements ChatViewEvent, ServerCallback {

	// 视图类和模型类
	private ChatView view;
	private ChatModel model;
	
	// 当前用户ID
	private int userID;
	
	// 当前聊天好友ID
	private int friendID;
	
	// 好友列表
	private List<UserInfo> friendList;
	
	// 构造方法
	public ChatController(int uid) {
		
		// 当前用户
		userID = uid;
		
		// 获取好友列表
		friendList = new ArrayList<UserInfo>();
		UserModel.getInstance().getFriendList(friendList, uid);
		
		// 实例化模型类和视图类
		model = new ChatModel(this, uid);
		model.start(); // 启动TCP连接线程
		view = new ChatView(this, friendList);
	}
	
	// 开始方法
	public void start() {
		
		view.show();
	}
	
	// 接收聊天消息
	@Override
	public void response(String result) {
		
		// 显示到视图上
		view.updateChat(new ChatData(uid, content));
	}
	
	// 发送聊天消息
	@Override
	public void sendHandler(String txt) {
		
		// 放入待发送列表中
		ChatDataList.getInstance().putByteData(buffer.array());
	}
	
	// 切换好友聊天
	@Override
	public void changeHandler(int id) {
		friendID = id;
	}

}

上述只是部分代码,主要是response和sendHandler两个方法的实现,前者用于接收消息数据并显示到View视图上(动态添加UI控件而已),而后者则是把待发的消息放置到ChatDataList中,上文中的ChatModel中,我们也提到了真正发送write方法中就是从该列表中获取消息,然后发送给服务器端。客户端的其他代码我们就不再详细介绍了。

接下来,我们来介绍服务器端的整个项目代码。服务器端由两个模块组成,一个是登录模块,也就是UDP服务监听,同时支持验证账号密码功能;另一个是聊天模块,也就是TCP服务监听,聊天消息转发功能。由于聊天模块需要同时处理多个客户端的连接,因此需要借助线程池技术来解决。我们直接给出服务端的文件结构,如下:

UDPServer                      服务端UDP监听线程,主要解决用户登录返回用户ID

NioData                           聊天数据对象,里面包含用户ID和聊天文本内容

NioServerThread           服务器端TCP监听线程,使用NIO来接收(线程池处理)和发送数据

NioPacketThread           接收数据包处理线程,转发给聊天对方的Channel通道

NioClient                          客户端对象,持有自己的Channel通道和待发送消息列表

NioClientList                  客户端Map列表,Key是用户ID,Value是NioClient对象

UserInfo                          用户数据对象,里面包含用户ID,昵称,账号和密码

UserModel                     用户数据模型,硬代码存储所有用户信息列表,支持登录查询用户ID

ServerConfig                 服务端配置类,主要是监听端口定义

HelloServer                   入口方法

FileLogUtil                    日志类

首先,我们先说一下UDPServer线程类,它接收客户端的账号和密码,然后从UserModel中查询用户ID,然后在返回给客户端,大致代码如下:


@Override
public void run() {
	super.run();
	try { while (!Thread.currentThread().isInterrupted()) {
		
		/************接收客户端发送的数据****************/
		
		// 创建服务器端DatagramSocket,并指定UDP端口
		DatagramSocket socket = new DatagramSocket(ServerConfig.UDP_PORT);
		
		// 创建接收客户端发来的数据包
		byte[] data = new byte[ServerConfig.PACKET_MAX];
		DatagramPacket packet = new DatagramPacket(data, data.length);
		
		// 接收客户端发送的数据,此方法在接收到数据包之前会一直阻塞
		socket.receive(packet);
		
		// 创建数据缓冲对象
		int len = packet.getLength();
		ByteBuffer buffer = ByteBuffer.allocate(len);
		buffer.put(data, 0, len);
		
		// 登录检查,返回用户ID
		int userID = checkLogin(buffer);
		
		// 用户ID转换字节数组,返回客户端的数据
		ByteBuffer buffer2 = ByteBuffer.allocate(4);
		buffer2.putInt(userID);
		byte[] data2 = buffer2.array();
		
		/************向客户端发送数据****************/
		
		// 获取客户端的地址和端口号
		InetAddress clientAddress = packet.getAddress();
		int cilentPort = packet.getPort();
		
		// 创建要返回的数据包
		DatagramPacket packet2 = new DatagramPacket(data2, data2.length, clientAddress, cilentPort);
		
		// 发送给客户端
		socket.send(packet2);
		
		// 关闭socket
		socket.close();
		
	} } catch (IOException e) { e.printStackTrace(); }
}

在上述代码中,主要是如何向客户端发送数据,这是我们之前没有介绍的,其实也很简单。接下来我们重点介绍NIO聊天内容转发的聊天模块,首先是NioServerThread线程类的介绍,

// 服务器NIO线程
public class NioServerThread extends Thread {
	
	// 选择器对象
	private Selector _selector;
	
	// 读写缓冲区
	private ByteBuffer _write_buffer;
	private ByteBuffer _read_buffer;
	
	// 线程池
	private ThreadPoolExecutor _packetHandlerThreadPool;
	
    // 构造方法
    public  NioServerThread() throws IOException {}
	
	@Override
	public void run() {}
	
    // 接入事件
	public void acceptConnection(SelectionKey key) throws IOException {
		
		// 创建socketChannel
		ServerSocketChannel serverSocketChannel = (ServerSocketChannel)key.channel();
		SocketChannel socketChannel = serverSocketChannel.accept();
		
		// 将socketChannel设置为非阻塞工作模式
		socketChannel.configureBlocking(false);
		
		// 将channel注册到selector上,监听可读事件       
		SelectionKey clientKey = socketChannel.register(_selector, SelectionKey.OP_READ|SelectionKey.OP_WRITE);
		
		// 绑定客户端,还没有用户ID
		NioClient client = new NioClient(clientKey);
		clientKey.attach(client);
		FileLogUtil.log("新用户端请求连接服务器!");
	}
	
	// 可读事件
    public void readPacket(SelectionKey key,  NioClient client) {
    	
    		// 获取 Socket 通道
    		SocketChannel channel = (SocketChannel)key.channel();
    		
    		try {
    			
    			// 接受数据前得清零
    			_read_buffer.clear();
        		
        		// read()方法返回的int值表示读了多少字节进Buffer里
        		int result = channel.read(_read_buffer);
        		if(result >= 0 ) {
        			
        			// 使用线程池来处理读取的数据
        			_read_buffer.flip();
        			_read_buffer.rewind();
        			byte[] data = new byte[_read_buffer.remaining()];
        			_read_buffer.get(data, 0, data.length);
        			NioPacketThread handler = new NioPacketThread(client, data);
        			_packetHandlerThreadPool.execute(handler);
        		}
    			
    		} catch (IOException e) {
    		
    			// 清理用户端连接,这里才是最重要的地方!!!
    			int uid = client.getUserID();
    			if(NioClientList.getInstance().containClient(uid))
    			NioClientList.getInstance().removeClient(uid);
    			client = null;
            	key.cancel();
            	try { channel.close(); } catch (IOException e1) {}
            	FileLogUtil.log("用户端 " + uid + " 退出:" + e.toString());
        }
    }
	
    // 写入事件
    public void writePacket(SelectionKey key,  NioClient client) throws IOException {
    	
    		// 没有待发数据
    		ByteBuffer data = client.getByteData();
    		if(data == null) return;
    		
    		// 用户待发送的数据写入 _write_buffer
    		_write_buffer.clear();
    		_write_buffer.put(data);
    		
    		// 发送用户端数据
    		_write_buffer.flip();
    		_write_buffer.rewind();
    		SocketChannel channel = (SocketChannel) key.channel();
    		if(null != channel) channel.write(_write_buffer);
    		FileLogUtil.log("向用户 " + client.getUserID() + " 发送数据......");
    }

}

在上述代码中,重点就是NioClient类的使用。因为这是服务器端程序,它要处理多个客户端的连接以及数据接收和转发工作,为了能够区分各个客户端Channel通道以及各自的通信数据,因此我们创建了NioClient类,并将此类与SelectionKey(Channel通道)绑定在一起。NioClient类中记录了用户信息,以及待发送的聊天消息列表(在客户端项目中,我们使用直接使用ChatDataList列表来存储聊天消息,因为客户端项目就代表一个客户端,而服务器端项目需要处理多个客户端(客户端列表NioClientList),每个客户端都必须由一个聊天消息列表)。NioClient类的实例化是在客户端连接服务器上的时候创建的,也就是acceptConnection事件处理中。此时,我们仅仅实例化了NioClient类,里面的数据都是空的,尤其是客户数据,因为没有用户ID,因此我们还不能放到客户端列表NioClientList中。客户端连接成功之后,就开始接收客户端发来的数据,也就是readPacket方法中。在该方法中,我们读取客户端发来的数据,然后使用一个线程NioPacketThread来对数据包进行处理。处理的大致过程就是解析数据包里面的聊天对方ID,然后将聊天消息转发给聊天对方ID的Channel通道。如何根据用户ID来查询对方的Channel通道,这就需要使用客户端列表NioClientList中进行查询,如果查询不到,说明对方不在线。最后介绍的是写事件writePacket方法,该方法就是从自己绑定的NioClient类中的聊天消息列表中获取待发送的数据,然后通过自己的Channel通道发送出去。

接下来,就是NioPacketThread线程类,该类主要处理客户端发来的聊天消息,从中解析出对方ID,然后查询对方的NioClient类,将消息放置到对方NioClient类的聊天消息列表中,

// 解析数据包
public void explain(ByteBuffer data) {
		
		// 数据包总长度(int数据类型)
		// 总长度是为了解决半包的问题
		int total = data.getInt();
		
		// 这里单独处理以下用户ID的问题
		// 这就要求客户端必须第一次发送自己的ID数据
		if(_client.getUserID() == 0) {
			_client.setUserID(total);
			NioClientList.getInstance().addClient(total, _client);
			FileLogUtil.log("接收客户端用户ID:"+total);
			return;
		}
		
		// 接收者用户ID(int数据类型)
		int id = data.getInt();
		FileLogUtil.log("接收者ID:"+id);
		
		// 聊天文本信息 
		int len = data.getInt();
		byte[] contentArray = new byte[len];
		data.get(contentArray);
		String content = new String(contentArray);
		FileLogUtil.log("接收内容:"+content);
		
		// 转发给客户端ID
		dispatchMsg(id, content);
		
		// 数据包是否还有内容,暂时不处理了
		if(data.hasRemaining()) return;	
}

// 转发给客户
public void dispatchMsg(int id, String content) {
		
		// 发送者信息,不是接收者信息
		int userID = _client.getUserID();
		
		// 聊天文本信息
		byte[] contentArray = content.getBytes();
		int len = contentArray.length;
		
		// 构建数据包:总长度 + UID + Content长度 + Content
		// 长度和UID都是int类型,就是4个字节
		// 总长度 = UID长度(4)+ Content长度(4)+ Content
		int totalLen = 4 + 4 + len;
		ByteBuffer data = ByteBuffer.allocate(totalLen+4);
		
		// 先放置总长度
		data.putInt(totalLen);
		
		// 放置用户ID
		data.putInt(userID);
		
		// 放置聊天消息
		data.putInt(len);
		data.put(contentArray);
		
		// 放置接收者用户待发列表中
		NioClient client = NioClientList.getInstance().getClient(id);
		if(client != null) {
			data.flip();
			client.putByteData(data.array());
			FileLogUtil.log("转发给用户("+id+")内容:"+content);
		} else {
			FileLogUtil.log("转发用户不在线!!!");
		}
}

我们主要介绍explain和dispatchMsg方法。前者主要是解析客户端发来的数据。这里需要重点介绍的是,客户端在连接服务器成功后,首先要发送自己的用户ID给服务器端,也就是说,服务器接收到客户端的第一个消息不是聊天消息,而是自己的用户ID。因此在explain方法的开始就是用户ID的处理,给NioClient类赋予用户ID,然后添加到NioClientList中。处理完这个重要的数据后,我们才处理聊天消息,也就是将聊天消息发送给对方。这个是由dispatchMsg来完成的,该方法就是重新拼凑数据包,将用户ID转换成发送者的ID,然后放置到接收者NioClient中的待发消息列表中。我们之前的writePacket方法中介绍过,该方法就是从NioClient中的待发消息列表中获取字节数组数据,然后通过Channel通道发回客户端,但是这个客户端就是消息信息对方(接收者)的客户端。这里大家一定要清楚,聊天信息来源于发送者客户端的Channel通道,然后从聊天信息中解析对方ID,根据ID查询对方NioClient,然后在重新组织数据包,将接收者ID转换成发送者ID,最终将数据包发送给接收者。这就是服务器端程序的核心功能。接下来,我们在看看NioClient类,

// 客户端类
public class NioClient {
	
	// 用户ID和昵称
	private int _id = 0;
	
	// 向本客户端发送的消息列表(其他客户端发过来的)
	private List<byte[]> _writeByteDataList = null;
	
	// 设置用户ID
	public void setUserID(int id) {
		
		this._id = id;
		this._name = UserModel.getInstance().getUserName(id);
	}
	
	// 添加发送数据
	public void putByteData(byte[] data) {
		
		_writeByteDataList.add(data);
	}
	
	// 汇总要发送的字节数据
	public ByteBuffer getByteData() {}
	
}

上述代码中,重点就是setUserID方法,NioClient代表的就是客户端,当客户端连接服务器成功后,就会创建该类,但是实例化的时候,是没有用户ID,需要让客户端通过Channel通道发送过来,我们才能标识NioClient所代表的客户端是那个用户在使用。由了用户ID我们才可以将这个NioClient放置到NioClientList列表类中,也方便我们根据用户ID来查找对应的NioClient对象。在NioClient类中除了身份属性外,就是待发消息列表,如果我们需要给该用户发送消息,就把数据添加到该列表中即可,随后在NioServerThread线程中,就会从该列表中获取数据,发送给对应的客户端啦。NioClientList类刚才已经说过啦,它就是一个Map列表而已。UserInfo和UserModel的代码与客户端项目类似,就是用户对象和用户数据模型,里面都是硬代码写了几个用户而已,注意,客户端项目和服务端项目中该类的用户数据必须保持一致啊。当然,在现实开发中,客户端的数据应该全部来源于服务器端。我们这里就不再实现这个用户数据的同步功能了。项目中剩余的类就不再介绍了,太简单了。

接下来,我们说一说项目部署或发布的问题。我们知道,Java源代码需要编译成class字节码文件,才可以运行,而且一个Java类对应一个class文件,即使我们一个源文件中写了多个类,编译后也是同样生成多个class文件。例如,本案例客户端class文件和服务端class文件如下所示:

我们可以看到,这些编译好的class文件还是不少的,在一些大型项目中,就更多了。当我们需要将这些class文件部署到目标计算机(服务器端)或者发布给用户使用的时候,直接复制这些文件显然是不太合适的。这里,我们对部署服务器和发布给用户两种情况,分别说明。首先是,部署到服务器上面,我们可以将class文件复制到服务器上某个目录下,然后通过Java HelloServer命令来执行服务器端程序,前提是服务器端也安装了JRE环境。当然,服务器端的环境搭建工作,也不是太难,我们会在后期的课程中介绍。我们部署服务器,还有另一种方式,就是将所有的class文件打包成一个JAR文件,然后复制到服务器端再运行。如何将class文件打包成JAR文件呢,当然可以通过JDK的打包命令(jar命令)。关于这个命令的使用,我们这里不再详细介绍了,因为我们要使用Eclipse提供的打包命令:

在Eclipse的Package Explorer窗口中右击我们的项目名称HelloServer

在弹出的菜单中选择“Export…”,如下图所示

在弹出的窗口中,我们需要选择“Java”->“Runable JAR file”后,点击Next

在弹出的窗口中,我们要指定入口main方法所在的类,以及导出的Jar文件路径。

点击“Launch configuration”下面的选择框,就会自动识别出我们的入口类,选择它即可。

接下来,我们点击“Browse…”来选择导出的Jar文件的路径和名称,如下所示

我们就按照项目名称来定义Jar文件名称,也就是“HelloServer”

两项都选择完毕后,就可以点击“Finish”进行打包操作啦。

打包完成后,就会出现上述弹框,表示打包完成,但是有一些警告,我们忽略这个,直接点击“OK”即可,因为是打包在我们当前项目工程目录下,因此在Package Explorer窗口中会显示这个Jar文件,如下图:

我们也可以去我们项目工程目录下看一看,

接下来,我们使用老办法,进入Dos黑窗口中,执行“java -jar HelloServer.jar”命令。

我们可以看到,服务器端的程序可以启动啦。

接下来,我们接续客户端的发布,同样也可以使用这种方式运行,我们将客户端项目打包成“Hello.jar”文件,然后在Dos黑窗口下运行,如下所示:

我们输入账号和密码,然后进入聊天页面,如下图所示:

同时,我们可以看到服务端也打印出了登录信息,如下图所示

我们可以看到,我们的程序可以正常的运行。但是,大家忽略了一个问题,就是,我们的客户端程序Hello.jar的运行方式太复杂了。作为一个Java开发人员,我们可以使用这种方式运行,但是作为一个普通的非Java开发人员,为了能够运行这个Hello.jar文件,得需要安装JRE环境,并且还得在Dos黑窗口下运行,这是不可能的。在我们Windows操作系统中,我们的桌面应用程序,一般都是一个exe可执行文件,点击即可运行。我们可以使用一个名称为“exe4j”的工具,它可以帮助我们把Java程序连同JRE打包成一个exe文件。有兴趣的同学,可以研究一下。

本课程涉及的代码可以免费下载:
https://download.csdn.net/download/richieandndsc/85645935

今天的内容就讲的这里,我们来总结一下。今天我们主要讲了聊天工具项目。这个项目不是我们日后做Web开发的重点,但是我们需要培养编程的逻辑思维能力,尤其是如何解决问题,如何规划我们的代码结构。一个优秀的程序开发人员,最重要的就是解决问题的逻辑思维能力,这个能力的培养需要大量的项目实践。在这些项目中,我们不仅仅要学会完成任务,更要学会完成任务的过程,从过程中学习很多的编程思维方式,提升自己的编程能力。好的,谢谢大家的收看,欢迎大家在下方留言,我也会及时回复大家的留言的。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值