1. Visão geral
A série de codelabs da Estação de migração sem servidor (tutoriais práticos e autoguiados) e os vídeos relacionados têm como objetivo ajudar os desenvolvedores sem servidor do Google Cloud a modernizar os aplicativos, orientando-os em uma ou mais migrações, principalmente para deixar de usar serviços legados. Isso torna seus apps mais portáteis e oferece mais opções e flexibilidade, permitindo que você se integre e acesse uma variedade maior de produtos do Cloud e faça upgrade para versões mais recentes de linguagens com mais facilidade. Embora o foco inicial seja nos primeiros usuários do Cloud, principalmente desenvolvedores do App Engine (ambiente padrão), esta série é ampla o suficiente para incluir outras plataformas sem servidor, como o Cloud Functions e o Cloud Run, ou outras, se aplicável.
Este codelab do Módulo 15 explica como adicionar o uso do blobstore do App Engine ao app de exemplo do Módulo 0. Depois, você vai estar pronto para migrar esse uso para o Cloud Storage no Módulo 16.
Você vai aprender a
- Adicionar o uso da API/biblioteca do Blobstore do App Engine
- Armazenar uploads de usuários no serviço
blobstore - Preparar-se para a próxima etapa de migração para o Cloud Storage
O que é necessário
- Um projeto do Google Cloud Platform com uma conta de faturamento ativa do GCP
- Habilidades básicas em Python
- Conhecimento prático de comandos comuns do Linux
- Conhecimento básico de desenvolvimento e implantação de apps do App Engine
- Um aplicativo do App Engine do módulo 0 em funcionamento (disponível no repositório)
Pesquisa
Como você vai usar este tutorial?
Como você classificaria sua experiência com Python?
Como você classificaria sua experiência de uso dos serviços do Google Cloud?
2. Contexto
Para migrar da API Blobstore do App Engine, adicione o uso dela ao aplicativo de referência do App Engine ndb do módulo 0. O app de exemplo mostra as dez visitas mais recentes ao usuário. Estamos modificando o app para solicitar que o usuário final faça upload de um artefato (um arquivo) que corresponda à "visita". Se o usuário não quiser fazer isso, há uma opção "pular". Independentemente da decisão do usuário, a próxima página renderiza a mesma saída do app do Módulo 0 (e de muitos outros módulos desta série). Com essa integração do blobstore do App Engine implementada, podemos migrá-la para o Cloud Storage no próximo codelab (Módulo 16).
O App Engine fornece acesso aos sistemas de modelos Django e Jinja2. Uma coisa que diferencia este exemplo (além de adicionar o acesso ao Blobstore) é que ele muda o uso do Django no módulo 0 para o Jinja2 aqui no módulo 15. Uma etapa importante na modernização de apps do App Engine é migrar os frameworks da Web de webapp2 para o Flask. O último usa o Jinja2 como sistema de modelos padrão. Por isso, começamos a migrar nessa direção implementando o Jinja2 e permanecendo no webapp2 para acesso ao Blobstore. Como o Flask usa o Jinja2 por padrão, isso significa que não será necessário fazer mudanças no modelo no Módulo 16.
3. Configuração/Pré-trabalho
Antes de passarmos para a parte principal do tutorial, configure seu projeto, acesse o código e implante o app de referência para começar com o código de trabalho.
1. Configurar projeto
Se você já implantou o app do Módulo 0, recomendamos reutilizar o mesmo projeto (e código). Como alternativa, é possível criar um projeto totalmente novo ou reutilizar outro projeto. Verifique se o projeto tem uma conta de faturamento ativa e se o App Engine está ativado.
2. Receber app de amostra do valor de referência
Um dos pré-requisitos para este codelab é ter um app de exemplo do Módulo 0 em funcionamento. Se você não tiver, faça o download na pasta "START" do Módulo 0 (link abaixo). Este codelab mostra cada etapa, concluindo com um código semelhante ao que está na pasta "FINISH" do módulo 15.
- START: pasta Module 0 (Python 2)
- FINAL: pasta do módulo 15 (Python 2)
- Repositório inteiro (para clonar ou baixar o arquivo ZIP)
O diretório de arquivos START do Módulo 0 deve ficar assim:
$ ls README.md index.html app.yaml main.py
3. (Re) Implantar aplicativo de referência
As etapas de pré-trabalho restantes para serem executadas agora:
- Conheça melhor a ferramenta de linha de comando
gcloud - Reimplantar o aplicativo de amostra com
gcloud app deploy - Confirmar se o aplicativo é executado no App Engine sem problemas
Depois de executar essas etapas e ver que o app da Web funciona (com uma saída semelhante à mostrada abaixo), você vai poder adicionar o uso de armazenamento em cache ao app.

4. Atualizar os arquivos de configuração
app.yaml
Não há mudanças significativas na configuração do aplicativo. No entanto, como mencionado anteriormente, estamos migrando dos modelos do Django (padrão) para o Jinja2. Portanto, para fazer a troca, os usuários precisam especificar a versão mais recente do Jinja2 disponível nos servidores do App Engine. Para isso, adicione-a à seção de bibliotecas de terceiros integradas do app.yaml.
ANTES:
runtime: python27
threadsafe: yes
api_version: 1
handlers:
- url: /.*
script: main.app
Edite o arquivo app.yaml adicionando uma nova seção libraries, como mostrado aqui:
DEPOIS:
runtime: python27
threadsafe: yes
api_version: 1
handlers:
- url: /.*
script: main.app
libraries:
- name: jinja2
version: latest
Não é necessário atualizar outros arquivos de configuração. Vamos passar para os arquivos do aplicativo.
5. Modificar arquivos do aplicativo
Importações e suporte ao Jinja2
O primeiro conjunto de mudanças para main.py inclui adicionar o uso da API Blobstore e substituir os modelos do Django pelo Jinja2. Confira o que vai mudar:
- O objetivo do módulo
osé criar um caminho de arquivo para um modelo do Django. Como estamos mudando para o Jinja2, em que isso é processado, o uso deose do renderizador de modelos do Django,google.appengine.ext.webapp.template, não é mais necessário, então eles estão sendo removidos. - Importe a API Blobstore:
google.appengine.ext.blobstore - Importe os gerenciadores do Blobstore encontrados no framework
webapporiginal, que não estão disponíveis emwebapp2:google.appengine.ext.webapp.blobstore_handlers - Importe o suporte ao Jinja2 do pacote
webapp2_extras
ANTES:
import os
import webapp2
from google.appengine.ext import ndb
from google.appengine.ext.webapp import template
Implemente as mudanças na lista acima substituindo a seção de importação atual em main.py pelo snippet de código abaixo.
DEPOIS:
import webapp2
from webapp2_extras import jinja2
from google.appengine.ext import blobstore, ndb
from google.appengine.ext.webapp import blobstore_handlers
Depois das importações, adicione um código boilerplate para permitir o uso do Jinja2, conforme definido na documentação do webapp2_extras. O snippet de código a seguir une a classe de gerenciador de solicitações padrão do webapp2 com a funcionalidade do Jinja2. Portanto, adicione esse bloco de código a main.py logo após as importações:
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))
Adicionar suporte ao Blobstore
Ao contrário de outras migrações desta série, em que mantemos a funcionalidade ou a saída do app de exemplo idêntica (ou quase igual) sem (muitas) mudanças na UX, este exemplo se afasta mais radicalmente da norma. Em vez de registrar imediatamente uma nova visita e exibir as dez mais recentes, estamos atualizando o app para pedir ao usuário um artefato de arquivo para registrar a visita. Os usuários finais podem fazer upload de um arquivo correspondente ou selecionar "Pular" para não fazer upload de nada. Depois que essa etapa for concluída, a página "Visitas mais recentes" será exibida.
Essa mudança permite que nosso app use o serviço Blobstore para armazenar (e possivelmente renderizar mais tarde) essa imagem ou outro tipo de arquivo na página de visitas mais recentes.
Atualizar o modelo de dados e implementar o uso dele
Estamos armazenando mais dados, especificamente atualizando o modelo de dados para armazenar o ID (chamado de "BlobKey") do arquivo enviado para o Blobstore e adicionando uma referência para salvar isso em store_visit(). Como esses dados extras são retornados com todo o restante na consulta, fetch_visits() permanece o mesmo.
Confira o antes e o depois dessas atualizações com file_blob, um ndb.BlobKeyProperty:
ANTES:
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)
DEPOIS:
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)
Confira uma representação gráfica das mudanças feitas até agora:

Suporte a uploads de arquivos
A mudança mais significativa na funcionalidade é o suporte a uploads de arquivos, seja solicitando um arquivo ao usuário, oferecendo suporte ao recurso "pular" ou renderizando um arquivo correspondente a uma visita. Tudo isso faz parte do cenário. Estas são as mudanças necessárias para oferecer suporte ao upload de arquivos:
- A solicitação do gerenciador principal
GETnão busca mais as visitas mais recentes para exibição. Em vez disso, ele solicita que o usuário faça um upload. - Quando um usuário final envia um arquivo para upload ou pula esse processo, um
POSTdo formulário passa o controle para o novoUploadHandler, derivado degoogle.appengine.ext.webapp.blobstore_handlers.BlobstoreUploadHandler. - O método
POSTdeUploadHandlerrealiza o upload, chamastore_visit()para registrar a visita e aciona um redirecionamento HTTP 307 para enviar o usuário de volta para "/", onde... - O método
POSTdo gerenciador principal consulta (viafetch_visits()) e mostra as visitas mais recentes. Se o usuário selecionar "pular", nenhum arquivo será enviado, mas a visita ainda será registrada, seguida pelo mesmo redirecionamento. - A exibição das visitas mais recentes inclui um novo campo para o usuário, que pode ser um hiperlink "visualizar" se um arquivo de upload estiver disponível ou "nenhum" caso contrário. Essas mudanças são realizadas no modelo HTML, junto com a adição de um formulário de upload (mais informações em breve).
- Se um usuário final clicar no link "visualizar" de qualquer visita com um vídeo enviado, ele vai fazer uma solicitação
GETpara um novoViewBlobHandler, derivado degoogle.appengine.ext.webapp.blobstore_handlers.BlobstoreDownloadHandler, renderizando o arquivo se for uma imagem (no navegador, se houver suporte), solicitando o download se não for ou retornando um erro HTTP 404 se não for encontrado. - Além do novo par de classes de gerenciador e do novo par de rotas para enviar tráfego a elas, o gerenciador principal precisa de um novo método
POSTpara receber o redirecionamento 307 descrito acima.
Antes dessas atualizações, o app do Módulo 0 apresentava apenas um gerenciador principal com um método GET e uma única rota:
ANTES:
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)
Com essas atualizações implementadas, agora há três gerenciadores: 1) gerenciador de upload com um método POST, 2) um gerenciador de download "view blob" com um método GET e 3) o gerenciador principal com os métodos GET e POST. Faça essas mudanças para que o restante do app fique como mostrado abaixo.
DEPOIS:
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)
Há várias chamadas importantes no código que acabamos de adicionar:
- Em
MainHandler.get, há uma chamada parablobstore.create_upload_url. Essa chamada gera o URL para o qual o formulário faz oPOST, chamando o gerenciador de upload para enviar o arquivo ao Blobstore. - Em
UploadHandler.post, há uma chamada parablobstore_handlers.BlobstoreUploadHandler.get_uploads. Essa é a verdadeira mágica que coloca o arquivo no Blobstore e retorna um ID exclusivo e persistente para ele, oBlobKey. - Em
ViewBlobHandler.get, chamarblobstore_handlers.BlobstoreDownloadHandler.sendcom oBlobKeyde um arquivo resulta na busca do arquivo e no encaminhamento dele para o navegador do usuário final.
Essas chamadas representam a maior parte do acesso aos recursos adicionados ao app. Veja uma representação gráfica desse segundo e último conjunto de mudanças em main.py:

Atualizar modelo HTML
Algumas das atualizações no aplicativo principal afetam a interface do usuário (IU) do app. Por isso, é necessário fazer duas mudanças correspondentes no modelo da Web:
- É necessário um formulário de upload de arquivo com três elementos de entrada: um arquivo e um par de botões de envio para upload e para pular, respectivamente.
- Atualize a saída das visitas mais recentes adicionando um link "visualizar" para visitas com um upload de arquivo correspondente ou "nenhum" caso contrário.
ANTES:
<!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>
Implemente as mudanças na lista acima para incluir o modelo atualizado:
DEPOIS:
<!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>
Esta imagem ilustra as atualizações necessárias para index.html:

Uma última mudança é que o Jinja2 prefere os modelos em uma pasta templates. Portanto, crie essa pasta e mova index.html para dentro dela. Com essa última etapa, você concluiu todas as mudanças necessárias para adicionar o uso do Blobstore ao app de exemplo do módulo 0.
(opcional) "Melhoria" do Cloud Storage
O armazenamento do Blobstore acabou evoluindo para o próprio Cloud Storage. Isso significa que os uploads do Blobstore ficam visíveis no console do Cloud, especificamente no navegador do Cloud Storage. A questão é onde. A resposta é o bucket padrão do Cloud Storage do seu app do App Engine. O nome dele é o nome de domínio completo do seu app do App Engine, PROJECT_ID.appspot.com. É muito conveniente porque todos os IDs do projeto são exclusivos, certo?
As atualizações feitas no aplicativo de exemplo colocam os arquivos enviados nesse bucket, mas os desenvolvedores podem escolher um local mais específico. O bucket padrão é acessível de forma programática por meio de google.appengine.api.app_identity.get_default_gcs_bucket_name(), exigindo uma nova importação se você quiser acessar esse valor, por exemplo, para usar como prefixo para organizar arquivos enviados. Por exemplo, classificação por tipo de arquivo:

Para implementar algo assim para imagens, por exemplo, você terá um código como este, além de um código que verifica os tipos de arquivo para escolher o nome do bucket desejado:
ROOT_BUCKET = app_identity.get_default_gcs_bucket_name()
IMAGE_BUCKET = '%s/%s' % (ROOT_BUCKET, 'images')
Você também vai validar as imagens enviadas usando uma ferramenta como o módulo imghdr da biblioteca padrão do Python para confirmar o tipo de imagem. Por fim, é recomendável limitar o tamanho dos uploads para evitar usuários mal-intencionados.
Digamos que tudo isso já tenha sido feito. Como podemos atualizar nosso app para que ele permita especificar onde os arquivos enviados serão armazenados? O segredo é ajustar a chamada para blobstore.create_upload_url em MainHandler.get para especificar o local desejado no Cloud Storage para o upload adicionando o parâmetro gs_bucket_name desta forma:
blobstore.create_upload_url('/upload', gs_bucket_name=IMAGE_BUCKET))
Como essa é uma atualização opcional para especificar onde os uploads devem ir, ela não faz parte do arquivo main.py no repositório. Em vez disso, uma alternativa chamada main-gcs.py está disponível para análise no repositório. Em vez de usar uma "pasta" de bucket separada, o código em main-gcs.py armazena uploads no bucket "raiz" (PROJECT_ID.appspot.com), assim como main.py, mas fornece o scaffolding necessário se você quiser derivar o exemplo em algo mais, como sugerido nesta seção. Veja abaixo uma ilustração das diferenças entre main.py e main-gcs.py.

6. Resumo/limpeza
Esta seção encerra o codelab com a implantação do app, a verificação de que ele funciona conforme o esperado e a análise de qualquer saída refletida. Após a validação do app, execute as etapas de limpeza e considere as próximas etapas.
Implantar e verificar o aplicativo
Implante o app novamente com gcloud app deploy e confirme se ele funciona conforme anunciado, com uma experiência do usuário (UX) diferente do app do Módulo 0. Agora há duas telas diferentes no app, sendo a primeira o prompt do formulário de upload de arquivos de visita:
Em seguida, os usuários finais fazem upload de um arquivo e clicam em "Enviar" ou em "Pular" para não fazer upload de nada. Em ambos os casos, o resultado é a tela de visita mais recente, agora com links de "visualização" ou "nenhum" entre os carimbos de data/hora da visita e as informações do visitante:

Parabéns por concluir este codelab e adicionar o uso do App Engine Blobstore ao app de exemplo do módulo 0. Seu código agora deve corresponder ao que está na pasta FINISH (módulo 15). A alternativa main-gcs.py também está presente nessa pasta.
Limpar
Geral
Se você já terminou, recomendamos que desative o aplicativo do App Engine para evitar cobranças. No entanto, se você quiser testar ou experimentar um pouco mais, a plataforma do App Engine tem uma cota sem custo financeiro. Portanto, desde que você não exceda esse nível de uso, não haverá cobranças. Isso é para computação, mas também pode haver cobranças por serviços relevantes do App Engine. Consulte a página de preços para mais informações. Se essa migração envolver outros serviços do Cloud, eles serão cobrados separadamente. Em ambos os casos, se aplicável, consulte a seção "Específico para este codelab" abaixo.
Para divulgação completa, a implantação em uma plataforma de computação sem servidor do Google Cloud, como o App Engine, gera pequenos custos de build e armazenamento. O Cloud Build tem uma cota sem custo financeiro, assim como o Cloud Storage. O armazenamento dessa imagem usa parte dessa cota. No entanto, talvez você more em uma região que não tenha esse nível sem custo financeiro. Por isso, fique de olho no uso do armazenamento para minimizar possíveis custos. As "pastas" específicas do Cloud Storage que você deve analisar incluem:
console.cloud.google.com/storage/browser/LOC.artifacts.PROJECT_ID.appspot.com/containers/imagesconsole.cloud.google.com/storage/browser/staging.PROJECT_ID.appspot.com- Os links de armazenamento acima dependem do seu
PROJECT_IDe da *LOC*ação, por exemplo, "us" se o app estiver hospedado nos EUA.
Por outro lado, se você não quiser continuar com este aplicativo ou outros codelabs de migração relacionados e quiser excluir tudo, encerre o projeto.
Específico para este codelab
Os serviços listados abaixo são exclusivos deste codelab. Consulte a documentação de cada produto para mais informações:
- O serviço Blobstore do App Engine está sujeito às cotas e limites de dados armazenados. Portanto, consulte essas informações e a página de preços dos serviços integrados legados.
- O serviço App Engine Datastore é fornecido pelo Cloud Datastore (Cloud Firestore no modo Datastore), que também tem um nível sem custo financeiro. Consulte a página de preços para mais informações.
Próximas etapas
A próxima migração lógica a ser considerada é abordada no Módulo 16, que mostra aos desenvolvedores como migrar do serviço Blobstore do App Engine para usar a biblioteca de cliente do Cloud Storage. Os benefícios do upgrade incluem o acesso a mais recursos do Cloud Storage e a familiarização com uma biblioteca de cliente que funciona para apps fora do App Engine, seja no Google Cloud, em outras nuvens ou até mesmo no local. Se você não precisa de todos os recursos disponíveis no Cloud Storage ou está preocupado com os efeitos dele no custo, pode continuar no Blobstore do App Engine.
Além do Módulo 16, há uma série de outras migrações possíveis, como o Cloud NDB e o Cloud Datastore, o Cloud Tasks ou o Cloud Memorystore. Também há migrações entre produtos para o Cloud Run e o Cloud Functions. O repositório de migração (link em inglês) apresenta todos os exemplos de código, links para todos os codelabs e vídeos disponíveis, além de orientações sobre quais migrações considerar e a "ordem" relevante delas.
7. Outros recursos
Problemas/feedback do codelab
Se você encontrar problemas com este codelab, pesquise seu problema antes de preenchê-lo. Links para pesquisar e criar novos problemas:
Recursos de migração
Os links para as pastas do repositório do Módulo 0 (START) e do Módulo 15 (FINISH) estão na tabela abaixo. Eles também podem ser acessados no repositório de todas as migrações de codelab do App Engine, em que é possível clonar ou fazer o download de um arquivo ZIP.
Codelab | Python 2 | Python 3 |
Module 0 | N/A | |
Módulo 15 (este codelab) | N/A |
Recursos on-line
Confira abaixo os recursos on-line que podem ser relevantes para este tutorial:
App Engine
- Serviço Blobstore do App Engine
- Cotas e limites de dados armazenados do App Engine
- Documentação do App Engine
- Ambiente de execução do Python 2 do App Engine (ambiente padrão)
- Como usar as bibliotecas integradas do App Engine no App Engine para Python 2
- Informações sobre preços e cotas do App Engine
- Lançamento da plataforma App Engine de segunda geração (2018)
- Comparação entre as plataformas de primeira e segunda geração
- Suporte de longo prazo para ambientes de execução legados
- Repositório de exemplos de migração de documentação
- Repositório de amostras de migração contribuídas pela comunidade
Google Cloud
- Python no Google Cloud Platform
- Bibliotecas de cliente do Python para Google Cloud
- Nível "Sempre sem custo financeiro" do Google Cloud
- SDK Google Cloud (ferramenta de linha de comando gcloud)
- Toda a documentação do Google Cloud
Python
- Sistemas de modelos Django e Jinja2
- Framework da Web
webapp2 - Documentação do
webapp2(link em inglês) webapp2_extraslinks- Documentação do Jinja2 para
webapp2_extras(em inglês)
Vídeos
- Estação de migração sem servidor
- Expedições sem servidor
- Inscreva-se no canal Google Cloud Tech
- Inscreva-se no Google Developers
Licença
Este conteúdo está sob a licença Atribuição 2.0 Genérica da Creative Commons.