RetroBase — аналог Retrofit для запросов к базам данных

    Многие разработчики, используют в своих проектах библиотеку Retrofit, которая позволяет превратить HTTP API в java-интерфейс. Это очень удобно, так как позволяет избавиться от лишнего кода и использовать ее очень легко. Нужно лишь создать интерфейс и навесить несколько аннотаций.

    Недавно я занимался разработкой приложения для Android, которому необходимо было делать запросы к базе данных через JDBC- драйвер. Тогда мне пришла идея создать нечто подобное Retrofit только для запросов к базе данных. Так появился RetroBase, о котором я Вам сейчас и расскажу.


    This article in English. Эта статья на английском.

    Для того, чтобы интерфейс и аннотации превратились в рабочий код, потребуется Annotation Processing, который открывает поистине огромные возможности для автоматизации написания однотипного кода. А в сочетании с JavaPoet процесс генерации java-кода становится удобным и простым.

    На хабре, как и на просторах интернета, имеется несколько хороших статей по этой теме, поэтому разобраться с Annotation Processing не составляет труда, а необходимый мануал библиотеки JavaPoet умещается в ее README.md.

    Основу RetroBase составляют две аннотации DBInterface и DBQuery вместе с DBAnnotationProcessor, который и выполняет всю работу. С помощью DBInterface отмечается интерфейс с методами-запросами к БД, а DBQuery отмечает сами методы. Методы могут иметь параметры, которые будут использованы в SQL-запросе. Например:

    @DBInterface(url = SpendDB.URL, login = SpendDB.USER_NAME, password = SpendDB.PASSWORD)
    @DBInterfaceRx
    public interface SpendDB {
        String USER_NAME = "postgres";
        String PASSWORD = "1234";
        String URL = "jdbc:postgresql://192.168.1.26:5432/spend";
    
        @DBMakeRx(modelClassName = "com.qwert2603.retrobase_example.DataBaseRecord")
        @DBQuery("SELECT * from spend_test")
        ResultSet getAllRecords();
    
        @DBMakeRx
        @DBQuery("DELETE FROM spend_test WHERE id = ?")
        void deleteRecord(int id) throws SQLException;
    }
    

    Самое интересное происходит в DBAnnotationProcessor, где осуществляется генерация класса, реализующего интерфейс, сгенерированный класс будет иметь имя *название_интерфейса* + Impl:

    TypeSpec.Builder newTypeBuilder = TypeSpec.classBuilder(dbInterfaceClass.getSimpleName() + GENERATED_FILENAME_SUFFIX)
                        .addSuperinterface(TypeName.get(dbInterfaceClass.asType()))
                        .addField(mConnection)
                        .addMethod(waitInit)
                        .addModifiers(Modifier.PUBLIC, Modifier.FINAL);
    

    После этого создается соединение с БД:

    FieldSpec mConnection = FieldSpec.builder(Connection.class, "mConnection", Modifier.PRIVATE)
                        .initializer("null")
                        .build();
    

    Также создается PreparedStatement для каждого запроса:

    FieldSpec fieldSpec = FieldSpec
                    .builder(PreparedStatement.class, executableElement.getSimpleName().toString() + PREPARED_STATEMENT_SUFFIX)
                    .addModifiers(Modifier.PRIVATE)
                    .initializer("null")
                    .build();
    

    … и реализация метода для этого запроса:

    MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder(executableElement.getSimpleName().toString())
                    .addAnnotation(Override.class)
                    .addModifiers(Modifier.PUBLIC)
                    .returns(returnTypeName);
    

    При этом учитывается тип возвращаемого значения метода. Он может быть либо void, если SQL-запрос представляет собой INSERT, DELETE или UPDATE. Либо ResultSet, если SQL-запрос представляет собой SELECT.

    Также выполняется проверка на то, может ли метод выбрасывать SQLException. Если может, они будут выброшены и из реализации метода. А если нет — пойманы и выведены в stderr.

    Все параметры аннотированного метода добавляются в переопределяющий метод, а также для каждого параметра генерируется выражение позволяющее передать значение параметра в PreparedStatement:

    insertRecord_PreparedStatement.setString(1, kind);
    

    Конечно же, количество и типы параметров метода должны соответствовать параметрам запроса, переданного с помощью аннотации DBQuery.

    После того, как файл был сгенерирован, он записывается средствами Annotation Processing:

    JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile(filename);
                    Writer writer = sourceFile.openWriter();
                    writer.write(javaFile.toString());
    

    Rx it!


    Конечно, удобно получать ResultSet, определяя лишь интерфейс. А еще удобнее было бы воспользоваться популярной RxJava и получать Observable. К тому же, это позволит легко решить проблему с выполнением запросов в другом потоке.

    Для этого был создан DBMakeRxAnnotationProcessor вместе с DBInterfaceRx и DBMakeRx, которые позволяют создать класс с методами-обертками. Применение этих аннотаций Вы уже могли увидеть в примере выше. Созданный класс будет иметь имя *название_интерфейса* + Rx, а также будет иметь открытый конструктор, принимающий объект интерфейса, аннотированного DBInterfaceRx, которому он будет перенаправлять запросы, возвращая результаты в реактивном стиле.

    Все, что нужно — это добавить к методу аннотацию DBMakeRx и передать ей название класса модели. Сгенерированный метод-обертка будет возвращать Observable<*класс модели*>. При этом, название класса модели можно и не определять. В этом случае сгенерированный метод будет возвращать io.reactivex.Completable, что удобно для SQL-запросов INSERT, DELETE или UPDATE, для которых не требуется возвращение результата.

    Например, для методов интерфейса ResultSet getAllRecords(); и void deleteRecord(int id) throws SQLException; из примера выше будет сгенерированы следующие методы-обертки:

      public io.reactivex.Observable<com.qwert2603.retrobase_example.DataBaseRecord> getAllRecords() {
        return Observable.generate(() -> mDB.getAllRecords(), (resultSet, objectEmitter) -> {
          if (resultSet.next()) {
            objectEmitter.onNext(new com.qwert2603.retrobase_example.DataBaseRecord(resultSet));
          }
          else {
            objectEmitter.onComplete();
          }
        } , ResultSet::close);
      }
    
      public Completable deleteRecord(int id) {
        return Completable.fromAction(() -> mDB.deleteRecord(id));
      }
    

    Здесь mDB представляет собой объект интерфейса, аннотированного DBInterfaceRx, который был передан в конструктор.

    Как видно из сгенерированного метода, нам потребуется создание объектов класса модели из ResultSet, поэтому у класса модели должен быть открытый конструктор, который принимает ResultSet.

    Естественно, что параметры сгенерированного метода будут точно соответствовать параметрам метода, вызов которого происходит:

      public Completable insertRecord(String kind, int value, Date date) {
        ...
            mDB.insertRecord(kind, value, date);
        ...
      }
    

    Все исключения, которые происходят при выполнении запроса, передаются Subscriber'у как и положено в Rx.

    Пример использования всего описанного выше может выглядеть следующим образом:

        private SpendDB mSpendDB = new SpendDBImpl();
        private SpendDBRx mSpendDBRx = new SpendDBRx(mSpendDB);
    
        public Single<List<DataBaseRecord>> getAllRecords() {
            return mSpendDBRx.getAllRecords()
                    .toList()
                    .compose(applySchedulers());
        }
    

    А если нужно подменить new SpendDBImpl(); или new SpendDBRx(mSpendDB); для выполнения тестов, можно воспользоваться популярным Dagger.

    На github Вы можете найти исходники с комментариями, а также рабочий пример этой небольшой библиотеки.

    Целью этой статьи было показать насколько полезным может быть Annotation Processing, позволяющий избавиться от написания однотипного кода. И, надеюсь, у Вас могут появиться новые идеи по использованию этого инструмента в своих проектах.

    UPD. 1: благодаря замечаниям в комментариях была добавлена проверка отписки подписчика в RX-методах-обертках. (Версия RetroBase 1.0.4)

    UPD. 2: если Вам необходимо выполнить запрос INSERT и получить id созданной записи, получить количество строк, измененных в результате выполнения запроса UPDATE или узнать id записей, которые были удалены запросом DELETE, Вы можете добавить конструкцию returning id в конец SQL запроса и получить ResultSet c искомыми id.
    @DBMakeRx(modelClassName = "com.qwert2603.spenddemo.model.Id")
    @DBQuery("UPDATE spend_test SET kind=?, value=?, date=? WHERE id=? returning id")
    ResultSet updateRecord(String kind, int value, Date date, int id) throws SQLException;
    

    Как видно из кода, возможно также использовать аннотацию DBMakeRx, чтобы получать Observable<*id*>. Для нужно создать класс модели, получающий Id из ResultSet и передать его параметру modelClassName аннотации DBMakeRx. Класс модели, содержащий Id может выглядеть следующим образом:
    public class Id {
        private int mId;
    
        public Id(ResultSet resultSet) throws SQLException {
            mId = resultSet.getInt(1);
        }
    
        public int getId() {
            return mId;
        }
    
        public void setId(int id) {
            mId = id;
        }
    }
    


    UPD. 3: в версии RertoBase 1.1 добавлена проверка java.sql.Connection#isValid(0) для автоматического создания нового подключения к БД в случае ошибки (например, потеря соединения).

    UPD. 4: в версии RertoBase 1.2 весь Rx код генерируется для версии RxJava 2.x. Пример сгенерированных методов Вы видели выше. Также таким образом была решена проблема backpressure, о которой было указано в комментариях.
    Share post

    Comments 12

      0
      Спасибо за идею!
        +2
        Есть даже без аннотаций: http://projects.spring.io/spring-data/#quick-start
          0
          Spring Data генерирует классы по интерфейсам в момент старта приложения (контекста), а автор генерирует код перед компиляцией.
          Иногда в spring приходится повозиться, чтобы понять, в чём ошибка наименования. С генерацией кода проверить проще.
          +2

          Немного пройдусь по поддержке Rx, если вы не против :)


          Например, для метода интерфейса ResultSet getAllRecords(); из примера выше будет сгенерирован следующий метод-обертка:

          public rx.Observable<com.qwert2603.retrobase_example.DataBaseRecord> getAllRecords() {
              return Observable.create(subscriber ->  {
                try {
                  ResultSet resultSet = mDB.getAllRecords();
                  while (resultSet.next()) {
                    subscriber.onNext(new com.qwert2603.retrobase_example.DataBaseRecord(resultSet));
                  }
                  subscriber.onCompleted();
                }
                catch (Exception e) {
                  subscriber.onError(e);
                }
              } );
            }

          Тут 3 проблемы:


          • Не надо использовать Observable.create() в RxJava v1, а если используете, будьте очень осторожны. В user-space коде используйте Observable.fromEmitter(), в библиотеках используйте его после того, как он выйдет из @Experimental. В RxJava v2 Observable.create() норм.
          • Вообще не проверяется subscriber.isUnsubscribed() — соответсвенно, Observable будет работать до конца, даже после отписки подписчика. Лучше добавить проверку прямо в цикл while(!subscriber.isUnsubscribed() && …) и перед каждым вызовом onNext/onCompleted/onError.
          • Нет обработки backpressure, а в IO операциях, таких как работа с БД — это неизбежно.

          Вообще судя по статье, Rx здесь прикручен постольку-поскольку и реактивную БД оно здесь явно не делает, тк на изменения, произошедшие в БД, новых данных просто так подписчикам не придёт, что, конечно, не сводит смысл от поддержки Rx в ноль, но всё же убирает важную часть реактивности.

            0
            По поводу 2 проблемы — исправить несложно. Сегодня вечером будет лучше. 3 проблему тоже можно исправить.
            Реактивность была и правда добавлена для возможности выполнять запросы в фоне и применять rx-операторы к результатам.
              0
              Мне кажется проблему backpressure библиотека не должна решать. Это должен делать пользователь библиотеки. Потому что как минимум не один вариант можно выбрать.
                0

                Проблема в том, что код выше просто не поддерживает backpressure и, соответственно, не даст пользователю библиотеки правильно её обработать.

              0
              Неужели есть Android приложения, которые работают напрямую с базой данных? По-моему, это какой-то уникальный и из ряда вон выходящий случай.
                0
                Действительно, таких очень немного. Одно я делал для себя для домашнего использования, поэтому и появилась идея для этой библиотеки. При этом сам инструмент Annotation Processing может быть полезен во многих других областях.
                  0
                  Был как-то опыт создания такого приложения. Грубо говоря, оно являлось интерфейсом для БД. Суть была в том, что клиент грузил шифрованную БД и в удобном виде представлял ее пользователю. Приложение было для фармакологической компании, и соответственно бдшка содержала всю инфу по препаратам. Так что да, такие приложения есть.
                    0

                    Ну вы же понимаете, что как только появляется Android клиент, ваша база больше не шифрована? Это всё равно что полагаться на честное слово пользователя — но тогда и шифровать ничего не нужно…
                    Ну и получается, что у вашего приложения был доступ в сеть — и чтобы нормально отслеживать распространение базы, правильно было бы, чтобы оно делало запросы к некоему серверу, который бы перед передачей данных проверял, не ушла ли информация на сторону… Так что такая реализация имхо не очень корректна.

                      0
                      На тот момент заказчик хотел, чтобы все было именно так. Я то конечно понимаю, но вот заказчику это объяснить не получалось. В общем, пришлось сделать так, как сделали. А так по идее я бы сделал некий механизм завязанный на первичных ключах, например, генерацию токена по device_model и user_id. Таким образом взломщику пришлось бы хотя-бы подольше ломать все это, и, возможно, ему бы просто надоело.

                Only users with full accounts can post comments. Log in, please.