这一讲的目标是实现一个以行为单位的回声服务器,即客户端向服务端发送一行文字,服务端将这行文字原样返回给客户端。如果客户端发送的是 quit\r\n,则服务端切断与客户端的连接。

1 ServerSocket 与 Socket

在正式开始之前,我们先复习一下 ServerSocket 与 Socket 的使用。如果你已经很熟悉 ServerSocket 与 Socket 的用法,请跳过这一节。

ServerSocket,顾名思义,是用来监听端口并接受用户连接的。我们创建一个 ServerSocket,在构造函数里把想要监听的端口传给它,它就绑定了我们所指定的端口。当我们调用 accept 方法的时候,程序就进入了阻塞状态,也就是说停在 accept 这句不再往下运行了,等待用户连接。当一个用户连接到指定的端口时,accept 方法返回一个 Socket,这个 Socket 就是我们用来与用户进行交互的通道。通过 Socket 我们可以获取一个 OutputStream 用来向客户端发送数据,也可以获取一个 InputStream 从客户端读取数据。

现在我们开始编写程序,练习 ServerSocket 和 Socket 的使用方法。首先新建一个工程,叫 HttpLite,新建一个 Java 源程序 Entry.java,输入以下代码:

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class Entry
{
    public static void main(String[] args)
    {
        try
        {
            // Listen at the specified port.
            ServerSocket server = new ServerSocket(8080);

            for (;;)
            {
                // Accept an incoming connection.
                Socket client = server.accept();
                // Write "Hello, world!" to the client.
                client.getOutputStream().write("Hello, world!\r\n".getBytes());
                // Close the connection.
                client.close();
            }
        }
        catch (IOException e)
        {
            System.err.println("Error binding the specified port.");
        }
    }
}

在你喜欢的开发环境中编译这个程序并执行,然后打开一个命令行窗口,输入:

telnet localhost 8080

可以看到服务器返回 Hello, world! 后切断连接,然后继续回到 accept 那行阻塞住,等待下一个用户的连接。

2 使用多线程

从上面的代码可以看出,我们只有完成对一个用户的全部服务,关闭与该用户的连接之后,才能回到 accept 语句处接受下一个用户的连接。如果一台服务器任何时刻只能接受一个用户在线的话,这无论如何是不可以忍受的。因此,我们需要使用多线程实现支持多用户的服务器。

大家也许已经想到答案了:当一个用户连接上来之后,我们创建一个线程,让这个线程为这个用户服务,主线程直接返回 accept,等待下一个用户的连接。

为了做到这一点,我们创建一个专门为单个用户服务的线程类,在构造函数中将 ServerSocket 接收到的 Socket 传递给它,让它为用户进行服务。我们新建一个 Java 源文件 Service.java,让它继承自 Thread,并输入以下代码:

import java.io.IOException;
import java.net.Socket;

public class Service extends Thread
{
    private Socket _socket;

    public Service(Socket socket)
    {
        _socket = socket;
    }

    public void run()
    {
        try
        {
            // Write "Hello, world!" to the client.
            _socket.getOutputStream().write("Hello, world!\r\n".getBytes());
        }
        catch (IOException e)
        {
            // Abandon the current connection.
        }
        finally
        {
            try
            {
                // Close the connection.
                _socket.close();
            }
            catch (IOException e)
            {
                // Eat the IOException.
            }
        }
    }
}

此时 Entry.java 中的 for 循环修改成为了这样的形式:

            ...
            for (;;)
            {
                // Accept an incoming connection.
                Socket client = server.accept();
                // Create a Service Thread to serve the client.
                Service service = new Service(client);
                service.start();
            }
            ...

3 实现按行读写

Java 的 IO 流是一个非常重要的概念,为了更好地说明 Java IO 流,滇狐把这一节放到这里单独作为一章。即使你觉得自己对 Java 的 IO 流非常了解了,也希望你能花点时间把这部分再阅读一遍。

Socket 返回的 OutputStream 只能写出 byte 数组,InputStream 也只能读入 byte 数组。为了能够以行为单位读写,我们需要将 InputStream 和 OutputStream 分别封装为BufferedReader 和 BufferedWriter。

...
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;

            ...
            // Wrapper the InputStream to BufferedReader
            BufferedReader input = new BufferedReader(new InputStreamReader(
                _socket.getInputStream()));
            // Wrapper the OutputStream to BufferedWriter
            BufferedWriter output = new BufferedWriter(new OutputStreamWriter(
                _socket.getOutputStream()));

将 InputStream 和 OutputStream 封装为 BufferedReader 和 BufferedWriter 后,我们就可以按行为单位读取客户端发来的数据,并按行为单位向客户端写出数据了。我们每次使用 input.readLine() 从客户端读取一行,然后判断是不是 quit。如果是的话,则切断与客户端的连接;否则将这行原样发送给客户端。如果客户端主动切断连接的话,input.readLine() 将返回 null。另外,再次提醒大家一个初学者易犯的错误:判断字符串相等要使用 equals 方法,不能直接使用 == 判定。

            ...
            String line = input.readLine();
            while (line != null)
            {
                if (line.equals("quit"))
                    return;
                output.write(line + "\r\n");
                output.flush();
                line = input.readLine();
            }
            
            input.close();
            output.close();
            ...

4 分离主程序

到现在为止,一个回声服务器已经基本完成了。我们回过头来看一看 Entry.java 中的主程序:

首先,开头处把 ServerSocket 需要监听的端口号硬编码在代码中,在之后的章节里我们需要把它改为读取配置文件的形式,但这样一来,主函数里就同时包含了读取配置文件、分析配置文件、监听端口、创建客户线程的多种功能。同一个函数具备的功能太多不利于扩展与维护。

其次,由于 main 方法是静态方法,因此我们的服务器目前只能够监听一个唯一的端口,无法实现同一个程序提供两个以上不同服务。

为了解决第一个问题,我们新建一个 Server.java 源文件,将端口号作为 Server 类的构造函数参数传进去,然后在 Server 类中进行具体的端口监听工作。这样今后主程序就只需要负责配置文件的读写,不同功能的代码就彻底分离开了。

为了解决第二个问题,我们让 Server 类继承自 Runnable,这样如果以后我们需要实现多服务器的话,随时可以让 Server 类摇身一变,成为一个线程对象。

经过处理后的 Server.java 内容如下:

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class Server implements Runnable
{
    private ServerSocket _server;
    
    public Server(int port) throws IOException
    {
        // This statement will throw out IOException,
        // if the specified port is not available.
        _server = new ServerSocket(port);
    }
    
    public void run()
    {
        try
        {
            for (;;)
            {
                // Accept an incoming connection.
                Socket client = _server.accept();
                // Create a Service Thread to serve the client.
                Service service = new Service(client);
                service.start();
            }
        }
        catch (IOException e)
        {
            // Eat the IOException
        }
    }
}

主程序内容如下:

import java.io.IOException;

        ...
        try
        {
            Server server = new Server(8080);
            server.run();
        }
        catch (IOException e)
        {
            System.err.println("Error binding the specified port.");
        }
        ...

需要注意一个细节,在 Server 的构造函数中,我们没有 try ... catch 那个 IOException,而是将 Server 的构造函数声明为 throws IOException,将 ServerSocket 构造时抛出的 IOException 不加处理直接抛出,然后在主程序中捕获这个例外,请仔细思考为什么要这么做?

因为,通过这样的处理,我们的主程序可以通过捕获这个例外来知道该端口已经被占用,这样我们就能在主程序中进行相应的处理,输出相应的错误信息。而如果我们在 Server 的构造函数中把 IOException 吃掉并输出相应的错误信息的话,主程序将无法对这个例外进行进一步的处理。

我们把这个问题更具体化一些,假设我们的主程序是在 Windows 下通过 javaw.exe 运行的,这将不会出现命令行窗口,这时如果发生了错误,我们就不能使用 System.out.println 输出错误信息,可能需要通过弹出错误提示对话框的形式提醒用户。

再假设另一种情况,如果我们的程序是作为系统服务运行的,此时非但没有命令行窗口可以查看错误信息,连弹出对话框也是不可能的。如果我们在底层的函数中输出错误信息或弹出对话框,将会给上层程序带来很大麻烦。

也许有人会说,那我根据不同情况,将错误处理的方式修改为相应的形式不就行了?首先,“底层”之所以成为“底层”,说明它在某种程度上有一定通用性,可能会作为函数库或类库被若干不同的工程引用。不同工程对错误处理的要求不同,如果将错误处理放在底层,将无法实现在多个不同工程中的重用。其次,如果错误发生在多个不同的地方,逐一修改每个错误发生点的处理程序,成本比把错误抛出后在某一位置统一处理成本高得多。

处理错误的一个原则是,底层捕获错误,上层处理错误。如果将错误放在底层处理,会给将来的扩展带来非常糟糕的影响。希望大家通过本次和之后若干讲的学习,能够逐渐体会到分层对于代码可维护性和可重用性的帮助。

这一讲就到这里,感谢大家的耐心阅读,我们下一讲再见。