+1

Bài học số 2 - Xây dựng REST API với Quarkus (JAVA framework)

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

img_resource_layers.png

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à quản lý schema bằng Flyway.

Panache cũng cần được định cấu hình trong compiler plugin. Thêm Panache 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>

Sau đó, thêm các cấu hình có liên quan trong application.properties.

# configure your datasource
quarkus.datasource.db-kind = postgresql
quarkus.datasource.username = postgres
quarkus.datasource.password = 1
quarkus.datasource.jdbc.url = jdbc:postgresql://localhost:5433/postgres

# use `update` to only update the schema
#quarkus.hibernate-orm.database.generation = update
quarkus.hibernate-orm.database.default-schema = demo
quarkus.hibernate-orm.log.sql=true

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;

Bạn có thể tùy chỉnh Flyway behaviour bằng cách thêm vào file application.properties các thuộc tính sau:

# run Flyway migrations automatically
quarkus.flyway.migrate-at-start=true

# more Flyway configuration options
quarkus.flyway.schemas=demo
quarkus.flyway.locations=db/migration
quarkus.flyway.baseline-on-migrate=true

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.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.