1. Overview
The Bookshelf app is a sample web app written in Groovy with the Grails framework. It shows how to use a variety of Google Cloud Platform products, including:
-
Google Cloud SQL
-
Google Cloud Storage
-
Google Cloud Vision API
-
Google Cloud Translation API
This tutorial explores the Bookshelf app in detail, and discusses how each feature of the app is implemented using familiar technologies and services provided by Cloud Platform.
The Bookshelf sample app stores a collection of book titles. Anyone who has access to the app can add books to the list. The sample app offers these features:
-
Users can view the list of books
-
Users can remove books from the list.
-
Users can add books. They create books uploading a cover image, text int that image is detected and used to populate the book title.
-
Users can edit book metadata such as title, description, author or publication date.
-
A title and description for Spanish and Italian is generated on the fly.
-
Users can log in with their Google accounts and view the books that they have added to the list.
1.1. Objectives
-
Clone or download the sample app.
-
Build the app and run it on your local machine.
-
Deploy the app to App Engine.
-
Walk through the sample code.
-
Learn how the app stores structured data.
-
Learn how the app stores binary data in Google Cloud Storage.
-
Learn how the app uses Cloud Translate API to translate English text into Spanish and Italian
-
Learn how the app uses Cloud Vision to extract text from a cover image
-
Learn how the app authenticates users.
-
Learn how the app creates event logs that are visible in the Google Cloud Platform Console.
1.2. Costs
This tutorial uses billable components of Cloud Platform including Google Compute Engine.
This tutorial has several steps, and each step is documented on its own page. The final page of the tutorial includes instructions for cleaning up resources, so you won’t continue to be billed for Cloud Platform services. If you decide not to complete all the steps of the tutorial, see the cleanup instructions on the final page.
1.3. Before you begin
-
Use the Cloud Platform Console to set up your Google Cloud Platform project:
-
Create a new Cloud Platform project, and then create an App Engine application and enable billing in that project: GO TO APP ENGINE
-
Enable the Cloud Datastore, Cloud Pub/Sub, Cloud Storage JSON, Stackdriver Logging, and Google+ APIs. ENABLE THE APIS
-
-
To deploy a Java app to App Engine, you must first setup your environment, see Using Gradle and the App Engine Plugin for details.
-
Download, install, and initialize the Google Cloud SDK: DOWNLOAD THE SDK
-
Acquire local credentials for authenticating with Google Cloud Platform services:
gcloud beta auth application-default login
-
Verify that your default project is correct:
gcloud config list
If the project ID listed in the output is not the project that you intended to use for this tutorial, set the project by entering this command:
gcloud config set project [YOUR_PROJECT_ID]
where [YOUR_PROJECT_ID] is the ID of the project you created or chose to use for this tutorial. -
Clone the sample repository:
git clone https://github.com/grails-samples/google-bookshelf.git
You can create Google Cloud SDK configurations to set configuration properties like a Cloud Platform project ID, and then quickly switch between those configurations each time you use the gcloud tool, see Managing Cloud SDK Configurations for more information. |
Alternatively, you can download the sample as a zip file and extract it.
1.4. Tutorial structure
The Bookshelf tutorial has several parts that demonstrate how the sample app uses various Cloud Platform services and leverages Grails Features.
The Controllers and Views part demonstrates how the app uses Grails: Controllers, Command Objects and GSPs to generate the UI and handle interaction.
The Services part shows how Grails can interact with different Cloud Services.
-
This sample stores book information in a SQL database. The app’s web page displays a form where the user can enter the title, author, description, and publication date of a book. For each book entered, the app stores this information in a database, so it can be retrieved later for viewing or editing.
-
This sample app stores binary data in Cloud Storage. On the app’s web page, the user can specify a cover image for each book. The app stores the cover images in a Cloud Storage bucket.
-
This sample app uses Cloud Vision API to extract the text present on a book cover image.
-
This sample app uses Cloud Translation API to translate text from English into Spanish and Italian.
The authorization part of the tutorial demonstrates how the app provides a sign-in flow for the user. When a user is signed in, any books entered are associated with the individual user. Signed-in users see only their own books.
The logging part of the tutorial demonstrates how the app writes logs that become visible in the Google Cloud Platform Console. Logs of this type can provide diagnostic information during app development.
1.5. Running the app on your local machine
To run the app locally while you are developing:
-
Create a MySQL database and configure its url, username and password in
app/grails-app/conf/application.yml
-
In the root of the repository, enter this command to start a local web server:
./gradlew app:bootRun
-
In your web browser, navigate to http://localhost:8080
2. Deploying
2.1. Deploying the app to the App Engine flexible environment
-
Create a configuration file for production:
app/grails-app/conf/application-production.yml
See code listing below. -
Enter this command to deploy the app:
./gradlew app:appengineDeploy
-
In your web browser, enter this address. Replace
[YOUR_PROJECT_ID]
with your project ID:
https://[YOUR_PROJECT_ID].appspot-preview.com
bookshelf:
clientID: {CLIENTID}
clientSecret: {CLIENTSECRET}
callback: https://{PROJECT_ID}.appspot.com/oauth2callback
---
dataSource:
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
driverClassName: com.mysql.jdbc.Driver
dbCreate: update
url: jdbc:mysql://google/{DATABASENAME}?cloudSqlInstance={CLOUD_SQL_INSTANCENAME}&socketFactory=com.google.cloud.sql.mysql.SocketFactory&user={USERNAME}&password={PASSWORD}&useSSL=false
properties:
jmxEnabled: true
initialSize: 5
maxActive: 50
minIdle: 5
maxIdle: 25
maxWait: 10000
maxAge: 600000
timeBetweenEvictionRunsMillis: 5000
minEvictableIdleTimeMillis: 60000
validationQuery: SELECT 1
validationQueryTimeout: 3
validationInterval: 15000
testOnBorrow: true
testWhileIdle: true
testOnReturn: false
jdbcInterceptors: ConnectionState
defaultTransactionIsolation: 2 # TRANSACTION_READ_COMMITTED
replace the above placeholders with the values of your configuration. |
If you update your app, you can deploy the updated version by entering the same command you used to deploy the app the first time.
The new deployment creates a new version of your app and promotes it to the default version. The older versions of your app remain,
as do their associated VM instances. Be aware that all of these app versions and VM instances are billable resources.
You can reduce costs by deleting the non-default versions of your app.
To delete an app version:
-
In the Cloud Platform Console, go to the App Engine Versions page. GO TO THE VERSIONS PAGE
-
Click the checkbox next to the non-default app version you want to delete.
-
Click the Delete button at the top of the page to delete the app version.
Note: The only way to delete the default version of your App Engine app is to delete your project. You can, however, stop the default version in the Cloud Platform Console. This will shut down all instances associated with the version. You can restart these instances later if needed. You can overwrite the default version of your app by redeploying the app.
For complete information about cleaning up billable resources, see the Cleaning up section in the final step of this tutorial.
2.2. Deployment changes from a regular Grails App
We define the dependencies' versions used in the project in gradle.properties
grailsVersion=3.2.9
gormVersion=6.0.10.RELEASE
gradleWrapperVersion=3.4.1
coberturaPluginVersion=2.4.0
googleCloudDatastoreVersion=0.12.0-beta
googleCloudStorageVersion=0.13.0-beta
googleCloudTranslateVersion=0.13.0-beta
googleCloudVisionVersion=0.13.0-beta
googleApiClientVersion=1.22.0
googleHttpClientJacksonVersion=1.22.0
jacksonDatabindVersion=2.8.8
htmlunitVersion=2.18
seleniumHtmlunitDriver=2.47.1
assetPipelineGrailsVersion=2.14.1
appengineGradlePluginVersion=1.1.1
mysqlSocketFactoryConnectorJ6Version=1.0.2
mysqlConnectorJavaVersion=6.0.5
jfrogGradlePluginVersion=4.4.15
guavaVersion=19.0
groovyVersion=2.4.10
In order to deploy to App Engine Flexible, we need to add the buildscript dependency and apply the Google Cloud Gradle Plugin.
buildscript {
repositories {
mavenLocal()
maven { url "https://repo.grails.org/grails/core" }
}
dependencies {
classpath "org.grails:grails-gradle-plugin:$grailsVersion"
classpath "com.bertramlabs.plugins:asset-pipeline-gradle:$assetPipelineGrailsVersion"
classpath "org.grails.plugins:hibernate5:${gormVersion-".RELEASE"}"
classpath "com.google.cloud.tools:appengine-gradle-plugin:$appengineGradlePluginVersion" (1)
classpath "net.saliman:gradle-cobertura-plugin:$coberturaPluginVersion"
}
}
version "0.1"
group "app"
apply plugin:"eclipse"
apply plugin:"idea"
apply plugin:"war"
apply plugin:"org.grails.grails-web"
apply plugin:"org.grails.grails-gsp"
apply plugin:"asset-pipeline"
apply plugin: 'com.google.cloud.tools.appengine' (2)
apply from: "${rootProject.projectDir}/gradle/codenarc.gradle"
1 | Google Cloud Gradle Plugin buildscript dependency |
2 | Apply Google Cloud Gradle Plugin |
We need an AppEngine configuration file too:
runtime: java
env: flex
runtime_config:
jdk: openjdk8
server: jetty9
health_check:
enable_health_check: False
resources:
cpu: 1
memory_gb: 2.3
manual_scaling:
instances: 1
Since we are using Jetty we need to replace the compile dependency:
provided 'org.springframework.boot:spring-boot-starter-tomcat'
with:
provided 'org.springframework.boot:spring-boot-starter-jetty'
Moreover, we need to fix the Google Guava version
configurations.all {
resolutionStrategy {
force "com.google.guava:guava:$guavaVersion"
}
exclude group: 'com.google.guava', module: 'guava-jdk5'
}
We need to exclude tomcat-juli
dependency too:
configurations {
compile.exclude module: "tomcat-juli"
}
We highlight next the changes regarding dependencies
dependencies {
compile "org.springframework.boot:spring-boot-starter-logging"
compile "org.springframework.boot:spring-boot-autoconfigure"
compile "org.grails:grails-core"
compile "org.springframework.boot:spring-boot-starter-actuator"
provided "org.springframework.boot:spring-boot-starter-jetty" (1)
compile "org.grails:grails-dependencies"
compile "org.grails:grails-web-boot"
compile "org.grails.plugins:cache"
compile "org.grails.plugins:scaffolding"
compile "org.grails.plugins:hibernate5"
compile "org.hibernate:hibernate-core:5.1.3.Final"
compile "org.hibernate:hibernate-ehcache:5.1.3.Final"
console "org.grails:grails-console"
profile "org.grails.profiles:web"
runtime "com.bertramlabs.plugins:asset-pipeline-grails:$assetPipelineGrailsVersion"
testRuntime "com.h2database:h2"
testCompile "org.grails:grails-plugin-testing"
testCompile "org.grails.plugins:geb"
testRuntime "org.seleniumhq.selenium:selenium-htmlunit-driver:$seleniumHtmlunitDriver"
testRuntime "net.sourceforge.htmlunit:htmlunit:$htmlunitVersion"
testCompile "org.grails:grails-datastore-rest-client"
runtime "mysql:mysql-connector-java:$mysqlConnectorJavaVersion" (2)
runtime "com.google.cloud.sql:mysql-socket-factory-connector-j-6:$mysqlSocketFactoryConnectorJ6Version" (2)
compile "com.google.guava:guava:$guavaVersion" (3)
compile project(':bookshelf-dao')
}
1 | Jetty dependency |
2 | Runtime depependencies to connect to a MySQL database in Cloud SQL |
3 | Fix Guava dependency |
3. Controllers and Views
3.1. Internationalization
Books' titles and descriptions, coming from the persistence layer, are translated by Google Cloud Translation API.
To translate UI elements we use properties files:
books=Books
books.mine=My books
login=Login
bookshelf=Bookshelf
book=Book
book.title=Title
book.author=Author
book.description=Description
book.cover.image=Cover Image
book.cover.image.url=Cover Image URL
book.date.published=Date Published
book.list.more=More
book.list.notFound=No books found
book.add=Add book
book.create=Create book
book.save=Save
book.edit=Edit book
book.delete=Delete book
book.author.by=By {0}
book.author.unknown=By Unknown
book.added.by=Added by {0}
book.added.by.anonymous=Added by Anonymous
Spanish
books=Libros
books.mine=Mis Libros
login=Entrar
bookshelf=Biblioteca
book=Libro
book.title=Título
book.author=Autor
book.description=Descripción
book.cover.image=Portada
book.cover.image.url=URL Portada
book.date.published=Fecha Publicación
book.list.more=Más
book.list.notFound=Libros no encontrados
book.add=Añadir libro
book.create=Crear libro
book.save=Guardar
book.edit=Editar libro
book.delete=Borrar libro
book.author.by=por {0}
book.author.unknown=por desconocido
book.added.by=Añadido por {0}
book.added.by.anonymous=Añadido porAnónimo
Italian
books=Libri
books.mine=I miei libri
login=Accesso
bookshelf=Libretto
book=Libro
book.title=Titolo
book.author=Autore
book.description=Descrizione
book.cover.image=Immagine di copertina
book.cover.image.url=Immagine di copertina URL
book.date.published=Data Pubblicata
book.list.more=Di Più
book.list.notFound=Nessun libro trovato
book.add=Aggiungi Libro
book.create=Aggiungi Libro
book.save=Salvare
book.edit=Modifica il libro
book.delete=Elimina il libro
book.author.by=By {0}
book.author.unknown=Per sconosciuto
book.added.by=Aggiunto da {0}
book.added.by.anonymous=Aggiunto da Anonimo
3.2. Listing books
We map the home page to be handled by the BookController
index
action.
'/'(controller: 'book', action: 'index', method: 'GET')
The controller’s action returns a list of books, along with a URL safe cursor. It uses a service to fetch books and handles the model to the view:
def index(String cursor) {
Result<Book> result = daoService.listBooks(cursor)
log.info 'Retrieved list of all books'
String title = messageSource.getMessage('books', null, 'Books', request.locale)
[books: result.result, cursor: result.cursor, title: title]
}
As you can see in the next code snippet, we use the message tag to reference the codes defined in the internationalization section.
<%@ page import="org.springframework.web.servlet.support.RequestContextUtils" %>
<!DOCTYPE html>
<html>
<head>
<meta name="layout" content="main" />
</head>
<body>
<div class="container">
<h3>${title}</h3>
<a href="/create">
<i class="glyphicon glyphicon-plus"></i>
<g:message code="book.add" default="Add book"/>
</a>
<g:if test="${books}">
<g:each in="${books}" var="book">
<div class="media">
<g:link controller="book" action="show" id="${book.id}">
<div class="media-left">
<img alt="ahhh" src="${book?.imageUrl ?: 'http://placekitten.com/g/128/192'}">
</div>
<div class="media-body">
<h4><bookshelf:bookTitle id="${book?.id}" languageCode="${RequestContextUtils.getLocale(request).language}"/></h4>
<p>${book.author}</p>
</div>
</g:link>
</div>
</g:each>
<g:if test="${cursor}">
<nav>
<ul class="pager">
<li><a href="?cursor=${cursor}"><g:message code="book.list.more" default="More"/></a></li>
</ul>
</nav>
</g:if>
</g:if>
<g:else>
<p><g:message code="book.list.notFound" default="No books found"/></p>
</g:else>
</div>
</body>
</html>
3.3. Create a book
To create a book, we create a form which allows us to upload a cover image:
We map the display of the form to be handled by the BookController
create
action.
'/create'(controller: 'book', action: 'create', method: 'GET')
@SuppressWarnings('EmptyMethod')
def create() {
}
We render the form with the help of the Grails Form Tag.
<!DOCTYPE html>
<html>
<head>
<meta name="layout" content="main" />
</head>
<body>
<div class="container">
<h3><g:message code="book.create" default="Create book"/></h3>
<g:form method="POST" url="/create${book?.id ? ('/' + book.id) : ''}" enctype="multipart/form-data">
<div class="form-group ${application.isCloudStorageConfigured ? '' : 'hidden'}">
<label for="file"><g:message code="book.cover.image" default="Cover Image"/></label>
<input type="file" name="file" id="file" class="form-control" />
</div>
<button type="submit" class="btn btn-success"><g:message code="book.save" default="Save"/></button>
</g:form>
</div>
</body>
</html>
We map the creation of the book to be handled by the BookController
save
action.
'/create'(controller: 'book', action: 'save', method: 'POST')
def save(CreateBookCommand cmd) {
if ( cmd.hasErrors() ) {
respond cmd.errors
return
}
BookCurator curator = new BookCurator()
if ( session[Oauth2CallbackController.SESSION_ATTRIBUTE_TOKEN] ) {
curator.createdBy = session[Oauth2CallbackController.SESSION_USER_EMAIL]
curator.createdById = session[Oauth2CallbackController.SESSION_USER_ID]
}
Long id = createBookWithCoverImageService.saveBookWithCover(cmd.file, curator)
log.info "Created book ${id}"
redirect(action: 'show', id: id)
}
We encapsulate the binding and validation of the cover image with a command object:
package com.example.getstarted.basicactions
import grails.compiler.GrailsCompileStatic
import grails.validation.Validateable
import org.springframework.web.multipart.MultipartFile
@GrailsCompileStatic
class CreateBookCommand implements Validateable {
MultipartFile file
static constraints = {
file nullable: false
}
}
3.4. Display book
We map the home page to be handled by the BookController
show
action.
"/read/$id"(controller: 'book', action: 'show', method: 'GET')
The controller’s action returns a list of books, along with a URL safe cursor. It uses a service to fetch books and handles the model to the view:
def show(Long id) {
log.info "Read book with id ${id}"
[book: daoService.readBook(id)]
}
We use a GSP to render the book:
<%@ page import="com.example.getstarted.domain.BookGormEntity" %>
<%@ page import="com.example.getstarted.domain.BookLocalizationGormEntity" %>
<%@ page import="org.springframework.web.servlet.support.RequestContextUtils" %>
<!DOCTYPE html>
<html>
<head>
<meta name="layout" content="main" />
</head>
<body>
<div class="container">
<h3><g:message code="book" default="Book"/></h3>
<div class="btn-group">
<g:link controller="book" action="edit" id="${book?.id}" class="btn btn-primary btn-sm">
<i class="glyphicon glyphicon-edit"></i>
<g:message code="book.edit" default="Edit book"/>
</g:link>
<g:link controller="book" action="delete" id="${book?.id}" class="btn btn-danger btn-sm">
<i class="glyphicon glyphicon-trash"></i>
<g:message code="book.delete" default="Delete book"/>
</g:link>
</div>
<div class="media">
<div class="media-left">
<img class="book-image" src="${book?.imageUrl ?: 'http://placekitten.com/g/128/192'}">
</div>
<div class="media-body">
<h4 class="book-title"><bookshelf:bookTitle id="${book?.id}" languageCode="${RequestContextUtils.getLocale(request).language}"/> <small>${book?.publishedDate}</small></h4>
<h5 class="book-author">
<g:if test="${book?.author}">
<g:message code="book.author.by" args="${[book.author]}"/>
</g:if>
<g:else>
<g:message code="book.author.unknown" default="By Unknown"/>
</g:else>
</h5>
<p class="book-description"><bookshelf:bookDescription id="${book?.id}" languageCode="${RequestContextUtils.getLocale(request).language}"/></p>
<small>Locale: ${RequestContextUtils.getLocale(request).language}</small><br/>
<small class="book-added-by">
<g:if test="${book?.createdBy}">
<g:message code="book.added.by" args="${[book.createdBy]}"/>
</g:if>
<g:else>
<g:message code="book.added.by.anonymous" default="Added by Anonymous"/>
</g:else>
</small>
</div>
</div>
</div>
</body>
</html>
The GSP uses a custom taglib to renders title
or description
depending on the current locale.
package com.example.getstarted
import com.example.getstarted.daos.DaoService
import com.example.getstarted.objects.BookLocalization
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.util.logging.Slf4j
@Slf4j
class BookLocalizationTagLib implements GrailsConfigurationAware {
static namespace = 'bookshelf'
static defaultEncodeAs = [taglib: 'html']
String defaultLanguageCode
DaoService daoService
def bookTitle = { args ->
String languageCode = args.languageCode ?: defaultLanguageCode
Long bookId = args.id
BookLocalization bookLocalization = daoService.getLocalization(bookId, languageCode)
out << bookLocalization?.title
}
def bookDescription = { args ->
String languageCode = args.languageCode ?: defaultLanguageCode
Long bookId = args.id
BookLocalization bookLocalization = daoService.getLocalization(bookId, languageCode)
out << bookLocalization?.description
}
@Override
void setConfiguration(Config co) {
defaultLanguageCode = co.getProperty('bookshelf.defaultLanguageCode', String, 'en')
}
}
3.5. Delete book
We map the home page to be handled by the BookController
show
action.
"/delete/$id"(controller: 'book', action: 'delete', method: 'GET')
The controller’s action deletes a book identify by its id.
def delete(Long id) {
daoService.deleteBook(id)
redirect action: 'index'
}
3.6. Edit a book
To edit a book, we have a form which allows us to modify the metadata and/or upload a new cover image:
We map the display of the form to be handled by the BookController
edit
action.
"/update/$id"(controller: 'book', action: 'edit', method: 'GET')
def edit(Long id) {
[book: daoService.readBook(id)]
}
3.7. Update a book
We manage the edit form submission with the Controller’s update action.
"/update/$id"(controller: 'book', action: 'update', method: 'POST')
def update(UpdateBookCommand cmd) {
if ( cmd.hasErrors() ) {
return
}
def book = cmd as Book
if ( (
book.createdById == null ||
book.createdBy == null
) && session[Oauth2CallbackController.SESSION_ATTRIBUTE_TOKEN] ) {
book.createdBy = session[Oauth2CallbackController.SESSION_USER_EMAIL]
book.createdById = session[Oauth2CallbackController.SESSION_USER_ID]
}
if ( cmd.file && !cmd.file.isEmpty() ) {
String fileName = uploadBookCoverService.nameForFile(cmd.file)
String imageUrl = googleCloudStorageService.storeMultipartFile(fileName, cmd.file)
book.imageUrl = imageUrl
}
daoService.updateBook(book)
redirect(action: 'show', id: book.id)
}
The update methods gets a Command Object which encapsulates the parameters binding and validation
package com.example.getstarted.basicactions
import com.example.getstarted.objects.Book
import com.example.getstarted.objects.BookImpl
import grails.compiler.GrailsCompileStatic
import grails.validation.Validateable
import org.springframework.web.multipart.MultipartFile
@GrailsCompileStatic
class UpdateBookCommand implements Validateable {
Long id
String author
String description
String publishedDate
String title
MultipartFile file
String imageUrl
String createdById
String createdBy
static constraints = {
id nullable: false
title nullable: false
author nullable: true
description nullable: true
publishedDate nullable: true
file nullable: true
imageUrl nullable: true
createdById nullable: true
createdBy nullable: true
}
Object asType(Class clazz) {
if (clazz == Book) {
def book = new BookImpl()
copyProperties(this, book)
return book
}
super.asType(clazz)
}
def copyProperties(source, target) {
source.properties.each { key, value ->
if (target.hasProperty(key as String) && !(key in ['class', 'metaClass'])) {
target[key as String] = value
}
}
}
}
4. Services
As you could see in the previous code snippets the business logic of the application is encapsulated in Grails Services.
4.1. Configuration
We setup several configuration values in application.yml
We use these configuration parameters across multiple services.
org:
grails:
plugins:
googlecloud:
storage:
bucket: grails-bookshelf.appspot.com
---
bookshelf:
storageType: cloudSQL
limit: 10
orderBy: title
callback: http://localhost:8080/oauth2callback
defaultLanguageCode: en
localizations:
- it
- es
---
4.2. Service integration Overview
Grails integrates with multiple Google Cloud Service to power the application.
4.3. Handling Cover Image upload
package com.example.getstarted
import com.example.getstarted.daos.DaoService
import com.example.getstarted.objects.BookCurator
import com.example.getstarted.objects.BookImpl
import com.example.getstarted.objects.BookLocalization
import com.example.getstarted.objects.BookLocalizationImpl
import org.grails.plugins.googlecloud.storage.GoogleCloudStorageService
import org.grails.plugins.googlecloud.vision.GoogleCloudVisionService
import org.springframework.web.multipart.MultipartFile
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@SuppressWarnings('GrailsStatelessService')
@Slf4j
@CompileStatic
class CreateBookWithCoverImageService {
UploadBookCoverService uploadBookCoverService
GoogleCloudStorageService googleCloudStorageService
GoogleCloudVisionService googleCloudVisionService
DaoService daoService
BookLocalization bookLocalizationWithFile(MultipartFile file) {
def text = googleCloudVisionService.detectDocumentText(file.inputStream)
bookLocalizationWithText(text)
}
BookLocalization bookLocalizationWithText(String text) {
if (text == null ) {
return null
}
final int titleMaxSize = 255
String title = null
String description = null
if ( text.size() >= (titleMaxSize + 1) ) {
title = text[0 .. (titleMaxSize - 1)]
description = text[titleMaxSize .. (text.size() - 1)]
} else {
title = text
}
new BookLocalizationImpl(title: title, description: description)
}
String imageUrlWithFile(MultipartFile file) {
String fileName = uploadBookCoverService.nameForFile(file)
googleCloudStorageService.storeMultipartFile(fileName, file)
}
Long saveBookWithCover(MultipartFile file, BookCurator curator) {
def book = new BookImpl()
book.createdBy = curator.createdBy
book.createdById = curator.createdById
book.imageUrl = imageUrlWithFile(file)
def bookLocalization = bookLocalizationWithFile(file)
if ( bookLocalization ) {
book.title = bookLocalization.title
book.description = bookLocalization.description
}
daoService.createBook(book)
}
}
When we upload a book cover image several things will happen:
-
The cover image is uploaded to Google Cloud Storage. This will provided use with an image url which we will store in the database
book
table row. -
Text in the cover image is extracted by Google Cloud Vision API.
-
A book entry in a MySQL database provided by Google Cloud SQL.
-
Prior to saving or updating a book, the English title and description are translated to Spanish and Italian with Google Cloud Translation API. Those translations are saved to the database.
4.4. Uploading an image to Cloud Storage
We encapsulate the code which interacts with Google Cloud Storage in its own service:
package org.grails.plugins.googlecloud.storage
import groovy.transform.CompileStatic
import com.google.cloud.storage.Acl
import com.google.cloud.storage.BlobId
import com.google.cloud.storage.BlobInfo
import com.google.cloud.storage.Storage
import com.google.cloud.storage.StorageOptions
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.util.logging.Slf4j
import org.springframework.web.multipart.MultipartFile
@Slf4j
@SuppressWarnings('GrailsStatelessService')
@CompileStatic
class GoogleCloudStorageService implements GrailsConfigurationAware {
// Cloud Storage Bucket
String bucket
Storage storage = StorageOptions.defaultInstance.service
String storeMultipartFile(String fileName, MultipartFile multipartFile) {
log.info "Uploaded file ${multipartFile.originalFilename}"
storeInputStream(fileName, multipartFile.inputStream)
}
String storeInputStream(String fileName, InputStream inputStream) {
BlobInfo blobInfo = storage.create(readableBlobInfo(bucket, fileName), inputStream)
log.info "Uploaded file as ${fileName} with mediaLink ${blobInfo.mediaLink}"
blobInfo.mediaLink
}
String storeBytes(String fileName, byte[] bytes) {
BlobInfo blobInfo = storage.create(readableBlobInfo(bucket, fileName), bytes)
blobInfo.mediaLink
}
private static BlobInfo readableBlobInfo(String bucket, String fileName) {
// Modify access list to allow all users with link to read file
List<Acl> acl = [Acl.of(Acl.User.ofAllUsers(), Acl.Role.READER)]
BlobInfo.newBuilder(bucket, fileName)
.setAcl(acl)
.build()
}
boolean deleteFile(String fileName) {
BlobId blobId = BlobId.of(bucket, fileName)
storage.delete(blobId)
}
@Override
void setConfiguration(Config co) {
bucket = co.getProperty('org.grails.plugins.googlecloud.storage.bucket', String)
}
}
In order to use Google Cloud Storage we need to add the next dependency to our compile dependencies:
compile "com.google.cloud:google-cloud-storage:$googleCloudStorageVersion"
4.5. Extracting the text of an image with Cloud Vision
We encapsulate the code which extracts the text in a Cover Image in a Grails Service
package org.grails.plugins.googlecloud.vision
import com.google.cloud.vision.spi.v1.ImageAnnotatorClient
import com.google.cloud.vision.v1.AnnotateImageRequest
import com.google.cloud.vision.v1.AnnotateImageResponse
import com.google.cloud.vision.v1.BatchAnnotateImagesResponse
import com.google.cloud.vision.v1.Feature
import com.google.cloud.vision.v1.Feature.Type
import com.google.cloud.vision.v1.Image
import com.google.cloud.vision.v1.TextAnnotation
import com.google.protobuf.ByteString
import groovy.util.logging.Slf4j
import groovy.transform.CompileStatic
@Slf4j
@CompileStatic
class GoogleCloudVisionService {
@SuppressWarnings(['ReturnNullFromCatchBlock', 'CatchException'])
String detectDocumentText(InputStream inputStream) {
try {
List<AnnotateImageRequest> requests = []
ByteString imgBytes = ByteString.readFrom(inputStream)
Image img = Image.newBuilder().setContent(imgBytes).build()
Feature feat = Feature.newBuilder().setType(Type.DOCUMENT_TEXT_DETECTION).build()
AnnotateImageRequest request =
AnnotateImageRequest.newBuilder().addFeatures(feat).setImage(img).build()
requests.add(request)
BatchAnnotateImagesResponse response =
ImageAnnotatorClient.create().batchAnnotateImages(requests)
List<AnnotateImageResponse> responses = response.responsesList
List<String> responsesText = []
for (AnnotateImageResponse res : responses) {
if (res.hasError()) {
log.error "Error ${res.error.message}"
continue
}
TextAnnotation annotation = res.fullTextAnnotation
responsesText << annotation.text
}
return responsesText*.replaceAll('\n', ' ').join(' ').trim().replaceAll(' +', ' ')
} catch ( Exception e ) {
log.error(e.message, e)
return null
}
}
}
In order to use Google Cloud Vision we need to add the next dependency to our compile dependencies:
compile "com.google.cloud:google-cloud-vision:$googleCloudVisionVersion"
Checkout Google Cloud Vision for complete overview of the image analysis options.
4.6. Translate Text with Cloud Translation API
The next service method would be invoked with code such as:
assert 'Hola Mundo' == googleCloudTranslateService.translateTextFromSourceToTarget('Hello World', 'en', 'es')
package org.grails.plugins.googlecloud.translate
import com.google.cloud.translate.Translate.TranslateOption
import com.google.cloud.translate.TranslateException
import com.google.cloud.translate.TranslateOptions
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@Slf4j
@CompileStatic
class GoogleCloudTranslateService {
@SuppressWarnings('ReturnNullFromCatchBlock')
String translateTextFromSourceToTarget(String text, String source, String target) {
if ( !text ) {
return text
}
try {
return TranslateOptions.defaultInstance.service.translate(text,
TranslateOption.sourceLanguage(source),
TranslateOption.targetLanguage(target)).translatedText
} catch (TranslateException e) {
log.error(e.message, e)
return null
}
}
}
In order to use Google Cloud Translation API wee need to add the next dependency:
compile "com.google.cloud:google-cloud-translate:$googleCloudTranslateVersion"
4.7. Interact with Cloud SQL: MySQL database with GORM Hibernate implementation
We use GORM. GORM is the data access toolkit used by Grails and provides a rich set of APIs for accessing relational and non-relational data including implementations for Hibernate (SQL), MongoDB, Neo4j, Cassandra and an in-memory ConcurrentHashMap for testing.
We map books and localizations in a one-to-many relationship with the use of Grails Domain Classes:
package com.example.getstarted.domain
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class BookGormEntity {
String author
String createdBy
String createdById
String publishedDate
String imageUrl
static hasMany = [localizations: BookLocalizationGormEntity]
static constraints = {
author nullable: true, maxSize: 255
createdBy nullable: true, maxSize: 255
createdById nullable: true, maxSize: 255
publishedDate nullable: true
imageUrl nullable: true
}
static mapping = {
table 'book'
}
}
package com.example.getstarted.domain
import com.example.getstarted.objects.BookLocalization
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class BookLocalizationGormEntity implements BookLocalization {
String title
String description
String languageCode
static belongsTo = [book: BookGormEntity]
static constraints = {
title nullable: false, maxSize: 255
description nullable: true
}
static mapping = {
table 'book_localization'
description type: 'text'
}
}
We encapsulate the GORM database access in a service:
package com.example.getstarted.daos
import com.example.getstarted.objects.BookImpl
import org.grails.plugins.googlecloud.translate.GoogleCloudTranslateService
import com.example.getstarted.domain.BookGormEntity
import com.example.getstarted.domain.BookLocalizationGormEntity
import com.example.getstarted.objects.Book
import com.example.getstarted.objects.BookLocalization
import com.example.getstarted.objects.Result
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import grails.gorm.DetachedCriteria
import grails.transaction.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@Slf4j
@SuppressWarnings('GrailsStatelessService')
@CompileStatic
@Transactional
class CloudSqlService implements BookDao, GrailsConfigurationAware {
int limit
String defaultLanguageCode
List<String> localizations
GoogleCloudTranslateService googleCloudTranslateService
@Override
void setConfiguration(Config co) {
limit = co.getProperty('bookshelf.limit', Integer, 10)
defaultLanguageCode = co.getProperty('bookshelf.defaultLanguageCode', String, 'en')
localizations = co.getProperty('bookshelf.localizations', List)
}
@SuppressWarnings('LineLength')
@Override
Long createBook(Book book) {
BookGormEntity entity = new BookGormEntity()
populateEntityWithBook(entity, book)
if ( book.title != null ) {
entity.addToLocalizations(new BookLocalizationGormEntity(languageCode: defaultLanguageCode,
title: book.title,
description: book.description))
for (String languageCode : localizations ) {
String title = googleCloudTranslateService.translateTextFromSourceToTarget(book.title, defaultLanguageCode, languageCode)
String description = googleCloudTranslateService.translateTextFromSourceToTarget(book.description, defaultLanguageCode, languageCode)
entity.addToLocalizations(new BookLocalizationGormEntity(languageCode: languageCode,
title: title,
description: description))
}
}
entity.save()
entity.id
}
@Transactional(readOnly = true)
@Override
Book readBook(Long bookId) {
def entity = BookGormEntity.get(bookId)
def book = new BookImpl()
book.with {
id = entity.id
author = entity.author
createdBy = entity.createdById
createdById = entity.createdById
publishedDate = entity.publishedDate
imageUrl = entity.imageUrl
}
BookLocalization bookLocalization = getLocalization(bookId, defaultLanguageCode)
book.title = bookLocalization?.title
book.description = bookLocalization?.description
book
}
@SuppressWarnings('LineLength')
@Override
void updateBook(Book book) {
def entity = BookGormEntity.get(book.id)
populateEntityWithBook(entity, book)
addOrUpdateBookLocalizationWithTitleAndDescriptionByLanguageCode(entity, defaultLanguageCode, book.title, book.description)
for (String languageCode : localizations ) {
String title = googleCloudTranslateService.translateTextFromSourceToTarget(book.title, defaultLanguageCode, languageCode)
String description = googleCloudTranslateService.translateTextFromSourceToTarget(book.description, defaultLanguageCode, languageCode)
addOrUpdateBookLocalizationWithTitleAndDescriptionByLanguageCode(entity, languageCode, title, description)
}
entity.save()
}
@SuppressWarnings('LineLength')
static void addOrUpdateBookLocalizationWithTitleAndDescriptionByLanguageCode(BookGormEntity entity, String languageCode, String title, String description) {
BookLocalizationGormEntity defaultBookLocalizationGormEntity = entity.localizations.find { it.languageCode == languageCode }
if ( defaultBookLocalizationGormEntity ) {
defaultBookLocalizationGormEntity.title = title
defaultBookLocalizationGormEntity.description = description
} else {
entity.addToLocalizations(new BookLocalizationGormEntity(languageCode: languageCode,
title: title,
description: description))
}
}
@Override
void deleteBook(Long bookId) {
def entity = BookGormEntity.get(bookId)
entity?.delete()
}
@Transactional(readOnly = true)
@Override
Result<Book> listBooks(String cursor) {
def query = listBooksQuery()
listBooksByQuery(cursor, query)
}
@Transactional(readOnly = true)
@Override
Result<Book> listBooksByUser(String userId, String cursor) {
def query = listBooksByUserQuery(userId)
listBooksByQuery(cursor, query)
}
@Transactional(readOnly = true)
@Override
BookLocalization getLocalization(Long bookId, String code) {
String language = code ?: defaultLanguageCode
def q = BookLocalizationGormEntity.where { book.id == bookId && languageCode == language }
log.info 'code: ' + code + 'size: ' + q.list().size()
q.get()
}
private DetachedCriteria<BookGormEntity> listBooksQuery() {
BookGormEntity.where { }
}
private DetachedCriteria<BookGormEntity> listBooksByUserQuery(String userId) {
BookGormEntity.where { createdById == userId }
}
private Result<Book> listBooksByQuery(String cursor, DetachedCriteria<BookGormEntity> query) {
def offset = cursor ? Integer.parseInt(cursor) : 0
def max = limit
int total = query.count() as int
def booksEntities = query.max(max).offset(offset).list()
def books = collectBooks(booksEntities)
if (total > (offset + limit)) {
return new Result<>(books, Integer.toString(offset + limit))
}
new Result<>(books)
}
private List<Book> collectBooks(List<BookGormEntity> entities) {
entities.collect { BookGormEntity entity ->
BookImpl book = new BookImpl()
BookLocalization bookLocalization = getLocalization(entity.id, defaultLanguageCode)
book.with {
id = entity.id
author = entity.author
createdBy = entity.createdBy
createdById = entity.createdById
publishedDate = entity.publishedDate
imageUrl = entity.imageUrl
title = bookLocalization?.title
description = bookLocalization?.description
}
book
} as List<Book>
}
private static void populateEntityWithBook(BookGormEntity entity, Book book) {
entity.with {
author = book.author
createdBy = book.createdBy
createdById = book.createdById
publishedDate = book.publishedDate
imageUrl = book.imageUrl
}
}
}
5. Authenticating Users with Grails
This part of the Bookshelf tutorial for Java shows how to create a sign-in flow for users and how to use profile information to provide users with personalized functionality.
By using Google Identity Platform, you can easily access information about your users while ensuring their sign-in credentials are safely managed by Google. OAuth 2.0 makes it easy to provide a sign-in flow for all users of your app and provides your application with access to basic profile information about authenticated users.
5.1. Creating a web application client ID
A web application client ID allows your application to authorize users and access Google APIs on behalf of your users.
-
Go to the credentials section in the Google Cloud Platform Console.
-
Click OAuth consent screen. For the the product name, enter
Java Bookshelf App
. Fill in any relevant optional fields. Click Save. -
Click Create credentials > OAuth client ID.
-
Under Application type, select Web Application.
-
Under Name, enter
Java Bookshelf Client
. -
Under Authorized redirect URIs enter the following URLs, one at a time. Replace
[YOUR_PROJECT_ID]
with your project ID:http://localhost:8080/oauth2callback
http://[YOUR_PROJECT_ID].appspot.com/oauth2callback
https://[YOUR_PROJECT_ID].appspot.com/oauth2callback
http://[YOUR_PROJECT_ID].appspot-preview.com/oauth2callback
https://[YOUR_PROJECT_ID].appspot-preview.com/oauth2callback` -
Click Create.
-
Copy the client ID and client secret and save them for later use.
5.2. Configuring settings
In the app/grails-app/conf/
directory, open application-production.yml
for editing.
-
Set
bookshelf.callback
to[YOUR_PROJECT_ID].appspot.com//auth2callback
. -
Set
bookshelf.clientID
to the client ID you created previously. -
Set
bookshelf.clientSecret
to the client secret you created previously. -
Save and close
application-production.yml
.
5.3. Application structure
The following diagram shows the application’s components and how they connect to one another.
5.4. Understanding the code
This section walks you through the application code and explains how it works.
The LoginController
is invoked when the user clicks Login.
package com.example.getstarted.basicactions
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.grails.plugins.googlecloud.authorization.GoogleAuthorizationService
@Slf4j
@CompileStatic
class LoginController implements GrailsConfigurationAware {
static allowedMethods = [index: 'GET']
public static final String SESSION_ATTRIBUTE_STATE = 'state'
public static final String SESSION_ATTRIBUTE_LOGIN_DESTINATION = 'loginDestination'
String defaultLoginDestination
GoogleAuthorizationService googleAuthorizationService
def index(String loginDestination) {
String state = googleAuthorizationService.randomState() (1)
def url = googleAuthorizationService.authorizationRedirectUrl(state)
session.setAttribute(SESSION_ATTRIBUTE_STATE, state)
String destination = loginDestination ?: defaultLoginDestination
log.info "logging destination $destination"
session.setAttribute(SESSION_ATTRIBUTE_LOGIN_DESTINATION, destination) (2)
redirect(url: url)
}
@Override
void setConfiguration(Config co) {
defaultLoginDestination = co.getProperty('bookshelf.loginDestination', String, '/books')
}
}
It does the following:
1 | Save some random state to help prevent request forgeries. |
2 | Save the destination of where to go after sign in. |
A service encapsulates the interaction with Google API Client Library, specifically GoogleAuthorizationCodeFlow. to generate a callback request to Google to handle signing in to a Google account. This app specifies the "email" and "profile" scopes, so it can display the user’s email and image on each page:
package org.grails.plugins.googlecloud.authorization
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.api.client.auth.oauth2.Credential
import com.google.api.client.auth.oauth2.TokenResponse
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow
import com.google.api.client.http.GenericUrl
import com.google.api.client.http.HttpRequest
import com.google.api.client.http.HttpRequestFactory
import com.google.api.client.http.HttpTransport
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.JsonFactory
import com.google.api.client.json.jackson.JacksonFactory
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import java.security.SecureRandom
import groovy.transform.CompileStatic
@SuppressWarnings('GrailsStatelessService')
@CompileStatic
class GoogleAuthorizationService implements GrailsConfigurationAware {
private static final String USERINFO_ENDPOINT = 'https://www.googleapis.com/plus/v1/people/me/openIdConnect'
private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport()
private static final Collection<String> SCOPES = ['email', 'profile']
private static final JsonFactory JSON_FACTORY = new JacksonFactory()
String clientID
String clientSecret
String callback
static String randomState() {
new BigInteger(130, new SecureRandom()).toString(32)
}
/**
*
* @param state - Used to prevent request forgery
* @return
*/
String authorizationRedirectUrl(String state) {
def flow = new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT,
JSON_FACTORY,
clientID,
clientSecret,
SCOPES)
.build()
flow.newAuthorizationUrl()
.setRedirectUri(callback)
.setState(state) // Prevent request forgery
.build()
}
GoogleAuthorizationCodeFlow flow() {
new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT,
JSON_FACTORY,
clientID,
clientSecret,
SCOPES).build()
}
TokenResponse tokenResponse(GoogleAuthorizationCodeFlow flow, String authorizationCode) {
flow.newTokenRequest(authorizationCode)
.setRedirectUri(callback)
.execute()
}
Map<String, String> useIdResultForTokenResponse(GoogleAuthorizationCodeFlow flow, TokenResponse tokenResponse) {
final Credential credential = flow.createAndStoreCredential(tokenResponse, null)
final HttpRequestFactory requestFactory = HTTP_TRANSPORT.createRequestFactory(credential)
final GenericUrl url = new GenericUrl(USERINFO_ENDPOINT) // Make an authenticated request.
final HttpRequest request = requestFactory.buildGetRequest(url)
request.headers.contentType = 'application/json'
final String jsonIdentity = request.execute().parseAsString()
new ObjectMapper().readValue(jsonIdentity, HashMap)
}
@Override
void setConfiguration(Config co) {
clientID = co.getRequiredProperty('bookshelf.clientID', String)
clientSecret = co.getRequiredProperty('bookshelf.clientSecret', String)
callback = co.getRequiredProperty('bookshelf.callback', String)
}
}
Google redirects the user to the /oauth2callback
URL. After the user successfully signs in, the Oauth2CallbackController
index
method does the following:
-
Check that its not a forgery by comparing our
state
with the saved sessionstate
. -
Delete the saved session
state
. -
Get the response
tokenResponse
. -
Use the
tokenResponse
to get aCredential
. -
Use the
credential
to create arequestFactory
. -
Use the
request
to get thejsonIdentity
. -
Extract email, a picture, and an ID.
-
Redirect to the saved
loginDestination
from the prior step.
package com.example.getstarted.basicactions
import com.google.api.client.auth.oauth2.TokenResponse
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.grails.plugins.googlecloud.authorization.GoogleAuthorizationService
import javax.servlet.http.HttpServletResponse
@Slf4j
@CompileStatic
class Oauth2CallbackController implements GrailsConfigurationAware {
static allowedMethods = [index: 'GET']
public static final String SESSION_ATTRIBUTE_TOKEN = 'token'
public static final String SESSION_USER_ID = 'userId'
public static final String SESSION_USER_EMAIL = 'userEmail'
public static final String SESSION_USER_IMAGE_URL = 'userImageUrl'
String defaultLoginDestination
GoogleAuthorizationService googleAuthorizationService
@SuppressWarnings('LineLength')
def index(String state, String code) {
// Ensure that this is no request forgery going on, and that the user
// sending us this connect request is the user that was supposed to.
if ( session[LoginController.SESSION_ATTRIBUTE_STATE] == null || !(state == session[LoginController.SESSION_ATTRIBUTE_STATE])) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
redirect(uri: defaultLoginDestination)
return
}
session.removeAttribute(LoginController.SESSION_ATTRIBUTE_STATE) // Remove one-time use state.
def flow = googleAuthorizationService.flow()
final TokenResponse tokenResponse = googleAuthorizationService.tokenResponse(flow, code)
session[SESSION_ATTRIBUTE_TOKEN] = tokenResponse.toString() // Keep track of the token.
Map<String, String> userIdResult = googleAuthorizationService.useIdResultForTokenResponse(flow, tokenResponse)
// From this map, extract the relevant profile info and store it in the session.
session[SESSION_USER_EMAIL] = userIdResult['email']
session[SESSION_USER_ID] = userIdResult['sub']
session[SESSION_USER_IMAGE_URL] = userIdResult['picture']
def destination = session[LoginController.SESSION_ATTRIBUTE_LOGIN_DESTINATION]
log.info "Login successful, redirecting to $destination"
redirect(uri: destination)
}
@Override
void setConfiguration(Config co) {
defaultLoginDestination = co.getProperty('bookshelf.loginDestination', String, '/books')
}
}
The LogoutController
deletes the session
and creates a new one:
package com.example.getstarted.basicactions
import groovy.transform.CompileStatic
@CompileStatic
class LogoutController {
static allowedMethods = [index: 'GET']
def index() {
session.invalidate()
redirect(controller: 'book', action: 'index')
}
}
6. Logging Application Events with Grails
This part of the Bookshelf tutorial for Grails shows how an app can incorporate detailed logging to help with detecting, debugging, and monitoring potential issues.
6.1. Viewing logs
As the bookshelf app runs, it writes logging data that is collected and made available in the Cloud Platform Console. You can use the log monitoring tools in the Cloud Platform Console to analyze the logs directly. If you want more detailed analysis, you can use the Cloud Platform Console to stream or import the app’s logs into BigQuery or export them to a Cloud Storage Bucket.
6.2. Application structure
The following diagram shows how the app handles logging when deployed to the App Engine flexible environment.
6.3. Understanding the code
There are many Java logging packages that are compatible with the App Engine flexible environment. Grails 3 uses Logback logging framework
Grails artefacts (Controllers, Services …) get injected a log
property automatically. For any other class, you can inject a org.slf4j.Logger log
property
in any Groovy Class with the @Slf4j Local Transformation.
You can use the methods debug
, info
, warn
, or error
to log with different levels: DEBUG
, INFO
, WARNING
, and ERROR
.
Example:
def show(Long id) {
log.info "Read book with id ${id}"
[book: daoService.readBook(id)]
}
Logging configuration can be configured in the grails-app/conf/logback.groovy
file.
6.4. Understanding the logging configuration
When the sample app runs in the App Engine flexible environment, anything logged to stderr
and stdout
is automatically collected by Cloud Logging and
available for viewing, searching, and exporting in the logs viewer in the Cloud Platform Console.
In this sample, all logs are written to stderr
/stdout
.
7. Cleaning up
To avoid incurring charges to your Google Cloud Platform account for the resources used in this tutorial:
7.1. Delete the project
The easiest way to eliminate billing is to delete the project you created for the tutorial.
To delete the project:
-
In the Cloud Platform Console, go to the Projects page. GO TO THE PROJECTS PAGE
-
In the project list, select the project you want to delete and click Delete project.
-
In the dialog, type the project ID, and then click Shut down to delete the project.
Deleting a project has the following consequences: If you used an existing project, you’ll also delete any other work you’ve done in the project. You can’t reuse the project ID of a deleted project. If you created a custom project ID that you plan to use in the future, you should delete the resources inside the project instead. This ensures that URLs that use the project ID, such as an appspot.com URL, remain available. If you are exploring multiple tutorials and quickstarts, reusing projects instead of deleting them prevents you from exceeding project quota limits. |
7.2. Delete non-default versions of your app
If you don’t want to delete your project, you can reduce costs by deleting the non-default versions of your app.
To delete an app version:
-
In the Cloud Platform Console, go to the App Engine Versions page. GO TO THE VERSIONS PAGE
-
Click the checkbox next to the non-default app version you want to delete. You can overwrite the default version of your app by redeploying the app.**
-
Click the Delete button at the top of the page to delete the app version.
The only way to delete the default version of your App Engine app is to delete your project. You can, however, stop the default version in the Cloud Platform Console. This will shut down all instances associated with the version. You can restart these instances later if needed. |
7.3. Delete your Cloud Storage bucket
To delete a Cloud Storage bucket:
-
In the Cloud Platform Console, go to the Cloud Storage browser. GO TO THE CLOUD STORAGE BROWSER
-
Click the checkbox next to the bucket you want to delete.
-
Click the Delete button at the top of the page to delete the bucket.