这一讲的目标是实现一个以行为单位的回声服务器,即客户端向服务端发送一行文字,服务端将这行文字原样返回给客户端。如果客户端发送的是
quit\r\n
,则服务端切断与客户端的连接。
ServerSocket
与 Socket
在正式开始之前,我们先复习一下 ServerSocket
与 Socket
的使用。如果你已经很熟悉
ServerSocket
与 Socket
的用法,请跳过这一节。
ServerSocket
,顾名思义,是用来监听端口并接受用户连接的。我们创建一个
ServerSocket
,在构造函数里把想要监听的端口传给它,它就绑定了我们所指定的端口。当我们调用
accept
方法的时候,程序就进入了阻塞状态,也就是说停在
accept
这句不再往下运行了,等待用户连接。当一个用户连接到指定的端口时,accept
方法返回一个 Socket
,这个 Socket
就是我们用来与用户进行交互的通道。通过
Socket
我们可以获取一个 OutputStream
用来向客户端发送数据,也可以获取一个
InputStream
从客户端读取数据。
现在我们开始编写程序,练习 ServerSocket
和
Socket
的使用方法。首先新建一个工程,叫
HttpLite
,新建一个 Java 源程序
Entry.java
,输入以下代码:
在你喜欢的开发环境中编译这个程序并执行,然后打开一个命令行窗口,输入:
可以看到服务器返回 Hello, world!
后切断连接,然后继续回到 accept
那行阻塞住,等待下一个用户的连接。
从上面的代码可以看出,我们只有完成对一个用户的全部服务,关闭与该用户的连接之后,才能回到
accept
语句处接受下一个用户的连接。如果一台服务器任何时刻只能接受一个用户在线的话,这无论如何是不可以忍受的。因此,我们需要使用多线程实现支持多用户的服务器。
大家也许已经想到答案了:当一个用户连接上来之后,我们创建一个线程,让这个线程为这个用户服务,主线程直接返回
accept
,等待下一个用户的连接。
为了做到这一点,我们创建一个专门为单个用户服务的线程类,在构造函数中将
ServerSocket
接收到的 Socket
传递给它,让它为用户进行服务。我们新建一个 Java
源文件 Service.java
,让它继承自
Thread
,并输入以下代码:
此时 Entry.java
中的 for
循环修改成为了这样的形式:
Java 的 IO 流是一个非常重要的概念,为了更好地说明 Java IO 流,滇狐把这一节放到这里单独作为一章。即使你觉得自己对 Java 的 IO 流非常了解了,也希望你能花点时间把这部分再阅读一遍。
Socket
返回的 OutputStream
只能写出
byte
数组,InputStream
也只能读入
byte
数组。为了能够以行为单位读写,我们需要将
InputStream
和 OutputStream
分别封装为BufferedReader
和
BufferedWriter
。
将 InputStream
和 OutputStream
封装为
BufferedReader
和 BufferedWriter
后,我们就可以按行为单位读取客户端发来的数据,并按行为单位向客户端写出数据了。我们每次使用
input.readLine()
从客户端读取一行,然后判断是不是
quit
。如果是的话,则切断与客户端的连接;否则将这行原样发送给客户端。如果客户端主动切断连接的话,input.readLine()
将返回
null
。另外,再次提醒大家一个初学者易犯的错误:判断字符串相等要使用
equals
方法,不能直接使用 ==
判定。
到现在为止,一个回声服务器已经基本完成了。我们回过头来看一看
Entry.java
中的主程序:
首先,开头处把 ServerSocket
需要监听的端口号硬编码在代码中,在之后的章节里我们需要把它改为读取配置文件的形式,但这样一来,主函数里就同时包含了读取配置文件、分析配置文件、监听端口、创建客户线程的多种功能。同一个函数具备的功能太多不利于扩展与维护。
其次,由于 main
方法是静态方法,因此我们的服务器目前只能够监听一个唯一的端口,无法实现同一个程序提供两个以上不同服务。
为了解决第一个问题,我们新建一个
Server.java
源文件,将端口号作为
Server
类的构造函数参数传进去,然后在
Server
类中进行具体的端口监听工作。这样今后主程序就只需要负责配置文件的读写,不同功能的代码就彻底分离开了。
为了解决第二个问题,我们让 Server
类继承自
Runnable
,这样如果以后我们需要实现多服务器的话,随时可以让
Server
类摇身一变,成为一个线程对象。
经过处理后的 Server.java
内容如下:
主程序内容如下:
需要注意一个细节,在 Server
的构造函数中,我们没有 try ... catch
那个 IOException
,而是将 Server
的构造函数声明为 throws IOException
,将
ServerSocket
构造时抛出的 IOException
不加处理直接抛出,然后在主程序中捕获这个例外,请仔细思考为什么要这么做?
因为,通过这样的处理,我们的主程序可以通过捕获这个例外来知道该端口已经被占用,这样我们就能在主程序中进行相应的处理,输出相应的错误信息。而如果我们在
Server
的构造函数中把 IOException
吃掉并输出相应的错误信息的话,主程序将无法对这个例外进行进一步的处理。
我们把这个问题更具体化一些,假设我们的主程序是在
Windows 下通过 javaw.exe
运行的,这将不会出现命令行窗口,这时如果发生了错误,我们就不能使用
System.out.println
输出错误信息,可能需要通过弹出错误提示对话框的形式提醒用户。
再假设另一种情况,如果我们的程序是作为系统服务运行的,此时非但没有命令行窗口可以查看错误信息,连弹出对话框也是不可能的。如果我们在底层的函数中输出错误信息或弹出对话框,将会给上层程序带来很大麻烦。
也许有人会说,那我根据不同情况,将错误处理的方式修改为相应的形式不就行了?首先,“底层”之所以成为“底层”,说明它在某种程度上有一定通用性,可能会作为函数库或类库被若干不同的工程引用。不同工程对错误处理的要求不同,如果将错误处理放在底层,将无法实现在多个不同工程中的重用。其次,如果错误发生在多个不同的地方,逐一修改每个错误发生点的处理程序,成本比把错误抛出后在某一位置统一处理成本高得多。
处理错误的一个原则是,底层捕获错误,上层处理错误。如果将错误放在底层处理,会给将来的扩展带来非常糟糕的影响。希望大家通过本次和之后若干讲的学习,能够逐渐体会到分层对于代码可维护性和可重用性的帮助。
这一讲就到这里,感谢大家的耐心阅读,我们下一讲再见。