Google Cloud Firestore 触发器(第 1 代)

Cloud Run functions 函数可以处理与该函数属于同一 Google Cloud 项目的 Firestore 中的事件。您可以使用 Firestore API 和客户端库读取或更新 Firestore,以便对这些事件做出响应。

在一个典型的生命周期中,Firestore 函数会执行以下操作:

  1. 等待特定文档发生更改。

  2. 在事件发生时触发并执行相应的任务。

  3. 接收包含受影响文档快照的数据对象。对于 writeupdate 事件,该数据对象包含代表触发事件前后的文档状态的快照。

事件类型

Firestore 支持 createupdatedeletewrite 事件。write 事件包含对某个文档所做的所有修改。

事件类型 触发器
providers/cloud.firestore/eventTypes/document.create(默认) 首次写入某个文档时触发。
providers/cloud.firestore/eventTypes/document.update 当某文档已存在并且其任何值发生了更改时触发。
providers/cloud.firestore/eventTypes/document.delete 当文档的数据被删除时触发。
providers/cloud.firestore/eventTypes/document.write 在创建、更新或删除某个文档时触发。

在触发器中,通配符是使用大括号编写的,如下所示: "projects/YOUR_PROJECT_ID/databases/(default)/documents/collection/{document_wildcard}"

指定文档路径

如需触发您的函数,请指定要侦听的文档路径。函数只会对文档更改做出响应,无法监控特定的字段或集合。以下是一些有效和无效的文档路径示例:

  • users/marie:有效的触发器。监控单个文档 (/users/marie)。

  • users/{username}:有效的触发器。监控所有用户文档。通配符用于监控集合中的所有文档。

  • users/{username}/addresses无效的触发器。该路径指向的是子集合 addresses,而不是某个文档。

  • users/{username}/addresses/home:有效的触发器。监控所有用户的家庭住址文档。

  • users/{username}/addresses/{addressId}:有效的触发器。监控所有地址文档。

使用通配符和参数

如果您不确定要监控的具体文档是哪个,请使用 {wildcard} 代替文档 ID:

  • users/{username} 侦听所有用户文档的更改。

在此示例中,如果 users 中的任何文档的任何字段发生更改,都会与一个名为 {username} 的通配符相匹配。

如果 users 中的某个文档有多个子集合,并且这些子集合中一个文档内的某字段发生了更改,那么不会触发 {username} 通配符。

通配符匹配项会从文档路径中提取出来。您可以定义任意多个通配符,以替代明确指定的集合或文档 ID。

事件结构

当发生类似于如下所示的事件时,此触发器会调用您的函数:

{
    "oldValue": { // Update and Delete operations only
        A Document object containing a pre-operation document snapshot
    },
    "updateMask": { // Update operations only
        A DocumentMask object that lists changed fields.
    },
    "value": {
        // A Document object containing a post-operation document snapshot
    }
}

每个 Document 对象都包含一个或多个 Value 对象。如需查看类型参考信息,请参阅 Value 文档。如果您使用类型化语言(例如 Go)编写函数,此方法特别有用。

代码示例

以下示例 Cloud Functions 函数会输出 Cloud Firestore 触发事件的字段:

Node.js

/**
 * Background Function triggered by a change to a Firestore document.
 *
 * @param {!Object} event The Cloud Functions event.
 * @param {!Object} context Cloud Functions event metadata.
 */
exports.helloFirestore = (event, context) => {
  const triggerResource = context.resource;

  console.log(`Function triggered by event on: ${triggerResource}`);
  console.log(`Event type: ${context.eventType}`);

  if (event.oldValue && Object.keys(event.oldValue).length) {
    console.log('\nOld value:');
    console.log(JSON.stringify(event.oldValue, null, 2));
  }

  if (event.value && Object.keys(event.value).length) {
    console.log('\nNew value:');
    console.log(JSON.stringify(event.value, null, 2));
  }
};

Python

import json

def hello_firestore(data, context):
    """Triggered by a change to a Firestore document.
    Args:
        data (dict): The event payload.
        context (google.cloud.functions.Context): Metadata for the event.
    """
    trigger_resource = context.resource

    print("Function triggered by change to: %s" % trigger_resource)

    print("\nOld value:")
    print(json.dumps(data["oldValue"]))

    print("\nNew value:")
    print(json.dumps(data["value"]))

Go


// Package hello contains a Cloud Function triggered by a Firestore event.
package hello

import (
	"context"
	"fmt"
	"log"
	"time"

	"cloud.google.com/go/functions/metadata"
)

// FirestoreEvent is the payload of a Firestore event.
type FirestoreEvent struct {
	OldValue   FirestoreValue `json:"oldValue"`
	Value      FirestoreValue `json:"value"`
	UpdateMask struct {
		FieldPaths []string `json:"fieldPaths"`
	} `json:"updateMask"`
}

// FirestoreValue holds Firestore fields.
type FirestoreValue struct {
	CreateTime time.Time `json:"createTime"`
	// Fields is the data for this value. The type depends on the format of your
	// database. Log the interface{} value and inspect the result to see a JSON
	// representation of your database fields.
	Fields     interface{} `json:"fields"`
	Name       string      `json:"name"`
	UpdateTime time.Time   `json:"updateTime"`
}

// HelloFirestore is triggered by a change to a Firestore document.
func HelloFirestore(ctx context.Context, e FirestoreEvent) error {
	meta, err := metadata.FromContext(ctx)
	if err != nil {
		return fmt.Errorf("metadata.FromContext: %w", err)
	}
	log.Printf("Function triggered by change to: %v", meta.Resource)
	log.Printf("Old value: %+v", e.OldValue)
	log.Printf("New value: %+v", e.Value)
	return nil
}

Java

import com.google.cloud.functions.Context;
import com.google.cloud.functions.RawBackgroundFunction;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.util.logging.Logger;

public class FirebaseFirestore implements RawBackgroundFunction {
  private static final Logger logger = Logger.getLogger(FirebaseFirestore.class.getName());

  // Use GSON (https://github.com/google/gson) to parse JSON content.
  private static final Gson gson = new Gson();

  @Override
  public void accept(String json, Context context) {
    JsonObject body = gson.fromJson(json, JsonObject.class);
    logger.info("Function triggered by event on: " + context.resource());
    logger.info("Event type: " + context.eventType());

    if (body != null && body.has("oldValue")) {
      logger.info("Old value:");
      logger.info(body.get("oldValue").getAsString());
    }

    if (body != null && body.has("value")) {
      logger.info("New value:");
      logger.info(body.get("value").getAsString());
    }
  }
}

C#

using CloudNative.CloudEvents;
using Google.Cloud.Functions.Framework;
using Google.Events.Protobuf.Cloud.Firestore.V1;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace FirebaseFirestore;

public class Function : ICloudEventFunction<DocumentEventData>
{
    private readonly ILogger _logger;

    public Function(ILogger<Function> logger) =>
        _logger = logger;

    public Task HandleAsync(CloudEvent cloudEvent, DocumentEventData data, CancellationToken cancellationToken)
    {
        _logger.LogInformation("Function triggered by event on {subject}", cloudEvent.Subject);
        _logger.LogInformation("Event type: {type}", cloudEvent.Type);
        MaybeLogDocument("Old value", data.OldValue);
        MaybeLogDocument("New value", data.Value);

        // In this example, we don't need to perform any asynchronous operations, so the
        // method doesn't need to be declared async.
        return Task.CompletedTask;
    }

    /// <summary>
    /// Logs the names and values of the fields in a document in a very simplistic way.
    /// </summary>
    private void MaybeLogDocument(string message, Document document)
    {
        if (document is null)
        {
            return;
        }

        // ConvertFields converts the Firestore representation into a .NET-friendly
        // representation.
        IReadOnlyDictionary<string, object> fields = document.ConvertFields();
        var fieldNamesAndTypes = fields
            .OrderBy(pair => pair.Key)
            .Select(pair => $"{pair.Key}: {pair.Value}");
        _logger.LogInformation(message + ": {fields}", string.Join(", ", fieldNamesAndTypes));
    }
}

Ruby

require "functions_framework"

# Triggered by a change to a Firestore document.
FunctionsFramework.cloud_event "hello_firestore" do |event|
  # The event parameter is a CloudEvents::Event::V1 object.
  # See https://cloudevents.github.io/sdk-ruby/latest/CloudEvents/Event/V1.html
  payload = event.data

  logger.info "Function triggered by change to: #{event.source}"
  logger.info "Old value: #{payload['oldValue']}"
  logger.info "New value: #{payload['value']}"
end

PHP


use Google\CloudFunctions\CloudEvent;

function firebaseFirestore(CloudEvent $cloudevent)
{
    $log = fopen(getenv('LOGGER_OUTPUT') ?: 'php://stderr', 'wb');

    fwrite($log, 'Event: ' . $cloudevent->getId() . PHP_EOL);
    fwrite($log, 'Event Type: ' . $cloudevent->getType() . PHP_EOL);

    $data = $cloudevent->getData();

    $resource = $data['resource'];
    fwrite($log, 'Function triggered by event on: ' . $resource . PHP_EOL);

    if (isset($data['oldValue'])) {
        fwrite($log, 'Old value: ' . json_encode($data['oldValue']) . PHP_EOL);
    }

    if (isset($data['value'])) {
        fwrite($log, 'New value: ' . json_encode($data['value']) . PHP_EOL);
    }
}

以下示例会检索用户添加的值,将该位置的字符串转换为大写,然后将该值替换为该大写字符串:

Node.js

const Firestore = require('@google-cloud/firestore');

const firestore = new Firestore({
  projectId: process.env.GOOGLE_CLOUD_PROJECT,
});

// Converts strings added to /messages/{pushId}/original to uppercase
exports.makeUpperCase = event => {
  const resource = event.value.name;
  const affectedDoc = firestore.doc(resource.split('/documents/')[1]);

  const curValue = event.value.fields.original.stringValue;
  const newValue = curValue.toUpperCase();

  if (curValue !== newValue) {
    console.log(`Replacing value: ${curValue} --> ${newValue}`);

    return affectedDoc.set({
      original: newValue,
    });
  } else {
    // Value is already upper-case
    // Don't perform a(nother) write to avoid infinite loops
    console.log('Value is already upper-case.');
  }
};

Python

from google.cloud import firestore

client = firestore.Client()


# Converts strings added to /messages/{pushId}/original to uppercase
def make_upper_case(data, context):
    path_parts = context.resource.split("/documents/")[1].split("/")
    collection_path = path_parts[0]
    document_path = "/".join(path_parts[1:])

    affected_doc = client.collection(collection_path).document(document_path)

    cur_value = data["value"]["fields"]["original"]["stringValue"]
    new_value = cur_value.upper()

    if cur_value != new_value:
        print(f"Replacing value: {cur_value} --> {new_value}")
        affected_doc.set({"original": new_value})
    else:
        # Value is already upper-case
        # Don't perform a second write (which can trigger an infinite loop)
        print("Value is already upper-case.")

Go


// Package upper contains a Firestore Cloud Function.
package upper

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"
	"time"

	"cloud.google.com/go/firestore"
	firebase "firebase.google.com/go/v4"
)

// FirestoreEvent is the payload of a Firestore event.
type FirestoreEvent struct {
	OldValue   FirestoreValue `json:"oldValue"`
	Value      FirestoreValue `json:"value"`
	UpdateMask struct {
		FieldPaths []string `json:"fieldPaths"`
	} `json:"updateMask"`
}

// FirestoreValue holds Firestore fields.
type FirestoreValue struct {
	CreateTime time.Time `json:"createTime"`
	// Fields is the data for this value. The type depends on the format of your
	// database. Log an interface{} value and inspect the result to see a JSON
	// representation of your database fields.
	Fields     MyData    `json:"fields"`
	Name       string    `json:"name"`
	UpdateTime time.Time `json:"updateTime"`
}

// MyData represents a value from Firestore. The type definition depends on the
// format of your database.
type MyData struct {
	Original struct {
		StringValue string `json:"stringValue"`
	} `json:"original"`
}

// GOOGLE_CLOUD_PROJECT is automatically set by the Cloud Functions runtime.
var projectID = os.Getenv("GOOGLE_CLOUD_PROJECT")

// client is a Firestore client, reused between function invocations.
var client *firestore.Client

func init() {
	// Use the application default credentials.
	conf := &firebase.Config{ProjectID: projectID}

	// Use context.Background() because the app/client should persist across
	// invocations.
	ctx := context.Background()

	app, err := firebase.NewApp(ctx, conf)
	if err != nil {
		log.Fatalf("firebase.NewApp: %v", err)
	}

	client, err = app.Firestore(ctx)
	if err != nil {
		log.Fatalf("app.Firestore: %v", err)
	}
}

// MakeUpperCase is triggered by a change to a Firestore document. It updates
// the `original` value of the document to upper case.
func MakeUpperCase(ctx context.Context, e FirestoreEvent) error {
	fullPath := strings.Split(e.Value.Name, "/documents/")[1]
	pathParts := strings.Split(fullPath, "/")
	collection := pathParts[0]
	doc := strings.Join(pathParts[1:], "/")

	curValue := e.Value.Fields.Original.StringValue
	newValue := strings.ToUpper(curValue)
	if curValue == newValue {
		log.Printf("%q is already upper case: skipping", curValue)
		return nil
	}
	log.Printf("Replacing value: %q -> %q", curValue, newValue)

	data := map[string]string{"original": newValue}
	_, err := client.Collection(collection).Doc(doc).Set(ctx, data)
	if err != nil {
		return fmt.Errorf("Set: %w", err)
	}
	return nil
}

Java


import com.google.cloud.firestore.Firestore;
import com.google.cloud.firestore.FirestoreOptions;
import com.google.cloud.firestore.SetOptions;
import com.google.cloud.functions.Context;
import com.google.cloud.functions.RawBackgroundFunction;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.logging.Level;
import java.util.logging.Logger;

public class FirebaseFirestoreReactive implements RawBackgroundFunction {

  // Use GSON (https://github.com/google/gson) to parse JSON content.
  private static final Gson gson = new Gson();

  private static final Logger logger = Logger.getLogger(FirebaseFirestoreReactive.class.getName());
  private static final Firestore FIRESTORE = FirestoreOptions.getDefaultInstance().getService();

  private final Firestore firestore;

  public FirebaseFirestoreReactive() {
    this(FIRESTORE);
  }

  FirebaseFirestoreReactive(Firestore firestore) {
    this.firestore = firestore;
  }

  @Override
  public void accept(String json, Context context) {
    // Get the recently-written value
    JsonObject body = gson.fromJson(json, JsonObject.class);
    JsonObject tempJson = body.getAsJsonObject("value");

    // Verify that value.fields.original.stringValue exists
    String currentValue = null;
    if (tempJson != null) {
      tempJson = tempJson.getAsJsonObject("fields");
    }
    if (tempJson != null) {
      tempJson = tempJson.getAsJsonObject("original");
    }
    if (tempJson != null && tempJson.has("stringValue")) {
      currentValue = tempJson.get("stringValue").getAsString();
    }
    if (currentValue == null) {
      throw new IllegalArgumentException("Malformed JSON: " + json);
    }

    // Convert recently-written value to ALL CAPS
    String newValue = currentValue.toUpperCase(Locale.getDefault());

    // Update Firestore DB with ALL CAPS value
    Map<String, String> newFields = Map.of("original", newValue);

    String affectedDoc = context.resource().split("/documents/")[1].replace("\"", "");

    if (!currentValue.equals(newValue)) {
      // The stored value needs to be updated
      // Write the upper-cased value to Firestore
      logger.info(String.format("Replacing value: %s --> %s", currentValue, newValue));
      try {
        FIRESTORE.document(affectedDoc).set(newFields, SetOptions.merge()).get();
      } catch (ExecutionException | InterruptedException e) {
        logger.log(Level.SEVERE, "Error updating Firestore document: " + e.getMessage(), e);
      }
    } else {
      // The stored value is already upper-case, and doesn't need updating.
      // (Don't perform a "second" write, since that could trigger an infinite loop.)
      logger.info(String.format("Value is already upper-case."));
    }
  }
}

C#

using CloudNative.CloudEvents;
using Google.Cloud.Firestore;
using Google.Cloud.Functions.Framework;
using Google.Cloud.Functions.Hosting;
using Google.Events.Protobuf.Cloud.Firestore.V1;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace FirestoreReactive;

public class Startup : FunctionsStartup
{
    public override void ConfigureServices(WebHostBuilderContext context, IServiceCollection services) =>
        services.AddSingleton(FirestoreDb.Create());
}

// Register the startup class to provide the Firestore dependency.
[FunctionsStartup(typeof(Startup))]
public class Function : ICloudEventFunction<DocumentEventData>
{
    private readonly ILogger _logger;
    private readonly FirestoreDb _firestoreDb;

    public Function(ILogger<Function> logger, FirestoreDb firestoreDb) =>
        (_logger, _firestoreDb) = (logger, firestoreDb);

    public async Task HandleAsync(CloudEvent cloudEvent, DocumentEventData data, CancellationToken cancellationToken)
    {
        // Get the recently-written value. This expression will result in a null value
        // if any of the following is true:
        // - The event doesn't contain a "new" document
        // - The value doesn't contain a field called "original"
        // - The "original" field isn't a string
        string currentValue = data.Value?.ConvertFields().GetValueOrDefault("original") as string;
        if (currentValue is null)
        {
            _logger.LogWarning($"Event did not contain a suitable document");
            return;
        }

        string newValue = currentValue.ToUpperInvariant();
        if (newValue == currentValue)
        {
            _logger.LogInformation("Value is already upper-cased; no replacement necessary");
            return;
        }

        // The CloudEvent subject is "documents/x/y/...".
        // The Firestore SDK FirestoreDb.Document method expects a reference relative to
        // "documents" (so just the "x/y/..." part). This may be simplified over time.
        if (cloudEvent.Subject is null || !cloudEvent.Subject.StartsWith("documents/"))
        {
            _logger.LogWarning("CloudEvent subject is not a document reference.");
            return;
        }
        string documentPath = cloudEvent.Subject.Substring("documents/".Length);

        _logger.LogInformation("Replacing '{current}' with '{new}' in '{path}'", currentValue, newValue, documentPath);
        await _firestoreDb.Document(documentPath).UpdateAsync("original", newValue, cancellationToken: cancellationToken);
    }
}

Ruby

require "functions_framework"

FunctionsFramework.on_startup do
  # Lazily construct a Firestore client when needed, and reuse it on
  # subsequent calls.
  set_global :firestore_client do
    require "google/cloud/firestore"
    Google::Cloud::Firestore.new project_id: ENV["GOOGLE_CLOUD_PROJECT"]
  end
end

# Converts strings added to /messages/{pushId}/original to uppercase
FunctionsFramework.cloud_event "make_upper_case" do |event|
  # Event-triggered Ruby functions receive a CloudEvents::Event::V1 object.
  # See https://cloudevents.github.io/sdk-ruby/latest/CloudEvents/Event/V1.html
  # The Firebase event payload can be obtained from the event data.
  cur_value = event.data["value"]["fields"]["original"]["stringValue"]

  # Compute new value and determine whether it needs to be modified.
  # If the value is already upper-case, don't perform another write,
  # to avoid infinite loops.
  new_value = cur_value.upcase
  if cur_value == new_value
    logger.info "Value is already upper-case"
    return
  end

  # Use the Firestore client library to update the value.
  # The document name can be obtained from the event subject.
  logger.info "Replacing value: #{cur_value} --> #{new_value}"
  doc_name = event.subject.split("documents/").last
  affected_doc = global(:firestore_client).doc doc_name
  new_doc_data = { original: new_value }
  affected_doc.set new_doc_data, merge: false
end

PHP


use Google\Cloud\Firestore\FirestoreClient;
use Google\CloudFunctions\CloudEvent;

function firebaseReactive(CloudEvent $cloudevent)
{
    $log = fopen(getenv('LOGGER_OUTPUT') ?: 'php://stderr', 'wb');
    $data = $cloudevent->getData();

    $resource = $data['value']['name'];

    $db = new FirestoreClient();

    $docPath = explode('/documents/', $resource)[1];

    $affectedDoc = $db->document($docPath);

    $curValue = $data['value']['fields']['original']['stringValue'];
    $newValue = strtoupper($curValue);

    if ($curValue !== $newValue) {
        fwrite($log, 'Replacing value: ' . $curValue . ' --> ' . $newValue . PHP_EOL);

        $affectedDoc->set(['original' => $newValue]);
    } else {
        // Value is already upper-case
        // Don't perform another write (it might cause an infinite loop)
        fwrite($log, 'Value is already upper-case.' . PHP_EOL);
    }
}

部署函数

以下 gcloud 命令会部署一个由文档 /messages/{pushId} 上的写入事件触发的函数:

gcloud functions deploy FUNCTION_NAME \
  --no-gen2 \
  --entry-point ENTRY_POINT \
  --runtime RUNTIME \
  --set-env-vars GOOGLE_CLOUD_PROJECT=YOUR_PROJECT_ID \
  --trigger-event "providers/cloud.firestore/eventTypes/document.write" \
  --trigger-resource "projects/YOUR_PROJECT_ID/databases/(default)/documents/messages/{pushId}"
参数 说明
FUNCTION_NAME 您要部署的 Cloud Functions 函数的注册名称。这可以是源代码中函数的名称,也可以是任意字符串。如果 FUNCTION_NAME 是任意字符串,则必须添加 --entry-point 标志。
--entry-point ENTRY_POINT 源代码中函数或类的名称。可选,除非您未使用 FUNCTION_NAME 指定源代码中要在部署期间执行的函数。在这种情况下,您必须使用 --entry-point 提供可执行函数的名称。
--runtime RUNTIME 您使用的运行时的名称。如需完整列表,请参阅 gcloud 参考文档
--set-env-vars GOOGLE_CLOUD_PROJECT=YOUR_PROJECT_ID 项目的唯一标识符,作为运行时环境变量。
--trigger-event NAME 函数将监控的事件类型(writecreateupdatedelete 之一)。
--trigger-resource NAME 函数将侦听的完全限定数据库路径。此路径应符合以下格式: "projects/YOUR_PROJECT_ID/databases/(default)/documents/PATH" {pushId} 文本是上面的指定文档路径中所述的通配符参数。

限制

请注意适用于 Cloud Run functions 的 Firestore 触发器的以下限制:

  • Cloud Run functions (第 1 代) 前提条件是 Firestore 原生模式的现有“(默认)”数据库。它不支持 Firestore 命名数据库或 Datastore 模式。在这种情况下,请使用 Cloud Run functions (第 2 代) 来配置事件。
  • 无法保证顺序。快速更改可能会以意想不到的顺序触发函数调用。
  • 事件至少会被传送一次,但单个事件可能会导致多次调用函数。应该避免依赖“正好一次”机制,并编写幂等函数
  • Datastore 模式 Firestore 需要 Cloud Run functions(第 2 代)。Cloud Run functions(第 1 代)不支持 Datastore 模式。
  • 一个触发器与单一数据库相关联。您无法创建与多个数据库匹配的触发器。
  • 删除数据库不会自动删除该数据库的任何触发器。触发器会停止传送事件,但会继续存在,直到您删除触发器
  • 如果匹配的事件超过请求大小上限,该事件可能不会传送到 Cloud Run functions (第 1 代)。
    • 因请求大小而未传送的事件会记录在平台日志中,并计入项目的日志使用量。
    • 您可以在 Logs Explorer 中找到这些日志,其严重性为 error 且内容为“由于大小超出第 1 代的限制,因此事件无法传送到 Cloud Functions 函数”消息。您可以在 functionName 字段下方找到函数名称。如果 receiveTimestamp 字段仍在从现在起的一小时内,您可以利用该时间戳之前和之后的快照来读取相关文档,从而推断实际事件内容。
    • 为避免这种情况发生,您可以:
      • 迁移和升级到 Cloud Run functions (第 2 代)
      • 缩小文档
      • 删除相关的 Cloud Run functions 函数
    • 您可以使用排除功能关闭日志记录功能本身,但请注意,违规事件仍然不会传送。