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

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

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

Для того, чтобы интерфейс и аннотации превратились в рабочий код, потребуется 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<*класс модели*>. При этом, название класса модели можно и не определять. В этом случае сгенерированный метод будет возвращать Observable, что удобно для SQL-запросов INSERT, DELETE или UPDATE, для которых не требуется возвращение результата.

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

  public rx.Observable 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);
      }
    } );
  }

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

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

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

  public rx.Observable 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 Observable> getAllRecords() {
        return mSpendDBRx.getAllRecords()
                .toList()
                .compose(applySchedulers());
    }

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

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

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

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

  • 5 октября 2016 в 09:11

    0

    Спасибо за идею!
  • 5 октября 2016 в 10:41 (комментарий был изменён)

    0

    Есть даже без аннотаций: http://projects.spring.io/spring-data/#quick-start

© Habrahabr.ru