Uma implementação simples de comércio eletrônico com o Spring
1. Visão geral de nosso aplicativo de comércio eletrônico
Neste tutorial, implementaremos um aplicativo simples de e-commerce. Vamos desenvolver uma API usandoSpring Boote um aplicativo cliente que consumirá a API usandoAngular.
Basicamente, o usuário poderá adicionar / remover produtos de uma lista de produtos de / para um carrinho de compras e fazer um pedido.
2. Parte de back-end
Para desenvolver a API, usaremos a versão mais recente do Spring Boot. Também usamos o banco de dados JPA e H2 para o lado persistente das coisas.
To learn more about Spring Boot,you could check out our Spring Boot series of articles e se desejarto get familiar with building a REST API, please check out another series.
2.1. Dependências do Maven
Vamos preparar nosso projeto e importar as dependências necessárias em nossopom.xml.
Precisaremos de algunsSpring Boot dependencies principais:
org.springframework.boot
spring-boot-starter-data-jpa
2.0.4.RELEASE
org.springframework.boot
spring-boot-starter-web
2.0.4.RELEASE
Então, oH2 database:
com.h2database
h2
1.4.197
runtime
E finalmente - oJackson library:
com.fasterxml.jackson.datatype
jackson-datatype-jsr310
2.9.6
UsamosSpring Initializr para configurar rapidamente o projeto com as dependências necessárias.
2.2. Configurando o banco de dados
Embora pudéssemos usar o banco de dados H2 in-memory out of the box com Spring Boot, ainda faremos alguns ajustes antes de começar a desenvolver nossa API.
Vamosenable H2 console em nosso arquivoapplication.propertiesso we can actually check the state of our database and see if everything is going as we’d expect.
Além disso, pode ser útil registrar consultas SQL no console durante o desenvolvimento:
spring.datasource.name=ecommercedb
spring.jpa.show-sql=true
#H2 settings
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
Depois de adicionar essas configurações, poderemos acessar o banco de dados emhttp://localhost:8080/h2-console usandojdbc:h2:mem:ecommercedb as URL JDBC e usuáriosa em senha.
2.3. A Estrutura do Projeto
O projeto será organizado em vários pacotes padrão, com o aplicativo Angular colocado na pasta 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
Devemos observar que todas as interfaces no pacote do repositório são simples e estendemCrudRepository do Spring Data, então vamos omitir sua exibição aqui.
2.4. Manipulação de exceção
Precisaremos de um manipulador de exceções para nossa API para lidar adequadamente com eventuais exceções.
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.
Aqui, nos concentramos emConstraintViolationExceptione em nossoResourceNotFoundException personalizado:
@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. Produtos
If you need more knowledge about persistence in Spring, there is a lot of useful articles in Spring Persistence series.
Nosso aplicativo suportaráonly reading products from the database, então precisamos adicionar alguns primeiro.
Vamos criar uma classeProduct simples:
@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
}
Embora o usuário não tenha a oportunidade de adicionar produtos por meio do aplicativo, oferecemos suporte para salvar um produto no banco de dados para preencher a lista de produtos.
Um serviço simples será suficiente para nossas necessidades:
@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);
}
}
Um controlador simples manipulará solicitações para recuperar a lista de produtos:
@RestController
@RequestMapping("/api/products")
public class ProductController {
// productService constructor injection
@GetMapping(value = { "", "/" })
public @NotNull Iterable getProducts() {
return productService.getAllProducts();
}
}
Tudo o que precisamos agora para expor a lista de produtos ao usuário - é realmente colocar alguns produtos no banco de dados. Portanto, faremos uso da classeCommandLineRunner para fazer umBean em nossa classe de aplicativo principal.
Dessa forma, inseriremos produtos no banco de dados durante a inicialização do aplicativo:
@Bean
CommandLineRunner runner(ProductService productService) {
return args -> {
productService.save(...);
// more products
}
Se agora iniciarmos nosso aplicativo, poderemos recuperar a lista de produtos por meio dehttp://localhost:8080/api/products. Além disso, se formos parahttp://localhost:8080/h2-consolee fizer login, veremos que há uma tabela chamadaPRODUCT com o produtos que acabamos de adicionar.
2.6. Encomendas
No lado da API, precisamos ativar as solicitações POST para salvar os pedidos que o usuário final fará.
Vamos primeiro criar o modelo:
@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
}
Devemos observar algumas coisas aqui. Certamente uma das coisas mais dignas de nota éremember to change the default name of our table. Como nomeamos a classeOrder, por padrão, a tabela chamadaORDER deve ser criada. Mas como essa é uma palavra SQL reservada, adicionamos@Table(name = “orders”) para evitar conflitos.
Além disso, temos dois@Transient methods that will return a total amount for that order and the number of products in it. Ambos representam dados calculados, portanto, não há necessidade de armazená-los no banco de dados.
Finalmente, temos um@OneToMany relation representing the order’s details. Para isso, precisamos de outra classe de entidade:
@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
}
Essas classes não são nada complicadas, mas devemos notar que na classeOrderProduct colocamos@JsonIgnore na chave primária. Isso ocorre porque não queremos serializarOrder parte da chave primária, pois seria redundante.
Precisamos apenas que oProduct seja exibido para o usuário, por isso temos o métodogetProduct() transitório.
Em seguida, precisamos de uma implementação de serviço simples:
@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);
}
}
E um controlador mapeado para/api/orders para lidar com solicitações deOrder.
O mais importante é o métodocreate ():
@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);
}
Em primeiro lugar,we accept a list of products with their corresponding quantities. Depois disso,we check if all products exist no banco de dados ethen create and save a new order. Estamos mantendo uma referência ao objeto recém-criado para que possamos adicionar detalhes do pedido a ele.
Finalmente,we create a “Location” header.
A implementação detalhada está no repositório - o link para ele é mencionado no final deste artigo.
3. A parte dianteira
Agora que construímos nosso aplicativo Spring Boot, é hora de moverthe Angular part of the project. Para fazer isso, primeiro teremos que instalarNode.js com NPM e, depois disso, umAngular CLI, uma interface de linha de comando para Angular.
É realmente fácil instalar ambos, como podemos ver na documentação oficial.
3.1. Configurando o projeto angular
Como mencionamos, usaremosAngular CLI para criar nosso aplicativo. Para manter as coisas simples e ter tudo em um só lugar, manteremos nosso aplicativo Angular dentro da pasta/src/main/frontend.
Para criá-lo, precisamos abrir um terminal (ou prompt de comando) na pasta/src/main e executar:
ng new frontend
Isso criará todos os arquivos e pastas que precisamos para nosso aplicativo Angular. No arquivopakage.json, podemos verificar quais versões de nossas dependências estão instaladas. Este tutorial é baseado no Angular v6.0.3, mas as versões mais antigas devem fazer o trabalho, pelo menos as versões 4.3 e mais recentes (HttpClient que usamos aqui foi introduzido no Angular 4.3).
Devemos observar quewe’ll run all our commands from the /frontend folder, a menos que indicado de forma diferente.
Esta configuração é suficiente para iniciar o aplicativo Angular executando o comandong serve. Por padrão, ele roda emhttp://localhost:4200e formos lá, veremos o aplicativo Angular de base carregado.
3.2. Adicionando Bootstrap
Antes de prosseguirmos com a criação de nossos próprios componentes, vamos primeiro adicionarBootstrap ao nosso projeto para que possamos fazer com que nossas páginas tenham uma boa aparência.
Precisamos de apenas algumas coisas para conseguir isso. First, we need torun a command to install it:
npm install --save bootstrap
ethen to say to Angular to actually use it. Para isso, precisamos abrir um arquivosrc/main/frontend/angular.jsone adicionar a propriedadenode_modules/bootstrap/dist/css/bootstrap.min.css under“styles”. E é isso.
3.3. Componentes e modelos
Antes de começarmos a criar os componentes para nosso aplicativo, vamos primeiro verificar como nosso aplicativo realmente será:
Agora, vamos criar um componente base, denominadoecommerce:
ng g c ecommerce
Isso criará nosso componente dentro da pasta/frontend/src/app. To load it at application startup, we’llinclude itinto the app.component.html:
A seguir, criaremos outros componentes dentro deste componente básico:
ng g c /ecommerce/products
ng g c /ecommerce/orders
ng g c /ecommerce/shopping-cart
Certamente, poderíamos ter criado todas essas pastas e arquivos manualmente, se preferíssemos, mas, nesse caso, precisaríamosremember to register those components in our AppModule.
Também precisaremos de alguns modelos para manipular facilmente nossos dados:
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[] = [];
}
O último modelo mencionado corresponde ao nossoOrderForm no backend.
3.4. Componente base
No topo do nosso componenteecommerce, colocaremos uma barra de navegação com o link Home à direita:
Também carregaremos outros componentes a partir daqui:
Devemos ter em mente que, para ver o conteúdo de nossos componentes, como estamos usando a classenavbar, precisamos adicionar algum CSS aapp.component.css:
.container {
padding-top: 65px;
}
Vamos verificar o arquivo.ts antes de comentar as partes mais importantes:
@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;
}
}
Como podemos ver, clicar no linkHome reinicializará os componentes filhos. Precisamos acessar métodos e um campo dentro dos componentes filhos do pai, é por isso que estamos mantendo referências aos filhos e os usamos dentro do métodoreset().
3.5. O serviço
Parasiblings components to communicate with each otherand to retrieve/send data from/to our API, precisamos criar um serviço:
@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
}
Coisas relativamente simples estão aqui, como poderíamos notar. Estamos fazendo solicitações GET e POST para nos comunicarmos com a API. Além disso, tornamos os dados que precisamos compartilhar entre os componentes observáveis para que possamos assiná-los posteriormente.
No entanto, precisamos ressaltar uma coisa sobre a comunicação com a API. Se rodarmos o aplicativo agora, receberíamos 404 e não recuperaremos dados. A razão para isso é que, como estamos usando URLs relativos, o Angular, por padrão, tentará fazer uma chamada parahttp://localhost:4200/api/productse nosso aplicativo de back-end está sendo executado emlocalhost:8080.
Poderíamos codificar os URLs paralocalhost:8080, é claro, mas isso não é algo que queremos fazer. Em vez disso,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
}
}
E então precisamosopen package.json and change scripts.start property para corresponder:
"scripts": {
...
"start": "ng serve --proxy-config proxy-conf.json",
...
}
E agora devemos apenaskeep in mind to start the application with npm start instead ng serve.
3.6. Produtos
Em nossoProductsComponent, vamos injetar o serviço que fizemos anteriormente, carregar a lista de produtos da API e transformá-la na lista deProductOrders, pois queremos acrescentar um campo de quantidade a cada produto:
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;
});
}
}
Também precisamos de uma opção para adicionar o produto ao carrinho de compras ou remover um:
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;
}
Finalmente, vamos criar um métodoreset () que mencionamos na Seção 3.4:
reset() {
this.productOrders = [];
this.loadProducts();
this.ecommerceService.ProductOrders.productOrders = [];
this.loadOrders();
this.productSelected = false;
}
Vamos iterar a lista de produtos em nosso arquivo HTML e exibi-la ao usuário:
Também adicionaremos uma classe simples ao arquivo CSS correspondente para que tudo se encaixe perfeitamente:
.padding-0 {
padding-right: 0;
padding-left: 1;
}
3.7. Carrinho de compras
No componenteShoppingCart, também injetaremos o serviço. Vamos usá-lo para assinar as alterações noProductsComponent (para notar quando o produto é selecionado para ser colocado no carrinho de compras) e, em seguida, atualizar o conteúdo do carrinho e recalcular o custo total de acordo:
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();
}
}
Estamos enviando um evento para o componente pai daqui quando o pedido for concluído e precisamos ir ao caixa. Há o métodoreset () aqui também:
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;
}
Arquivo HTML é simples:
Shopping Cart
Total: ${{total}}
Items bought:
-
{{ order.product.name }} - {{ order.quantity}} pcs.
3.8. Encomendas
Manteremos as coisas o mais simples que pudermos e noOrdersComponent simularemos o pagamento definindo a propriedade como true e salvando o pedido no banco de dados. Podemos verificar se os pedidos são salvos por meio deh2-console ou pressionandohttp://localhost:8080/api/orders.
Precisamos dosEcommerceService aqui também para recuperar a lista de produtos do carrinho de compras e o valor total do nosso pedido:
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();
}
}
E, finalmente, precisamos exibir informações para o usuário:
ORDER
-
{{ order.product.name }} - ${{ order.product.price }} x {{ order.quantity}} pcs.
Total amount: ${{ total }}
Congratulation! You successfully made the order.
4. Mesclando aplicativos Spring Boot e Angular
Concluímos o desenvolvimento de nossos aplicativos e provavelmente é mais fácil desenvolvê-lo separadamente, como fizemos. Mas, na produção, seria muito mais conveniente ter um único aplicativo, então vamos agora mesclar os dois.
O que queremos fazer aqui é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. Dessa forma, podemos simplesmente executar o aplicativo Spring Boot e testar nosso aplicativo, compactar tudo isso e implantar como um aplicativo.
Para tornar isso possível, precisamosopen ‘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",
Estamos usando alguns pacotes que não instalamos, então vamos instalá-los:
npm install --save-dev rimraf
npm install --save-dev mkdirp
npm install --save-dev copyfiles
O comandorimraf vai olhar para o diretório e criar um novo diretório (limpando-o na verdade), enquantocopyfiles copia os arquivos da pasta de distribuição (onde o Angular coloca tudo) em nossostaticpasta s.
Agora precisamos apenasrun npm run build command and this should run all those commands and the ultimate output will be our packaged application in the static folder.
Em seguida, executamos nosso aplicativo Spring Boot na porta 8080, acessamos lá e usamos o aplicativo Angular.
5. Conclusão
Neste artigo, criamos um aplicativo de comércio eletrônico simples. Criamos uma API no back-end usando o Spring Boot e a consumimos em nosso aplicativo front-end feito em Angular. Demonstramos como criar os componentes de que precisamos, fazê-los se comunicar e recuperar / enviar dados de / para a API.
Por fim, mostramos como mesclar os dois aplicativos em um aplicativo Web empacotado dentro da pasta estática.
Como sempre, o projeto completo que descrevemos neste artigo pode ser encontrado noGitHub project.