用 Python 3 搭建简易 HTTP Server – wiki基地


深入浅出:用 Python 3 从零搭建简易 HTTP 服务器

引言:为何要自己搭建一个简易 HTTP 服务器?

在现代网络世界中,HTTP(超文本传输协议)无处不在。我们每天访问的网页、使用的 API 大多数都基于 HTTP 协议。作为开发者,我们经常使用成熟的 Web 框架(如 Flask, Django)或现成的服务器软件(如 Nginx, Apache)来部署我们的 Web 应用。它们功能强大、性能卓越,极大地提高了开发效率。

然而,这些工具虽然便利,却往往隐藏了底层的工作原理。HTTP 请求是如何从浏览器到达服务器的?服务器又是如何处理请求并返回响应的?Socket 编程是什么?TCP/IP 在其中扮演什么角色?理解这些基础知识对于成为一名更优秀的开发者至关重要。

自己动手使用低级 API(如 Socket)搭建一个简易的 HTTP 服务器,是一个绝佳的学习过程。它能帮助我们:

  1. 深入理解 HTTP 协议: 亲手解析 HTTP 请求报文,构建 HTTP 响应报文,让我们对协议的格式、字段、状态码有更直观的认识。
  2. 掌握 Socket 编程基础: 了解如何创建、绑定、监听 Socket,如何接受连接,以及如何在连接上发送和接收数据。
  3. 理解服务器的工作模型: 虽然简易,但它展示了服务器接收请求、处理请求、发送响应的基本循环。
  4. 提升调试和问题解决能力: 当遇到网络或协议相关问题时,对底层原理的理解能帮助我们更快地定位问题。

Python 3 凭借其简洁易读的语法和强大的标准库,是进行这类实验的理想选择。Python 的 socket 模块提供了操作系统底层的 Socket API 接口,让我们能够方便地进行网络通信编程。

本文将带领读者一步步从零开始,使用 Python 3 的 socket 模块构建一个功能非常简单的 HTTP 服务器。我们将从最基础的 Socket 连接开始,逐步添加接收请求、解析请求、构建响应、发送响应等功能,并最终实现一个可以服务静态文件的简易服务器。

请注意,本文构建的服务器仅用于教学目的,它在性能、安全性、并发处理等方面远不能与生产环境的服务器相比。但它足以帮助我们揭开 HTTP 服务器的神秘面纱。

前置知识:你需要了解什么?

在开始之前,你需要对以下概念有基本的了解:

  • Python 3 基础: 函数、类、循环、条件判断、字符串处理、字节串 (bytes) 与字符串 (str) 的转换。
  • 网络基础概念: IP 地址、端口、TCP/IP 协议栈(知道 TCP 是一个可靠的面向连接的协议即可)。
  • HTTP 协议基础: 了解 HTTP 请求和响应的基本结构(请求行、请求头、空行、请求体;状态行、响应头、空行、响应体)。了解常见的 HTTP 方法(GET)。

如果你对这些概念不熟悉,不用担心,文章中会尽可能地解释相关内容,但建议你在阅读过程中或之后查阅更多资料进行补充学习。

第一步:搭建 Socket 监听器

HTTP 服务器本质上是一个持续运行的程序,它在一个特定的网络地址(IP 地址)和端口上监听来自客户端(通常是浏览器)的连接请求。一旦接收到连接,就处理客户端发送的数据(HTTP 请求),然后将结果(HTTP 响应)发送回客户端。

在 Python 中,我们使用 socket 模块来实现这一过程。

“`python
import socket

服务器的 IP 地址和端口

‘127.0.0.1’ 表示本地回环地址,只有本机可以访问

” 表示监听所有可用的网络接口,外部计算机可以通过服务器的实际IP访问

端口号选择一个未被占用的,常用的 HTTP 端口是 80,但需要管理员权限,所以我们选择一个大于 1024 的

HOST = ‘127.0.0.1’ # 或者使用 ” 监听所有接口
PORT = 8888

def run_server():
# 1. 创建一个 socket 对象
# socket.AF_INET 表示使用 IPv4 地址族
# socket.SOCK_STREAM 表示使用 TCP 协议 (流式 Socket)
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print(“Socket created.”)

# 2. 绑定服务器的地址和端口
# bind 方法需要一个元组作为参数 (host, port)
try:
    server_socket.bind((HOST, PORT))
    print(f"Socket bound to {HOST}:{PORT}")
except socket.error as msg:
    print(f"Bind failed. Error code: {str(msg[0])}, Message: {msg[1]}")
    # 如果绑定失败,通常是端口被占用,或者IP地址无效
    # 在实际应用中,你可能需要更复杂的错误处理或退出程序
    return # 退出函数

# 3. 开始监听连接
# listen 方法的参数指定了在拒绝新的连接之前,操作系统可以挂起的最大连接数(等待接受的连接队列大小)
# 这个值通常是一个小的正整数,表示连接队列的长度
server_socket.listen(5)
print("Socket now listening...")

# 4. 进入主循环,持续接受客户端连接
# 一个简单的服务器会一直运行,直到手动停止或发生错误
while True:
    # server_socket.accept() 方法会阻塞程序,直到接收到一个新的客户端连接
    # 它返回一个新的 socket 对象 (conn) 和客户端的地址 (addr)
    # conn 是一个专用于与该客户端通信的 socket
    # addr 是一个元组 (client_ip, client_port)
    conn, addr = server_socket.accept()
    print(f"Accepted connection from {addr[0]}:{addr[1]}")

    # 在这里,我们将处理这个客户端连接,但在当前阶段,我们只是接受并立即关闭
    # 实际的请求处理逻辑将在后续步骤添加
    print("Connection closed.")
    conn.close() # 关闭与该客户端的连接

# 注意:上面的 while True 循环是无限的,所以下面的代码实际上不会被执行
# 在更复杂的服务器中,可能会在这里添加服务器关闭前的清理工作
# server_socket.close() # 关闭服务器监听 socket

if name == “main“:
run_server()
“`

代码讲解:

  • import socket: 导入 Python 的 socket 模块。
  • HOSTPORT: 定义服务器监听的 IP 地址和端口。'127.0.0.1' 是本地回环地址,非常适合在同一台计算机上测试。
  • socket.socket(...): 创建一个 Socket 对象。我们指定使用 IPv4 (AF_INET) 和 TCP (SOCK_STREAM)。
  • server_socket.bind((HOST, PORT)): 将创建的 Socket 绑定到指定的地址和端口。这是服务器向操作系统申请在该地址端口上提供服务。
  • server_socket.listen(5): 使 Socket 进入监听模式。5 是等待连接队列的最大长度,意味着最多可以有 5 个客户端连接在等待被 accept() 处理。
  • while True:: 创建一个无限循环,让服务器持续运行。
  • conn, addr = server_socket.accept(): 这是服务器最核心的操作之一。它会阻塞,直到有客户端连接到服务器的地址和端口。成功连接后,accept() 返回一个 新的 Socket 对象 (conn),专门用于与这个特定的客户端进行后续通信,以及客户端的地址信息 (addr)。原来的 server_socket 继续负责监听新的连接。
  • conn.close(): 关闭与当前客户端的连接。在简单的服务器中,处理完一个请求后通常会关闭连接。

运行这段代码,你会在控制台看到 Socket 创建、绑定、监听的信息。然后程序会停在 server_socket.accept() 这一行,等待客户端连接。你可以打开浏览器,访问 http://127.0.0.1:8888/。浏览器会尝试连接,服务器会 accept 这个连接并打印信息,然后立即关闭连接。在浏览器端,你可能会看到一个错误,因为服务器没有发送任何有效的 HTTP 响应。这正是我们下一步要解决的问题。

第二步:接收并解析 HTTP 请求

客户端(如浏览器)连接成功后,会立即通过建立好的连接 Socket 发送 HTTP 请求报文。服务器需要接收这些数据,并解析出请求的关键信息,例如请求方法(GET, POST 等)、请求路径(/, /index.html 等)以及请求头。

HTTP 请求报文通常是文本格式(尽管请求体可能是二进制数据),它以请求行开始,后面跟着一系列请求头,然后是一个空行,最后是可选的请求体。

“`
GET /index.html HTTP/1.1
Host: 127.0.0.1:8888
User-Agent: Mozilla/5.0 …
Accept: text/html,…

[请求体,如果存在]
“`

我们需要从 Socket 中读取这些数据,直到遇到空行(\r\n\r\n),这标志着请求头的结束。

修改 run_server 函数中的 while 循环内部,添加接收和解析请求的代码:

“`python
import socket

导入 sys 用于退出程序,os 用于文件操作,mimetypes 用于获取文件MIME类型

import sys
import os
import mimetypes

HOST = ‘127.0.0.1’
PORT = 8888

定义静态文件根目录

STATIC_ROOT = ‘.’ # 或者指定一个具体的目录,例如 ‘static’

def run_server():
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print(“Socket created.”)

try:
    server_socket.bind((HOST, PORT))
    print(f"Socket bound to {HOST}:{PORT}")
except socket.error as msg:
    print(f"Bind failed. Error code: {str(msg[0])}, Message: {msg[1]}")
    sys.exit() # 绑定失败则退出

server_socket.listen(5)
print("Socket now listening...")

while True:
    conn = None # 初始化 conn 为 None,方便在 finally 块中检查是否已赋值
    try:
        conn, addr = server_socket.accept()
        print(f"Accepted connection from {addr[0]}:{addr[1]}")

        # 接收客户端发送的数据
        # 我们分块接收数据,直到接收到整个请求报文
        request_bytes = b'' # 使用字节串存储接收到的数据
        # 一个简单的策略:持续接收,直到遇到 \r\n\r\n
        # 注意:这对于包含请求体的POST请求是不够的,简单的GET请求通常可以
        # 真正的HTTP服务器需要更复杂的逻辑来判断请求是否接收完整(例如,根据Content-Length头)
        while True:
            chunk = conn.recv(1024) # 每次接收最多1024字节
            request_bytes += chunk
            # 检查是否接收到请求头结束标记
            if b'\r\n\r\n' in request_bytes:
                break
            # 如果客户端关闭连接,recv会返回空字节串
            if not chunk:
                 break # 或者处理错误/异常

        # 将接收到的字节串解码为字符串
        # 假设请求使用 UTF-8 编码
        request_str = request_bytes.decode('utf-8', errors='ignore')
        print("--- Received Request ---")
        print(request_str)
        print("------------------------")

        # 解析请求行
        # 请求行是请求报文的第一行,格式通常是 "方法 路径 HTTP版本"
        # 例如: "GET /index.html HTTP/1.1"
        lines = request_str.split('\r\n')
        if not lines or not lines[0]:
            # 如果没有收到任何数据或第一行是空的,则跳过处理
            print("Received empty request.")
            continue # 继续等待下一个连接

        request_line = lines[0]
        parts = request_line.split() # 按空格分割请求行

        # 简单的检查,确保请求行至少包含方法、路径和版本
        if len(parts) != 3:
             print(f"Malformed request line: {request_line}")
             # 在实际服务器中,这里应该发送一个 400 Bad Request 响应
             # 为简单起见,这里先忽略或关闭连接
             continue # 继续等待下一个连接

        method, path, version = parts
        print(f"Method: {method}, Path: {path}, Version: {version}")

        # 在这里,我们已经成功接收并解析了请求的基本信息
        # 下一步是根据这些信息构建并发送响应

        # === 第三步的代码将放在这里 ===
        # ... 构建并发送响应 ...
        # conn.sendall(...) # 发送响应字节串
        # === 第三步的代码结束 ===

    except socket.timeout:
        print("Connection timed out.")
        # 可以选择关闭连接 conn.close()
    except socket.error as e:
        print(f"Socket error: {e}")
        # 通常是客户端在数据传输过程中关闭了连接
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
        # 捕获其他潜在的异常,例如解析错误
    finally:
        # 确保无论是否发生异常,连接都会被关闭
        if conn:
            print("Closing connection.")
            conn.close()

# 服务器关闭前的清理工作
# 注意:在上面的无限循环中,这行代码不会被执行
# 如果你想让服务器在收到特定信号时关闭,需要额外的逻辑
# server_socket.close()
print("Server shutting down.")

if name == “main“:
# 配置 mimetypes,加载系统的 MIME 类型映射
# 这有助于正确识别文件类型
mimetypes.init()
run_server()
“`

代码讲解:

  • conn = None: 初始化 conn 为 None,在 finally 块中检查 conn 是否已经被赋值(即是否成功 accept 了连接),以避免在 accept 之前发生异常导致 conn.close() 出错。
  • try...except...finally: 使用异常处理来确保在发生错误时程序不会崩溃,并且客户端连接能被妥善关闭。finally 块中的代码总是在 tryexcept 块执行完毕后执行,无论是否发生异常。
  • request_bytes = b'': 创建一个空的字节串来累积接收到的数据。Socket 发送和接收的都是字节串。
  • conn.recv(1024): 接收最多 1024 字节的数据。recv 方法也是阻塞的,直到接收到数据或连接关闭。
  • b'\r\n\r\n' in request_bytes: 检查接收到的数据中是否包含 HTTP 请求头结束的标记(一个空行,由回车换行符序列 \r\n 组成)。
  • request_bytes.decode('utf-8', errors='ignore'): 将接收到的字节串解码为字符串,以便进行文本处理。errors='ignore' 参数表示如果遇到解码错误,则忽略错误字符。
  • request_str.split('\r\n'): 将整个请求字符串按行分割。
  • request_line = lines[0]: 获取第一行,即请求行。
  • request_line.split(): 将请求行按空格分割,得到方法、路径和 HTTP 版本。

现在运行代码,用浏览器访问 http://127.0.0.1:8888/。服务器会打印接收到的整个请求报文和解析出的方法、路径、版本信息。浏览器端仍然会显示错误,因为我们还没有发送响应。

第三步:构建并发送 HTTP 响应

接收并解析完请求后,服务器需要根据请求的内容生成相应的 HTTP 响应,并通过 Socket 发送回客户端。HTTP 响应报文以状态行开始,接着是一系列响应头,然后是一个空行,最后是响应体。

“`
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 13

Hello, World!
“`

响应报文也必须是字节串才能通过 Socket 发送。

我们将把构建和发送响应的代码添加到上面 while True 循环的 try 块中,紧接着解析请求的部分。

我们先实现一个最简单的响应:无论请求什么,都返回一个 “Hello, World!” 的网页。

“`python

接上一步的代码…

        # ... 解析请求行,获取 method, path, version ...
        print(f"Method: {method}, Path: {path}, Version: {version}")

        # === 第三步:构建并发送响应 ===

        # 1. 准备响应体
        # 将字符串响应体编码为字节串
        response_body = "<h1>Hello from Simple Python Server!</h1>"
        response_body_bytes = response_body.encode('utf-8')

        # 2. 准备响应头
        # 包括 Content-Type 和 Content-Length 是良好的实践
        status_line = "HTTP/1.1 200 OK\r\n" # 状态行
        headers = {
            "Content-Type": "text/html; charset=utf-8", # 告诉浏览器响应是HTML,使用UTF-8编码
            "Content-Length": len(response_body_bytes), # 告诉浏览器响应体的大小
            "Connection": "close", # 告诉浏览器响应发送完毕后关闭连接
        }

        # 将响应头字典转换为字符串格式,每个头一行,以 \r\n 结束
        header_lines = "".join([f"{key}: {value}\r\n" for key, value in headers.items()])

        # 3. 组合完整的响应报文
        # 状态行 + 响应头 + 空行 + 响应体
        response_str = status_line + header_lines + "\r\n"
        # 将响应报文的头部(状态行和头)编码为字节串
        response_header_bytes = response_str.encode('utf-8')

        # 完整的响应报文是头部字节串和响应体字节串的组合
        full_response = response_header_bytes + response_body_bytes

        # 4. 发送响应
        # sendall 确保发送所有字节
        conn.sendall(full_response)
        print("Response sent.")

        # === 第三步的代码结束 ===

    except socket.timeout:
        print("Connection timed out.")
    except socket.error as e:
        print(f"Socket error: {e}")
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
    finally:
        if conn:
            print("Closing connection.")
            conn.close()

… run_server 函数的其余部分和 if name == “main“: 部分不变 …

“`

代码讲解:

  • response_body = "...": 定义响应体的内容,这里是一个简单的 HTML 字符串。
  • response_body_bytes = response_body.encode('utf-8'): 将字符串编码为字节串。HTTP 协议传输的是字节。
  • status_line = "HTTP/1.1 200 OK\r\n": 构建状态行。HTTP/1.1 是协议版本,200 是状态码(表示成功),OK 是状态码对应的文本描述。注意末尾的 \r\n
  • headers = {...}: 使用字典存储响应头。Content-Type 告诉客户端响应体的媒体类型(MIME 类型),Content-Length 告诉客户端响应体的大小(字节数),这对于客户端正确接收和处理响应体非常重要。Connection: close 告诉客户端服务器将在发送完响应后关闭连接。
  • "".join([...]): 将响应头字典转换为 HTTP 头部的字符串格式。每个头字段一行,格式为 Key: Value\r\n
  • response_str = status_line + header_lines + "\r\n": 组合状态行、所有响应头和一个空行,构成完整的响应头部字符串。注意响应头和响应体之间的空行是必须的。
  • response_header_bytes = response_str.encode('utf-8'): 将响应头部字符串编码为字节串。
  • full_response = response_header_bytes + response_body_bytes: 将头部字节串和响应体字节串拼接起来,构成完整的 HTTP 响应报文字节串。
  • conn.sendall(full_response): 将完整的响应报文字节串发送给客户端。sendall 会尝试发送所有字节,直到发送完毕或发生错误。

现在再次运行代码,并用浏览器访问 http://127.0.0.1:8888/。这次,你不再看到浏览器错误,而是会看到 “Hello from Simple Python Server!” 的标题,并且控制台会打印接收和发送的信息。恭喜你,你已经成功搭建了一个可以响应任何请求的简易 HTTP 服务器!

第四步:处理不同的请求路径和方法

目前我们的服务器只会返回固定的内容,无论客户端请求什么路径。一个实用的服务器需要能够根据请求的路径(URL)返回不同的内容。我们先添加一个简单的逻辑,根据请求路径返回不同的文本,或者返回一个 404 Not Found 错误。

我们只处理 GET 方法,对于其他方法,可以返回 405 Method Not Allowed 错误。

修改 run_server 函数中的响应生成逻辑:

“`python

接上一步的代码…

        # ... 解析请求行,获取 method, path, version ...
        print(f"Method: {method}, Path: {path}, Version: {version}")

        # === 第四步:根据请求路径和方法生成不同的响应 ===

        response_body = b''
        status_line = ""
        content_type = "text/html; charset=utf-8" # 默认Content-Type
        response_code = 200
        response_text = "OK"

        if method != 'GET':
            # 不支持的 HTTP 方法
            response_code = 405
            response_text = "Method Not Allowed"
            response_body = f"<h1>405 Method Not Allowed</h1><p>Server only supports GET method.</p>".encode('utf-8')
            content_type = "text/html; charset=utf-8"

        elif path == '/':
            # 根路径请求
            response_body = f"<h1>Welcome!</h1><p>This is the homepage.</p><p>Try visiting <a href='/hello'>/hello</a>.</p>".encode('utf-8')
            content_type = "text/html; charset=utf-8"
            response_code = 200
            response_text = "OK"

        elif path == '/hello':
            # /hello 路径请求
            response_body = f"<h1>Hello There!</h1><p>You requested the /hello page.</p>".encode('utf-8')
            content_type = "text/html; charset=utf-8"
            response_code = 200
            response_text = "OK"

        else:
            # 其他路径,返回 404 Not Found
            response_code = 404
            response_text = "Not Found"
            response_body = f"<h1>404 Not Found</h1><p>The requested path {path} was not found on this server.</p>".encode('utf-8')
            content_type = "text/html; charset=utf-8"


        # 2. 准备响应头 (通用部分)
        status_line = f"HTTP/1.1 {response_code} {response_text}\r\n"
        headers = {
            "Content-Type": content_type,
            "Content-Length": len(response_body),
            "Connection": "close",
        }

        header_lines = "".join([f"{key}: {value}\r\n" for key, value in headers.items()])

        # 3. 组合完整的响应报文
        response_header_bytes = (status_line + header_lines + "\r\n").encode('utf-8')
        full_response = response_header_bytes + response_body

        # 4. 发送响应
        conn.sendall(full_response)
        print(f"Response sent with status {response_code} {response_text}.")


        # === 第四步的代码结束 ===

    except socket.timeout:
        print("Connection timed out.")
    except socket.error as e:
        print(f"Socket error: {e}")
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
    finally:
        if conn:
            print("Closing connection.")
            conn.close()

… run_server 函数的其余部分和 if name == “main“: 部分不变 …

“`

代码讲解:

  • 我们根据 methodpath 的值,使用 if/elif/else 结构来决定响应的内容和状态码。
  • 对于不受支持的 method (!= 'GET'),设置状态码为 405,并返回相应的 HTML 提示。
  • 对于 / 路径,返回一个欢迎页面。
  • 对于 /hello 路径,返回一个简单的问候页面。
  • 对于任何其他路径,设置状态码为 404,并返回一个 “Not Found” 页面。
  • 根据不同的响应内容,动态设置 response_code, response_text, response_bodycontent_type
  • 构建状态行时,使用变量 response_coderesponse_text
  • 构建响应头时,动态计算 Content-Length 并使用变量 content_type

现在运行代码,访问 http://127.0.0.1:8888/http://127.0.0.1:8888/hellohttp://127.0.0.1:8888/abc,你应该能看到不同的页面内容和状态码(在浏览器开发者工具的网络tab中查看)。尝试使用其他 HTTP 方法(例如,用 curl -X POST http://127.0.0.1:8888/)也会得到 405 响应。

第五步:服务静态文件

一个更实用的 Web 服务器通常需要能够直接服务存储在文件系统中的静态文件,例如 HTML 文件、CSS 文件、JavaScript 文件、图片等。

我们将扩展服务器,使其能够根据请求路径到指定的根目录(STATIC_ROOT)下查找并返回文件内容。

我们需要使用 os 模块来处理文件路径,使用 mimetypes 模块来根据文件扩展名确定 Content-Type

修改 run_server 函数中的响应生成逻辑,特别是处理 GET 请求的部分:

“`python

接上一步的代码…

确保导入 os 和 mimetypes

import os
import mimetypes
import sys

HOST = ‘127.0.0.1’
PORT = 8888

定义静态文件根目录

例如设置为 ‘static’ 目录,则服务器会尝试从当前脚本所在目录下的 ‘static’ 文件夹中查找文件

如果设置为 ‘.’ 则从脚本当前目录查找

STATIC_ROOT = ‘static’ # 创建一个名为 ‘static’ 的文件夹并在其中放置一些文件(如 index.html)

def run_server():
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print(“Socket created.”)

try:
    server_socket.bind((HOST, PORT))
    print(f"Socket bound to {HOST}:{PORT}")
except socket.error as msg:
    print(f"Bind failed. Error code: {str(msg[0])}, Message: {msg[1]}")
    sys.exit()

server_socket.listen(5)
print("Socket now listening...")

# 确保静态文件目录存在,如果不存在则创建
if not os.path.exists(STATIC_ROOT):
    os.makedirs(STATIC_ROOT)
    print(f"Created static root directory: {STATIC_ROOT}")
    # 可以在这里创建一个默认的 index.html 文件方便测试
    try:
        with open(os.path.join(STATIC_ROOT, 'index.html'), 'w', encoding='utf-8') as f:
            f.write("<!DOCTYPE html><html><head><title>Simple Server</title></head><body><h1>It Works!</h1><p>This is the default index.html served from the static directory.</p></body></html>")
        print(f"Created default index.html in {STATIC_ROOT}")
    except Exception as e:
        print(f"Could not create default index.html: {e}")


while True:
    conn = None
    try:
        conn, addr = server_socket.accept()
        print(f"Accepted connection from {addr[0]}:{addr[1]}")

        request_bytes = b''
        while True:
            chunk = conn.recv(1024)
            request_bytes += chunk
            if b'\r\n\r\n' in request_bytes:
                break
            if not chunk:
                 break

        request_str = request_bytes.decode('utf-8', errors='ignore')
        # print("--- Received Request ---")
        # print(request_str)
        # print("------------------------") # 请求报文可能很长,打印出来会刷屏,根据需要打开

        lines = request_str.split('\r\n')
        if not lines or not lines[0]:
            print("Received empty request.")
            continue

        request_line = lines[0]
        parts = request_line.split()

        if len(parts) != 3:
             print(f"Malformed request line: {request_line}")
             # 发送 400 Bad Request 响应
             response_code = 400
             response_text = "Bad Request"
             response_body = b"<h1>400 Bad Request</h1>"
             content_type = "text/html; charset=utf-8"

        else:
            method, path, version = parts
            print(f"Method: {method}, Path: {path}, Version: {version}")

            response_body = b''
            status_line = ""
            content_type = "text/html; charset=utf-8"
            response_code = 200
            response_text = "OK"

            if method != 'GET':
                # 不支持的 HTTP 方法
                response_code = 405
                response_text = "Method Not Allowed"
                response_body = b"<h1>405 Method Not Allowed</h1><p>Server only supports GET method.</p>"
                content_type = "text/html; charset=utf-8"

            else: # 处理 GET 请求
                # 如果请求路径是根目录 '/',默认查找 index.html
                if path == '/':
                    requested_file_path = 'index.html'
                else:
                    # 移除路径开头的 '/'
                    requested_file_path = path.lstrip('/')

                # 构建完整的本地文件路径
                # 使用 os.path.join 可以正确处理不同操作系统的路径分隔符
                file_path = os.path.join(STATIC_ROOT, requested_file_path)

                # 安全性考虑:防止目录穿越攻击
                # 检查构建的文件路径是否确实在 STATIC_ROOT 目录下
                # os.path.abspath 可以获取规范化的绝对路径
                # os.path.commonprefix 返回两个路径的最长共同前缀
                # 或者使用 os.path.realpath 更安全
                abs_file_path = os.path.realpath(file_path)
                abs_static_root = os.path.realpath(STATIC_ROOT)

                if not abs_file_path.startswith(abs_static_root):
                    # 如果请求的文件路径不在静态文件根目录下,视为非法请求
                    response_code = 403 # Forbidden
                    response_text = "Forbidden"
                    response_body = b"<h1>403 Forbidden</h1><p>Access to this resource is forbidden.</p>"
                    content_type = "text/html; charset=utf-8"
                    print(f"Attempted directory traversal: {path}")

                elif not os.path.exists(abs_file_path) or not os.path.isfile(abs_file_path):
                    # 文件不存在或不是一个文件
                    response_code = 404
                    response_text = "Not Found"
                    response_body = f"<h1>404 Not Found</h1><p>The requested file {path} was not found.</p>".encode('utf-8')
                    content_type = "text/html; charset=utf-8"
                    print(f"File not found: {abs_file_path}")

                else:
                    # 文件存在且是文件,读取并返回
                    try:
                        with open(abs_file_path, 'rb') as f: # 以二进制模式读取文件
                            response_body = f.read()

                        # 根据文件扩展名猜测 Content-Type
                        # guess_type 返回 (type, encoding)
                        mime_type, _ = mimetypes.guess_type(abs_file_path)
                        if mime_type:
                            content_type = mime_type
                        else:
                            # 如果无法猜测类型,使用默认的二进制流类型
                            content_type = 'application/octet-stream'

                        response_code = 200
                        response_text = "OK"
                        print(f"Serving file: {abs_file_path} ({content_type})")

                    except IOError as e:
                        # 读取文件时发生错误
                        response_code = 500
                        response_text = "Internal Server Error"
                        response_body = f"<h1>500 Internal Server Error</h1><p>Could not read file {path}: {e}</p>".encode('utf-8')
                        content_type = "text/html; charset=utf-8"
                        print(f"Error reading file {abs_file_path}: {e}")
                    except Exception as e:
                         # 捕获其他潜在的文件处理异常
                        response_code = 500
                        response_text = "Internal Server Error"
                        response_body = f"<h1>500 Internal Server Error</h1><p>An unexpected error occurred while processing {path}: {e}</p>".encode('utf-8')
                        content_type = "text/html; charset=utf-8"
                        print(f"Unexpected error processing file {abs_file_path}: {e}")


        # 2. 准备响应头 (通用部分)
        # 即使发生错误,也需要发送响应头
        status_line = f"HTTP/1.1 {response_code} {response_text}\r\n"
        headers = {
            "Content-Type": content_type,
            "Content-Length": len(response_body),
            "Connection": "close",
            # 可以添加其他头,例如 Server 标识
            # "Server": "SimplePythonServer/0.1"
        }

        header_lines = "".join([f"{key}: {value}\r\n" for key, value in headers.items()])

        # 3. 组合完整的响应报文
        response_header_bytes = (status_line + header_lines + "\r\n").encode('utf-8')
        full_response = response_header_bytes + response_body

        # 4. 发送响应
        conn.sendall(full_response)
        print(f"Response sent with status {response_code} {response_text}.")


    except socket.timeout:
        print("Connection timed out.")
    except socket.error as e:
        print(f"Socket error: {e}")
    except Exception as e:
        print(f"An unexpected error occurred during request processing: {e}")
    finally:
        if conn:
            print("Closing connection.")
            conn.close()

print("Server shutting down.")

if name == “main“:
mimetypes.init() # 初始化 mimetypes
run_server()
“`

代码讲解:

  • STATIC_ROOT = 'static': 定义存放静态文件的目录名。建议在运行 Python 脚本的同一目录下创建这个文件夹。
  • os.makedirs(STATIC_ROOT): 如果 STATIC_ROOT 目录不存在,则创建它。
  • 对于 GET 请求,我们首先判断请求路径是否是 /。如果是,我们将其视为请求 index.html
  • 对于其他路径,我们移除开头的 /,得到相对文件路径。
  • os.path.join(STATIC_ROOT, requested_file_path): 将静态文件根目录和相对文件路径组合成完整的本地文件路径。这是跨平台安全的方式。
  • 安全性:目录穿越攻击防护
    • 一个重要的安全措施是防止客户端通过请求路径(例如 /../somefile)访问 STATIC_ROOT 目录之外的文件。
    • os.path.realpath(file_path) 获取文件路径的规范化绝对路径(解析符号链接等)。
    • os.path.realpath(STATIC_ROOT) 获取静态根目录的规范化绝对路径。
    • abs_file_path.startswith(abs_static_root) 检查请求文件的绝对路径是否以静态根目录的绝对路径开头。如果不是,则表示客户端试图访问根目录之外的文件,我们返回 403 Forbidden 错误。这是一个简单的防护措施,在实际应用中需要更全面的安全考虑。
  • os.path.exists(abs_file_path)os.path.isfile(abs_file_path): 检查文件是否存在以及它是否是一个普通文件(而不是目录等)。如果不是,返回 404 Not Found。
  • with open(abs_file_path, 'rb') as f:: 以二进制模式 ('rb') 打开文件。HTTP 响应体通常是字节流,特别是图片、文件下载等。
  • response_body = f.read(): 读取文件的全部内容作为响应体。
  • mimetypes.guess_type(abs_file_path): 使用 mimetypes 模块根据文件扩展名猜测文件的 MIME 类型。这对于浏览器正确渲染文件至关重要。例如,.html 文件会得到 text/html.css 文件会得到 text/css.png 文件会得到 image/png
  • 如果成功读取文件并确定了 Content-Type,则设置状态码为 200 OK 并返回文件内容。
  • 如果文件读取失败 (IOError) 或发生其他异常,则返回 500 Internal Server Error。
  • 在构建响应头时,我们使用了动态确定的 response_code, response_text, content_type 和计算出的 len(response_body)
  • 异常处理中增加了对 400, 403, 404, 405, 500 等不同错误状态的处理,并返回相应的 HTML 错误页面。

测试静态文件服务:

  1. 在 Python 脚本所在的目录创建名为 static 的文件夹。
  2. static 文件夹中创建一些文件,例如:
    • index.html: (包含一些简单的 HTML 内容)
    • about.html: (包含一些简单的 HTML 内容)
    • style.css: body { background-color: lightblue; }
    • test.txt: This is a test file.
  3. 运行 Python 脚本。
  4. 在浏览器中访问:
    • http://127.0.0.1:8888/ (应该返回 static/index.html)
    • http://127.0.0.1:8888/about.html
    • http://127.0.0.1:8888/style.css (检查页面背景是否变为浅蓝色)
    • http://127.0.0.1:8888/test.txt
    • http://127.0.0.1:8888/nonexistent.html (应该返回 404 错误)
    • http://127.0.0.1:8888/../ (应该返回 403 错误)

现在,你已经拥有一个能够服务静态文件的简易 HTTP 服务器了!

局限性与改进方向

我们构建的这个服务器非常基础,它有许多局限性:

  1. 单线程/进程: 我们的服务器在 server_socket.accept() 后,在一个 while True 循环内处理 一个 客户端连接的所有阶段(接收、处理、发送)。这意味着在处理一个客户端请求时,服务器无法接受和处理其他客户端的连接。这是典型的阻塞式服务器模型。当请求处理时间较长(例如读取大文件或执行复杂计算)时,其他客户端将不得不等待,直到当前请求处理完毕。
  2. 简陋的请求解析: 我们只简单地解析了请求行和检查了请求头的结束标记。没有解析其他请求头(如 Host, User-Agent, Cookie 等),也没有处理带有请求体的 POST 请求。
  3. 没有并发处理: 由于是单线程阻塞模型,无法同时处理多个请求。
  4. 错误处理不完善: 虽然添加了一些 try...except,但对于各种可能的网络错误、协议错误、文件操作错误等,处理仍然比较基础。
  5. 缺乏高级 HTTP 特性: 没有实现 Keep-Alive 连接、分块传输编码 (Chunked Transfer Encoding)、请求体解析(POST 数据、文件上传)、缓存控制、压缩、SSL/TLS (HTTPS) 等。
  6. 性能和效率低: 每次连接都打开、读取、关闭文件,没有缓存。Socket 接收数据的方式也比较简单,可能效率不高。
  7. 安全性不足: 除了简单的目录穿越防护,没有考虑其他安全问题,如输入验证、防止各种攻击等。

可能的改进方向:

  1. 并发处理:
    • 多进程:accept 接收连接后,创建一个新的进程来处理客户端连接。每个进程处理一个连接。可以使用 multiprocessing 模块。
    • 多线程:accept 接收连接后,创建一个新的线程来处理客户端连接。每个线程处理一个连接。可以使用 threading 模块。多线程在 I/O 密集型任务(如等待网络或磁盘)上通常比多进程效率高,但在 CPU 密集型任务上受 GIL 限制。
    • 异步 I/O: 使用 asyncio 配合非阻塞 Socket,或者使用 selectors / poll / epoll / kqueue 等机制,通过一个或少量线程/进程来管理多个并发连接,是处理大量连接的高效方式。
  2. 完善 HTTP 请求和响应处理:
    • 解析所有请求头,并将它们存储在字典中。
    • 根据 Content-Length 头正确接收带有请求体的请求(如 POST)。
    • 实现简单的路由功能,将不同的请求路径映射到不同的处理函数。
  3. 高级功能:
    • 支持 Keep-Alive 连接,避免每次请求都重新建立和关闭 TCP 连接。
    • 实现简单的请求体解析器(如解析表单数据)。
    • 添加日志记录功能。
    • 实现基本的缓存机制。

实现这些改进会让服务器变得更加复杂,但也能更接近真实的 Web 服务器。

使用 Python 标准库 http.server

Python 标准库提供了一个 http.server 模块,可以非常方便地创建一个简单的 HTTP 服务器,特别是用于服务静态文件或作为临时的 Web 服务器。这个模块在底层也是基于 socketserversocket 构建的,但封装了 HTTP 协议的处理细节。

了解了前面手动搭建服务器的过程,我们现在来看看使用 http.server 是多么简单:

“`python
import http.server
import socketserver

定义服务器的 IP 地址和端口

HOST = “127.0.0.1”
PORT = 8000 # 常用端口

指定要服务的根目录 (默认为当前目录)

Handler = http.server.SimpleHTTPRequestHandler

或者指定特定目录

class Handler(http.server.SimpleHTTPRequestHandler):
def init(self, args, directory=None, kwargs):
# 如果指定了目录,SimpleHTTPRequestHandler 会从该目录服务文件
super().init(
args, directory=”static”, **kwargs)

创建一个 TCP 服务器,使用我们定义的 Handler 来处理请求

allow_reuse_address=True 允许在服务器关闭后立即重新绑定同一端口

with socketserver.TCPServer((HOST, PORT), Handler) as httpd:
print(f”Serving on {HOST}:{PORT} using http.server”)
print(f”Serving directory: {os.path.realpath(‘static’)}”) # 假设静态目录是 static

# 启动服务器,forever() 会一直运行直到收到中断信号 (如 Ctrl+C)
try:
    httpd.serve_forever()
except KeyboardInterrupt:
    print("\nServer stopped.")
    httpd.server_close() # 关闭服务器

“`

代码讲解:

  • import http.serverimport socketserver: 导入所需的模块。socketserver 提供服务器框架,http.server 提供 HTTP 协议处理的具体实现。
  • http.server.SimpleHTTPRequestHandler: 这是 http.server 模块中提供的一个请求处理器类,它会自动处理 GET 和 HEAD 请求,并从当前目录(或指定目录)服务文件。
  • socketserver.TCPServer((HOST, PORT), Handler): 创建一个 TCP 服务器实例。它监听指定的地址和端口,并将接收到的每个客户端连接交给 Handler 类的一个新实例去处理。with 语句确保服务器在使用完毕后会被关闭。
  • httpd.serve_forever(): 启动服务器的主循环,它会持续监听并处理请求,直到程序被中断(例如按下 Ctrl+C)。
  • try...except KeyboardInterrupt: 优雅地捕获用户中断信号,关闭服务器。

将上面的代码保存为 .py 文件,然后在 static 目录下放置一些文件(如前所述)。运行这个脚本,你将获得一个功能更完善、可以稳定服务静态文件的本地 Web 服务器,而且代码量大大减少!

通过手动实现 Socket 编程和 HTTP 协议解析,我们深刻理解了 http.server 这样的标准库背后是如何工作的,这正是从零搭建简易服务器的学习价值所在。

总结

本文详细介绍了如何使用 Python 3 的 socket 模块从零开始搭建一个简易的 HTTP 服务器。我们一步步构建了服务器的骨架:创建和绑定 Socket、监听和接受连接、接收和解析 HTTP 请求、构建和发送 HTTP 响应,并最终实现了服务静态文件的功能。

通过这个过程,我们:

  • 实践了基本的 Socket 编程流程 (socket, bind, listen, accept, recv, sendall, close)。
  • 加深了对 HTTP 请求和响应报文结构的理解。
  • 学习了如何使用 Python 进行基本的网络数据处理(字节串与字符串转换、解析报文)。
  • 了解了构建一个 Web 服务器所需的基本组件和逻辑。

虽然我们构建的服务器在功能和性能上与成熟的服务器相去甚远,但它帮助我们揭示了 Web 服务器底层的工作原理。理解这些基础是进一步学习更高级的网络编程、并发模型、Web 框架甚至安全性的重要基石。

最后,我们介绍了 Python 标准库中的 http.server 模块,展示了在实际应用中如何快速搭建一个简单的 HTTP 服务器,并认识到它是建立在我们手动实现的这些基础之上的更高级抽象。

希望这篇文章能帮助你更好地理解 HTTP 协议和网络服务器的工作原理。继续深入学习 Socket 的非阻塞模式、asyncio、多线程/多进程并发、以及更复杂的 HTTP 协议特性,将是你提升网络编程技能的下一个方向。


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部