Day 10 / 共 20 天 · 第 2 周 app_server 编排层(收官)

server 服务装配

第2周收官。看 FastAPI 应用怎么被中间件(CORS/缓存/限流)武装、生命周期钩子怎么自动跑数据库迁移、路由怎么汇总、鉴权加在哪。昨天(Day 09)我们看清了沙箱这块地基;今天把整个 app_server 的"外壳装配"补齐——中间件、迁移、路由、鉴权;装完这一层,下周(Day 11 起)就能安心离开"管家",进入"工人的大脑"了。

📍 第 2 周 后端编排层 · 你在这里
Day6 app_server总览Day7 会话生命周期Day8 事件系统Day9 SandboxDay10 server装配
L01

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,一删就把它们全弄崩。留着这层薄壳的成本几乎为零,删掉的风险却不可控——这正是"兼容壳"存在的意义。
L02

中间件三件套

🤔 痛点:跨域检查、缓存头、限流……这些逻辑每个接口都要,难道每个函数里都写一遍?如果让你自己实现,最朴素的做法是在每个路由函数开头都 copy 一段"检查一下"。几十个接口就是几十份重复代码,改一处要改几十处。
💡 本质:中间件就像商场开业前统一装好的"入口设施"。商场不会让每个铺位自己装安检门和闸机,而是在大门口统一装一套——所有顾客进出都先过这道关。中间件也一样:请求进来先统一过一遍"关卡",通用逻辑写一份,所有接口自动享用。

回忆 Day 06,app.py 给应用装了三个中间件(app.py:81,实现在 middleware.py):

LocalhostCORSMiddleware — 跨域控制(+ 本地开发放行)
CacheControlMiddleware — 静态资源缓存策略
RateLimitMiddleware — 按客户端限流
中间件(middleware)是什么? 中间件是"每个请求进出都会经过的关卡"。请求到达路由处理函数之前、响应返回之后,中间件都能插一脚做统一处理——比如"所有请求都检查跨域""所有响应都加缓存头""所有请求都算一下限流"。这样这些通用逻辑不用在每个接口里重复写。中间件 = 请求处理的流水线上的通用工序。
📝 举个例子浏览器发来 GET /api/v1/sandboxes。它先穿过 CORS 关卡(来源是不是被允许?)→ 再过限流关卡(这个 IP 这一秒是不是已经超 10 次?)→ 才到真正的路由函数;函数返回后,响应又反向穿过缓存关卡(该不该加 Cache-Control 头?)才回到浏览器。三道工序,业务代码一行都没写。
请求 CORS 限流 路由函数 缓存头 进来一路过关卡 → 干活 → 出去再过关卡(商场大门的安检 / 闸机 / 导览)
图:一个请求依次穿过中间件流水线,业务函数只管干自己的活
L03

CORS 与本地放行

LocalhostCORSMiddlewaremiddleware.py:21)继承标准 CORS 中间件,额外放行任意 localhost/127.0.0.1 来源:

class LocalhostCORSMiddleware(CORSMiddleware):
    # 除了配置的 origins,额外无条件放行 localhost / 127.0.0.1(开发便利)
    # 若一个 origin 都没配,会放行所有并打 warning
CORS 是什么?为什么要特殊放行 localhost? CORS(跨域资源共享)是浏览器的安全机制:默认不允许"A 网站的页面去请求 B 网站的接口"。开发 OpenHands 时,前端跑在 localhost:3001、后端在 localhost:3000——严格说是"跨域",会被浏览器拦。所以这个中间件特意放行本地来源,让开发者不用折腾 CORS 配置就能前后端联调。生产环境则靠配置的 permitted_cors_origins 严格限定。一个都没配还会打 warning 提醒你——防止误放行所有来源。
L04

缓存与限流

# CacheControlMiddleware (middleware.py:59)
#   /assets 下(带指纹的文件名)→ 强缓存 30 天 immutable
#   其余 → no-store(一律不缓存)
# RateLimitMiddleware + InMemoryRateLimiter (middleware.py:78)
#   按 client host 内存滑动窗口限流(app.py 配 10 req/s)
#   /assets 和 sandbox resume 请求豁免
🤔 错误驱动:如果没有限流,会出什么事故?假设某个客户端(或脚本 bug)每秒往接口打几千次请求。没有限流的话,服务器 CPU 被瞬间打满、内存耗尽,正常用户全部卡死甚至整个服务崩溃——一个人的失控请求拖垮所有人。限流就是商场入口的"限流闸机":客流太猛时先挡在门外排队,保护里面不被挤爆。
读法:缓存策略"看人下菜"——带指纹的静态资源(文件名含 hash,内容变文件名就变)可以放心长期强缓存;动态接口一律 no-store(防止拿到过期数据)。限流按来源 IP 滑动窗口算,保护服务不被打爆。
为什么 sandbox resume 请求要豁免限流? 因为"恢复一个暂停的沙箱"可能很慢(要 unpause 容器、等 agent-server 就绪,可能几秒到几十秒)。如果这种慢请求也被 10 req/s 的限流误伤,用户想恢复会话却被限流挡住,体验很差。所以特意豁免。限流是好事,但要给"合理的慢请求"开绿灯——一刀切的限流会误伤正常操作。这种对边界情况的照顾,是打磨产品的功夫。
L05

生命周期与 Alembic 自动迁移

应用启动/关闭时要做一些事(比如建数据库表),这靠"生命周期(lifespan)"钩子。OssAppLifespanServiceoss_app_lifespan_service.py:12)在启动时自动跑数据库迁移

def run_alembic(self):    # :23 启动时执行
    # alembic upgrade head —— 自动把数据库结构升级到最新版本
    # 迁移脚本在 app_lifespan/alembic/versions/001.py … 014.py
数据库迁移(migration)是什么?为什么要自动跑? 数据库迁移就像商场开业/翻新前的"装修改造":新版本要给某张表加个字段,就像某个铺位要加个隔断墙,而"迁移脚本"就是一张张按顺序的施工图纸(001→002→…→014),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。
L06

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.pyinclude_router(v1_router.router) 一句就把全部 API 挂上了。这种"每个模块管自己的路由、总路由汇总"的组织方式,让新增模块很容易(写好自己的 router、在这里 include 一行)。
L07

鉴权加在哪

各业务路由用 dependencies=get_dependencies() 标记"受保护"(比如 event_router.py:16)。但源码注释多次强调:真正的保护由 SetAuthCookieMiddleware 提供——那个 dependencies 主要是给 OpenAPI 文档打"需要认证"的标记。

🤔 对话体:鉴权到底加在哪? 👶 小白:既然每个路由都写了 dependencies=get_dependencies(),那鉴权不就是这句做的吗?
👨‍🏫 老师:不是。那句主要是"贴标签"——让自动生成的 API 文档标注这个接口"需要登录"。
👶 小白:那真正拦住未登录请求的是谁?
👨‍🏫 老师:是中间件 SetAuthCookieMiddleware。它在商场大门口统一验票(cookie),没票的根本进不来,压根到不了铺位(路由函数)。
👶 小白:那沙箱回调(机器)也要登录吗?
👨‍🏫 老师:不,机器走另一道门——凭 X-Session-API-Key 头验证,就像供货商走商场的"货运通道"刷工作证,不走顾客大门。
两套鉴权,看对象:① 用户请求(前端→app_server)走 cookie/中间件鉴权(证明"你是登录用户");② 沙箱回调(agent-server→app_server 的 webhook、沙箱来取密钥)走 X-Session-API-Key 头鉴权(证明"我是那个合法沙箱",Day 08/09 见过)。不同来源用不同的鉴权方式——用户用 cookie,机器用 API key,各得其所。
L08

🎓 第 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
下周预告 · Day 11:离开管家,进入工人的大脑——Agent SDK 概念(openhands-sdk)。虽然核心在外部包,但我们能从本仓的调用方式 + 公开设计,讲清 Agent 的感知-决策循环、状态、步进机制。
← Day 09 沙箱 Day 11 · Agent SDK →