JavaRush /Java блог /Random /Создание системы мониторинга цен на авиабилеты: пошаговое...
Roman Beekeeper
35 уровень

Создание системы мониторинга цен на авиабилеты: пошаговое руководство [Часть 2]

Статья из группы Random
Создание системы мониторинга цен на авиабилеты: пошаговое руководство [Часть 1]

Содержание

Создание системы мониторинга цен на авиабилеты: пошаговое руководство [Часть 2] - 1

Настраиваем H2 базу данных и создаем Subscription сущность. Слой Repository

Нужно хранить состояния подписок на стороне приложения, чтобы знать, кому отправлять уведомления о снижении цены. Для этого я выбрал объект со всеми необходимыми данными, которые нужны — Subscription. Воспользуемся аннотациями JPA (java persistence api), получим:

import java.io.Serializable;
import java.time.LocalDate;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
import lombok.Data;
import lombok.ToString;

@Data
@Entity
@Table(name = "subscription")
public class Subscription implements Serializable {

   private static final long serialVersionUID = 1;

   @Id
   @GeneratedValue
   private Long id;

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

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

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

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

   @Column(name = "origin_place")
   private String originPlace;

   @Column(name = "destination_place")
   private String destinationPlace;

   @Column(name = "outbound_partial_date")
   private LocalDate outboundPartialDate;

   @Column(name = "inbound_partial_date")
   private LocalDate inboundPartialDate;

   @Column(name = "min_price")
   private Integer minPrice;
}
где мы указали уже известную нам @Data. Помимо ее есть новые:
  • @Entity — аннотация из JPA, которая говорит, что это будет сущность из базы данных;
  • @Table(name = “subscription”) — также из JPA, которая определяет, с какой таблицей будет соединяться эта сущность.
Далее, нужно настроить H2 на проекте. К счастью, зависимость уже у нас есть: нам нужно написать простенький скрипт для создания таблицы и добавить настройки в application.properties файле. Полное описание как добавить H2.

Выдержка из книги Spring in Action 5th edition:
schema.sql — if there’s a file named schema.sql in the root of the application’s classpath, then the SQL in that file will be executed against the database when the application starts.

Spring Boot поймет, что файл с именем schema.sql в правильном месте будет значить, что он нужен для базы данных. Как файл со схемой БД, поместим его в корень main/resources: schema.sql

DROP TABLE IF EXISTS subscription;

CREATE TABLE subscription (
   id INT AUTO_INCREMENT PRIMARY KEY,
   email VARCHAR(250) NOT NULL,
   country VARCHAR(250) NOT NULL,
   currency VARCHAR(250) NOT NULL,
   locale VARCHAR(250) NOT NULL,
   origin_place VARCHAR(250) NOT NULL,
   destination_place VARCHAR(250) NOT NULL,
   outbound_partial_date DATE NOT NULL,
   min_price INT,
   inbound_partial_date DATE
)
Далее из гайда, который я привел выше, берем следующие настройки:

# H2
spring.h2.console.enabled=true
spring.h2.console.settings.web-allow-others=true
spring.datasource.url=jdbc:h2:mem:subscriptiondb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=flights
spring.datasource.password=flights
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
Добавим SubscriptionRespository — интерфейс, при помощи которого будет общаться с БД из Java-кода.

import com.github.romankh3.flightsmonitoring.repository.entity.Subscription;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
   List<Subscription> findByEmail(String email);
}
Со Spring Data этого вполне достаточно. JpaRepository имеет набор необходимых методов для нашей работы. Метод findByEmail(String email) создан как дополнительный, который нужен помимо стандартного набора. Прелесть Spring Data в том, что правильно написанного имени метода хватает, чтоб Spring уже сам все сделал без нашей реализации. Аннотация @Repository нужна для того, чтобы потом можно было этот интерфейс инъектировать в другие классы. И всё…) Вот так просто работать со Spring Data.

EmailNofiticationService: пишем сервис отправки электронных писем

Чтобы подписчики получали уведомления, необходимо добавить отправку писем на почту. Здесь можно выбрать и другой путь, например, отправку через мессенджеры. Из опыта использования gmail почты для отправки уведомлений скажу, что нужно сделать еще дополнительную настройку в аккаунте и отключить двойную авторизацию, чтобы все работало нормально. Вот хорошее решение. Если работаешь со Spring Boot, велика вероятность, что с задачей которую нужно реализовать, уже решили и можно воспользоваться этим. А именно, Spring boot starter mail. Зависимость уже добавили еще при создании проекта, поэтому добавляем необходимые настройки в application.properties как указано в статье.

# Spring Mail
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=IMAIL
spring.mail.password=PASSWORD

# Other properties
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.connectiontimeout=5000
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000

# TLS , port 587
spring.mail.properties.mail.smtp.starttls.enable=true
и собственно сервис. Нужно отправлять уведомление о регистрации подписки и об уменьшении цены на перелет:

import com.github.romankh3.flightsmonitoring.repository.entity.Subscription;

/**
* Sends email notification.
*/
public interface EmailNotifierService {

   /**
    * Notifies subscriber, that the minPrice has decreased.
    *
    * @param subscription the {@link Subscription} object.
    * @param oldMinPrice minPrice before recount.
    * @param newMinPrice minPrice after recount.
    */
   void notifySubscriber(Subscription subscription, Integer oldMinPrice, Integer newMinPrice);

   /**
    * Notifies subscriber, that subscription has added.
    *
    * @param subscription the {@link Subscription} object.
    */
   void notifyAddingSubscription(Subscription subscription);
}
и реализация:

import com.github.romankh3.flightsmonitoring.repository.entity.Subscription;
import com.github.romankh3.flightsmonitoring.service.EmailNotifierService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

/**
* {@inheritDoc}
*/
@Slf4j
@Service
public class EmailNotifierServiceImpl implements EmailNotifierService {

   @Autowired
   private JavaMailSender javaMailSender;

   /**
    * {@inheritDoc}
    */
   @Override
   public void notifySubscriber(Subscription subscription, Integer oldMinPrice, Integer newMinPrice) {
       log.debug("method notifySubscriber STARTED");
       SimpleMailMessage msg = new SimpleMailMessage();
       msg.setTo(subscription.getEmail());
       msg.setSubject("Flights Monitoring Service");
       msg.setText(String.format("Hello, dear! \n "
               + "the price for your flight has decreased \n"
               + "Old min price = %s,\n new min price = %s,\n Subscription details = %s", oldMinPrice, newMinPrice, subscription.toString()));
       javaMailSender.send(msg);
       log.debug("method notifySubscriber FINISHED");
   }

   /**
    * {@inheritDoc}
    */
   @Override
   public void notifyAddingSubscription(Subscription subscription) {
       log.debug("method notifyAddingSubscription STARTED");
       SimpleMailMessage msg = new SimpleMailMessage();
       msg.setTo(subscription.getEmail());
       msg.setSubject("Flights Monitoring Service");
       msg.setText(String.format("Hello, dear! \n "
               + "Subscription has been successfully added. \n"
               + "Subscription details = %s", subscription.toString()));
       javaMailSender.send(msg);
       log.debug("method notifyAddingSubscription FINISHED");
   }
}

FlightPriceService — связь сервисов клиента с сервисами приложения

Чтобы связать работу нашего FlightPricesClient и сервиса для обработки подписок, нужно создать сервис, который будет на основании Subscription объекта выдавать полную информацию о рейсе с минимальной стоимостью. Для этого есть FlightPriceService:

import com.github.romankh3.flightsmonitoring.client.dto.FlightPricesDto;
import com.github.romankh3.flightsmonitoring.repository.entity.Subscription;

/**
* Service, for getting details based on {@link Subscription} object.
*/
public interface FlightPriceService {

   /**
    * Finds minPrice based on {@link Subscription}.
    *
    * @param flightPricesDto provided {@link FlightPricesDto} object.
    * @return
    */
   Integer findMinPrice(FlightPricesDto flightPricesDto);

   /**
    * Finds all the flight data related to {@link Subscription} object.
    *
    * @param subscription provided {@link Subscription} object
    * @return {@link FlightPricesDto} with all the data related to flight specific in {@link Subscription}.
    */
   FlightPricesDto findFlightPrice(Subscription subscription);
}
и реализация:

import com.github.romankh3.flightsmonitoring.client.dto.FlightPricesDto;
import com.github.romankh3.flightsmonitoring.client.service.FlightPricesClient;
import com.github.romankh3.flightsmonitoring.repository.entity.Subscription;
import com.github.romankh3.flightsmonitoring.service.FlightPriceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
* {@inheritDoc}
*/
@Service
public class FlightPriceServiceImpl implements FlightPriceService {

   @Autowired
   private FlightPricesClient flightPricesClient;

   /**
    * {@inheritDoc}
    */
   @Override
   public Integer findMinPrice(FlightPricesDto flightPricesDto) {
       return flightPricesDto.getQuotas().get(0).getMinPrice();
   }

   /**
    * {@inheritDoc}
    */
   @Override
   public FlightPricesDto findFlightPrice(Subscription subscription) {
       if (subscription.getInboundPartialDate() == null) {
           return flightPricesClient
                   .browseQuotes(subscription.getCountry(), subscription.getCurrency(), subscription.getLocale(),
                           subscription.getOriginPlace(), subscription.getDestinationPlace(),
                           subscription.getOutboundPartialDate().toString());
       } else {
           return flightPricesClient
                   .browseQuotes(subscription.getCountry(), subscription.getCurrency(), subscription.getLocale(),
                           subscription.getOriginPlace(), subscription.getDestinationPlace(),
                           subscription.getOutboundPartialDate().toString(), subscription.getInboundPartialDate().toString());
       }
   }
}
Здесь у нас есть два метода: один возвращает полную информацию о полете с минимальной ценой, а другой принимает эту информацию и выдает значение минимальной цены. Это можно было бы проделывать каждый раз и с результатом, но я считаю, что так удобнее использовать.

Создаем SubscriptionService и CRUD операции в нём

Для полного управления подписками нужно создать сервис с CRUD операциями. CRUD расшифровывается как create, read, update, delete. То есть нужно уметь создавать подписку, считать ее по ID, отредактировать и удалить. С одной лишь разницей, что получать подписки будем не по ID, а по email, так как нам нужно именно это. Ведь зачем нам подписки по непонятному ID, а вот все подписки пользователя по его почте реально нужны. Итак SubscriptionService:

import com.github.romankh3.flightsmonitoring.repository.entity.Subscription;
import com.github.romankh3.flightsmonitoring.rest.dto.SubscriptionCreateDto;
import com.github.romankh3.flightsmonitoring.rest.dto.SubscriptionDto;
import com.github.romankh3.flightsmonitoring.rest.dto.SubscriptionUpdateDto;
import java.util.List;

/**
* Manipulates with  subscriptions.
*/
public interface SubscriptionService {

   /**
    * Add new subscription.
    * @param dto the dto of the subscription.
    */
   SubscriptionDto create(SubscriptionCreateDto dto);

   /**
    * Get all subscription based on email.
    *
    * @param email provided email;
    * @return the collection of the {@link SubscriptionDto} objects.
    */
   List<SubscriptionDto> findByEmail(String email);

   /**
    * Remove subscription based on it ID
    *
    * @param subscriptionId the ID of the {@link Subscription}.
    */
   void delete(Long subscriptionId);

   /**
    * Update subscription based on ID
    *
    *
    * @param subscriptionId the ID of the subscription to be updated.
    * @param dto the data to be updated.
    * @return updated {@link SubscriptionDto}.
    */
   SubscriptionDto update(Long subscriptionId, SubscriptionUpdateDto dto);
}
В аргументах можно заметить три новых DTO объекта:
  • SubscriptionDto — содержит всю информацию для показа;
  • SubscriptionCreateDto — данные для создания подписки;
  • SubscriptionUpdateDto — данные, которые можно обновлять в подписке.
В Create, Update DTO не попали такие поля как ID, minPrice, так как у пользователя есть к ним доступ только на чтение. ID задает база данных, а minPrice получаем от запроса на Skyscanner API. И реализация этого сервиса, SubscriptionServiceImpl:

import com.github.romankh3.flightsmonitoring.client.dto.FlightPricesDto;
import com.github.romankh3.flightsmonitoring.repository.SubscriptionRepository;
import com.github.romankh3.flightsmonitoring.repository.entity.Subscription;
import com.github.romankh3.flightsmonitoring.rest.dto.SubscriptionCreateDto;
import com.github.romankh3.flightsmonitoring.rest.dto.SubscriptionDto;
import com.github.romankh3.flightsmonitoring.rest.dto.SubscriptionUpdateDto;
import com.github.romankh3.flightsmonitoring.service.EmailNotifierService;
import com.github.romankh3.flightsmonitoring.service.FlightPriceService;
import com.github.romankh3.flightsmonitoring.service.SubscriptionService;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Example;
import org.springframework.stereotype.Service;

/**
* {@inheritDoc}
*/
@Slf4j
@Service
public class SubscriptionServiceImpl implements SubscriptionService {

   @Autowired
   private SubscriptionRepository subscriptionRepository;

   @Autowired
   private FlightPriceService flightPriceService;

   @Autowired
   private EmailNotifierService emailNotifierService;

   /**
    * {@inheritDoc}
    */
   @Override
   public SubscriptionDto create(SubscriptionCreateDto dto) {
       Subscription subscription = toEntity(dto);
       Optional<Subscription> one = subscriptionRepository.findOne(Example.of(subscription));

       if (one.isPresent()) {
           log.info("The same subscription has been found for Subscription={}", subscription);
           Subscription fromDatabase = one.get();
           FlightPricesDto flightPriceResponse = flightPriceService.findFlightPrice(subscription);
           subscription.setMinPrice(flightPriceService.findMinPrice(flightPriceResponse));
           return toDto(fromDatabase, flightPriceResponse);
       } else {
           FlightPricesDto flightPriceResponse = flightPriceService.findFlightPrice(subscription);
           subscription.setMinPrice(flightPriceService.findMinPrice(flightPriceResponse));
           Subscription saved = subscriptionRepository.save(subscription);
           log.info("Added new subscription={}", saved);
           emailNotifierService.notifyAddingSubscription(subscription);
           return toDto(saved, flightPriceResponse);
       }
   }

   /**
    * {@inheritDoc}
    */
   @Override
   public List<SubscriptionDto> findByEmail(String email) {
       return subscriptionRepository.findByEmail(email).stream()
               .map(subscription -> {
                   FlightPricesDto flightPriceResponse = flightPriceService.findFlightPrice(subscription);
                   if (subscription.getMinPrice() != flightPriceService.findMinPrice(flightPriceResponse)) {
                       subscription.setMinPrice(flightPriceService.findMinPrice(flightPriceResponse));
                       subscriptionRepository.save(subscription);
                   }
                   return toDto(subscription, flightPriceResponse);
               })
               .collect(Collectors.toList());
   }

   /**
    * {@inheritDoc}
    */
   @Override
   public void delete(Long subscriptionId) {
       subscriptionRepository.deleteById(subscriptionId);
   }

   /**
    * {@inheritDoc}
    */
   @Override
   public SubscriptionDto update(Long subscriptionId, SubscriptionUpdateDto dto) {
       Subscription subscription = subscriptionRepository.getOne(subscriptionId);
       subscription.setDestinationPlace(dto.getDestinationPlace());
       subscription.setOriginPlace(dto.getOriginPlace());
       subscription.setLocale(dto.getLocale());
       subscription.setCurrency(dto.getCurrency());
       subscription.setCountry(dto.getCountry());
       subscription.setEmail(dto.getEmail());
       subscription.setOutboundPartialDate(dto.getOutboundPartialDate());
       subscription.setInboundPartialDate(dto.getInboundPartialDate());

       FlightPricesDto flightPriceResponse = flightPriceService.findFlightPrice(subscription);
       subscription.setMinPrice(flightPriceService.findMinPrice(flightPriceResponse));
       return toDto(subscriptionRepository.save(subscription), flightPriceResponse);
   }

   private Subscription toEntity(SubscriptionCreateDto dto) {
       Subscription subscription = new Subscription();
       subscription.setCountry(dto.getCountry());
       subscription.setCurrency(dto.getCurrency());
       subscription.setDestinationPlace(dto.getDestinationPlace());
       subscription.setInboundPartialDate(dto.getInboundPartialDate());
       subscription.setOutboundPartialDate(dto.getOutboundPartialDate());
       subscription.setLocale(dto.getLocale());
       subscription.setOriginPlace(dto.getOriginPlace());
       subscription.setEmail(dto.getEmail());

       return subscription;
   }

   private SubscriptionDto toDto(Subscription entity, FlightPricesDto response) {
       SubscriptionDto dto = new SubscriptionDto();
       dto.setEmail(entity.getEmail());
       dto.setCountry(entity.getCountry());
       dto.setCurrency(entity.getCurrency());
       dto.setLocale(entity.getLocale());
       dto.setOriginPlace(entity.getOriginPlace());
       dto.setDestinationPlace(entity.getDestinationPlace());
       dto.setOutboundPartialDate(entity.getOutboundPartialDate());
       dto.setInboundPartialDate(entity.getInboundPartialDate());
       dto.setMinPrice(entity.getMinPrice());
       dto.setId(entity.getId());
       dto.setFlightPricesDto(response);
       return dto;
   }
}

Пишем Spring Scheduler для проверки состояния билетов

Чтобы знать, изменилась ли цена на авиабилеты, нужно время от времени проводить запросы по созданным подпискам и проверять с сохраненным состоянием минимальной цены, которая лежит в базе данных. Для этого есть Spring Scheduler, который и поможет нам с этим. Вот отличное описание. Как и всё во Spring, не нужно много действий:
  • Добавляем аннотацию @EnableScheduling;

  • Создаем SchedulerTasks объект и помещаем его в Application Context

    
    import com.github.romankh3.flightsmonitoring.service.RecountMinPriceService;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.scheduling.annotation.Scheduled;
    import org.springframework.stereotype.Component;
    
    @Slf4j
    @Component
    public class SchedulerTasks {
    
       @Autowired
       private RecountMinPriceService recountMinPriceService;
    
       private static final long TEN_MINUTES = 1000 * 60 * 10;
    
       @Scheduled(fixedRate = TEN_MINUTES)
       public void recountMinPrice() {
           log.debug("recount minPrice Started");
           recountMinPriceService.recount();
           log.debug("recount minPrice finished");
       }
    }
    
  • Пишем RecountMinPriceService, который будет выполнять всю логику:

    
    /**
    * Recounts minPrice for all the subscriptions.
    */
    public interface RecountMinPriceService {
    
       /**
        * Recounts minPrice for all the subscriptions.
        */
       void recount();
    }
    

    и реализация:

    
    import com.github.romankh3.flightsmonitoring.client.dto.FlightPricesDto;
    import com.github.romankh3.flightsmonitoring.repository.SubscriptionRepository;
    import com.github.romankh3.flightsmonitoring.service.EmailNotifierService;
    import com.github.romankh3.flightsmonitoring.service.FlightPriceService;
    import com.github.romankh3.flightsmonitoring.service.RecountMinPriceService;
    import java.time.LocalDate;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    
    /**
    * {@inheritDoc}
    */
    @Service
    public class RecountMinPriceServiceImpl implements RecountMinPriceService {
    
       @Autowired
       private SubscriptionRepository subscriptionRepository;
    
       @Autowired
       private FlightPriceService flightPriceService;
    
       @Autowired
       private EmailNotifierService emailNotifierService;
    
       //todo add async
       /**
        * {@inheritDoc}
        */
       @Override
       public void recount() {
           subscriptionRepository.findAll().forEach(subscription -> {
               if(subscription.getOutboundPartialDate().isAfter(LocalDate.now().minusDays(1))) {
                   FlightPricesDto flightPricesDto = flightPriceService.findFlightPrice(subscription);
                   Integer newNumPrice = flightPriceService.findMinPrice(flightPricesDto);
                   if (subscription.getMinPrice() > newNumPrice) {
                       emailNotifierService.notifySubscriber(subscription, subscription.getMinPrice(), newNumPrice);
                       subscription.setMinPrice(newNumPrice);
                       subscriptionRepository.save(subscription);
                   }
               } else {
                   subscriptionRepository.delete(subscription);
               }
           });
       }
    }
    
и всё, можно использовать. Теперь каждые 30 минут (эта цифра задана в SchedulerTasks) будет происходить пересчет minPrice без нашего участия. Если цена станет меньше, уведомление будет отправлено пользователю и сохранено в базе данных. Это будет происходить daemon потоком.

Добавляем Swagger и Swagger UI в приложение

Прежде чем написать контроллеры, добавим Swagger и Swagger UI. Отличная статья на эту тему.

Swagger — это программная среда с открытым исходным кодом, опирающаяся на обширную экосистему инструментов, которая помогает разработчикам проектировать, создавать, документировать и использовать веб-сервисы RESTful.

Swagger UI предоставляет веб-страницу с информацией о REST API, чтобы показать, какие есть запросы, какие данные принимает, а какие возвращает. Добавить описания к полям, показать примеры полей, которые ожидает приложение. Также с помощью Swagger UI можно отправлять запросы к приложение без помощи сторонних инструментов. Это важная часть, так как никакого front-end не предусматривается. Разобрались для чего, теперь — как это добавить:
  • добавляем две зависимости в pom.xml

    
    <dependency>
        <groupId>io.springfox</groupId>
         <artifactId>springfox-swagger2</artifactId>
        <version>2.9.2</version>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.9.2</version>
    </dependency>
    
  • Новый класс для конфигурации SwaggerConfig, с настройками того, что будет показываться на UI (user interface).

    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import springfox.documentation.builders.PathSelectors;
    import springfox.documentation.builders.RequestHandlerSelectors;
    import springfox.documentation.spi.DocumentationType;
    import springfox.documentation.spring.web.plugins.Docket;
    import springfox.documentation.swagger2.annotations.EnableSwagger2;
    
    @Configuration
    @EnableSwagger2
    public class SwaggerConfig {
       @Bean
       public Docket api() {
           return new Docket(DocumentationType.SWAGGER_2)
                   .select()
                   .apis(RequestHandlerSelectors.basePackage("com.github.romankh3.flightsmonitoring.rest.controller"))
                   .paths(PathSelectors.any())
                   .build();
       }
    }
    
И всё: теперь при запуске приложения, если перейти по ссылке swagger-ui.html, будет видна вся информация о контроллерах. Так как никакого фронтенда в приложении нет, при попадании на главную страницу будет ошибка. Добавим редирект с главной страницы на Swagger UI. Для этого ye;ty еще один конфигурационный класс WebConfig:

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* Web configuration class.
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {

   @Override
   public void addViewControllers(ViewControllerRegistry registry) {
       registry.addViewController("/").setViewName("redirect:/swagger-ui.html");
   }
}
Создание системы мониторинга цен на авиабилеты: пошаговое руководство [Часть 3]
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Roman Beekeeper Уровень 35
11 марта 2021
⚡️UPDATE⚡️ Друзья, создал телеграм-канал 🤓, в котором освещаю свою писательскую деятельность и свою open-source разработку в целом. Не хотите пропустить новые статьи? Присоединяйтесь ✌️