第4章 4.3 静态文件服务

上一章我们学会了路由,知道如何根据不同 URL 返回不同内容。但你有没有想过:当你在浏览器输入 http://localhost:3000/index.html 时,服务器是怎么找到并返回那个 .html 文件的?

这,就是静态文件服务要解决的问题。

🎯 开场 3 分钟:为什么要学这个?

场景还原:你写了一个页面 index.html,里面有 CSS 样式和 JavaScript 脚本。你双击打开它,浏览器显示正常。但当你用 http://localhost:3000/index.html 访问时——样式没了,脚本也报错了

为什么?因为浏览器请求 style.css 时,它实际在请求 http://localhost:3000/style.css。如果你的服务器没"告诉"浏览器去哪里找这个文件,浏览器就找不到了。

痛点
- 静态资源(图片、CSS、JS)返回 404
- 不知道怎么处理 MIME 类型(浏览器不知道这是图片还是文本)
- 大文件传输慢,不知道怎么优化

学完本文你能:搭建一个能自动serve图片、CSS、JS文件的服务器,还能玩点缓存和断点续传的骚操作。


🧱 基础 25 分钟:核心概念

4.3.1 什么是静态文件服务?

生活类比:想象你开了一家快递站。用户说"帮我取个包裹",你需要知道:
1. 包裹放在哪个架子上(文件路径)
2. 这个包裹是什么类型(易碎品?文件?生鲜?)—— MIME 类型
3. 怎么打包给用户(流式传输 vs 整个加载)

静态文件服务就是:服务器收到请求后,去服务器磁盘上找到对应文件,读取它,返回给客户端。

4.3.2 最简单的文件服务

import http.server
import socketserver

PORT = 8000

# 直接使用 Python 内置的 SimpleHTTPRequestHandler
# 它会自动把当前目录的文件 serve 出去
Handler = http.server.SimpleHTTPRequestHandler

with socketserver.TCPServer(("", PORT), Handler) as httpd:
print(f"服务器运行在 http://localhost:{PORT}")
httpd.serve_forever()

保存为 server.py,运行后浏览器访问 http://localhost:8000,你会看到当前文件夹里的所有文件。

一行解释SimpleHTTPRequestHandler 是 Python 自带的静态文件处理器,你不需要自己写读取文件、判断类型这些脏活累活。

4.3.3 手动实现:返回任意文件

有时候你想更灵活地控制,比如限制只能访问某个目录:

import http.server
import os
from pathlib import Path

class CustomHandler(http.server.SimpleHTTPRequestHandler):
# 设置 serve 的根目录
base_directory = "/Users/apple/static_files"

def translate_path(self, path):
    """把 URL 路径转成真实文件路径"""
    # 去掉开头的 /
    path = path.split('?', 1)[0]
    path = path.split('#', 1)[0]
    path = path.lstrip('/')
    # 拼接成完整路径
    return os.path.join(self.base_directory, path)

def do_GET(self):
    """处理 GET 请求"""
    # 获取请求的文件路径
    filepath = self.translate_path(self.path)

    if os.path.isfile(filepath):
        # 文件存在,读取并返回
        try:
            with open(filepath, 'rb') as f:
                content = f.read()
            self.send_response(200)
            # 自动识别 MIME 类型
            self.send_header('Content-type', self.guess_type(filepath))
            self.send_header('Content-Length', len(content))
            self.end_headers()
            self.wfile.write(content)
        except Exception as e:
            self.send_error(500, str(e))
    else:
        # 文件不存在,返回 404
        self.send_error(404, "File not found")

if __name__ == "__main__":
PORT = 8080
with socketserver.TCPServer(("", PORT), CustomHandler) as httpd:
    print(f"静态文件服务启动:http://localhost:{PORT}")
    httpd.serve_forever()

核心流程图

配图1 - 配图1

4.3.4 MIME 类型是什么?

生活类比:快递员看到"生鲜"标签就知道要用冷链车,看到"易碎"就知道要轻拿轻放。MIME 类型就是文件的"标签",告诉浏览器怎么解析这个文件。

常见 MIME 类型:

文件类型 MIME 类型 例子
HTML text/html .html
CSS text/css .css
JavaScript application/javascript .js
JSON application/json .json
图片 PNG image/png .png
图片 JPG image/jpeg .jpg
PDF application/pdf .pdf

Python 的 mimetypes 模块能自动识别:

import mimetypes

# 注册一些额外的类型
mimetypes.add_type('application/javascript', '.js')

filepath = "/path/to/style.css"
mime_type, _ = mimetypes.guess_type(filepath)
print(mime_type)  # 输出: text/css

4.3.5 流式传输:大文件不用一次性读入内存

痛点:一个 1G 的视频文件,如果一次性读入内存,内存直接爆掉。正确做法是流式传输——像水管一样,一段一段地送。

import http.server
import os

class StreamingHandler(http.server.SimpleHTTPRequestHandler):
def stream_file(self, filepath):
    """流式传输文件,不占用大量内存"""
    file_size = os.path.getsize(filepath)
    self.send_response(200)
    self.send_header('Content-Type', self.guess_type(filepath))
    self.send_header('Content-Length', file_size)
    self.end_headers()

    # 分块读取,每次读 8KB
    chunk_size = 8 * 1024
    with open(filepath, 'rb') as f:
        while True:
            chunk = f.read(chunk_size)
            if not chunk:
                break
            self.wfile.write(chunk)

def do_GET(self):
    filepath = self.translate_path(self.path)
    if os.path.isfile(filepath):
        self.stream_file(filepath)
    else:
        self.send_error(404)

关键点:不是 content = f.read() 一次性读完,而是用循环 chunk = f.read(8192) 分块读,边读边发。


🔥 实战 35 分钟:3 个递进项目

项目 1(5 分钟):一个能显示图片的相册服务器

需求:搭建一个服务器,能浏览文件夹里的图片。

完整代码

#!/usr/bin/env python3
import http.server
import os
import socketserver
from pathlib import Path

PORT = 9000
PHOTO_DIR = "./photos"

# 确保目录存在
os.makedirs(PHOTO_DIR, exist_ok=True)

class PhotoHandler(http.server.SimpleHTTPRequestHandler):
# 自定义根目录
def __init__(self, *args, **kwargs):
    super().__init__(*args, directory=PHOTO_DIR, **kwargs)

if __name__ == "__main__":
print(f"📷 相册服务器启动!")
print(f"   把图片放到 ./photos 文件夹")
print(f"   访问 http://localhost:{PORT}")

with socketserver.TCPServer(("", PORT), PhotoHandler) as httpd:
    httpd.serve_forever()

预期输出

📷 相册服务器启动!
图片放到 ./photos 文件夹
问 http://localhost:9000

把几张 .jpg.png 图片扔进 photos 文件夹,刷新浏览器就能看到缩略图列表。


项目 2(15 分钟):带缓存控制的资源服务器

需求:网站图片更新了,但用户看到的是旧图(缓存问题)。实现一个带缓存控制的服务器。

完整代码

#!/usr/bin/env python3
import http.server
import os
import time
import hashlib
from datetime import datetime, timedelta

PORT = 9001
STATIC_DIR = "./static"

os.makedirs(STATIC_DIR, exist_ok=True)

class CacheableHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
    super().__init__(*args, directory=STATIC_DIR, **kwargs)

def do_GET(self):
    filepath = self.translate_path(self.path)

    if not os.path.isfile(filepath):
        self.send_error(404)
        return

    # 生成文件的 ETag(基于修改时间和大小)
    stat = os.stat(filepath)
    etag = hashlib.md5(f"{stat.st_mtime}{stat.st_size}".encode()).hexdigest()

    # 检查浏览器是否带了 If-None-Match(缓存验证)
    client_etag = self.headers.get('If-None-Match')

    if client_etag == etag:
        # 文件没变,告诉浏览器用缓存
        self.send_response(304)
        self.end_headers()
        return

    # 文件有变化,返回新内容
    try:
        with open(filepath, 'rb') as f:
            content = f.read()

        self.send_response(200)
        self.send_header('Content-Type', self.guess_type(filepath))
        self.send_header('Content-Length', len(content))
        self.send_header('ETag', etag)
        # 缓存 1 小时
        self.send_header('Cache-Control', 'max-age=3600')
        self.send_header('Last-Modified', self.date_time_string(stat.st_mtime))
        self.end_headers()
        self.wfile.write(content)

    except Exception as e:
        self.send_error(500, str(e))

if __name__ == "__main__":
print(f"🚀 带缓存的静态服务器启动!")
print(f"   资源目录: ./{STATIC_DIR}")
print(f"   访问 http://localhost:{PORT}")

with socketserver.TCPServer(("", PORT), CacheableHandler) as httpd:
    httpd.serve_forever()

关键代码解读
- ETag:文件的"指纹",文件变了指纹就变
- Cache-Control: max-age=3600:告诉浏览器缓存 1 小时
- 304 Not Modified:文件没变,浏览器直接用本地缓存

配图2 - 配图2


项目 3(15 分钟):断点续传的多线程下载服务器

需求:下载一个大文件,中途断了不用从头开始(断点续传)。

完整代码

#!/usr/bin/env python3
import http.server
import os
import socketserver

PORT = 9002
DOWNLOAD_DIR = "./downloads"

os.makedirs(DOWNLOAD_DIR, exist_ok=True)

class RangeHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
    super().__init__(*args, directory=DOWNLOAD_DIR, **kwargs)

def do_GET(self):
    filepath = self.translate_path(self.path)

    if not os.path.isfile(filepath):
        self.send_error(404)
        return

    file_size = os.path.getsize(filepath)

    # 检查是否请求部分内容(Range 请求)
    range_header = self.headers.get('Range')

    if range_header:
        # 解析 Range: bytes=start-end
        try:
            range_spec = range_header.replace('bytes=', '')
            start_str, end_str = range_spec.split('-')
            start = int(start_str) if start_str else 0
            end = int(end_str) if end_str else file_size - 1

            # 确保范围合法
            if start >= file_size or end >= file_size:
                self.send_error(416, "Range Not Satisfiable")
                return

            self.send_response(206)  # Partial Content
            self.send_header('Content-Type', self.guess_type(filepath))
            self.send_header('Content-Length', end - start + 1)
            self.send_header('Content-Range', f'bytes {start}-{end}/{file_size}')
            self.send_header('Accept-Ranges', 'bytes')
            self.end_headers()

            with open(filepath, 'rb') as f:
                f.seek(start)
                remaining = end - start + 1
                chunk_size = 8192
                while remaining > 0:
                    to_read = min(chunk_size, remaining)
                    chunk = f.read(to_read)
                    self.wfile.write(chunk)
                    remaining -= len(chunk)

        except Exception as e:
            self.send_error(400, str(e))
    else:
        # 完整文件下载
        with open(filepath, 'rb') as f:
            content = f.read()
        self.send_response(200)
        self.send_header('Content-Type', self.guess_type(filepath))
        self.send_header('Content-Length', file_size)
        self.send_header('Accept-Ranges', 'bytes')
        self.end_headers()
        self.wfile.write(content)

if __name__ == "__main__":
print(f"📥 支持断点续传的下载服务器!")
print(f"   把要分享的文件放到 ./{DOWNLOAD_DIR}")
print(f"   访问 http://localhost:{PORT}")

with socketserver.TCPServer(("", PORT), RangeHandler) as httpd:
    httpd.serve_forever()

断点续传原理:浏览器下载到一半断了,再请求时带上 Range: bytes=500000- 告诉服务器"我从第 50 万字节开始要"。服务器返回 206 Partial Content,只发这一段。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:路径穿越漏洞 ❌ → ✅

错误示例(危险!):

# 用户请求 /../../etc/passwd 会读取到系统文件!
filepath = os.path.join(self.base_directory, self.path)

正确示例

from pathlib import Path

def safe_path(self, url_path):
# 使用 Path 解析并确保在基础目录内
base = Path(self.base_directory).resolve()
requested = (base / url_path.lstrip('/')).resolve()
# 防止路径穿越:确保解析后的路径在 base 目录下
if not str(requested).startswith(str(base)):
    raise ValueError("路径穿越攻击!")
return str(requested)

坑 2:文件路径有中文会报错 ❌ → ✅

import urllib.parse

def translate_path(self, path):
# URL 解码处理中文路径
path = urllib.parse.unquote(path)
# ...

坑 3:忘记以二进制模式打开文件 ❌ → ✅

# ❌ 错误:文本模式会在 Windows 上坏掉
with open(filepath, 'r') as f:
content = f.read()

# ✅ 正确:二进制模式
with open(filepath, 'rb') as f:
content = f.read()

坑 4:大文件直接 read() 爆内存 ❌ → ✅

# ❌ 错误:1G 文件直接读进内存
with open(filepath, 'rb') as f:
content = f.read()  # 内存爆炸!

# ✅ 正确:流式传输
self.stream_file(filepath)

坑 5:静态文件服务器不要开 debug ❌ → ✅

# ❌ 线上环境开 debug 会暴露信息
Handler = http.server.SimpleHTTPRequestHandler
Handler.log_message = print  # 不要这样!

# ✅ 生产环境用成熟的 nginx 做静态文件服务

性能小贴士:静态资源用 nginx

Python 的静态文件服务器适合开发调试,生产环境建议用 nginx

server {
listen 80;
server_name example.com;

location /static/ {
    alias /var/www/static/;
    # nginx 会自动处理缓存、压缩、断点续传
    expires 7d;  # 缓存 7 天
    gzip on;     # 压缩传输
}
}

调试技巧:用 logging 记录请求

import logging

logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(message)s'
)

class DebugHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
    logging.info(f"请求: {self.path} 来自 {self.client_address}")
    super().do_GET()

✏️ 练习题

练习 1(2 分钟):改端口
- 输入:把相册服务器的端口从 9000 改成 8888
- 预期输出:服务器启动信息显示 http://localhost:8888
- 提示:改一个数字就行

练习 2(3 分钟):限制文件类型
- 输入:在项目 1 基础上,只允许显示 .jpg.png 文件
- 预期输出:访问 .txt 文件返回 403 禁止访问
- 提示:在 do_GET 里加个 if 判断后缀

练习 3(5 分钟):添加访问日志
- 输入:给项目 2 添加日志,每请求一个文件打印 "访问: filename"
- 预期输出:终端显示 访问: logo.png
- 提示:在 do_GET 开头加 print()logging.info()

练习 4(10 分钟):组合缓存+断点续传
- 输入:把项目 2 的缓存控制和项目 3 的断点续传合并
- 预期输出:同时支持 ETag 缓存验证和 Range 请求
- 提示:两个项目的代码都要用到

练习 5(5 分钟):分析报错
- 输入:用户访问返回 "File not found",但文件明明存在
- 预期输出:说出可能原因并修复
- 提示:检查路径拼接和 translate_path 方法


作业:做一个「静态文件浏览器工具」

  • 需求:实现一个带 Web 界面的文件浏览器,能浏览、切换目录、预览图片
  • 功能点
    1. 显示当前目录的文件列表(名字、大小、修改时间)
    2. 点击文件夹可以进入,点击文件可以预览(图片直接显示,其他文件显示内容或下载)
    3. 顶部显示当前路径,可点击返回上级目录
  • 加分项
    1. 支持按文件名搜索过滤
    2. 显示图片缩略图
  • 验收标准:能跑起来 + 目录切换正常 + 图片能预览
  • 提交方式:评论区贴代码或 GitHub 链接

📚 总结 + 资源

本文学到的 3 个核心点
1. 静态文件服务 = 读取磁盘文件 + 返回给浏览器,Python 有现成的 SimpleHTTPRequestHandler
2. MIME 类型告诉浏览器怎么解析文件,用 mimetypes.guess_type() 自动识别
3. 大文件要用流式传输,缓存用 ETag + Cache-Control,断点续传用 Range 请求

延伸学习资源
- Python官方文档:http.server模块 —— 最权威的参考
- 《HTTP权威指南》—— 理解 Web 底层必备
- MDN:HTTP缓存 —— 缓存机制详解

互动钩子:你在项目里用过静态文件服务吗?有没有遇到过缓存刷不掉或者文件找不到的坑?评论区聊聊,老粉优先回复!


📝 下章预告:学会了静态文件服务,但你发现原生 http.server 太基础了——路由要自己写、错误处理要自己加……下一章我们来认识一个更强大的框架:Express,用它写 Web 服务就像搭积木一样简单!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。