使用命名空间实现多租户

借助 Namespaces API,您可以在应用中轻松实现多租户功能,只需使用 NamespaceManager 软件包在 web.xml 中为每个租户选择命名空间字符串即可。

设置当前命名空间

您可以使用 NamespaceManager 获取、设置和验证命名空间。通过命名空间管理器,您能够为支持命名空间的 API 设置当前命名空间。您可以使用 web.xml 预先设置当前命名空间,数据存储区和 Memcache 会自动使用该命名空间。

大多数 App Engine 开发者会使用其 Google Workspace(以前称为 G Suite)网域作为当前命名空间。借助 Google Workspace,您可以将应用部署到您拥有的任何网域,从而可轻松利用此机制为不同网域配置不同的命名空间。然后,您可以使用这些单独的命名空间分隔各个网域中的数据。如需了解详情,请参阅映射自定义网域

以下代码示例演示了如何将当前命名空间设置为用于映射网址的 Google Workspace 网域。值得注意的是,对于通过同一 Google Workspace 网域映射的所有网址,此字符串是相同的。

您可以在调用 servlet 方法之前使用 servlet 过滤器界面在 Java 中设置命名空间。以下简单示例演示了如何将 Google Workspace 网域用作当前命名空间:

// Filter to set the Google Apps domain as the namespace.
public class NamespaceFilter implements Filter {

  @Override
  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
      throws IOException, ServletException {
    // Make sure set() is only called if the current namespace is not already set.
    if (NamespaceManager.get() == null) {
      // If your app is hosted on appspot, this will be empty. Otherwise it will be the domain
      // the app is hosted on.
      NamespaceManager.set(NamespaceManager.getGoogleAppsNamespace());
    }
    chain.doFilter(req, res); // Pass request back down the filter chain
  }

必须在 web.xml 文件中配置命名空间过滤器。请注意,如果有多个过滤器条目,则要设置的第一个命名空间即为要使用的命名空间。

以下代码示例演示了如何在 web.xml 中配置命名空间过滤器:

<!-- Configure the namespace filter. -->
<filter>
    <filter-name>NamespaceFilter</filter-name>
    <filter-class>com.example.appengine.NamespaceFilter</filter-class>
</filter>

<filter-mapping>
    <filter-name>NamespaceFilter</filter-name>
    <url-pattern>/sign</url-pattern>
</filter-mapping>

如需详细了解 web.xml 文件以及如何将网址路径映射到 Servlet,请参阅部署描述符:web.xml

您还可以使用下方显示的 try/finally 格式,为临时操作设置新的命名空间,并在操作完成后重置原始命名空间:

// Set the namepace temporarily to "abc"
String oldNamespace = NamespaceManager.get();
NamespaceManager.set("abc");
try {
  //      ... perform operation using current namespace ...
} finally {
  NamespaceManager.set(oldNamespace);
}

如果您没有为 namespace 指定值,命名空间将设置为空字符串。namespace 可以是任意字符串,但只能包含字母数字字符、句点、下划线和连字符且长度不超过 100 个字符。更明确地说,命名空间字符串必须与正则表达式 [0-9A-Za-z._-]{0,100} 匹配。

按照惯例,所有以“_”(下划线)开头的命名空间将保留给系统使用。该系统命名空间规则不强制实施,但若不遵循,则很可能造成难以预料的负面影响。

避免数据泄露

与多租户应用相关的一个常见风险是,命名空间中可能会发生数据泄露。导致数据意外泄露的原因有很多,其中包括:

  • 将命名空间与尚不支持命名空间的 App Engine API 一起使用。例如,Blobstore 不支持命名空间。如果将命名空间与 Blobstore 一起使用,需要避免对最终用户请求使用 Blobstore 查询,并避免使用来自不可信来源的 Blobstore 键。
  • 通过 URL Fetch 或其他机制使用外部存储介质(而不是 Memcache 和数据存储区),但并未为命名空间提供划分方案。
  • 根据用户的电子邮件网域设置命名空间。大多数情况下,您并不希望一个网域中的所有电子邮件地址都访问同一个命名空间。此外,使用电子邮件网域时,在用户登录后您的应用才可使用命名空间。

部署命名空间

以下部分介绍了如何使用其他 App Engine 工具和 API 来部署命名空间。

根据用户创建命名空间

部分应用需要根据用户来创建命名空间。如果要在用户级别为已登录用户划分数据,请考虑使用 User.getUserId(),它会为用户返回唯一的永久 ID。以下代码示例演示了如何使用 Users API 实现此目的:

if (com.google.appengine.api.NamespaceManager.get() == null) {
  // Assuming there is a logged in user.
  namespace = UserServiceFactory.getUserService().getCurrentUser().getUserId();
  NamespaceManager.set(namespace);
}

通常,基于用户创建命名空间的应用还会为不同用户提供特定的着陆页。在这些情况下,应用需要提供一个网址架构,以指示向用户显示哪个着陆页。

将命名空间与数据存储区一起使用

默认情况下,数据存储区使用命名空间管理器中的当前命名空间设置来处理数据存储区请求。KeyQuery 对象创建完成时,API 会将此当前命名空间应用于这些对象。因此,如果应用在序列化表单中存储 KeyQuery 对象,您需要特别小心,因为命名空间将保留在这些序列化内容中。

如果您使用反序列化的 KeyQuery 对象,请确保这些对象会按预期方式工作。通过在调用任何 Datastore API 之前设置当前命名空间,大多数使用数据存储区 (put/query/get) 而未使用其他存储机制的简单应用将按预期方式工作。

对于命名空间,QueryKey 对象会表现出以下独特行为:

  • QueryKey 对象构造完毕后,将继承当前命名空间,除非您设置了明确的命名空间。
  • 当应用通过祖先实体创建新的 Key 时,新 Key 会继承祖先实体的命名空间。
  • 尚无任何适用于 Java 的 API 可用来明确设置 KeyQuery 的命名空间。
以下代码示例演示了 SomeRequest 请求处理程序,它用于递增 Counter 数据存储区实体中的当前命名空间以及任意命名的 -global- 命名空间的计数。

public class UpdateCountsServlet extends HttpServlet {

  private static final int NUM_RETRIES = 10;

  @Entity
  public class CounterPojo {

    @Id
    public Long id;
    @Index
    public String name;
    public Long count;

    public CounterPojo() {
      this.count = 0L;
    }

    public CounterPojo(String name) {
      this.name = name;
      this.count = 0L;
    }

    public void increment() {
      count++;
    }
  }

  /**
   * Increment the count in a Counter datastore entity.
   **/
  public long updateCount(String countName) {

    CounterPojo cp = ofy().load().type(CounterPojo.class).filter("name", countName).first().now();
    if (cp == null) {
      cp = new CounterPojo(countName);
    }
    cp.increment();
    ofy().save().entity(cp).now();

    return cp.count;
  }

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp)
      throws java.io.IOException {

    // Update the count for the current namespace.
    updateCount("request");

    // Update the count for the "-global-" namespace.
    String namespace = NamespaceManager.get();
    try {
      // "-global-" is namespace reserved by the application.
      NamespaceManager.set("-global-");
      updateCount("request");
    } finally {
      NamespaceManager.set(namespace);
    }
    resp.setContentType("text/plain");
    resp.getWriter().println("Counts are now updated.");
  }

将命名空间与 Memcache 一起使用

默认情况下,Memcache 会使用命名空间管理器中的当前命名空间来处理 Memcache 请求。大多数情况下,您不需要在 Memcache 中明确设置命名空间,那样做可能会产生意外错误。

不过,在一些特殊情况下,您可能需要在 Memcache 中明确设置命名空间。例如,应用可能在所有命名空间之间共享相同的数据(例如,包含国家/地区代码的表格)。

以下代码段演示了如何在 Memcache 中明确设置命名空间:

默认情况下,适用于 Java 的 Memcache API 会从 MemcacheService 中查询命名空间管理器中的当前命名空间。当您使用 getMemcacheService(Namespace) 构造内存缓存时,也可以明确声明命名空间。对于大多数应用,您都无需明确指定命名空间。

以下代码示例演示了如何创建 Memcache 并让其使用命名空间管理器中的当前命名空间。

// Create a MemcacheService that uses the current namespace by
// calling NamespaceManager.get() for every access.
MemcacheService current = MemcacheServiceFactory.getMemcacheService();

// stores value in namespace "abc"
oldNamespace = NamespaceManager.get();
NamespaceManager.set("abc");
try {
  current.put("key", value); // stores value in namespace “abc”
} finally {
  NamespaceManager.set(oldNamespace);
}

以下代码示例在创建 Memcache 服务时明确指定了命名空间:

// Create a MemcacheService that uses the namespace "abc".
MemcacheService explicit = MemcacheServiceFactory.getMemcacheService("abc");
explicit.put("key", value); // stores value in namespace "abc"

将命名空间与任务队列一起使用

默认情况下,推送队列会使用创建任务时在命名空间管理器中设置的当前命名空间。大多数情况下,您不需要在任务队列中明确设置命名空间,这么做可能会导致产生意外的错误。

任务名称会在所有命名空间中共享。即使任务使用不同的命名空间,也不能同名。如果要对多个命名空间使用相同的任务名称,您只需将每个命名空间附加到任务名称中。

当新任务调用任务队列 add() 方法时,任务队列会从命名空间管理器复制当前命名空间和(如果适用)Google Workspace 网域。执行任务后,系统将恢复当前命名空间和 Google Workspace 命名空间。

如果当前命名空间不是在原始请求中设置的(换句话说,如果 get() 返回 null),则任务队列在执行的任务中将命名空间设置为 ""

在一些特殊情况下,您需要为在所有命名空间中执行的任务明确设置命名空间。例如,您可以创建一个任务,以汇总所有命名空间中使用情况的统计信息。然后,您可以明确设置该任务的命名空间。以下代码示例演示了如何为任务队列明确设置命名空间。

首先,创建一个将在 Counter 数据存储区实体中递增计数的任务队列处理程序:

public class UpdateCountsServlet extends HttpServlet {

  private static final int NUM_RETRIES = 10;

  @Entity
  public class CounterPojo {

    @Id
    public Long id;
    @Index
    public String name;
    public Long count;

    public CounterPojo() {
      this.count = 0L;
    }

    public CounterPojo(String name) {
      this.name = name;
      this.count = 0L;
    }

    public void increment() {
      count++;
    }
  }

  /**
   * Increment the count in a Counter datastore entity.
   **/
  public long updateCount(String countName) {

    CounterPojo cp = ofy().load().type(CounterPojo.class).filter("name", countName).first().now();
    if (cp == null) {
      cp = new CounterPojo(countName);
    }
    cp.increment();
    ofy().save().entity(cp).now();

    return cp.count;
  }

// called from Task Queue
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
  String[] countName = req.getParameterValues("countName");
  if (countName.length != 1) {
    resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
    return;
  }
  updateCount(countName[0]);
}

然后,使用 servlet 创建任务:

public class SomeRequestServlet extends HttpServlet {

  // Handler for URL get requests.
  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {

    // Increment the count for the current namespace asynchronously.
    QueueFactory.getDefaultQueue()
        .add(TaskOptions.Builder.withUrl("/_ah/update_count").param("countName", "SomeRequest"));
    // Increment the global count and set the
    // namespace locally.  The namespace is
    // transferred to the invoked request and
    // executed asynchronously.
    String namespace = NamespaceManager.get();
    try {
      NamespaceManager.set("-global-");
      QueueFactory.getDefaultQueue()
          .add(TaskOptions.Builder.withUrl("/_ah/update_count").param("countName", "SomeRequest"));
    } finally {
      NamespaceManager.set(namespace);
    }
    resp.setContentType("text/plain");
    resp.getWriter().println("Counts are being updated.");
  }
}

将命名空间与 Blobstore 一起使用

Blobstore 不按命名空间划分。要在 Blobstore 中保留命名空间,您需要通过支持命名空间的存储介质(当前只有 Memcache、数据存储区和任务队列)访问 Blobstore。例如,如果 blob 的 Key 存储在数据存储区实体中,您可以使用支持命名空间的数据存储区 KeyQuery 访问该实体。

如果应用通过支持命名空间的存储空间中存储的键访问 Blobstore,则 Blobstore 本身无需按命名空间进行划分。应用必须通过以下方法避免命名空间之间的 blob 泄露:

  • 不使用 com.google.appengine.api.blobstore.BlobInfoFactory 来处理最终用户请求。您可以对管理请求(例如,生成关于所有应用 Blob 的报告)使用 BlobInfo 查询,但对最终用户请求使用该查询可能会导致数据泄露,因为并非所有 BlobInfo 记录都按命名空间划分。
  • 不使用来自不可信来源的 Blobstore 键。

为数据存储区查询设置命名空间

在 Google Cloud 控制台中,您可以为 Datastore 查询设置命名空间

如果不想使用默认值,请从下拉列表中选择要使用的命名空间。

将命名空间与批量加载器一起使用

批量加载器支持 --namespace=NAMESPACE 标志,后者可用于指定要使用的命名空间。每个命名空间都需要单独处理;如果要访问所有命名空间,则需要依次处理这些命名空间。

Index 的新实例将继承用于创建该实例的 SearchService 的命名空间。创建对索引的引用后,其命名空间便无法更改。在使用 SearchService 创建索引之前,您可以通过两种方式为其设置命名空间:

  • 默认情况下,新 SearchService 会采用当前命名空间。您可以在创建服务之前设置当前命名空间:
// Set the current namespace to "aSpace"
NamespaceManager.set("aSpace");
// Create a SearchService with the namespace "aSpace"
SearchService searchService = SearchServiceFactory.getSearchService();
// Create an IndexSpec
IndexSpec indexSpec = IndexSpec.newBuilder().setName("myIndex").build();
// Create an Index with the namespace "aSpace"
Index index = searchService.getIndex(indexSpec);

您可以在创建服务时在 SearchServiceConfig 中指定命名空间:

// Create a SearchServiceConfig, specifying the namespace "anotherSpace"
SearchServiceConfig config =
    SearchServiceConfig.newBuilder().setNamespace("anotherSpace").build();
// Create a SearchService with the namespace "anotherSpace"
searchService = SearchServiceFactory.getSearchService(config);
// Create an IndexSpec
indexSpec = IndexSpec.newBuilder().setName("myindex").build();
// Create an Index with the namespace "anotherSpace"
index = searchService.getIndex(indexSpec);