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

ServerSocketSocket

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

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

现在我们开始编写程序,练习 ServerSocketSocket 的使用方法。首先新建一个工程,叫 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 数组。为了能够以行为单位读写,我们需要将 InputStreamOutputStream 分别封装为BufferedReaderBufferedWriter

...
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()));

InputStreamOutputStream 封装为 BufferedReaderBufferedWriter 后,我们就可以按行为单位读取客户端发来的数据,并按行为单位向客户端写出数据了。我们每次使用 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 输出错误信息,可能需要通过弹出错误提示对话框的形式提醒用户。

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

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

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

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