Введение


В данном туториале я хочу привести пример приложения для отправки email-ов юзерам, основываясь на дате их рождения(например с поздравлениями), используя аннотацию Scheduled. Я решил привести данный пример, т к по моему мнению он включает в себя довольно многие вещи, такие как работа с базой данных(в нашем случает это PostgreSQL), Spring Data JPA, новый java 8 time api, email-сервис, создание фоновых задач и небольшую бизнес-логику при этом оставаясь компактным. Сегодня интернет пестрит огромным множеством туториалов которые обычно сводятся к тому как наследоваться от CrudRepository, JpaRepository и тд. Туториал расчитан на то, что вы уже смотрели хотя бы некоторые из них и имеете представление о том, что такое Spring Boot. Я же постараюсь показать пример приложения, которое более обширно показывает его возможности и как с ним работать.

Создание проекта


Идем на Spring Initializr.

Добавляем зависимости:

1. PosgreSQL — в качестве базы данных
2. JPA — доступ к базе
3. Lombok — для удобства и избавления от бойлерплейт кода(не придётся писать геттеры, сеттеры и тд самим), подробнее тут
4. Mail — собственно для работы и отправки email-ов, оф. документация

Указываем группу и артефакт, к примеру com.application и task. Скачиваем и распаковываем проект, затем открываем его в среде разработки, у меня это Intellij IDEA.

База данных


Теперь устанавливаем себе PostgreSQL. Далее создаём базу данных с юзером и паролем. Можно сделать это прямо из IDEA, во вкладке database, можно с помощью командной строки если у вас линукс, следующими командами:

sudo -u postgres createuser <username>
sudo -u postgres createdb <dbname>
$ sudo -u postgres psql
psql=# alter user <username> with encrypted password '<password>';
psql=# grant all privileges on database <dbname> to <username> ;

Также на windows это можно сделать с помощью pgAdmin или его альтернатив.

Начало


Открываем наш проект и можем приступать к написанию кода.

Сейчас у нас в проекте есть только один java-файл. Он выглядит примерно так:

@SpringBootApplication
public class TaskApplication {

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

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

Данный класс это точка запуска приложения. Аннотация @SpringBootApplication означает, что это Spring Boot приложение и эквивалентна использованию @Configuration, @EnableAutoConfiguration и @ComponentScan.

Создание модели


Первым делом разделим каталог в котором лежит наш класс для запуска всего приложения и разделим его на три директории: model, repository, service.

Далее в папке model создаем класс User:

@Getter
@Setter
@ToString
@NoArgsConstructor
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    
    @Column(name = "name", nullable = false)
    private String name;

    private String email;

    private LocalDate birthday;
}

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

Пройдёмся по аннотациям: Первые 4 над классом это аннотации lombok, которые генерируют геттеры, сеттеры, метод toString, и конструктор без аргументов.

Entity — указывает Hibernate, что данный класс является сущностью.
Table — название соответствует таблице в бд.
Id — указывает на первичный ключ данного класса.
@GeneratedValue — используется вместе с Id и определяет паметры strategy и generator.
@Column — указывает на имя колонки, которая отображается в свойство сущности, также с помощью nullable = false указываем на то, что поле обязательно.

Репозиторий


Далее в папке repository создаём интерфейс UserRepository:

public interface UserRepository extends JpaRepository<User, Integer> {}

И это собственно практически весь наш DAO. Наследование от JpaRepository даёт нам возможность использовать его методы для работы с бд такие как delete, save, findAll и многие другие. Кроме этого при желании мы можем создавать свои методы, по принципу «пишем то что нужно». Т е если нам нужно найти всех юзеров с одинаковым именем, то наш метод будет выглядеть так:

List<User> findAllByName(String name);

Данный метод в итоге создаст SQL запрос подобный этому:

SELECT * FROM users WHERE name = ?;

Или например:

List<User> findByBirthdayAfter(LocalDate date);

Позволит выбрать всех юзеров родившихся после определенной даты.

Вообще это довольно обширная тема, на которую довольно много статей и видео. Как например вот это.

Мы же создадим метод, который будет брать из базы всех юзеров, у которых дата рождения и email не null, и теперь наш репозиторий будет выглядеть следующим образом:


@Repository
public interface UserRepository extends JpaRepository<User, Integer> {
    List<User> findAllByBirthdayIsNotNullAndEmailIsNotNull();
}

Пара особенностей репозитория

Первый параметр дженерика должен быть сущностью с которой мы будем работать, а второй соответствовать типу его первичного ключа.
Также типы методов должны соответствовать первому параметру.
Если вдруг у вас возник вопрос, почему данный каталог называется repository, а не dao, то это правило хорошего тона в Spring Boot, вы не обязаны делать так-же, просто так принято.

Сервисы


Первым делом создадим в каталоге service интерфейс UserRepositoryService:


public interface UserRepositoryService {
    List<User> getAll();
}

Далее здесь же создаем ещё один каталог impl и в нём класс-имплементацию для нашего сервиса:


@Service
public class UserRepositoryServiceImpl implements UserRepositoryService {

    private final UserRepository repository;

    @Autowired
    public UserRepositoryServiceImpl(UserRepository repository) {
        this.repository = repository;
    }

    @Override
    public List<User> getAll() {
        return repository.findAllByBirthdayIsNotNullAndEmailIsNotNull();
    }
}

Теперь разберём наш класс:
Аннотация Service показывает спрингу, что это сервис.
Далее объявляем переменную типа UserRepository и инициализируем её в конструкторе, предварительно пометив его аннотаций @Autowired.
(Можно поставить аннотации прямо над полем repository, но предпочтительнее создать конструктор или сеттер)
@Autowired — спринг находит нужный бин и подставляет его значение в свойство помеченное аннотацией.
Есть возможность создания autowired конструктора с помощью аннотации ломбока над классом:
@RequiredArgsConstructor(onConstructor = @__(@Autowired))


После конструктора реализуем метод нашего интерфейса и в нём возвращаем метод из репозитория.

Идём дальше: в каталоге service создаём EmailService:

public interface EmailService {
    void send(String to, String title, String body);
}

И его имплементацию EmailServiceImpl в impl:

@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class EmailServiceImpl implements EmailService {

    private final JavaMailSender emailSender;

    @Override
    public void send(String to, String subject, String text) {
        MimeMessage message = this.emailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message);
        try {
            helper.setTo(to);
            helper.setSubject(subject);
            helper.setText(text);
            this.emailSender.send(message);
        } catch (MessagingException messageException) {
            throw new RuntimeException(messageException);
        }
    }
}

Не буду углубляться в описание, вот ОД.

Теперь в service создадим наш последний и основной класс с шедулером и бизнес-логикой, назовём его к примеру SchedulerService.

Сразу определим в нём следующие поля:


@Slf4j
@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class SchedulerService {

    private static final LocalDate DATE = LocalDate.now();  

    private final UserRepositoryService userService; 

    private final EmailService emailService;

}

Итак мы инициализировали логгер (также аннотаций ломбока @Slf4j), константу с текущей датой(Java 8 time api), user и email сервисы в конструкторе(@RequiredArgsConstructor(onConstructor = @__(@Autowired))).

Далее создадим void метод sendMailToUsers а над ним укажем аннотацию:

@Scheduled(cron = "*/10 * * * * *")

Данная аннотация позволяет указывать то, когда наш метод будет работать. Мы используем параметр cron, позволяющий указывать расписание по конкретным часам и датам. Также есть такие параметры как fixedRate(определяет интервал между вызовами метода), fixedDelay(определяет интервал с момента окончания работы последнего вызова метода и началом работы следующего), initialDelay(количество миллисекунд для задержки перед первым выполнением fixedRate или fixedDelay) и ещё парочка.

Каждая звездочка в строке cron означает секунды, минуты, часы, дни, месяцы, и дни недели. Вот более подробно. Сейчас значение означает, что проверка будет проходить каждые 10 секунд, это сделано для примера, в дальнейшем мы это поменяем.

Значение cron для удобства можно вынести в константу:

private static final String CRON = "*/10 * * * * *";

В методе создадим проверку на то, не вернёт ли нам метод getAll пустой список и лист юзеров, который будет содержать всех пользователей из нашей таблицы в бд:


if (!userService.getAll().isEmpty()) {
	List<User> list = userService.getAll(); 
}

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

list.forEach(user -> {
        if (DATE.getMonth() == user.getBirthday().getMonth() && DATE.getDayOfMonth() == user.getBirthday().getDayOfMonth()) {                        
         }
});

Если проверки удовлетворены, то создаём переменную для сообщения. После чего вызываем метод send из EmailService, и передаём в него email юзера, заголовок и наше сообщение. В конце оборачиваем всё в try/catch во избежание исключений. Всё, наш метод готов.

Смотрим на весь класс:


@Service
@Slf4j
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class SchedulerService {

    private static final String CRON = "*/10 * * * * *";

    private static final LocalDate DATE = LocalDate.now();

    private final UserRepositoryService userService;

    private final EmailService emailService;

    @Scheduled(cron = CRON)
    public void sendMailToUsers() {
        if (!userService.getAll().isEmpty()) {
            List<User> list = userService.getAll();
            list.forEach(user -> {
                if (user.getBirthday() != null) {
                    if (DATE.getMonth() == user.getBirthday().getMonth() && DATE.getDayOfMonth() == user.getBirthday().getDayOfMonth()) {
                        try {
                            String message = "Happy Birthday dear " + user.getName() + "!";
                            emailService.send(user.getEmail(), "Happy Birthday!", message);
                            log.info("Email have been sent. User's id: {}, Date: {}", user.getId(), DATE);
                        } catch (Exception e) {
                            log.error("Email can't be sent.User's id: {}, Error: {}", user.getId(), e.getMessage());
                            e.printStackTrace();
                        }
                    }
                }
            });
        }
    }
}

Теперь, чтобы иметь возможность запускать фоновые задачи добавим в наш TaskApplication аннотацию @EnableScheduling прямо над @SpringBootApplication, чтобы он в итоге выглядел вот так:

@EnableScheduling
@SpringBootApplication
public class TaskApplication {

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

На этом работа с java кодом закончена, нам осталось только в файле application.properties в каталоге resources указать конфиги.

Конфигурация


# Локальный порт сервера. Может быть любым, главное чтобы не был занят
server.port=7373
# База данных
spring.jpa.database=POSTGRESQL
spring.jpa.show-sql=true
spring.datasource.platform=postgres
spring.jpa.generate-ddl=true       
spring.jpa.hibernate.ddl-auto=update

spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/your_database?stringtype=unspecified
spring.datasource.username=your_database_username
spring.datasource.password=your_database_password
# Логирование
logging.level.org.hibernate=info
logging.level.org.springframework.security=debug
# Предотвращает возможные ошибки связанные с jpa и postgreSQL
spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults = false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
#Настройки email-a
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=your_email@gmail.com
spring.mail.password=your_password
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
</code>
Пара объяснений:
<code>spring.jpa.generate-ddl=true       
spring.jpa.hibernate.ddl-auto=update 

Исользуются для автоматического создания/обновления таблицы в бд, используя нашу сущность.(В продакшне значения лучше менять на false и none)

spring.datasource.url=jdbc:postgresql://localhost:5432/your_database?stringtype=unspecified
spring.datasource.username=your_database_username
spring.datasource.password=your_database_password

Здесь указываются название вашей бд, логин и пароль

spring.mail.username=your_email@gmail.com
spring.mail.password=your_password

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

Запуск


Идём в наш TaskApplication и запускаем приложение. Если всё сделано правильно, то у вас должны будут идти подобные логи каждые 10 секунд:

Hibernate: select users0_.id as id1_0_, users0_.birthday as birthday2_0_, users0_.email as email3_0_, users0_.name as name4_0_ from users users0_ where (users0_.birthday is not null) and (users0_.email is not null)

Означающие, что наш метод как минимум берёт лист юзеров из бд. Теперь если мы откроем нашу базу(я это делаю прямо в IDEA. Во вкладке database, обычно в правом верхнем углу, есть возможность подключиться к нужной нам бд), то увидим, что там появилась таблица users с соответствующими полями. Создадим новую запись и в качестве дня рождения впишем текущую дату, а в качестве email-a свой собственный. После коммита изменений, каждые 10 секунд должен появляться наш лог сообщающий о том, что email-успешно послан. Проверяем email и если всё сделано корректно, то там нас должны ждать одно или несколько поздравлений с днём рождения(В зависимости от того сколько раз отработал метод). Останавливаем наше приложение и меняем значение CRON на «0 0 10 * * *» означающее, что теперь проверка будет проходить не каждые 10 секунд, а ежедневно в 10 утра, что гарантирует нам отправку только одного поздравления.

Заключение


На основе данного примера можно создавать и решать разнообразные задачи, связанные в частности с фоновыми процессами, главное не бояться экспериментировать. Надеюсь сегодня я смог помочь кому-нибудь лучше понять как работать со Spring Boot, базами данных и java. Если кому-то будет интересно, то я могу написать вторую часть статьи, с добавлением контроллера(чтобы например при желании можно было отключать рассылку email-ов) тестирование и безопасность.

Конструктивная критика и замечания по теме приветствуются.
Отдельное спасибо за комментарии: StanislavL, elegorod, APXEOLOG, Singaporian

Ссылки


Исходный код на github
Официальная документация Spring Boot

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


  1. AstarothAst
    06.04.2018 12:49

    Думаю это не слишком дальновидно — завязывать таймаут на аннотацию @Scheduled, особенное если «в дальнейшем мы это поменяем». Как менять-то будете? Изменив 10 на 20 с пересборкой проекта и рестартом сервиса? @Scheduled хорош там, где таймаут либо не слишком важен, либо никогда не поменяется. У Спринга есть возможности регистрировать таск и определять нужно ли ему выполнится в конкретный момент времени ориентируясь хоть на фазу луны.


    1. Filex
      07.04.2018 09:34

      А как правильно? Если не сложно напишите или дайте ссылку.


    1. AKiNO
      08.04.2018 00:23

      Что cron, что fixedDelay может быть EL-выражением, так что это очень легко решается. Что впрочем не отменяет того факта, что данный туториал крайне низкого качества.


    1. Rusya_2_0 Автор
      08.04.2018 16:55

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


    1. GVART
      09.04.2018 08:11

      Зря вы так о данной аннотации, она ничем не плоха.
      Можно добавить cron-pattern в application.properties, и инжектить в класс.
      А обновлять собственно в рантайме можно при помощи spring-cloud-starter + actuator. Смотрите сюда
      stackoverflow.com/questions/27919270/set-override-spring-spring-boot-properties-at-runtime


  1. vba
    06.04.2018 15:44

    Вообще Spring boot все больше и больше превращается в нишевый продукт. Сейчас большое количество компаний поворачиваются в сторону serverless и FaaS подхода. И это гораздо выгоднее. Ниша это в основном компании кто не хочет и не может двигаться в сторону FaaS да и облака в целом.


    1. fzn7
      06.04.2018 19:58

      Белены объелись там? Жду статью "как написать туду лист на Амазон лямбда"


      1. vba
        06.04.2018 21:12

        Объелись, тыц. Амазон, ну что же ты делаешь, перестань ...


        У амазона черт его знает сколько внутренних решений на этом завязано. Они при всяком удобном случае об этом говорят. А от подобного boot-oвского решения монолитом попахивает, увы.


        1. fzn7
          07.04.2018 09:24

          От вас попахивает нафталином. Я было надеялся, что эти "прогрессивные" идеи с маленькими сервлетами и большими аппликейшн контейнерами, от маразматиков из IBM, благополучно вымерли. Ан нет, вы опять лезете. Теперь это лямбда Амазон )


          1. vba
            07.04.2018 21:34

            Судя по всему ты у себя в нефтескваженске так и не понял как общаться с людьми. О чем твоя карма ярко свидетельствует. Ни одной публикации у тебя нет, и тебя постоянно плюсует один и тот же человек, что странно. Используешь два аккаунта на хабре?


            … сервлетами и большими аппликейшн контейнерами… Теперь это лямбда Амазон

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


            Хотя что-то мне говорит что индивидууму вроде тебя сложно понять как устроен docker-container и в чем его преимущество перед спринг 2 приложением запущенным гденить на одном из ваших газпромвнешторгмаш серверов.


  1. StanislavL
    06.04.2018 18:02
    +2

                               StringBuffer message = new StringBuffer();
                                message.append("Happy Birthday dear ")
                                        .append(user.getName())
                                        .append("!");
                                emailService.send(user.getEmail(), "Happy Birthday!", message.toString());


    Вот это бессмысленно. Во первых StringBuffer тут не нужен. Никакого мультипоточного доступа там нет. Во вторых простая конкатенация с java 7 если я правильно помню на уровне компиляции преобразуется в StringBuilder(). append().append().append(). Нет нужды городить.

    SchedulerService. Вы проверьте будет ли у вас меняться DATE. Подозреваю что нет т.к. это по сути singleton сервис. Его только один instance существует.

    } catch (Exception e) {
    вот так тоже нехорошо делать. Если у вас будут какие-то неожиданные эксепшены у вас будет только строка в логе. И потом поди найди их. Stacktrace надо. Да и OutOfmemory у вас скроется.

    if (DATE.getMonth() == user.getBirthday().getMonth()

    тут вы словите NPE если даты рождения нету (NULL) и судя по отсутствию NotNull на поле в Entity вы его таки получите.


    1. Rusya_2_0 Автор
      09.04.2018 10:18

      Спасибо. Исправил первые два замечания. NPE не будет, посмотрите на метод в репозитории


      1. StanislavL
        09.04.2018 10:47

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

        e.printStackTrace(); бахнет только в консоль, которой у вас может и не быть.

        log.error(«Message», e); вот так логгер выведет во все источники.
        Разберите Exception e на несколько тех что реально бросаются, чтобы не подавлять RuntimeException


  1. Singaporian
    06.04.2018 19:13

    открываем его в среде разработки, у меня это Intellij IDEA.

    На windows это можно сделать с помощью pgAdmin или его альтернатив.

    Database tool


    1. Rusya_2_0 Автор
      09.04.2018 10:20
      +1

      Добавил в описание, спасибо)


  1. ageres
    07.04.2018 00:52
    +1

    Ох

    if (userService.getAll() != null && userService.getAll().size() != 0) {
                List<User> list = userService.getAll();
    


    1. dimkrayan
      07.04.2018 11:09

      Добавлю:
      1. а зачем в данном контексте потокозащищенность StringBuffer-а?
      2. А сколько пользователь получит писем? Там же правило крона на раз в 10 минут настроено. Получается, в свой ДР он получит 144 письма.
      Ну и еще были вопросы, пока читал, но когда дочитал — не все вспомнил.


      1. Rusya_2_0 Автор
        08.04.2018 16:58

        1. Ну, шедулер завязан на многопоточности
        2. Последнее предложение перед заключением. Поменял на то, чтобы раз в день была отправка.


        1. ageres
          09.04.2018 01:52

          Вы правда не видите в чём «Ох»?


          1. Rusya_2_0 Автор
            09.04.2018 10:20

            Исправил)


  1. Filex
    07.04.2018 09:35

    А с quartz для планирования вы не работали?


  1. LeshaRB
    07.04.2018 09:59

    постАраюсь…


  1. igorp1024
    07.04.2018 10:21

    А не проще ли вместо @Getter+@Setter+@ToString написать лишь @Data? Или это было для наглядности?


    1. vlanko
      07.04.2018 13:38

      и @NoArgConstructor не нужен, если мы не пишем другой


      1. Rusya_2_0 Автор
        08.04.2018 17:02

        Вообще то нужен, это одна из спецификаций


    1. Rusya_2_0 Автор
      08.04.2018 17:01

      Нам не нужен @EqualsAndHashcode и @AllArgsConstructor, которые есть в Data. Но нужен конструктор без аргументов.


  1. APXEOLOG
    08.04.2018 16:05
    +1

    Несколько замечаний:


    1. Зачем в модели protected id, а не private?


    2. Вы используете ломбок, это прекрасно, но тогда можно использовать его и для генерации логгера (https://projectlombok.org/features/log)


    3. Опять же, для генерации Autowired конструктора можно использовать код типа


      @RequiredArgsConstructor(onConstructor = @__(@Autowired))

      При условии что у вас stateless bean


    4. Про StringBuffer уже писали выше


    5. Неконсистентное именование переменных (верхний регистр обычно применяют для static final переменных, а у вас DATE)


    6. Методы репозитория, возвращающие список, не могут вернуть null, поэтому можно просто ограничиться проверкой .isEmpty()
      Repository methods returning collections, collection alternatives, wrappers, and streams are guaranteed never to return null but rather the corresponding empty representation

      А главное — вы вызываете этот метод три раза и не кэшируете его результат



    1. Rusya_2_0 Автор
      09.04.2018 10:20

      Исправил. Очень конструктивно, спасибо)


  1. Rusya_2_0 Автор
    08.04.2018 17:48

    Многие пишут, что StringBuffer тут не нужен, хотя @Scheduled использует многопоточность. Я не являюсь экспертом в области потоков, поэтому может ли кто-то из высказавшихся привести аргументы, и я исправлю его StringBuilder?


    1. elegorod
      08.04.2018 18:48

      Потому что этот StringBuffer — локальная переменная, которая используется только внутри метода. Если метод sendMailToUsers() параллельно вызовется другим потоком, то это создаст второй StringBuffer, а к первому буферу никакой другой поток доступа не получит. Если бы этот буфер был полем класса, тогда ещё можно было бы говорить о многопоточности.

      Менять на StringBuilder смысла нет, так как можно написать гораздо проще:

      String message = "Happy Birthday dear " + user.getName() + "!"
      

      А дальше компилятор сам заменит этот код на StringBuilder.


      1. Rusya_2_0 Автор
        09.04.2018 10:21

        Спасибо, исправил