Java第十四课——相声和群口相声
相声,我们知道有一个逗哏一个捧哏,也就是一唱一和,也就相当于双向的交流。群口相声同理,三人及三人以上,也就是群聊的效果。那么这次就来实现一个服务器和客户端双向的交流,也是客户和客户之间的交流。
其实二者实现过程同理,就拿vx来举个例子,我输入了一条消息,先发送给服务器,而这条消息是需要被我自己和接收消息的人看到的,所以服务器只是获取到我自己和接收消息的人的输出流,输出这句话就完成了。群聊同理,获取当前群聊所有人的输出流,全部输出就可以了。
先把单向通信的代码放上
服务器端:
public class Server {
ServerSocket serversocket;
public void create(){
try{
serversocket = new ServerSocket(9876);
System.out.println("ServerSocket: loading successfully");
} catch (IOException e){
e.printStackTrace();
}
}
public void conn(){
try {
//等待用户连接
Socket socket = serversocket.accept();
System.out.println("ServerSocket: user enter");
//获取输入输出流
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
int onebyte = in.read();
char ch = (char)onebyte;
System.out.println("ServerSocket: has read: " + ch);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public static void main(String[] args){
Server server = new Server();
server.create();
server.conn();
}
}
客户端:
public class UserSocket {
String ip = "0.0.0.0";//IP地址
int port = 9876;
OutputStream out;//客户端的输出流
InputStream in;//客户端输入流
public void userUI(){
JFrame frame = new JFrame();
frame.setSize(300,300);
frame.setLayout(new FlowLayout());
frame.setDefaultCloseOperation(3);
JTextField field = new JTextField(20);//输入框
JButton btn = new JButton("发送");
frame.add(field);
frame.add(btn);
frame.setVisible(true);
ActionListener l = new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// TODO Auto-generated method stub
try {
System.out.println("User: field:"+field.getText());
out.write(field.getText().getBytes());
} catch (IOException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}
}
};
btn.addActionListener(l);
}
public void conn(){
try {
Socket socket = new Socket(ip, port);
System.out.println("User: connected");
in = socket.getInputStream();
out = socket.getOutputStream();
} catch (UnknownHostException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public static void main(String[] args) {
UserSocket us = new UserSocket();
us.conn();
}
}
动手之前先梳理一下问题,明确一下目标:
1、目前只能读取一个字节,而中国汉字都是一个字符,如何实现发送文字?
2、多人聊天的话,会有多个客户端连接服务器,怎么处理?会遇到什么问题?
3、需要有聊天框来显示聊天内容,且JTextField不能满足发送多行文本的需要
1和3比较容易解决,主要是2。下面先解决1和3.
一、聊天界面优化
修改一下客户端的界面就好
1、首先把界面加大,放下更多组件
2、其次用JTextArea来代替JTextField,且用JTextArea显示聊天内容
3、加入一些个性化设计
(直接看长代码很累,但一小段一小段的代码也看得很累,所以我把改动的地方在注释里改的稍微明显了一点,就不用一行一行的看了)
public void userUI(){
JFrame frame = new JFrame();
frame.setTitle("老王加密聊天v1.0");//————————————————————————————————————增加Title
frame.setSize(600,300);//————————————————————————————————————————————————增大界面
frame.setLayout(new FlowLayout());
frame.setDefaultCloseOperation(3);
//—————————————————————————————————————inputArea和outputArea都是全局变量JTextArea
inputArea = new JTextArea(10,15);//——————————————————————————————————————客户输入
outputArea = new JTextArea(10,15);//———————————————————————————————————服务器输出
JButton btn = new JButton("发送");
outputArea.setEditable(false);
outputArea.setBackground(new Color(0xdcdcdc));//———————聊天显示框设置为灰色不可编辑
frame.add(inputArea);
frame.add(btn);
frame.add(outputArea);//——————————————————————————————————————把三个组件加到窗体上
frame.setVisible(true);
ActionListener l = new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// TODO Auto-generated method stub
try {
//——————————————————————————————————————————————把field改成inputArea
System.out.println("User: inputArea:"+inputArea.getText());
out.write(inputArea.getText().getBytes());
} catch (IOException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}
}
};
btn.addActionListener(l);
}
到这里还没有结束,毕竟没有测试(这很重要),测试方法就是连接服务器,敲一个字节看看有没有输出。
二、增加发送内容的样式
我们知道,InputStream和OutputStream只能发送和接收byte,上节课有用到更加好用的DataInputStream和DataOutputStream,不会用的小伙伴去看上节课内容:
在迷你会议的实现中使用了DataInputStream和DataOutputStream
但毕竟我们发送的消息不可能只是Int,查看DataInputStream和DataOutputStream的方法可以看到有这两个方法:
既然白给了就用它!
1、先把客户端的OutputStream和InputStream改成DataOutputStream和DataInputStream
public void conn(){
try {
Socket socket = new Socket(ip, port);
System.out.println("User: connected");
//—————————————————————因InputStream和OutputStream后面不再使用,可以变成局部变量了
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
//——————————————————————————但DataInputStream和DataOutputStream需要变成全局变量
dout = new DataOutputStream(out);
din = new DataInputStream(in);
} catch (UnknownHostException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
2、修改ActionListener里的write方法
(ActionListener在userUI的方法里作为内部类)
ActionListener l = new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// TODO Auto-generated method stub
try {
System.out.println("User: inputArea:"+inputArea.getText());
//————————————————————————改成用dout(DataOutputStream)来发,且getBytes要删去
dout.writeUTF(inputArea.getText());
} catch (IOException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}
}
};
3、把服务器端的InputStream变成DataInputStream,并修改read方法
public void conn(){
try {
//等待用户连接
Socket socket = serversocket.accept();
System.out.println("ServerSocket: user enter");
//获取输入输出流
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
//——————————————————————————————————————————————DataInputStream din作为全局变量
din = new DataInputStream(in);
//——————————————————————————————————————————————————————用readUTF读取消息并显示
String sentence = din.readUTF();
System.out.println("ServerSocket: has read: " + sentence);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
进行测试看到如下结果:
在客户端和服务器都加上while(true)就可以持续的发送和读取消息了
三、群聊雏形
回到上面的第二个问题:2、多人聊天的话,会有多个客户端连接服务器,怎么处理?会遇到什么问题?
先来看看会遇到什么问题?
当客户端连接上服务器后,服务器端会执行System.out.println(“ServerSocket: user enter”); ,那么如果已经有一个客户端连接上了服务器,当第二个服务器去连接时,服务器端的输出如下
可以看到,服务器只输出了一次ServerSocket: user enter
并且在第二个客户端输入后发送,服务器没有收到。
我们查看服务器的代码:
1| public void conn(){
2| try {
3| //等待用户连接
4| Socket socket = serversocket.accept();
5| System.out.println("ServerSocket: user enter");
6|
7| //获取输入输出流
8| InputStream in = socket.getInputStream();
9| OutputStream out = socket.getOutputStream();
10|
11| din = new DataInputStream(in);
12|
13| //读取消息
14| String sentence = din.readUTF();
15| System.out.println("ServerSocket: has read: " + sentence);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
不难看出,当第一个客户端进入后,服务器先获取输入输出流,然后就会卡在第14行,等待客户端发来消息,当第二个客户端连接时,如果第一个客户端没有输入,服务器仍在14行,就会导致无响应,而如果第一个客户输入了,因为没写while(true),服务器收完消息就会关闭了。
那么如何处理呢?
把这个问题想象成一个模型,假设现在有个用户打给警察局报警,此时如果又有一个用户来报警,难道会打不通吗?警察局的处理方式就是叫一个接线员来接电话,这样就不会使第二个人打进了的时候占线了。而我们日常打电话时,显示正在通话中,就是因为我们只有一个人,我们没有接线员,所以导致占线打不通。
所以处理办法就是,叫”一个人“来帮助我们处理这个用户,那便是多线程。
服务器只管连接,有人连接了,就分配一个线程处理
1、先修改服务器
服务器要做的就是等待连接,启动线程(这个线程假设名字为ChatTh)
public class Server {
ServerSocket serversocket;
public void create(){
try{
serversocket = new ServerSocket(9876);
System.out.println("ServerSocket: loading successfully");
} catch (IOException e){
e.printStackTrace();
}
}
public void conn(){
try {
//等待用户连接
Socket socket = serversocket.accept();
System.out.println("ServerSocket: user enter");
//———————————————————————————————————————————————————————这里往下该写什么?
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public static void main(String[] args){
Server server = new Server();
server.create();
server.conn();
}
上面的代码并不完整,主要是conn()方法里应该写什么?
是获取输入输出流,再传给线程?还是直接启动线程,把socket传过去?
2、ChatTh线程
通过构造方法获取Socket对象,然后获取输入输出流,转换成DataInputStream和DataOutputStream。然后获取消息输出就可以了
public class ChatTh implements Runnable{
Socket socket;//用户
DataInputStream din;
DataOutputStream dout;
public ChatTh(Socket socket) {
super();
this.socket = socket;
//初始化时获取输入输出流
getStream();
}
//获取输入输出流
public void getStream(){
try{
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
din = new DataInputStream(in);
dout = new DataOutputStream(out);
}catch(IOException e){
e.printStackTrace();
}
}
@Override
public void run() {
try{
while(true){//持续读取消息
String sentence = din.readUTF();
System.out.println("ServerSocket: has read: " + sentence);
}
}catch(IOException e){
e.printStackTrace();
}
}
}
再完善一下服务器conn方法:
public void conn(){
try {
while(true){//持续等待用户连接
//等待用户连接
Socket socket = serversocket.accept();
System.out.println("ServerSocket: user enter");
//启动线程进行管理
ChatTh chatth = new ChatTh(socket);
new Thread(chatth).start();
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
现在测试一下看看多个客户端连接有没有输出!
3、服务器端获取所有用户的输出流
现在距离群聊雏形还差一步,在文章最开始时也说了,获取群聊内所有用户的输出流,把服务器收到的聊天内容进行输出,就是群聊的效果。
这里用列表来收集用户的输出流,而列表的创建要在服务器的主方法里,不能在线程里。然后传给ChatTh,当一个用户发送消息之后,遍历列表发送
public class Server {
ServerSocket serversocket;
//————————————————————————————————————————————————————————————————————————创建列表
ArrayList<DataOutputStream> doutlist = new ArrayList<DataOutputStream>();
public void create(){...}
public void conn(){
try {
while(true){//持续等待用户连接
//等待用户连接
Socket socket = serversocket.accept();
System.out.println("ServerSocket: user enter");
//启动线程进行管理
//————————————————————————————————————把列表传过去,在ChatTh用构造方法接收
ChatTh chatth = new ChatTh(socket, doutlist);
new Thread(chatth).start();
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public static void main(String[] args){...}
}
ChatTh这边修改构造方法。在getStream方法中,获取到dout之后向列表里添加。最后在获取消息时,遍历列表进行输出
public class ChatTh implements Runnable{
Socket socket;//用户
DataInputStream din;
DataOutputStream dout;
ArrayList<DataOutputStream> doutlist;//——————————————————————————所有用户的输出流
//———————————————————————————————————————————————————————————————————修改构造方法
public ChatTh(Socket socket, ArrayList<DataOutputStream> doutlist) {
super();
this.socket = socket;
this.doutlist = doutlist;
getStream();
}
//获取输入输出流
public void getStream(){
try{
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
din = new DataInputStream(in);
dout = new DataOutputStream(out);
//————————————————————————————————————————————————————————————加入到列表
doutlist.add(dout);
}catch(IOException e){
e.printStackTrace();
}
}
@Override
public void run() {
try{
while(true){
String sentence = din.readUTF();
System.out.println("ServerSocket: has read: " + sentence);
//————————————————————————————————————————————————————遍历全部进行输出
for(int i = 0; i < doutlist.size(); i++){
DataOutputStream douts = doutlist.get(i);
douts.writeUTF(sentence);
}
}
}catch(IOException e){
e.printStackTrace();
}
}
}
4、客户端把收到的消息放到显示框上
客户端这边,连接上服务器之后,要做的事情就是:点击按钮发送消息,这个交给ActionListener去做,接收消息就可以通过while(true)来实现。
在客户端的conn()方法中加入while(true):
这里因为在conn()方法里写了while(true),所以打开窗体的方法不能在conn()方法之后调用,但如果没有连接上服务器之前打开窗体又不太合适,所以把userUI()方法放进conn()方法中
public void conn(){
try {
Socket socket = new Socket(ip, port);
System.out.println("User: connected");
in = socket.getInputStream();
out = socket.getOutputStream();
dout = new DataOutputStream(out);
din = new DataInputStream(in);
//——————————————————————————————————————————————————————————————————打开窗体
userUI();
//——————————————————————————————————————————————————————————————持续读取消息
while(true){
//读取到的消息
String sentence = din.readUTF();
System.out.println("User: has recieved: " + sentence);
//显示在聊天窗体上
outputArea.append(sentence + "\r\n");
}
} catch (UnknownHostException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
最后来测试一下:
那么到这里,本节课内容就实现了。当然可以进一步迭代优化:
1、仿视频软件,又能发视频又能发文字,考虑使用并发+报文头或多端口来实现。
2、客户端退出问题,如果连接上服务器之后直接退出了,就会报错,并且在doutlist的列表里就会出现僵尸dout,考虑使用心跳机制