1. 概要
Serverless Migration Station の Codelab シリーズ(セルフペース型のハンズオン チュートリアル)と関連動画は、Google Cloud サーバーレス デベロッパーが主にレガシー サービスからの移行を 1 つ以上の移行を通じてガイドし、アプリをモダナイズできるようにすることを目的としています。これにより、アプリのポータビリティが高まり、選択肢と柔軟性が増し、より広範な Cloud プロダクトと統合してアクセスできるようになり、より新しい言語リリースに簡単にアップグレードできるようになります。当初は初期の Cloud ユーザー、主に App Engine(スタンダード環境)のデベロッパーを対象としていますが、Cloud Functions や Cloud Run など、その他のサーバーレス プラットフォーム(該当する場合)まで幅広くカバーしています。
モジュール 15 の Codelab では、モジュール 0 のサンプルアプリに App Engine blobstore
の使用方法を追加する方法について説明します。その後、モジュール 16 でその使用量を Cloud Storage に移行する準備が整います。
GCP コンソールの
- App Engine Blobstore API/ライブラリの使用を追加する
blobstore
サービスへのユーザーのアップロードを保存する- Cloud Storage への移行に向けた次のステップの準備
必要なもの
- 有効な GCP 請求先アカウントを持つ Google Cloud Platform プロジェクト
- 基本的な Python スキル
- 一般的な Linux コマンドに関する実践的な知識
- App Engine アプリの開発とデプロイに関する基本的な知識
- 動作中のモジュール 0 App Engine アプリ(リポジトリから取得)
アンケート
このチュートリアルをどのように使用されますか?
Python のご利用経験はどの程度ありますか?
Google Cloud サービスの使用経験はどの程度ありますか?
<ph type="x-smartling-placeholder">2. 背景情報
App Engine Blobstore API から移行するには、モジュール 0 で、その使用を既存のベースライン App Engine ndb
アプリに追加します。このサンプルアプリでは、直近 10 件のユーザー訪問を表示します。エンドユーザーが「訪問」に対応するアーティファクト(ファイル)をアップロードするよう促すよう、アプリを変更します。ユーザーがその操作を望まない場合は、スキップします。選択します。ユーザーの判断にかかわらず、次のページにはモジュール 0 のアプリ(およびこのシリーズの他の多くのモジュール)と同じ出力が表示されます。この App Engine blobstore
インテグレーションを実装したら、次の Codelab(モジュール 16)で Cloud Storage に移行できます。
App Engine では Django と Jinja2 のテンプレート システムにアクセスできます。この例の違いの 1 つは、Blobstore アクセス権を追加するほかに、モジュール 0 の Django の使用から、モジュール 15 の Jinja2 に切り替えられる点です。App Engine アプリのモダナイゼーションの重要なステップは、ウェブ フレームワークを webapp2
から Flask に移行することです。後者では、デフォルトのテンプレート システムとして Jinja2 を使用しているため、Jinja2 を実装することで Jinja2 を実装し、Blobstore アクセス用に webapp2
を維持します。Flask はデフォルトで Jinja2 を使用するため、モジュール 16 でテンプレートを変更する必要はありません。
3. 設定/事前作業
チュートリアルの本題に入る前に、プロジェクトをセットアップし、コードを入手して、ベースライン アプリをデプロイして、動作するコードから始めます。
1. プロジェクトのセットアップ
モジュール 0 アプリをすでにデプロイしている場合は、同じプロジェクト(およびコード)を再利用することをおすすめします。あるいは、新しいプロジェクトを作成することも、別の既存のプロジェクトを再利用することもできます。プロジェクトに有効な請求先アカウントがあり、App Engine が有効になっていることを確認します。
2. ベースラインのサンプルアプリを取得する
この Codelab の前提条件の一つは、機能するモジュール 0 サンプルアプリがあることです。ない場合は、モジュール 0「開始」から取得できます。(下記のリンクを参照)。この Codelab では各ステップを紹介し、最後にモジュール 15 の「FINISH」に似たコードで締めくくります。フォルダに配置されます。
- 開始: モジュール 0 フォルダ(Python 2)
- FINISH: モジュール 15 のフォルダ(Python 2)
- リポジトリ全体(ZIP ファイルのクローンを作成するかダウンロードするため)
モジュール 0 の開始ファイルのディレクトリは次のようになります。
$ ls README.md index.html app.yaml main.py
3. ベースライン アプリを(再)デプロイする
この段階で実施する必要がある残りの事前作業のステップ:
gcloud
コマンドライン ツールを学びなおすgcloud app deploy
を使用してサンプルアプリを再デプロイする- アプリが App Engine で問題なく動作することを確認する
これらのステップを正常に実行し、ウェブアプリが動作することを確認したら(次のような出力が表示されます)、アプリにキャッシュ機能を追加できます。
4. 構成ファイルを更新する
app.yaml
アプリケーション構成には実質的な変更はありませんが、前述したように、Django テンプレート(デフォルト)から Jinja2 に移行するため、切り替えるには、App Engine サーバーで利用可能な Jinja2 の最新バージョンを指定する必要があります。これは、app.yaml
の組み込みサードパーティ ライブラリ セクションに追加して行います。
変更前:
runtime: python27
threadsafe: yes
api_version: 1
handlers:
- url: /.*
script: main.app
次のように、新しい libraries
セクションを追加して app.yaml
ファイルを編集します。
変更後:
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 への置き換えです。変更点は次のとおりです。
os
モジュールの目的は、Django テンプレートへのファイルパス名を作成することです。この処理は Jinja2 に切り替えるため、os
と Django テンプレート レンダラであるgoogle.appengine.ext.webapp.template
の使用は不要になったため、今後削除されます。- Blobstore API をインポートします。
google.appengine.ext.blobstore
- 元の
webapp
フレームワークで見つかった Blobstore ハンドラをインポートします。これはwebapp2
では使用できません。google.appengine.ext.webapp.blobstore_handlers
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 を使用できるようにボイラープレート コードを追加します。次のコード スニペットは、標準の webapp2 リクエスト ハンドラ クラスを Jinja2 機能でラップしているため、インポートの直後にこのコードブロックを 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 のサポートを追加する
このシリーズの他の移行では、UX に(大きく)変更を加えずにサンプルアプリの機能や出力を同一(またはほぼ同じ)に維持しますが、この例では、通常とは根本的な違いがあります。そこで、新規訪問を即座に登録して直近の 10 件を表示するのではなく、訪問を登録するためのファイル アーティファクトをユーザーに求めるようにアプリを更新します。エンドユーザーは対応するファイルをアップロードするか、[スキップ] を選択できます。何もアップロードしないという方法ですこの手順を完了すると、[最近のアクセス] タブにはページが表示されます。
この変更により、アプリケーションは Blobstore サービスを使用して、その画像やその他のファイル形式を最新の訪問ページに保存する(と後でレンダリングする)ことができます。
データモデルを更新し、その使用法を実装する
保存されるデータが増えます。具体的には、Blobstore にアップロードされたファイルの ID(「BlobKey
」)を保存するようにデータモデルを更新し、store_visit()
に保存するための参照を追加します。この追加データは、クエリ時に他のすべてのデータとともに返されるため、fetch_visits()
は同じままです。
ndb.BlobKeyProperty
である file_blob
が登場するアップデートの適用前と導入後は次のとおりです。
変更前:
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)
以下は、これまでに行われた変更を画像で表したものです。
ファイルのアップロードをサポートする
機能の最も大きな変更点は、ファイルのアップロード、ユーザーへのファイル入力のプロンプトの表示、「スキップ」のサポートです。訪問に対応するファイルのレンダリングなどを行いますこれらすべてが、全体像の一部となります。ファイルのアップロードをサポートするには、次の変更が必要です。
- メインハンドラの
GET
リクエストは、表示する最新の訪問を取得しなくなりました。代わりに、ユーザーにアップロードを促すメッセージが表示されます。 - エンドユーザーがファイルをアップロードするか、そのプロセスをスキップすると、フォームの
POST
が、google.appengine.ext.webapp.blobstore_handlers.BlobstoreUploadHandler
から派生した新しいUploadHandler
に制御を渡します。 UploadHandler
のPOST
メソッドがアップロードを実行し、store_visit()
を呼び出して訪問を登録し、ユーザーを「/」に戻すために HTTP 307 リダイレクトをトリガーします。- メインハンドラの
POST
メソッドは、(fetch_visits()
を介して)をクエリし、最新のアクセスを表示します。ユーザーが [スキップ]を選択するとファイルはアップロードされませんが、訪問は登録された後、同じリダイレクトが続きます。 - 最新の訪問履歴には、ユーザーに表示される新しいフィールド(ハイパーリンク付きの「ビュー」)が含まれています。アップロード ファイルが使用可能な場合、または「none」できません。これらの変更は、アップロード フォームの追加と併せて HTML テンプレートに実装されます(詳細は近日中に説明します)。
- エンドユーザーが [表示] をクリックした場合アップロードされた動画へのアクセスに対するリンクがあると、
google.appengine.ext.webapp.blobstore_handlers.BlobstoreDownloadHandler
から派生した新しいViewBlobHandler
に対してGET
リクエストが行われます。その際、画像がレンダリングされている場合はブラウザ内でレンダリングし、そうでない場合はダウンロードを促し、見つからなかった場合は HTTP 404 エラーを返します。 - ハンドラクラスの新しいペアと、トラフィックを送信するための新しいルートペアに加えて、メインハンドラには上記の 307 リダイレクトを受け取るための新しい
POST
メソッドが必要です。
これらのアップデートが行われる前は、モジュール 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)
これらの更新が実装されると、ハンドラが 3 つになりました。1)POST
メソッドを持つアップロード ハンドラ、2)「ビュー blob」です。GET
メソッドを持つダウンロード ハンドラ、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
形式の URL を生成し、アップロード ハンドラを呼び出してファイルを Blobstore に送信します。UploadHandler.post
にblobstore_handlers.BlobstoreUploadHandler.get_uploads
への呼び出しがあります。これは、ファイルを Blobstore に挿入し、そのファイルの一意で永続的な ID(BlobKey
)を返す真の魔法です。ViewBlobHandler.get
でファイルのBlobKey
を指定してblobstore_handlers.BlobstoreDownloadHandler.send
を呼び出すと、ファイルが取得され、エンドユーザーのブラウザに転送されます。
これらの呼び出しは、アプリに追加された機能へのアクセスの大部分を表します。次に示すのは、main.py
の 2 つ目(最後)の変更を図で表したものです。
HTML テンプレートを更新
メインアプリの更新の一部はアプリのユーザー インターフェース(UI)に影響するため、対応する変更をウェブ テンプレートで行う必要があります。実際は以下の 2 つの変更を行います。
- ファイル アップロード フォームには、ファイルの 3 つの入力要素(ファイルのアップロード、スキップ用の送信ボタン)がそれぞれ必要です。
- 「ビュー」を追加して最新の来店数のデータを更新する対応するファイルがアップロードされた、または「なし」の訪問のリンクできません。
変更前:
<!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
に必要な更新を示しています。
最後に、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()
を介してプログラムでアクセスできます。この値にアクセスする場合(たとえば、アップロードされたファイルを整理するための接頭辞として使用する場合など)は、新しいインポートが必要になります。たとえば、ファイル形式で並べ替えると次のようになります。
画像についてこのような処理を実装するには、たとえば次のようなコードを用意します。また、ファイル形式をチェックして目的のバケット名を選択するコードもいくつか用意します。
ROOT_BUCKET = app_identity.get_default_gcs_bucket_name()
IMAGE_BUCKET = '%s/%s' % (ROOT_BUCKET, 'images')
また、Python 標準ライブラリの imghdr
モジュールなどのツールを使用して、アップロードされた画像を検証し、画像タイプを確認します。最後に、不正な行為者に備え、アップロードのサイズを制限することをおすすめします。
すべて完了するとしましょうアップロードされたファイルの保存場所を指定できるようにアプリを更新するにはどうすればよいですか?重要な点は、次のように gs_bucket_name
パラメータを追加して、MainHandler.get
の blobstore.create_upload_url
の呼び出しを調整し、アップロードに使用する Cloud Storage 内の場所を指定することです。
blobstore.create_upload_url('/upload', gs_bucket_name=IMAGE_BUCKET))
これは、アップロード先を指定するオプションの更新であるため、リポジトリの main.py
ファイルの一部ではありません。代わりに、main-gcs.py
という名前の代替方法がリポジトリで確認できます。個別のバケットの「フォルダ」を使用するのではなくmain-gcs.py
のコードにより、アップロードは「root」に保存されます。バケット(PROJECT_ID
.appspot.com
)は main.py
に似ていますが、このセクションで示すようにサンプルを導出する場合に必要な足場要素を提供します。以下は、「差分」の図です。main.py
~main-gcs.py
。
6. 概要/クリーンアップ
このセクションでは、この Codelab の締めくくりとして、アプリをデプロイし、意図したとおりに動作することを確認し、反映された出力を確認します。アプリの検証後、クリーンアップ手順を実施し、次のステップを検討します。
アプリケーションのデプロイと検証
gcloud app deploy
でアプリを再デプロイし、ユーザー エクスペリエンス(UX)がモジュール 0 のアプリと異なり、アプリが広告どおりに動作することを確認します。現在、アプリには 2 つの異なる画面があります。1 つ目は、ファイル アップロード フォームの訪問プロンプトです。
エンドユーザーは、このページでファイルをアップロードして [送信] をクリックします。または [スキップ]をクリックしますアップロードしないよう設定できますどちらの場合も、最新の訪問画面に「view」を追加して拡張されたものが表示されます。リンクまたは「none」次のような違いがあります。
お疲れさまでした。以上で、App Engine Blobstore をモジュール 0 サンプルアプリに追加して、この Codelab は終了です。コードが FINISH(モジュール 15)フォルダの内容と一致しているはずです。このフォルダには代替の main-gcs.py
もあります。
クリーンアップ
全般
現時点で完了したら、課金が発生しないように App Engine アプリを無効にすることをおすすめします。さらにテストや実験を行う場合は、App Engine プラットフォームに無料の割り当てが用意されています。この使用量ティアを超えない限り、料金は発生しません。これはコンピューティングに関するものですが、関連する App Engine サービスに対して料金が発生する場合もあります。詳細については、料金ページをご覧ください。この移行に他のクラウド サービスが含まれる場合、それらは別途請求されます。いずれの場合も、該当する場合は「この 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
*に依存します(例: 「us
」)アプリが米国でホストされている場合は、
一方、このアプリケーションや他の関連する移行 Codelab を続行せず、すべてを完全に削除したい場合は、プロジェクトをシャットダウンします。
この Codelab のみ
以下に示すサービスは、この Codelab に固有のものです。詳細については、各プロダクトのドキュメントをご覧ください。
- App Engine Blobstore サービスは保存データの割り当てと上限の対象になるため、この点と以前のバンドル サービスの料金ページをご確認ください。
- App Engine Datastore サービスは Cloud Datastore(Datastore モードの Cloud Firestore)で提供しており、これにも無料枠があります。詳しくは料金ページをご覧ください。
次のステップ
次に検討すべき論理的な移行については、モジュール 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(START)とモジュール 15(FINISH)のリポジトリ フォルダへのリンクを示します。これらは、すべての App Engine Codelab 移行用のリポジトリからもアクセスでき、ZIP ファイルのクローンを作成したりダウンロードしたりできます。
Codelab | Python 2 | Python 3 |
モジュール 0 | なし | |
モジュール 15(この Codelab) | なし |
オンライン リソース
このチュートリアルに関連する可能性のあるオンライン リソースは次のとおりです。
App Engine
- App Engine Blobstore サービス
- App Engine 保存データの割り当てと上限
- App Engine ドキュメント
- Python 2 App Engine(スタンダード環境)ランタイム
- App Engine の Python 2 組み込みライブラリで Python 2 を使用する
- App Engine の料金と割り当てに関する情報
- 第 2 世代の App Engine プラットフォームのリリース(2018 年)
- 1 番目と 2 番目の比較をプラットフォームに
- レガシー ランタイムの長期サポート
- ドキュメント移行サンプル リポジトリ
- コミュニティ提供の移行サンプル リポジトリ
Google Cloud
- Google Cloud Platform での Python
- Google Cloud Python クライアント ライブラリ
- Google Cloud「Always Free」ティア
- Google Cloud SDK(gcloud コマンドライン ツール)
- Google Cloud に関するすべてのドキュメント
Python
- Django と Jinja2 のテンプレート システム
webapp2
ウェブ フレームワークwebapp2
ドキュメントwebapp2_extras
件のリンクwebapp2_extras
Jinja2 のドキュメント
動画
- サーバーレス移行ステーション
- Serverless Expeditions(サーバーレス探索)
- Google Cloud Tech に登録
- Google Developers に登録します。
ライセンス
この作業はクリエイティブ・コモンズの表示 2.0 汎用ライセンスにより使用許諾されています。