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