141 lines
5.4 KiB
Python
141 lines
5.4 KiB
Python
"""第5-6课标准示例:启动本地HTTP服务并自动调用图书接口。"""
|
|
|
|
import json
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from threading import Thread
|
|
from urllib.error import HTTPError
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
class BookApiHandler(BaseHTTPRequestHandler):
|
|
"""处理本课图书查询和新增请求的最小HTTP处理器。"""
|
|
|
|
# 本课不连接数据库,使用类属性保存当前程序运行期间的图书。
|
|
# 程序退出后这份列表会消失,再次运行时会重新从一条图书开始。
|
|
books = [{"id": 1, "title": "Python入门"}]
|
|
|
|
def send_json(
|
|
self,
|
|
status: HTTPStatus,
|
|
data: dict | list,
|
|
extra_headers: dict | None = None,
|
|
) -> None:
|
|
"""把Python对象转换为UTF-8 JSON响应。"""
|
|
|
|
# HTTP连接传输的是字节,因此先生成JSON字符串,再编码成UTF-8字节。
|
|
response_body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
# Content-Length必须填写字节数量,不能直接计算中文字符串的字符数。
|
|
self.send_header("Content-Length", str(len(response_body)))
|
|
if extra_headers is not None:
|
|
for header_name, header_value in extra_headers.items():
|
|
self.send_header(header_name, header_value)
|
|
self.end_headers()
|
|
self.wfile.write(response_body)
|
|
|
|
def do_GET(self) -> None:
|
|
"""处理GET /books,并拒绝未知路径。"""
|
|
|
|
# 标准库收到GET请求后会自动调用本方法,self.path保存请求目标。
|
|
if self.path == "/books":
|
|
self.send_json(HTTPStatus.OK, {"items": self.books})
|
|
return
|
|
self.send_json(HTTPStatus.NOT_FOUND, {"message": "资源不存在"})
|
|
|
|
def do_POST(self) -> None:
|
|
"""处理POST /books,校验JSON并创建图书。"""
|
|
|
|
if self.path != "/books":
|
|
self.send_json(HTTPStatus.NOT_FOUND, {"message": "资源不存在"})
|
|
return
|
|
|
|
try:
|
|
# Content-Length告诉服务器本次请求体一共有多少字节。
|
|
content_length = int(self.headers.get("Content-Length", "0"))
|
|
request_body = self.rfile.read(content_length).decode("utf-8")
|
|
request_data = json.loads(request_body)
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
self.send_json(HTTPStatus.BAD_REQUEST, {"message": "JSON格式错误"})
|
|
return
|
|
|
|
title = request_data.get("title")
|
|
if not isinstance(title, str) or not title.strip():
|
|
self.send_json(
|
|
HTTPStatus.UNPROCESSABLE_CONTENT,
|
|
{"message": "书名不能为空"},
|
|
)
|
|
return
|
|
|
|
# 本课用列表长度模拟生成编号;真实项目通常由数据库生成主键。
|
|
book = {"id": len(self.books) + 1, "title": title.strip()}
|
|
self.books.append(book)
|
|
self.send_json(
|
|
HTTPStatus.CREATED,
|
|
book,
|
|
{"Location": f"/books/{book['id']}"},
|
|
)
|
|
|
|
def log_message(self, format_text: str, *args: object) -> None:
|
|
"""关闭标准库默认访问日志,让教学输出保持清晰。"""
|
|
|
|
|
|
def send_request(
|
|
url: str,
|
|
method: str = "GET",
|
|
data: dict | None = None,
|
|
) -> None:
|
|
"""作为客户端发送HTTP请求并输出响应。"""
|
|
|
|
request_body = None
|
|
headers = {"Accept": "application/json"}
|
|
if data is not None:
|
|
request_body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
headers["Content-Type"] = "application/json; charset=utf-8"
|
|
|
|
# Request只负责描述请求;urlopen()执行时才真正连接服务器并发送请求。
|
|
request = Request(url, data=request_body, headers=headers, method=method)
|
|
try:
|
|
with urlopen(request, timeout=3) as response:
|
|
response_text = response.read().decode("utf-8")
|
|
print(f"{method} {request.full_url} -> {response.status}")
|
|
print(f"响应体:{response_text}")
|
|
except HTTPError as error:
|
|
response_text = error.read().decode("utf-8")
|
|
print(f"{method} {request.full_url} -> {error.code}")
|
|
print(f"响应体:{response_text}")
|
|
|
|
|
|
def main() -> None:
|
|
"""在随机本地端口启动服务,完成调用后安全关闭。"""
|
|
|
|
# 127.0.0.1限制为本机访问;端口0表示让操作系统选择可用端口。
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), BookApiHandler)
|
|
# 服务器需要持续等待请求,因此放入后台线程;主线程继续充当客户端。
|
|
server_thread = Thread(target=server.serve_forever, daemon=True)
|
|
server_thread.start()
|
|
host, port = server.server_address
|
|
base_url = f"http://{host}:{port}"
|
|
print(f"本地服务已启动:{base_url}")
|
|
|
|
try:
|
|
send_request(f"{base_url}/books")
|
|
send_request(
|
|
f"{base_url}/books",
|
|
method="POST",
|
|
data={"title": "HTTP接口实践"},
|
|
)
|
|
send_request(f"{base_url}/books")
|
|
send_request(f"{base_url}/missing")
|
|
finally:
|
|
# 即使某次请求失败,也必须释放监听端口并等待后台线程退出。
|
|
server.shutdown()
|
|
server.server_close()
|
|
server_thread.join()
|
|
print("本地服务已关闭。")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|