В предыдущих статьях («Google Cloud Endpoints на Java: Руководство. ч. 1», «Google Cloud Endpoints на Java: Руководство. ч. 2 (Frontend)», «Google Cloud Endpoints на Java: Руководство. ч. 3») мы разбирали создание API на Google Cloud Endpoints и фронтенда к нему на AngularJS.

Однако руководство по созданию API было бы неполным без работы с базой данных.

В этой статье мы рассмотрим фреймворк Objectify для работы с встроенной в GAE базой данных App Engine Datastore.

App Engine Datastore


App Engine Datastore представляет собой нереляционную NoSQL-базу данных (schemaless NoSQL datastore) типа «хранилище ключ-значение» (Key-value database).

Ключ

Ключ является уникальным идентификатором «объекта» (в App Engine datastore это называется «Entity») в базе данных.

Ключ состоит из трех составляющих:

Kind (тип): который соответствует типу объекта в базе данных (с помощью Objectify мы моделируем kind в виде класса Java, т.е. условно говоря в нашем случае kind означает класс объекта размещенного в базе данных)

Identifier (идентификатор): уникальный идентификатор объекта, который может быть либо строкой (String), и в этом случае он называется name, либо числом (Long) в этом случае он называется Id. Т.е. идентификатор вида "01234" — это name, а вида 01234 — это Id. Идентификатор должен быть уникальным среди объектов одного типа, объекты разного типа могут иметь одинаковый идентификатор, т.е. мы можем иметь объект типа «строка» с идентификатором «01», и объект типа «колонка» с идентификатором «01». Для вновь создаваемого объекта в базе данных идентификатор, если он не задан явным образом, генерируется автоматически.

Parent(группа объектов): объекты в базе могут объединяется в «группы объектов», для этого в parent указывается либо ключ «родительского» объекта, либо таковым является null (по умолчанию) для объектов не включенных в группы.

Объект (Entity)

Объект (Entity) в базе данных имеет свойства (properties) которые могут содержать значения (Value type), их соответствие типам данных Java (Java types)) приведено в таблице:
Value type Java type(s) Sort order Notes
Integer short
int
long
java.lang.Short
java.lang.Integer
java.lang.Long
Numeric
Floating-point number float
double
java.lang.Float
java.lang.Double
Numeric 64-bit double precision,
IEEE 754
Boolean boolean
java.lang.Boolean
false or true
Text string (short) java.lang.String Unicode До 1500 bytes

значения больше 1500 bytes выбрасывает исключение IllegalArgumentException
Text string (long) com.google.appengine.api.datastore.Text None До 1 megabyte

Не индексируется
Byte string (short) com.google.appengine.api.datastore.ShortBlob Byte order До 1500 bytes

Значения большие 1500 bytes выбрасывают исключение IllegalArgumentException
Byte string (long) com.google.appengine.api.datastore.Blob None До 1 megabyte

Не индексируется
Date and time java.util.Date Chronological
Geographical point com.google.appengine.api.datastore.GeoPt By latitude,
then longitude
Postal address com.google.appengine.api.datastore.PostalAddress Unicode
Telephone number com.google.appengine.api.datastore.PhoneNumber Unicode
Email address com.google.appengine.api.datastore.Email Unicode
Google Accounts user com.google.appengine.api.users.User Email address
in Unicode order
Instant messaging handle com.google.appengine.api.datastore.IMHandle Unicode
Link com.google.appengine.api.datastore.Link Unicode
Category com.google.appengine.api.datastore.Category Unicode
Rating com.google.appengine.api.datastore.Rating Numeric
Datastore key com.google.appengine.api.datastore.Key
or the referenced object (as a child)
By path elements
(kind, identifier,
kind, identifier...)
До 1500 bytes

Значения большие 1500 bytes выбрасывают исключение IllegalArgumentException
Blobstore key com.google.appengine.api.blobstore.BlobKey Byte order
Embedded entity com.google.appengine.api.datastore.EmbeddedEntity None не индексируется
Null null None

Операции с базой данных

Objectify производит три базовых операции:

save(): сохранить объект в базе данных

delete(): удалить объект из базы данных

load(): загрузить объект или список (List) объектов из базы данных.

Трансакции (Transactions) и группы объектов (Entity Groups)

Для того чтобы объединить объекты в группу «родительский» объект не обязательно должен существовать в базе, достаточно указать ключ объекта. Удаление «родительского объекта» не приводит к удалению «дочерних», они продолжат ссылаться на его ключ.

С помощью этого механизма объекты в базе данных можно организовывать в виде иерархических структур.
Отношения «родительский объект» — «дочерний объект» (parent–child relationship) могут быть установлены как между объектами одного типа (например, прадед -> дед -> отец -> я -> сын) так и объектами разного типа (например, для объекта типа «автомобиль» дочерними объектами могут быть объекты типа «колесо», «двигатель»)

При этом у каждого «дочернего» объекта может быть только один «родительский» объект. И, поскольку ключ родительского объекта является частью ключа объекта, мы не можем добавлять или убирать его после того как объект создан — ключ не изменяем. Поэтому к использованию «родительского ключа» надо подходить с осторожностью.

Как правило рамках одной трансакции мы можем получить доступ к данным только из одной группы объектов (но существует способ задействовать в одной трансакции несколько групп)
Когда изменяется любой объект в группе для группы меняется отметка времени (timestamp). Отметка времени ставиться для целой группы, и обновляется когда изменяется любой объект в группе.

Когда мы производим трансакцию, то каждая группа объектов которую затрагивает трансакция отмечается как задействованная (enlisted) в данной трансакции. Когда трансакция передана (committed), проверяются все отметки времени групп, задействованных в трансакции. Если любая из отметок времени изменилась (поскольку другая трансакция в это время изменила объект(ы) в группе) то вся трансакция отменяется и выбрасывается исключение ConcurrentModificationException. Подробнее см. github.com/objectify/objectify/wiki/Concepts#optimistic-concurrency
Objectify обрабатывает такого рода исключения и повторяет трансакцию. Поэтому трансакции должны быть идемпотентны (idempotent), т.е. мы должны иметь возможность повторить трансакцию любое количество раз и получить тот же самый результат.

Подробнее о трансакциях в Objectify, см.: github.com/objectify/objectify/wiki/Transactions

Подключение Objectify в проект


Для использования фреймворка нам понадобиться добавить в проект objectify.jar и guava.jar.
Objectify есть в репозитории Maven, нам достаточно добавить в pom.xml:
  <dependencies>
    <dependency>
      <groupId>com.googlecode.objectify</groupId>
      <artifactId>objectify</artifactId>
      <version>5.1.9</version>
    </dependency>
  </dependencies>

— objectify.jar и guava.jar будут добавлены в проект.
Objectify использует фильтр который надо прописать в WEB-INF/web.xml:
<filter>
    <filter-name>ObjectifyFilter</filter-name>
    <filter-class>com.googlecode.objectify.ObjectifyFilter</filter-class>
</filter>
<filter-mapping>
    <filter-name>ObjectifyFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>


Создадим класс UserData, который будет моделировать объект (Entity) в базе данных:
package com.appspot.hello_habrahabr_api;

import com.googlecode.objectify.annotation.Entity;
import com.googlecode.objectify.annotation.Id;
import com.googlecode.objectify.annotation.Index;
import com.googlecode.objectify.annotation.Cache;

import java.io.Serializable;


@Entity // indicates that this is an Entity
@Cache  // Annotate your entity classes with @Cache to make them cacheable.
        // The cache is shared by all running instances of your application
        // and can both improve the speed and reduce the cost of your application.
        // Memcache requests are free and typically complete in a couple milliseconds.
        // Datastore requests are metered and typically complete in tens of milliseconds.
public class UserData implements Serializable {
    @Id     // indicates that the userId is to be used in the Entity's key
            // @Id field can be of type Long, long, or String
            // Entities must have have at least one field annotated with @Id
    String userId;
    @Index // this field will be indexed in database
    private String  createdBy; // email
    @Index
    private String  firstName;
    @Index
    private String  lastName;

    private UserData() {
    } // There must be a no-arg constructor
    // (or no constructors - Java creates a default no-arg constructor).
    // The no-arg constructor can have any protection level (private, public, etc).

    public UserData(String createdBy, String firstName, String lastName) {
        this.userId = firstName + lastName;
        this.createdBy = createdBy;
        this.firstName = firstName;
        this.lastName = lastName;
    }

    /* Getters and setters */
    // You need getters and setters to have a serializable class if you need to send it from backend to frontend,
    // to avoid exception:
    // java.io.IOException: com.google.appengine.repackaged.org.codehaus.jackson.map.JsonMappingException: No serializer found for class ...
    //

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getCreatedBy() {
        return createdBy;
    }

    public void setCreatedBy(String createdBy) {
        this.createdBy = createdBy;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
}



Далее нам следует создать класс в котором зарегистрируем классы созданные для описания объектов в базе данных, и который будет содержать метод выдающий сервисный объект Objectify (Objectify service object), методы которого мы будет использовать для взаимодействия с базой данных. Назовем его OfyService:
package com.appspot.hello_habrahabr_api;

import com.googlecode.objectify.Objectify;
import com.googlecode.objectify.ObjectifyFactory;
import com.googlecode.objectify.ObjectifyService;

/**
 * Custom Objectify Service that this application should use.
 */
public class OfyService {

    // This static block ensure the entity registration.
    static {
        factory().register(UserData.class);
    }

    // Use this static method for getting the Objectify service factory.
    public static ObjectifyFactory factory() {
        return ObjectifyService.factory();
    }

    /**
     * Use this static method for getting the Objectify service object in order
     * to make sure the above static block is executed before using Objectify.
     *
     * @return Objectify service object.
     */
    @SuppressWarnings("unused")
    public static Objectify ofy() {
        return ObjectifyService.ofy();
    }
}


Теперь создадим API (назовем файл UserDataAPI.java):
package com.appspot.hello_habrahabr_api;

import com.google.api.server.spi.config.Api;
import com.google.api.server.spi.config.ApiMethod;
import com.google.api.server.spi.config.ApiMethod.HttpMethod;
import com.google.api.server.spi.config.Named;
import com.google.api.server.spi.response.NotFoundException;
import com.google.api.server.spi.response.UnauthorizedException;
import com.google.appengine.api.users.User;
import com.googlecode.objectify.Key;
import com.googlecode.objectify.Objectify;

import java.io.Serializable;
import java.util.List;
import java.util.logging.Logger;

/**
 * explore this API on:
 * hello-habrahabr-api.appspot.com/_ah/api/explorer
 * {project ID}.appspot.com/_ah/api/explorer
 */

@Api(
        name = "userDataAPI", // The api name must match '[a-z]+[A-Za-z0-9]*'
        version = "v1",
        scopes = {Constants.EMAIL_SCOPE},
        clientIds = {Constants.WEB_CLIENT_ID, Constants.API_EXPLORER_CLIENT_ID},
        description = "UserData API using OAuth2")
public class UserDataAPI {

    private static final Logger LOG = Logger.getLogger(UserDataAPI.class.getName());

    // Primitives and enums are not allowed as return type in @ApiMethod
    // So we create inner class (which should be a JavaBean) to serve as wrapper for String
    private class MessageToUser implements Serializable {

        private String message;

        public MessageToUser() {
        }

        public MessageToUser(String message) {
            this.message = message;
        }

        public String getMessage() {
            return message;
        }

        public void setMessage(String message) {
            this.message = message;
        }
    }

    @ApiMethod(
            name = "createUser",
            path = "createUser",
            httpMethod = HttpMethod.POST)
    @SuppressWarnings("unused")
    public MessageToUser createUser(final User gUser,
                                    @Named("firstName") final String firstName,
                                    @Named("lastName") final String lastName
                                    // instead of @Named arguments, we could also use
                                    // another JavaBean for modelling data received from frontend
    ) throws UnauthorizedException {

        if (gUser == null) {
            LOG.warning("User not logged in");
            throw new UnauthorizedException("Authorization required");
        }

        Objectify ofy = OfyService.ofy();

        UserData user = new UserData(gUser.getEmail(), firstName, lastName);

        ofy.save().entity(user).now();

        return new MessageToUser("user created: " + firstName + " " + lastName);
    }

    @ApiMethod(
            name = "deleteUser",
            path = "deleteUser",
            httpMethod = HttpMethod.DELETE)
    @SuppressWarnings("unused")
    public MessageToUser deleteUser(final User gUser,
                                    @Named("firstName") final String firstName,
                                    @Named("lastName") final String lastName
    ) throws UnauthorizedException {

        if (gUser == null) {
            LOG.warning("User not logged in");
            throw new UnauthorizedException("Authorization required");
        }

        Objectify ofy = OfyService.ofy();

        String userId = firstName + lastName;
        Key<UserData> userDataKey = Key.create(UserData.class, userId);

        ofy.delete().key(userDataKey);

        return new MessageToUser("User deleted: " + firstName + " " + lastName);
    }

    @ApiMethod(
            name = "findUsersByLastName",
            path = "findUsersByLastName",
            httpMethod = HttpMethod.GET)
    @SuppressWarnings("unused")
    public List<UserData> findUsers(final User gUser,
                                    @Named("query") final String query
    ) throws UnauthorizedException, NotFoundException {

        if (gUser == null) {
            LOG.warning("User not logged in");
            throw new UnauthorizedException("Authorization required");
        }

        Objectify ofy = OfyService.ofy();

        List<UserData> result = ofy.load().type(UserData.class).filter("lastName ==", query).list();
        // for queries see: 
        // https://github.com/objectify/objectify/wiki/Queries#executing-queries 

        if (result.isEmpty()) {
            throw new NotFoundException("no results found");
        }

        return result; // we need to return a serializable object
    }
}



Теперь по адресу {project ID}.appspot.com/_ah/api/explorer мы можем с помощью веб-интерфейса протестировать API добавляя, удаляя и загружая объекты из базы данных.


В консоли разработчика по адресу console.developers.google.com/datastore/entities/query, выбрав соответствующий проект, мы получаем доступ в веб-интерфейсу позволяющему работать с базой данных, в том числе создавать, удалять, сортировать объекты:


Ссылки:


Objectify wiki

Objectify JavaDoc

Java Datastore API

Storing Data in Datastore (Google Tutorial)

Краткое представление фреймворка от его создателя Jeff Schnitzer (@jeffschnitzer) на Google I/O 2011: youtu.be/imiquTOLl64?t=3m40s

Комментарии (6)


  1. anjiJa
    01.01.2016 19:25

    У них база данных вроде платная, да?
    Для использования Virtual Machines тоже просят кредитку?


  1. ageyev
    01.01.2016 19:54

    Google App Engine до определенного лимита использования ресурсов — бесплатно (для регистрации нужна кредитка), потом платно в зависимости от интенсивности использования, см. cloud.google.com/appengine/pricing, cloud.google.com/products/calculator Порядок определения стоимости замысловатый, стоит в принципе не дешево, но по идее должно быть меньше чем зарплата сисадмина.
    Managed Virtual Machines в GAE — это возможность самостоятельно сконфигурировать среду в которой запускается приложение, см. cloud.google.com/appengine/docs/managed-vms


  1. anjiJa
    01.01.2016 20:24

    Да да, спасибо, ссылки я знаю. Просто хотел уточнить, вдруг они расщедрились!; )

    В appengine-web.xml делаю так:

    <vm>true</vm>
    

    и локально работает ок.
    Пытаюсь аплод сделать, так сразу:

    «У вас не оформлен биллинг...»

    А без VM, некоторые классы не доступны в онлайн, например ImageIO.
    Просто хотел добавить, что у них есть «white list» классы и «black list» классы, что малость неудобно.

    А так, очень удобный сервис!


    1. ageyev
      01.01.2016 21:58
      +1

      А без VM, некоторые классы не доступны в онлайн, например ImageIO

      Возможно это пригодится:
      How to read a image url in google appengine using java:
      byte[] b = URLFetchServiceFactory.getURLFetchService().fetch( url ).getContent();
      

      и App Engine Images Java API Overview


      1. anjiJa
        01.01.2016 22:49

        Спасибо за наводку… но я привел ImageIO как пример.
        Дело в том, что я пытался использовать Apache FOP, так ему оно, и возможно другие классы тоже, и были нужны.

        Здесь говорят, что в GAE
        >java.awt.* classes are forbidden.

        Возможно они могли бы брать плату за трафик, а не за использование классов.
        И главное, в вышеуказанном линке, отражена суть этой проблемы:

        >Google not evil, just hungry, very very hungry.


        1. splix
          04.01.2016 10:44
          +1

          Они берут плату за полноценную vm с изоляцией, не за классы. Обычный GAE это типа shared hosting, несколько проектов на одной машине. Поэтому все системное, типа доступа к файловой системе и awt запрещены, чтобы не навредить соседям.