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 полностью избавляет нас от необходимости задумываться над проблемами сериализации, а также позволяет менять один протокол на другой буквально двумя строками кода.

7 июня 2016 г.

Stream API в Java 8

Актуальная версия статьи доступна на моём сайте devmark.ru

В Java 8 появилось довольно важное нововведение под названием Stream. И здесь имеются в виду не потоки ввода/вывода. Stream - это абстракция, позволяющая с любыми объектами работать как с потоками. Порой это чем-то похоже на выполнение запросов к БД. Рассмотрим несколько типовых задач, с которыми часто сталкивается каждый разработчик.

Объединение нескольких строк в одну

Наверняка вам приходилось генерить одну строку из нескольких других, разделённых запятыми. При этом после последнего элемента запятой быть не должно. Знакомо? В java 8 это делается так:

Stream.of("Linux", "Windows", "Mac")
    .collect(Collectors.joining(", ")));

Мы создаём новый поток из простых строк, а затем собираем их в одну при помощи метода collect. В результате получим следующую строку:

Linux, Windows, Mac

Сгенерить N одинаковых элементов

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

Stream.generate(() -> "?")                
        .limit(3)                         
        .collect(Collectors.joining(", "))

Получим:

?, ?, ?

Работа с произвольными объектами

Потоки могут работать с любыми типами объектов. Давайте создадим простой бин, представляющий имя и возраст человека. Для наглядности также переопределим метод toString() у этого бина:

public class Person {

    private final String name;
    private final int age;
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }


    public String getName() {
        return name;
    }


    public int getAge() {
        return age;
    }


    @Override
    public String toString() {
        return String.format("%s (%s)", name, age);
    }
}

Теперь инициализируем коллекцию, которую потом будем преобразовывать.

List<Person> persons = new ArrayList<>();
persons.add(new Person("Боб", 25));
persons.add(new Person("Алиса", 20));
persons.add(new Person("Николай Петрович", 60));

Фильтрация элементов

Выберем всех пользователей не старше 30 лет и выведем каждого из них на экран:

persons.stream()                       
        .filter(p -> p.getAge() <= 30) 
        .forEach(System.out::println); 

Обратите внимание, что Stream можно получить из любой коллекции при помощи метода stream(). Затем в методе filter используем лямбда-выражение (вместо p можно использовать любое другое имя). В качестве выражения фильтрации можно использовать абсолютно любое выражение, возвращающее тип boolean. И наконец, для каждого из оставшихся элементов мы вызываем статическую функцию println. Обратите внимание на её особую запись. Эту запись можно использовать для любого метода, принимающего один параметр.

В результате на экране увидим Боба и Алису, а вот Николаю Петровичу не повезло:

Боб (25)
Алиса (20)

Среднее значение

Подсчитаем средний возраст наших персон, используя другой коллектор:

persons.stream()                                          
        .collect(Collectors.averagingInt(Person::getAge)) 

Для метода Person.getAge опять используем сокращённую запись. В результате получим, что средний возраст составляет 35,0 лет.

Преобразование элементов


Создадим новый стрим из строк, каждая из которых будет представлять имя персоны в верхнем регистре:

persons.stream()                            
        .map(p -> p.getName().toUpperCase())
        .collect(Collectors.joining(", "))  

Результат:

БОБ, АЛИСА, НИКОЛАЙ ПЕТРОВИЧ

Чтение файла в строку

Прочитать все строки текстового файла и объединить их в одну строку можно так:

Path path = Paths.get(путь_до_файла);                        
Files.lines(path, StandardCharsets.UTF_8)                    
        .collect(Collectors.joining(System.lineSeparator()));

Обратите внимание, что для объединения строк здесь используется платформенно-независимый метод System.lineSeparator(), возращающий один или два символа перевода строки в зависимости от вашей ОС.

Запись коллекции строк в файл

Похожим образом производится и запись строк в файл:

Path path = Paths.get("путь_до_файла");
Files.write(path, Arrays.asList("first line", "second line"), StandardCharsets.UTF_8);

На выходе получим файл, состоящий из двух строк.

6 июня 2016 г.

Spring Boot Restful Service

Актуальная версия статьи доступна на моём новом сайте devmark.ru

Что мы получим в результате

Простой сервис, который при выполнении get-запроса будет возращать профиль пользователя в формате json в зависимости от id, который передаётся в запросе. При возникновении исключительных ситуаций (например, профиль не найден), пользователь получит соответствующий ответ и это будет зафиксировано в логе.

Требования

Java 8, Spring Boot 1.3.5, Maven 3

Исходники

https://github.com/nordmine/spring-boot-restful-service

Реализуем обработку get-запроса


Сразу оговорюсь, что здесь рассмотрю только создание самого веб-сервиса. Чаще всего, он будет обращаться к базе для получения профиля пользователя. Мы же этого здесь делать не будем, а только сымитируем загрузку профиля по id. Но всё, что касается взаимодействия по http, будет работать как положено.

Spring Boot позволяет просто и без лишних телодвижений создавать веб-сервисы. При этом конфигурацию служебных бинов он берёт на себя. При этом вы всегда можете переопределить дефолтное поведение, объявив тот или иной бин явно.

Давайте создадим maven-проект, в котором в качестве parent укажем spring-boot-starter-parent. Также нам потребуется добавить одну зависимость spring-boot-starter-web. Этого вполне достаточно для нашего проекта.

<parent>                                                
    <groupId>org.springframework.boot</groupId>         
    <artifactId>spring-boot-starter-parent</artifactId> 
    <version>1.3.5.RELEASE</version>                    
</parent>                                               
                                                        
<dependencies>                                          
    <dependency>                                        
        <groupId>org.springframework.boot</groupId>     
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>                                       
</dependencies>                                         

Для упрощения процедуры развёртывания добавим spring-boot-maven-plugin. Он создаст нам один jar-файл со всеми необходимыми зависимостями внутри, а также определит точку запуска для нашего приложения.

<build>                                                      
    <plugins>                                                
        <plugin>                                             
            <groupId>org.springframework.boot</groupId>      
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>                                            
    </plugins>                                               
</build>

Теперь добавим класс developer.remarks.app.RestfulApplication, который будет содержать единственный статический метод main. Он и будет отправной точкой при старте нашего приложения.

@SpringBootApplication(scanBasePackages = "developer.remarks")
public class RestfulApplication {

    public static void main(String[] args) {
        SpringApplication.run(RestfulApplication.class, args);
    }
}

Обратите внимание на аннотацию @SpringBootApplication. По сути это замена трёх стандартных для спринга аннотаций @Configuration (программная конфигурация бинов), @EnableAutoConfiguration (автоматически создавать необходимые бины), @ComponentScan (где искать бины). Также важно указать параметр scanBasePackages. Он содержит базовый пакет, в котором Spring Boot будет искать бины. Если этого не сделать или указать пакет неправильно, у вас ничего работать не будет. Вместо пакета можно также явно указать конкретный класс.

А если перенести класс RestfulApplication в пакет developer.remarks, то и scanBasePackages указывать не обязательно, т.к. все бины в пределах этого пакета (включая вложенные) будут подтягиваться автоматически. Удобно, однако производя глобальные рефакторинги в больших проектах об этом можно легко забыть и внезапно вы обнаружите неработающее приложение. Причём ваша среда разработки такую ошибку скорее всего не обнаружит. Поэтому я за явные параметры в аннотациях.

Теперь давайте создадим класс профиля пользователя. Именно в нём содержатся все поля, которые будет возвращать наш сервис (уникальный id пользователя, его имя и фамилия). Это простой бин, которому даже не требуется специальных аннотаций. Однако будьте внимательны: Spring многое делает автоматически, но он не увидит те поля, для которых явно не сгенерены getter'ы.

public class Profile {

    private final int id;
    private final String firstName;
    private final String lastName;

    public Profile(int id, String firstName, String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public int getId() {
        return id;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }
}

Теперь мы можем добавить сервис, содержащий уровень бизнес-логики. Как я уже говорил, там может быть обращение к БД, но мы ограничимся лишь имитацией: если id = 2, то возвращаем некий профиль, иначе говорим, что профиль с указанным номером не существует.

@Component
public class ProfileService {

    public Profile getProfile(int personId) {
        // имитируем обращение к БД
        if (personId == 2) {
            return new Profile(personId, "Иван", "Петров");
        } else {
            throw new ProfileNotFoundException(personId);
        }
    }
}

Аннотация @Component гарантирует, что Spring создаст только один экземпляр данного класса. И это правильно, т.к. сервис не содержит внутренних состояний. Иными словами, любые запросы к сервису можно выполнять в любой последовательности.

ProfileNotFoundException наследуется от RuntimeException и не требует явного указания в сигнатуре метода, т.е. это исключение непроверяемое (unchecked). Это исключение отличается от стандартного тем, что содержит id профиля пользователя, который привёл к ошибке, а также переопределяет метод getMessage(), чтобы клиент, выполняющий запрос, получил более понятное описание ошибки.

Теперь мы готовы добавить в наш проект rest-контроллер. Его мы снабдим соответствующей аннотацией @RestController:

@RestController
@RequestMapping(value = "/profile", produces = MediaType.APPLICATION_JSON_VALUE)
public class ProfileController {

    private final ProfileService profileService;

    @Autowired
    public ProfileController(ProfileService profileService) {
        this.profileService = profileService;
    }

    @RequestMapping(value = "/{personId:\\d+}", method = RequestMethod.GET)
    @ResponseBody
    public Profile getProfile(@PathVariable String personId) {
        return profileService.getProfile(Integer.valueOf(personId));
    }
}

Обратите внимание на аннотацию @RequestMapping. С её помощью мы указываем, что данный контроллер обрабатывает все http-запросы, выполняемые по адресу адрес_сервера:8080/profile/*. Если вы запускаете сервис на локальной машине, то адресом сервера будет, разумеется, localhost. Также через параметр produces мы указываем, что сервис возвращает ответ в формате json.

Далее целевой метод аналогичным образом мапится уже на get-запрос адрес_сервера:8080/profile/ид_пользователя. Причём номер пользователя должен содержать только цифры. Spring автоматически поместит значение из адреса в переменную personId благодаря аннотации @PathVariable.

В самом методе мы просто конвертируем полученный номер пользователя из строки в целое число (можем это делать благодаря регулярке в @RequestMapping) и дёргаем соответствующий метод у нашего сервиса, который инжектим в данный контроллер через конструктор при помощи аннотации @Autowired. Можно было бы инжектить и напрямую в поле, но мировая общественность выступает за то, что удобнее это делать через конструктор. В частности, это облегчает написание юнит-тестов.

Очень важно здесь отметить, что мы инкапсулируем всю бизнес-логику в ProfileService и изолируем её от непосредственного rest-взаимодействия. Таким образом, если завтра нам помимо json потребуется добавить ещё и xml-контроллер, мы легко это сделаем без копипаста, задействовав тот же ProfileService.

В принципе, можете собирать приложение мавеном (mvn clean package) и запускать его через java -jar.

java -jar target/spring-boot-restful-service-1.0-SNAPSHOT.jar

Если выполнить get-запрос по адресу http://localhost:8080/profile/2, вы получите в ответ следующий json:

{"id":2,"firstName":"Иван","lastName":"Петров"}

Добавляем обработку ошибок


Сервис вроде бы работает. Однако что получит пользователь, если профиль не найден? Желательно, чтобы он получал корректное описание ошибки также в формате json.

Spring Boot позволяет перехватывать все исключения, возникающие в каком-либо из контроллеров, при помощи аннотации @ControllerAdvice.

@ControllerAdvice
public class ErrorController {
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public ErrorInfo processException(Exception e) {
        return new ErrorInfo(e.getMessage());
    }
}

В @ExceptionHandler указывается одно или несколько перехватываемых исключений. Если требуется указать более одного, их нужно взять в фигурные скобки. ErrorInfo - также обычный бин, который содержит текстовое описание ошибки. Здесь тоже нельзя забывать про добавление getter'ов. В идеале, сюда бы ещё добавить код ошибки для упрощения отладки и поиска багов.

Теперь, если выполнить запрос http://localhost:8080/profile/1, вы получите ответ:

{"message":"Profile with id = 1 not found"}

Обратите внимание, что текст сообщения определяется в методе getMessage() перехваченного исключения. То есть различные исключения у нас обрабатываются единообразно.

Добавляем логирование

Хорошо, клиенту мы ошибку вернули, но нам бы зафиксировать факт этой ошибки для дальнейших разбирательств. Пришла пора добавить логирование.

Spring Boot по умолчанию использует библиотеку логирования logback и ищет конфигурационный файл logback-spring.xml в проекте. Полное содержимое этого файла вы можете посмотреть в git-репозитории, а я здесь приложу только значимые части.

Сначала определяем appender. В нём указывается, как будет называться файл с логами, а также политика его ротации. В данном случае, если размер лога превысит 10 МБ, будет создан новый файл, а старый будет переименован и получит индекс.

    <appender name="error-controller" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
        <file>${LOG_PATH}/exceptions.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
            <fileNamePattern>${LOG_PATH}/exceptions.log.%i</fileNamePattern>
        </rollingPolicy>
        <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
            <MaxFileSize>10MB</MaxFileSize>
        </triggeringPolicy>
    </appender>

Затем определяем сам logger, ссылаясь на этот аппендер:

<logger name="developer.remarks.controller.ErrorController" level="INFO">
    <appender-ref ref="error-controller" />                              
</logger>

Здесь мы явно указываем имя класса, который должен логировать в указанный файл. Мы также можем указать не конкретный класс, а целый пакет. Но делать это нужно аккуратно, если у нас имеется множество аппендеров и логгеров. Вполне возможна ситуация, когда одни и те же сообщения будут записываться в разные файлы.

Модифицируем наш ErrorController для ведения логов:

@ControllerAdvice
public class ErrorController {

    private Logger logger = LoggerFactory.getLogger(ErrorController.class);

    @ExceptionHandler(Exception.class)
    @ResponseBody
    public ErrorInfo processException(Exception e) {
        logger.error("Unexpected error", e);
        return new ErrorInfo(e.getMessage());
    }
}

При запуске также рекомендую явно указывать путь к директории с логами при помощи параметра logging.path. Итого, полная строка запуска нашего restful-сервиса:

java -jar target/spring-boot-restful-service-1.0-SNAPSHOT.jar -Dlogging.path=путь_до_директории_с_логами


Вот и всё. Если после прочтения статьи возникли вопросы, пишите их в комментариях. Напоминаю, что более подробно работающую версию вы можете посмотреть здесь: https://github.com/nordmine/spring-boot-restful-service.