4. Socket 承载应用

概念

Web 和 FTP 不是另一种 Socket,而是跑在 TCP 连接上的应用协议。浏览器访问网站时,客户端发出的是按 HTTP 文本格式组织的字节;FTP 则把命令和文件数据分成控制连接与数据连接。 看懂这两段例程,就能把前面的短连接、长连接对应到真实服务:一次 HTTP/1.0 请求通常是短连接;FTP 控制连接在登录之后保持打开,属于长连接。

  • HTTP:请求行、首部、空行、正文。响应要写状态行、Content-Type、Content-Length,再写正文。
  • 例子只实现 GET /,用来说明 Web 服务器如何把 HTTP 写进 Socket。
  • FTP:控制连接上传 USER、PASS、LIST、QUIT 这类文本命令。真正传文件时还会另开一条数据连接。
  • 例子实现登录和 LIST,不实现 PORT/PASV 传文件,重点是命令如何被 Socket 承载。

算法流程

下面的 UML 图说明 Java 例程和 Python 例程共同实现的流程。实线箭头表示调用或发送,虚线箭头表示返回或唤起。

最小 Web 响应(UML 时序图)

sequenceDiagram participant Browser as 浏览器 participant Web as Web 服务器 Browser->>Web: GET 请求行和首部 Web->>Web: 正文按 UTF-8 计算字节长度 Web-->>Browser: 状态行、Content-Type、Content-Length Web-->>Browser: 空行,然后是 HTML 正文 Web->>Browser: Connection close,这次短连接结束

FTP 命令通道(UML 时序图)

sequenceDiagram participant Client as FTP 客户端 participant Ctrl as 控制连接 Ctrl-->>Client: 220 ready Client->>Ctrl: USER Ctrl-->>Client: 331 password required Client->>Ctrl: PASS Ctrl-->>Client: 230 logged in Client->>Ctrl: LIST Ctrl-->>Client: 文件名,然后 226 Client->>Ctrl: QUIT Ctrl-->>Client: 221 bye Note over Client,Ctrl: 真正传文件还要另开数据连接,本例只演示命令通道

Java 例程

import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public class AppOverSocket {
    /** HTTP 也是写进 Socket 的文本。这里只演示一次短连接响应。 */
    static void web(Socket s) throws Exception {
        InputStream in = s.getInputStream();
        byte[] buf = new byte[1024];
        in.read(buf);                                 // 先把浏览器发来的请求行和首部读走
        byte[] body = "<h1>SCAU</h1>".getBytes(StandardCharsets.UTF_8);
        String head = "HTTP/1.1 200 OK\r\n"           // 状态行
                + "Content-Type: text/html; charset=utf-8\r\n"
                + "Content-Length: " + body.length + "\r\n" // 长度按字节,不是按字符
                + "Connection: close\r\n"             // 这次响应后关闭,相当于短连接
                + "\r\n";                             // 空行表示首部结束,后面才是正文
        OutputStream out = s.getOutputStream();
        out.write(head.getBytes(StandardCharsets.US_ASCII)); // 首部是 ASCII
        out.write(body);                              // 正文按前面声明的 UTF-8
        s.close();
    }

    /** FTP 控制连接:登录后保持打开,按一行一条命令交互。 */
    static void ftp(Socket s) throws Exception {
        java.io.BufferedReader in = new java.io.BufferedReader(
                new java.io.InputStreamReader(s.getInputStream(), StandardCharsets.US_ASCII));
        java.io.PrintWriter out = new java.io.PrintWriter(
                new java.io.OutputStreamWriter(s.getOutputStream(), StandardCharsets.US_ASCII), true);
        out.print("220 ready\r\n");                   // 连接建立后服务器先打招呼
        out.flush();
        String line;
        while ((line = in.readLine()) != null) {      // 控制连接是长连接,循环读命令
            String cmd = line.split(" ")[0].toUpperCase(); // 命令动词与参数用空格分开
            if ("USER".equals(cmd)) out.print("331 password required\r\n");
            else if ("PASS".equals(cmd)) out.print("230 logged in\r\n");
            else if ("LIST".equals(cmd)) out.print("file1.txt\r\n226 transfer complete\r\n");
            else if ("QUIT".equals(cmd)) {
                out.print("221 bye\r\n");
                out.flush();                          // 先把告别发出去,再断开
                break;
            }
            out.flush();
        }
        s.close();                                    // 真正传文件还要另开数据连接,这里不实现
    }

    public static void main(String[] args) throws Exception {
        ServerSocket webServer = new ServerSocket(8080);
        web(webServer.accept());
        ServerSocket ftpServer = new ServerSocket(2121);
        ftp(ftpServer.accept());
    }
}

Python 例程

import socket

def web_once(conn):
    conn.recv(1024)                       # 读走浏览器发来的请求行和首部
    body = "<h1>SCAU</h1>".encode("utf-8")
    head = (
        "HTTP/1.1 200 OK\r\n"             # 状态行
        "Content-Type: text/html; charset=utf-8\r\n"
        "Content-Length: %d\r\n"          # 必须是正文的字节数
        "Connection: close\r\n"           # 响应后关闭,这是一次短连接
        "\r\n"                            # 空行之后才是正文
        % len(body)
    )
    conn.sendall(head.encode("ascii") + body)  # 首部 ASCII,正文 UTF-8,一次写完
    conn.close()

def ftp_session(conn):
    def say(text):
        # FTP 控制连接的一行以 \r\n 结束,命令本身是 ASCII
        conn.sendall((text + "\r\n").encode("ascii"))

    say("220 ready")                      # 服务器先说话,告诉客户端可以发命令了
    while True:                           # 登录之后控制连接保持打开
        line = conn.recv(1024).decode("ascii", "replace").strip()
        cmd = line.split(" ", 1)[0].upper()   # USER anonymous 里只取 USER
        if cmd == "USER":
            say("331 password required")
        elif cmd == "PASS":
            say("230 logged in")
        elif cmd == "LIST":
            say("file1.txt")
            say("226 transfer complete")  # 列目录的结果仍写在控制连接上,仅作演示
        elif cmd == "QUIT":
            say("221 bye")
            break
    conn.close()