Primeros pasos: Cloud SQL

En esta guía se amplía el fragmento de código utilizado en Gestión de datos enviados por los usuarios almacenando y recuperando datos con Google Cloud SQL.

Cloud SQL es una opción de almacenamiento disponible en App Engine que se puede integrar fácilmente en las aplicaciones y almacenar datos de texto relacionales. Compara Cloud SQL, Cloud Datastore y Cloud Storage y elige el que mejor se adapte a los requisitos de tu aplicación.

Este ejemplo se basa en una serie de guías y muestra cómo almacenar, actualizar y eliminar datos de entradas de blog en Cloud SQL.

Antes de empezar

Configura tu entorno de desarrollo y crea tu proyecto de App Engine.

Crear una instancia de Cloud SQL y conectarse a la base de datos

Deberás crear una instancia de Cloud SQL y configurar una conexión a ella desde tu aplicación de App Engine. Para obtener instrucciones sobre cómo conectarte a Cloud SQL, consulta el artículo Conectarse a App Engine.

Crear tablas

Debes crear un objeto Connection en el método init() del servlet para gestionar la conexión a la instancia de Cloud SQL:

Connection conn; // Cloud SQL connection

// Cloud SQL table creation commands
final String createContentTableSql =
    "CREATE TABLE IF NOT EXISTS posts ( post_id INT NOT NULL "
        + "AUTO_INCREMENT, author_id INT NOT NULL, timestamp DATETIME NOT NULL, "
        + "title VARCHAR(256) NOT NULL, "
        + "body VARCHAR(1337) NOT NULL, PRIMARY KEY (post_id) )";

final String createUserTableSql =
    "CREATE TABLE IF NOT EXISTS users ( user_id INT NOT NULL "
        + "AUTO_INCREMENT, user_fullname VARCHAR(64) NOT NULL, "
        + "PRIMARY KEY (user_id) )";

@Override
public void init() throws ServletException {
  try {
    String url = System.getProperty("cloudsql");

    try {
      conn = DriverManager.getConnection(url);

      // Create the tables so that the SELECT query doesn't throw an exception
      // if the user visits the page before any posts have been added

      conn.createStatement().executeUpdate(createContentTableSql); // create content table
      conn.createStatement().executeUpdate(createUserTableSql); // create user table

      // Create a test user
      conn.createStatement().executeUpdate(createTestUserSql);
    } catch (SQLException e) {
      throw new ServletException("Unable to connect to SQL server", e);
    }

  } finally {
    // Nothing really to do here.
  }
}

El método init() configura una conexión a Cloud SQL y, a continuación, crea las tablas content y user si no existen. Después de usar el método init(), la aplicación estará lista para ofrecer y almacenar datos nuevos.

En el fragmento, las instrucciones SQL de creación de tablas se almacenan en String variables, que se ejecutan en el servlet init() mediante la llamada al método executeUpdate. Ten en cuenta que no se crearán esas tablas si ya existen.

Las dos tablas creadas en el fragmento se llaman posts y users. posts contiene los detalles de cada entrada del blog, mientras que users incluye información sobre el autor, como se muestra a continuación:

Tabla: posts

Campo Tipo
post_id INT (incremento automático, clave principal)
author_id INT
timestamp DATETIME
title VARCHAR (256)
cuerpo VARCHAR (1337)

Tabla: usuarios

Campo Tipo
user_id INT (incremento automático, clave principal)
user_fullname VARCHAR (64)

Obtener los datos iniciales que se mostrarán en un formulario

Un caso práctico habitual es rellenar previamente un formulario con datos almacenados en la base de datos para que los usuarios puedan seleccionarlos. Por ejemplo:

Connection conn;

final String getUserId = "SELECT user_id, user_fullname FROM users";
Map<Integer, String> users = new HashMap<Integer, String>();

@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp)
    throws ServletException, IOException {

  // Find the user ID from the full name
  try (ResultSet rs = conn.prepareStatement(getUserId).executeQuery()) {
    while (rs.next()) {
      users.put(rs.getInt("user_id"), rs.getString("user_fullname"));
    }

    req.setAttribute("users", users);
    req.getRequestDispatcher("/form.jsp").forward(req, resp);

  } catch (SQLException e) {
    throw new ServletException("SQL error", e);
  }
}

En el fragmento de código anterior, el servlet consulta la base de datos de Cloud SQL para obtener una lista de IDs de usuario y nombres de autores. Se almacenan como tuplas (id, full name) en un mapa hash. A continuación, el servlet reenvía el usuario y el mapa hash a /form.jsp, que procesa el mapa hash de los nombres de los autores, tal como se muestra en la siguiente sección.

Admitir interacciones con bases de datos en un formulario

El siguiente fragmento usa JavaServer Pages (JSP) para mostrar al usuario los datos iniciales del mapa de hash del nombre del autor que se ha transferido desde el servlet y usa esos datos en una lista de selección. El formulario también permite al usuario crear y actualizar datos.

<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn"%>
<div>
  <c:choose>
    <c:when test="${id == null}">
      <h2>Create a new blog post</h2>
      <form method="POST" action="/create">
    </c:when>
    <c:otherwise>
      <h2><c:out value="${pagetitle}" /></h2>
      <form method="POST" action="/update">
      <input type="hidden" name="blogContent_id" value="${id}">
    </c:otherwise>
  </c:choose>

    <div>
      <label for="title">Title</label>
      <input type="text" name="blogContent_title" id="title" size="40" value="${title}" />
    </div>

    <div>
      <label for="author">Author</label>
      <select name="blogContent_id">
        <c:forEach items="${users}" var="user">
        <option value="${user.key}">${user.value}</option>
        </c:forEach>
      </select>
      <input type="text" name="blogContent_author" id="author" size="40" value="${author}" />
    </div>

    <div>
      <label for="description">Post content</label>
      <textarea name="blogContent_description" id="description" rows="10" cols="50">${body}</textarea>
    </div>

    <button type="submit">Save</button>
  </form>
</div>

En el fragmento de código anterior, el formulario se rellena cuando se carga la página con el mapa hash de los nombres de los autores que se han transferido desde el servlet. El formulario usa la biblioteca de etiquetas estándar de JavaServer Pages (JSTL) when y las operaciones otherwise proporcionan la lógica if..else y los bucles forEach a través del mapa hash transferido desde el servlet.

La página JSP del fragmento de código anterior contiene un formulario para crear entradas de blog y actualizar las que ya existen. Ten en cuenta que el formulario puede enviar los datos a los controladores /create o /update, según si el usuario está creando o actualizando una entrada de blog.

Para obtener más información sobre cómo usar formularios, consulta Gestionar datos POST.

Almacenar registros

En el siguiente fragmento se muestra cómo crear un registro a partir de los datos que el usuario ha proporcionado en el formulario y almacenarlo en la base de datos. En el ejemplo se muestra una instrucción SQL INSERT creada a partir de los datos enviados en el formulario de creación de la entrada de blog descrito en la sección anterior:

// Post creation query
final String createPostSql =
    "INSERT INTO posts (author_id, timestamp, title, body) VALUES (?, ?, ?, ?)";

@Override
public void doPost(HttpServletRequest req, HttpServletResponse resp)
    throws ServletException, IOException {

  // Create a map of the httpParameters that we want and run it through jSoup
  Map<String, String> blogContent =
      req.getParameterMap()
          .entrySet()
          .stream()
          .filter(a -> a.getKey().startsWith("blogContent_"))
          .collect(
              Collectors.toMap(
                  p -> p.getKey(), p -> Jsoup.clean(p.getValue()[0], Whitelist.basic())));

  // Build the SQL command to insert the blog post into the database
  try (PreparedStatement statementCreatePost = conn.prepareStatement(createPostSql)) {
    // set the author to the user ID from the user table
    statementCreatePost.setInt(1, Integer.parseInt(blogContent.get("blogContent_id")));
    statementCreatePost.setTimestamp(2, new Timestamp(new Date().getTime()));
    statementCreatePost.setString(3, blogContent.get("blogContent_title"));
    statementCreatePost.setString(4, blogContent.get("blogContent_description"));
    statementCreatePost.executeUpdate();

    conn.close(); // close the connection to the Cloud SQL server

    // Send the user to the confirmation page with personalised confirmation text
    String confirmation = "Post with title " + blogContent.get("blogContent_title") + " created.";

    req.setAttribute("confirmation", confirmation);
    req.getRequestDispatcher("/confirm.jsp").forward(req, resp);

  } catch (SQLException e) {
    throw new ServletException("SQL error when creating post", e);
  }
}

El fragmento de código toma la entrada del usuario y la ejecuta a través de jSoup para sanearla. Usar jSoup y PreparedStatement mitigar la posibilidad de ataques de XSS y de inyección de SQL.

La variable createPostSql contiene la consulta INSERT con ? como marcadores de posición de los valores que se asignarán mediante el método PreparedStatement.set().

Ten en cuenta el orden de los campos de la tabla, ya que se hace referencia a ellos en los métodos PreparedStatement. Por ejemplo, author_id es un campo de tipo INT, por lo que debe usarse setInt() para definir author_id.

Recuperar registros

En el siguiente fragmento se muestra el método doGet() de un servlet que obtiene las filas de la tabla de entradas de blog y las imprime.

// Preformatted HTML
String headers =
    "<!DOCTYPE html><meta charset=\"utf-8\"><h1>Welcome to the App Engine Blog</h1><h3><a href=\"blogpost\">Add a new post</a></h3>";
String blogPostDisplayFormat =
    "<h2> %s </h2> Posted at: %s by %s [<a href=\"/update?id=%s\">update</a>] | [<a href=\"/delete?id=%s\">delete</a>]<br><br> %s <br><br>";

@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp)
    throws ServletException, IOException {
  // Retrieve blog posts from Cloud SQL database and display them

  PrintWriter out = resp.getWriter();

  out.println(headers); // Print HTML headers

  try (ResultSet rs = conn.prepareStatement(selectSql).executeQuery()) {
    Map<Integer, Map<String, String>> storedPosts = new HashMap<>();

    while (rs.next()) {
      Map<String, String> blogPostContents = new HashMap<>();

      // Store the particulars for a blog in a map
      blogPostContents.put("author", rs.getString("users.user_fullname"));
      blogPostContents.put("title", rs.getString("posts.title"));
      blogPostContents.put("body", rs.getString("posts.body"));
      blogPostContents.put("publishTime", rs.getString("posts.timestamp"));

      // Store the post in a map with key of the postId
      storedPosts.put(rs.getInt("posts.post_id"), blogPostContents);
    }

    // Iterate the map and display each record's contents on screen
    storedPosts.forEach(
        (k, v) -> {
          // Encode the ID into a websafe string
          String encodedID = Base64.getUrlEncoder().encodeToString(String.valueOf(k).getBytes());

          // Build up string with values from Cloud SQL
          String recordOutput =
              String.format(blogPostDisplayFormat, v.get("title"), v.get("publishTime"),
                  v.get("author"), encodedID, encodedID, v.get("body"));

          out.println(recordOutput); // print out the HTML
        });

  } catch (SQLException e) {
    throw new ServletException("SQL error", e);
  }
}

Los resultados de la instrucción SELECT se colocan en un ResultSet, que se itera mediante el método ResultSet.get(). Ten en cuenta el método ResultSet.get() getString que corresponde al esquema de tabla definido anteriormente.

En este ejemplo, cada publicación tiene un enlace [Update] y un enlace [Delete], que se usan para iniciar actualizaciones y eliminaciones de publicaciones, respectivamente. Para ofuscar el ID de la publicación, el identificador se codifica en Base64.

Actualizar registros

En el siguiente fragmento se muestra cómo actualizar un registro:

final String updateSql = "UPDATE posts SET title = ?, body = ? WHERE post_id = ?";

@Override
public void doPost(HttpServletRequest req, HttpServletResponse resp)
    throws ServletException, IOException {

  // Create a map of the httpParameters that we want and run it through jSoup
  Map<String, String> blogContent =
      req.getParameterMap()
          .entrySet()
          .stream()
          .filter(a -> a.getKey().startsWith("blogContent_"))
          .collect(
              Collectors.toMap(
                  p -> p.getKey(), p -> Jsoup.clean(p.getValue()[0], Whitelist.basic())));

  // Build up the PreparedStatement
  try (PreparedStatement statementUpdatePost = conn.prepareStatement(updateSql)) {
    statementUpdatePost.setString(1, blogContent.get("blogContent_title"));
    statementUpdatePost.setString(2, blogContent.get("blogContent_description"));
    statementUpdatePost.setString(3, blogContent.get("blogContent_id"));
    statementUpdatePost.executeUpdate(); // Execute update query

    conn.close();

    // Confirmation string
    final String confirmation = "Blog post " + blogContent.get("blogContent_id") + " has been updated";

    req.setAttribute("confirmation", confirmation);
    req.getRequestDispatcher("/confirm.jsp").forward(req, resp);

  } catch (SQLException e) {
    throw new ServletException("SQL error", e);
  }
}

En este fragmento, cuando el usuario hace clic en el enlace [Update] (Actualizar) de una entrada de blog, se muestra el formulario JSP que se usa para crear una entrada nueva, pero ahora se rellena automáticamente con el título y el contenido de la entrada. El nombre del autor no se muestra en la muestra porque no va a cambiar.

Actualizar una publicación es similar a crearla, excepto que se usa la consulta SQL UPDATE en lugar de INSERT.

Después de ejecutar executeUpdate(), se redirige al usuario a una página de confirmación en el fragmento.

Eliminar registros

Para eliminar una fila (en este ejemplo, una entrada de blog), es necesario eliminar una fila de la tabla de destino, que en este caso es la tabla content. Cada registro se identifica por su ID, que es el valor post_id del código de ejemplo. Usa este ID como filtro en la consulta DELETE:

Después de ejecutar executeUpdate(), se redirige al usuario a una página de confirmación.

final String deleteSql = "DELETE FROM posts WHERE post_id = ?";

@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp)
    throws ServletException, IOException {

  Map<String, String[]> userData = req.getParameterMap();

  String[] postId = userData.get("id");
  String decodedId = new String(Base64.getUrlDecoder().decode(postId[0])); // Decode the websafe ID


  try (PreparedStatement statementDeletePost = conn.prepareStatement(deleteSql)) {
    statementDeletePost.setString(1, postId[0]);
    statementDeletePost.executeUpdate();

    final String confirmation = "Post ID " + postId[0] + " has been deleted.";

    req.setAttribute("confirmation", confirmation);
    req.getRequestDispatcher("/confirm.jsp").forward(req, resp);

  } catch (SQLException e) {
    throw new ServletException("SQL error", e);
  }

}

Después de decodificar el ID de la publicación, el fragmento eliminará una sola publicación de la tabla posts.

Desplegar en App Engine

Puedes desplegar tu aplicación en App Engine con Maven.

Ve al directorio raíz de tu proyecto y escribe lo siguiente:

mvn package appengine:deploy -Dapp.deploy.projectId=PROJECT_ID

Sustituye PROJECT_ID por el ID de tu Google Cloud proyecto. Si tu archivo pom.xml ya especifica tu ID de proyecto, no es necesario que incluyas la propiedad -Dapp.deploy.projectId en el comando que ejecutes.

Una vez que Maven haya desplegado tu aplicación, se abrirá automáticamente una pestaña del navegador web con tu nueva aplicación. Para ello, escribe lo siguiente:

gcloud app browse

Siguientes pasos

Cloud SQL es útil para almacenar datos basados en texto. Sin embargo, si quieres almacenar contenido multimedia enriquecido, como imágenes, te recomendamos que uses Cloud Storage.

A continuación, consulta cómo usar las colas de tareas para realizar tareas asíncronas. Para ello, sigue un ejemplo de uso de la API Images para cambiar el tamaño de las imágenes subidas en esta guía.