Meteor Methods 完全指南:定义、调用、错误处理与底层生命周期原理
【免费下载链接】meteorMeteor, the JavaScript App Platform项目地址: https://gitcode.com/gh_mirrors/me/meteor
Meteor Methods 是 Meteor 框架内置的远程过程调用(RPC)系统,是客户端向服务端写入数据库、执行业务逻辑的标准通道。本文将以官方 Guide 的 methods 章节为主体,结合当前仓库packages/ddp-client、packages/ddp-server、packages/ddp-common等核心源码,系统讲解 Method 的定义与调用方式、错误处理约定、表单集成方案、数据加载场景,以及 Method 从客户端模拟到服务端执行的完整生命周期。读完本文,你将掌握从"最简 Method"到"可测试的工程化 Method"的完整演进路径,并理解 Optimistic UI、randomSeed一致 ID 生成、Method 重试等底层机制是如何在源码中实现的。
什么是 Method?
Methods 是 Meteor 的远程过程调用(RPC)系统,用于保存来自客户端的用户输入事件与数据。如果你熟悉 REST API 或 HTTP,可以把 Method 想象成发往服务端的 POST 请求——但它针对现代 Web 应用做了大量优化,提供了普通 HTTP 端点不具备的能力。后面会详细讨论这些收益。
本质上,Method 就是服务端的一个 API 端点:你可以在服务端定义一个 Method,同时在客户端定义它的"副本"(stub),然后用一些数据调用它,写入数据库,并在回调中拿到返回值。Meteor Methods 与 Meteor 的 pub/sub 和数据加载系统紧密集成,从而支持 Optimistic UI(乐观 UI)——即在客户端模拟服务端操作,让应用响应快于真实网络往返。
注意:本文中的 "Method"(大写 M)特指 Meteor 的 RPC 机制,以区别于 JavaScript 中的类方法(class method)。
在源码层面,Method 的注册与调用由两个包承载:
- 客户端:
packages/ddp-client/common/livedata_connection.js,其中LivedataConnection.prototype.methods()把方法注册到this._methodHandlers(同名冲突会抛出"A method named '...' is already defined"),Meteor.call/Meteor.apply等调用入口也都在该文件中。 - 服务端:
packages/ddp-server/livedata_server.js,其中methods()把方法注册到self.server.method_handlers,DDP 协议层的method消息处理器负责接收调用并执行。
定义和调用 Methods
基础 Method:直接使用 Meteor 核心 API
在简单应用中,定义一个 Meteor Method 就像定义一个函数一样简单。以下示例使用simpl-schemanpm 包来校验参数(Guide 多篇文章推荐使用它):
import SimpleSchema from 'simpl-schema'; Meteor.methods({ 'todos.updateText'({ todoId, newText }) { new SimpleSchema({ todoId: { type: String }, newText: { type: String } }).validate({ todoId, newText }); const todo = Todos.findOne(todoId); if (!todo.editableBy(this.userId)) { throw new Meteor.Error('todos.updateText.unauthorized', 'Cannot edit todos in a private list that is not yours'); } Todos.update(todoId, { $set: { text: newText } }); } });定义 Method 时需要注意:
- 方法体应放在客户端和服务端都能加载的公共代码中,这样客户端才能运行 Method 模拟(stub),实现 Optimistic UI。如果 Method 内含机密逻辑,请参考 安全指南的 Secret server code 一节 了解如何对客户端隐藏。
- 方法内部的
this是当前 Method 调用上下文(DDPCommon.MethodInvocation实例,见packages/ddp-common/method_invocation.js),通过this.userId可以拿到当前登录用户 ID。
从源码看,DDPCommon.MethodInvocation(packages/ddp-common/method_invocation.js)为每个调用维护了以下关键字段:
name:Method 名称;isSimulation:是否为客户端模拟(stub)执行;userId:调用该 Method 的用户 ID,未登录时为null;connection:服务端收到的连接对象,服务端发起的调用为null;randomSeed:用于客户端/服务端一致 ID 生成的随机种子;unblock():允许同一客户端的后续 Method 不必等待当前 Method 完成;setUserId(userId):在 Method 内切换当前连接的用户,且在调用unblock()之后禁止调用(源码会抛出"Can't call setUserId in a method after calling unblock")。
调用 Method
使用Meteor.call即可在客户端和服务端调用上面定义的 Method:
Meteor.call('todos.updateText', { todoId: '12345', newText: 'This is a todo item.' }, (err, res) => { if (err) { alert(err); } else { // success! } });调用语义:如果 Method 抛出错误,错误会出现在回调的第一个参数err中;如果成功,结果在第二个参数res中,此时err为undefined。关于错误处理的更多细节见下文。
需要强调的是:只有在需要从客户端调用代码时才应该使用 Method。如果只是想模块化仅由服务端调用的代码,请使用普通 JavaScript 函数,而不是 Method。
Meteor.call在packages/ddp-server/livedata_server.js中的实现本质上就是对this.apply(name, args, callback)的薄封装;其异步版本callAsync(返回 Promise)同样位于该文件中。客户端侧对应的callAsync/applyAsync实现位于packages/ddp-client/common/livedata_connection.js(Meteor.callAsync会拒绝传入回调,要求使用await或.then()获取结果)。对应的 TypeScript 类型签名可在packages/meteor/meteor.d.ts中查看:Meteor.methods、Meteor.call、Meteor.callAsync、Meteor.apply、Meteor.applyAsync以及MethodApplyOptions。
进阶 Method 样板:拆解验证与执行
Meteor Methods 有许多不易察觉的特性,复杂应用迟早会用到。这些特性是多年间以向后兼容的方式逐步加入的,因此要完全解锁 Methods 的能力需要不少样板代码。一个理想的 Method 应该具备以下能力:
- 只运行校验代码,不运行 Method 主体;
- 可覆盖 Method 以进行测试;
- 尤其是测试时,可以用自定义用户 ID 调用 Method(即 Discover Meteor 提出的两层 Method 模式);
- 通过 JS 模块引用 Method,而不是使用魔法字符串;
- 拿到 Method 模拟(stub)的返回值,以获取插入文档的 ID;
- 客户端校验失败时不再调用服务端 Method,以节省服务端资源。
下面是把这些能力逐一落地的完整样板:
export const updateText = { name: 'todos.updateText', // 抽出校验逻辑,使其可独立运行 (1) validate(args) { new SimpleSchema({ todoId: { type: String }, newText: { type: String } }).validate(args) }, // 抽出 Method 主体,使其可独立调用 (3) run({ todoId, newText }) { const todo = Todos.findOne(todoId); if (!todo.editableBy(this.userId)) { throw new Meteor.Error('todos.updateText.unauthorized', 'Cannot edit todos in a private list that is not yours'); } Todos.update(todoId, { $set: { text: newText } }); }, // 通过引用 JS 对象来调用 Method (4) // 同时把 Meteor.apply 的选项固化在 Method 实现里, // 调用方无需在调用处重复指定。 call(args, callback) { const options = { returnStubValue: true, // (5) throwStubExceptions: true // (6) } Meteor.apply(this.name, [args], options, callback); } }; // 真正把 Method 注册进 Meteor 的 DDP 系统 Meteor.methods({ [updateText.name]: function (args) { updateText.validate.call(this, args); updateText.run.call(this, args); } })这样调用 Method 就变成了调用普通 JS 函数:
import { updateText } from './path/to/methods.js'; // 调用 Method updateText.call({ todoId: '12345', newText: 'This is a todo item.' }, (err, res) => { if (err) { alert(err); } else { // success! } }); // 只调用校验 updateText.validate({ wrong: 'args'}); // 测试中以自定义 userId 调用 Method updateText.run.call({ userId: 'abcd' }, { todoId: '12345', newText: 'This is a todo item.' });这种"对象化 Method"的开发方式能显著改善工作流:可以分别处理 Method 的各个部分,测试时无需触碰 Meteor 内部机制。代价是定义侧需要编写较多样板代码。
其中returnStubValue与throwStubExceptions两个选项在客户端源码packages/ddp-client/common/livedata_connection.js的_apply()中有精确实现:
returnStubValue: true时,Meteor.apply直接返回 stub 的返回值(result = options.returnStubValue ? stubReturnValue : undefined),这样在客户端插入文档后可以立刻拿到新文档的_id;throwStubExceptions: true时,如果 stub 抛出了异常,会直接重新抛出并不再把 Method 发给服务端(if (options.throwStubExceptions) { throw exception; }),从而避免浪费一次注定失败的服务端调用。
用 jam:method 简化进阶样板
为减轻正确编写 Method 所需的样板代码,可以使用jam:method这个 Atmosphere 包,它替你完成了大部分工作。上面同一个 Method 用该包定义如下:
import { createMethod } from 'meteor/jam:method'; export const updateText = createMethod({ name: 'todos.updateText', schema: new SimpleSchema({ todoId: { type: String }, newText: { type: String } }), async run({ todoId, newText }) { const todo = await Todos.findOneAsync(todoId); if (!todo.editableBy(this.userId)) { throw new Meteor.Error('todos.updateText.unauthorized', 'Cannot edit todos in a private list that is not yours'); } Todos.updateAsync(todoId, { $set: { text: newText } }); } });调用方式与上面的进阶 Method 完全相同,但定义侧显著更简洁。这种方式能让你一眼看清三个关键要素:线上传输的 Method 名称(name)、期望的参数格式(schema)、以及可供 JS 引用的命名空间(导出的对象)。
注意:
jam:method是第三方包(不在当前仓库内),本例中的findOneAsync/updateAsync对应 Meteor 3 的异步 Mongo API。jam:method默认会为Meteor.apply开启returnStubValue与throwStubExceptions(它会阻止 stub 抛错时继续调用服务端实现)。
错误处理
普通 JavaScript 函数通过抛出Error对象来表示错误。Meteor Method 抛错的方式几乎一样,但多了一层复杂性:错误对象有时要通过 WebSocket 传回客户端。因此 Meteor 引入了两种新的错误类型:Meteor.Error和ValidationError。它们与普通 JSError适用于不同的场景。
服务端内部错误:使用普通Error
当错误不需要上报给客户端、只属于服务端内部问题时,抛出普通 JS 错误对象即可。这类错误会被服务端包装成完全不透明的"内部服务错误"传给客户端——客户端拿不到任何细节。
从packages/ddp-server/livedata_server.js的wrapInternalException()可以看到确切机制:普通异常(没有isClientSafe标记)会被转换成new Meteor.Error(500, "Internal server error"),并在服务端日志中打印原始堆栈;只有带isClientSafe标记的错误才会原样传给客户端。此外,如果异常带有sanitizedError属性且该属性isClientSafe,则优先使用这个"净化版"错误。
一般运行时错误:使用Meteor.Error
当服务端因为某个已知条件而无法完成用户期望的操作时,应向客户端抛出描述性的Meteor.Error。例如在 Todos 示例应用中,用它报告"当前用户无权执行某操作",或"该操作在应用内不被允许"(如删除最后一个公开列表)。
Meteor.Error接受三个参数:error、reason、details。
error:简短、唯一、机器可读的错误码字符串,客户端据此理解发生了什么。建议以 Method 名作为前缀以方便国际化,例如'todos.updateText.unauthorized'。reason:面向开发者的简短错误描述,应足以让同事据此调试。不要直接把reason展示给终端用户——否则你必须在服务端先做国际化,而且 UI 开发者也不应该为了决定界面展示内容而关心 Method 的实现细节。details(可选):携带帮助客户端理解问题的额外数据。特别是可以与error字段组合,向终端用户输出更有帮助的错误信息。
在源码packages/meteor/errors.js中,Meteor.Error通过Meteor.makeErrorType构造,并设置了self.isClientSafe = true(这是 DDP 判断错误可否回传客户端的关键标记),同时提供了clone()方法以保证经 EJSON 克隆后error/reason/details字段不丢失。注意,部分内置函数(如check)出于历史原因会在error字段放一个数字。
参数校验错误:使用ValidationError
当 Method 调用因参数类型错误而失败时,建议抛出ValidationError。它的工作方式与Meteor.Error类似,但它是自定义构造函数,强制了一种标准的错误格式,可被不同的表单和校验库读取。特别是:如果 Method 是从表单调用的,抛出ValidationError就能在表单的特定字段旁边展示友好的错误信息。
ValidationError属于mdg:validation-error包(不在当前仓库内),其约定格式为:err.error === 'validation-error',且err.details是一个形如[{ name, type, ... }]的数组,每个元素对应一个字段的错误。
处理错误
调用 Method 时,它抛出的任何错误都会出现在回调中。此时应判断错误类型并向用户展示合适的消息。以只处理"未授权"错误为例:
// 调用 Method updateText({ todoId: '12345', newText: 'This is a todo item.' }, (err, res) => { if (err) { if (err.error === 'todos.updateText.unauthorized') { // 真实应用中不应使用 alert; // 应该用优雅的 UI 展示错误,并配合 i18n 库 // 根据错误码生成消息。 alert('You aren\'t allowed to edit this todo item'); } else { // 意外错误,在 UI 中另行处理 } } else { // success! } });ValidationError的页面处理方式见下文"从表单调用 Method"一节。
Method 模拟(stub)中的错误
调用一个 Method 时,它通常会执行两次:一次在客户端模拟(stub),用于 Optimistic UI;另一次在服务端执行真正的数据库写入。这意味着如果 Method 抛错,很可能在客户端和服务端各失败一次。因此jam:method会开启Meteor.apply的throwStubExceptions选项,在模拟阶段抛错时直接跳过服务端实现,避免浪费服务端资源。
这个行为适合"注定失败"的 Method,但必须确保:在服务端 Method 本可以成功的情况下,stub 不要抛错(例如客户端没加载 Method 模拟所需的数据)。此时可以把仅依赖服务端环境的逻辑包在isSimulation判断里:
if (!this.isSimulation) { // 依赖服务端环境的逻辑写在这里 }this.isSimulation正是DDPCommon.MethodInvocation的字段(packages/ddp-common/method_invocation.js):在客户端 stub 中为true,在服务端真实执行为false。
从表单调用 Method
ValidationError约定带来的最大价值,就是打通 Method 与调用它的表单之间的集成。通常你的应用里,UI 表单与 Method 是一一对应的。首先为业务逻辑定义一个 Method:
// 定义邮箱与金额校验用的正则表达式 const emailRegEx = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/g; const amountRegEx = /^\d*\.(\d\d)?$/; // 这个 Method 内嵌了表单的校验要求。 // 把校验定义在 Method 里,客户端与服务端就只需写一份校验逻辑。 export const insert = createMethod({ name: 'Invoices.methods.insert', schema: new SimpleSchema({ email: { type: String, regEx: emailRegEx }, description: { type: String, min: 5 }, amount: { type: String, regEx: amountRegEx } }), run(newInvoice) { // 进入这里时,可以确信 newInvoice 参数已通过校验。 if (!this.userId) { throw new Meteor.Error('Invoices.methods.insert.not-logged-in', 'Must be logged in to create an invoice.'); } Invoices.insertAsync(newInvoice) } });出于安全考虑,建议为email、amount这类字段自定义 regEx 表达式;对 Meteor 相关的功能(如文档 ID),可以使用SimpleSchema.RegEx.Id表达式。更多正则用法可参考 simpl-schema 文档。
接下来定义 HTML 表单(使用 Blaze 模板):
<template name="Invoices_newInvoice"> <form class="Invoices_newInvoice"> <label for="email">Recipient email</label> <input type="email" name="email" /> {{#each error in errors "email"}} <div class="form-error">{{error}}</div> {{/each}} <label for="description">Item description</label> <input type="text" name="description" /> {{#each error in errors "description"}} <div class="form-error">{{error}}</div> {{/each}} <label for="amount">Amount owed</label> <input type="text" name="amount" /> {{#each error in errors "amount"}} <div class="form-error">{{error}}</div> {{/each}} </form> </template>再写 JavaScript 来优雅地处理这个表单:
import { insert } from '../api/invoices/methods.js'; Template.Invoices_newInvoice.onCreated(function() { this.errors = new ReactiveDict(); }); Template.Invoices_newInvoice.helpers({ errors(fieldName) { return Template.instance().errors.get(fieldName); } }); Template.Invoices_newInvoice.events({ 'submit .Invoices_newInvoice'(event, instance) { const data = { email: event.target.email.value, description: event.target.description.value, amount: event.target.amount.value }; insert(data, (err, res) => { if (err) { if (err.error === 'validation-error') { // 初始化错误对象 const errors = { email: [], description: [], amount: [] }; // 遍历 Method 返回的校验错误 err.details.forEach((fieldError) => { // XXX i18n errors[fieldError.name].push(fieldError.type); }); // 更新 ReactiveDict,错误会出现在 UI 上 instance.errors.set(errors); } } }); } });可以看到,在表单里优雅地处理错误需要相当数量的样板代码,但其中大部分都可以由一个现成的表单框架或你自己封装的通用组件来抽象掉。这里的核心约定是:Method 的 schema 校验失败时抛出ValidationError(err.error === 'validation-error'),err.details中的每一项包含字段名name与错误类型type,表单据此把错误渲染到对应字段下方。
用 Method 加载数据
由于 Method 是通用的 RPC,它也可以用来拉取数据而不用 publication。这种方案与 publication 相比各有优劣,但 Guide 最终建议:加载数据始终使用 publication。
Method 拉取数据的适用场景是:从服务端获取一个复杂计算结果,且该结果不需要随服务端数据变化而自动更新。Method 方案最大的缺点是:数据不会自动进入 Minimongo(Meteor 的客户端数据缓存),需要手动管理数据生命周期;另一个缺点是数据库查询无法像 publication 游标那样在客户端之间共享——每个调用该 Method 的客户端都会独立执行一次(包括其中的查询)。
用本地集合(local collection)存储和展示 Method 取回的数据
集合是客户端存储数据的便捷方式。如果通过订阅之外的方式取数据,可以手动放进一个集合。以下示例中,我们有一个复杂的算法,要为多名玩家计算一系列游戏的平均分。之所以不用 publication,是因为我们想精确控制它的运行时机,且不希望数据被自动缓存。
首先创建一个本地集合——只存在于客户端、不对应服务端数据库集合的集合。创建方式参见 集合指南的 Local collections 一节:
// 在客户端代码中,传入 null 即声明一个本地集合 ScoreAverages = new Mongo.Collection(null);然后用 Method 拉取数据并写入该集合:
import { calculateAverages } from '../api/games/methods.js'; function updateAverages() { // 清空结果缓存 ScoreAverages.remove({}); // 调用执行昂贵计算的 Method calculateAverages.call((err, res) => { res.forEach((item) => { ScoreAverages.insert(item); }); }); }之后就可以像使用普通 MongoDB 集合一样,在 UI 组件中使用本地集合ScoreAverages的数据。区别在于它不会自动更新,每次需要新结果时都要手动调用updateAverages。
高级概念
你可以照 Meteor 入门教程用 Method 开发应用,但要把它用好,尤其是用在生产环境中,必须理解它的底层工作原理。框架替你做得多,你反而更容易忽视背后发生了什么。
Method 调用生命周期
当一个 Method 被调用时,按顺序依次发生以下事情:
1. 客户端执行 Method 模拟(stub)
如果 Method 按规范定义在客户端与服务端公共代码中,发起调用的客户端会先执行一次 Method 模拟。客户端进入一种特殊模式:跟踪对客户端集合的所有改动,以便稍后回滚。这一步完成后,用户会立刻看到 UI 基于客户端数据库新内容更新,但此时服务端还没收到任何数据。
如果 Method 模拟抛出了异常,默认情况下 Meteor 会忽略它并继续步骤 (2)。但如果你使用jam:method,或给Meteor.apply传入throwStubExceptions选项,模拟阶段的异常会阻止服务端 Method 继续执行。
Method 模拟的返回值默认被丢弃,除非调用时传入returnStubValue选项——此时返回值会交给调用方。jam:method默认开启该选项。
客户端这一过程在packages/ddp-client/common/livedata_connection.js的apply()中实现:先通过_stubCall()取出注册在_methodHandlers里的 stub 并以isSimulation: true的MethodInvocation上下文执行(DDP._CurrentMethodInvocation.withValue(invocation, stubInvocation)),再通过_saveOriginals()/_retrieveAndStoreOriginals()记录 stub 修改过的文档原值,供后续回滚使用。
2. 向服务端发送methodDDP 消息
Meteor 客户端构造一条 DDP 消息发往服务端,包含 Method 名称、参数,以及一个自动生成的 Method ID(代表本次具体调用)。
从源码看(packages/ddp-client/common/livedata_connection.js的_apply()),这条消息的形态是:
const message = { msg: 'method', id: methodId, // 由 self._nextMethodId++ 生成 method: name, params: args };如果 stub 使用过随机数生成器,消息中还会附带randomSeed(见下文"一致的 ID 生成")。
3. 服务端执行 Method
服务端收到消息后,会再次执行 Method 代码。客户端的版本只是稍后会被回滚的模拟,这一次才是写入真实数据库的版本。在服务端执行真实逻辑至关重要,因为服务端是可信环境,安全关键代码可以确定地按预期运行。
服务端入口在packages/ddp-server/livedata_server.js的method: async function (msg, unblock)处理器中:先校验消息格式,然后从self.server.method_handlers[msg.method]找到处理器(找不到时返回new Meteor.Error(404, "Method '...' not found")),构造isSimulation: false的MethodInvocation,并在DDP._CurrentMethodInvocation.withValue(...)上下文中执行 handler。如果应用启用了ddp-rate-limiter包,这里还会先做限流检查(too-many-requests错误)。此外,若安装了audit-argument-checks包,maybeAuditArgumentChecks()会校验 Method 内的所有参数是否都经过了check/Match检查。
4. 返回值发送给客户端
服务端 Method 执行完毕后,向客户端发送一条携带第 2 步 Method ID 和返回值本身的result消息。客户端会先存储这个结果,但暂时不调用 Method 回调。如果给Meteor.apply传了onResultReceived选项,此时会触发该回调。
5. 受 Method 影响的 DDP publications 被更新
如果页面上有任何 publication 受该 Method 数据库写入的影响,服务端会把相应的数据更新发送给客户端。注意:客户端数据系统在这一步不会把更新暴露给 UI。
6. 发送updated消息,客户端用服务端结果替换本地数据,触发 Method 回调
相关数据更新发送完毕后,服务端发出 Method 生命周期的最后一条消息——携带 Method ID 的 DDPupdated消息。客户端回滚第 1 步 Method 模拟对客户端数据做的改动,替换为第 5 步来自服务端的真实改动。
最后,传给Meteor.call的回调才真正以第 4 步的返回值触发。回调必须等到客户端数据同步完成,这样你的 Method 回调才能假定客户端状态已反映 Method 内的所有改动。
服务端侧updated消息的发送时机由 WriteFence 机制控制(packages/ddp-server/livedata_server.js):服务端为每个 Method 调用创建一个_WriteFence,fence.onAllCommitted()在所有观察者(含订阅)对 Method 写入做出反应后,发送{msg: 'updated', methods: [msg.id]}。
错误场景
以上流程没有覆盖服务端 Method 抛错的情况。此时没有返回值,客户端收到的是错误。Method 回调会立刻以错误作为第一个参数被触发。错误处理细节见上文。
Methods 相比 REST 的优势
Guide 认为:对构建现代应用而言,Method 是比基于 HTTP 的 REST 端点更好的原语。下面列出用 Method 就能"免费获得"、而用 HTTP 需要自己操心的事情。这一节的目的不是论证 REST 不好,而是提醒你:在 Meteor 应用里,这些事不需要你亲自处理。
同步风格 API,但非阻塞
注意上面的示例 Method:与 MongoDB 交互时没写任何回调,但 Method 依然具备人们所熟知 Node.js 回调风格代码的非阻塞特性。Meteor 借助协程库 Fibers,让你能用返回值与异常写代码,避免大量嵌套回调。(注:Meteor 3 已转向async/await,即上文createMethod示例中的async run+findOneAsync/updateAsync。)
有序执行与返回
访问 REST API 时,可能连续发出两个请求但结果乱序返回。Meteor 的底层机制保证 Method 不会出现这种情况:来自同一客户端的多个 Method 调用,Meteor 会逐个执行完毕再开始下一个。如果某个特别耗时的 Method 需要让出执行权,可以使用this.unblock()允许下一个 Method 在当前 Method 仍在进行时开始执行。此外,由于 Meteor 基于 WebSocket 而非 HTTP,所有 Method 调用与结果都保证按发送顺序到达。也可以给Meteor.apply传wait: true选项:等待此前所有 Method 返回后再发送该 Method,并且在该 Method 返回前不发送任何后续 Method。
unblock机制在服务端packages/ddp-server/livedata_server.js的method处理器中通过unblock回调实现,并随MethodInvocation暴露为this.unblock();而wait选项在客户端MethodInvoker中维护排队顺序(packages/ddp-client/common/livedata_connection.js)。
变更追踪,支撑 Optimistic UI
当 Method 模拟与服务端执行运行时,Meteor 会追踪它们对数据库产生的所有变更。这正是 Meteor 数据系统能够回滚 Method 模拟的改动、并用服务端真实写入替换它们的前提。没有这种自动的数据库追踪,要实现正确的 Optimistic UI 几乎不可能。
在 Method 中调用另一个 Method
有时你需要在 Method 里调用另一个 Method——比如已有某个功能实现,想加一个自动填充部分参数的包装。这是完全合理的模式,Meteor 还为此做了两件好事:
- 在客户端 Method 模拟内部,调用另一个 Method 不会额外向服务端发请求——假设服务端实现会执行它。但它会运行被调 Method 的模拟,使客户端的模拟与服务端将要发生的事尽可能一致。
- 在服务端 Method 执行内部,调用另一个 Method 会如同被同一客户端调用那样运行。也就是说,
userId、connection等上下文会沿用最初那次 Method 调用的值。
从客户端源码可以验证这一点:_stubCall()中如果检测到当前已在模拟中(alreadyInSimulation),就不会发起 RPC,而是直接返回 stub 的执行结果。
一致的 ID 生成与 Optimistic UI
当你在客户端 Method 模拟中向 Minimongo 插入文档时,每个文档的_id是一个随机字符串。服务端执行同一 Method 时 ID 会重新生成。如果实现得很粗糙,服务端生成的 ID 可能与客户端不同,导致 Method 模拟被回滚、替换为服务端数据时出现恼人的闪烁和重渲染。但 Meteor 不会这样!
每次 Method 调用都会与发起它的客户端共享一个随机种子(randomSeed),因此客户端与服务端 Method 生成的任何 ID 都保证相同。这意味着你可以放心地在 Method 发送到服务端期间,使用客户端生成的 ID 做事(比如先创建文档、再立即重定向到包含该新文档 ID 的 URL),并确信 Method 完成时 ID 依然一致。
底层机制在packages/ddp-common/random_stream.js中实现:客户端在 stub 需要随机数时通过DDPCommon.makeRpcSeed(enclosing, methodName)生成randomSeed,随methodDDP 消息发送给服务端(见livedata_connection.js的_apply():if (randomSeed.value !== null) { message.randomSeed = randomSeed.value; });服务端用DDPCommon.RandomStream以该 seed 作为种子创建可复现的伪随机序列(Alea 算法)。两端从同一个 seed 出发,沿着相同的调用路径消费随机数,产出的 ID 自然一致。
Method 重试与幂等性
如果从客户端调用 Method 后、收到结果前用户断网,Meteor 会假定该 Method 没有真正执行。连接恢复后,该 Method 调用会被重新发送。这意味着某些情况下 Method 可能被发送不止一次。这种情况很少见,但如果额外的调用可能带来负面后果,就值得花力气保证 Method 是幂等的——即多次调用不会导致数据库产生额外变化。
好消息是许多 Method 操作天然幂等:插入重复执行会因 ID 冲突而抛错;对集合的remove第二次执行不会有任何效果;$set这类大多数更新操作符重复运行结果相同。真正需要担心"执行两次"的地方是:会叠加的 MongoDB 更新操作符(如$inc、$push),以及对外部 API 的调用。
与 allow/deny 的历史对比
Meteor 核心 API 提供了一套 Method 之外的、从客户端操作数据的替代方案:不显式定义带参数的 Method,而是直接从客户端调用insert、update、remove,并用allow和deny规则来约束安全性。Guide 在此采取明确立场:应避免该特性,改用 Method。关于 allow/deny 的问题,参见安全指南的 Avoid allow/deny 一节。
历史上对 Meteor Methods 与 allow/deny 存在一些误解,例如认为用 Method 更难实现 Optimistic UI。实际上,客户端侧的insert、update、remove功能恰恰是构建在 Method 之上的,所以 Method 严格更强大。只要按照上文生命周期所述,把 Method 代码同时定义在客户端和服务端,你就能获得开箱即用的优质 Optimistic UI。
源码速查
以下是本文涉及的核心实现与文档路径,便于进一步深入:
- 客户端 Method 注册与调用:
packages/ddp-client/common/livedata_connection.js(methods()、call()、callAsync()、apply()、applyAsync()、_apply()、_stubCall()) - 服务端 Method 注册与 DDP 处理:
packages/ddp-server/livedata_server.js(methods()、method消息处理器、wrapInternalException()、maybeAuditArgumentChecks()) - Method 调用上下文:
packages/ddp-common/method_invocation.js(DDPCommon.MethodInvocation:name、isSimulation、userId、connection、unblock()、setUserId()) - 错误类型:
packages/meteor/errors.js(Meteor.Error与Meteor.makeErrorType) - 一致 ID 的随机种子机制:
packages/ddp-common/random_stream.js(RandomStream与makeRpcSeed) - TypeScript 类型签名:
packages/meteor/meteor.d.ts(Meteor.methods、Meteor.call、Meteor.apply、MethodApplyOptions等) - 相关文档:Meteor 安全指南、Meteor 集合指南
【免费下载链接】meteorMeteor, the JavaScript App Platform项目地址: https://gitcode.com/gh_mirrors/me/meteor
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考