Come utilizzare l'archivio BLOB di App Engine (Modulo 15)

1. Panoramica

La serie di codelab Serverless Migration Station (tutorial pratici e self-service) e i video correlati mirano ad aiutare gli sviluppatori Google Cloud serverless a modernizzare le applicazioni guidandoli attraverso una o più migrazioni, principalmente abbandonando i servizi legacy. In questo modo le tue app saranno più portabili e avrai più opzioni e flessibilità, consentendoti di integrare e accedere a una gamma più ampia di prodotti Cloud e di eseguire più facilmente l'upgrade a release delle lingue più recenti. Pur concentrandosi inizialmente sui primi utenti di Cloud, principalmente sviluppatori di App Engine (ambiente standard), questa serie è sufficientemente ampia da includere altre piattaforme serverless come Cloud Functions e Cloud Run, o altrove, se applicabile.

Questo codelab del modulo 15 spiega come aggiungere l'utilizzo di App Engine blobstore all'app di esempio dal modulo 0. Dopodiché sarai pronto per eseguire la migrazione di tale utilizzo a Cloud Storage nel modulo 16.

Imparerai a utilizzare

  • Aggiungi utilizzo dell'API/libreria App Engine Blobstore
  • Archivia i caricamenti degli utenti sul servizio blobstore
  • Preparati per il passaggio successivo della migrazione a Cloud Storage

Che cosa ti serve

Sondaggio

Come utilizzerai questo tutorial?

Solo lettura Leggilo e completa gli esercizi

Come valuteresti la tua esperienza con Python?

Principiante Livello intermedio Eccellente

Come giudichi la tua esperienza di utilizzo dei servizi Google Cloud?

Principiante Livello intermedio Eccellente

2. Sfondo

Per eseguire la migrazione dall'API App Engine Blobstore, aggiungine l'utilizzo all'app App Engine ndb di base esistente dal Modulo 0. L'app di esempio mostra le dieci visite più recenti all'utente. Stiamo modificando l'app per richiedere all'utente finale di caricare un elemento (un file) corrispondente alla sua "visita". Se l'utente non vuole farlo, viene visualizzato un avviso "salta" . Indipendentemente dalla decisione dell'utente, nella pagina successiva viene visualizzato lo stesso output dell'app del Modulo 0 (e di molti altri moduli di questa serie). Con questa integrazione di App Engine blobstore implementata, possiamo eseguirne la migrazione a Cloud Storage nel prossimo codelab (Modulo 16).

App Engine fornisce l'accesso ai sistemi di modelli Django e Jinja2 e una cosa che rende questo esempio diverso (oltre ad aggiungere l'accesso a Blobstore) è che passa dall'utilizzo di Django nel Modulo 0 a Jinja2 qui nel Modulo 15. Un passaggio fondamentale nella modernizzazione delle app di App Engine è la migrazione dei framework web da webapp2 a Flask. Quest'ultimo utilizza Jinja2 come sistema di modelli predefinito, quindi iniziamo a muoverci in quella direzione implementando Jinja2 rimanendo su webapp2 per l'accesso al Blobstore. Poiché Flask utilizza Jinja2 per impostazione predefinita, nel modulo 16 non saranno necessarie modifiche al modello.

3. Configurazione/pre-lavoro

Prima di passare alla parte principale del tutorial, configura il progetto, recupera il codice ed esegui il deployment dell'app di base per iniziare a lavorare con il codice.

1. Configura il progetto

Se hai già eseguito il deployment dell'app Modulo 0, ti consigliamo di riutilizzare lo stesso progetto (e lo stesso codice). In alternativa, puoi creare un nuovo progetto o riutilizzare un altro progetto esistente. Assicurati che il progetto abbia un account di fatturazione attivo e che App Engine sia abilitato.

2. Ottieni app di esempio di riferimento

Uno dei prerequisiti di questo codelab è avere un'app di esempio del Modulo 0 funzionante. Se non lo hai, puoi recuperarlo dal modulo 0 "START" cartella (link sotto). Questo codelab ti guida in ogni passaggio e si conclude con un codice simile a quello presente nel modulo 15 "FINISH" .

La directory dei file STARTing del modulo 0 dovrebbe essere simile alla seguente:

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

3. (Ri)Esegui il deployment dell'app di riferimento

I passaggi preliminari rimanenti da eseguire ora:

  1. Acquisisci familiarità con lo strumento a riga di comando gcloud
  2. Esegui di nuovo il deployment dell'app di esempio con gcloud app deploy
  3. Verifica che l'app venga eseguita su App Engine senza problemi

Dopo aver eseguito correttamente questi passaggi e aver verificato che la tua applicazione web funziona (con output simile all'output riportato di seguito), puoi iniziare ad aggiungere l'utilizzo della memorizzazione nella cache alla tua app.

a7a9d2b80d706a2b.png

4. Aggiorna i file di configurazione

app.yaml

Non sono state apportate modifiche sostanziali alla configurazione dell'applicazione. Tuttavia, come accennato in precedenza, stiamo passando dal modello Django (predefinito) a Jinja2, quindi per effettuare il passaggio, gli utenti devono specificare l'ultima versione di Jinja2 disponibile sui server App Engine e puoi farlo aggiungendola alla sezione delle librerie di terze parti integrate di app.yaml.

PRIMA:

runtime: python27
threadsafe: yes
api_version: 1

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

Modifica il tuo file app.yaml aggiungendo una nuova sezione libraries come quella mostrata qui:

DOPO:

runtime: python27
threadsafe: yes
api_version: 1

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

libraries:
- name: jinja2
  version: latest

Nessun altro file di configurazione deve essere aggiornato, quindi passiamo ai file dell'applicazione.

5. Modifica i file delle applicazioni

Importazioni e supporto di Jinja2

Il primo insieme di modifiche per main.py include l'aggiunta dell'utilizzo dell'API Blobstore e la sostituzione del modello Django con Jinja2. Ecco cosa cambierà:

  1. Lo scopo del modulo os è creare un percorso file per un modello Django. Dal momento che stiamo passando a Jinja2, dove viene gestito questo aspetto, l'uso di os e del renderer del modello di Django, google.appengine.ext.webapp.template, non sono più necessari, perciò verranno rimossi.
  2. Importa l'API Blobstore: google.appengine.ext.blobstore
  3. Importa i gestori dell'archivio BLOB presenti nel framework webapp originale, che non sono disponibili in webapp2: google.appengine.ext.webapp.blobstore_handlers
  4. Importa il supporto Jinja2 dal pacchetto webapp2_extras

PRIMA:

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

Implementare le modifiche nell'elenco precedente sostituendo la sezione di importazione corrente in main.py con lo snippet di codice riportato di seguito.

DOPO:

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

Dopo le importazioni, aggiungi del codice boilerplate per supportare l'uso di Jinja2 come definito nei documenti di webapp2_extras. Il seguente snippet di codice unisce la classe standard del gestore di richieste webapp2 con la funzionalità Jinja2, quindi aggiungi questo blocco di codice a main.py subito dopo le importazioni:

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))

Aggiungi supporto Blobstore

A differenza di altre migrazioni di questa serie in cui manteniamo la funzionalità o l'output dell'app di esempio identica (o quasi uguale) senza (molte) modifiche all'esperienza utente, questo esempio si discosta più radicalmente dalla norma. Anziché registrare immediatamente una nuova visita e visualizzare le dieci più recenti, stiamo aggiornando l'app per chiedere all'utente un elemento del file per registrare la sua visita. Gli utenti finali possono quindi caricare un file corrispondente o selezionare "Salta" per non caricare nulla. Una volta completato questo passaggio, le "visite più recenti" .

Questa modifica consente alla nostra app di utilizzare il servizio Blobstore per archiviare (ed eventualmente eseguire il rendering in un secondo momento) dell'immagine o di un altro tipo di file nella pagina delle visite più recenti.

Aggiornare il modello dei dati e implementarne l'utilizzo

Stiamo archiviando più dati, in particolare aggiornando il modello dei dati per archiviare l'ID (denominato "BlobKey") del file caricato in Blobstore e aggiungendo un riferimento per salvare il file in store_visit(). Poiché questi dati aggiuntivi vengono restituiti insieme a tutti gli altri dati al momento della query, fetch_visits() rimane invariato.

Ecco i prima e dopo questi aggiornamenti con file_blob, un ndb.BlobKeyProperty:

PRIMA:

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)

DOPO:

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)

Ecco una rappresentazione grafica delle modifiche apportate finora:

2270783776759f7f.png

Supporta i caricamenti di file

Il cambiamento più significativo nella funzionalità è il supporto del caricamento di file, che si tratti di richiedere all'utente un file o di "ignorare" l'azione o eseguire il rendering di un file corrispondente a una visita. Tutto fa parte del quadro. Queste sono le modifiche necessarie per supportare i caricamenti di file:

  1. La richiesta GET del gestore principale non recupera più le visite più recenti per la visualizzazione. ma richiede all'utente di eseguire un caricamento.
  2. Quando un utente finale invia un file da caricare o ignora questo processo, un POST del modulo passa il controllo al nuovo UploadHandler, derivato da google.appengine.ext.webapp.blobstore_handlers.BlobstoreUploadHandler.
  3. Il metodo POST di UploadHandler esegue il caricamento, chiama store_visit() per registrare la visita e attiva un reindirizzamento HTTP 307 per rimandare l'utente a "/", dove...
  4. Il metodo POST del gestore principale esegue una query (tramite fetch_visits()) e mostra le visite più recenti. Se l'utente seleziona "Salta", non viene caricato alcun file, ma la visita viene comunque registrata, seguita dallo stesso reindirizzamento.
  5. La visualizzazione delle visite più recenti include un nuovo campo mostrato all'utente, la "vista" collegata tramite link ipertestuale. se un file di caricamento è disponibile o se è impostato su "nessuno" negli altri casi. Queste modifiche vengono apportate nel modello HTML con l'aggiunta di un modulo di caricamento (ulteriori informazioni saranno disponibili a breve).
  6. Se un utente finale fa clic sul pulsante "Visualizza" per ogni visita con un video caricato, viene effettuata una richiesta GET a un nuovo ViewBlobHandler, derivato da google.appengine.ext.webapp.blobstore_handlers.BlobstoreDownloadHandler, eseguendo il rendering del file se si tratta di un'immagine (nel browser se supportata), la richiesta di download in caso contrario o la restituzione di un errore HTTP 404 se non trovata.
  7. Oltre alla nuova coppia di classi di gestori e a una nuova coppia di route a cui inviare il traffico, il gestore principale ha bisogno di un nuovo metodo POST per ricevere il reindirizzamento 307 descritto sopra.

Prima di questi aggiornamenti, l'app Modulo 0 presentava solo un gestore principale con un metodo GET e una singola route:

PRIMA:

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)

Con questi aggiornamenti implementati, ora ci sono tre gestori: 1) gestore del caricamento con un metodo POST, 2) "visualizza blob" gestore dei download con un metodo GET e 3) il gestore principale con i metodi GET e POST. Apporta queste modifiche in modo che il resto dell'app abbia ora l'aspetto seguente.

DOPO:

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)

Il codice che abbiamo appena aggiunto include diverse chiamate chiave:

  • Tra MainHandler.get, c'è una chiamata al numero blobstore.create_upload_url. Questa chiamata genera l'URL del modulo POST, chiamando il gestore di caricamento per inviare il file a Blobstore.
  • Tra UploadHandler.post, c'è una chiamata al numero blobstore_handlers.BlobstoreUploadHandler.get_uploads. Questa è la vera magia che inserisce il file in Blobstore e restituisce un ID univoco e permanente per il file, il suo BlobKey.
  • In ViewBlobHandler.get, la chiamata di blobstore_handlers.BlobstoreDownloadHandler.send con il valore BlobKey di un file comporta il recupero del file e l'inoltro al browser dell'utente finale

Queste chiamate rappresentano la maggior parte dell'accesso alle funzionalità aggiunte all'app. Ecco una rappresentazione grafica di questa seconda e ultima serie di modifiche a main.py:

da2960525ac1b90d.png

Aggiorna modello HTML

Alcuni degli aggiornamenti dell'applicazione principale influiscono sull'interfaccia utente (UI), pertanto le corrispondenti modifiche sono necessarie nel modello web. Di fatto, due sono:

  1. È richiesto un modulo di caricamento file con tre elementi di input: un file e una coppia di pulsanti di invio rispettivamente per caricare e saltare i file.
  2. Aggiorna l'output delle visite più recenti aggiungendo una "vista" link per le visite con un caricamento file corrispondente o "nessuno" negli altri casi.

PRIMA:

<!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>

Implementare le modifiche nell'elenco precedente per comprendere il modello aggiornato:

DOPO:

<!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>

Questa immagine illustra gli aggiornamenti richiesti per index.html:

8583e975f25aa9e7.png

Un'ultima modifica è che Jinja2 preferisce i propri modelli in una cartella templates, quindi crea questa cartella e sposta index.html al suo interno. Con questo ultimo spostamento, hai completato tutte le modifiche necessarie per aggiungere l'utilizzo di Blobstore all'app di esempio del Modulo 0.

(Facoltativo) "miglioramento" di Cloud Storage

Lo spazio di archiviazione Blobstore si è evoluto in Cloud Storage. Ciò significa che i caricamenti dell'archivio BLOB sono visibili nella console Cloud, in particolare nel browser di Cloud Storage. La domanda è dove. La risposta è il bucket Cloud Storage predefinito dell'app App Engine. Il nome è il nome del nome di dominio completo della tua app App Engine, PROJECT_ID.appspot.com. È così pratico perché tutti gli ID progetto sono univoci, giusto?

Gli aggiornamenti apportati all'applicazione di esempio eliminano i file caricati nel bucket, ma gli sviluppatori hanno la possibilità di scegliere una posizione più specifica. Il bucket predefinito è accessibile in modo programmatico tramite google.appengine.api.app_identity.get_default_gcs_bucket_name(), il che richiede una nuova importazione per accedere a questo valore, ad esempio per utilizzarlo come prefisso per organizzare i file caricati. Ad esempio, ordinando per tipo di file:

f61f7a23a1518705.png

Ad esempio, per implementare qualcosa di simile per le immagini, avrete un codice simile a questo insieme ad alcuni codici che controllano i tipi di file per scegliere il nome del bucket desiderato:

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

Convaliderai anche le immagini caricate utilizzando uno strumento come il modulo imghdr della libreria standard di Python per confermare il tipo di immagine. Infine, ti consigliamo di limitare le dimensioni dei caricamenti in caso di utenti malintenzionati.

Diciamo che abbiamo fatto tutto. Come possiamo aggiornare l'app in modo che supporti la specifica dove archiviare i file caricati? La chiave è modificare la chiamata a blobstore.create_upload_url in MainHandler.get per specificare la posizione desiderata in Cloud Storage per il caricamento aggiungendo il parametro gs_bucket_name in questo modo:

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

Poiché si tratta di un aggiornamento facoltativo, se vuoi specificare dove devono essere inviati i caricamenti, non fa parte del file main.py nel repository. Nel repository è disponibile un'alternativa denominata main-gcs.py che puoi esaminare. Anziché utilizzare una "cartella" di bucket separata, Il codice in main-gcs.py archivia i caricamenti nella directory "principale" (PROJECT_ID.appspot.com) come main.py, ma fornisce lo scaffolding necessario se dovessi ricavare il campione in qualcosa di più, come suggerito in questa sezione. Di seguito è riportata un'illustrazione delle "differenze" tra main.py e main-gcs.py.

256e1ea68241a501.png

6. Riepilogo/Pulizia

In questa sezione si conclude questo codelab eseguendo il deployment dell'app e verificando che funzioni come previsto e in qualsiasi output riportato. Dopo la convalida dell'app, esegui tutti i passaggi di pulizia e considera i passaggi successivi.

Esegui il deployment e verifica l'applicazione

Esegui di nuovo il deployment dell'app con gcloud app deploy e conferma che l'app funzioni come pubblicizzato, in modo che l'esperienza utente (UX) differisca da quella dell'app Modulo 0. Ora la tua app presenta due diverse schermate, la prima è la richiesta di caricamento del modulo di visita del file:

f5b5f9f19d8ae978.pngDa qui, gli utenti finali caricano un file e fanno clic su "Invia" oppure fai clic su "Salta" per non caricare nulla. In entrambi i casi, il risultato è la schermata della visita più recente, ora arricchita con "visualizzazione" link o "nessuno" tra timestamp della visita e informazioni del visitatore:

f5ac6b98ee8a34cb.png

Complimenti per aver completato questo codelab quando hai aggiunto l'utilizzo del Blobstore di App Engine all'app di esempio del Modulo 0. Ora il codice dovrebbe corrispondere al contenuto della cartella FINISH (Modulo 15). In questa cartella è presente anche l'alternativa main-gcs.py.

Esegui la pulizia

Generale

Se per il momento hai finito, ti consigliamo di disabilitare l'app App Engine per evitare di incorrere in fatturazione. Tuttavia, se desideri eseguire altri test o sperimentarli, la piattaforma App Engine ha una quota senza costi, pertanto, se non superi il livello di utilizzo, non ti verrà addebitato alcun costo. Questo riguarda il computing, ma potrebbero essere addebitati costi anche per i servizi App Engine pertinenti, quindi consulta la pagina dei prezzi per ulteriori informazioni. Se la migrazione coinvolge altri servizi Cloud, questi vengono fatturati separatamente. In entrambi i casi, se applicabile, consulta la sezione "Specifici di questo codelab" di seguito.

Per garantire la piena divulgazione, il deployment su una piattaforma di serverless computing di Google Cloud come App Engine comporta costi minori di build e archiviazione. Cloud Build e Cloud Storage hanno una quota senza costi specifica. Lo spazio di archiviazione dell'immagine esaurisce una parte della quota. Tuttavia, potresti risiedere in una regione che non ha un livello senza costi, quindi tieni presente l'utilizzo dello spazio di archiviazione per ridurre al minimo i costi potenziali. "cartelle" specifiche di Cloud Storage da esaminare includono:

  • 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
  • I link allo spazio di archiviazione riportati sopra dipendono dalla tua PROJECT_ID e dalla tua *LOC*azione, ad esempio "us" se la tua app è ospitata negli Stati Uniti.

Se invece non intendi continuare con questa applicazione o con altri codelab di migrazione correlati e vuoi eliminare tutto completamente, chiudi il progetto.

Specifico di questo codelab

I servizi elencati di seguito sono esclusivi per questo codelab. Per ulteriori informazioni, fai riferimento alla documentazione di ciascun prodotto:

Passaggi successivi

La prossima migrazione logica da considerare è trattata nel modulo 16, che mostra agli sviluppatori come eseguire la migrazione dal servizio Blobstore di App Engine all'utilizzo della libreria client di Cloud Storage. I vantaggi dell'upgrade includono la possibilità di accedere a più funzionalità di Cloud Storage, acquisire familiarità con una libreria client che funziona per le app al di fuori di App Engine, in Google Cloud, in altri cloud o anche on-premise. Se non ritieni di aver bisogno di tutte le funzionalità disponibili in Cloud Storage o ti preoccupano gli effetti sui costi, puoi continuare a utilizzare App Engine Blobstore.

Oltre il Modulo 16 ci sono tutta una serie di altre possibili migrazioni come Cloud NDB e Cloud Datastore, Cloud Tasks o Cloud Memorystore. Esistono anche migrazioni tra prodotti a Cloud Run e Cloud Functions. Il repository per la migrazione contiene tutti gli esempi di codice, ti rimanda a tutti i codelab e i video disponibili e fornisce anche indicazioni sulle migrazioni da prendere in considerazione e su eventuali "ordini" pertinenti. delle migrazioni.

7. Risorse aggiuntive

Problemi/feedback del codelab

Se riscontri problemi con questo codelab, cercali prima di procedere con l'invio. Link per eseguire ricerche e creare nuovi problemi:

Risorse di migrazione

I link alle cartelle repository per il modulo 0 (START) e il modulo 15 (FINISH) sono disponibili nella tabella seguente. Sono inoltre accessibili dal repository per tutte le migrazioni del codelab di App Engine che puoi clonare o scaricare un file ZIP.

Codelab

Python 2

Python 3

Modulo 0

codice

N/A

Modulo 15 (questo codelab)

codice

N/A

Risorse online

Di seguito sono riportate alcune risorse online che potrebbero essere pertinenti per questa esercitazione:

App Engine

Google Cloud

Python

Video

Licenza

Questo lavoro è concesso in licenza ai sensi di una licenza Creative Commons Attribution 2.0 Generic.