Простая реализация электронной коммерции с Spring

Простая реализация электронной коммерции с помощью Spring

1. Обзор нашего приложения для электронной коммерции

В этом руководстве мы реализуем простое приложение для электронной коммерции. Мы разработаем API, используяSpring Boot, и клиентское приложение, которое будет использовать API, используяAngular.

По сути, пользователь сможет добавлять / удалять товары из списка товаров в / из корзины покупок и размещать заказ.

2. Бэкэнд-часть

Для разработки API мы будем использовать последнюю версию Spring Boot. Мы также используем базу данных JPA и H2 для постоянства вещей.

To learn more about Spring Boot,you could check out our Spring Boot series of articles и, если хотите,to get familiar with building a REST API, please check out another series.

2.1. Maven Зависимости

Давайте подготовим наш проект и импортируем необходимые зависимости в нашpom.xml.

Нам понадобится ядроSpring Boot dependencies:


    org.springframework.boot
    spring-boot-starter-data-jpa
    2.0.4.RELEASE


    org.springframework.boot
    spring-boot-starter-web
    2.0.4.RELEASE

ТогдаH2 database:


    com.h2database
    h2
    1.4.197
    runtime

И, наконец,Jackson library:


    com.fasterxml.jackson.datatype
    jackson-datatype-jsr310
    2.9.6

Мы использовалиSpring Initializr, чтобы быстро настроить проект с необходимыми зависимостями.

2.2. Настройка базы данных

Хотя мы могли бы использовать базу данных H2 в памяти прямо из коробки с Spring Boot, мы все же внесем некоторые корректировки, прежде чем приступить к разработке нашего API.

Мы добавимenable H2 console в наш файлapplication.propertiesso we can actually check the state of our database and see if everything is going as we’d expect.

Также может быть полезно записывать запросы SQL на консоль при разработке:

spring.datasource.name=ecommercedb
spring.jpa.show-sql=true

#H2 settings
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

После добавления этих настроек мы сможем получить доступ к базе данных вhttp://localhost:8080/h2-console, используяjdbc:h2:mem:ecommercedb as URL-адрес JDBC и пользователяsa без пароля.

2.3. Структура проекта

Проект будет организован в несколько стандартных пакетов, а приложение Angular помещено в папку frontend:

├───pom.xml
├───src
    ├───main
    │   ├───frontend
    │   ├───java
    │   │   └───com
    │   │       └───example
    │   │           └───ecommerce
    │   │               │   EcommerceApplication.java
    │   │               ├───controller
    │   │               ├───dto
    │   │               ├───exception
    │   │               ├───model
    │   │               ├───repository
    │   │               └───service
    │   │
    │   └───resources
    │       │   application.properties
    │       ├───static
    │       └───templates
    └───test
        └───java
            └───com
                └───example
                    └───ecommerce
                            EcommerceApplicationIntegrationTest.java

Следует отметить, что все интерфейсы в пакете репозитория просты и расширяютCrudRepository Spring Data, поэтому мы не будем отображать их здесь.

2.4. Обработка исключений

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

You can find more details about the topic in our Error Handling for REST with Spring and Custom Error Message Handling for REST API articles.

Здесь мы сосредоточены наConstraintViolationException и нашем пользовательскомResourceNotFoundException:

@RestControllerAdvice
public class ApiExceptionHandler {

    @SuppressWarnings("rawtypes")
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity handle(ConstraintViolationException e) {
        ErrorResponse errors = new ErrorResponse();
        for (ConstraintViolation violation : e.getConstraintViolations()) {
            ErrorItem error = new ErrorItem();
            error.setCode(violation.getMessageTemplate());
            error.setMessage(violation.getMessage());
            errors.addError(error);
        }
        return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
    }

    @SuppressWarnings("rawtypes")
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity handle(ResourceNotFoundException e) {
        ErrorItem error = new ErrorItem();
        error.setMessage(e.getMessage());

        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }
}

2.5. Товары

If you need more knowledge about persistence in Spring, there is a lot of useful articles in Spring Persistence series.

Наше приложение будет поддерживатьonly reading products from the database, поэтому нам нужно сначала добавить.

Давайте создадим простой классProduct:

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull(message = "Product name is required.")
    @Basic(optional = false)
    private String name;

    private Double price;

    private String pictureUrl;

    // all arguments contructor
    // standard getters and setters
}

Хотя у пользователя не будет возможности добавлять продукты через приложение, мы поддержим сохранение продукта в базе данных, чтобы предварительно заполнить список продуктов.

Для наших нужд будет достаточно простого сервиса:

@Service
@Transactional
public class ProductServiceImpl implements ProductService {

    // productRepository constructor injection

    @Override
    public Iterable getAllProducts() {
        return productRepository.findAll();
    }

    @Override
    public Product getProduct(long id) {
        return productRepository
          .findById(id)
          .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
    }

    @Override
    public Product save(Product product) {
        return productRepository.save(product);
    }
}

Простой контроллер будет обрабатывать запросы на получение списка продуктов:

@RestController
@RequestMapping("/api/products")
public class ProductController {

    // productService constructor injection

    @GetMapping(value = { "", "/" })
    public @NotNull Iterable getProducts() {
        return productService.getAllProducts();
    }
}

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

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

@Bean
CommandLineRunner runner(ProductService productService) {
    return args -> {
        productService.save(...);
        // more products
}

Если мы сейчас запустим наше приложение, мы сможем получить список продуктов черезhttp://localhost:8080/api/products.. Кроме того, если мы перейдем кhttp://localhost:8080/h2-console и войдем в систему, мы увидим, что есть таблица с именемPRODUCT с продукты, которые мы только что добавили.

2.6. заказы

На стороне API нам нужно включить запросы POST, чтобы сохранить заказы, которые будет делать конечный пользователь.

Давайте сначала создадим модель:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @JsonFormat(pattern = "dd/MM/yyyy")
    private LocalDate dateCreated;

    private String status;

    @JsonManagedReference
    @OneToMany(mappedBy = "pk.order")
    @Valid
    private List orderProducts = new ArrayList<>();

    @Transient
    public Double getTotalOrderPrice() {
        double sum = 0D;
        List orderProducts = getOrderProducts();
        for (OrderProduct op : orderProducts) {
            sum += op.getTotalPrice();
        }
        return sum;
    }

    @Transient
    public int getNumberOfProducts() {
        return this.orderProducts.size();
    }

    // standard getters and setters
}

Мы должны отметить несколько вещей здесь. Безусловно, одна из самых примечательных вещей -remember to change the default name of our table. Поскольку мы назвали классOrder, по умолчанию должна быть создана таблица с именемORDER. Но поскольку это зарезервированное слово SQL, мы добавили@Table(name = “orders”), чтобы избежать конфликтов.

Кроме того, у нас есть два@Transient methods that will return a total amount for that order and the number of products in it. Оба представляют расчетные данные, поэтому нет необходимости хранить их в базе данных.

Наконец, у нас есть@OneToMany relation representing the order’s details. Для этого нам нужен еще один класс сущностей:

@Entity
public class OrderProduct {

    @EmbeddedId
    @JsonIgnore
    private OrderProductPK pk;

    @Column(nullable = false)
    private Integer quantity;

    // default constructor

    public OrderProduct(Order order, Product product, Integer quantity) {
        pk = new OrderProductPK();
        pk.setOrder(order);
        pk.setProduct(product);
        this.quantity = quantity;
    }

    @Transient
    public Product getProduct() {
        return this.pk.getProduct();
    }

    @Transient
    public Double getTotalPrice() {
        return getProduct().getPrice() * getQuantity();
    }

    // standard getters and setters

    // hashcode() and equals() methods
}

We have a composite primary keyhere:

@Embeddable
public class OrderProductPK implements Serializable {

    @JsonBackReference
    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;

    // standard getters and setters

    // hashcode() and equals() methods
}

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

Нам нужно только, чтобыProduct отображался пользователю, поэтому у нас есть временный методgetProduct().

Следующее, что нам нужно, это простая реализация сервиса:

@Service
@Transactional
public class OrderServiceImpl implements OrderService {

    // orderRepository constructor injection

    @Override
    public Iterable getAllOrders() {
        return this.orderRepository.findAll();
    }

    @Override
    public Order create(Order order) {
        order.setDateCreated(LocalDate.now());
        return this.orderRepository.save(order);
    }

    @Override
    public void update(Order order) {
        this.orderRepository.save(order);
    }
}

И контроллер, сопоставленный с/api/orders, для обработки запросовOrder.

Наиболее важным является методcreate ():

@PostMapping
public ResponseEntity create(@RequestBody OrderForm form) {
    List formDtos = form.getProductOrders();
    validateProductsExistence(formDtos);
    // create order logic
    // populate order with products

    order.setOrderProducts(orderProducts);
    this.orderService.update(order);

    String uri = ServletUriComponentsBuilder
      .fromCurrentServletMapping()
      .path("/orders/{id}")
      .buildAndExpand(order.getId())
      .toString();
    HttpHeaders headers = new HttpHeaders();
    headers.add("Location", uri);

    return new ResponseEntity<>(order, headers, HttpStatus.CREATED);
}

Прежде всего,we accept a list of products with their corresponding quantities. После этогоwe check if all products exist в базе данных иthen create and save a new order. Мы сохраняем ссылку на вновь созданный объект, чтобы мы могли добавить к нему детали заказа.

Наконец,we create a “Location” header.

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

3. Внешний интерфейс

Теперь, когда у нас создано приложение Spring Boot, пора переместитьthe Angular part of the project. Для этого сначала нужно установитьNode.js с помощью NPM, а затем -Angular CLI, интерфейс командной строки для Angular.

Как мы могли убедиться в официальной документации, установить и то и другое очень просто.

3.1. Настройка углового проекта

Как мы уже упоминали, мы будем использоватьAngular CLI для создания нашего приложения. Чтобы все было просто и все было в одном месте, мы поместим наше приложение Angular в папку/src/main/frontend.

Чтобы создать его, нам нужно открыть терминал (или командную строку) в папке/src/main и запустить:

ng new frontend

Это создаст все файлы и папки, которые нам нужны для нашего приложения Angular. В файлеpakage.json мы можем проверить, какие версии наших зависимостей установлены. Это руководство основано на Angular v6.0.3, но старые версии должны выполнять эту работу, по крайней мере версии 4.3 и новее (HttpClient, которые мы здесь используем, были введены в Angular 4.3).

Следует отметить, чтоwe’ll run all our commands from the /frontend folder, если не указано иное.

Этой настройки достаточно, чтобы запустить приложение Angular, выполнив командуng serve. По умолчанию он запускается наhttp://localhost:4200, и если мы сейчас перейдем туда, то увидим, что базовое приложение Angular загружено.

3.2. Добавление Bootstrap

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

Нам нужно всего несколько вещей, чтобы достичь этого. First, we need torun a command to install it:

npm install --save bootstrap

иthen to say to Angular to actually use it. Для этого нам нужно открыть файлsrc/main/frontend/angular.json и добавить свойствоnode_modules/bootstrap/dist/css/bootstrap.min.css under“styles”. Вот и все.

3.3. Компоненты и модели

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

image

Теперь мы создадим базовый компонент с именемecommerce:

ng g c ecommerce

Это создаст наш компонент внутри папки/frontend/src/app. To load it at application startup, we’llinclude itinto the app.component.html:

Затем мы создадим другие компоненты внутри этого базового компонента:

ng g c /ecommerce/products
ng g c /ecommerce/orders
ng g c /ecommerce/shopping-cart

Конечно, при желании мы могли бы создать все эти папки и файлы вручную, но в этом случае нам понадобитсяremember to register those components in our AppModule.

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

export class Product {
    id: number;
    name: string;
    price: number;
    pictureUrl: string;

    // all arguments constructor
}
export class ProductOrder {
    product: Product;
    quantity: number;

    // all arguments constructor
}
export class ProductOrders {
    productOrders: ProductOrder[] = [];
}

Последняя упомянутая модель соответствует нашемуOrderForm на бэкэнде.

3.4. Базовый компонент

Вверху нашего компонентаecommerce мы поместим панель навигации со ссылкой Home справа:

Отсюда загрузим и другие компоненты:

Мы должны помнить, что для просмотра содержимого наших компонентов, поскольку мы используем классnavbar, нам нужно добавить некоторый CSS вapp.component.css:

.container {
    padding-top: 65px;
}

Прежде чем комментировать наиболее важные части, давайте проверим файл.ts:

@Component({
    selector: 'app-ecommerce',
    templateUrl: './ecommerce.component.html',
    styleUrls: ['./ecommerce.component.css']
})
export class EcommerceComponent implements OnInit {
    private collapsed = true;
    orderFinished = false;

    @ViewChild('productsC')
    productsC: ProductsComponent;

    @ViewChild('shoppingCartC')
    shoppingCartC: ShoppingCartComponent;

    @ViewChild('ordersC')
    ordersC: OrdersComponent;

    toggleCollapsed(): void {
        this.collapsed = !this.collapsed;
    }

    finishOrder(orderFinished: boolean) {
        this.orderFinished = orderFinished;
    }

    reset() {
        this.orderFinished = false;
        this.productsC.reset();
        this.shoppingCartC.reset();
        this.ordersC.paid = false;
    }
}

Как мы видим, нажатие на ссылкуHome сбрасывает дочерние компоненты. Нам нужно получить доступ к методам и полю внутри дочерних компонентов от родителя, поэтому мы сохраняем ссылки на дочерние элементы и используем их внутри методаreset().

3.5. Сервис

Дляsiblings components to communicate with each otherand to retrieve/send data from/to our API нам нужно создать сервис:

@Injectable()
export class EcommerceService {
    private productsUrl = "/api/products";
    private ordersUrl = "/api/orders";

    private productOrder: ProductOrder;
    private orders: ProductOrders = new ProductOrders();

    private productOrderSubject = new Subject();
    private ordersSubject = new Subject();
    private totalSubject = new Subject();

    private total: number;

    ProductOrderChanged = this.productOrderSubject.asObservable();
    OrdersChanged = this.ordersSubject.asObservable();
    TotalChanged = this.totalSubject.asObservable();

    constructor(private http: HttpClient) {
    }

    getAllProducts() {
        return this.http.get(this.productsUrl);
    }

    saveOrder(order: ProductOrders) {
        return this.http.post(this.ordersUrl, order);
    }

    // getters and setters for shared fields
}

Как мы могли заметить, здесь есть относительно простые вещи. Мы выполняем запросы GET и POST для связи с API. Кроме того, мы делаем данные, которые нам нужны, чтобы компоненты были видимыми, чтобы мы могли подписаться на них позже.

Тем не менее, мы должны указать на одну вещь относительно связи с API. Если мы запустим приложение сейчас, мы получим 404 и не получим никаких данных. Причина этого в том, что, поскольку мы используем относительные URL-адреса, Angular по умолчанию попытается выполнить вызовhttp://localhost:4200/api/products, а наше внутреннее приложение работает наlocalhost:8080.

Конечно, мы могли бы жестко закодировать URL-адреса вlocalhost:8080, но мы не хотим этого делать. Вместо этогоwhen working with different domains, we should create a file named proxy-conf.json in our /frontend folder:

{
    "/api": {
        "target": "http://localhost:8080",
        "secure": false
    }
}

И тогда нам нужноopen package.json and change scripts.start property для соответствия:

"scripts": {
    ...
    "start": "ng serve --proxy-config proxy-conf.json",
    ...
  }

А теперь нам просто нужноkeep in mind to start the application with npm start instead ng serve.

3.6. Товары

В нашемProductsComponent мы внедрим сервис, который мы создали ранее, загрузим список продуктов из API и преобразуем его в списокProductOrders, поскольку мы хотим добавить поле количества к каждому продукту:

export class ProductsComponent implements OnInit {
    productOrders: ProductOrder[] = [];
    products: Product[] = [];
    selectedProductOrder: ProductOrder;
    private shoppingCartOrders: ProductOrders;
    sub: Subscription;
    productSelected: boolean = false;

    constructor(private ecommerceService: EcommerceService) {}

    ngOnInit() {
        this.productOrders = [];
        this.loadProducts();
        this.loadOrders();
    }

    loadProducts() {
        this.ecommerceService.getAllProducts()
            .subscribe(
                (products: any[]) => {
                    this.products = products;
                    this.products.forEach(product => {
                        this.productOrders.push(new ProductOrder(product, 0));
                    })
                },
                (error) => console.log(error)
            );
    }

    loadOrders() {
        this.sub = this.ecommerceService.OrdersChanged.subscribe(() => {
            this.shoppingCartOrders = this.ecommerceService.ProductOrders;
        });
    }
}

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

addToCart(order: ProductOrder) {
    this.ecommerceService.SelectedProductOrder = order;
    this.selectedProductOrder = this.ecommerceService.SelectedProductOrder;
    this.productSelected = true;
}

removeFromCart(productOrder: ProductOrder) {
    let index = this.getProductIndex(productOrder.product);
    if (index > -1) {
        this.shoppingCartOrders.productOrders.splice(
            this.getProductIndex(productOrder.product), 1);
    }
    this.ecommerceService.ProductOrders = this.shoppingCartOrders;
    this.shoppingCartOrders = this.ecommerceService.ProductOrders;
    this.productSelected = false;
}

Наконец, мы создадим методreset (), упомянутый в разделе 3.4:

reset() {
    this.productOrders = [];
    this.loadProducts();
    this.ecommerceService.ProductOrders.productOrders = [];
    this.loadOrders();
    this.productSelected = false;
}

Мы пройдемся по списку продуктов в нашем HTML-файле и покажем его пользователю:

{{order.product.name}}

${{order.product.price}}

Мы также добавим простой класс к соответствующему файлу CSS, чтобы все было удобно:

.padding-0 {
    padding-right: 0;
    padding-left: 1;
}

3.7. Корзина

В компонентShoppingCart мы также внедрим службу. Мы будем использовать его, чтобы подписаться на изменения вProductsComponent (чтобы заметить, когда продукт выбран для добавления в корзину), а затем обновим содержимое корзины и соответствующим образом пересчитаем общую стоимость:

export class ShoppingCartComponent implements OnInit, OnDestroy {
    orderFinished: boolean;
    orders: ProductOrders;
    total: number;
    sub: Subscription;

    @Output() onOrderFinished: EventEmitter;

    constructor(private ecommerceService: EcommerceService) {
        this.total = 0;
        this.orderFinished = false;
        this.onOrderFinished = new EventEmitter();
    }

    ngOnInit() {
        this.orders = new ProductOrders();
        this.loadCart();
        this.loadTotal();
    }

    loadTotal() {
        this.sub = this.ecommerceService.OrdersChanged.subscribe(() => {
            this.total = this.calculateTotal(this.orders.productOrders);
        });
    }

    loadCart() {
        this.sub = this.ecommerceService.ProductOrderChanged.subscribe(() => {
            let productOrder = this.ecommerceService.SelectedProductOrder;
            if (productOrder) {
                this.orders.productOrders.push(new ProductOrder(
                    productOrder.product, productOrder.quantity));
            }
            this.ecommerceService.ProductOrders = this.orders;
            this.orders = this.ecommerceService.ProductOrders;
            this.total = this.calculateTotal(this.orders.productOrders);
        });
    }

    ngOnDestroy() {
        this.sub.unsubscribe();
    }
}

Отсюда мы отправляем событие в родительский компонент, когда заказ завершен, и нам нужно перейти к оформлению заказа. Здесь также есть методreset ():

finishOrder() {
    this.orderFinished = true;
    this.ecommerceService.Total = this.total;
    this.onOrderFinished.emit(this.orderFinished);
}

reset() {
    this.orderFinished = false;
    this.orders = new ProductOrders();
    this.orders.productOrders = []
    this.loadTotal();
    this.total = 0;
}

HTML-файл прост:

Shopping Cart
Total: ${{total}}

Items bought:
  • {{ order.product.name }} - {{ order.quantity}} pcs.

3.8. заказы

Мы сделаем все максимально простым и вOrdersComponent имитируем оплату, установив для свойства значение true и сохранив порядок в базе данных. Мы можем проверить, что заказы сохранены либо черезh2-console, либо нажавhttp://localhost:8080/api/orders.

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

export class OrdersComponent implements OnInit {
    orders: ProductOrders;
    total: number;
    paid: boolean;
    sub: Subscription;

    constructor(private ecommerceService: EcommerceService) {
        this.orders = this.ecommerceService.ProductOrders;
    }

    ngOnInit() {
        this.paid = false;
        this.sub = this.ecommerceService.OrdersChanged.subscribe(() => {
            this.orders = this.ecommerceService.ProductOrders;
        });
        this.loadTotal();
    }

    pay() {
        this.paid = true;
        this.ecommerceService.saveOrder(this.orders).subscribe();
    }
}

И, наконец, нам нужно отобразить информацию для пользователя:

ORDER

  • {{ order.product.name }} - ${{ order.product.price }} x {{ order.quantity}} pcs.

Total amount: ${{ total }}

4. Слияние пружинной загрузки и угловых приложений

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

Здесь мы хотим использоватьbuild the Angular app which calls Webpack to bundle up all the assets and push them into the /resources/static directory of the Spring Boot app. Таким образом, мы можем просто запустить приложение Spring Boot и протестировать наше приложение, упаковать все это и развернуть как одно приложение.

Чтобы это стало возможным, нам нужноopen ‘package.json‘ again add some new scripts after scripts.build:

"postbuild": "npm run deploy",
"predeploy": "rimraf ../resources/static/ && mkdirp ../resources/static",
"deploy": "copyfiles -f dist/** ../resources/static",

Мы используем некоторые пакеты, которых не установили, поэтому давайте установим их:

npm install --save-dev rimraf
npm install --save-dev mkdirp
npm install --save-dev copyfiles

Командаrimraf будет просматривать каталог и создавать новый каталог (фактически очищая его), в то время какcopyfiles копирует файлы из папки распространения (где Angular все размещает) в нашstatic папку.

Теперь нам просто нужноrun npm run build command and this should run all those commands and the ultimate output will be our packaged application in the static folder.

Затем мы запускаем наше приложение Spring Boot на порту 8080, обращаемся к нему и используем приложение Angular.

5. Заключение

В этой статье мы создали простое приложение для электронной коммерции. Мы создали API на бэкэнде с помощью Spring Boot, а затем использовали его в нашем приложении, созданном на Angular. Мы продемонстрировали, как сделать необходимые нам компоненты, заставить их взаимодействовать друг с другом и извлекать / отправлять данные из / в API.

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

Как всегда, полный проект, который мы описали в этой статье, можно найти в папкеGitHub project.