如何使用 App Engine Blob 存储区(模块 15)

1. 概览

无服务器迁移站系列 Codelab(自学进度、动手教程)和相关视频旨在帮助 Google Cloud 无服务器开发者实现应用现代化,引导他们完成一项或多项迁移,主要是从旧版服务迁移。这样做可以提高应用的可移植性,为您提供更多选择和灵活性,使您能够集成和访问更广泛的 Cloud 产品,并更轻松地升级到较新的语言版本。虽然本系列最初侧重于最早的 Cloud 用户(主要是 App Engine [标准环境] 开发者),但其内容足够广泛,也涵盖了其他无服务器平台(例如 Cloud Functions 和 Cloud Run)或其他平台(如适用)。

此模块 15 Codelab 介绍了如何向模块 0 中的示例应用添加 App Engine blobstore 用法。然后,您就可以在下一个模块(模块 16)中将该用量迁移到 Cloud Storage。

在接下来的实验中

  • 添加对 App Engine Blobstore API/库的使用
  • 将用户上传的内容存储到 blobstore 服务
  • 为下一步迁移到 Cloud Storage 做好准备

所需条件

调查问卷

您将如何使用本教程?

仅阅读教程内容 阅读并完成练习

您如何评价使用 Python 的体验?

新手水平 中等水平 熟练水平

您如何评价自己在使用 Google Cloud 服务方面的经验水平?

新手 中等 熟练

2. 背景

为了从 App Engine Blobstore API 迁移,请将 Blobstore API 的使用添加到模块 0 中的现有基准 App Engine ndb 应用。示例应用会向用户显示最近的十次访问。我们正在修改应用,以提示最终用户上传与其“访问”对应的工件(文件)。如果用户不想这样做,可以选择“跳过”。无论用户做出何种决定,下一页都会呈现与模块 0(以及本系列中的许多其他模块)中的应用相同的输出。实现此 App Engine blobstore 集成后,我们可以在下一个(模块 16)Codelab 中将其迁移到 Cloud Storage。

App Engine 提供了对 Django 和 Jinja2 模板系统的访问权限,而此示例的不同之处(除了添加 Blobstore 访问权限之外)在于,它从模块 0 中使用 Django 切换到模块 15 中使用 Jinja2。App Engine 应用现代化改造的一个关键步骤是将 Web 框架从 webapp2 迁移到 Flask。后者使用 Jinja2 作为其默认模板系统,因此我们开始朝着这个方向发展,即实现 Jinja2,同时继续使用 webapp2 访问 Blobstore。由于 Flask 默认使用 Jinja2,因此在模块 16 中无需对模板进行任何更改。

3. 设置/准备工作

在进入本教程的主要部分之前,请先设置项目、获取代码并部署基准应用,以便从可运行的代码开始。

1. 设置项目

如果您已部署了第 0 单元应用,我们建议您重复使用同一项目(和代码)。或者,您也可以创建全新项目或重复使用其他现有项目。确保项目具有有效的结算账号,并且 App Engine 已启用。

2. 获取基准示例应用

此 Codelab 的前提条件之一是拥有一个可正常运行的模块 0 示例应用。如果您没有,可以从模块 0 的“START”文件夹(链接如下)中获取。此 Codelab 将引导您完成每个步骤,最终生成的代码与模块 15“FINISH”文件夹中的代码类似。

模块 0 起始文件的目录应如下所示:

$ ls
README.md               index.html
app.yaml                main.py

3. (重新)部署基准应用

您现在需要执行的剩余准备工作步骤:

  1. 重新熟悉 gcloud 命令行工具
  2. 使用 gcloud app deploy 重新部署示例应用
  3. 确认应用在 App Engine 上运行没有任何问题

成功执行这些步骤并看到 Web 应用正常运行(输出类似于下图)后,您就可以向应用添加缓存功能了。

a7a9d2b80d706a2b.png

4. 更新配置文件

app.yaml

应用配置没有实质性变更,但如前所述,我们将从 Django 模板(默认)迁移到 Jinja2,因此为了进行切换,用户应指定 App Engine 服务器上可用的最新版本 Jinja2,方法是将其添加到 app.yaml 的内置第三方库部分。

之前:

runtime: python27
threadsafe: yes
api_version: 1

handlers:
- url: /.*
  script: main.app

修改 app.yaml 文件,添加一个新的 libraries 部分,如下所示:

升级后:

runtime: python27
threadsafe: yes
api_version: 1

handlers:
- url: /.*
  script: main.app

libraries:
- name: jinja2
  version: latest

无需更新其他配置文件,因此我们继续处理应用文件。

5. 修改应用文件

导入和 Jinja2 支持

main.py 的第一组更改包括添加 Blobstore API 的使用,以及将 Django 模板替换为 Jinja2。具体变化如下:

  1. os 模块的作用是创建指向 Django 模板的文件路径名。由于我们将改用 Jinja2 来处理此问题,因此不再需要使用 os 以及 Django 模板渲染器 google.appengine.ext.webapp.template,所以它们将被移除。
  2. 导入 Blobstore API:google.appengine.ext.blobstore
  3. 导入原始 webapp 框架中的 Blobstore 处理程序,因为 webapp2 中不提供这些处理程序:google.appengine.ext.webapp.blobstore_handlers
  4. 从 webapp2_extras 软件包导入 Jinja2 支持

之前:

import os
import webapp2
from google.appengine.ext import ndb
from google.appengine.ext.webapp import template

将 main.py 中的当前导入部分替换为以下代码段,以实现上述列表中的更改。

升级后:

import webapp2
from webapp2_extras import jinja2
from google.appengine.ext import blobstore, ndb
from google.appengine.ext.webapp import blobstore_handlers

导入后,添加一些样板代码,以支持使用 webapp2_extras 文档中定义的 Jinja2。以下代码段使用 Jinja2 功能封装了标准 webapp2 请求处理程序类,因此请在导入后立即将此代码块添加到 main.py:

class BaseHandler(webapp2.RequestHandler):
    'Derived request handler mixing-in Jinja2 support'
    @webapp2.cached_property
    def jinja2(self):
        return jinja2.get_jinja2(app=self.app)

    def render_response(self, _template, **context):
        self.response.write(self.jinja2.render_template(_template, **context))

添加 Blobstore 支持

在本系列的其他迁移中,我们保持示例应用的功能或输出相同(或几乎相同),而不会(过多)更改用户体验,但本示例与常规做法大相径庭。我们不再立即注册新的访问记录,然后显示最近的十条访问记录,而是更新应用,要求用户提供文件工件来注册其访问记录。然后,最终用户可以上传相应文件,也可以选择“跳过”而不上传任何内容。完成此步骤后,系统会显示“最近访问”页面。

通过这项更改,我们的应用可以使用 Blobstore 服务在“最近访问”页面上存储(以及可能在稍后呈现)该图片或其他文件类型。

更新数据模型并实现其使用

我们将存储更多数据,具体而言,就是更新数据模型以存储上传到 Blobstore 的文件的 ID(称为“BlobKey”),并添加一个引用以将其保存在 store_visit() 中。由于这些额外数据会在查询时与其他所有数据一起返回,因此 fetch_visits() 保持不变。

以下是这些更新前后的对比,其中 file_blob 是ndb.BlobKeyProperty:

之前:

class Visit(ndb.Model):
    'Visit entity registers visitor IP address & timestamp'
    visitor   = ndb.StringProperty()
    timestamp = ndb.DateTimeProperty(auto_now_add=True)

def store_visit(remote_addr, user_agent):
    'create new Visit entity in Datastore'
    Visit(visitor='{}: {}'.format(remote_addr, user_agent)).put()

def fetch_visits(limit):
    'get most recent visits'
    return Visit.query().order(-Visit.timestamp).fetch(limit)

升级后:

class Visit(ndb.Model):
    'Visit entity registers visitor IP address & timestamp'
    visitor   = ndb.StringProperty()
    timestamp = ndb.DateTimeProperty(auto_now_add=True)
    file_blob = ndb.BlobKeyProperty()

def store_visit(remote_addr, user_agent, upload_key):
    'create new Visit entity in Datastore'
    Visit(visitor='{}: {}'.format(remote_addr, user_agent),
            file_blob=upload_key).put()

def fetch_visits(limit):
    'get most recent visits'
    return Visit.query().order(-Visit.timestamp).fetch(limit)

下图展示了到目前为止所做的更改:

2270783776759f7f.png

支持文件上传

功能方面最显著的变化是支持文件上传,无论是提示用户上传文件、支持“跳过”功能,还是呈现与访问对应的文件。所有这些都是整体情况的一部分。以下是支持文件上传所需的更改:

  1. 主处理程序 GET 请求不再提取最近的访问记录进行显示。而是会提示用户上传。
  2. 当最终用户提交要上传的文件或跳过该过程时,表单中的 POST 会将控制权传递给派生自 google.appengine.ext.webapp.blobstore_handlers.BlobstoreUploadHandler 的新 UploadHandler。
  3. UploadHandler 的 POST 方法会执行上传操作,调用 store_visit() 来注册访问,并触发 HTTP 307 重定向,将用户送回“/”,其中…
  4. 主处理程序的 POST 方法会查询(通过 fetch_visits())并显示最近的访问记录。如果用户选择“跳过”,则不会上传任何文件,但系统仍会记录此次访问,然后执行相同的重定向。
  5. “最近访问”显示内容中包含一个向用户显示的新字段,如果上传文件可用,则显示超链接“查看”,否则显示“无”。这些更改会在 HTML 模板中实现,同时还会添加一个上传表单(敬请期待)。
  6. 如果最终用户点击任何包含已上传视频的访问记录的“查看”链接,系统会向派生自 google.appengine.ext.webapp.blobstore_handlers.BlobstoreDownloadHandler 的新 ViewBlobHandler 发出 GET 请求,如果文件是图片,则会呈现该文件(如果浏览器支持,则在浏览器中呈现),如果不是图片,则会提示下载,如果找不到,则会返回 HTTP 404 错误。
  7. 除了新的处理程序类对以及用于将流量发送到这些类的新路由对之外,主处理程序还需要一个新的 POST 方法来接收上述 307 重定向。

在这些更新之前,模块 0 应用仅包含一个具有 GET 方法和单个路由的主处理程序:

之前:

class MainHandler(webapp2.RequestHandler):
    'main application (GET) handler'
    def get(self):
        store_visit(self.request.remote_addr, self.request.user_agent)
        visits = fetch_visits(10)
        tmpl = os.path.join(os.path.dirname(__file__), 'index.html')
        self.response.out.write(template.render(tmpl, {'visits': visits}))

app = webapp2.WSGIApplication([
    ('/', MainHandler),
], debug=True)

实施这些更新后,现在有三个处理程序:1) 具有 POST 方法的上传处理程序;2) 具有 GET 方法的“查看 blob”下载处理程序;3) 具有 GET 和 POST 方法的主处理程序。进行这些更改,使应用的其余部分现在如下所示。

升级后:

class UploadHandler(blobstore_handlers.BlobstoreUploadHandler):
    'Upload blob (POST) handler'
    def post(self):
        uploads = self.get_uploads()
        blob_id = uploads[0].key() if uploads else None
        store_visit(self.request.remote_addr, self.request.user_agent, blob_id)
        self.redirect('/', code=307)

class ViewBlobHandler(blobstore_handlers.BlobstoreDownloadHandler):
    'view uploaded blob (GET) handler'
    def get(self, blob_key):
        self.send_blob(blob_key) if blobstore.get(blob_key) else self.error(404)

class MainHandler(BaseHandler):
    'main application (GET/POST) handler'
    def get(self):
        self.render_response('index.html',
                upload_url=blobstore.create_upload_url('/upload'))

    def post(self):
        visits = fetch_visits(10)
        self.render_response('index.html', visits=visits)

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/upload', UploadHandler),
    ('/view/([^/]+)?', ViewBlobHandler),
], debug=True)

我们刚刚添加的代码中有几个关键调用:

  • 在 MainHandler.get 中,有一个对 blobstore.create_upload_url 的调用。此调用会生成表单 POST 到的网址,并调用上传处理程序将文件发送到 Blobstore。
  • 在 UploadHandler.post 中,有一个对 blobstore_handlers.BlobstoreUploadHandler.get_uploads 的调用。这才是真正的魔法,它将文件放入 Blobstore,并为该文件返回一个唯一的永久性 ID,即 BlobKey。
  • 在 ViewBlobHandler.get 中,使用文件的 BlobKey 调用 blobstore_handlers.BlobstoreDownloadHandler.send 会导致提取文件并将其转发到最终用户的浏览器

这些调用代表了对应用中添加的功能的大部分访问。下图展示了对 main.py 的第二组也是最后一组更改:

da2960525ac1b90d.png

更新 HTML 模板

主应用的一些更新会影响应用的界面 (UI),因此需要在 Web 模板中进行相应更改,实际上有两处:

  1. 需要一个包含 3 个输入元素的文件上传表单:一个文件和一对提交按钮,分别用于文件上传和跳过。
  2. 更新最近访问记录输出,为有相应文件上传的访问记录添加“查看”链接,否则添加“无”。

之前:

<!doctype html>
<html>
<head>
<title>VisitMe Example</title>
<body>

<h1>VisitMe example</h1>
<h3>Last 10 visits</h3>
<ul>
{% for visit in visits %}
    <li>{{ visit.timestamp.ctime }} from {{ visit.visitor }}</li>
{% endfor %}
</ul>

</body>
</html>

实施上述列表中的更改,以构成更新后的模板:

升级后:

<!doctype html>
<html>
<head>
<title>VisitMe Example</title>
<body>

<h1>VisitMe example</h1>
{% if upload_url %}

<h3>Welcome... upload a file? (optional)</h3>
<form action="{{ upload_url }}" method="POST" enctype="multipart/form-data">
    <input type="file" name="file"><p></p>
    <input type="submit"> <input type="submit" value="Skip">
</form>

{% else %}

<h3>Last 10 visits</h3>
<ul>
{% for visit in visits %}
<li>{{ visit.timestamp.ctime() }}
    <i><code>
    {% if visit.file_blob %}
        (<a href="/view/{{ visit.file_blob }}" target="_blank">view</a>)
    {% else %}
        (none)
    {% endif %}
    </code></i>
    from {{ visit.visitor }}
</li>
{% endfor %}
</ul>

{% endif %}

</body>
</html>

此图片说明了对 index.html 的必要更新:

8583e975f25aa9e7.png

最后一项更改是,Jinja2 倾向于将模板放在 templates 文件夹中,因此请创建该文件夹并将 index.html 移入其中。完成最后这一步后,您就完成了在模块 0 示例应用中添加 Blobstore 的使用所需的所有更改。

(可选)Cloud Storage“增强功能”

Blobstore 存储空间最终演变为 Cloud Storage 本身。这意味着 Blobstore 上传内容会显示在 Cloud 控制台中,具体而言是 Cloud Storage 浏览器中。问题是,该从哪里入手呢?答案是 App Engine 应用的默认 Cloud Storage 存储桶。其名称是 App Engine 应用的完整域名 PROJECT_ID.appspot.com。这非常方便,因为所有项目 ID 都是唯一的,对吧?

对示例应用所做的更新会将上传的文件放入该存储桶中,但开发者可以选择更具体的位置。默认存储桶可通过 google.appengine.api.app_identity.get_default_gcs_bucket_name() 以编程方式访问,如果您想访问此值(例如,用作整理上传文件的路径前缀),则需要进行新的导入。例如,按文件类型排序:

f61f7a23a1518705.png

例如,如需为图片实现类似的功能,您需要使用如下代码以及一些检查文件类型以选择所需存储桶名称的代码:

ROOT_BUCKET = app_identity.get_default_gcs_bucket_name()
IMAGE_BUCKET = '%s/%s' % (ROOT_BUCKET, 'images')

您还将使用 Python 标准库 imghdr 模块等工具验证上传的图片,以确认图片类型。最后,您可能需要限制上传文件的大小,以防范恶意行为者。

假设所有这些工作都已完成。如何更新应用,以支持指定上传文件的存储位置?关键在于调整 MainHandler.get 中对 blobstore.create_upload_url 的调用,通过添加 gs_bucket_name 参数来指定 Cloud Storage 中所需的上传位置,如下所示:

blobstore.create_upload_url('/upload', gs_bucket_name=IMAGE_BUCKET))

由于这是一项可选更新,如果您想指定上传位置,则它不属于代码库中 main.py 文件的一部分。不过,您可以在代码库中查看名为 main-gcs.py 的替代方案。main-gcs.py 中的代码不会使用单独的存储桶“文件夹”,而是像 main.py 一样将上传内容存储在“根”存储桶 (PROJECT_ID.appspot.com) 中,但会提供您需要的框架,以便您按照本部分中的提示将示例派生为更多内容。下图展示了 main.py 和 main-gcs.py 之间的“差异”。

256e1ea68241a501.png

6. 总结/清理

本部分将通过部署应用、验证应用是否按预期运行以及检查任何反映的输出来结束此 Codelab。应用验证完成后,执行所有清理步骤并考虑后续步骤。

部署并验证应用

使用 gcloud app deploy 重新部署应用,并确认应用按预期运行,其用户体验 (UX) 与模块 0 应用不同。现在,您的应用中有两个不同的界面,第一个是访问文件上传表单提示:

f5b5f9f19d8ae978.png然后,最终用户可以上传文件并点击“提交”,也可以点击“跳过”而不上传任何内容。无论哪种情况,结果都是最近一次访问屏幕,现在在访问时间戳和访问者信息之间增加了“查看”链接或“无”:

f5ac6b98ee8a34cb.png

恭喜您完成了此 Codelab,成功将 App Engine Blobstore 的使用添加到模块 0 示例应用中。您的代码现在应该与“FINISH”(模块 15)文件夹中的内容一致。该文件夹中还存在备用 main-gcs.py。

清理

常规

如果您目前已完成操作,建议您停用 App Engine 应用,以免产生费用。不过,如果您想进行更多测试或实验,App Engine 平台提供了免费配额,只要您不超过该用量层级,就不会产生费用。这是计算费用,但可能还会产生相关 App Engine 服务的费用,因此请查看其价格页面了解详情。如果此迁移涉及其他 Cloud 服务,则这些服务会单独计费。在任何一种情况下,如果适用,请参阅下文中的“本 Codelab 专属”部分。

为充分披露信息,特此说明:部署到 App Engine 等 Google Cloud 无服务器计算平台会产生少量构建和存储费用。Cloud Build 和 Cloud Storage 都有自己的免费配额。存储该映像会占用部分配额。不过,您所在的地区可能没有此类免费层级,因此请注意您的存储空间用量,以尽可能降低潜在费用。您应查看的特定 Cloud Storage“文件夹”包括:

  • console.cloud.google.com/storage/browser/LOC.artifacts.PROJECT_ID.appspot.com/containers/images
  • console.cloud.google.com/storage/browser/staging.PROJECT_ID.appspot.com
  • 上述存储空间链接取决于您的 PROJECT_ID 和 *LOC*ation,例如,如果您的应用托管在美国,则为“us”。

另一方面,如果您不打算继续使用此应用或其他相关的迁移 Codelab,并希望彻底删除所有内容,请关停项目。

此 Codelab 专属

下面列出的服务是此 Codelab 独有的。如需了解详情,请参阅各产品的文档:

后续步骤

接下来要考虑的逻辑迁移在模块 16 中介绍,该模块向开发者展示了如何从 App Engine Blobstore 服务迁移到使用 Cloud Storage 客户端库。升级的好处包括:能够使用更多 Cloud Storage 功能;熟悉适用于 App Engine 之外的应用(无论是在 Google Cloud、其他云还是本地)的客户端库。如果您认为自己不需要 Cloud Storage 提供的所有功能,或者担心其对费用的影响,可以继续使用 App Engine Blobstore。

模块 16 之后还有许多其他可能的迁移,例如 Cloud NDB 和 Cloud Datastore、Cloud Tasks 或 Cloud Memorystore。此外,还有跨产品迁移到 Cloud Run 和 Cloud Functions 的情况。迁移代码库包含所有代码示例,提供指向所有可用 Codelab 和视频的链接,并就应考虑哪些迁移以及迁移的相关“顺序”提供指导。

7. 其他资源

Codelab 问题/反馈

如果您发现此 Codelab 存在任何问题,请先搜索您的问题,然后再提交。用于搜索和创建新问题的链接:

迁移时可参考的资源

下表提供了模块 0(开始)和模块 15(完成)的代码库文件夹链接。您也可以从所有 App Engine Codelab 迁移的代码库中访问这些代码,您可以克隆该代码库或下载 ZIP 文件。

Codelab

Python 2

Python 3

模块 0

代码

不适用

模块 15(此 Codelab)

代码

不适用

在线资源

以下是可能与本教程相关的在线资源:

App Engine

Google Cloud

Python

视频

许可

本作品已获知识共享署名 2.0 通用许可。