方法

如何使用方法(Meteor 的远程过程调用系统)写入数据库。

阅读本文后,您将了解

  1. Meteor 中的方法是什么以及它们的详细工作原理。
  2. 定义和调用方法的最佳实践。
  3. 如何使用方法抛出和处理错误。
  4. 如何从表单调用方法。

什么是方法?

方法是 Meteor 的远程过程调用 (RPC) 系统,用于保存来自客户端的用户输入事件和数据。如果您熟悉 REST API 或 HTTP,可以将它们视为对服务器的 POST 请求,但具有许多针对构建现代 Web 应用程序而优化的不错功能。在本文后面,我们将详细介绍从方法中获得的一些好处,而这些好处是您无法从 HTTP 端点获得的。

从本质上讲,方法是您服务器的 API 端点;您可以在服务器上定义一个方法,并在客户端上定义其对应部分,然后用一些数据调用它,写入数据库,并在回调中获取返回值。Meteor 方法也与 Meteor 的发布/订阅和数据加载系统紧密集成,以允许乐观 UI——能够在客户端模拟服务器端操作,使您的应用程序感觉比实际速度更快。

我们将使用大写 M 来指代 Meteor 方法,以将其与 JavaScript 中的类方法区分开来。

定义和调用方法

基本方法

在基本的应用程序中,定义 Meteor 方法就像定义函数一样简单。在复杂的应用程序中,您需要一些额外的功能来使方法更强大且更易于测试。首先,我们将介绍如何使用 Meteor 核心 API 定义方法,在后面的部分中,我们将介绍如何使用我们创建的一个有用的包装程序包来启用更强大的方法工作流程。

定义

以下是如何使用内置的Meteor.methods API来定义方法。请注意,方法应始终在客户端和服务器上加载的通用代码中定义,以启用乐观 UI。如果您的方法中有一些秘密代码,请查阅安全文章,了解如何将其隐藏在客户端之外。

此示例使用simpl-schema npm 包,这在其他几篇文章中也推荐过,用于验证方法参数。

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 }
    });
  }
});

调用

此方法可以使用Meteor.call从客户端和服务器调用。请注意,您应该只在某些代码需要从客户端调用时才使用方法;如果您只想模块化仅从服务器调用的代码,请使用常规 JavaScript 函数,而不是方法。

以下是如何从客户端调用此方法

Meteor.call('todos.updateText', {
  todoId: '12345',
  newText: 'This is a todo item.'
}, (err, res) => {
  if (err) {
    alert(err);
  } else {
    // success!
  }
});

如果方法抛出错误,您将在回调的第一个参数中获取该错误。如果方法成功,您将在第二个参数中获取结果,并且第一个参数err将为undefined。有关错误的更多信息,请参见下面关于错误处理的部分。

高级方法样板

Meteor 方法有几个功能并不立即显而易见,但每个复杂的应用程序在某些时候都会需要它们。这些功能是在几年内以向后兼容的方式增量添加的,因此要释放方法的全部功能需要大量的样板。在本文中,我们将首先向您展示为每个功能需要编写的全部代码,然后下一节将讨论我们开发的一个方法包装程序包,以使其更轻松。

以下是理想方法具有的部分功能

  1. 单独运行验证代码,而不运行方法体。
  2. 覆盖用于测试的方法。
  3. 使用自定义用户 ID 调用方法,尤其是在测试中(如Discover Meteor 两层方法模式推荐的那样)。
  4. 通过 JS 模块而不是魔术字符串引用方法。
  5. 获取方法模拟返回值以获取插入文档的 ID。
  6. 如果客户端验证失败,避免调用服务器端方法,这样我们就不会浪费服务器资源。

定义

export const updateText = {
  name: 'todos.updateText',

  // Factor out validation so that it can be run independently (1)
  validate(args) {
    new SimpleSchema({
      todoId: { type: String },
      newText: { type: String }
    }).validate(args)
  },

  // Factor out Method body so that it can be called independently (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 }
    });
  },

  // Call Method by referencing the JS object (4)
  // Also, this lets us specify Meteor.apply options once in
  // the Method implementation, rather than requiring the caller
  // to specify it at the call site.
  call(args, callback) {
    const options = {
      returnStubValue: true,     // (5)
      throwStubExceptions: true  // (6)
    }

    Meteor.apply(this.name, [args], options, callback);
  }
};

// Actually register the method with Meteor's DDP system
Meteor.methods({
  [updateText.name]: function (args) {
    updateText.validate.call(this, args);
    updateText.run.call(this, args);
  }
})

调用

现在调用方法就像调用 JavaScript 函数一样简单

import { updateText } from './path/to/methods.js';

// Call the Method
updateText.call({
  todoId: '12345',
  newText: 'This is a todo item.'
}, (err, res) => {
  if (err) {
    alert(err);
  } else {
    // success!
  }
});

// Call the validation only
updateText.validate({ wrong: 'args'});

// Call the Method with custom userId in a test
updateText.run.call({ userId: 'abcd' }, {
  todoId: '12345',
  newText: 'This is a todo item.'
});

如您所见,这种调用方法的方法可以带来更好的开发工作流程 - 您可以更轻松地分别处理方法的不同部分并测试您的代码,而无需处理 Meteor 内部细节。但是,这种方法要求您在方法定义方面编写大量样板。

使用 mdg:validated-method 的高级方法

为了减轻正确方法定义中涉及的一些样板,我们发布了一个名为mdg:validated-method的包装程序包,它可以为您完成大部分工作。以下是与上面相同的 Method,但使用该包定义的

import { ValidatedMethod } from 'meteor/mdg:validated-method';

export const updateText = new ValidatedMethod({
  name: 'todos.updateText',
  validate: new SimpleSchema({
    todoId: { type: String },
    newText: { type: String }
  }).validator(),
  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 }
    });
  }
});

您以与上面调用高级方法相同的方式调用它,但方法定义要简单得多。我们认为这种方法样式可以让您清楚地看到重要的部分 - 通过网络发送的方法名称、预期参数的格式以及方法可以被引用的 JavaScript 命名空间。经过验证的方法只接受单个参数和一个回调函数。

错误处理

在常规 JavaScript 函数中,您可以通过抛出Error对象来指示错误。从 Meteor 方法抛出错误的工作方式几乎相同,但由于在某些情况下错误对象将通过 websocket 发送回客户端,因此引入了一些复杂性。

从方法抛出错误

Meteor 引入了两种新的 JavaScript 错误类型:Meteor.ErrorValidationError。这些和常规 JavaScript Error 类型应在不同的情况下使用

常规 `Error` 用于内部服务器错误

当您遇到不需要向客户端报告但对服务器内部的错误时,请抛出一个常规的 JavaScript 错误对象。这将向客户端报告为完全不透明的内部服务器错误,没有任何详细信息。

Meteor.Error 用于一般的运行时错误

当服务器由于已知条件而无法完成用户的所需操作时,您应该向客户端抛出一个描述性的Meteor.Error对象。在 Todos 示例应用程序中,我们使用它们来报告当前用户无权完成某些操作或应用程序内不允许该操作的情况 - 例如,删除最后一个公共列表。

Meteor.Error 接受三个参数:errorreasondetails

  1. error 应是一个简短、唯一、机器可读的错误代码字符串,客户端可以解释它以了解发生了什么。最好以方法名称作为前缀,以便于国际化,例如:'todos.updateText.unauthorized'
  2. reason 应该是错误的简短描述,供开发人员使用。它应该为您的同事提供足够的信息来调试错误。reason 参数不应直接打印给最终用户,因为这意味着您现在必须在发送错误消息之前在服务器上进行国际化,并且 UI 开发人员在考虑 UI 中将显示的内容时必须担心方法的实现。
  3. details 是可选的,可以在额外数据有助于客户端了解错误原因的情况下使用。特别是,它可以与error字段结合使用,向最终用户打印更友好的错误消息。

ValidationError 用于参数验证错误

当方法调用失败是因为参数类型错误时,最好抛出ValidationError。这与Meteor.Error类似,但它是一个自定义构造函数,它强制执行可由不同的表单和验证库读取的标准错误格式。特别是,如果您是从表单调用此方法,则抛出ValidationError将使其能够在表单中的特定字段旁边显示友好的错误消息。

当您如上所述将mdg:validated-methodsimpl-schema一起使用时,将为您抛出此类错误。

mdg:validation-error 文档中阅读有关错误格式的更多信息。

处理错误

当您调用方法时,它抛出的任何错误都将在回调中返回。此时,您应该识别错误类型并向用户显示相应的错误消息。在这种情况下,方法不太可能抛出ValidationError或内部服务器错误,因此我们只处理未授权错误

// Call the Method
updateText.call({
  todoId: '12345',
  newText: 'This is a todo item.'
}, (err, res) => {
  if (err) {
    if (err.error === 'todos.updateText.unauthorized') {
      // Displaying an alert is probably not what you would do in
      // a real app; you should have some nice UI to display this
      // error, and probably use an i18n library to generate the
      // message from the error code.
      alert('You aren\'t allowed to edit this todo item');
    } else {
      // Unexpected error, handle it in the UI somehow
    }
  } else {
    // success!
  }
});

我们将在下面关于表单的部分中讨论如何处理ValidationError

方法模拟中的错误

当调用方法时,它通常会运行两次——一次在客户端上模拟乐观 UI 的结果,另一次在服务器上对数据库进行实际更改。这意味着,如果您的方法抛出错误,它很可能在客户端和服务器上都会失败。出于这个原因,ValidatedMethod开启了 Meteor 中未公开的选项,以避免在模拟抛出错误时调用服务器端实现。

虽然这种行为对于在方法肯定会失败的情况下节省服务器资源很有好处,但务必确保模拟在服务器方法本来会成功的情况下不会抛出错误(例如,如果您没有在客户端加载方法需要正确执行模拟的一些数据)。在这种情况下,您可以将仅服务器端逻辑包装在一个检查方法模拟的块中

if (!this.isSimulation) {
  // Logic that depends on server environment here
}

从表单调用方法

ValidationError约定启用的主要内容是方法与其调用它们的表单之间的集成。通常,您的应用程序可能在 UI 中的表单与方法之间存在一对一的映射。首先,让我们为我们的业务逻辑定义一个方法

// Define a regular expression for email and amount validation.
const emailRegEx = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/g;
const amountRegEx = /^\d*\.(\d\d)?$/;

// This Method encodes the form validation requirements.
// By defining them in the Method, we do client and server-side
// validation in one place.
export const insert = new ValidatedMethod({
  name: 'Invoices.methods.insert',
  validate: new SimpleSchema({
    email: { type: String, regEx: emailRegEx },
    description: { type: String, min: 5 },
    amount: { type: String, regEx: amountRegEx }
  }).validator(),
  run(newInvoice) {
    // In here, we can be sure that the newInvoice argument is
    // validated.

    if (!this.userId) {
      throw new Meteor.Error('Invoices.methods.insert.not-logged-in',
        'Must be logged in to create an invoice.');
    }

    Invoices.insert(newInvoice)
  }
});

出于安全考虑,我们鼓励您为诸如emailamout之类的字段创建自定义正则表达式。对于与Meteor相关的功能,例如IDs,您可以使用SimpleSchema.RegEx.Id表达式。查看Simple Schema 文档以获取更多信息。

让我们定义一个HTML表单

<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.call(data, (err, res) => {
      if (err) {
        if (err.error === 'validation-error') {
          // Initialize error object
          const errors = {
            email: [],
            description: [],
            amount: []
          };

          // Go through validation errors returned from Method
          err.details.forEach((fieldError) => {
            // XXX i18n
            errors[fieldError.name].push(fieldError.type);
          });

          // Update ReactiveDict, errors will show up in the UI
          instance.errors.set(errors);
        }
      }
    });
  }
});

如您所见,在表单中很好地处理错误需要相当数量的样板代码,但其中大部分可以通过现成的表单框架或您自己设计的应用程序特定包装器来抽象。

使用方法加载数据

由于方法可以作为通用RPC,因此它们也可以用于获取数据而不是发布。与通过发布加载数据相比,这种方法有一些优点和缺点,最终我们建议始终使用发布来加载数据。

方法可用于从服务器获取复杂计算的结果,而这些结果在服务器数据发生变化时无需更新。通过方法获取数据的最大缺点是数据不会自动加载到Minimongo(Meteor的客户端数据缓存)中,因此您需要手动管理该数据生命周期。另一个缺点是数据库查询不会像发布游标那样在客户端之间共享——方法(以及它包含的任何查询)将为调用它的每个客户端运行一次。

使用本地集合存储和显示从方法获取的数据

集合是在客户端存储数据的非常方便的方式。如果您使用订阅以外的其他方式获取数据,则可以将其手动放入集合中。让我们来看一个示例,其中我们有一个复杂的算法,用于根据多位玩家的一系列游戏计算平均分数。我们不想使用发布来加载此数据,因为我们希望精确控制其运行时间,并且不希望数据自动缓存。

首先,您需要创建一个本地集合 - 这仅存在于客户端的集合,并且不与服务器上的数据库集合绑定。在集合文章中了解更多信息。

// In client-side code, declare a local collection
// by passing `null` as the argument
ScoreAverages = new Mongo.Collection(null);

现在,如果您使用方法获取数据,则可以将其放入此集合中

import { calculateAverages } from '../api/games/methods.js';

function updateAverages() {
  // Clean out result cache
  ScoreAverages.remove({});

  // Call a Method that does an expensive computation
  calculateAverages.call((err, res) => {
    res.forEach((item) => {
      ScoreAverages.insert(item);
    });
  });
}

现在,我们可以像使用常规MongoDB集合一样,在UI组件中使用本地集合ScoreAverages中的数据。与其自动更新,我们需要在每次需要新结果时调用updateAverages

高级概念

虽然您可以通过遵循Meteor入门教程在应用程序中使用方法,但了解它们的工作原理对于在生产应用程序中有效地使用它们至关重要。使用像Meteor这样的框架(它为您在幕后做了很多事情)的缺点之一是您并不总是了解发生了什么,因此学习一些核心概念是很有必要的。

方法调用生命周期

以下是调用方法时按顺序发生的事件

1. 方法模拟在客户端运行

如果我们在客户端和服务器代码中定义了此方法(所有方法都应如此),则会在调用它的客户端中执行方法模拟。

客户端进入一种特殊模式,在此模式下,它会跟踪对客户端集合所做的所有更改,以便以后可以回滚这些更改。此步骤完成后,应用程序的用户会立即看到他们的UI使用客户端数据库的新内容更新,但服务器尚未收到任何数据。

如果方法模拟抛出异常,则默认情况下Meteor会忽略它并继续执行步骤(2)。如果您使用ValidatedMethod或将特殊的throwStubExceptions选项传递给Meteor.apply,则从模拟中抛出的异常将完全阻止服务器端方法运行。

方法模拟的返回值将被丢弃,除非在调用方法时传递了returnStubValue选项,在这种情况下,它将返回给方法调用者。ValidatedMethod默认情况下会传递此选项。

2. 发送到服务器的`method` DDP消息

Meteor客户端构建一条要发送到服务器的DDP消息。这包括方法名称、参数和一个自动生成的方法ID,该ID表示此特定方法调用。

3. 方法在服务器上运行

服务器收到消息后,会再次在服务器上执行方法代码。客户端版本是一个稍后将回滚的模拟,但这次是写入实际数据库的真实版本。在服务器上运行实际的方法逻辑至关重要,因为服务器是一个受信任的环境,我们知道安全关键代码将按我们预期的方式运行。

4. 将返回值发送到客户端

服务器上的方法运行完成后,它会向客户端发送一条result消息,其中包含在步骤2中生成的方法ID和返回值本身。客户端将其存储以备后用,但尚未调用方法回调。如果您将onResultReceived选项传递给Meteor.apply,则会触发该回调。

5. 方法影响的任何DDP发布都会更新

如果页面上存在任何受此方法的数据库写入影响的发布,则服务器会将相应的更新发送到客户端。请注意,客户端数据系统不会在下一步之前将这些更新显示给应用程序UI。

6. 发送到客户端的`updated`消息,数据替换为服务器结果,方法回调触发

在将相关的数据库更新发送到正确的客户端后,服务器会发送方法生命周期中的最后一条消息 - 带有相关方法ID的DDP updated消息。客户端会回滚在步骤1中的方法模拟中对客户端数据所做的任何更改,并将其替换为步骤5中服务器发送的实际更改。

最后,传递给Meteor.call的回调实际上会使用步骤4中的返回值触发。回调等待客户端更新非常重要,以便您的方法回调可以假设客户端状态反映了方法内部所做的任何更改。

错误情况

在上表中,我们没有介绍服务器上的方法执行抛出错误的情况。在这种情况下,没有返回值,客户端会收到错误。方法回调会立即触发,并将返回的错误作为第一个参数。

方法相对于REST的优势

我们认为,方法比基于HTTP构建的REST端点为构建现代应用程序提供了更好的基础。让我们回顾一下使用方法可以免费获得的一些内容,而使用HTTP则需要担心这些内容。本节的目的是不是说服您REST不好 - 只是提醒您在Meteor应用程序中不需要自己处理这些事情。

方法使用同步风格的API,但是非阻塞的

您可能在上面的示例方法中注意到,我们在与MongoDB交互时不需要编写任何回调,但方法仍然具有人们与Node.js和回调风格代码相关的非阻塞特性。Meteor使用一个名为Fibers的协程库,使您能够编写使用返回值并抛出错误的代码,并避免处理大量嵌套回调。

方法始终按顺序运行和返回

当访问REST API时,您有时会遇到这种情况,您一个接一个地发出两个请求,但结果却乱序到达。Meteor的基础机制确保方法永远不会发生这种情况。当从同一个客户端接收多个方法调用时,Meteor会运行每个方法直至完成,然后再开始下一个方法。如果您需要为一个特别耗时的特定方法禁用此功能,则可以使用this.unblock()允许下一个方法在当前方法仍在进行时运行。此外,由于Meteor基于WebSockets而不是HTTP,因此所有方法调用和结果都保证按照发送的顺序到达。您还可以将特殊的选项wait: true传递给Meteor.apply,以等待发送特定方法,直到所有其他方法都返回,并且在该方法返回之前不发送任何其他方法。

乐观UI的更改跟踪

当方法模拟和服务器端执行运行时,Meteor会跟踪对数据库的任何结果更改。这就是Meteor数据系统回滚方法模拟的更改并将它们替换为服务器的实际写入的方式。如果没有这种自动数据库跟踪,将很难实现正确的乐观UI系统。

从另一个方法调用方法

有时,您可能希望从另一个方法调用方法。也许您已经实现了一些功能,并且想要添加一个包装器,该包装器会自动填写一些参数。这是一种完全正常的模式,Meteor为您做了一些不错的事情

  1. 在客户端方法模拟内部,调用另一个方法不会向服务器发出额外的请求 - 假设该方法的服务器端实现将执行此操作。但是,它确实运行了被调用方法的模拟,以便客户端上的模拟与服务器上将发生的情况紧密匹配。
  2. 在服务器上的方法执行内部,调用另一个方法会像由同一个客户端调用一样运行该方法。这意味着该方法照常运行,并且上下文(userIdconnection等)取自原始方法调用。

一致的ID生成和乐观UI

当您从方法的客户端模拟将文档插入Minimongo时,每个文档的_id字段都是一个随机字符串。当方法调用在服务器上执行时,会在将其插入数据库之前再次生成ID。如果实现起来很粗糙,则可能意味着服务器上生成的ID不同,这会导致在回滚方法模拟并将其替换为服务器数据时,UI中出现不希望出现的闪烁和重新渲染。但这在Meteor中并非如此!

每个Meteor方法调用都与调用该方法的客户端共享一个随机生成器种子,因此客户端和服务器方法生成的任何ID都保证是相同的。这意味着您可以安全地使用客户端生成的ID在方法发送到服务器时执行操作,并确信方法完成后ID将相同。一个特别有用的情况是,如果您想在数据库中创建一个新文档,然后立即重定向到包含该新文档ID的URL。

方法重试

如果您从客户端调用方法,并且用户在收到结果之前断开了互联网连接,则Meteor会假设该方法实际上没有运行。当连接重新建立时,将再次发送方法调用。这意味着在某些情况下,方法可能会发送多次。这种情况应该很少发生,但在额外的方法调用可能产生负面后果的情况下,值得付出额外的努力来确保方法是幂等的 - 也就是说,多次调用它们不会导致数据库发生额外更改。

许多 Method 操作默认情况下是幂等的。如果插入操作发生两次,由于生成的 ID 会冲突,则会抛出错误。对集合的移除操作第二次不会做任何事情,并且大多数更新操作符(如$set)如果再次运行,将产生相同的结果。您只需要担心代码运行两次的地方是 MongoDB 中会堆叠的更新操作符,例如$inc$push,以及对外部 API 的调用。

与 allow/deny 的历史比较

Meteor 核心 API 包括一个用于从客户端操作数据的 Method 替代方案。您可以不使用带有特定参数的显式定义 Method,而是直接从客户端调用insertupdateremove,并使用allowdeny指定安全规则。在 Meteor 指南中,我们强烈建议避免使用此功能,而应使用 Method。有关 allow/deny 问题的更多信息,请阅读安全文章

历史上,关于 Meteor Method 的功能与 allow/deny 功能相比,存在一些误解,包括使用 Method 时难以实现乐观 UI。但是,客户端的insertupdateremove功能实际上是基于Method 实现的,因此 Method 严格来说功能更强大。通过在客户端和服务器端定义 Method 代码(如上所述的 Method 生命周期部分),您可以获得出色的默认乐观 UI。

在 GitHub 上编辑
// 搜索框