server 服务装配
第2周收官。看 FastAPI 应用怎么被中间件(CORS/缓存/限流)武装、生命周期钩子怎么自动跑数据库迁移、路由怎么汇总、鉴权加在哪。昨天(Day 09)我们看清了沙箱这块地基;今天把整个 app_server 的"外壳装配"补齐——中间件、迁移、路由、鉴权;装完这一层,下周(Day 11 起)就能安心离开"管家",进入"工人的大脑"了。
openhands/server 目录为何废弃
先解一个可能的困惑:既然叫"server",为什么真正的东西在 app_server?看 openhands/server/ 里的文件,全是一行转发:
# openhands/server/app.py(全部内容差不多就这样)
from openhands.app_server.app import app # 只是 re-export
# openhands/server/__main__.py 注释建议:直接用 uvicorn openhands.app_server.app:app
openhands/server/ 是历史遗留的"兼容壳"——老代码/文档可能还 import openhands.server,为了不破坏它们,保留这些转发文件,实际全指向 app_server。启动入口 main() 默认端口 3000。看到 server 目录别停留,真东西在 app_server。openhands/server/ 全是转发、没有真逻辑,那就是"死代码",删掉更干净。其实:可能还有部署脚本、外部集成、老文档在 import openhands.server,一删就把它们全弄崩。留着这层薄壳的成本几乎为零,删掉的风险却不可控——这正是"兼容壳"存在的意义。中间件三件套
回忆 Day 06,app.py 给应用装了三个中间件(app.py:81,实现在 middleware.py):
GET /api/v1/sandboxes。它先穿过 CORS 关卡(来源是不是被允许?)→ 再过限流关卡(这个 IP 这一秒是不是已经超 10 次?)→ 才到真正的路由函数;函数返回后,响应又反向穿过缓存关卡(该不该加 Cache-Control 头?)才回到浏览器。三道工序,业务代码一行都没写。CORS 与本地放行
LocalhostCORSMiddleware(middleware.py:21)继承标准 CORS 中间件,额外放行任意 localhost/127.0.0.1 来源:
class LocalhostCORSMiddleware(CORSMiddleware):
# 除了配置的 origins,额外无条件放行 localhost / 127.0.0.1(开发便利)
# 若一个 origin 都没配,会放行所有并打 warning
localhost:3001、后端在 localhost:3000——严格说是"跨域",会被浏览器拦。所以这个中间件特意放行本地来源,让开发者不用折腾 CORS 配置就能前后端联调。生产环境则靠配置的 permitted_cors_origins 严格限定。一个都没配还会打 warning 提醒你——防止误放行所有来源。缓存与限流
# CacheControlMiddleware (middleware.py:59)
# /assets 下(带指纹的文件名)→ 强缓存 30 天 immutable
# 其余 → no-store(一律不缓存)
# RateLimitMiddleware + InMemoryRateLimiter (middleware.py:78)
# 按 client host 内存滑动窗口限流(app.py 配 10 req/s)
# /assets 和 sandbox resume 请求豁免
生命周期与 Alembic 自动迁移
应用启动/关闭时要做一些事(比如建数据库表),这靠"生命周期(lifespan)"钩子。OssAppLifespanService(oss_app_lifespan_service.py:12)在启动时自动跑数据库迁移:
def run_alembic(self): # :23 启动时执行
# alembic upgrade head —— 自动把数据库结构升级到最新版本
# 迁移脚本在 app_lifespan/alembic/versions/001.py … 014.py
alembic upgrade head 就是"按图纸一路施工到最新的样子"。随着项目演进,数据库表结构会变(加字段、加表)。"迁移脚本"就是"如何把旧结构升级到新结构"的一步步说明(001→002→…→014)。alembic upgrade head = "把数据库升级到最新版本"。启动时自动跑意味着:你拉个新版本、一启动,表结构就自动升级好了,不用手动执行 SQL。对开源用户(OSS 模式)超友好——开箱即用。SaaS 模式则用不同的 lifespan 实现(初始化 PostHog 埋点等),由 config.py 根据部署模式选择。AppLifespanService 本身是个 async 上下文管理器抽象——启动时进入、关闭时退出。Day 06 的 combine_lifespans 把它和 MCP 子应用的 lifespan 合并成一个,交给 FastAPI。v1_router:把所有路由汇成一束
各模块(会话/事件/沙箱/用户…)各有自己的路由,v1_router.py:24 把它们全挂到统一前缀 /api/v1 下:
# v1_router.py(概念)
router = APIRouter(prefix='/api/v1')
router.include_router(event_router) # /api/v1/conversation/{id}/events
router.include_router(app_conversation_router) # /api/v1/app-conversations
router.include_router(sandbox_router) # /api/v1/sandboxes
router.include_router(webhook_router) # /api/v1/webhooks
router.include_router(settings_router, secrets_router, user_router, git_router, config_router)
/api/v1 前缀。然后 app.py 只 include_router(v1_router.router) 一句就把全部 API 挂上了。这种"每个模块管自己的路由、总路由汇总"的组织方式,让新增模块很容易(写好自己的 router、在这里 include 一行)。鉴权加在哪
各业务路由用 dependencies=get_dependencies() 标记"受保护"(比如 event_router.py:16)。但源码注释多次强调:真正的保护由 SetAuthCookieMiddleware 提供——那个 dependencies 主要是给 OpenAPI 文档打"需要认证"的标记。
dependencies=get_dependencies(),那鉴权不就是这句做的吗?👨🏫 老师:不是。那句主要是"贴标签"——让自动生成的 API 文档标注这个接口"需要登录"。
👶 小白:那真正拦住未登录请求的是谁?
👨🏫 老师:是中间件
SetAuthCookieMiddleware。它在商场大门口统一验票(cookie),没票的根本进不来,压根到不了铺位(路由函数)。👶 小白:那沙箱回调(机器)也要登录吗?
👨🏫 老师:不,机器走另一道门——凭
X-Session-API-Key 头验证,就像供货商走商场的"货运通道"刷工作证,不走顾客大门。X-Session-API-Key 头鉴权(证明"我是那个合法沙箱",Day 08/09 见过)。不同来源用不同的鉴权方式——用户用 cookie,机器用 API key,各得其所。🎓 第 2 周收官 + 动手
第 2 周(Day 06-10)你已读懂 app_server 编排层
- Day 06 架构总览 + Injector 依赖注入(改环境变量换实现)
- Day 07 会话生命周期:启动状态机、生成器即进度流
- Day 08 事件系统:只存不推、模板方法存储、webhook 入站
- Day 09 沙箱管理:Docker 供给、回调闭环、密钥按需下发
- Day 10 服务装配:中间件、Alembic 迁移、路由汇总、鉴权
你已理解"管家"如何编排一切。下周(Day 11-15)进入 Agent 大脑——SDK 概念、工具体系、运行时、LLM、CodeAct 范式。
✋ 动手
cat openhands/server/app.py # 确认是转发壳
grep -n 'class .*Middleware\|localhost\|no-store\|resume' openhands/app_server/middleware.py
grep -n 'run_alembic\|upgrade' openhands/app_server/app_lifespan/oss_app_lifespan_service.py
grep -n 'include_router\|prefix' openhands/app_server/v1_router.py