您的位置:首页 > 其它

利用局域网聊天程序来锻炼j2se--之挑战所有你心目中的高手

2011-10-21 12:57 363 查看
写之前思考了一下,本打算发布一下最终版本,并附带所有的注释就算大功告成了.
后来细细想了下,这样的聊天程序网上很多,同时,对于只看我注释来学这个项目的人来说,他即便看懂看会,
还只能是依葫芦画瓢,不懂一步步创建的原理和调试的方法.
所以今天,我会带着大家把开发局域网聊天的详细思路和步骤用文字和代码一句句写出来,同时发布最终版无注释代码,
此目的也是让大家自己读懂每个代码段为什么要这样写的原由.
好了下面就开始吧.

首先利用Eclipse新建一个项目,同样在项目里面新建一个类,这里的具体步骤我不多说了,相信大家都比我清楚.

做之前先思考下,第一步先做什么?
聊天程序我们直观的来说就是可以有个输入栏发布消息,同时能在另一个界面上看到自己已经发布的消息从而确认发布出去了.

好,那么首先做的就是画一个你认为满意的窗口.
Frame 图形窗口类,根据我的笔记gui这课,我想你应该能画出来.
特别照顾下不喜欢查jdk帮助文档的朋友,TextField是单输入栏.TextArea是多行输入栏.(我怎么知道的,查的呗)
我简单画了个大概,大家比较下.

import java.awt.*;
public class Client extends Frame {
//由于聊天系统有一个用户的输入框,那么就用输入栏TextField类来编写
//为什么写在成员方法里,那是因为方便被窗口方法调用
TextField text=new TextField();//建立了text文本输入对象
//由于上面的是单行,作为用户输入可以,但显示栏就不行,所以必须找个多行的方法
TextArea teext=new TextArea();//多行文本编辑器

public static void main(String[] args) {
new Client().launchFrame();
}

public void launchFrame() {
// 由于对相关方法不熟悉,用this来使得有代码提示。
// 设置窗口,首先是确定位置,如果不确定,那么默认是左上角
this.setLocation(300, 200);// 移到新位置//从Component继承的
// 下面要设置窗口的大小
this.setSize(244, 366);// 调整大小//window类继承的
// 为了不让用户自己变化窗口的大小,所以要限制大小不能变化
this.setResizable(false);// 设置此窗体是否可由用户调整大小//自己的方法
// 显示窗口可以设置不同的颜色来增加视觉效果,当然,这里还是设置为默认的白色
this.setBackground(Color.white);// 设置背景色//Component类继承
//加载并设置输入栏位置,注意布局管理器的运用
this.add(text,BorderLayout.SOUTH);//这里,把输入栏载入到窗口,同时用布局管理器设置在南边
this.add(teext,BorderLayout.NORTH);//注意,鼠标输入筐是按照第一个设置哪个输入栏而默认的
//由于上面两个输入栏都是默认的大小,所以对于窗口来说高度不一定合适,所以在没有设置输入栏大小的时候用自动设置来的比较方便
this.pack();//自动 设置大小
this.setVisible(true);// 把上面的所有设置的结果 显示出来//Window 类继承
}

}

注释写的太多影响观看了,后面尽量少写点,恩.
下面我们运行下刚才的程序,有两个输入栏,同时也都能写文字,这时,我们去关闭该程序,发现他关不掉,只能在控制台中结束.那怎么办呢?
大家应该想起我的笔记上说过的监听类吧.那关闭窗口也有他的监听类和接口.不熟悉的朋友请回头看下我的笔记.恩
查找java.awt.event 图形事件包
找到WindowListener用于接收窗口事件的侦听器接口.
然后找到 windowClosing(WindowEvent e) 方法,由于该方法是 WindowAdapter继承下来的子方法.
所以我们创建WindowAdapter方法并实现windowClosing方法的操作,这样可以避免所有的方法都一一实现.
说到这,大家考虑下怎么写,写监听有三个方法,第一是写个并排的外部类,以后别的方法也可以调用.
第二是写个内部类,可以针对主方法做更多的方法实现.当然还有第三个就是写个匿名类.尽量不做其他的处理用.
考虑了一下,我想还是用匿名类比较合适.本程序在关闭事件上就只打算简单的实现关闭操作.

好具体写法如下:
this.addWindowListener(new WindowAdapter() {
public void windowClosing(WindowEvent e) {
// TODO 自动生成方法存根
System.exit(0);
}
});
顺带说下,如果你还没用Eclipse工具的话,请赶紧开始用它.应为windowClosing方法是通过编辑器来生成的.

下面考虑一个问题,我们需要在单文本输入栏上写东西,然后一敲回车,文字得移动到上面的多文本编辑框上.
这里又要使用另外一个监听器接口 ActionListener用于接收操作事件
考虑下这里具体该怎么写.
由于我们要输入文字,以后可能还要包装成管道流输出等,代码量会比较多,所以匿名类在这里就不会太方便了.
所以内部类是最好的方法
private class list implements ActionListener{
public void actionPerformed(ActionEvent e) {
//先获取输入栏的字符放到ss中
String ss=text.getText().trim();//去掉两边的空格后的字符串
//再把字符到多行输入栏显示出来
teext.setText(ss);
//然后把单行输入栏设置空
text.setText("");
}
}
同样,内部的方法通过编译器可以自动调出.好了,同时在显示窗口之前调用该方法.
text.addActionListener(new list());
^-^知道在哪里加入吧.

写到这里,我们可以在输入栏写东西并敲入回车把文字在多行输入栏显示出来了.
现在应该是考虑服务端的制作了.首先要做的是如何使得服务端和客户端连接起来.
java.net.*;网络应用程序提供类包在服务端要用到了.
import java.net.*;
public class Server {
public static void main(String[] args) {
try {
ServerSocket ss = new ServerSocket(8888);
while(true) {
Socket s1 = ss.accept();
System.out.println("一台客户端连接上来了");
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
上面是简单做了个通讯端,当然程序后面会改进他.
这里要注意的是服务端实现了accept()侦听并接受到此套接字的连接。此方法在连接传入之前一直阻塞。
至于try catch那些语句,在Eclipse中也会帮你自动生成.你只需要注意关键语句的地方就可以了.
好,既然服务器端提供了端口和监听方法,那么就开始等待客户端发送地址端口上来了.
可以写成方法,方便以后调用观看.对了知道该方法放哪里吧.显示窗口结束后就可以用它了.
public void connect() {
try {
Socket s2 = new Socket("127.0.0.1", 8888);
System.out.println("客户端启动!");
} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}

}
看看上面的语句,其实只有一句话是重点,哪句呢?
s = new Socket("127.0.0.1", 8888);//创建发送地址端口
其他的都是eclipse帮你完成的,只要记得Ctrl+1快捷键的使用就可以了.
大家可以运行服务端后接着运行客户端程序.恩,到此,简单的客户端和服务端连接就算完工了.

好下面要思考下,如何能在客户端发送一句话,服务端能收到.
注意:为什么不是一个客户端发一句话另一个客户端收到呢?
应为所有客户端之间的交流都是先发送到服务端,然后再由服务端发送到客户端上的.
既然是客户端在输入栏写一段话,然后一回车,服务端收到,那么大家想想,以前网络这节讲的内容.

首先我们要把客户端的 Socket 的s2,往外发送.
s2.getOutputStream();//返回此套接字的输出流。为了直接往外发送一个字符串,
所以使用DataOutputStream把它变成流.这样就可以用writeUTF了.
具体写法是 DataOutputStream dos = new DataOutputStream(s2.getOutputStream());
这里我们要注意的是,由于该实现是在输入栏监听事件方法中编写的,所以无法取得connect方法中的,Socket.
那么怎么办呢?我们可以把Socket定义为成员变量并为null.在connect方法中就不必再单独定义了.
以下是list下的具体代码
DataOutputStream dos = new DataOutputStream(s2.getOutputStream());
dos.writeUTF(ss);
dos.flush();
dos.close();
下面看下服务端应该怎么接收.在accept监听后.
DataInputStream dis = new DataInputStream(s1.getInputStream());
String str = dis.readUTF();
System.out.println(str);
dis.close();
这些运用我就不解释了,笔记上都有.
好下面大家完成以上的程序代码,并测试下,当输入一次时可以在服务端显示,但 当第二次输入时候,却发现出错了.

好讲解上面留下来的问题,第一次输入的时候,服务端是正常的,为什么到了第二次就出现不正常了呢?
大家通过出错的信息,可以连接到是在DataOutputStream dos = new DataOutputStream(s2.getOutputStream());
这里出的错,那么,具体分析下,我们第一次输入回车,显示正常,第二次呢?
对了,第二次之前,他已经关闭了.dos.close();大家看到了吧.所以,网络上关闭后需要重新连接才能拿到.
所以简单的操作就是 把 dos.close();去掉.同时,我们把DataOutputStream创建放到成员变量区里面.目的是不用每次都创建.
然后在连接的时候初始化一下,就可以了.连接地方是connect方法内,应该都知道的吧.
那么list里客户端往外发送还保留哪些代码呢?
dos.writeUTF(ss);
dos.flush();
就两个,对吧.
好那么关闭的时候顺便把里面资源清理一下.
写一个方法,在窗口关闭的时候调用他.
public void disconnect() {
try {
dos.close();
s2.close();
} catch (IOException e) {
e.printStackTrace();
}
}
好,接下来我们再看下服务端,由于客户端发送了多次数据,服务端就必须接受多次数据,那么
readUTF就要多次使用,那么这里,我们就要用一个循环来多次接受客户端的数据.
修改之后的服务端代码如下:
import java.net.*;
public class Server {
public static void main(String[] args) {
boolean started = false;
try {
ServerSocket ss = new ServerSocket(8888);
while(started) {
boolean bConnected = false;
Socket s1 = ss.accept();
System.out.println("一台客户端连接上来了");
bConnected = true;
DataInputStream dis = new DataInputStream(s1.getInputStream());
while(bConnected) {
String str = dis.readUTF();
System.out.println(str);
}
dis.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
呵呵,可以多次输入了吧,不过大家有没有发现一个问题.服务端用readUTF是阻塞式的一直等待客户端发送数据的.有问题吗?
当然有,一旦关掉客户端,那么程序必然出错,怎么办呢?
好,继续开始昨天的问题.
其实答案很简单,既然readUTF一直会等待客户端发送数据,那么我们可以在服务端直接把accept方法监听到的s1给关闭了,
不过写法有点讲究,是当发现到任何出现 IOException的时候,尝试关闭s1.同时也把dis也关闭.
好了下面看下这么来写是不是较清楚合适.
import java.io.*;
import java.net.*;
public class Server {
public static void main(String[] args) {
boolean started = false;
ServerSocket ss = null;
Socket s1 = null;
DataInputStream dis = null;
try {
ss = new ServerSocket(8888);
} catch (BindException e) {
System.out.println("端口使用中....");
System.out.println("请关掉相关程序并重新运行服务器!");
System.exit(0);
} catch (IOException e) {
e.printStackTrace();
}
try {
started = true;
while(started) {
boolean bConnected = false;
s1 = ss.accept();
System.out.println("一台客户端连接上来了");
bConnected = true;
dis = new DataInputStream(s1.getInputStream());
while(bConnected) {
String str = dis.readUTF();
System.out.println(str);
}
}
} catch (EOFException e) {
System.out.println("客户端关闭了");
} catch (IOException e) {
e.printStackTrace();
} finally {//强迫使用下面方法
try {
if(dis != null) dis.close();//关闭
if(s1 != null) s1.close();//关闭
} catch (IOException e1) {
e1.printStackTrace();
}
}
}
}
恩,到这里关闭程序就完美了,下面开始继续思考,目前是一个客户端和一个服务端之间的通讯,
那么两个或多个客户端之间如何能和服务端连接和通讯呢?
我们先启动一下目前的程序看下,发现第一个客户端启动是没有问题的,但是启动到第二个时候,我们发现他并没有连接到服务端.
这个是怎么回事呢?原来还是服务端的readUTF搞得怪.由于阻塞式的读取,导致和第一个建立完通讯后,就一直等待第一个客户端发送下一个数据过来.
所以就没有机会回到accept方法中去了.怎么解决,呵呵,请听下回分解(异步模型和多线程之间的选择)
本人比较保守,对于java1.4版本内容情有独钟.所以采取传统线程的方法来解决多客户连接和数据处理的问题.
先讲讲为什么要用线程,使用线程的目的就是把接受客户端连接和接受客户端发送数据分开来.
主线程不干别的,就为了接受客户端的连接,而一旦连接后,就交给另外的线程来执行数据交互处理.
好,开始创建一个线程类并实现Runnable接口.代码如下:
class client implements Runnable {
private Socket s;
private boolean bConnected = false;
private DataInputStream dis = null;
public client(Socket s) {
this.s = s;
try {
dis = new DataInputStream(s.getInputStream());
bConnected=true;
} catch (IOException e) {
e.printStackTrace();
}
}
public void run(){}
}
好,分析下上面的代码.首先我们定义了Socket s;目的就是当主线程accept获取一个客户端的时候,
我们把获取到的Socket包装起来,保留他的属性.然后通过构造方法client(Socket s),
包装给自己的客户端.在主线程accept后,我们添加client C = new client(s1);new Thread(C).start();
这样当主程序一接受到客户端连接后,它就被另外一条线程接受到,然后立即对该客户端进行包装处理.
主线程就负责监听客户端连接.而子线程就负责接受主线程连接到后的数据处理.
那怎么处理呢?
处理当然是在run方法中进行的.由于dis在上面都被获取和保留了下来,
那么run方法里读取数据就是 String str = dis.readUTF();// 阻塞式的
是不是都能明白了?
由于修改代码和使用try catch比较多,所以我简单把思路和重要部分写下来.
好了,程序到这,我们可以创建多个客户端连接并发送数据给服务端了.但是我们的最终目的是由服务端转发给每个客户端.
现在来谈谈服务端如何发送给客户端.第一个客户端连接发送过来,
我必须先知道其他的客户端那些Socket存放在哪里.我们只有知道Socket后才能对他们来进行操作.
所以第一步,必须保存每一个客户端的连接.保存下来后,你才能对其他的Socket进行操作.
由于当初定义的client C是局部变量,所以别人用来访问他是不可能的.保存用集合比较好.动态增加嘛.
所以在成员变量里List<Client> clients = new ArrayList<Client>();记得集合要声明.
然后在主线程连接好客户端后clients.add(C);吧这个Socket 添加到集合里.

由于服务端拿到客户端的数据操作是getInputStream.然后对他外面包装管道的.
那么,反之,要输出来,必然是getOutputStream.用DataOutputStream来包装外接管道.
具体代码dos = new DataOutputStream(s.getOutputStream());对了dos别忘了声明.
好,下面就要对接受到的客户端数据往外发送了.我们到run方法里实现吧.
writeUTF,往外发送数据.那发给哪个呢?就发给所有客户端吧.
既然是所有客户端,那必须先把集合里面的数据 都拿出来.
用一个循环怎么样?
for(int i=0; i<clients.size(); i++)
{
Client c = clients.get(i);
c.dos.writeUTF(str);
}
当然了,你可以单独为发送写个方法.比如;
public void send(String str) {
try {
dos.writeUTF(str);
} catch (IOException e) {
e.printStackTrace();
}
}
这样的话,代码更具有观赏性.
下面.要让客户端这边接收了,不然服务端发送客户端不收还是白搭.呵呵
开始编写客户端.
先考虑下客户端该怎么写,写在main里面?当然不行,这样就严重影响了其他语句的执行,
我们不能被readUTF给阻塞死,那还是老办法,用线程.
照着服务端的方式,给客户端开动线程.
private class clientThread implements Runnable {

public void run() {
while(ture){
String str = dis.readUTF();
teext.setText(teext.getText() + str + '\n');
}
}
}
简单解释上面两句,run方法里,首先读取一行字符串,然后利用多文本编辑框设置读取的字符串.
注意点是,由于接受的信息是一行一行从上往下的,所以多文本设置的字符串内容可以看下如何写的.
先获取原有的 再加上现在的 再加上回车.
测试前注意要把线程调用出来.在成员变量上加上 Thread tRecv = new Thread(new clientThread());
恩,终于可以相互聊天了,不过大家注意:多个客户端聊天时候,当全部把所有的客户端关闭,服务器会出错.
为什么?还是老问题.readUTF,客户端强迫关闭时,客户端还在等着读取服务端发来的数据呢?这样程序当然要报错了.
怎么解决呢?嘿嘿,合并线程方法.
好,说到了合并线程大家应该明白为什么用他了吧.
是的,目前客户端是两条线程,如果吧主线程和子线程合并,那么就只有主线程操作完然后等子线程操作.
放在哪里合并呢?关闭的时候.windowClosing方法内.写上tRecv.join();
还有一种办法是 系统报错就结束线程.输出需要的提示信息.
在readUTF 后写上 catch (SocketException e) {System.out.println("退出了!");}
好再看下服务端,服务端里面有个send方法大家记得吧.
由于定义了List集合类,虽然客户端关闭了,但是他的客户端信息还是存在了集合类里,所以他还会继续发,
那么当然还要报错.所以发送出现异常时必须对他做出清理.
改写send方法如下:
public void send(String str) {
try {
dos.writeUTF(str);
} catch (IOException e) {
clients.remove(this);
e.printStackTrace();
}
}
好了,基本上聊天程序就写到这里,大家有什么不明白的地方可以相互讨论.下一个版本,游戏开发.感谢大家支持
--作者:马士兵
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: