Architecture layers: Resource/Controller, Service, Repository
Mục tiêu của bài học này là xây dựng một REST API cho thiết bị IoT. REST API sẽ hỗ trợ chức năng CRUD (tạo, đọc, cập nhật và xóa) cơ bản, lưu dữ liệu vào cơ sở dữ liệu quan hệ SQL. Mình thích bắt đầu với Resource Service Repository layering pattern truyền thống, được hiển thị trong hình dưới đây
Trong pattern này, layer Repository
trả về một đối tượng Entity
đối tượng này được liên kết chặt chẽ với cấu trúc cơ sở dữ liệu bên dưới. Layer Service
chấp nhận và trả về các đối tượng Domain
và layer Resource/Controller
quản lý các REST, ngoài ra có thể xử lý các chuyển đổi dữ liệu bổ sung từ đối tượng Domain
sang một đối tượng View
cụ thể.
Lombok and MapStruct
Lombok là một thư viện Java giúp rút gọn code trong trong ứng dụng tới mức tối thiểu.
MapStruct là một code generator giúp đơn giản hóa rất nhiều việc triển khai mappings giữa các loại JavaBean.
Lombok và MapStruct cần được định cấu hình trong compiler plugin để đảm bảo rằng cả hai đều được thực thi. Dưới đây là đoạn trích của các thay đổi pom.xml
mà bạn cần thực hiện.
<properties>
... <lombok.version>1.18.22</lombok.version> <mapstruct.version>1.4.2.Final</mapstruct.version>
...
</properties> <dependencies>
... <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </dependency> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>${mapstruct.version}</version> </dependency>
...
<dependencies> <build> <plugins> ... <plugin> <artifactId>maven-compiler-plugin</artifactId> <version>${compiler-plugin.version}</version> <configuration> <compilerArgs> <arg>-parameters</arg> </compilerArgs> <annotationProcessorPaths> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </path> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>${mapstruct.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin> ... </plugins>
</build>
Exceptions
Tiếp theo, bạn sẽ triển khai thêm một Exceptions nhanh để sử dụng trong project. Bằng cách sử dụng một ServiceException đơn giản, bạn có thể đơn giản hóa mô hình Exceptions và thêm một số định dạng thông báo để dễ debug.
package org.acme.exception; public class ServiceException extends RuntimeException { public ServiceException(String message) { super(message); } public ServiceException(String format, Object... objects) { super(String.format(format, objects)); } }
Responses
Tiếp theo, bạn sẽ triển khai nhanh một ResponseObject đơn giản.
package org.acme.response; import lombok.Data;
import lombok.NoArgsConstructor; @Data
@NoArgsConstructor public class ResponseObject { private int returnCode; private String message; private Object data; public void getSuccess(Object data) { this.setReturnCode(200); this.setMessage("Fetched data successfully"); this.setData(data); } public void createSuccess(Object data) { this.setReturnCode(201); this.setMessage("Create data successfully"); this.setData(data); } public void updateSuccess(Object data) { this.setReturnCode(204); this.setMessage("Update data successfully"); this.setData(data); } public void deleteSuccess() { this.setReturnCode(204); this.setMessage("Delete data successfully"); } public void getFailed() { this.setReturnCode(404); this.setMessage("Get data failed"); } public void createFailed() { this.setReturnCode(409); this.setMessage("Create data failed"); this.setData(data); } public void updateFailed() { this.setReturnCode(409); this.setMessage("Update data failed"); } public void deleteFailed() { this.setReturnCode(400); this.setMessage("Delete data failed"); } }
Repository layer
Các tương tác cơ sở dữ liệu sẽ được quản lý bởi tiện ích mở rộng Quarkus Panache. Tiện ích mở rộng Panache sử dụng dưới vỏ bọc Hibernate, nhưng cung cấp rất nhiều chức năng để giúp các nhà phát triển làm việc hiệu quả hơn. Trong bài viết này mình sẽ sử dụng cơ sở dữ liệu PostgreSQL và sẽ quản lý schema bằng Flyway, cho phép quản lý phiên bản và mã nguồn của các schema cơ sở dữ liệu. Panache cũng cần được định cấu hình trong compiler plugin
Thêm các phần mở rộng này vào tệp pom.xml
của dự án.
<dependency> <groupId>io.quarkus</groupId> <artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency> <groupId>io.quarkus</groupId> <artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency> <groupId>io.quarkus</groupId> <artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<dependency> <groupId>io.quarkus</groupId> <artifactId>quarkus-flyway</artifactId>
</dependency>
Flyway
Flyway là một công cụ migration cơ sở dữ liệu phổ biến thường được sử dụng trong môi trường JVM. Flyway thường yêu cầu tất cả các tệp migration sẽ nằm trong một folder và đường dẫn lớp có tên là db/migration. Các file SQL sẽ có định dạng như sau:
src/main/resources/db/migration/V1__device_table_create.sql
CREATE TABLE device
( id SERIAL PRIMARY KEY, name TEXT NOT NULL, ipAddress TEXT NOT NULL, macAddress TEXT NOT NULL, status TEXT NOT NULL, type TEXT NOT NULL, version TEXT NOT NULL, CONSTRAINT ipAddress UNIQUE (ipAddress), CONSTRAINT macAddress UNIQUE (macAddress) );
ALTER SEQUENCE device_id_seq RESTART 1000000;
JPA với Panache
Việc sử dụng Java Persistence API (JPA) bắt đầu bằng việc xây dựng một đối tượng Entity
. Khi sử dụng Panache, bạn có thể lựa chọn giữa hai pattern: Active Record and Repository. Mình thích pattern Repository hơn vì mình thích single-responsibility principle.
package org.acme.entity; import lombok.Data; import javax.persistence.*;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern; @Entity(name = "Device")
@Table(name = "device")
@Data
public class DeviceEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Integer id; @Column(name = "name") @NotEmpty private String name; @Column(name = "ipAddress") @NotEmpty @Pattern(regexp="^(([0-9]|[1-9][0-9]|1[0-9]" + "{2}|2[0-4][0-9]|25[0-5])\\.)" + "{3}([0-9]|[1-9][0-9]|1[0-9]" + "{2}|2[0-4][0-9]|25[0-5])$", message="{invalid.ipAddress}") private String ipAddress; @Column(name = "macAddress") @NotEmpty @Pattern(regexp="^([0-9A-Fa-f]{2}[:-])" + "{5}([0-9A-Fa-f]{2})|" + "([0-9a-fA-F]{4}\\." + "[0-9a-fA-F]{4}\\." + "[0-9a-fA-F]{4})$", message="{invalid.macAddress}") private String macAddress; @Column(name = "status") @NotEmpty private String status; @Column(name = "type") @NotEmpty private String type; @Column(name = "version") @NotEmpty private String version;
}
@Data
là cách dùng nhanh khi bạn muốn thêm tất cả các annotation:
@Getter / @Setter
@ToString
@EqualsAndHashCode
@RequiredArgsConstructor
của Lombok vào 1 class.
@Entity
: cho phép bạn tạo ra một thực thể Entity
để ánh xạ với Table trong cơ sở dữ liệu.
@Pattern
: dùng để validate giá trị của một thuộc tính, giá trị chỉ hợp lệ khi nó khớp với một biểu thức chính quy nhất định. Ở đây mình sẽ validate format ipAddress và macAddress có đúng hay không mới cho lưu chúng vào cột tương ứng trong table của cơ sở dữ liệu.
Bước tiếp theo là tạo layer Repository
:
package org.acme.repository;
import org.acme.entity.DeviceEntity; import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import javax.enterprise.context.ApplicationScoped; @ApplicationScoped
public class DeviceRepository implements PanacheRepositoryBase<DeviceEntity, Integer> { }
Service layer: Domain object, MapStruct mapper, và Service
Trong ví dụ này, đối tượng Domain
khá đơn giản. Về cơ bản, nó là một bản sao của đối tượng thực thể Entity
. Tuy nhiên, khi dữ liệu UI muốn lấy khác với dữ liệu lưu trong database, thì lớp bổ sung này sẽ có ích. Điều này giữ cho kiến trúc ứng dụng clean, linh hoạt và dễ dàng mở rộng layer này mà không ảnh hướng đến layer khác.
package org.acme.service.device; import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern; @Data
public class Device { private Integer id; @NotEmpty private String name; @NotEmpty @Pattern(regexp="^(([0-9]|[1-9][0-9]|1[0-9]" + "{2}|2[0-4][0-9]|25[0-5])\\.)" + "{3}([0-9]|[1-9][0-9]|1[0-9]" + "{2}|2[0-4][0-9]|25[0-5])$", message="{invalid.ipAddress}") private String ipAddress; @NotEmpty @Pattern(regexp="^([0-9A-Fa-f]{2}[:-])" + "{5}([0-9A-Fa-f]{2})|" + "([0-9a-fA-F]{4}\\." + "[0-9a-fA-F]{4}\\." + "[0-9a-fA-F]{4})$", message="{invalid.macAddress}") private String macAddress; @NotEmpty private String status; @NotEmpty private String type; @NotEmpty private String version; }
Trong layer Service, bạn sẽ cần map giữa entity
objects và Domain
objects . Đây là nơi MapSturation xuất hiện: để thực hiện ánh xạ cho bạn.
package org.acme.service.device;
import org.acme.entity.DeviceEntity; import org.mapstruct.InheritInverseConfiguration;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
import java.util.List; @Mapper(componentModel = "cdi")
public interface DeviceMapper { List<Device> toDomainList(List<DeviceEntity> entities); Device toDomain(DeviceEntity entity); @InheritInverseConfiguration(name = "toDomain") DeviceEntity toEntity(Device domain); void updateEntityFromDomain(Device domain, @MappingTarget DeviceEntity entity); void updateDomainFromEntity(DeviceEntity entity, @MappingTarget Device domain); }
Bây giờ bạn đã có layer Domain
và layer Mapper
dựa trên MapStruct cần thiết. Bạn có thể thêm layer Service cho chức năng CRUD (Tạo, Đọc, Cập nhật, Xóa) cơ bản.
package org.acme.service.device; import org.acme.entity.DeviceEntity;
import org.acme.repository.DeviceRepository;
import org.acme.exception.ServiceException;
import lombok.AllArgsConstructor;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j; import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.transaction.Transactional;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.util.List;
import java.util.Objects;
import java.util.Optional; @ApplicationScoped
@AllArgsConstructor
@Slf4j
public class DeviceService { @Inject DeviceRepository deviceRepository; @Inject DeviceMapper deviceMapper; public List<Device> findAll() { log.info("Find all devices: {}"); List<Device> deviceObject = this.deviceMapper.toDomainList(deviceRepository.findAll().list()); if (Objects.isNull(deviceObject)) { throw new ServiceException("Table devices is empty"); } else { return deviceObject; } } public Optional<Device> findById(@NonNull Integer deviceId) { log.info("Find device by id: {}", deviceId); Optional<Device> deviceObject = deviceRepository.findByIdOptional(deviceId).map(deviceMapper::toDomain); if (deviceObject.isEmpty()) { throw new ServiceException("Can not find device for ID: ", deviceId); } else { return deviceObject; } } public Optional<Device> findByMac(@NonNull String macAddress) { log.info("Finding Device By Mac Address: {}", macAddress); DeviceEntity entity = deviceRepository.find("macAddress", macAddress).firstResult(); Optional<Device> deviceObject = Optional.ofNullable(entity).map(deviceMapper::toDomain); if (deviceObject.isEmpty()) { throw new ServiceException("Can not find device for MAC Address: ", macAddress); } else { return deviceObject; } } public void validateAddress(@Valid Device device) { log.info("Validate Mac address and IP address: {}", device.getMacAddress()); DeviceEntity entityMac = deviceRepository.find("macAddress", device.getMacAddress()).firstResult(); Optional<Device> deviceMac = Optional.ofNullable(entityMac).map(deviceMapper::toDomain); log.info("Validate Mac address and IP address: {}", device.getIpAddress()); DeviceEntity entityIp = deviceRepository.find("ipAddress", device.getIpAddress()).firstResult(); Optional<Device> deviceIp = Optional.ofNullable(entityIp).map(deviceMapper::toDomain); if ((deviceMac.isPresent()) && (deviceIp.isPresent())) { throw new ServiceException("IP address & MAC Address is already exists"); } } public void validateUpdate(@Valid Device device) { this.validateId(device); this.validateAddress(device); } public void validateId(@Valid Device device) { log.info("Validate ID: {}", device.getId()); if (Objects.isNull(device.getId())) { throw new ServiceException("ID is null"); } } @Transactional public void save(@Valid Device device) { log.info("Create Device: {}", device); this.validateAddress(device); DeviceEntity entity = deviceMapper.toEntity(device); deviceRepository.persist(entity); deviceMapper.updateDomainFromEntity(entity, device); } @Transactional public void update(@NotNull @Valid Device device) { log.info("Updating Device: {}", device); this.validateId(device); DeviceEntity entity = deviceRepository.findByIdOptional(device.getId()) .orElseThrow(() -> new ServiceException("No Device found for ID[%s]", device.getId())); deviceMapper.updateEntityFromDomain(device, entity); deviceRepository.persist(entity); deviceMapper.updateDomainFromEntity(entity, device); } @Transactional public void deleteAll() { log.debug("Deleting Devices: {}"); deviceRepository.deleteAll(); } }
@AllArgsConstructor
: sẽ sinh ra constructor với tất cả các tham số cho các thuộc tính của class một cách tự động.
Resource/Controller layer
Thêm mở rộng OpenAPI vào tệp pom.xml
của project.
<dependency> <groupId>io.quarkus</groupId> <artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
Bây giờ tiện ích mở rộng OpenAPI đã có, bạn có thể triển khai layer DeviceController
package org.acme.controller;
import org.acme.response.ResponseObject;
import org.acme.service.device.Device;
import org.acme.service.device.DeviceService; import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag; import javax.inject.Inject;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.List;
import java.util.Optional; @Path("/devices")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "device", description = "Device Operations")
@AllArgsConstructor
@Slf4j
public class DeviceController { @Inject DeviceService deviceService; @GET @APIResponse( responseCode = "200", description = "Get All Devices", content = @Content( mediaType = MediaType.APPLICATION_JSON, schema = @Schema(type = SchemaType.ARRAY, implementation = Device.class) ) ) @APIResponse( responseCode = "404", description = "DB may be not connected", content = @Content(mediaType = MediaType.APPLICATION_JSON) ) public Response getAll() { ResponseObject responseObject = new ResponseObject(); try { List<Device> data = deviceService.findAll(); responseObject.getSuccess(data); return Response.ok(responseObject).build(); } catch (Exception e) { log.error("Failed to get all data", e); responseObject.getFailed(); return Response.serverError().entity(responseObject).build(); } } @GET @Path("/getById/{id}") @APIResponse( responseCode = "200", description = "Get Device by Id", content = @Content( mediaType = MediaType.APPLICATION_JSON, schema = @Schema(type = SchemaType.OBJECT, implementation = Device.class) ) ) @APIResponse( responseCode = "404", description = "DB may be not connected", content = @Content(mediaType = MediaType.APPLICATION_JSON) ) public Response getById(@Parameter(name = "id", required = true) @PathParam("id") Integer deviceId) { ResponseObject responseObject = new ResponseObject(); try { Optional<Device> data = deviceService.findById(deviceId); responseObject.getSuccess(data); return Response.ok(responseObject).build(); } catch (Exception e) { log.error("Failed to get data by id", e); responseObject.getFailed(); return Response.serverError().entity(responseObject).build(); } } @GET @Path("/getByMac/{macAddress}") @APIResponse( responseCode = "200", description = "Get Device by Id", content = @Content( mediaType = MediaType.APPLICATION_JSON, schema = @Schema(type = SchemaType.OBJECT, implementation = Device.class) ) ) @APIResponse( responseCode = "404", description = "Device does not exist for Mac Adress", content = @Content(mediaType = MediaType.APPLICATION_JSON) ) public Response getByMac(@Parameter(name = "macAddress", required = true) @PathParam("macAddress") String deviceMacAddress) { ResponseObject responseObject = new ResponseObject(); try { Optional<Device> data = deviceService.findByMac(deviceMacAddress); responseObject.getSuccess(data); return Response.ok(responseObject).build(); } catch (Exception e) { log.error("Failed to get data by id", e); responseObject.getFailed(); return Response.serverError().entity(responseObject).build(); } } @POST @APIResponse( responseCode = "201", description = "Device Created", content = @Content( mediaType = MediaType.APPLICATION_JSON, schema = @Schema(type = SchemaType.OBJECT, implementation = Device.class) ) ) @APIResponse( responseCode = "409", description = "Invalid Device", content = @Content(mediaType = MediaType.APPLICATION_JSON) ) public Response post(@NotNull @Valid Device device, @Context UriInfo uriInfo) { ResponseObject responseObject = new ResponseObject(); try { deviceService.validateAddress(device); deviceService.save(device); responseObject.createSuccess(device); URI uri = uriInfo.getAbsolutePathBuilder().path(Integer.toString(device.getId())).build(); return Response.created(uri).entity(responseObject).build(); } catch (Exception e) { log.error("Failed to get data by id", e); responseObject.createFailed(); return Response.serverError().entity(responseObject).build(); } } @PUT @APIResponse( responseCode = "204", description = "Device updated", content = @Content( mediaType = MediaType.APPLICATION_JSON, schema = @Schema(type = SchemaType.OBJECT, implementation = Device.class) ) ) @APIResponse( responseCode = "409", description = "Invalid Device", content = @Content(mediaType = MediaType.APPLICATION_JSON) ) public Response put( @NotNull @Valid Device device) { ResponseObject responseObject = new ResponseObject(); try { deviceService.validateUpdate(device); deviceService.update(device); responseObject.updateSuccess(device); return Response.ok(responseObject).build(); } catch (Exception e) { log.error("Failed to get data by id", e); responseObject.updateFailed(); return Response.serverError().entity(responseObject).build(); } } @DELETE @APIResponse( responseCode = "204", description = "Delete All Devices", content = @Content( mediaType = MediaType.APPLICATION_JSON, schema = @Schema(type = SchemaType.ARRAY, implementation = Device.class) ) ) @APIResponse( responseCode = "500", description = "DB may be not connected", content = @Content( mediaType = MediaType.APPLICATION_JSON, schema = @Schema(type = SchemaType.ARRAY, implementation = Device.class) ) ) public Response deleteAll() { ResponseObject responseObject = new ResponseObject(); try { deviceService.deleteAll(); return Response.ok(responseObject).build(); } catch (Exception e) { log.error("Failed to get data by id", e); responseObject.deleteFailed(); return Response.serverError().entity(responseObject).build(); } } }
Kết Luận
Sự thay đổi của hệ sinh thái Quarkus trong những năm gần đây thực sự đáng kinh ngạc. Mặc dù framework vẫn nhằm mục đích nhẹ và nhanh, nhưng tiện tích hỗ trợ và khả năng tích hợp của nó dường như nằm trên một Đồ thị hàm số mũ. Nếu bạn đang tìm kiếm một framework Java như một phần trong nỗ lực hiện đại hóa ứng dụng hoặc đang bắt đầu hành trình phát triển ứng dụng Kubernetes hay Cloud native, thì Quarkus chắc chắn sẽ nằm trong danh sách lựa chọn của bạn.