ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Puter 后端架构深度解析:Controller–Service–Store 分层、依赖注入与可插拔扩展机制

Puter 后端架构深度解析:Controller–Service–Store 分层、依赖注入与可插拔扩展机制 Puter 后端架构深度解析Controller–Service–Store 分层、依赖注入与可插拔扩展机制【免费下载链接】puter The Internet Computer! Free, Open-Source, and Self-Hostable.项目地址: https://gitcode.com/GitHub_Trending/pu/puter本文围绕 doc/architecture.md 展开结合 Puter 开源仓库的 后端启动器、分层注册表、扩展 API、请求上下文 与 路由选项类型 等源码完整拆解其分层架构、启动装配流程、基于 AsyncLocalStorage 的请求上下文以及扩展机制让读者既能按图索骥地定位某类代码应该放哪一层也能理解一个 HTTP 请求从进入网关到落库的完整链路并掌握编写一个合规扩展Extension所需的全部 API 细节。Puter 是一个自托管的开源互联网计算机The Internet Computer它的后端被组织成一个每一层只依赖其下层的洋葱状分层栈并由PuterServer在启动时按固定顺序实例化、通过构造函数逐层注入依赖。这种设计与经典 Controller–Service–Repository 模式形似而神似同时引入了一套与核心栈平行生长的扩展体系让非关键能力缩略图、服务器信息、开发者热重载等可以像插件一样干净地挂在任意一层之上。一、总体设计分层与依赖注入的基本原则文档开宗明义整个后端松散地受 Controller–Service–Repository 模式启发配合依赖注入DI。核心组织方式是把后端叠成一摞层stack of layers并立下两条铁律每一层只依赖它下面的层绝不反向引用、也绝不横向跨层调用所有依赖都通过构造函数注入由唯一的引导者PuterServer按顺序实例化各层并把下层实例递给上层。这两条规则共同保证了依赖关系是显式、可追溯的——从 PuterServer 源码 出发可以沿着构造链完整画出任意组件的依赖图。1.1 七层栈全景下图为文档中给出的官方分层示意block-beta1.2 各层职责速查表层代码位置职责Controllerssrc/backend/controllers/路由处理器。负责解析 校验输入、施加每条路由的 gate鉴权、子域、限流、body 解析等见RouteOptions、调用 service、格式化响应。Driverssrc/backend/drivers/可选层。暴露在/drivers/*表面的 RPC 风格处理器puter-kvstore、puter-chat-completion等。Driver 是一层薄壳校验 RPC 输入后调用 services/stores当 Controller 需要在 HTTP 上复用同一逻辑时可以持有对 driver 的类型化引用。Servicessrc/backend/services/业务逻辑层。默认调用方已完成鉴权/授权——Service 自身不再跑鉴权 gate。Storessrc/backend/stores/持久化与存储逻辑。把 Client 包装成 Service 消费的领域形态行、实体、KV 命名空间。Clientssrc/backend/clients/外部/内部服务的适配器sql、redis、s3、dynamodb、email、事件总线……。只懂协议不懂领域概念。Configconfig.*.json→IConfig启动时每个层都收到的扁平化、带类型的配置对象仓库内可参考 config.template.jsonc。1.3 代码组织上的强制约束每层通过构造函数接收它下面的层因此从PuterServer出发依赖是显式且可追踪的Controller 不得直接伸手去够 Client如果某个 Controller 确实需要 Client 的能力正确的做法通常是经由 ServiceService 之间不得为了代码复用而互相调用——需要复用就向上抽取成 util/helper详见后文约定一节。二、入口点PuterServer的启动装配流程src/backend/server.ts 中的PuterServer是整个后端的 bootstrap。其#setupServer流程清晰对应文档中的四步描述2.1 加载扩展目录在实例化之前// 从 config.extensions 读取目录逐个 import 目录下的入口文件 const extensionDirs this.#config.extensions; await this.#importExtensions(extensionDirs);config.extensions是字符串数组例如 config.template.jsonc 中默认配置// ── Extensions ────────────────────────────────────────────────────── // Directories scanned for extension entrypoints (*.ts / subdirs). extensions: [ ./extensions ],#importExtensionsserver.ts的扫描规则值得注意目录下单个.ts/.js文件直接动态import()但会跳过*.test.*与*.d.ts子目录优先读其package.json的main字段否则回退到index.js/index.mjs/index.cjs两者都没有的数据型 sidecar 目录会被静默跳过不会导致启动崩溃之所以必须先于实例化加载是因为扩展通过模块级副作用把注册内容写入内存注册表随后PuterServer装配各层时要把这些注册内容合并进对应层见下一节。仓库内 extensions/package.json 只有一行{type: commonjs}说明扩展模块本身保持 CommonJS 形态运行时依赖编译产物。2.2 按序实例化各层并合并扩展注册代码中实际执行顺序为clients → stores → services → rate-limiter 装配 → drivers → controllers。每类实例的构造参数都只给下层的引用被实例化对象构造函数注入的依赖Client(config)Store(config, clients, stores)Service(config, clients, stores, services)Driver(config, clients, stores, services)Controller(config, clients, stores, services, drivers)源码中对每个层都写了两遍循环第一遍遍历内置注册表如 puterControllers第二遍遍历extensionStore.xxx——这正是合并进扩展已注册的东西的实现方式。此外值得留意的一个关键次序细节Drivers 先于 Controllers 实例化因此 Controller 构造时就能拿到类型化的drivers引用而/drivers/*这个 HTTP 表面本身由普通 Controller 中的 DriverController 承担它从this.drivers读取目标——也就是文档中不再有单独的 driver 注册表对象的含义。内置 driver 注册表见 src/backend/drivers/index.ts包含kvStoreKV 存储、aiChat/aiImage/aiTts/aiVideo/aiSpeech2Speech/aiSpeech2Txt/aiOcr各 AI 能力、apps、subdomains、notifications、workers等。类似的services/index.ts 通过declare module声明合并生成IPuterServiceInstances内含auth、token、fs、metering、events、permission等stores/index.ts 同样生成IPuterStoreInstances内含kv、user、fsEntry、session等。2.3 装配 Express 全局中间件实例化完成后#setupServer创建 Express app 并设定两个会影响所有子域路由的关键 app 级选项server.tstrust proxy必须在任何读取req.ip/req.ips/req.protocol的中间件之前设置。默认false部署在反向代理链后面时须设为跳数如单层 Cloudflare/nginx 设1生产环境绝不能设true那会信任每一跳使 XFF 可被伪造subdomain offset由subdomainOffsetForDomain(config.domain)算出让部署在puter.example.com时不会把puter误读为活跃子域。随后#installGlobalMiddleware以对次序极其讲究的方式挂载全局中间件其中文档没有展开、但源码注释里交代清楚的细节包括出口计量中间件最先注册它在compression之前包裹res.write因此统计的是真实发出的字节而非 handler 产生的字节express.json必须在 auth probe 之前这样req.body.auth_token才是可读的authProbe永不 reject它只在存在合法 token 时填充req.actorHost 校验#installHostValidation维护一张由domain、static_hosting_domain、private_app_hosting_domain等拼接、带缓存的允许域名表/healthcheck与自定义域名custom_domains_enabled例外放行CORS 中间件允许任意 Originputer.js设计上要被任意第三方站点消费并仅在api子域反射 Origin 时允许携带凭据、为dav子域单独暴露 WebDAV 需要的响应头集合认证探测之后依次是指纹中间件打上req.networkFingerprint/req.deviceFingerprint与每请求 ALS 上下文中间件此时req.actor已被填充可安全快照进上下文见第三节用户自托管站点*.puter.site等由createPuterSiteMiddleware在路由匹配前短路处理扩展注册的全局中间件extensionStore.globalMiddlewares最后追加。2.4 路由装配与终止中间件Controllers 与扩展路由都收敛到同一个#materializeRoute细节见下节RouteOptions全部装配完毕后最后才安装 404 catch-all 与错误处理中间件——保证 404 只对真正未匹配的请求触发、错误处理器能接住栈中任意位置抛出的错误Express 5 会自动把同步/异步 throw 转发到错误处理器因此 handler 里可以直接throw new HttpError(...)无需next(err)仪式。2.5 生命周期钩子文档提到PuterServer会触发onServerStart/onServerPrepareShutdown/onServerShutdown。源码server.ts显示这些钩子按clients → stores → services → controllers → drivers的顺序在每一层上逐一遍历触发#runPrepareShutdownHooks有幂等保护只跑一次attachHttpServer则在 listen 之前被调用供 socket.io 等需要裸http.Server的服务挂接。start()还支持noHttpServer模式测试用与--server的 AuthMe 浏览器登录流程。三、请求上下文基于 AsyncLocalStorage 的Context为了不在每个函数签名里都穿针引线地传递每请求状态后端在 src/backend/core/context.ts 中提供了由 NodeAsyncLocalStorage支撑的Context。3.1 设计要点承载能力既有带类型的已知字段KnownContextFields又有开放式的 key-value map。已知字段包括actor——auth probe 解析出的已认证主体可能为undefinedreq——当前 Express 请求对象requestId——每请求唯一 ID可用于结构化日志/追踪driverName——由 DriverController 在/drivers/call分发时写入drivers 据此挑选 providerabortSignal——客户端提前断开时中止长任务长运行 driver 轮询它避免为没人接收的工作继续计量。传播机制因为基于AsyncLocalStorage上下文能自动穿过async/await、定时器与微任务。runWithContext(initial, fn)用于开启一个上下文作用域request-context 中间件在 auth probe 之后用它包裹整条中间件/处理器链。用法源码注释中的示例// 读取类型化字段 const actor Context.get(actor); // 在任意位置读取 express 请求 const req Context.get(req); // 存取临时值 Context.set(myService.txId, txId); const txId Context.get(myService.txId);约束Context.set在请求作用域之外调用会直接抛错Context之外的调用无 scope取值返回undefined。3.2 使用纪律宁可显式传参文档给出的工程纪律非常明确优先显式参数只有当值确实是请求级、且不通过 Context 就得穿透很多层函数签名时才使用Context。它被设计为有节制地使用mostly foractorandreq而不是一条随手可用的隐形全局通道——滥用会悄悄破坏函数的纯度与可测试性。一个真实的消费端示例是 extensions/whoami.ts 的handleWhoamihandler 里直接Context.get(actor)取出当前主体并据此判断是否返回 401——这正是文档任何请求 handler 内可用Context.get(actor)/Context.get(req)替代层层传参的落地形态。四、扩展Extensions与核心栈平行的插件体系扩展代码放在仓库根的 extensions/文档中的packages/puter/extensions/即该目录中与分层栈平行。它们服务于系统中**非关键non-crucial**的部分——移除后 Puter 仍然能工作的功能。这是判断该不该做成扩展的第一把尺子。4.1 文档给出的分类示范适合做扩展thumbnails、serverInfo、devWatcher——干净的外挂式可选功能其实更该进核心metering、appTelemetry——客户端已默认它们存在扩展的框架反而误导人本不该做成扩展whoami——它对每一个已认证客户端都是承重墙。文档特意把它列为警世案例当你犹豫某个东西是否属于扩展时想想 whoami。以 serverInfo 为例它就是一个典型的薄壳扩展读取os/fs系统信息后用extension.get注册一个仅管理员可访问的端点extension.get( /serverInfo, { subdomain: api, adminOnly: true }, handleServerInfo, );而 thumbnails 则展示了另一种形态——不注册路由而是通过extension.on订阅文件系统事件thumbnail.created、thumbnail.upload.prepare、thumbnail.read、fs.copy.node、fs.remove.node把缩略图落盘为带thumbnails/前缀的 S3 对象并签发预签名 URL全程不碰核心栈的任何一行。4.2 扩展 API 全貌extension全局对象定义在 src/backend/extensions.ts其底层是一个内存注册表extensionStoreextensions.ts扩展模块在模块作用域执行时把注册写入其中PuterServer启动时再排干它。API 分三类1. 一等公民式的层注册Layer registrationextension.registerClient(name, ClientClass)extension.registerStore(name, StoreClass)extension.registerService(name, ServiceClass)extension.registerDriver(name, DriverClass)extension.registerController(name, ControllerClass)另有extension.registerGlobalMiddleware(mw)追加全局中间件2. 轻量包装Lightweight wrappers——当为一个常用场景单独写一个完整类不值得时extension.on(event, handler)——订阅事件总线事件extension.get(path, opts?, handler)/.post/.put/.delete/.patch/.head/.options/.all/.use——注册路由。opts就是 Controller 用的同一套RouteOptions所以subdomain、requireAuth、adminOnly、body 解析器等在扩展路由上完全等价地生效两种调用形态extension.get(/path, handler)与extension.get(/path, options, handler)实现见 extensions.ts。3. 跨层访问Cross-layer accessextension.import(client | store | service | controller | driver)——返回对已实例化对象的惰性代理lazy proxyextension.config——暴露实时配置。import代理有两处实现细节值得展开extensions.ts惰性解析扩展模块在层实例化之前就被 import模块顶层读到名字通常是空的因此代理在每次属性访问时都重新查容器模块 import 时捕获的extension.import(client).db在真正实例落地后依然可用方法绑定bindLayerMethods用 Proxy 让取出即被 bind 到实例这样const w svc.fs.write; w(...)这种剥离式调用不会因this丢失而在访问私有字段时炸掉代价是每次方法访问都返回新函数引用同一性不稳定——对取一次、调用多次的 import 场景可接受代理对从未注册的名字在首次属性读取时抛错而非返回undefined因此拼写错误会在访问点立刻暴露而不是静默变成 no-op想探测可选层条目需要 try/catch。文档给出的示例代码正是把上面三类 API 串起来的完整最小扩展import { extension } from heyputer/backend/src/extensions; const services extension.import(service); extension.get(/healthcheck/deep, { subdomain: api, adminOnly: true }, async (_req, res) { res.json({ ok: await services.health.runDeepCheck() }); }); extension.on(user.signup, (_key, data) { console.log(new user, data.user.username); });真实仓库里可以对照 whoami 的完整路由声明来验证它同时使用了subdomain: api、requireAuth: true、allowUnconfirmed: true以及一个key: user、scope: whoami、limit: 1_800、window: 60_000的限流配置——GUI 轮询该端点、且每次调用会扇出到所有whoami事件监听器因此限流上限必须清掉最忙会话在做什么而不是按人点击的节奏来定。extension.config的用法也出现在其中读取feature_flags后按白名单CLIENT_VISIBLE_FEATURE_FLAGS过滤避免内部标记payment_bypass、staff_only_*等经/whoami泄漏到客户端。五、RouteOptions一条路由可声明的全部护栏文档把per-route gates归入 Controllers 的职责而承载这些声明的类型就是 src/backend/core/http/types.ts 中的RouteOptions。理解它就能理解Controller 层做输入校验与 I/O 塑形究竟意味着什么。RouteMethod除了标准的 REST 动词还包含lock、unlock、propfind、proppatch、mkcol、copy、move等 WebDAV 动词对应 WebDAVController 的需求RoutePath接受字符串/正则/数组。RouteOptions的可声明项含义见类型源码中的长注释概览如下类别选项说明子域subdomain字符串或数组省略时 verb 路由只匹配根域*显式匹配任意子域use中间件默认不加此闸门认证requireAuth拒绝匿名与被封禁用户仅放行 user 与 app 主体requireUserActor拒绝 app/access-token 主体蕴含requireAuthnoUserSession拒绝裸会话 token要求委托凭据app/worker/API tokenworker 会话恒通过allowFullAccessToken配合requireUserActor放行用户本人的全权 PAT默认拒绝adminOnly用户名白名单默认admin/system可加数组扩展要求根 token且需 step-up 二次认证可叠加allowedAppIdsallowedAppIdsapp-under-user 白名单蕴含requireAuth验证allowUnconfirmed放行尚未完成注册期验证邮箱/短信/卡片的账号供登出、whoami、确认类核心流使用requireVerified要求邮箱已验证是否生效取决于config.strict_email_verification_requiredrequirePhoneVerified/requireCardVerified各自要求确实完成过对应因子验证captcha/antiCsrf验证码闸门 / 一次性 anti-CSRF token 消费guiOriginOnly仅限本部署 GUI origin 的浏览器页面用于发放会话凭据的路由配额与防护rateLimit单/数组key可为fingerprint默认/ip/user/自定义函数scope隔离计数器backend选memory/redis/kvconcurrent并发在飞限制可bySubscription按订阅档覆盖额度requireCredits余额不足 402insufficient_funds只用于替你花钱的路由requireSubscription计划门槛true任一非免费计划数组指定SubscriptionPolicy.idrequireReputation信誉分档位门槛档位阈值在配置的reputationGate.tiers中定义请求体bodyJsonfalse或{limit, type}覆盖全局 JSON 解析器bodyRaw/bodyText/bodyUrlencoded分别拿到 Buffer / string / 表单对象通用middleware额外的每路由中间件在门控与解析之后、handler 之前执行在类型层面RouteOptions还通过AuthRequiredO与TypedRequestO做了类型收窄当路由声明了requireAuth或任何蕴含它的选项时handler 中的req.actor会被窄化为Actor非空无需再写非空断言。门控链的落地#materializeRoute每条路由无论来自 Controller 还是扩展最终都流入 server.ts 的#materializeRoute。它把RouteOptions翻译成一条有序的中间件链源码注释给出的顺序是subdomain → origin gate → auth gates → requireUserActor → noUserSession → adminOnly(step-up) → allowedAppIds → requireVerified → reputation → subscription → rateLimit → requireCredits → concurrent → captcha → antiCsrf → body parsers → 调用方 middleware → 生命周期事件 → handler几个由代码佐证的语义细节蕴含去重adminOnly、allowedAppIds、requireUserActor等都蕴含requireAuth实现中只 push 一次requireAuthGate()默认开启的待验证闸门任何需要认证且未声明allowUnconfirmed的路由都会被追加requireVerifiedAccount()这是让低信誉注册账号在服务端就被挡在 AI/FS/driver 端点之外、而不只靠 GUI 弹窗的机制默认拒绝 access token需要认证且未声明allowAccessToken的路由会追加requireNonAccessTokenGate()adminOnly叠加 step-up管理路由还要求最近的重认证createStepUpGate除非 token 携带路由白名单中的 app idWebDAV 特例OPTIONS预检对dav子域放行DAV 客户端要靠它发现服务器能力其余子域直接回 200路由级生命周期每个端点use除外最后会注入createRouteLifecycleMiddleware使before/after钩子在完整认证的请求上触发。Controller 端的声明式注册走的是同一套物料化管道PuterRouter 配合装饰器元数据Controller(/prefix)等把前缀/路由存在 prototype 的PREFIX_METADATA_KEY/ROUTES_METADATA_KEY上#registerControllerRoutes在装配时读出并批量registerRoutes(router)同时支持 Controller 用isEnabled()在 config 层面关闭整组路由——关闭后其路径表现为 404 而非存在但拒绝。六、架构约定Conventions文档在最后给出了四条团队编码约定它们也是审阅任何 Puter 后端改动时的检查清单新代码优先 TypeScript在可行处存量 JS 可保留动到该文件时顺带机会式迁移命名变量/函数用camelCase类、以及内含类的文件AuthService.ts、KVStoreDriver.ts用PascalCase去重优先两个 Service 需要同一段逻辑时把它提升到 util/helper而不是在同一层内横向互调——Service 不应为复用代码而依赖其他 Service绝不跨层伸手Controller 不直接捅 ClientService 不注册路由。一旦发现自己想这么做通常意味着抽象本身出了问题。七、快速定位指南想找某个 HTTP 端点由谁处理→ 从 controllers/index.ts 的puterControllers表出发再按需看对应 Controller 的registerRoutes想确认某个 driver 暴露了什么 RPC→ drivers/index.ts 是完整注册表RPC 分发在 DriverController想理解路由可声明哪些闸门/解析器→ core/http/types.ts 的RouteOptions注释最权威想看扩展能注册什么、如何跨层取实例→ src/backend/extensions.ts 的extension对象与其文档注释想确认启动顺序与生命周期钩子→ server.ts 的#setupServer、#fireOnServerStart、prepareShutdown/shutdown想写第一个扩展 → 以 serverInfo极简路由型或 thumbnails事件订阅型为模板并用 extensions/whoami.ts 对照承重型路由应如何精细声明RouteOptions。结语Puter 的后端不是一套玄虚的框架而是一组朴素却强约束的工程决策层间只向下依赖 构造注入 单一引导者按序装配让依赖图完全可读ALS 上下文只解决真正请求级状态的穿透问题扩展体系则把锦上添花与承重墙清楚地分成两堆用同一套RouteOptions物料化管道保证扩展路由与核心路由享受完全一致的鉴权/限流/解析能力。理解这四件事你就能在 Puter 仓库中准确定位任意功能的落点也能判断一个新需求应该写进哪一层、或是否应该以扩展形态独立存在。【免费下载链接】puter The Internet Computer! Free, Open-Source, and Self-Hostable.项目地址: https://gitcode.com/GitHub_Trending/pu/puter创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表