使用命名空间实现多租户

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

设置当前命名空间

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

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

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

如需在 Python 中设置命名空间,请使用应用根目录中的 App Engine 配置系统 appengine_config.py。以下简单示例演示了如何将 Google Workspace 网域用作当前命名空间:

from google.appengine.api import namespace_manager

# Called only if the current namespace is not set.
def namespace_manager_default_namespace_for_request():
    # The returned string will be used as the Google Apps domain.
    return namespace_manager.google_apps_namespace()

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

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

如需了解有关如何配置 appengine_config.py 的更多常规信息,请参阅 Python 模块配置

避免数据泄露

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

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

部署命名空间

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

根据用户创建命名空间

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

from google.appengine.api import users

def namespace_manager_default_namespace_for_request():
    # assumes the user is logged in.
    return users.get_current_user().user_id()

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

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

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

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

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

  • QueryKey 对象构造完毕后,将继承当前命名空间,除非您设置了明确的命名空间。
  • 当应用通过祖先实体创建新的 Key 时,新 Key 会继承祖先实体的命名空间。

下面的代码示例演示了一个示例请求处理程序,它会在数据存储区中针对全局命名空间和任意指定的命名空间递增计数。

from google.appengine.api import namespace_manager
from google.appengine.ext import ndb
import webapp2

class Counter(ndb.Model):
    count = ndb.IntegerProperty()

@ndb.transactional
def update_counter(name):
    """Increment the named counter by 1."""
    counter = Counter.get_by_id(name)
    if counter is None:
        counter = Counter(id=name, count=0)

    counter.count += 1
    counter.put()

    return counter.count

class DatastoreCounterHandler(webapp2.RequestHandler):
    """Increments counters in the global namespace as well as in whichever
    namespace is specified by the request, which is arbitrarily named 'default'
    if not specified."""

    def get(self, namespace='default'):
        global_count = update_counter('counter')

        # Save the current namespace.
        previous_namespace = namespace_manager.get_namespace()
        try:
            namespace_manager.set_namespace(namespace)
            namespace_count = update_counter('counter')
        finally:
            # Restore the saved namespace.
            namespace_manager.set_namespace(previous_namespace)

        self.response.write('Global: {}, Namespace {}: {}'.format(
            global_count, namespace, namespace_count))

app = webapp2.WSGIApplication([
    (r'/datastore', DatastoreCounterHandler),
    (r'/datastore/(.*)', DatastoreCounterHandler)
], debug=True)

将命名空间与 Memcache 一起使用

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

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

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

使用 Python API 的 Memcache 服务,您可以从命名空间管理器中获得当前命名空间,或者在创建 Memcache 服务时明确设置当前命名空间。

下面的代码示例演示了一个示例请求处理程序,它会在 memcache 中针对全局命名空间和任意指定的命名空间递增计数。

from google.appengine.api import memcache
from google.appengine.api import namespace_manager
import webapp2

class MemcacheCounterHandler(webapp2.RequestHandler):
    """Increments counters in the global namespace as well as in whichever
    namespace is specified by the request, which is arbitrarily named 'default'
    if not specified."""

    def get(self, namespace='default'):
        global_count = memcache.incr('counter', initial_value=0)

        # Save the current namespace.
        previous_namespace = namespace_manager.get_namespace()
        try:
            namespace_manager.set_namespace(namespace)
            namespace_count = memcache.incr('counter', initial_value=0)
        finally:
            # Restore the saved namespace.
            namespace_manager.set_namespace(previous_namespace)

        self.response.write('Global: {}, Namespace {}: {}'.format(
            global_count, namespace, namespace_count))

app = webapp2.WSGIApplication([
    (r'/memcache', MemcacheCounterHandler),
    (r'/memcache/(.*)', MemcacheCounterHandler)
], debug=True)

当您在 Memcache 中存储值时,以下示例将明确设置命名空间:

  // Store an entry to the memcache explicitly
memcache.add("key", data, namespace='abc')

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

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

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

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

如果当前命名空间不是在原始请求中设置的(换句话说,如果 get_namespace() 返回 ''),则您可以使用 set_namespace() 为任务设置当前命名空间。

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

from google.appengine.api import namespace_manager
from google.appengine.api import taskqueue
from google.appengine.ext import ndb
import webapp2

class Counter(ndb.Model):
    count = ndb.IntegerProperty()

@ndb.transactional
def update_counter(name):
    """Increment the named counter by 1."""
    counter = Counter.get_by_id(name)
    if counter is None:
        counter = Counter(id=name, count=0)

    counter.count += 1
    counter.put()

    return counter.count

def get_count(name):
    counter = Counter.get_by_id(name)
    if not counter:
        return 0
    return counter.count

class DeferredCounterHandler(webapp2.RequestHandler):
    def post(self):
        name = self.request.get('counter_name')
        update_counter(name)

class TaskQueueCounterHandler(webapp2.RequestHandler):
    """Queues two tasks to increment a counter in global namespace as well as
    the namespace is specified by the request, which is arbitrarily named
    'default' if not specified."""
    def get(self, namespace='default'):
        # Queue task to update global counter.
        current_global_count = get_count('counter')
        taskqueue.add(
            url='/tasks/counter',
            params={'counter_name': 'counter'})

        # Queue task to update counter in specified namespace.
        previous_namespace = namespace_manager.get_namespace()
        try:
            namespace_manager.set_namespace(namespace)
            current_namespace_count = get_count('counter')
            taskqueue.add(
                url='/tasks/counter',
                params={'counter_name': 'counter'})
        finally:
            namespace_manager.set_namespace(previous_namespace)

        self.response.write(
            'Counters will be updated asyncronously.'
            'Current values: Global: {}, Namespace {}: {}'.format(
                current_global_count, namespace, current_namespace_count))

app = webapp2.WSGIApplication([
    (r'/tasks/counter', DeferredCounterHandler),
    (r'/taskqueue', TaskQueueCounterHandler),
    (r'/taskqueue/(.*)', TaskQueueCounterHandler)
], debug=True)

将命名空间与 Blobstore 一起使用

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

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

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

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

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

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

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

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

当您创建 Index 的新实例时,系统会默认将其分配给当前命名空间:

# set the current namespace
namespace_manager.set_namespace("aSpace")
index = search.Index(name="myIndex")
# index namespace is now fixed to "aSpace"

您还可以在构造函数中显式分配命名空间:

index = search.Index(name="myIndex", namespace="aSpace")

一旦您创建了索引规范,其命名空间便无法更改:

# change the current namespace
namespace_manager.set_namespace("anotherSpace")
# the namespaceof 'index' is still "aSpace" because it was bound at create time
index.search('hello')