12 июня 2016 г.

Сервер и клиент на Thrift

Apache Thrift - это язык описания программных интерфейсов для построения сервисов под разные языки программирования. Работает это следующим образом. На специальном C-подобном языке вы описываете все интерфейсы и сущности вашего сервиса, после чего генерите код на нужном вам языке. Thrift поддерживает несколько десятков языков, в том числе и Java.

Сгенерированные классы вы копируете как на сервер, так и на клиент. На сервере делаете имплементацию интерфейсов, а на клиенте - их вызов. Таким образом, вызов процедур на сервере будет для вас выглядеть так же, как и вызов обычных методов локально. При этом код на клиенте и сервере может быть на разных языках. Thrift берёт на себя все вопросы сериализации данных.

План

Мы опишем интерфейс нашего сервиса на специальном языке Thrift, реализуем этот интерфейс на сервере и будем вызывать его с клиента. Для удобства реализуем всё это в виде maven-проекта с тремя модулями (клиент, сервер и сгенерированные общие классы). Для примера, наш сервис будет возвращать профиль пользователя по его id.

Требования

Java 8, Apache Thrift 0.9, Maven 3.

Исходники


https://github.com/nordmine/examples

Maven-проект с модулями

Поскольку классы, которые генерит Thrift нам нужны как на сервере, так и на клиенте, мы выделим их в отдельный maven-модуль и будем подтягивать его через dependency в модуле сервера и модуле клиента. Все эти три модуля связаны между собой, поэтому я поместил их в один maven-проект с модулями, хотя это и не обязательно. Вы можете сделать два или три отдельных проекта.

<groupId>developer-remarks</groupId>
<artifactId>examples</artifactId>
<packaging>pom</packaging>
<version>1.0</version>

<modules>
    <module>thrift-entities</module>
    <module>thrift-server</module>
    <module>thrift-client</module>
</modules>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.thrift</groupId>
            <artifactId>libthrift</artifactId>
            <version>0.9.3</version>
        </dependency>
    </dependencies>
</dependencyManagement>

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </pluginManagement>
</build>

В корневом pom-файле определяются все зависимости и версии, которые будут доступны в модулях. Здесь нам потребуется только библиотека libthrift. Также в parent-pom определяются плагины для сборки.

Модуль с классами Thrift


Добавим в наш maven-проект новый модуль thrift-entities. В parent-pom у вас должна появится запись:

<modules>
    <module>thrift-entities</module>
</modules>

А в pom-файле нового модуля будет ссылка на parent-pom:

<parent>
    <artifactId>examples</artifactId>
    <groupId>developer-remarks</groupId>
    <version>1.0</version>
</parent>

<artifactId>thrift-entities</artifactId>

<dependencies>
    <dependency>
        <groupId>org.apache.thrift</groupId>
        <artifactId>libthrift</artifactId>
    </dependency>
</dependencies>

В модулях номер версии можно будет не указывать - он будет получен из parent-pom.

Теперь мы можем перейти к описанию нашего сервиса на языке Thrift. Поскольку в качестве примера мы выбрали сервис, возвращающий профиль пользотеля, пусть у каждого пользователя может быть некий статус. Статус имеет конечное множество значений, по сути, это enum. Предположим, профиль пользователя может быть не подтверждённым, подтверждённым (проверили почтовый ящик) и золотым (какие-то платные услуги подключены, например).

На языке Thrift перечисление будет выглядеть следующим образом (cначала указывается java-пакет). Для перечисления используется ключевое слово enum:

namespace java developer.remarks.thrift.v1.enums

enum PersonState {
    UNVERIFIED
    VERIFIED
    GOLD
}

Поместим его в файл thrift-entities/src/main/resources/Enums.thrift.

Затем в thrift-entities/src/main/resources/ создадим файл Models.thrift и поместим в него описание сущности самого профиля пользователя.

namespace java developer.remarks.thrift.v1.models

include "Enums.thrift"

struct Profile {
    1: i64 prsId
    2: string firstName
    3: string lastName
    4: i64 birthDate
    5: Enums.PersonState state
}


К текущему файлу могут быть подключены другие thrift-файлы при помощи ключевого слова include. Классы-сущности описываются при помощи ключевого слова struct.

Обратите внимание, что каждое поле структуры снабжается обязательным индексом - такова особенности Thrift. Строка обозначается ключевым словом string, 32-битные целые обозначаются как i32, 64-битные целые - как i64. Если при описании нужно сослаться на сущность из другого thrift-файла, к имени этой сущности прибавляют имя файла с точкой (Enums.PersonState).

Наш профиль содержит id пользователя, его имя и фамилию, статус, а также дату рождения. В Thrift нет встроенных типов работы с датами. Можно было бы конечно определить отдельную структуру с полями год, месяц и день. Но что, если нужно знать дату до секунды? Уже потребуется шесть полей. А шесть полей уже не очень удобно инициализировать. Кроме того, кто будет контролировать, что в году 12 месяцев, а в месяце не более 31 дня? Поэтому я предлагаю любые даты отправлять в виде 64-битного целого числа, которое представляет собой количество миллисекунд, прошедших с 1 января 1970 года (Unix timestamp). А на клиенте мы всегда сможем создать новый объект даты на основе этого значения.

Теперь определим непосредственно интерфейс нашего сервиса с единственным методом getById, возвращающим сущность профиля. Обратите внимание, что метод выбрасывает исключение. Его определение будет дано ниже.

namespace java developer.remarks.thrift.v1.services

include "Exceptions.thrift"
include "Models.thrift"

service ProfileService {

    Models.Profile getById(1: i64 prsId) throws (1: Exceptions.ProfileException e)
}

Интерфейсы сервисов определяются при помощи ключевого слова service. Параметры метода и все возможные исключения также снабжаются порядковыми номерами.

Хорошей практикой является создание отдельного, специального исключения, связанного с ошибками бизнес-логики нашего сервиса.

namespace java developer.remarks.thrift.v1.exceptions

exception ProfileException {
    1: i32 errorCode
    2: string description
}

В данном случае это исключение (ключевое слово exception) с двумя пронумерованными полями: код ошибки и текстовое описание.

Мы разложили наши сущности по разным файлам, подобно тому, как это делается в java. Однако thrift не накладывает таких ограничений и мы можем хоть все сущности описать в одном файле и обойтись вовсе без include и лишних префиксов. Однако в больших проектах я так делать не рекомендую.

Генерим java-классы на основе thrift-файлов


Теперь мы определили все сущности нашего сервиса и готовы сгенерить java-классы на основании наших файлов. Делается это при помощи утилиты thrift в командной строке:

thrift -gen java -r -out thrift-entities/src/main/java  thrift-entities/src/main/resources/Services.thrift

  • флаг -gen указывает, на каком языке следует сгенерить сущности
  • флаг -r говорит, что нужно сгенерить не только указанную сущность, но и пройтись по всем include.
  • флаг -out указывает директорию, куда следует сложить сгенерированные файлы.
  • в конце следует путь до целевого thrift-файла, как правило, это файл с сервисом.

Thrift создаст несколько файлов с исходниками на java, внутрь которых я заглядывать вам не советую - там сплошная простыня быдлокода, который вам пришлось бы писать вручную, если бы вы не захотели использовать thrift. Соответственно, модифицировать эти файлы тоже нельзя, т.к. они были сгенерированы автоматически и ваши изменения могут потеряться при следующем запуске утилиты.

В итоге у вас должна получиться такая структура в модуле thrift-entities:


Запускаем сервер


Модуль с сущностями нам понадобится на сервере. Добавим новый maven-модуль thrift-server. Его pom-файл делаем аналогично предыдущему, только туда добавляется ещё одна зависимость:

<dependencies>
    <dependency>
        <groupId>org.apache.thrift</groupId>
        <artifactId>libthrift</artifactId>
    </dependency>
    <dependency>
        <groupId>developer-remarks</groupId>
        <artifactId>thrift-entities</artifactId>
        <version>1.0</version>
    </dependency>
</dependencies>

Теперь создадим обработчик для нашего сервиса. Для этого достаточно реализовать его интерфейс:

public class ProfileHandler implements ProfileService.Iface {

    @Override
    public Profile getById(long prsId) throws ProfileException, TException {
        if (prsId == 2) {
            long birthTimestamp = LocalDateTime.of(1985, 8, 31, 0, 0, 0)
                    .toEpochSecond(ZoneOffset.UTC);
            return new Profile(prsId, "Александр", "Кузнецов", 
                    birthTimestamp, PersonState.GOLD);
        } else {
            throw new ProfileException(-1, "Profile with id = " + prsId + " not found");
        }
    }
}


В идеале здесь могла бы быть работа с базой, но мы лишь имитируем получение профиля. Если id персоны равно 2, то возвращаем некоторые данные. Иначе имитируем отсутствие данных. Обратите внимание, что тут мы используем два класса (Profile и ProfileException), которые изначально определяли на thrift.

Ну и ещё надо добавить класс с кодом инициализации самого сервера, где мы определяем протокольный и транспортный уровень. Здесь наш сервер слушает порт 9090 и взаимодействие происходит через сокеты.

public class ProfileServer {

    private static final int PORT = 9090;

    public static ProfileHandler handler;
    public static ProfileService.Processor processor;

    public static void main(String[] args) {
        try {
            handler = new ProfileHandler();
            processor = new ProfileService.Processor(handler);

            Runnable simple = () -> perform(processor);

            new Thread(simple).start();
        } catch (Exception x) {
            x.printStackTrace();
        }
    }

    public static void perform(ProfileService.Processor processor) {
        try {
            TServerTransport serverTransport = new TServerSocket(PORT);
            TServer server = new TSimpleServer(
                    new TServer.Args(serverTransport).processor(processor)
            );

            System.out.println("Starting the simple server on port " + PORT + "...");
            server.serve();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}


Добавляем клиент

Создадим новый модуль thrift-client. Его pom-файл будет аналогичен серверному. В этом модуле мы также используем thrift-сущности.

Код клиента предельно прост. Указываем хост и порт сервера, а также задаём взаимодействие через сокеты (TSocket) по бинарному протоколу (TBinaryProtocol):


public class ThriftClient {

    public static void main(String[] args) {
        try {
            TTransport transport = new TSocket("localhost", 9090);
            transport.open();

            TProtocol protocol = new TBinaryProtocol(transport);
            ProfileService.Client client = new ProfileService.Client(protocol);

            perform(client);

            transport.close();
        } catch (TException x) {
            x.printStackTrace();
        }
    }

    private static void perform(ProfileService.Client client) throws TException {
        Profile profile = client.getById(2L);

        Instant instant = Instant.ofEpochSecond(profile.getBirthDate());
        LocalDateTime birthDate = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());

        System.out.println(
                String.format("%s родился %s. Статус: %s.",
                        profile.getFirstName(),
                        DateTimeFormatter.ISO_DATE.format(birthDate),
                        profile.getState())
        );
    }
} 

Теперь можно запустить сервер и выполнить запрос с клиента для prsId = 2. В ответ вы получите профиль пользователя. Для любого другого значения prsId вы получите исключение.

Заключение


Таким образом, Apache Thrift полностью избавляет нас от необходимости задумываться над проблемами сериализации, а также позволяет менять один протокол на другой буквально двумя строками кода.

Комментариев нет:

Отправить комментарий