Лесно внедряване на електронна търговия с пролетта

1. Преглед на нашето приложение за електронна търговия

В този урок ще приложим просто приложение за електронна търговия. Ще разработим API, използвайки Spring Boot и клиентско приложение, което ще консумира API, използвайки Angular.

По принцип потребителят ще може да добавя / премахва продукти от списък с продукти към / от пазарска количка и да прави поръчка.

2. Backend част

За да разработим API, ще използваме най-новата версия на Spring Boot. Също така използваме база данни JPA и H2 за устойчивостта на нещата.

За да научите повече за Spring Boot, можете да разгледате нашата серия статии за Spring Boot и ако искате да се запознаете с изграждането на REST API, моля, разгледайте друга серия .

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

Нека да подготвим нашия проект и да импортираме необходимите зависимости в нашия pom.xml .

Ще ни трябват някои основни зависимости Spring Boot:

 org.springframework.boot spring-boot-starter-data-jpa 2.2.2.RELEASE   org.springframework.boot spring-boot-starter-web 2.2.2.RELEASE  

След това базата данни H2:

 com.h2database h2 1.4.197 runtime 

И накрая - библиотеката на Джаксън:

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

Използвахме Spring Initializr, за да настроим бързо проекта с необходимите зависимости.

2.2. Настройване на базата данни

Въпреки че бихме могли да използваме H2 база данни в паметта с Spring Boot, все пак ще направим някои корекции, преди да започнем да разработваме нашия API.

Ще активираме конзолата H2 в нашия файл application.properties, за да можем всъщност да проверим състоянието на нашата база данни и да видим дали всичко върви както очакваме .

Също така би било полезно да регистрирате SQL заявки в конзолата, докато разработвате:

spring.datasource.name=ecommercedb spring.jpa.show-sql=true #H2 settings spring.h2.console.enabled=true spring.h2.console.path=/h2-console

След добавяне на тези настройки ще можем да осъществим достъп до базата данни на // localhost: 8080 / h2-console, използвайки jdbc: h2: mem: ecommercedb като JDBC URL и потребител sa без парола.

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

Проектът ще бъде организиран в няколко стандартни пакета, като приложението Angular ще бъде поставено във външната папка:

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

Трябва да отбележим, че всички интерфейси в пакета на хранилището са прости и разширяват CrudRepository на Spring Data, така че ще пропуснем да ги показваме тук.

2.4. Обработка на изключения

Ще ни е необходим манипулатор на изключения за нашия API, за да се справим правилно с евентуални изключения.

Можете да намерите повече подробности за темата в нашите Обработка на грешки за REST с пролет и обработка на съобщения за грешки за статии на REST API .

Тук се фокусираме върху 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. Продукти

Ако имате нужда от повече знания за постоянството през пролетта, има много полезни статии в поредицата Spring Persistence .

Нашето приложение ще поддържа само четене на продукти от базата данни , така че първо трябва да добавим някои.

Нека създадем прост продуктов клас:

@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 }

Ако сега стартираме нашето приложение, бихме могли да извлечем списък с продукти чрез // localhost: 8080 / api / products. Също така, ако отидем на // 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 }

We should note a few things here. Certainly one of the most noteworthy things is to remember to change the default name of our table. Since we named the class Order, by default the table named ORDER should be created. But because that is a reserved SQL word, we added @Table(name = “orders”) to avoid conflicts.

Furthermore, we have two @Transient methods that will return a total amount for that order and the number of products in it. Both represent calculated data, so there is no need to store it in the database.

Finally, we have a @OneToMany relation representing the order's details. For that we need another entity class:

@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 }

Those classes are nothing too complicated, but we should note that in OrderProduct class we put @JsonIgnore on the primary key. That's because we don't want to serialize Order part of the primary key since it'd be redundant.

We only need the Product to be displayed to the user, so that's why we have transient getProduct() method.

Next what we need is a simple service implementation:

@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); } }

And a controller mapped to /api/orders to handle Order requests.

Most important is the create() method:

@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); }

First of all, we accept a list of products with their corresponding quantities. After that, we check if all products exist in the database and then create and save a new order. We're keeping a reference to the newly created object so we can add order details to it.

Finally, we create a “Location” header.

The detailed implementation is in the repository – the link to it is mentioned at the end of this article.

3. Frontend

Now that we have our Spring Boot application built up, it's time to move the Angular part of the project. To do so, we'll first have to install Node.js with NPM and, after that, an Angular CLI, a command line interface for Angular.

It's really easy to install both of those as we could see in the official documentation.

3.1. Setting Up the Angular Project

As we mentioned, we'll use Angular CLI to create our application. To keep things simple and have all in one place, we'll keep our Angular application inside the /src/main/frontend folder.

To create it, we need to open a terminal (or command prompt) in the /src/main folder and run:

ng new frontend

This will create all the files and folders we need for our Angular application. In the file pakage.json, we can check which versions of our dependencies are installed. This tutorial is based on Angular v6.0.3, but older versions should do the job, at least versions 4.3 and newer (HttpClient that we use here was introduced in Angular 4.3).

We should note that we'll run all our commands from the /frontend folder unless stated differently.

This setup is enough to start the Angular application by running ng serve command. By default, it runs on //localhost:4200 and if we now go there we'll see base Angular application loaded.

3.2. Adding Bootstrap

Before we proceed with creating our own components, let's first add Bootstrap to our project so we can make our pages look nice.

We need just a few things to achieve this. First, we need torun a command to install it:

npm install --save bootstrap

and then to say to Angular to actually use it. For this, we need to open a file src/main/frontend/angular.json and add node_modules/bootstrap/dist/css/bootstrap.min.css under “styles” property. And that's it.

3.3. Components and Models

Before we start creating the components for our application, let's first check out how our app will actually look like:

Now, we'll create a base component, named ecommerce:

ng g c ecommerce

This will create our component inside the /frontend/src/app folder. To load it at application startup, we'llinclude itinto the app.component.html:

Next, we'll create other components inside this base component:

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

Certainly, we could've created all those folders and files manually if preferred, but in that case, we'd need to remember to register those components in our AppModule.

We'll also need some models to easily manipulate our data:

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[] = []; }

The last model mentioned matches our OrderForm on the backend.

3.4. Base Component

At the top of our ecommerce component, we'll put a navbar with the Home link on the right:

 Baeldung Ecommerce 
    
  • Home (current)

We'll also load other components from here:

We should keep in mind that, in order to see the content from our components, since we are using the navbar class, we need to add some CSS to the app.component.css:

.container { padding-top: 65px; }

Let's check out the .ts file before we comment most important parts:

@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; } }

As we can see, clicking on the Home link will reset child components. We need to access methods and a field inside child components from the parent, so that's why we are keeping references to the children and use those inside the reset() method.

3.5. The Service

In order for siblings components to communicate with each otherand to retrieve/send data from/to our API, we'll need to create a service:

@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 }

Relatively simple things are in here, as we could notice. We're making a GET and a POST requests to communicate with the API. Also, we make data we need to share between components observable so we can subscribe to it later on.

Nevertheless, we need to point out one thing regarding the communication with the API. If we run the application now, we would receive 404 and retrieve no data. The reason for this is that, since we are using relative URLs, Angular by default will try to make a call to //localhost:4200/api/products and our backend application is running on localhost:8080.

We could hardcode the URLs to localhost:8080, of course, but that's not something we want to do. Instead, when working with different domains, we should create a file named proxy-conf.json in our /frontend folder:

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

And then we need to open package.json and change scripts.start property to match:

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

And now we just should keep in mind to start the application with npm start instead ng serve.

3.6. Products

In our ProductsComponent, we'll inject the service we made earlier and load the product list from the API and transform it into the list of ProductOrders since we want to append a quantity field to every product:

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; }); } }

We also need an option to add the product to the shopping cart or to remove one from it:

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; }

Finally, we'll create a reset() method we mentioned in Section 3.4:

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

We'll iterate through the product list in our HTML file and display it to the user:

{{order.product.name}}

${{order.product.price}}

3.8. Orders

We'll keep things as simple as we can and in the OrdersComponent simulate paying by setting the property to true and saving the order in the database. We can check that the orders are saved either via h2-console or by hitting //localhost:8080/api/orders.

We need the EcommerceService here as well in order to retrieve the product list from the shopping cart and the total amount for our order:

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(); } }

And finally we need to display info to the user:

ORDER

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

Total amount: ${{ total }}

Pay Congratulation! You successfully made the order.

4. Merging Spring Boot and Angular Applications

We finished development of both our applications and it is probably easier to develop it separately as we did. But, in production, it would be much more convenient to have a single application so let's now merge those two.

What we want to do here is to 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. That way, we can just run the Spring Boot application and test our application and pack all this and deploy as one app.

To make this possible, we need to 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",

We're using some packages that we don't have installed, so let's install them:

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

The rimraf command is gonna look at the directory and make a new directory (cleaning it up actually), while copyfiles copies the files from the distribution folder (where Angular places everything) into our static folder.

Now we just need to 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.

Then we run our Spring Boot application at the port 8080, access it there and use the Angular application.

5. Conclusion

В тази статия създадохме просто приложение за електронна търговия. Създадохме API на бекенда, използвайки Spring Boot и след това го консумирахме в нашето външно приложение, направено в Angular. Демонстрирахме как да направим компонентите, от които се нуждаем, да ги накараме да комуникират помежду си и да извлекат / изпратят данни от / към API.

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

Както винаги, пълният проект, който описахме в тази статия, може да бъде намерен в проекта GitHub.