GrabDuck

Переписываем домашний проект на микросервисы (Java, Spring Boot, Gradle)

:

Введение

Image

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

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

Всё это было реализовано в виде прототипа — были пользователи, один урок и одна задача для него, возможность отправить код, который компилировался и исполнялся. Кое-какой фронтенд, но в статье о нём речи не будет. Технологии — Spring Boot, Spring Data, Gradle.

В статье будет реализован такой же прототип, но уже на микросервисах. Реализация будет наиболее простым путём (точнее наиболее простым, из известных мне). Реализация будет доступна любому, кто знаком со Spring.

В процессе изучения информации я нашёл хорошую статью, где аналогично разбивали некий небольшой монолит на микросервисы. Но там всё делалось на основе Spring Cloud, что безусловно правильнее, но мне хотелось сначала написать велосипед, чтобы потом на практике понимать от каких проблем лечат данные решения. Из данной статьи я использовал только Zuul.


Микросервисы

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

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


  • user-service: сервис с пользователями (создание, просмотр, возможно авторизация)
  • lesson-service: сервис с уроками (создание, просмотр уроков и задач)
  • result-service: сервис с ответами (отправка выполненных задач, хранение результатов)
  • task-executor-service: сервис исполнения кода (компиляция и исполнение задач)

На этом этапе появляется мысль, что со всем этим зоопарком нужно как-то общаться фронтенду и отдельным микросервисам. Кажется неудобным, если все будут знать API и адреса друг друга.
Отсюда появляется ещё один сервис — gateway-service — общая точка входа.

Схема проекта будет выглядеть так:

diagram


Gateway-service

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

build.gradle сервиса выглядит так:

plugins {
    id 'java'
    id 'war'
}

apply plugin: 'spring-boot'

springBoot {
    mainClass 'gateway.App'
}

dependencies {
    compile('org.springframework.cloud:spring-cloud-starter-zuul:1.2.0.RELEASE')
    compile('org.springframework.boot:spring-boot-starter-web')
}

А весь код микросервиса состоит из одного класса, App.java:

@SpringBootApplication
@EnableZuulProxy
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

Это включает ZuulProxy. Маршрутизация описывается в конфиге, у меня это application.properties:

zuul.routes.lesson-service.url=http://localhost:8081
zuul.routes.user-service.url=http://localhost:8082
zuul.routes.task-executor-service.url=http://localhost:8083
zuul.routes.result-service.url=http://localhost:8084

zuul.prefix=/services

Таким образом, запросы на /services/lesson-service/... будут направляться на http://localhost:8081/... и т.д. Получается очень удобное и простое решение для точки входа.
Zuul имеет много других различных фич типа фильтров, но нам от него больше ничего и не нужно.

Фронтенд так же, как мне кажется, в нашем случае должен отдаваться клиенту отсюда. Кладём всё что нужно в gateway-service/src/main/webapp/... и всё.


Остальные сервисы

Остальные сервисы будут сильно похожи друг на друга и их реализация мало чем отличается от привычного подхода. Но тут есть несколько моментов:


  1. Базы данных.
  2. Взаимодействие между микросервисами.

Базы данных

Каждый микросервис теперь может сам решать как ему хранить данные. Традиционным преимуществом микросервисов является как раз свобода выбора технологий независимо от других частей приложения.

Можно для каждого использовать новый тип базы данных. Но у меня просто появились три MySQL базы данных вместо одной, для user-service, lesson-service и answer-service. A task-executor-service должен хранить некий код задач, в который вставляется пользовательский код для выполнения задачи. Это будет храниться без БД, просто в виде файлов.

В момент разделения схемы на три базы у меня с непривычки возник вопрос — а как же внешние ключи, целостность данных на уровне бд и всё такое. Как оказалось никак. Точнее — всё на уровне бизнес-логики.


Взаимодействие между микросервисами

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

Создаём новый модуль в проекте, назовём его service-client. В нём будут, во-первых, классы для взаимодействия с сервисами, во-вторых общие классы для передачи данных. То есть у каждого сервиса есть свои какие-то Entity, соответствующие внутренней логике или схеме БД, но наружу они должны отдавать только экземпляры объектов из общей библиотеки.

Для классов-клиентов пишем абстрактный класс Client:

abstract class Client {
    private final RestTemplate rest;
    private final String serviceFullPath;

    private final static String GATEWAY_PATH = "http://localhost:8080/services";

    Client(final String servicePath) {
        this.rest = new RestTemplate(Collections.singletonList(new MappingJackson2HttpMessageConverter()));
        this.serviceFullPath = GATEWAY_PATH + servicePath;
    }

    protected <T extends Result> T get(final String path, final Class<T> type) {
        return rest.getForObject(serviceFullPath + path, type);
    }

    protected <T extends Result, E> T post(final String path, final E object, final Class<T> type) {
        return rest.postForObject(serviceFullPath + path, object, type);
    }
}

GATEWAY_PATH — лучше задавать из конфига или ещё как-то, а не хардкодить в этом классе.
И пример наследования этого класса для lesson-service:

public class TaskClient extends Client {
    private static final String SERVICE_PATH = "/lesson-service/task/";

    public TaskClient() {
        super(SERVICE_PATH);
    }

    public Task get(final Long id) {
        return get(id.toString(), TaskResult.class).getData();
    }

    public List<Task> getList() {
        return get("", TaskListResult.class).getData();
    }

    public List<Task> getListByLesson(final Long lessonId) {
        return get("/getByLesson/" + lessonId, TaskListResult.class).getData();
    }

    public Task add(final TaskCreation taskCreation) {
        return post( "/add", taskCreation, TaskResult.class).getData();
    }
}

Тут может возникнуть вопрос, что за Result и почему мы возвращаем результат от getData() для него. Каждый контроллер возвращает не просто некий запрашиваемый объект в json, а ещё и дополнительную мета-информацию, которая в дальнейшем может быть полезна, поэтому при переписывании в микросервисы я не убрал это. То есть возвращается объект класса Result<T>, где T это сам запрашиваемый объект:

@Data
public class Result<T> {
    public String message;
    public T data;

    public static <T> Result<T> success(final T data) {
        return new Result<>(null, data);
    }

    public static <T> Result<T> error(final String message) {
        return new Result<>(message, null);
    }

    public static <T> Result<T> run(final Supplier<T> function ) {
        final T result = function.get();
        return Result.success(result);
    }
}

Тут нет метода getData(), хотя ранее в коде он используется. Всё это благодаря аннотации @Data от lombok, который я активно использовал.Result удобен тем, что далее можно легко добавлять некую мета-информацию (например время выполнения запроса), и как-то её использовать.

Теперь, чтобы использовать написанный нами код в других модулях, достаточно добавить зависимость (compile project(':service-client') в блок dependencies) и создать такой бин. Вот так выглядит конфигурация result-service:

@SpringBootApplication(scanBasePackages = "result")
@EnableJpaRepositories("result.repository")
@Configuration
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

    @Bean
    public UserClient getUserClient() {
        return new UserClient();
    }

    @Bean
    public TaskClient getTaskClient() {
        return new TaskClient();
    }

    @Bean
    public ExecutorClient getTaskExecutor() {
        return new ExecutorClient();
    }
}

Его контроллер:

@RestController
@RequestMapping
public class ResultController {

    @Autowired
    private ResultService service;

    @RequestMapping(value = "/submit", method = RequestMethod.POST)
    public Result<TaskResult> submit(@RequestBody final SubmitRequest submit){
        return run(() -> service.submit(submit));
    }

    @RequestMapping(value = "/getByTask/{id}", method = RequestMethod.GET)
    public Result<List<AnswerEntity>> getByTask(@PathVariable final Long id) {
        return run(() -> service.getByTask(id));
    }
}

Видно, что везде контроллер возвращает некий Result<T>. И фрагмент сервиса:

@Service
@Transactional
public class ResultService {

    @Autowired
    private AnswerRepository answerRepository;

    @Autowired
    private TaskClient taskClient;

    @Autowired
    private ExecutorClient executorClient;

    public TaskResult submit(final SubmitRequest submit) {

        val task = taskClient.get(submit.getTaskId());
        if (task == null)
            throw new RuntimeException("Invalid task id");

        val result = executorClient.submit(submit);

        val answerEntity = new AnswerEntity();
        answerEntity.setAnswer(submit.getCode());
        answerEntity.setTaskId(task.getId());
        answerEntity.setUserId(1L);
        answerEntity.setCorrect(result.getStatus() == TaskResult.Status.SUCCESS);
        answerRepository.save(answerEntity);

        return result;
    }

    ...

answerEntity.setUserId(1L) — пока тут просто константа, ибо пока что совершенно непонятно как делать авторизацию.

В целом основную часть сделали, по образцу реализуем все остальные сервисы и всё должно работать. Но остаётся ещё разобраться с пользователями и их авторизацией. Это оказалось самой сложной частью для меня.


Авторизация

Раньше, до разбивки на микросервисы, авторизация была стандартная — по логину и паролю пользователь авторизировался в пределах контекста приложения.

Теперь задача расширяется, и каждый из сервисов должен понимать, авторизован ли пользователь, по которому пришёл к сервису запрос. И это при том, что запросы приходят не только непосредственно от пользователя, но и от других сервисов.

Первоначальный поиск привёл меня на разнообразные статьи, показывающие как шарить сессии с помощью Redis, но прочитанное мною показалось слишком сложным для hello-world домашнего проекта. Через некоторое время, вернувшись к вопросу, я уже нашёл информацию о JWT — JSON Web Token. Кстати, повторяя попытки поиска при написании этой статьи, я уже сразу натыкался на JWT.

Идея проста — вместо куков, на которых обычно держится авторизация, авторизирующий сервис будет выдавать некий токен, который в себя включает данные о пользователе, время выдачи и прочую нужную вам информацию. Затем, при любом обращении к сервисам, клиент должен передавать этот токен в заголовке (или как-либо ещё, как удобнее). Каждый сервис умеет его расшифровывать и понимать что это за пользователь, и ему не нужно лезть в базу и всё такое.

Тут возникает множество проблем, например как отзывать токен. Появляются идеи с несколькими токенами (длительного действия и короткого, второй используем для обычных запросов, первый для получения нового токена второго типа и как раз первый можно отозвать и для его проверки нужно лезть в БД).

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

Но у нас hello-world проект, поэтому нам не нужна серьёзная и совсем правильная авторизация, а нужно что-то, что можно быстро реализовать, но что тем не менее будет работать достаточно хорошо.

Итак, немного почитав интернеты, например эту статью, решаем что токен будет всего один и его будет выдавать user-service. Добавляем зависимости:

compile('org.springframework.boot:spring-boot-starter-security')
compile('io.jsonwebtoken:jjwt:0.7.0')

Второе необходимо как раз для генерации самого токена. Токен генерируем следующим образом на запрос с верным логином и паролем:

private String getToken(final UserEntity user) {
    final Map<String, Object> tokenData = new HashMap<>();
    tokenData.put(TokenData.ID.getValue(), user.getId());
    tokenData.put(TokenData.LOGIN.getValue(), user.getLogin());
    tokenData.put(TokenData.GROUP.getValue(), user.getGroup());
    tokenData.put(TokenData.CREATE_DATE.getValue(), new Date().getTime());
    Calendar calendar = Calendar.getInstance();
    calendar.add(Calendar.DATE, tokenDaysAlive);
    tokenData.put(TokenData.EXPIRATION_DATE.getValue(), calendar.getTime());
    JwtBuilder jwtBuilder = Jwts.builder();
    jwtBuilder.setExpiration(calendar.getTime());
    jwtBuilder.setClaims(tokenData);
    return jwtBuilder.signWith(SignatureAlgorithm.HS512, key).compact();
}

key тут это секретный ключ токена, который должны знать все сервисы для декодирования токена. Мне не понравилось, что его нужно писать в конфиг каждого сервиса, но другие варианты сложнее.

Далее нам нужно написать фильтр, который при каждом запросе будет проверять токен и авторизировать если всё ок. Но фильтр уже будет не в user-service, а в service-client, т.к. это общий код для всех сервисов.

Сам фильтр:

public class TokenAuthenticationFilter extends GenericFilterBean {

    private final TokenService tokenService;

    public TokenAuthenticationFilter(final TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        final String token = ((HttpServletRequest) request).getHeader(TokenData.TOKEN.getValue());
        if (token == null) {
            chain.doFilter(request, response);
            return;
        }

        final TokenAuthentication authentication = tokenService.parseAndCheckToken(token);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        chain.doFilter(request, response);
    }
}

Если token не прислали — не делаем ничего, иначе пытаемся авторизировать клиента. Проверка авторизации осуществляется уже не нами, а дальше в другом (стандартном) фильтре от spring security. TokenService, где происходит непосредственная проверка токена:

public class TokenService {

    private String key;

    public void setKey(String key) {
        this.key = key;
    }

    public TokenAuthentication parseAndCheckToken(final String token) {
        DefaultClaims claims;
        try {
            claims = (DefaultClaims) Jwts.parser().setSigningKey(key).parse(token).getBody();
        } catch (Exception ex) {
            throw new AuthenticationServiceException("Token corrupted");
        }

        if (claims.get(TokenData.EXPIRATION_DATE.getValue(), Long.class) == null) {
            throw new AuthenticationServiceException("Invalid token");
        }

        Date expiredDate = new Date(claims.get(TokenData.EXPIRATION_DATE.getValue(), Long.class));
        if (!expiredDate.after(new Date())) {
            throw new AuthenticationServiceException("Token expired date error");
        }

        Long id = claims.get(TokenData.ID.getValue(), Number.class).longValue();
        String login = claims.get(TokenData.LOGIN.getValue(), String.class);
        String group = claims.get(TokenData.GROUP.getValue(), String.class);

        TokenUser user = new TokenUser(id, login, group);

        return new TokenAuthentication(token, true, user);
    }
}

TokenData это enum для удобства, из которого можно взять строковые представления полей. Ещё тут есть два класса — TokenUser (это класс с тремя полями) и TokenAuthentication:

public class TokenAuthentication implements Authentication {
    private String token;
    private Collection<? extends GrantedAuthority> authorities;
    private boolean isAuthenticated;
    private TokenUser principal;

    public TokenAuthentication(String token, boolean isAuthenticated,
                               TokenUser principal) {
        this.token = token;
        this.authorities = Collections.singletonList(new SimpleGrantedAuthority(principal.getGroup()));
        this.isAuthenticated = isAuthenticated;
        this.principal = principal;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getDetails() {
        return null;
    }

    @Override
    public String getName() {
        if (principal != null)
            return principal.getLogin();
        else
            return null;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }

    @Override
    public boolean isAuthenticated() {
        return isAuthenticated;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        this.isAuthenticated = isAuthenticated;
    }

    public String getToken() {
        return token;
    }

}

Конфиг user-service теперь будет выглядеть так:

@SpringBootApplication
@EnableJpaRepositories("user.repository")
@Configuration
@ComponentScan(value = "user")
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled=true)
public class App extends WebSecurityConfigurerAdapter {

    @Value("${token.key}")
    private String tokenKey;

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

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .headers().frameOptions().sameOrigin()
                .and()
                .csrf()
                .disable()
                .addFilterAfter(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean(name = "tokenAuthenticationFilter")
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter(tokenService());
    }

    @Bean(name = "tokenService")
    public TokenService tokenService() {
        TokenService tokenService = new TokenService();
        tokenService.setKey(tokenKey);
        return tokenService;
    }
}

Ключевое тут — .addFilterAfter(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class), регистрируем фильтр и указываем когда он должен запускаться. Ограничивать же доступ к ресурсам мне нравится не в конфиге, а аннотациями над методами контроллеров, например @Secured("ROLE_ADMIN").

Появление токена делает необходимым приём его не только от клиента, но и от других сервисов, соответственно нужно его уметь и отправлять дальше. Для этого я просто принимаю из заголовков токен в контроллерах, где он необходим, и передаю его в методы сервис-клиента. То есть у класса Client становится по два метода get и post, для случае с токеном и без него, пример:

protected <T extends Result> T get(final String path, final Class<T> type) {
    return rest.getForObject(serviceFullPath + path, type);
}

protected <T extends Result> T get(final String path, final Class<T> type, final String token) {
    HttpHeaders headers = new HttpHeaders();
    headers.set(TokenData.TOKEN.getValue(), token);
    HttpEntity entity = new HttpEntity(headers);
    return rest.exchange(serviceFullPath + path, HttpMethod.GET, entity, type).getBody();
}

Соответствующим образом меняются конкретные классы-клиенты. А в контроллере мы получаем токен с помощью аннотации, например:

@PreAuthorize("isAuthenticated()")
@RequestMapping(value = "/submit", method = RequestMethod.POST)
public Result<TaskResult> submit(@RequestBody final SubmitRequest submit, @RequestHeader("token") String token){
    return run(() -> service.submit(submit, token));
}

Хоть это и простое решение, оно кажется неудобным — постоянная возня с токеном, его приём, передача в методы. Более правильным мне кажется было бы сделать всё тоже самое автоматическим.

При авторизации по токену запоминать его (это почти уже сделано, можно посмотреть на класс TokenAuthentication), а при использовании классов Client автоматически доставать токен, если он есть, и передавать в следующий сервис.

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


Запуск

Есть не один готовый продукт, позволяющий разворачивать и управлять микросервисами, следить за их состоянием и прочее, но мне кажется, что это уже выходит за пределы hello-world. Для запуска я просто выполняю bootRun для каждого сервиса и наслаждаюсь результатом.


Заключение

В целом это был интересный и полезный опыт (писать велосипеды всегда интересно), но если бы я хотел развивать свой проект дальше — я бы откатил все изменения и продолжил бы работу над классическим монолитом, т.к. на масштабах такого небольшого проекта сложность управления этим всем сильно возросла, а преимуществ мало.

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

Надеюсь, статья была полезна.

→ Полный исходный код проекта можно найти тут.