Java REST в Школе Программистов HeadHunter

в 7:07, , рубрики: java, RESTful, web-разработка, Блог компании HeadHunter

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

Java REST в Школе Программистов HeadHunter - 1

Всем привет. В этом году я окончил Школу Программистов hh и в этом посте расскажу об учебном проекте, в котором участвовал. Во время обучения в школе, и в особенности на проекте, мне не хватало примера боевого приложения (а еще лучше гайда), в котором можно было бы подсмотреть, как правильно разделить логику и построить масштабируемую архитектуру. Все статьи, которые я находил, были трудны для понимания новичка, т.к. либо в них активно применяли IoC без исчерпывающих объяснений, как добавить новые компоненты или модифицировать старые, либо они были архаичными и содержали тонну конфигов на xml и фронтенда на jsp. Я же старался ориентироваться на свой уровень до обучения, т.е. практически нулевой с небольшими оговорками, так что эта статья должна стать полезной для будущих учеников школы, а также самоучек-энтузиастов, решивших начать писать на java.

Дано (постановка задачи)

Команда — 5 человек. Срок — 3 месяца, в конце каждого — демо. Цель — сделать приложение, помогающее HR сопровождать сотрудников на испытательном сроке, автоматизируя все процессы, какие получится. На входе нам объяснили, как сейчас устроен испытательный срок (ИС): как только становится известно, что выходит новый сотрудник, HR начинает пинать будущего руководителя, чтобы тот поставил задачи на ИС, причем это нужно успеть сделать до первого рабочего дня. В день выхода сотрудника на работу HR проводит welcome-встречу, рассказывает об инфраструктуре компании и вручает задачи на ИС. Спустя 1,5 и 3 месяца проводятся промежуточная и итоговая встречи HR, руководителя и сотрудника, на которых обсуждаются успехи прохождения и составляется бланк о результатах. В случае успеха, после итоговой встречи сотруднику вручают распечатанный опросник новичка (вопросы в стиле «оцените удовольствие от ИС») и заводят на HRов задачу в jira оформить сотруднику ДМС.

Десигн

Мы решили сделать для каждого сотрудника персональную страницу, на которой будет отображена общая информация (ФИО, отдел, руководитель, и т. п.), поле для комментариев и истории изменений, прикрепленные файлы (задачи на ИС, опросник) и воркфлоу сотрудника, отражающий уровень прохождения ИС. Воркфлоу было решено разбить на 8 этапов, а именно:

  • 1-й этап — добавление сотрудника: становится выполненным сразу после регистрации нового сотрудника в системе HRом. При этом HRу отправляются три календаря на велком, промежуточную и итоговую встречу.
  • 2-й этап — согласование задач на ИС: руководителю отправляется форма для постановки задач на ИС, которую после заполнения получит HR. Далее HR распечатывает их, подписывает и ставит в интерфейсе отметку о завершении этапа.
  • 3-й этап — welcome-встреча: HR проводит встречу и нажимает кнопку «Этап завершен».
  • 4-й этап — промежуточная встреча: аналогично третьему этапу
  • 5-й этап — результаты промежуточной встречи: HR заполняет результаты на странице сотрудника и нажимает «Далее».
  • 6-й этап — итоговая встреча: аналогично третьему этапу
  • 7-й этап — результаты итоговой встречи: аналогично пятому этапу
  • 8-й этап — завершение ИС: в случае успешного прохождения ИС сотруднику на e-mail высылается ссылка с формой опросника, а в jira автоматически создается задача на оформление ДМС (до нас задачу заводили руками).

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

Эксплуатация

Одна из целей школы — подготовить учеников к работе в крупных проектах, поэтому процесс выпуска задач у нас был подобающим.
По окончании работы над задачей мы отдаем ее на ревью_1 другому ученику из команды для исправления очевидных ошибок/обмена опытом. Затем происходит ревью_2 — задачку проверяют два ментора, которые следят за тем, чтобы мы не выпускали говнокод на пару с ревьювером_1. Далее предполагалось тестирование, но этот этап не очень целесообразен, учитывая масштаб школьного проекта. Так что пройдя ревью мы считали, что задача готова к выпуску.
Теперь пару слов про деплой. Приложение должно быть все время доступно в сети с любых компьютеров. Для этого мы купили дешевенькую виртуалку (за 100 руб/мес), но, как я узнал позже, все можно было устроить бесплатно и по-модному в докере на AWS. Для непрерывной интеграции мы выбрали Travis. Если кто не знает (лично я до школы вообще не слышал про continuous integration), это такая крутая штука, которая будет мониторить ваш github и при появлении нового коммита (как настроите) собирать код в jar, отправлять на сервер и перезапускать приложение автоматически. Как именно проводить сборку, описывается в трэвисовском ямле в корне проекта, он достаточно похож на bash, так что думаю комментариев не потребуется. Также мы купили домен www.adaptation.host, чтобы не прописывать некрасивый айпишник в адресную строку на демо. Еще мы настроили postfix (для отправки почты), apache (не nginx, т. к. apache был из коробки) и сервер jira (trial). Фронтенд и бекенд сделали двумя отдельными сервисами, которые будут общаться по http (#2к18, #микросервисы). На этом часть статьи «в школе программистов HeadHunter» плавно заканчивается, и мы переходим к java rest service.

Бекенд

0. Введение

Мы использовали следующие технологии:

  • JDK 1.8;
  • Maven 3.5.2;
  • Postgres 9.6;
  • Hibernate 5.2.10;
  • Jetty 9.4.8;
  • Jersey 2.27.

В качестве фреймворка мы взяли NaB 3.5.0 от hh. Во-первых, он используется в HeadHunter, а во-вторых, из коробки содержит jetty, jersey, hibernate, embedded postgres, о чем написано на гитхабе. Уточню коротко для начинающих: jetty — это веб-сервер, который занимается идентификацией клиентов и организации сессии для каждого из них; jersey — фреймворк, помогающий удобно создавать RESTful сервис; hibernate — ORM для упрощения работы с базой; maven — сборщик java проекта.
Покажу простой пример, как с этим работать. Я создал небольшой тестовый репозиторий, в который добавил две сущности: пользователя и резюме, а также ресурсы их создания и получения со связью OneToMany/ManyToOne. Для запуска достаточно склонировать репозиторий и выполнить mvn clean install exec:java в корне проекта. Прежде чем комментировать код, расскажу про структуру нашего сервиса. Она выглядит примерно так:

Java REST в Школе Программистов HeadHunter - 2

Основные директории:

  • Services — главная директория в приложении, здесь хранится вся бизнес-логика. В других местах работы с данными без веских причин быть не должно.
  • Resources — обработчики урлов, прослойка между сервисами и фронтендом. Здесь допускается валидация входящих данных и конвертация выходящих, но не бизнес-логика.
  • Dao (Data Access Object) — прослойка между базой и сервисами. В дао должны содержаться только фундаментальные базовые операции: добавить, считать, обновить, удалить один/все.
  • Entity — объекты, которыми ORM обменивается с базой. Как правило, они напрямую соответствуют таблицам и должны содержать все поля, что и сущность в базе с соответствующими типами.
  • Dto (Data Transfer Object) — аналог энтити, только для ресурсов (фронта), помогает формировать json из данных, которые мы хотим отправить/получить.

1. База

По-хорошему следовало бы использовать рядом установленный postgres, как в основном приложении, но я хотел чтобы тестовый пример был простым и запускался одной командой, поэтому взял встроенную HSQLDB. Подключение базы в нашу инфраструктуру осуществляется путем добавления DataSource в ProdConfig (также не забудьте сказать hibernate, какую базу вы используете):

@Bean(destroyMethod = "shutdown")
DataSource dataSource() {
  return new EmbeddedDatabaseBuilder()
    .setType(EmbeddedDatabaseType.HSQL)
    .addScript("db/sql/create-db.sql")
    .build();
}

Скрипт создания таблиц я вынес в файл create-db.sql. Вы можете добавить и другие скрипты, которые проинициализируют базу данными. В нашем легковесном примере с in_memory базой можно было обойтись вообще без скриптов. Если в настройках hibernate.properties указать hibernate.hbm2ddl.auto=create, то hibernate сам создаст таблицы по entity при запуске приложения. Но если понадобится иметь в базе что-то, чего в entity нет, то без файлика не обойтись. Лично я привык разделять базу и приложение, поэтому обычно не доверяю hibernate заниматься такими делами.
db/sql/create-db.sql:

CREATE TABLE employee
(
  id              INTEGER IDENTITY PRIMARY KEY,
  first_name      VARCHAR(256) NOT NULL,
  last_name       VARCHAR(256) NOT NULL,
  email           VARCHAR(128) NOT NULL
);

CREATE TABLE resume
(
  id                INTEGER IDENTITY PRIMARY KEY,
  employee_id     INTEGER NOT NULL,
  position        VARCHAR(128) NOT NULL,
  about            VARCHAR(256) NOT NULL,
  FOREIGN KEY (employee_id) REFERENCES employee(id)
);

2. Entity

entities/employee:

@Entity
@Table(name = "employee")
public class Employee {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id", nullable = false)
  private Integer id;

  @Column(name = "first_name", nullable = false)
  private String firstName;

  @Column(name = "last_name", nullable = false)
  private String lastName;

  @Column(name = "email", nullable = false)
  private String email;

  @OneToMany(mappedBy = "employee")
  @OrderBy("id")
  private List<Resume> resumes;

  //..geters and seters..
}

entities/resume:

@Entity
@Table(name = "resume")
public class Resume {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Integer id;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "employee_id")
  private Employee employee;

  @Column(name = "position", nullable = false)
  private String position;

  @Column(name = "about")
  private String about;

  //..geters and seters..
}

Энтити ссылаются друг на друга не полем класса, а полностью объектом родителя/наследника. Таким образом, мы можем получить рекурсию, когда попытаемся взять из базы Employee, для которого вытянутся резюме, для которых… Чтобы этого не случилось, мы указали аннотации @OneToMany(mappedBy = "employee") и @ManyToOne(fetch = FetchType.LAZY). Они будут учтены в сервисе, при выполнении транзакции на запись/чтение из базы. Настройка FetchType.LAZY не обязательна, но использование ленивой связи облегчает транзакцию. Так, если в транзакции мы получаем из базы резюме и не обращаемся к его владельцу, то и сущность employee загружена не будет. Вы можете убедиться в этом сами: убрать FetchType.LAZY и посмотреть в дебаге, что возвращается из сервиса вместе с резюме. Но следует быть аккуратным — если мы не загрузили employee в транзакции, то обращение к полям employee вне транзакции может вызвать LazyInitializationException.

3. Dao

В нашем случае EmployeeDao и ResumeDao практически идентичны, поэтому приведу сюда только одну из них
EmployeeDao:

public class EmployeeDao {
  private final SessionFactory sessionFactory;

  @Inject
  public EmployeeDao(SessionFactory sessionFactory) {
    this.sessionFactory = sessionFactory;
  }

  public void save(Employee employee) {
    sessionFactory.getCurrentSession().save(employee);
  }

  public Employee getById(Integer id) {
    return sessionFactory.getCurrentSession().get(Employee.class, id);
  }
}

Аннотация @Inject означает, что в конструкторе нашего dao, используется Dependency Injection. В моей прошлой жизни физика, который парсил файлики, строил графики по результатам числаков и худо-бедно разобрался в ООП, в гайдах по java подобные конструкции казались чем-то невменяемым. И в школе, пожалуй, именно эта тема является самой неочевидной, имхо. К счастью, о DI есть множество материалов в интернете. Если совсем лень читать, то первый месяц можно придерживаться правила: новые ресурсы/сервисы/дао регистрируем в нашем контекст-конфиге, энтити добавляем в маппинг. Если есть необходимость использовать одни сервисы/дао в других, их нужно добавить в конструкторе с аннотацией inject, как показано выше, и спринг инициализирует все за вас. Но потом разобраться с DI все равно придется.

4. Dto

Dto, как и dao, практически идентичны для employee и resume. Рассмотрим здесь только employeeDto. Нам понадобится два класса: EmployeeCreateDto, необходимый при создании сотрудника; EmployeeDto, использующийся при получении (содержит дополнительные поля id и resumes). Поле id добавлено, чтобы в будущим, по запросам снаружи, мы могли работать с employee, не проводя предварительный поиск сущности по email. Поле resumes, чтобы получать сотрудника вместе со всеми его резюме в одном запросе. Можно было бы обойтись и с одной dto на все операции, но тогда для списка всех резюме конкретного сотрудника нам бы пришлось создавать дополнительный ресурс, вроде getResumesByEmployeeEmail, загрязнять код кастомными запросами к базе и перечеркивать все удобства предоставляемые ORM.
EmployeeCreateDto:

public class EmployeeCreateDto {
  public String firstName;
  public String lastName;
  public String email;
}

EmployeeDto:

public class EmployeeDto {
  public Integer id;
  public String firstName;
  public String lastName;
  public String email;
  public List<ResumeDto> resumes;

  public EmployeeDto(){
  }

 public EmployeeDto(Employee employee){
    id = employee.getId();
    firstName = employee.getFirstName();
    lastName = employee.getLastName();
    email = employee.getEmail();
    if (employee.getResumes() != null) {
     resumes = employee.getResumes().stream().map(ResumeDto::new).collect(Collectors.toList());
    }
  }
}

Еще раз обращаю внимание на то, что писать логику в dto настолько неприлично, что все поля обозначаются как public, чтобы не использовать геттеров и сеттеров.

5. Сервис

EmployeeService:

public class EmployeeService {

  private EmployeeDao employeeDao;
  private ResumeDao resumeDao;

  @Inject
  public EmployeeService(EmployeeDao employeeDao, ResumeDao resumeDao) {
    this.employeeDao = employeeDao;
    this.resumeDao = resumeDao;
  }

  @Transactional
  public EmployeeDto createEmployee(EmployeeCreateDto employeeCreateDto) {
    Employee employee = new Employee();
    employee.setFirstName(employeeCreateDto.firstName);
    employee.setLastName(employeeCreateDto.lastName);
    employee.setEmail(employeeCreateDto.email);

    employeeDao.save(employee);
    return new EmployeeDto(employee);
  }

  @Transactional
  public ResumeDto createResume(ResumeCreateDto resumeCreateDto) {
    Resume resume = new Resume();
    resume.setEmployee(employeeDao.getById(resumeCreateDto.employeeId));
    resume.setPosition(resumeCreateDto.position);
    resume.setAbout(resumeCreateDto.about);

    resumeDao.save(resume);
    return new ResumeDto(resume);
  }

  @Transactional(readOnly = true)
  public EmployeeDto getEmployeeById(Integer id) {
    return new EmployeeDto(employeeDao.getById(id));
  }

  @Transactional(readOnly = true)
  public ResumeDto getResumeById(Integer id) {
    return new ResumeDto(resumeDao.getById(id));
  }
}

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

6. Ресурсы

Наконец, добавим ресурсы создания и получения наших сущностей:
EmployeeResource:

@Path("/")
@Singleton
public class EmployeeResource {
  private final EmployeeService employeeService;

  public EmployeeResource(EmployeeService employeeService) {
    this.employeeService = employeeService;
  }

  @GET
  @Produces("application/json")
  @Path("/employee/{id}")
  @ResponseBody
  public Response getEmployee(@PathParam("id") Integer id) {
    return Response.status(Response.Status.OK)
        .entity(employeeService.getEmployeeById(id))
        .build();
  }

  @POST
  @Produces("application/json")
  @Path("/employee/create")
  @ResponseBody
  public Response createEmployee(@RequestBody EmployeeCreateDto employeeCreateDto){
    return Response.status(Response.Status.OK)
        .entity(employeeService.createEmployee(employeeCreateDto))
        .build();
  }

  @GET
  @Produces("application/json")
  @Path("/resume/{id}")
  @ResponseBody
  public Response getResume(@PathParam("id") Integer id) {
    return Response.status(Response.Status.OK)
        .entity(employeeService.getResumeById(id))
        .build();
  }

  @POST
  @Produces("application/json")
  @Path("/resume/create")
  @ResponseBody
  public Response createResume(@RequestBody ResumeCreateDto resumeCreateDto){
    return Response.status(Response.Status.OK)
        .entity(employeeService.createResume(resumeCreateDto))
        .build();
  }
}

Produces(“application/json”) нужен, чтобы json и dto корректно преобразовывались друг в друга. Он требует зависимости pom.xml:

<dependency>
  <groupId>org.glassfish.jersey.media</groupId>
  <artifactId>jersey-media-json-jackson</artifactId>
  <version>${jersey.version}</version>
</dependency>

Другие json-конверторы почему-то выставляют невалидный mediaType.

7. Результат

Запустим и проверим, что у нас получилось (mvn clean install exec:java в корне проекта). Порт, на котором запускается приложение, указывается в service.properties. Создадим пользователя и резюме. Я делаю это с помощью curl, но вы можете использовать postman, если презираете консоль.

curl --header "Content-Type: application/json" 
  --request POST 
  --data '{"firstName": "Jason", "lastName": "Statham", "email": "jasonst@t.ham"}' 
  http://localhost:9999/employee/create

curl --header "Content-Type: application/json" 
  --request POST 
  --data '{"employeeId": 0, "position": "Voditel", "about": "Opyt raboty perevozchikom 15 let"}' 
  http://localhost:9999/resume/create

curl --header "Content-Type: application/json" --request GET http://localhost:9999/employee/0

curl --header "Content-Type: application/json" --request GET http://localhost:9999/employee/0

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

Заключение

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

Java REST в Школе Программистов HeadHunter - 3
Java REST в Школе Программистов HeadHunter - 4

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

P.S. спустя некоторое время пережив шок от школы, остатки команды собрались и, проведя разбор полетов, решили сделать адаптацию 2.0, учтя все ошибки. Основная цель проекта та же — научиться делать серьезные приложения, строить продуманную архитектуру и быть востребованными специалистами на рынке. Вы можете следить за работой в том же репозитории. Пул-реквесты приветствуются. Спасибо за внимание и пожелайте нам удачи!

плюшки

видеолекция по ioc от hh

Автор: llev

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js