mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-14 04:01:30 +08:00
docs: drop incomplete ja-JP and zh-CN Quarkus translations
The ja-JP and zh-CN translations of the four Quarkus skills are missing content (quarkus-tdd: 8/11 sections, quarkus-verification: 10/15 sections; quarkus-security trimmed by 60-100 lines). Removing rather than shipping partial translations. Turkish translations remain — they mirror the English source. ja-JP and zh-CN to be redone in a focused follow-up.
This commit is contained in:
@@ -1,766 +0,0 @@
|
|||||||
---
|
|
||||||
name: quarkus-patterns
|
|
||||||
description: Quarkus 3.x LTSアーキテクチャパターン、Camelメッセージング、RESTful API設計、CDIサービス、Panacheデータアクセス、非同期処理。イベント駆動アーキテクチャを持つJava Quarkusバックエンド作業に使用。
|
|
||||||
origin: ECC
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quarkus 開発パターン
|
|
||||||
|
|
||||||
Apache Camelを使用したクラウドネイティブなイベント駆動サービスのためのQuarkus 3.xアーキテクチャとAPIパターン。
|
|
||||||
|
|
||||||
## いつアクティブにするか
|
|
||||||
|
|
||||||
- JAX-RSまたはRESTEasy ReactiveでREST APIを構築する
|
|
||||||
- リソース → サービス → リポジトリレイヤーを構造化する
|
|
||||||
- Apache CamelとRabbitMQでイベント駆動パターンを実装する
|
|
||||||
- Hibernate Panache、キャッシング、またはリアクティブストリームを構成する
|
|
||||||
- バリデーション、例外マッピング、またはページネーションを追加する
|
|
||||||
- dev/staging/production環境のプロファイルを設定する(YAML構成)
|
|
||||||
- LogContextとLogback/Logstashエンコーダーでカスタムロギング
|
|
||||||
- CompletableFutureで非同期操作を行う
|
|
||||||
- 条件付きフロー処理を実装する
|
|
||||||
- GraalVMネイティブコンパイルで作業する
|
|
||||||
|
|
||||||
## 複数依存関係を持つサービスレイヤー(Lombok)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Slf4j
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class As2ProcessingService {
|
|
||||||
|
|
||||||
private final InvoiceFlowValidator invoiceFlowValidator;
|
|
||||||
private final EventService eventService;
|
|
||||||
private final DocumentJobService documentJobService;
|
|
||||||
private final BusinessRulesPublisher businessRulesPublisher;
|
|
||||||
private final FileStorageService fileStorageService;
|
|
||||||
|
|
||||||
public void processFile(Path filePath) throws Exception {
|
|
||||||
LogContext logContext = CustomLog.getCurrentContext();
|
|
||||||
try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) {
|
|
||||||
|
|
||||||
String structureIdPartner = logContext.get(As2Constants.STRUCTURE_ID);
|
|
||||||
|
|
||||||
// 条件付きフローロジック
|
|
||||||
boolean isChorusFlow = Boolean.parseBoolean(logContext.get(As2Constants.CHORUS_FLOW));
|
|
||||||
log.info("Is CHORUS_FLOW message: {}", isChorusFlow);
|
|
||||||
|
|
||||||
ValidationFlowConfig validationFlowConfig = isChorusFlow
|
|
||||||
? ValidationFlowConfig.xsdOnly()
|
|
||||||
: ValidationFlowConfig.allValidations();
|
|
||||||
|
|
||||||
InvoiceValidationResult invoiceValidationResult = this.invoiceFlowValidator
|
|
||||||
.validateFlowWithConfig(filePath, validationFlowConfig,
|
|
||||||
EInvoiceSyntaxFormat.UBL, logContext);
|
|
||||||
|
|
||||||
FlowProfile flowProfile = isChorusFlow ?
|
|
||||||
FlowProfile.EXTENDED_CTC_FR :
|
|
||||||
this.invoiceFlowValidator.computeFlowProfile(invoiceValidationResult,
|
|
||||||
invoiceValidationResult.getInvoiceDetails().invoiceFormat().getProfile());
|
|
||||||
|
|
||||||
log.info("Invoice validation completed. Message is valid");
|
|
||||||
|
|
||||||
// CompletableFuture非同期操作
|
|
||||||
try(InputStream inputStream = Files.newInputStream(filePath)) {
|
|
||||||
CompletableFuture<StoredDocumentInfo> documentInfoCompletableFuture =
|
|
||||||
fileStorageService.uploadOriginalFile(inputStream,
|
|
||||||
invoiceValidationResult.getSize(), logContext,
|
|
||||||
invoiceValidationResult.getInvoiceFormat());
|
|
||||||
|
|
||||||
StoredDocumentInfo documentInfo = documentInfoCompletableFuture.join();
|
|
||||||
log.info("File uploaded successfully: {}", documentInfo.getPath());
|
|
||||||
|
|
||||||
if (StringUtils.isBlank(documentInfo.getPath())) {
|
|
||||||
String errorMsg = "File path is empty after upload";
|
|
||||||
log.error(errorMsg);
|
|
||||||
this.eventService.createErrorEvent(documentInfo, "FILE_UPLOAD_FAILED", errorMsg);
|
|
||||||
throw new As2ServerProcessingException(errorMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.eventService.createSuccessEvent(documentInfo, "PERSISTENCE_BLOB_EVENT_TYPE");
|
|
||||||
|
|
||||||
BusinessRulesPayload payload = this.documentJobService.createDocumentAndJobEntities(
|
|
||||||
documentInfo, originalFileName, structureIdPartner,
|
|
||||||
flowProfile, invoiceValidationResult.getDocumentHash());
|
|
||||||
|
|
||||||
// 非同期Camelパブリッシング
|
|
||||||
businessRulesPublisher.publishAsync(payload);
|
|
||||||
this.eventService.createSuccessEvent(payload, "BUSINESS_RULES_MESSAGE_SENT");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**主要パターン:**
|
|
||||||
- Lombokによるコンストラクタインジェクション用の`@RequiredArgsConstructor`
|
|
||||||
- Logbackロギング用の`@Slf4j`
|
|
||||||
- try-with-resourcesによるスコープ付きLogContext
|
|
||||||
- ランタイムパラメータに基づく条件付きフローロジック
|
|
||||||
- 非同期操作用の`.join()`付きCompletableFuture
|
|
||||||
- 成功/エラーシナリオのイベントトラッキング
|
|
||||||
- 非同期Camelメッセージパブリッシング
|
|
||||||
|
|
||||||
## カスタムロギングコンテキストパターン(Logback)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class ProcessingService {
|
|
||||||
|
|
||||||
public void processDocument(Document doc) {
|
|
||||||
LogContext logContext = CustomLog.getCurrentContext();
|
|
||||||
try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) {
|
|
||||||
// すべてのログステートメントにコンテキストを追加
|
|
||||||
logContext.put("documentId", doc.getId().toString());
|
|
||||||
logContext.put("documentType", doc.getType());
|
|
||||||
logContext.put("userId", SecurityContext.getUserId());
|
|
||||||
|
|
||||||
log.info("Starting document processing");
|
|
||||||
|
|
||||||
// このスコープ内のすべてのログはコンテキストを継承
|
|
||||||
processInternal(doc);
|
|
||||||
|
|
||||||
log.info("Document processing completed");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Document processing failed", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Logback構成(logback.xml):**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<configuration>
|
|
||||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
|
||||||
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
|
|
||||||
<includeContext>true</includeContext>
|
|
||||||
<includeMdc>true</includeMdc>
|
|
||||||
</encoder>
|
|
||||||
</appender>
|
|
||||||
|
|
||||||
<logger name="com.example" level="INFO"/>
|
|
||||||
<root level="WARN">
|
|
||||||
<appender-ref ref="CONSOLE"/>
|
|
||||||
</root>
|
|
||||||
</configuration>
|
|
||||||
```
|
|
||||||
|
|
||||||
## イベントサービスパターン
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class EventService {
|
|
||||||
private final EventRepository eventRepository;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
public void createSuccessEvent(Object payload, String eventType) {
|
|
||||||
Objects.requireNonNull(payload, "Payload cannot be null");
|
|
||||||
Event event = new Event();
|
|
||||||
event.setType(eventType);
|
|
||||||
event.setStatus(EventStatus.SUCCESS);
|
|
||||||
event.setPayload(serializePayload(payload));
|
|
||||||
event.setTimestamp(Instant.now());
|
|
||||||
|
|
||||||
eventRepository.persist(event);
|
|
||||||
log.info("Success event created: {}", eventType);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void createErrorEvent(Object payload, String eventType, String errorMessage) {
|
|
||||||
Objects.requireNonNull(payload, "Payload cannot be null");
|
|
||||||
if (errorMessage == null || errorMessage.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("Error message cannot be blank");
|
|
||||||
}
|
|
||||||
Event event = new Event();
|
|
||||||
event.setType(eventType);
|
|
||||||
event.setStatus(EventStatus.ERROR);
|
|
||||||
event.setErrorMessage(errorMessage);
|
|
||||||
event.setPayload(serializePayload(payload));
|
|
||||||
event.setTimestamp(Instant.now());
|
|
||||||
|
|
||||||
eventRepository.persist(event);
|
|
||||||
log.error("Error event created: {} - {}", eventType, errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String serializePayload(Object payload) {
|
|
||||||
try {
|
|
||||||
return objectMapper.writeValueAsString(payload);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalStateException("Failed to serialize event payload", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Camelメッセージパブリッシング(RabbitMQ)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class BusinessRulesPublisher {
|
|
||||||
private final ProducerTemplate producerTemplate;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "camel.rabbitmq.queue.business-rules")
|
|
||||||
String businessRulesQueue;
|
|
||||||
|
|
||||||
public void publishAsync(BusinessRulesPayload payload) {
|
|
||||||
producerTemplate.asyncSendBody(
|
|
||||||
"direct:business-rules-publisher",
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
log.info("Message published to business rules queue: {}", payload.getDocumentId());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void publishSync(BusinessRulesPayload payload) {
|
|
||||||
producerTemplate.sendBody(
|
|
||||||
"direct:business-rules-publisher",
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Camelルート構成:**
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class BusinessRulesRoute extends RouteBuilder {
|
|
||||||
|
|
||||||
@ConfigProperty(name = "camel.rabbitmq.queue.business-rules")
|
|
||||||
String businessRulesQueue;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "rabbitmq.host")
|
|
||||||
String rabbitHost;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "rabbitmq.port")
|
|
||||||
Integer rabbitPort;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configure() {
|
|
||||||
from("direct:business-rules-publisher")
|
|
||||||
.routeId("business-rules-publisher")
|
|
||||||
.log("Publishing message to RabbitMQ: ${body}")
|
|
||||||
.marshal().json(JsonLibrary.Jackson)
|
|
||||||
.toF("spring-rabbitmq:%s?hostname=%s&portNumber=%d",
|
|
||||||
businessRulesQueue, rabbitHost, rabbitPort);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Camel Directルート(インメモリ)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class DocumentProcessingRoute extends RouteBuilder {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configure() {
|
|
||||||
// エラーハンドリング
|
|
||||||
onException(ValidationException.class)
|
|
||||||
.handled(true)
|
|
||||||
.to("direct:validation-error-handler")
|
|
||||||
.log("Validation error: ${exception.message}");
|
|
||||||
|
|
||||||
// メイン処理ルート
|
|
||||||
from("direct:process-document")
|
|
||||||
.routeId("document-processing")
|
|
||||||
.log("Processing document: ${header.documentId}")
|
|
||||||
.bean(DocumentValidator.class, "validate")
|
|
||||||
.bean(DocumentTransformer.class, "transform")
|
|
||||||
.choice()
|
|
||||||
.when(header("documentType").isEqualTo("INVOICE"))
|
|
||||||
.to("direct:process-invoice")
|
|
||||||
.when(header("documentType").isEqualTo("CREDIT_NOTE"))
|
|
||||||
.to("direct:process-credit-note")
|
|
||||||
.otherwise()
|
|
||||||
.to("direct:process-generic")
|
|
||||||
.end();
|
|
||||||
|
|
||||||
from("direct:validation-error-handler")
|
|
||||||
.bean(EventService.class, "createErrorEvent")
|
|
||||||
.log("Validation error handled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Camelファイル処理
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class FileMonitoringRoute extends RouteBuilder {
|
|
||||||
|
|
||||||
@ConfigProperty(name = "file.input.directory")
|
|
||||||
String inputDirectory;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "file.processed.directory")
|
|
||||||
String processedDirectory;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "file.error.directory")
|
|
||||||
String errorDirectory;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configure() {
|
|
||||||
from("file:" + inputDirectory + "?move=" + processedDirectory +
|
|
||||||
"&moveFailed=" + errorDirectory + "&delay=5000")
|
|
||||||
.routeId("file-monitor")
|
|
||||||
.log("Processing file: ${header.CamelFileName}")
|
|
||||||
.to("direct:process-file");
|
|
||||||
|
|
||||||
from("direct:process-file")
|
|
||||||
.bean(As2ProcessingService.class, "processFile")
|
|
||||||
.log("File processing completed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Camel Bean呼び出し
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class InvoiceRoute extends RouteBuilder {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configure() {
|
|
||||||
from("direct:invoice-validation")
|
|
||||||
.bean(InvoiceFlowValidator.class, "validateFlowWithConfig")
|
|
||||||
.log("Validation result: ${body}");
|
|
||||||
|
|
||||||
from("direct:persist-and-publish")
|
|
||||||
.bean(DocumentJobService.class, "createDocumentAndJobEntities")
|
|
||||||
.bean(BusinessRulesPublisher.class, "publishAsync")
|
|
||||||
.bean(EventService.class, "createSuccessEvent(${body}, 'PUBLISHED')");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## REST API構造
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Path("/api/documents")
|
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DocumentResource {
|
|
||||||
private final DocumentService documentService;
|
|
||||||
|
|
||||||
@GET
|
|
||||||
public Response list(
|
|
||||||
@QueryParam("page") @DefaultValue("0") int page,
|
|
||||||
@QueryParam("size") @DefaultValue("20") int size) {
|
|
||||||
List<Document> documents = documentService.list(page, size);
|
|
||||||
return Response.ok(documents).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@POST
|
|
||||||
public Response create(@Valid CreateDocumentRequest request, @Context UriInfo uriInfo) {
|
|
||||||
Document document = documentService.create(request);
|
|
||||||
URI location = uriInfo.getAbsolutePathBuilder()
|
|
||||||
.path(String.valueOf(document.id))
|
|
||||||
.build();
|
|
||||||
return Response.created(location).entity(DocumentResponse.from(document)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("/{id}")
|
|
||||||
public Response getById(@PathParam("id") Long id) {
|
|
||||||
return documentService.findById(id)
|
|
||||||
.map(DocumentResponse::from)
|
|
||||||
.map(Response::ok)
|
|
||||||
.orElse(Response.status(Response.Status.NOT_FOUND))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## リポジトリパターン(Panache Repository)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class DocumentRepository implements PanacheRepository<Document> {
|
|
||||||
|
|
||||||
public List<Document> findByStatus(DocumentStatus status, int page, int size) {
|
|
||||||
return find("status = ?1 order by createdAt desc", status)
|
|
||||||
.page(page, size)
|
|
||||||
.list();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<Document> findByReferenceNumber(String referenceNumber) {
|
|
||||||
return find("referenceNumber", referenceNumber).firstResultOptional();
|
|
||||||
}
|
|
||||||
|
|
||||||
public long countByStatusAndDate(DocumentStatus status, LocalDate date) {
|
|
||||||
return count("status = ?1 and createdAt >= ?2", status, date.atStartOfDay());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## トランザクション付きサービスレイヤー
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DocumentService {
|
|
||||||
private final DocumentRepository repo;
|
|
||||||
private final EventService eventService;
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Document create(CreateDocumentRequest request) {
|
|
||||||
Document document = new Document();
|
|
||||||
document.setReferenceNumber(request.referenceNumber());
|
|
||||||
document.setDescription(request.description());
|
|
||||||
document.setStatus(DocumentStatus.PENDING);
|
|
||||||
document.setCreatedAt(Instant.now());
|
|
||||||
|
|
||||||
repo.persist(document);
|
|
||||||
|
|
||||||
eventService.createSuccessEvent(document, "DOCUMENT_CREATED");
|
|
||||||
|
|
||||||
return document;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<Document> findById(Long id) {
|
|
||||||
return repo.findByIdOptional(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Document> list(int page, int size) {
|
|
||||||
return repo.findAll()
|
|
||||||
.page(page, size)
|
|
||||||
.list();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## DTOとバリデーション
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record CreateDocumentRequest(
|
|
||||||
@NotBlank @Size(max = 200) String referenceNumber,
|
|
||||||
@NotBlank @Size(max = 2000) String description,
|
|
||||||
@NotNull @FutureOrPresent Instant validUntil,
|
|
||||||
@NotEmpty List<@NotBlank String> categories) {}
|
|
||||||
|
|
||||||
public record DocumentResponse(Long id, String referenceNumber, DocumentStatus status) {
|
|
||||||
public static DocumentResponse from(Document document) {
|
|
||||||
return new DocumentResponse(document.getId(), document.getReferenceNumber(),
|
|
||||||
document.getStatus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 例外マッピング
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Provider
|
|
||||||
public class ValidationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
|
|
||||||
@Override
|
|
||||||
public Response toResponse(ConstraintViolationException exception) {
|
|
||||||
String message = exception.getConstraintViolations().stream()
|
|
||||||
.map(cv -> cv.getPropertyPath() + ": " + cv.getMessage())
|
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
|
|
||||||
return Response.status(Response.Status.BAD_REQUEST)
|
|
||||||
.entity(Map.of("error", "validation_error", "message", message))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provider
|
|
||||||
@Slf4j
|
|
||||||
public class GenericExceptionMapper implements ExceptionMapper<Exception> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Response toResponse(Exception exception) {
|
|
||||||
log.error("Unhandled exception", exception);
|
|
||||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
|
||||||
.entity(Map.of("error", "internal_error", "message", "An unexpected error occurred"))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CompletableFuture非同期操作
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Slf4j
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FileStorageService {
|
|
||||||
private final S3Client s3Client;
|
|
||||||
private final ExecutorService executorService;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "storage.bucket-name") String bucketName;
|
|
||||||
|
|
||||||
public CompletableFuture<StoredDocumentInfo> uploadOriginalFile(
|
|
||||||
InputStream inputStream,
|
|
||||||
long size,
|
|
||||||
LogContext logContext,
|
|
||||||
InvoiceFormat format) {
|
|
||||||
|
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
|
||||||
try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) {
|
|
||||||
String path = generateStoragePath(format);
|
|
||||||
|
|
||||||
PutObjectRequest request = PutObjectRequest.builder()
|
|
||||||
.bucket(bucketName)
|
|
||||||
.key(path)
|
|
||||||
.contentLength(size)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
s3Client.putObject(request, RequestBody.fromInputStream(inputStream, size));
|
|
||||||
|
|
||||||
log.info("File uploaded to S3: {}", path);
|
|
||||||
|
|
||||||
return new StoredDocumentInfo(path, size, Instant.now());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to upload file to S3", e);
|
|
||||||
throw new StorageException("Upload failed", e);
|
|
||||||
}
|
|
||||||
}, executorService);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## キャッシング
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DocumentCacheService {
|
|
||||||
private final DocumentRepository repo;
|
|
||||||
|
|
||||||
@CacheResult(cacheName = "document-cache")
|
|
||||||
public Optional<Document> getById(@CacheKey Long id) {
|
|
||||||
return repo.findByIdOptional(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@CacheInvalidate(cacheName = "document-cache")
|
|
||||||
public void evict(@CacheKey Long id) {}
|
|
||||||
|
|
||||||
@CacheInvalidateAll(cacheName = "document-cache")
|
|
||||||
public void evictAll() {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## YAML構成
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# application.yml
|
|
||||||
"%dev":
|
|
||||||
quarkus:
|
|
||||||
datasource:
|
|
||||||
jdbc:
|
|
||||||
url: jdbc:postgresql://localhost:5432/dev_db
|
|
||||||
username: dev_user
|
|
||||||
password: dev_pass
|
|
||||||
hibernate-orm:
|
|
||||||
database:
|
|
||||||
generation: drop-and-create
|
|
||||||
|
|
||||||
rabbitmq:
|
|
||||||
host: localhost
|
|
||||||
port: 5672
|
|
||||||
username: guest
|
|
||||||
password: guest
|
|
||||||
|
|
||||||
"%test":
|
|
||||||
quarkus:
|
|
||||||
datasource:
|
|
||||||
jdbc:
|
|
||||||
url: jdbc:h2:mem:test
|
|
||||||
hibernate-orm:
|
|
||||||
database:
|
|
||||||
generation: drop-and-create
|
|
||||||
|
|
||||||
"%prod":
|
|
||||||
quarkus:
|
|
||||||
datasource:
|
|
||||||
jdbc:
|
|
||||||
url: ${DATABASE_URL}
|
|
||||||
username: ${DB_USER}
|
|
||||||
password: ${DB_PASSWORD}
|
|
||||||
hibernate-orm:
|
|
||||||
database:
|
|
||||||
generation: validate
|
|
||||||
|
|
||||||
rabbitmq:
|
|
||||||
host: ${RABBITMQ_HOST}
|
|
||||||
port: ${RABBITMQ_PORT}
|
|
||||||
username: ${RABBITMQ_USER}
|
|
||||||
password: ${RABBITMQ_PASSWORD}
|
|
||||||
|
|
||||||
# Camel構成
|
|
||||||
camel:
|
|
||||||
rabbitmq:
|
|
||||||
queue:
|
|
||||||
business-rules: business-rules-queue
|
|
||||||
invoice-processing: invoice-processing-queue
|
|
||||||
```
|
|
||||||
|
|
||||||
## ヘルスチェック
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Readiness
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DatabaseHealthCheck implements HealthCheck {
|
|
||||||
private final AgroalDataSource dataSource;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public HealthCheckResponse call() {
|
|
||||||
try (Connection conn = dataSource.getConnection()) {
|
|
||||||
boolean valid = conn.isValid(2);
|
|
||||||
return HealthCheckResponse.named("Database connection")
|
|
||||||
.status(valid)
|
|
||||||
.build();
|
|
||||||
} catch (SQLException e) {
|
|
||||||
return HealthCheckResponse.down("Database connection");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Liveness
|
|
||||||
@ApplicationScoped
|
|
||||||
public class CamelHealthCheck implements HealthCheck {
|
|
||||||
@Inject
|
|
||||||
CamelContext camelContext;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public HealthCheckResponse call() {
|
|
||||||
boolean isStarted = camelContext.getStatus().isStarted();
|
|
||||||
return HealthCheckResponse.named("Camel Context")
|
|
||||||
.status(isStarted)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 依存関係(Maven)
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<properties>
|
|
||||||
<quarkus.platform.version>3.27.0</quarkus.platform.version>
|
|
||||||
<lombok.version>1.18.42</lombok.version>
|
|
||||||
<assertj-core.version>3.24.2</assertj-core.version>
|
|
||||||
<jacoco-maven-plugin.version>0.8.13</jacoco-maven-plugin.version>
|
|
||||||
<maven.compiler.release>17</maven.compiler.release>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencyManagement>
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus.platform</groupId>
|
|
||||||
<artifactId>quarkus-bom</artifactId>
|
|
||||||
<version>${quarkus.platform.version}</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus.platform</groupId>
|
|
||||||
<artifactId>quarkus-camel-bom</artifactId>
|
|
||||||
<version>${quarkus.platform.version}</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</dependencyManagement>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<!-- Quarkusコア -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-arc</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-config-yaml</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Camelエクステンション -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.camel.quarkus</groupId>
|
|
||||||
<artifactId>camel-quarkus-spring-rabbitmq</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.camel.quarkus</groupId>
|
|
||||||
<artifactId>camel-quarkus-direct</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.camel.quarkus</groupId>
|
|
||||||
<artifactId>camel-quarkus-bean</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Lombok -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
<version>${lombok.version}</version>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- ロギング -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkiverse.logging.logback</groupId>
|
|
||||||
<artifactId>quarkus-logging-logback</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>net.logstash.logback</groupId>
|
|
||||||
<artifactId>logstash-logback-encoder</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
```
|
|
||||||
|
|
||||||
## ベストプラクティス
|
|
||||||
|
|
||||||
### アーキテクチャ
|
|
||||||
- コンストラクタインジェクション用にLombokの`@RequiredArgsConstructor`を使用
|
|
||||||
- サービスレイヤーは薄く保ち、複雑なロジックは専門クラスに委譲
|
|
||||||
- メッセージルーティングと統合パターンにCamelルートを使用
|
|
||||||
- データアクセスにはPanache Repositoryパターンを優先
|
|
||||||
|
|
||||||
### イベント駆動
|
|
||||||
- 常にEventServiceで操作をトラッキング(成功/エラーイベント)
|
|
||||||
- インメモリルーティングにCamelの`direct:`エンドポイントを使用
|
|
||||||
- RabbitMQ統合に`spring-rabbitmq`コンポーネントを使用
|
|
||||||
- `ProducerTemplate.asyncSendBody()`で非同期パブリッシングを実装
|
|
||||||
|
|
||||||
### ロギング
|
|
||||||
- 構造化ロギング用にLogstashエンコーダー付きLogbackを使用
|
|
||||||
- `SafeAutoCloseable`でサービスコール間でLogContextを伝播
|
|
||||||
- リクエストトレーシング用にLogContextにコンテキスト情報を追加
|
|
||||||
- 手動ロガーインスタンス化の代わりに`@Slf4j`を使用
|
|
||||||
|
|
||||||
### 非同期操作
|
|
||||||
- ノンブロッキングI/O操作にCompletableFutureを使用
|
|
||||||
- 完了を待つ必要がある場合は`.join()`を呼び出す
|
|
||||||
- CompletableFutureからの例外を適切にハンドリング
|
|
||||||
- トレーシング用に非同期操作にLogContextを渡す
|
|
||||||
|
|
||||||
### 構成
|
|
||||||
- YAML構成を使用(`quarkus-config-yaml`)
|
|
||||||
- dev/test/prod環境のプロファイル対応構成
|
|
||||||
- 機密構成を環境変数に外部化
|
|
||||||
- 型安全な構成インジェクション用に`@ConfigProperty`を使用
|
|
||||||
|
|
||||||
### バリデーション
|
|
||||||
- リソースレイヤーで`@Valid`によるバリデーション
|
|
||||||
- DTOにBean Validationアノテーションを使用
|
|
||||||
- `@Provider`で例外を適切なHTTPレスポンスにマッピング
|
|
||||||
|
|
||||||
### トランザクション
|
|
||||||
- データを変更するサービスメソッドに`@Transactional`を使用
|
|
||||||
- トランザクションは短く焦点を絞る
|
|
||||||
- トランザクション内で非同期操作を呼び出さない
|
|
||||||
|
|
||||||
### テスト
|
|
||||||
- ルートテストに`camel-quarkus-junit5`を使用
|
|
||||||
- アサーションにAssertJを使用
|
|
||||||
- すべての外部依存関係をモック
|
|
||||||
- 条件付きフローロジックを徹底的にテスト
|
|
||||||
|
|
||||||
### Quarkus固有
|
|
||||||
- 最新のLTSバージョン(3.x)を維持
|
|
||||||
- ホットリロード用にQuarkus devモードを使用
|
|
||||||
- 本番準備のためにヘルスチェックを追加
|
|
||||||
- ネイティブコンパイル互換性を定期的にテスト
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
---
|
|
||||||
name: quarkus-security
|
|
||||||
description: Quarkusセキュリティのベストプラクティス:認証、認可、JWT/OIDC、RBAC、入力バリデーション、CSRF、シークレット管理、依存関係セキュリティ。
|
|
||||||
origin: ECC
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quarkus セキュリティレビュー
|
|
||||||
|
|
||||||
認証、認可、入力バリデーションによるQuarkusアプリケーションのセキュリティベストプラクティス。
|
|
||||||
|
|
||||||
## いつアクティブにするか
|
|
||||||
|
|
||||||
- 認証の追加(JWT、OIDC、Basic Auth)
|
|
||||||
- @RolesAllowedまたはSecurityIdentityによる認可の実装
|
|
||||||
- ユーザー入力のバリデーション(Bean Validation、カスタムバリデータ)
|
|
||||||
- CORSまたはセキュリティヘッダーの構成
|
|
||||||
- シークレット管理(Vault、環境変数、構成ソース)
|
|
||||||
- レート制限またはブルートフォース保護の追加
|
|
||||||
- 依存関係のCVEスキャン
|
|
||||||
- MicroProfile JWTまたはSmallRye JWTの使用
|
|
||||||
|
|
||||||
## 認証
|
|
||||||
|
|
||||||
### JWT認証
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Path("/api/protected")
|
|
||||||
@Authenticated
|
|
||||||
public class ProtectedResource {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
JsonWebToken jwt;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SecurityIdentity securityIdentity;
|
|
||||||
|
|
||||||
@GET
|
|
||||||
public Response getData() {
|
|
||||||
String username = jwt.getName();
|
|
||||||
Set<String> roles = jwt.getGroups();
|
|
||||||
return Response.ok(Map.of(
|
|
||||||
"username", username,
|
|
||||||
"roles", roles,
|
|
||||||
"principal", securityIdentity.getPrincipal().getName()
|
|
||||||
)).build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
構成(application.properties):
|
|
||||||
```properties
|
|
||||||
mp.jwt.verify.publickey.location=publicKey.pem
|
|
||||||
mp.jwt.verify.issuer=https://auth.example.com
|
|
||||||
|
|
||||||
# OIDC
|
|
||||||
quarkus.oidc.auth-server-url=https://auth.example.com/realms/myrealm
|
|
||||||
quarkus.oidc.client-id=backend-service
|
|
||||||
quarkus.oidc.credentials.secret=${OIDC_SECRET}
|
|
||||||
```
|
|
||||||
|
|
||||||
### カスタム認証フィルター
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Provider
|
|
||||||
@Priority(Priorities.AUTHENTICATION)
|
|
||||||
public class CustomAuthFilter implements ContainerRequestFilter {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SecurityIdentity identity;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void filter(ContainerRequestContext requestContext) {
|
|
||||||
String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
|
|
||||||
|
|
||||||
// ヘッダーが存在しないか不正な場合は即座に拒否
|
|
||||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
|
||||||
requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String token = authHeader.substring(7);
|
|
||||||
if (!validateToken(token)) {
|
|
||||||
requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean validateToken(String token) {
|
|
||||||
// トークンバリデーションロジック
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 認可
|
|
||||||
|
|
||||||
### ロールベースアクセス制御
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Path("/api/admin")
|
|
||||||
@RolesAllowed("ADMIN")
|
|
||||||
public class AdminResource {
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("/users")
|
|
||||||
public List<UserDto> listUsers() {
|
|
||||||
return userService.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@DELETE
|
|
||||||
@Path("/users/{id}")
|
|
||||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
|
|
||||||
public Response deleteUser(@PathParam("id") Long id) {
|
|
||||||
userService.delete(id);
|
|
||||||
return Response.noContent().build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Path("/api/users")
|
|
||||||
public class UserResource {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SecurityIdentity securityIdentity;
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("/{id}")
|
|
||||||
@RolesAllowed("USER")
|
|
||||||
public Response getUser(@PathParam("id") Long id) {
|
|
||||||
// オーナーシップチェック
|
|
||||||
if (!securityIdentity.hasRole("ADMIN") &&
|
|
||||||
!isOwner(id, securityIdentity.getPrincipal().getName())) {
|
|
||||||
return Response.status(Response.Status.FORBIDDEN).build();
|
|
||||||
}
|
|
||||||
return Response.ok(userService.findById(id)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isOwner(Long userId, String username) {
|
|
||||||
return userService.isOwner(userId, username);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### プログラマティックセキュリティ
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class SecurityService {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SecurityIdentity securityIdentity;
|
|
||||||
|
|
||||||
public boolean canAccessResource(Long resourceId) {
|
|
||||||
if (securityIdentity.isAnonymous()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (securityIdentity.hasRole("ADMIN")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
String userId = securityIdentity.getPrincipal().getName();
|
|
||||||
return resourceRepository.isOwner(resourceId, userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 入力バリデーション
|
|
||||||
|
|
||||||
### Bean Validation
|
|
||||||
|
|
||||||
```java
|
|
||||||
// BAD: バリデーションなし
|
|
||||||
@POST
|
|
||||||
public Response createUser(UserDto dto) {
|
|
||||||
return Response.ok(userService.create(dto)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// GOOD: バリデーション付きDTO
|
|
||||||
public record CreateUserDto(
|
|
||||||
@NotBlank @Size(max = 100) String name,
|
|
||||||
@NotBlank @Email String email,
|
|
||||||
@NotNull @Min(18) @Max(150) Integer age,
|
|
||||||
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String phone
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@POST
|
|
||||||
@Path("/users")
|
|
||||||
public Response createUser(@Valid CreateUserDto dto) {
|
|
||||||
User user = userService.create(dto);
|
|
||||||
return Response.status(Response.Status.CREATED).entity(user).build();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### カスタムバリデータ
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Target({ElementType.FIELD, ElementType.PARAMETER})
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
@Constraint(validatedBy = UsernameValidator.class)
|
|
||||||
public @interface ValidUsername {
|
|
||||||
String message() default "Invalid username format";
|
|
||||||
Class<?>[] groups() default {};
|
|
||||||
Class<? extends Payload>[] payload() default {};
|
|
||||||
}
|
|
||||||
|
|
||||||
public class UsernameValidator implements ConstraintValidator<ValidUsername, String> {
|
|
||||||
@Override
|
|
||||||
public boolean isValid(String value, ConstraintValidatorContext context) {
|
|
||||||
if (value == null) return false;
|
|
||||||
return value.matches("^[a-zA-Z0-9_-]{3,20}$");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## SQLインジェクション防止
|
|
||||||
|
|
||||||
### Panache Active Record(デフォルトで安全)
|
|
||||||
|
|
||||||
```java
|
|
||||||
// GOOD: Panacheによるパラメータ化クエリ
|
|
||||||
List<User> users = User.list("email = ?1 and active = ?2", email, true);
|
|
||||||
|
|
||||||
Optional<User> user = User.find("username", username).firstResultOptional();
|
|
||||||
|
|
||||||
// GOOD: 名前付きパラメータ
|
|
||||||
List<User> users = User.list("email = :email and age > :minAge",
|
|
||||||
Parameters.with("email", email).and("minAge", 18));
|
|
||||||
```
|
|
||||||
|
|
||||||
### ネイティブクエリ(パラメータを使用)
|
|
||||||
|
|
||||||
```java
|
|
||||||
// BAD: 文字列連結
|
|
||||||
@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true)
|
|
||||||
|
|
||||||
// GOOD: パラメータ化ネイティブクエリ
|
|
||||||
@Entity
|
|
||||||
public class User extends PanacheEntity {
|
|
||||||
public static List<User> findByEmailNative(String email) {
|
|
||||||
return getEntityManager()
|
|
||||||
.createNativeQuery("SELECT * FROM users WHERE email = :email", User.class)
|
|
||||||
.setParameter("email", email)
|
|
||||||
.getResultList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## パスワードハッシュ
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class PasswordService {
|
|
||||||
|
|
||||||
public String hash(String plainPassword) {
|
|
||||||
return BcryptUtil.bcryptHash(plainPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean verify(String plainPassword, String hashedPassword) {
|
|
||||||
return BcryptUtil.matches(plainPassword, hashedPassword);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CORS構成
|
|
||||||
|
|
||||||
```properties
|
|
||||||
# application.properties
|
|
||||||
quarkus.http.cors=true
|
|
||||||
quarkus.http.cors.origins=https://app.example.com,https://admin.example.com
|
|
||||||
quarkus.http.cors.methods=GET,POST,PUT,DELETE
|
|
||||||
quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with
|
|
||||||
quarkus.http.cors.exposed-headers=Content-Disposition
|
|
||||||
quarkus.http.cors.access-control-max-age=24H
|
|
||||||
quarkus.http.cors.access-control-allow-credentials=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## シークレット管理
|
|
||||||
|
|
||||||
```properties
|
|
||||||
# application.properties — ここにシークレットを置かない
|
|
||||||
|
|
||||||
# 環境変数を使用
|
|
||||||
quarkus.datasource.username=${DB_USER}
|
|
||||||
quarkus.datasource.password=${DB_PASSWORD}
|
|
||||||
quarkus.oidc.credentials.secret=${OIDC_CLIENT_SECRET}
|
|
||||||
|
|
||||||
# またはVaultを使用
|
|
||||||
quarkus.vault.url=https://vault.example.com
|
|
||||||
quarkus.vault.authentication.kubernetes.role=my-role
|
|
||||||
```
|
|
||||||
|
|
||||||
## レート制限
|
|
||||||
|
|
||||||
**セキュリティ注意**: `X-Forwarded-For`を直接使用しないでください — クライアントが偽装できます。
|
|
||||||
サーブレットリクエストの実際のリモートアドレス、または認証済みIDを使用してください。
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class RateLimitFilter implements ContainerRequestFilter {
|
|
||||||
private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
HttpServletRequest servletRequest;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void filter(ContainerRequestContext requestContext) {
|
|
||||||
String clientId = getClientIdentifier();
|
|
||||||
RateLimiter limiter = limiters.computeIfAbsent(clientId,
|
|
||||||
k -> RateLimiter.create(100.0)); // 1秒あたり100リクエスト
|
|
||||||
|
|
||||||
if (!limiter.tryAcquire()) {
|
|
||||||
requestContext.abortWith(
|
|
||||||
Response.status(429)
|
|
||||||
.entity(Map.of("error", "Too many requests"))
|
|
||||||
.build()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getClientIdentifier() {
|
|
||||||
// コンテナ提供のリモートアドレスを使用(X-Forwarded-Forではない)。
|
|
||||||
// 信頼できるプロキシの背後にある場合はquarkus.http.proxy.proxy-address-forwarding=trueを設定。
|
|
||||||
return servletRequest.getRemoteAddr();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## セキュリティヘッダー
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Provider
|
|
||||||
public class SecurityHeadersFilter implements ContainerResponseFilter {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void filter(ContainerRequestContext request, ContainerResponseContext response) {
|
|
||||||
MultivaluedMap<String, Object> headers = response.getHeaders();
|
|
||||||
|
|
||||||
// クリックジャッキング防止
|
|
||||||
headers.putSingle("X-Frame-Options", "DENY");
|
|
||||||
|
|
||||||
// XSS保護
|
|
||||||
headers.putSingle("X-Content-Type-Options", "nosniff");
|
|
||||||
headers.putSingle("X-XSS-Protection", "1; mode=block");
|
|
||||||
|
|
||||||
// HSTS
|
|
||||||
headers.putSingle("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
||||||
|
|
||||||
// CSP — script-srcに'unsafe-inline'を使用しないでください。XSS保護が無効になります。
|
|
||||||
// 代わりにnonceまたはhashを使用してください。
|
|
||||||
headers.putSingle("Content-Security-Policy",
|
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 監査ロギング
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class AuditService {
|
|
||||||
private static final Logger LOG = Logger.getLogger(AuditService.class);
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SecurityIdentity securityIdentity;
|
|
||||||
|
|
||||||
public void logAccess(String resource, String action) {
|
|
||||||
String user = securityIdentity.isAnonymous()
|
|
||||||
? "anonymous"
|
|
||||||
: securityIdentity.getPrincipal().getName();
|
|
||||||
|
|
||||||
LOG.infof("AUDIT: user=%s action=%s resource=%s timestamp=%s",
|
|
||||||
user, action, resource, Instant.now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 依存関係セキュリティスキャン
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Maven
|
|
||||||
mvn org.owasp:dependency-check-maven:check
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
./gradlew dependencyCheckAnalyze
|
|
||||||
|
|
||||||
# Quarkusエクステンション確認
|
|
||||||
quarkus extension list --installable
|
|
||||||
```
|
|
||||||
|
|
||||||
## ベストプラクティス
|
|
||||||
|
|
||||||
- 本番環境では常にHTTPSを使用
|
|
||||||
- ステートレス認証にJWTまたはOIDCを有効化
|
|
||||||
- 宣言的認可に`@RolesAllowed`を使用
|
|
||||||
- Bean Validationですべての入力をバリデーション
|
|
||||||
- BCryptでパスワードをハッシュ(平文禁止)
|
|
||||||
- シークレットはVaultまたは環境変数に保存
|
|
||||||
- SQLインジェクション防止にパラメータ化クエリを使用
|
|
||||||
- すべてのレスポンスにセキュリティヘッダーを追加
|
|
||||||
- パブリックエンドポイントにレート制限を実装
|
|
||||||
- 機密操作を監査
|
|
||||||
- 依存関係を最新に保ちCVEをスキャン
|
|
||||||
- プログラマティックチェックにSecurityIdentityを使用
|
|
||||||
- 適切なCORSポリシーを設定
|
|
||||||
- 認証・認可パスをテスト
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
---
|
|
||||||
name: quarkus-tdd
|
|
||||||
description: Quarkus 3.x LTS向けテスト駆動開発。JUnit 5、Mockito、REST Assured、Camelテスト、JaCoCoを使用。機能追加、バグ修正、イベント駆動サービスのリファクタリングに使用。
|
|
||||||
origin: ECC
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quarkus TDDワークフロー
|
|
||||||
|
|
||||||
80%以上のカバレッジ(ユニット+統合)を目指すQuarkus 3.xサービスのTDDガイダンス。Apache Camelによるイベント駆動アーキテクチャに最適化。
|
|
||||||
|
|
||||||
## いつ使用するか
|
|
||||||
|
|
||||||
- 新機能またはRESTエンドポイント
|
|
||||||
- バグ修正またはリファクタリング
|
|
||||||
- データアクセスロジック、セキュリティルール、またはリアクティブストリームの追加
|
|
||||||
- Apache Camelルートとイベントハンドラーのテスト
|
|
||||||
- RabbitMQによるイベント駆動サービスのテスト
|
|
||||||
- 条件付きフローロジックのテスト
|
|
||||||
- CompletableFuture非同期操作のバリデーション
|
|
||||||
- LogContext伝播のテスト
|
|
||||||
|
|
||||||
## ワークフロー
|
|
||||||
|
|
||||||
1. まずテストを書く(失敗するはず)
|
|
||||||
2. テストを通過する最小限のコードを実装
|
|
||||||
3. テストがグリーンの状態でリファクタリング
|
|
||||||
4. JaCoCoでカバレッジを強制(80%以上が目標)
|
|
||||||
|
|
||||||
## @Nestedによるユニットテスト構成
|
|
||||||
|
|
||||||
包括的で読みやすいテストのための構造化アプローチ:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
@DisplayName("As2ProcessingService Unit Tests")
|
|
||||||
class As2ProcessingServiceTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private InvoiceFlowValidator invoiceFlowValidator;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private EventService eventService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private DocumentJobService documentJobService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private BusinessRulesPublisher businessRulesPublisher;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FileStorageService fileStorageService;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private As2ProcessingService as2ProcessingService;
|
|
||||||
|
|
||||||
private Path testFilePath;
|
|
||||||
private LogContext testLogContext;
|
|
||||||
private InvoiceValidationResult validationResult;
|
|
||||||
private StoredDocumentInfo documentInfo;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
// ARRANGE - 共通テストデータ
|
|
||||||
testFilePath = Path.of("/tmp/test-invoice.xml");
|
|
||||||
|
|
||||||
testLogContext = new LogContext();
|
|
||||||
testLogContext.put(As2Constants.STRUCTURE_ID, "STRUCT-001");
|
|
||||||
testLogContext.put(As2Constants.FILE_NAME, "invoice.xml");
|
|
||||||
testLogContext.put(As2Constants.AS2_FROM, "PARTNER-001");
|
|
||||||
|
|
||||||
validationResult = new InvoiceValidationResult();
|
|
||||||
validationResult.setValid(true);
|
|
||||||
validationResult.setSize(1024L);
|
|
||||||
validationResult.setDocumentHash("abc123");
|
|
||||||
|
|
||||||
documentInfo = new StoredDocumentInfo();
|
|
||||||
documentInfo.setPath("s3://bucket/path/invoice.xml");
|
|
||||||
documentInfo.setSize(1024L);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@DisplayName("Tests for processFile")
|
|
||||||
class ProcessFile {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should successfully process non-CHORUS file with all validations")
|
|
||||||
void givenNonChorusFile_whenProcessFile_thenAllValidationsApplied() throws Exception {
|
|
||||||
// ARRANGE
|
|
||||||
testLogContext.put(As2Constants.CHORUS_FLOW, "false");
|
|
||||||
CustomLog.setCurrentContext(testLogContext);
|
|
||||||
|
|
||||||
when(invoiceFlowValidator.validateFlowWithConfig(
|
|
||||||
eq(testFilePath),
|
|
||||||
eq(ValidationFlowConfig.allValidations()),
|
|
||||||
eq(EInvoiceSyntaxFormat.UBL),
|
|
||||||
any(LogContext.class)))
|
|
||||||
.thenReturn(validationResult);
|
|
||||||
|
|
||||||
when(invoiceFlowValidator.computeFlowProfile(any(), any()))
|
|
||||||
.thenReturn(FlowProfile.BASIC);
|
|
||||||
|
|
||||||
when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any()))
|
|
||||||
.thenReturn(CompletableFuture.completedFuture(documentInfo));
|
|
||||||
|
|
||||||
when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), any(), any()))
|
|
||||||
.thenReturn(new BusinessRulesPayload());
|
|
||||||
|
|
||||||
// ACT
|
|
||||||
assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath));
|
|
||||||
|
|
||||||
// ASSERT
|
|
||||||
verify(invoiceFlowValidator).validateFlowWithConfig(
|
|
||||||
eq(testFilePath),
|
|
||||||
eq(ValidationFlowConfig.allValidations()),
|
|
||||||
eq(EInvoiceSyntaxFormat.UBL),
|
|
||||||
any(LogContext.class));
|
|
||||||
|
|
||||||
verify(eventService).createSuccessEvent(any(StoredDocumentInfo.class),
|
|
||||||
eq("PERSISTENCE_BLOB_EVENT_TYPE"));
|
|
||||||
verify(businessRulesPublisher).publishAsync(any(BusinessRulesPayload.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should create error event when file upload fails")
|
|
||||||
void givenUploadFailure_whenProcessFile_thenErrorEventCreated() throws Exception {
|
|
||||||
// ARRANGE
|
|
||||||
testLogContext.put(As2Constants.CHORUS_FLOW, "false");
|
|
||||||
CustomLog.setCurrentContext(testLogContext);
|
|
||||||
|
|
||||||
when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any()))
|
|
||||||
.thenReturn(validationResult);
|
|
||||||
|
|
||||||
when(invoiceFlowValidator.computeFlowProfile(any(), any()))
|
|
||||||
.thenReturn(FlowProfile.BASIC);
|
|
||||||
|
|
||||||
documentInfo.setPath(""); // 空パスでエラーをトリガー
|
|
||||||
when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any()))
|
|
||||||
.thenReturn(CompletableFuture.completedFuture(documentInfo));
|
|
||||||
|
|
||||||
// ACT & ASSERT
|
|
||||||
As2ServerProcessingException exception = assertThrows(
|
|
||||||
As2ServerProcessingException.class,
|
|
||||||
() -> as2ProcessingService.processFile(testFilePath)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(exception.getMessage())
|
|
||||||
.contains("File path is empty after upload");
|
|
||||||
|
|
||||||
verify(businessRulesPublisher, never()).publishAsync(any());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 主要テストパターン
|
|
||||||
|
|
||||||
1. **@Nestedクラス**: テスト対象メソッドごとにテストをグループ化
|
|
||||||
2. **@DisplayName**: テストレポート用の読みやすい説明
|
|
||||||
3. **命名規則**: 明確さのために`givenX_whenY_thenZ`
|
|
||||||
4. **AAAパターン**: 明示的な`// ARRANGE`、`// ACT`、`// ASSERT`コメント
|
|
||||||
5. **@BeforeEach**: 重複削減のための共通テストデータセットアップ
|
|
||||||
6. **assertDoesNotThrow**: 例外をキャッチせずに成功シナリオをテスト
|
|
||||||
7. **assertThrows**: メッセージバリデーション付きの例外シナリオテスト
|
|
||||||
8. **包括的カバレッジ**: ハッピーパス、null入力、エッジケース、例外をテスト
|
|
||||||
9. **インタラクション検証**: Mockitoの`verify()`でメソッドが正しく呼ばれることを確認
|
|
||||||
10. **Never検証**: エラーシナリオでメソッドが呼ばれないことを`never()`で確認
|
|
||||||
|
|
||||||
## Camelルートのテスト
|
|
||||||
|
|
||||||
```java
|
|
||||||
@QuarkusTest
|
|
||||||
@DisplayName("Business Rules Camel Route Tests")
|
|
||||||
class BusinessRulesRouteTest {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
CamelContext camelContext;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
ProducerTemplate producerTemplate;
|
|
||||||
|
|
||||||
@InjectMock
|
|
||||||
EventService eventService;
|
|
||||||
|
|
||||||
@InjectMock
|
|
||||||
DocumentValidator documentValidator;
|
|
||||||
|
|
||||||
private BusinessRulesPayload testPayload;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
// ARRANGE - テストデータ
|
|
||||||
testPayload = new BusinessRulesPayload();
|
|
||||||
testPayload.setDocumentId(1L);
|
|
||||||
testPayload.setFlowProfile(FlowProfile.BASIC);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should successfully publish message to RabbitMQ")
|
|
||||||
void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception {
|
|
||||||
// ARRANGE
|
|
||||||
MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class);
|
|
||||||
mockRabbitMQ.expectedMessageCount(1);
|
|
||||||
|
|
||||||
camelContext.getRouteController().stopRoute("business-rules-publisher");
|
|
||||||
AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> {
|
|
||||||
advice.replaceFromWith("direct:business-rules-publisher");
|
|
||||||
advice.weaveByToString(".*spring-rabbitmq.*").replace().to("mock:rabbitmq");
|
|
||||||
});
|
|
||||||
camelContext.getRouteController().startRoute("business-rules-publisher");
|
|
||||||
|
|
||||||
// ACT
|
|
||||||
producerTemplate.sendBody("direct:business-rules-publisher", testPayload);
|
|
||||||
|
|
||||||
// ASSERT
|
|
||||||
mockRabbitMQ.assertIsSatisfied(5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## リソースレイヤーテスト(REST Assured)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@QuarkusTest
|
|
||||||
@DisplayName("DocumentResource API Tests")
|
|
||||||
class DocumentResourceTest {
|
|
||||||
|
|
||||||
@InjectMock
|
|
||||||
DocumentService documentService;
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@DisplayName("Tests for POST /api/documents")
|
|
||||||
class CreateDocument {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should create document and return 201")
|
|
||||||
void givenValidRequest_whenCreate_thenReturns201() {
|
|
||||||
// ARRANGE
|
|
||||||
Document document = createDocument(1L, "DOC-001");
|
|
||||||
when(documentService.create(any())).thenReturn(document);
|
|
||||||
|
|
||||||
// ACT & ASSERT
|
|
||||||
given()
|
|
||||||
.contentType(ContentType.JSON)
|
|
||||||
.body("""
|
|
||||||
{
|
|
||||||
"referenceNumber": "DOC-001",
|
|
||||||
"description": "Test document",
|
|
||||||
"validUntil": "2030-01-01T00:00:00Z",
|
|
||||||
"categories": ["test"]
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
.when().post("/api/documents")
|
|
||||||
.then()
|
|
||||||
.statusCode(201)
|
|
||||||
.body("referenceNumber", equalTo("DOC-001"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should return 400 for invalid input")
|
|
||||||
void givenInvalidRequest_whenCreate_thenReturns400() {
|
|
||||||
given()
|
|
||||||
.contentType(ContentType.JSON)
|
|
||||||
.body("""
|
|
||||||
{
|
|
||||||
"referenceNumber": "",
|
|
||||||
"description": "Test"
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
.when().post("/api/documents")
|
|
||||||
.then()
|
|
||||||
.statusCode(400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## JaCoCoカバレッジ
|
|
||||||
|
|
||||||
### Maven構成
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.jacoco</groupId>
|
|
||||||
<artifactId>jacoco-maven-plugin</artifactId>
|
|
||||||
<version>0.8.13</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>prepare-agent</id>
|
|
||||||
<goals><goal>prepare-agent</goal></goals>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>report</id>
|
|
||||||
<phase>verify</phase>
|
|
||||||
<goals><goal>report</goal></goals>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>check</id>
|
|
||||||
<goals><goal>check</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<rules>
|
|
||||||
<rule>
|
|
||||||
<element>BUNDLE</element>
|
|
||||||
<limits>
|
|
||||||
<limit>
|
|
||||||
<counter>LINE</counter>
|
|
||||||
<value>COVEREDRATIO</value>
|
|
||||||
<minimum>0.80</minimum>
|
|
||||||
</limit>
|
|
||||||
</limits>
|
|
||||||
</rule>
|
|
||||||
</rules>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
```
|
|
||||||
|
|
||||||
カバレッジ付きテスト実行:
|
|
||||||
```bash
|
|
||||||
mvn clean test
|
|
||||||
mvn jacoco:report
|
|
||||||
mvn jacoco:check
|
|
||||||
|
|
||||||
# レポート: target/site/jacoco/index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
## テスト依存関係
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-junit5</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-junit5-mockito</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.assertj</groupId>
|
|
||||||
<artifactId>assertj-core</artifactId>
|
|
||||||
<version>3.24.2</version>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.rest-assured</groupId>
|
|
||||||
<artifactId>rest-assured</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.camel.quarkus</groupId>
|
|
||||||
<artifactId>camel-quarkus-junit5</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
```
|
|
||||||
|
|
||||||
## ベストプラクティス
|
|
||||||
|
|
||||||
### テスト構成
|
|
||||||
- テスト対象メソッドごとに`@Nested`クラスでグループ化
|
|
||||||
- レポートに表示される読みやすい説明に`@DisplayName`を使用
|
|
||||||
- テストメソッドに`givenX_whenY_thenZ`命名規則を使用
|
|
||||||
|
|
||||||
### テスト構造
|
|
||||||
- 明示的コメント付きAAAパターン(`// ARRANGE`、`// ACT`、`// ASSERT`)
|
|
||||||
- 成功シナリオに`assertDoesNotThrow`を使用
|
|
||||||
- メッセージバリデーション付き例外シナリオに`assertThrows`を使用
|
|
||||||
|
|
||||||
### アサーション
|
|
||||||
- 値チェックにはJUnitアサーションより**AssertJを優先**(`assertThat`)
|
|
||||||
- 読みやすさのためにAssertJのfluent APIを使用
|
|
||||||
- 例外: JUnitの`assertThrows`でキャプチャし、AssertJでメッセージを検証
|
|
||||||
- 成功パス: JUnitの`assertDoesNotThrow`を使用
|
|
||||||
|
|
||||||
### イベント駆動テスト
|
|
||||||
- `AdviceWith`と`MockEndpoint`でCamelルートをテスト
|
|
||||||
- メッセージコンテンツ、ヘッダー、ルーティングロジックを検証
|
|
||||||
- エラーハンドリングルートを個別にテスト
|
|
||||||
- ユニットテストで外部システム(RabbitMQ、S3、データベース)をモック
|
|
||||||
|
|
||||||
### Quarkus固有
|
|
||||||
- 最新のLTSバージョン(Quarkus 3.x)を維持
|
|
||||||
- ネイティブコンパイル互換性を定期的にテスト
|
|
||||||
- 異なるシナリオにQuarkusテストプロファイルを使用
|
|
||||||
- `@MockBean`の代わりに`@InjectMock`を使用(Quarkus固有)
|
|
||||||
|
|
||||||
**覚えておいてください**: テストは高速、分離、決定的に保ちます。実装の詳細ではなく動作をテストしてください。
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
---
|
|
||||||
name: quarkus-verification
|
|
||||||
description: "Quarkusプロジェクトの検証ループ: ビルド、静的解析、カバレッジ付きテスト、セキュリティスキャン、ネイティブコンパイル、リリースまたはPR前のdiffレビュー。"
|
|
||||||
origin: ECC
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quarkus 検証ループ
|
|
||||||
|
|
||||||
PR前、大きな変更後、デプロイ前に実行。
|
|
||||||
|
|
||||||
## いつアクティブにするか
|
|
||||||
|
|
||||||
- Quarkusサービスのプルリクエストを開く前
|
|
||||||
- 大規模なリファクタリングまたは依存関係アップグレード後
|
|
||||||
- ステージングまたは本番のデプロイ前検証
|
|
||||||
- 完全なビルド → lint → テスト → セキュリティスキャン → ネイティブコンパイルパイプラインの実行
|
|
||||||
- テストカバレッジが閾値を満たしていることの検証(80%以上)
|
|
||||||
- ネイティブイメージ互換性のテスト
|
|
||||||
|
|
||||||
## フェーズ1: ビルド
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Maven
|
|
||||||
mvn clean verify -DskipTests
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
./gradlew clean assemble -x test
|
|
||||||
```
|
|
||||||
|
|
||||||
ビルドが失敗した場合、停止してコンパイルエラーを修正。
|
|
||||||
|
|
||||||
## フェーズ2: 静的解析
|
|
||||||
|
|
||||||
### Checkstyle、PMD、SpotBugs(Maven)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mvn checkstyle:check pmd:check spotbugs:check
|
|
||||||
```
|
|
||||||
|
|
||||||
### SonarQube(構成されている場合)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mvn sonar:sonar \
|
|
||||||
-Dsonar.projectKey=my-quarkus-project \
|
|
||||||
-Dsonar.host.url=http://localhost:9000 \
|
|
||||||
-Dsonar.login=${SONAR_TOKEN}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 対処すべき一般的な問題
|
|
||||||
|
|
||||||
- 未使用のimportまたは変数
|
|
||||||
- 複雑なメソッド(高い循環的複雑度)
|
|
||||||
- 潜在的なnullポインター参照
|
|
||||||
- SpotBugsが検出したセキュリティ問題
|
|
||||||
|
|
||||||
## フェーズ3: テスト + カバレッジ
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# すべてのテストを実行
|
|
||||||
mvn clean test
|
|
||||||
|
|
||||||
# カバレッジレポートを生成
|
|
||||||
mvn jacoco:report
|
|
||||||
|
|
||||||
# カバレッジ閾値を強制(80%)
|
|
||||||
mvn jacoco:check
|
|
||||||
|
|
||||||
# またはGradleで
|
|
||||||
./gradlew test jacocoTestReport jacocoTestCoverageVerification
|
|
||||||
```
|
|
||||||
|
|
||||||
### テストカテゴリ
|
|
||||||
|
|
||||||
#### ユニットテスト
|
|
||||||
モック化された依存関係でサービスロジックをテスト:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class UserServiceTest {
|
|
||||||
@Mock UserRepository userRepository;
|
|
||||||
@InjectMocks UserService userService;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUser_validInput_returnsUser() {
|
|
||||||
var dto = new CreateUserDto("Alice", "alice@example.com");
|
|
||||||
|
|
||||||
// Panacheのpersist()はvoidを返す — doNothing + verifyを使用
|
|
||||||
doNothing().when(userRepository).persist(any(User.class));
|
|
||||||
|
|
||||||
User result = userService.create(dto);
|
|
||||||
|
|
||||||
assertThat(result.name).isEqualTo("Alice");
|
|
||||||
verify(userRepository).persist(any(User.class));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 統合テスト
|
|
||||||
実データベース(Testcontainers)でテスト:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@QuarkusTest
|
|
||||||
@QuarkusTestResource(PostgresTestResource.class)
|
|
||||||
class UserRepositoryIntegrationTest {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
UserRepository userRepository;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Transactional
|
|
||||||
void findByEmail_existingUser_returnsUser() {
|
|
||||||
User user = new User();
|
|
||||||
user.name = "Alice";
|
|
||||||
user.email = "alice@example.com";
|
|
||||||
userRepository.persist(user);
|
|
||||||
|
|
||||||
Optional<User> found = userRepository.findByEmail("alice@example.com");
|
|
||||||
|
|
||||||
assertThat(found).isPresent();
|
|
||||||
assertThat(found.get().name).isEqualTo("Alice");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### APIテスト
|
|
||||||
REST AssuredでRESTエンドポイントをテスト:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@QuarkusTest
|
|
||||||
class UserResourceTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUser_validInput_returns201() {
|
|
||||||
given()
|
|
||||||
.contentType(ContentType.JSON)
|
|
||||||
.body("""
|
|
||||||
{"name": "Alice", "email": "alice@example.com"}
|
|
||||||
""")
|
|
||||||
.when().post("/api/users")
|
|
||||||
.then()
|
|
||||||
.statusCode(201)
|
|
||||||
.body("name", equalTo("Alice"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUser_invalidEmail_returns400() {
|
|
||||||
given()
|
|
||||||
.contentType(ContentType.JSON)
|
|
||||||
.body("""
|
|
||||||
{"name": "Alice", "email": "invalid"}
|
|
||||||
""")
|
|
||||||
.when().post("/api/users")
|
|
||||||
.then()
|
|
||||||
.statusCode(400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### カバレッジレポート
|
|
||||||
|
|
||||||
詳細カバレッジは`target/site/jacoco/index.html`を確認:
|
|
||||||
- 全体行カバレッジ(目標: 80%以上)
|
|
||||||
- ブランチカバレッジ(目標: 70%以上)
|
|
||||||
- カバーされていない重要パスを特定
|
|
||||||
|
|
||||||
## フェーズ4: セキュリティスキャン
|
|
||||||
|
|
||||||
### 依存関係脆弱性(Maven)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mvn org.owasp:dependency-check-maven:check
|
|
||||||
```
|
|
||||||
|
|
||||||
CVEについて`target/dependency-check-report.html`を確認。
|
|
||||||
|
|
||||||
### Quarkusセキュリティ監査
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 脆弱なエクステンションを確認
|
|
||||||
mvn quarkus:audit
|
|
||||||
|
|
||||||
# すべてのエクステンションをリスト
|
|
||||||
mvn quarkus:list-extensions
|
|
||||||
```
|
|
||||||
|
|
||||||
### 一般的なセキュリティチェック
|
|
||||||
|
|
||||||
- [ ] すべてのシークレットが環境変数に(コード内ではなく)
|
|
||||||
- [ ] すべてのエンドポイントで入力バリデーション
|
|
||||||
- [ ] 認証/認可が構成済み
|
|
||||||
- [ ] CORSが適切に構成済み
|
|
||||||
- [ ] セキュリティヘッダーが設定済み
|
|
||||||
- [ ] パスワードがBCryptでハッシュ済み
|
|
||||||
- [ ] SQLインジェクション保護(パラメータ化クエリ)
|
|
||||||
- [ ] パブリックエンドポイントでレート制限
|
|
||||||
|
|
||||||
## フェーズ5: ネイティブコンパイル
|
|
||||||
|
|
||||||
GraalVMネイティブイメージ互換性をテスト:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ネイティブ実行可能ファイルをビルド
|
|
||||||
mvn package -Dnative
|
|
||||||
|
|
||||||
# またはコンテナで
|
|
||||||
mvn package -Dnative -Dquarkus.native.container-build=true
|
|
||||||
|
|
||||||
# ネイティブ実行可能ファイルをテスト
|
|
||||||
./target/*-runner
|
|
||||||
|
|
||||||
# 基本的なスモークテストを実行
|
|
||||||
curl http://localhost:8080/q/health/live
|
|
||||||
curl http://localhost:8080/q/health/ready
|
|
||||||
```
|
|
||||||
|
|
||||||
### ネイティブイメージトラブルシューティング
|
|
||||||
|
|
||||||
一般的な問題:
|
|
||||||
- **Reflection**: 動的クラス用のreflection構成を追加
|
|
||||||
- **Resources**: `quarkus.native.resources.includes`でリソースを含める
|
|
||||||
- **JNI**: ネイティブライブラリ使用時にJNIクラスを登録
|
|
||||||
|
|
||||||
例のreflection構成:
|
|
||||||
```java
|
|
||||||
@RegisterForReflection(targets = {MyDynamicClass.class})
|
|
||||||
public class ReflectionConfiguration {}
|
|
||||||
```
|
|
||||||
|
|
||||||
## フェーズ6: ヘルスチェック
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Liveness
|
|
||||||
curl http://localhost:8080/q/health/live
|
|
||||||
|
|
||||||
# Readiness
|
|
||||||
curl http://localhost:8080/q/health/ready
|
|
||||||
|
|
||||||
# すべてのヘルスチェック
|
|
||||||
curl http://localhost:8080/q/health
|
|
||||||
|
|
||||||
# メトリクス(有効な場合)
|
|
||||||
curl http://localhost:8080/q/metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
## 検証チェックリスト
|
|
||||||
|
|
||||||
### コード品質
|
|
||||||
- [ ] ビルドが警告なしで通過
|
|
||||||
- [ ] 静的解析クリーン(高/中の問題なし)
|
|
||||||
- [ ] コードがチーム規約に従う
|
|
||||||
- [ ] PRにコメントアウトされたコードやTODOがない
|
|
||||||
|
|
||||||
### テスト
|
|
||||||
- [ ] すべてのテストが通過
|
|
||||||
- [ ] コードカバレッジ ≥ 80%
|
|
||||||
- [ ] 実データベースとの統合テスト
|
|
||||||
- [ ] セキュリティテストが通過
|
|
||||||
- [ ] パフォーマンスが許容範囲内
|
|
||||||
|
|
||||||
### セキュリティ
|
|
||||||
- [ ] 依存関係脆弱性なし
|
|
||||||
- [ ] 認証/認可がテスト済み
|
|
||||||
- [ ] 入力バリデーション完了
|
|
||||||
- [ ] ソースコードにシークレットなし
|
|
||||||
- [ ] セキュリティヘッダーが構成済み
|
|
||||||
|
|
||||||
### デプロイメント
|
|
||||||
- [ ] ネイティブコンパイル成功
|
|
||||||
- [ ] コンテナイメージがビルド可能
|
|
||||||
- [ ] ヘルスチェックが正しく応答
|
|
||||||
- [ ] ターゲット環境で構成が有効
|
|
||||||
|
|
||||||
## 自動検証スクリプト
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== フェーズ1: ビルド ==="
|
|
||||||
mvn clean verify -DskipTests
|
|
||||||
|
|
||||||
echo "=== フェーズ2: 静的解析 ==="
|
|
||||||
mvn checkstyle:check pmd:check spotbugs:check
|
|
||||||
|
|
||||||
echo "=== フェーズ3: テスト + カバレッジ ==="
|
|
||||||
mvn test jacoco:report jacoco:check
|
|
||||||
|
|
||||||
echo "=== フェーズ4: セキュリティスキャン ==="
|
|
||||||
mvn org.owasp:dependency-check-maven:check
|
|
||||||
|
|
||||||
echo "=== フェーズ5: ネイティブコンパイル ==="
|
|
||||||
mvn package -Dnative -Dquarkus.native.container-build=true
|
|
||||||
|
|
||||||
echo "=== 全フェーズ完了 ==="
|
|
||||||
echo "レポートを確認:"
|
|
||||||
echo " - カバレッジ: target/site/jacoco/index.html"
|
|
||||||
echo " - セキュリティ: target/dependency-check-report.html"
|
|
||||||
echo " - ネイティブ: target/*-runner"
|
|
||||||
```
|
|
||||||
|
|
||||||
## ベストプラクティス
|
|
||||||
|
|
||||||
- すべてのPR前に検証ループを実行
|
|
||||||
- CI/CDパイプラインで自動化
|
|
||||||
- 問題を即座に修正し、技術的負債を蓄積しない
|
|
||||||
- カバレッジを80%以上に維持
|
|
||||||
- 依存関係を定期的に更新
|
|
||||||
- ネイティブコンパイルを定期的にテスト
|
|
||||||
- パフォーマンストレンドを監視
|
|
||||||
- 破壊的変更を文書化
|
|
||||||
- セキュリティスキャン結果をレビュー
|
|
||||||
- 各環境の構成を検証
|
|
||||||
@@ -1,740 +0,0 @@
|
|||||||
---
|
|
||||||
name: quarkus-patterns
|
|
||||||
description: Quarkus 3.x LTS架构模式,Camel消息传递、RESTful API设计、CDI服务、Panache数据访问和异步处理。用于具有事件驱动架构的Java Quarkus后端工作。
|
|
||||||
origin: ECC
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quarkus 开发模式
|
|
||||||
|
|
||||||
使用Apache Camel的云原生事件驱动服务的Quarkus 3.x架构和API模式。
|
|
||||||
|
|
||||||
## 何时激活
|
|
||||||
|
|
||||||
- 使用JAX-RS或RESTEasy Reactive构建REST API
|
|
||||||
- 构建资源 → 服务 → 仓库层结构
|
|
||||||
- 使用Apache Camel和RabbitMQ实现事件驱动模式
|
|
||||||
- 配置Hibernate Panache、缓存或响应式流
|
|
||||||
- 添加验证、异常映射或分页
|
|
||||||
- 为开发/预发布/生产环境设置配置文件(YAML配置)
|
|
||||||
- 使用LogContext和Logback/Logstash编码器进行自定义日志记录
|
|
||||||
- 使用CompletableFuture进行异步操作
|
|
||||||
- 实现条件流处理
|
|
||||||
- 使用GraalVM原生编译
|
|
||||||
|
|
||||||
## 多依赖服务层(Lombok)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Slf4j
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class As2ProcessingService {
|
|
||||||
|
|
||||||
private final InvoiceFlowValidator invoiceFlowValidator;
|
|
||||||
private final EventService eventService;
|
|
||||||
private final DocumentJobService documentJobService;
|
|
||||||
private final BusinessRulesPublisher businessRulesPublisher;
|
|
||||||
private final FileStorageService fileStorageService;
|
|
||||||
|
|
||||||
public void processFile(Path filePath) throws Exception {
|
|
||||||
LogContext logContext = CustomLog.getCurrentContext();
|
|
||||||
try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) {
|
|
||||||
|
|
||||||
String structureIdPartner = logContext.get(As2Constants.STRUCTURE_ID);
|
|
||||||
|
|
||||||
// 条件流逻辑
|
|
||||||
boolean isChorusFlow = Boolean.parseBoolean(logContext.get(As2Constants.CHORUS_FLOW));
|
|
||||||
log.info("Is CHORUS_FLOW message: {}", isChorusFlow);
|
|
||||||
|
|
||||||
ValidationFlowConfig validationFlowConfig = isChorusFlow
|
|
||||||
? ValidationFlowConfig.xsdOnly()
|
|
||||||
: ValidationFlowConfig.allValidations();
|
|
||||||
|
|
||||||
InvoiceValidationResult invoiceValidationResult = this.invoiceFlowValidator
|
|
||||||
.validateFlowWithConfig(filePath, validationFlowConfig,
|
|
||||||
EInvoiceSyntaxFormat.UBL, logContext);
|
|
||||||
|
|
||||||
FlowProfile flowProfile = isChorusFlow ?
|
|
||||||
FlowProfile.EXTENDED_CTC_FR :
|
|
||||||
this.invoiceFlowValidator.computeFlowProfile(invoiceValidationResult,
|
|
||||||
invoiceValidationResult.getInvoiceDetails().invoiceFormat().getProfile());
|
|
||||||
|
|
||||||
log.info("Invoice validation completed. Message is valid");
|
|
||||||
|
|
||||||
// CompletableFuture异步操作
|
|
||||||
try(InputStream inputStream = Files.newInputStream(filePath)) {
|
|
||||||
CompletableFuture<StoredDocumentInfo> documentInfoCompletableFuture =
|
|
||||||
fileStorageService.uploadOriginalFile(inputStream,
|
|
||||||
invoiceValidationResult.getSize(), logContext,
|
|
||||||
invoiceValidationResult.getInvoiceFormat());
|
|
||||||
|
|
||||||
StoredDocumentInfo documentInfo = documentInfoCompletableFuture.join();
|
|
||||||
log.info("File uploaded successfully: {}", documentInfo.getPath());
|
|
||||||
|
|
||||||
if (StringUtils.isBlank(documentInfo.getPath())) {
|
|
||||||
String errorMsg = "File path is empty after upload";
|
|
||||||
log.error(errorMsg);
|
|
||||||
this.eventService.createErrorEvent(documentInfo, "FILE_UPLOAD_FAILED", errorMsg);
|
|
||||||
throw new As2ServerProcessingException(errorMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.eventService.createSuccessEvent(documentInfo, "PERSISTENCE_BLOB_EVENT_TYPE");
|
|
||||||
|
|
||||||
BusinessRulesPayload payload = this.documentJobService.createDocumentAndJobEntities(
|
|
||||||
documentInfo, originalFileName, structureIdPartner,
|
|
||||||
flowProfile, invoiceValidationResult.getDocumentHash());
|
|
||||||
|
|
||||||
// 异步Camel发布
|
|
||||||
businessRulesPublisher.publishAsync(payload);
|
|
||||||
this.eventService.createSuccessEvent(payload, "BUSINESS_RULES_MESSAGE_SENT");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键模式:**
|
|
||||||
- 通过Lombok的`@RequiredArgsConstructor`进行构造函数注入
|
|
||||||
- 通过`@Slf4j`进行Logback日志记录
|
|
||||||
- 使用try-with-resources的作用域LogContext
|
|
||||||
- 基于运行时参数的条件流逻辑
|
|
||||||
- 使用`.join()`的CompletableFuture异步操作
|
|
||||||
- 成功/错误场景的事件跟踪
|
|
||||||
- 异步Camel消息发布
|
|
||||||
|
|
||||||
## 自定义日志上下文模式(Logback)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class ProcessingService {
|
|
||||||
|
|
||||||
public void processDocument(Document doc) {
|
|
||||||
LogContext logContext = CustomLog.getCurrentContext();
|
|
||||||
try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) {
|
|
||||||
// 向所有日志语句添加上下文
|
|
||||||
logContext.put("documentId", doc.getId().toString());
|
|
||||||
logContext.put("documentType", doc.getType());
|
|
||||||
logContext.put("userId", SecurityContext.getUserId());
|
|
||||||
|
|
||||||
log.info("Starting document processing");
|
|
||||||
|
|
||||||
// 此作用域内的所有日志都继承上下文
|
|
||||||
processInternal(doc);
|
|
||||||
|
|
||||||
log.info("Document processing completed");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Document processing failed", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Logback配置(logback.xml):**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<configuration>
|
|
||||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
|
||||||
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
|
|
||||||
<includeContext>true</includeContext>
|
|
||||||
<includeMdc>true</includeMdc>
|
|
||||||
</encoder>
|
|
||||||
</appender>
|
|
||||||
|
|
||||||
<logger name="com.example" level="INFO"/>
|
|
||||||
<root level="WARN">
|
|
||||||
<appender-ref ref="CONSOLE"/>
|
|
||||||
</root>
|
|
||||||
</configuration>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 事件服务模式
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class EventService {
|
|
||||||
private final EventRepository eventRepository;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
public void createSuccessEvent(Object payload, String eventType) {
|
|
||||||
Objects.requireNonNull(payload, "Payload cannot be null");
|
|
||||||
Event event = new Event();
|
|
||||||
event.setType(eventType);
|
|
||||||
event.setStatus(EventStatus.SUCCESS);
|
|
||||||
event.setPayload(serializePayload(payload));
|
|
||||||
event.setTimestamp(Instant.now());
|
|
||||||
|
|
||||||
eventRepository.persist(event);
|
|
||||||
log.info("Success event created: {}", eventType);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void createErrorEvent(Object payload, String eventType, String errorMessage) {
|
|
||||||
Objects.requireNonNull(payload, "Payload cannot be null");
|
|
||||||
if (errorMessage == null || errorMessage.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("Error message cannot be blank");
|
|
||||||
}
|
|
||||||
Event event = new Event();
|
|
||||||
event.setType(eventType);
|
|
||||||
event.setStatus(EventStatus.ERROR);
|
|
||||||
event.setErrorMessage(errorMessage);
|
|
||||||
event.setPayload(serializePayload(payload));
|
|
||||||
event.setTimestamp(Instant.now());
|
|
||||||
|
|
||||||
eventRepository.persist(event);
|
|
||||||
log.error("Error event created: {} - {}", eventType, errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String serializePayload(Object payload) {
|
|
||||||
try {
|
|
||||||
return objectMapper.writeValueAsString(payload);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalStateException("Failed to serialize event payload", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Camel消息发布(RabbitMQ)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class BusinessRulesPublisher {
|
|
||||||
private final ProducerTemplate producerTemplate;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "camel.rabbitmq.queue.business-rules")
|
|
||||||
String businessRulesQueue;
|
|
||||||
|
|
||||||
public void publishAsync(BusinessRulesPayload payload) {
|
|
||||||
producerTemplate.asyncSendBody(
|
|
||||||
"direct:business-rules-publisher",
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
log.info("Message published to business rules queue: {}", payload.getDocumentId());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void publishSync(BusinessRulesPayload payload) {
|
|
||||||
producerTemplate.sendBody(
|
|
||||||
"direct:business-rules-publisher",
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Camel路由配置:**
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class BusinessRulesRoute extends RouteBuilder {
|
|
||||||
|
|
||||||
@ConfigProperty(name = "camel.rabbitmq.queue.business-rules")
|
|
||||||
String businessRulesQueue;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "rabbitmq.host")
|
|
||||||
String rabbitHost;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "rabbitmq.port")
|
|
||||||
Integer rabbitPort;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configure() {
|
|
||||||
from("direct:business-rules-publisher")
|
|
||||||
.routeId("business-rules-publisher")
|
|
||||||
.log("Publishing message to RabbitMQ: ${body}")
|
|
||||||
.marshal().json(JsonLibrary.Jackson)
|
|
||||||
.toF("spring-rabbitmq:%s?hostname=%s&portNumber=%d",
|
|
||||||
businessRulesQueue, rabbitHost, rabbitPort);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Camel Direct路由(内存中)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class DocumentProcessingRoute extends RouteBuilder {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configure() {
|
|
||||||
// 错误处理
|
|
||||||
onException(ValidationException.class)
|
|
||||||
.handled(true)
|
|
||||||
.to("direct:validation-error-handler")
|
|
||||||
.log("Validation error: ${exception.message}");
|
|
||||||
|
|
||||||
// 主处理路由
|
|
||||||
from("direct:process-document")
|
|
||||||
.routeId("document-processing")
|
|
||||||
.log("Processing document: ${header.documentId}")
|
|
||||||
.bean(DocumentValidator.class, "validate")
|
|
||||||
.bean(DocumentTransformer.class, "transform")
|
|
||||||
.choice()
|
|
||||||
.when(header("documentType").isEqualTo("INVOICE"))
|
|
||||||
.to("direct:process-invoice")
|
|
||||||
.when(header("documentType").isEqualTo("CREDIT_NOTE"))
|
|
||||||
.to("direct:process-credit-note")
|
|
||||||
.otherwise()
|
|
||||||
.to("direct:process-generic")
|
|
||||||
.end();
|
|
||||||
|
|
||||||
from("direct:validation-error-handler")
|
|
||||||
.bean(EventService.class, "createErrorEvent")
|
|
||||||
.log("Validation error handled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Camel文件处理
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class FileMonitoringRoute extends RouteBuilder {
|
|
||||||
|
|
||||||
@ConfigProperty(name = "file.input.directory")
|
|
||||||
String inputDirectory;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "file.processed.directory")
|
|
||||||
String processedDirectory;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "file.error.directory")
|
|
||||||
String errorDirectory;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configure() {
|
|
||||||
from("file:" + inputDirectory + "?move=" + processedDirectory +
|
|
||||||
"&moveFailed=" + errorDirectory + "&delay=5000")
|
|
||||||
.routeId("file-monitor")
|
|
||||||
.log("Processing file: ${header.CamelFileName}")
|
|
||||||
.to("direct:process-file");
|
|
||||||
|
|
||||||
from("direct:process-file")
|
|
||||||
.bean(As2ProcessingService.class, "processFile")
|
|
||||||
.log("File processing completed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## REST API结构
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Path("/api/documents")
|
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DocumentResource {
|
|
||||||
private final DocumentService documentService;
|
|
||||||
|
|
||||||
@GET
|
|
||||||
public Response list(
|
|
||||||
@QueryParam("page") @DefaultValue("0") int page,
|
|
||||||
@QueryParam("size") @DefaultValue("20") int size) {
|
|
||||||
List<Document> documents = documentService.list(page, size);
|
|
||||||
return Response.ok(documents).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@POST
|
|
||||||
public Response create(@Valid CreateDocumentRequest request, @Context UriInfo uriInfo) {
|
|
||||||
Document document = documentService.create(request);
|
|
||||||
URI location = uriInfo.getAbsolutePathBuilder()
|
|
||||||
.path(String.valueOf(document.id))
|
|
||||||
.build();
|
|
||||||
return Response.created(location).entity(DocumentResponse.from(document)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("/{id}")
|
|
||||||
public Response getById(@PathParam("id") Long id) {
|
|
||||||
return documentService.findById(id)
|
|
||||||
.map(DocumentResponse::from)
|
|
||||||
.map(Response::ok)
|
|
||||||
.orElse(Response.status(Response.Status.NOT_FOUND))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 仓库模式(Panache Repository)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class DocumentRepository implements PanacheRepository<Document> {
|
|
||||||
|
|
||||||
public List<Document> findByStatus(DocumentStatus status, int page, int size) {
|
|
||||||
return find("status = ?1 order by createdAt desc", status)
|
|
||||||
.page(page, size)
|
|
||||||
.list();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<Document> findByReferenceNumber(String referenceNumber) {
|
|
||||||
return find("referenceNumber", referenceNumber).firstResultOptional();
|
|
||||||
}
|
|
||||||
|
|
||||||
public long countByStatusAndDate(DocumentStatus status, LocalDate date) {
|
|
||||||
return count("status = ?1 and createdAt >= ?2", status, date.atStartOfDay());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 带事务的服务层
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DocumentService {
|
|
||||||
private final DocumentRepository repo;
|
|
||||||
private final EventService eventService;
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Document create(CreateDocumentRequest request) {
|
|
||||||
Document document = new Document();
|
|
||||||
document.setReferenceNumber(request.referenceNumber());
|
|
||||||
document.setDescription(request.description());
|
|
||||||
document.setStatus(DocumentStatus.PENDING);
|
|
||||||
document.setCreatedAt(Instant.now());
|
|
||||||
|
|
||||||
repo.persist(document);
|
|
||||||
|
|
||||||
eventService.createSuccessEvent(document, "DOCUMENT_CREATED");
|
|
||||||
|
|
||||||
return document;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<Document> findById(Long id) {
|
|
||||||
return repo.findByIdOptional(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## DTO和验证
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record CreateDocumentRequest(
|
|
||||||
@NotBlank @Size(max = 200) String referenceNumber,
|
|
||||||
@NotBlank @Size(max = 2000) String description,
|
|
||||||
@NotNull @FutureOrPresent Instant validUntil,
|
|
||||||
@NotEmpty List<@NotBlank String> categories) {}
|
|
||||||
|
|
||||||
public record DocumentResponse(Long id, String referenceNumber, DocumentStatus status) {
|
|
||||||
public static DocumentResponse from(Document document) {
|
|
||||||
return new DocumentResponse(document.getId(), document.getReferenceNumber(),
|
|
||||||
document.getStatus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 异常映射
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Provider
|
|
||||||
public class ValidationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
|
|
||||||
@Override
|
|
||||||
public Response toResponse(ConstraintViolationException exception) {
|
|
||||||
String message = exception.getConstraintViolations().stream()
|
|
||||||
.map(cv -> cv.getPropertyPath() + ": " + cv.getMessage())
|
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
|
|
||||||
return Response.status(Response.Status.BAD_REQUEST)
|
|
||||||
.entity(Map.of("error", "validation_error", "message", message))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provider
|
|
||||||
@Slf4j
|
|
||||||
public class GenericExceptionMapper implements ExceptionMapper<Exception> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Response toResponse(Exception exception) {
|
|
||||||
log.error("Unhandled exception", exception);
|
|
||||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
|
||||||
.entity(Map.of("error", "internal_error", "message", "An unexpected error occurred"))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CompletableFuture异步操作
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Slf4j
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FileStorageService {
|
|
||||||
private final S3Client s3Client;
|
|
||||||
private final ExecutorService executorService;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "storage.bucket-name") String bucketName;
|
|
||||||
|
|
||||||
public CompletableFuture<StoredDocumentInfo> uploadOriginalFile(
|
|
||||||
InputStream inputStream,
|
|
||||||
long size,
|
|
||||||
LogContext logContext,
|
|
||||||
InvoiceFormat format) {
|
|
||||||
|
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
|
||||||
try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) {
|
|
||||||
String path = generateStoragePath(format);
|
|
||||||
|
|
||||||
PutObjectRequest request = PutObjectRequest.builder()
|
|
||||||
.bucket(bucketName)
|
|
||||||
.key(path)
|
|
||||||
.contentLength(size)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
s3Client.putObject(request, RequestBody.fromInputStream(inputStream, size));
|
|
||||||
|
|
||||||
log.info("File uploaded to S3: {}", path);
|
|
||||||
|
|
||||||
return new StoredDocumentInfo(path, size, Instant.now());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to upload file to S3", e);
|
|
||||||
throw new StorageException("Upload failed", e);
|
|
||||||
}
|
|
||||||
}, executorService);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 缓存
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DocumentCacheService {
|
|
||||||
private final DocumentRepository repo;
|
|
||||||
|
|
||||||
@CacheResult(cacheName = "document-cache")
|
|
||||||
public Optional<Document> getById(@CacheKey Long id) {
|
|
||||||
return repo.findByIdOptional(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@CacheInvalidate(cacheName = "document-cache")
|
|
||||||
public void evict(@CacheKey Long id) {}
|
|
||||||
|
|
||||||
@CacheInvalidateAll(cacheName = "document-cache")
|
|
||||||
public void evictAll() {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## YAML配置
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# application.yml
|
|
||||||
"%dev":
|
|
||||||
quarkus:
|
|
||||||
datasource:
|
|
||||||
jdbc:
|
|
||||||
url: jdbc:postgresql://localhost:5432/dev_db
|
|
||||||
username: dev_user
|
|
||||||
password: dev_pass
|
|
||||||
hibernate-orm:
|
|
||||||
database:
|
|
||||||
generation: drop-and-create
|
|
||||||
|
|
||||||
rabbitmq:
|
|
||||||
host: localhost
|
|
||||||
port: 5672
|
|
||||||
username: guest
|
|
||||||
password: guest
|
|
||||||
|
|
||||||
"%test":
|
|
||||||
quarkus:
|
|
||||||
datasource:
|
|
||||||
jdbc:
|
|
||||||
url: jdbc:h2:mem:test
|
|
||||||
hibernate-orm:
|
|
||||||
database:
|
|
||||||
generation: drop-and-create
|
|
||||||
|
|
||||||
"%prod":
|
|
||||||
quarkus:
|
|
||||||
datasource:
|
|
||||||
jdbc:
|
|
||||||
url: ${DATABASE_URL}
|
|
||||||
username: ${DB_USER}
|
|
||||||
password: ${DB_PASSWORD}
|
|
||||||
hibernate-orm:
|
|
||||||
database:
|
|
||||||
generation: validate
|
|
||||||
|
|
||||||
rabbitmq:
|
|
||||||
host: ${RABBITMQ_HOST}
|
|
||||||
port: ${RABBITMQ_PORT}
|
|
||||||
username: ${RABBITMQ_USER}
|
|
||||||
password: ${RABBITMQ_PASSWORD}
|
|
||||||
|
|
||||||
# Camel配置
|
|
||||||
camel:
|
|
||||||
rabbitmq:
|
|
||||||
queue:
|
|
||||||
business-rules: business-rules-queue
|
|
||||||
invoice-processing: invoice-processing-queue
|
|
||||||
```
|
|
||||||
|
|
||||||
## 健康检查
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Readiness
|
|
||||||
@ApplicationScoped
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DatabaseHealthCheck implements HealthCheck {
|
|
||||||
private final AgroalDataSource dataSource;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public HealthCheckResponse call() {
|
|
||||||
try (Connection conn = dataSource.getConnection()) {
|
|
||||||
boolean valid = conn.isValid(2);
|
|
||||||
return HealthCheckResponse.named("Database connection")
|
|
||||||
.status(valid)
|
|
||||||
.build();
|
|
||||||
} catch (SQLException e) {
|
|
||||||
return HealthCheckResponse.down("Database connection");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Liveness
|
|
||||||
@ApplicationScoped
|
|
||||||
public class CamelHealthCheck implements HealthCheck {
|
|
||||||
@Inject
|
|
||||||
CamelContext camelContext;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public HealthCheckResponse call() {
|
|
||||||
boolean isStarted = camelContext.getStatus().isStarted();
|
|
||||||
return HealthCheckResponse.named("Camel Context")
|
|
||||||
.status(isStarted)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 依赖(Maven)
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<properties>
|
|
||||||
<quarkus.platform.version>3.27.0</quarkus.platform.version>
|
|
||||||
<lombok.version>1.18.42</lombok.version>
|
|
||||||
<assertj-core.version>3.24.2</assertj-core.version>
|
|
||||||
<jacoco-maven-plugin.version>0.8.13</jacoco-maven-plugin.version>
|
|
||||||
<maven.compiler.release>17</maven.compiler.release>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencyManagement>
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus.platform</groupId>
|
|
||||||
<artifactId>quarkus-bom</artifactId>
|
|
||||||
<version>${quarkus.platform.version}</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus.platform</groupId>
|
|
||||||
<artifactId>quarkus-camel-bom</artifactId>
|
|
||||||
<version>${quarkus.platform.version}</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</dependencyManagement>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<!-- Quarkus核心 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-arc</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-config-yaml</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Camel扩展 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.camel.quarkus</groupId>
|
|
||||||
<artifactId>camel-quarkus-spring-rabbitmq</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.camel.quarkus</groupId>
|
|
||||||
<artifactId>camel-quarkus-direct</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.camel.quarkus</groupId>
|
|
||||||
<artifactId>camel-quarkus-bean</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Lombok -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
<version>${lombok.version}</version>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- 日志 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkiverse.logging.logback</groupId>
|
|
||||||
<artifactId>quarkus-logging-logback</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>net.logstash.logback</groupId>
|
|
||||||
<artifactId>logstash-logback-encoder</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
### 架构
|
|
||||||
- 使用Lombok的`@RequiredArgsConstructor`进行构造函数注入
|
|
||||||
- 保持服务层精简,将复杂逻辑委托给专门的类
|
|
||||||
- 使用Camel路由进行消息路由和集成模式
|
|
||||||
- 数据访问优先使用Panache Repository模式
|
|
||||||
|
|
||||||
### 事件驱动
|
|
||||||
- 始终使用EventService跟踪操作(成功/错误事件)
|
|
||||||
- 使用Camel的`direct:`端点进行内存路由
|
|
||||||
- 使用`spring-rabbitmq`组件进行RabbitMQ集成
|
|
||||||
- 使用`ProducerTemplate.asyncSendBody()`实现异步发布
|
|
||||||
|
|
||||||
### 日志
|
|
||||||
- 使用Logstash编码器的Logback进行结构化日志
|
|
||||||
- 使用`SafeAutoCloseable`在服务调用间传播LogContext
|
|
||||||
- 向LogContext添加上下文信息以进行请求追踪
|
|
||||||
- 使用`@Slf4j`代替手动日志实例化
|
|
||||||
|
|
||||||
### 异步操作
|
|
||||||
- 使用CompletableFuture进行非阻塞I/O操作
|
|
||||||
- 需要等待完成时调用`.join()`
|
|
||||||
- 正确处理CompletableFuture的异常
|
|
||||||
- 为追踪目的向异步操作传递LogContext
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
- 使用YAML配置(`quarkus-config-yaml`)
|
|
||||||
- dev/test/prod环境的配置文件感知配置
|
|
||||||
- 将敏感配置外部化到环境变量
|
|
||||||
- 使用`@ConfigProperty`进行类型安全的配置注入
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
- 在资源层使用`@Valid`进行验证
|
|
||||||
- 在DTO上使用Bean Validation注解
|
|
||||||
- 使用`@Provider`将异常映射到适当的HTTP响应
|
|
||||||
|
|
||||||
### 事务
|
|
||||||
- 在修改数据的服务方法上使用`@Transactional`
|
|
||||||
- 保持事务短小且聚焦
|
|
||||||
- 避免在事务内调用异步操作
|
|
||||||
|
|
||||||
### 测试
|
|
||||||
- 使用`camel-quarkus-junit5`进行路由测试
|
|
||||||
- 使用AssertJ进行断言
|
|
||||||
- 模拟所有外部依赖
|
|
||||||
- 彻底测试条件流逻辑
|
|
||||||
|
|
||||||
### Quarkus特定
|
|
||||||
- 保持最新的LTS版本(3.x)
|
|
||||||
- 使用Quarkus开发模式进行热重载
|
|
||||||
- 添加健康检查以确保生产就绪
|
|
||||||
- 定期测试原生编译兼容性
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
---
|
|
||||||
name: quarkus-security
|
|
||||||
description: Quarkus安全最佳实践:认证、授权、JWT/OIDC、RBAC、输入验证、CSRF、密钥管理和依赖安全。
|
|
||||||
origin: ECC
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quarkus 安全审查
|
|
||||||
|
|
||||||
使用认证、授权和输入验证保护Quarkus应用程序的最佳实践。
|
|
||||||
|
|
||||||
## 何时激活
|
|
||||||
|
|
||||||
- 添加认证(JWT、OIDC、Basic Auth)
|
|
||||||
- 使用@RolesAllowed或SecurityIdentity实现授权
|
|
||||||
- 验证用户输入(Bean Validation、自定义验证器)
|
|
||||||
- 配置CORS或安全头
|
|
||||||
- 管理密钥(Vault、环境变量、配置源)
|
|
||||||
- 添加速率限制或暴力破解保护
|
|
||||||
- 扫描依赖CVE
|
|
||||||
- 使用MicroProfile JWT或SmallRye JWT
|
|
||||||
|
|
||||||
## 认证
|
|
||||||
|
|
||||||
### JWT认证
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Path("/api/protected")
|
|
||||||
@Authenticated
|
|
||||||
public class ProtectedResource {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
JsonWebToken jwt;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SecurityIdentity securityIdentity;
|
|
||||||
|
|
||||||
@GET
|
|
||||||
public Response getData() {
|
|
||||||
String username = jwt.getName();
|
|
||||||
Set<String> roles = jwt.getGroups();
|
|
||||||
return Response.ok(Map.of(
|
|
||||||
"username", username,
|
|
||||||
"roles", roles,
|
|
||||||
"principal", securityIdentity.getPrincipal().getName()
|
|
||||||
)).build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
配置(application.properties):
|
|
||||||
```properties
|
|
||||||
mp.jwt.verify.publickey.location=publicKey.pem
|
|
||||||
mp.jwt.verify.issuer=https://auth.example.com
|
|
||||||
|
|
||||||
# OIDC
|
|
||||||
quarkus.oidc.auth-server-url=https://auth.example.com/realms/myrealm
|
|
||||||
quarkus.oidc.client-id=backend-service
|
|
||||||
quarkus.oidc.credentials.secret=${OIDC_SECRET}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 自定义认证过滤器
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Provider
|
|
||||||
@Priority(Priorities.AUTHENTICATION)
|
|
||||||
public class CustomAuthFilter implements ContainerRequestFilter {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SecurityIdentity identity;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void filter(ContainerRequestContext requestContext) {
|
|
||||||
String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
|
|
||||||
|
|
||||||
// 头部缺失或格式错误时立即拒绝
|
|
||||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
|
||||||
requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String token = authHeader.substring(7);
|
|
||||||
if (!validateToken(token)) {
|
|
||||||
requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean validateToken(String token) {
|
|
||||||
// 令牌验证逻辑
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 授权
|
|
||||||
|
|
||||||
### 基于角色的访问控制
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Path("/api/admin")
|
|
||||||
@RolesAllowed("ADMIN")
|
|
||||||
public class AdminResource {
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("/users")
|
|
||||||
public List<UserDto> listUsers() {
|
|
||||||
return userService.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@DELETE
|
|
||||||
@Path("/users/{id}")
|
|
||||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
|
|
||||||
public Response deleteUser(@PathParam("id") Long id) {
|
|
||||||
userService.delete(id);
|
|
||||||
return Response.noContent().build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Path("/api/users")
|
|
||||||
public class UserResource {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SecurityIdentity securityIdentity;
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("/{id}")
|
|
||||||
@RolesAllowed("USER")
|
|
||||||
public Response getUser(@PathParam("id") Long id) {
|
|
||||||
// 所有权检查
|
|
||||||
if (!securityIdentity.hasRole("ADMIN") &&
|
|
||||||
!isOwner(id, securityIdentity.getPrincipal().getName())) {
|
|
||||||
return Response.status(Response.Status.FORBIDDEN).build();
|
|
||||||
}
|
|
||||||
return Response.ok(userService.findById(id)).build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 编程式安全
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class SecurityService {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SecurityIdentity securityIdentity;
|
|
||||||
|
|
||||||
public boolean canAccessResource(Long resourceId) {
|
|
||||||
if (securityIdentity.isAnonymous()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (securityIdentity.hasRole("ADMIN")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
String userId = securityIdentity.getPrincipal().getName();
|
|
||||||
return resourceRepository.isOwner(resourceId, userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 输入验证
|
|
||||||
|
|
||||||
### Bean Validation
|
|
||||||
|
|
||||||
```java
|
|
||||||
// BAD: 无验证
|
|
||||||
@POST
|
|
||||||
public Response createUser(UserDto dto) {
|
|
||||||
return Response.ok(userService.create(dto)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// GOOD: 验证DTO
|
|
||||||
public record CreateUserDto(
|
|
||||||
@NotBlank @Size(max = 100) String name,
|
|
||||||
@NotBlank @Email String email,
|
|
||||||
@NotNull @Min(18) @Max(150) Integer age,
|
|
||||||
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String phone
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@POST
|
|
||||||
@Path("/users")
|
|
||||||
public Response createUser(@Valid CreateUserDto dto) {
|
|
||||||
User user = userService.create(dto);
|
|
||||||
return Response.status(Response.Status.CREATED).entity(user).build();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## SQL注入防护
|
|
||||||
|
|
||||||
### Panache Active Record(默认安全)
|
|
||||||
|
|
||||||
```java
|
|
||||||
// GOOD: Panache参数化查询
|
|
||||||
List<User> users = User.list("email = ?1 and active = ?2", email, true);
|
|
||||||
|
|
||||||
Optional<User> user = User.find("username", username).firstResultOptional();
|
|
||||||
|
|
||||||
// GOOD: 命名参数
|
|
||||||
List<User> users = User.list("email = :email and age > :minAge",
|
|
||||||
Parameters.with("email", email).and("minAge", 18));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 原生查询(使用参数)
|
|
||||||
|
|
||||||
```java
|
|
||||||
// BAD: 字符串拼接
|
|
||||||
@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true)
|
|
||||||
|
|
||||||
// GOOD: 参数化原生查询
|
|
||||||
@Entity
|
|
||||||
public class User extends PanacheEntity {
|
|
||||||
public static List<User> findByEmailNative(String email) {
|
|
||||||
return getEntityManager()
|
|
||||||
.createNativeQuery("SELECT * FROM users WHERE email = :email", User.class)
|
|
||||||
.setParameter("email", email)
|
|
||||||
.getResultList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 密码哈希
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class PasswordService {
|
|
||||||
|
|
||||||
public String hash(String plainPassword) {
|
|
||||||
return BcryptUtil.bcryptHash(plainPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean verify(String plainPassword, String hashedPassword) {
|
|
||||||
return BcryptUtil.matches(plainPassword, hashedPassword);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CORS配置
|
|
||||||
|
|
||||||
```properties
|
|
||||||
# application.properties
|
|
||||||
quarkus.http.cors=true
|
|
||||||
quarkus.http.cors.origins=https://app.example.com,https://admin.example.com
|
|
||||||
quarkus.http.cors.methods=GET,POST,PUT,DELETE
|
|
||||||
quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with
|
|
||||||
quarkus.http.cors.exposed-headers=Content-Disposition
|
|
||||||
quarkus.http.cors.access-control-max-age=24H
|
|
||||||
quarkus.http.cors.access-control-allow-credentials=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## 密钥管理
|
|
||||||
|
|
||||||
```properties
|
|
||||||
# application.properties — 此处不放密钥
|
|
||||||
|
|
||||||
# 使用环境变量
|
|
||||||
quarkus.datasource.username=${DB_USER}
|
|
||||||
quarkus.datasource.password=${DB_PASSWORD}
|
|
||||||
quarkus.oidc.credentials.secret=${OIDC_CLIENT_SECRET}
|
|
||||||
|
|
||||||
# 或使用Vault
|
|
||||||
quarkus.vault.url=https://vault.example.com
|
|
||||||
quarkus.vault.authentication.kubernetes.role=my-role
|
|
||||||
```
|
|
||||||
|
|
||||||
## 速率限制
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class RateLimitFilter implements ContainerRequestFilter {
|
|
||||||
private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void filter(ContainerRequestContext requestContext) {
|
|
||||||
String clientId = getClientIdentifier(requestContext);
|
|
||||||
RateLimiter limiter = limiters.computeIfAbsent(clientId,
|
|
||||||
k -> RateLimiter.create(100.0)); // 每秒100个请求
|
|
||||||
|
|
||||||
if (!limiter.tryAcquire()) {
|
|
||||||
requestContext.abortWith(
|
|
||||||
Response.status(429)
|
|
||||||
.entity(Map.of("error", "Too many requests"))
|
|
||||||
.build()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 安全头
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Provider
|
|
||||||
public class SecurityHeadersFilter implements ContainerResponseFilter {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void filter(ContainerRequestContext request, ContainerResponseContext response) {
|
|
||||||
MultivaluedMap<String, Object> headers = response.getHeaders();
|
|
||||||
|
|
||||||
// 防止点击劫持
|
|
||||||
headers.putSingle("X-Frame-Options", "DENY");
|
|
||||||
|
|
||||||
// XSS保护
|
|
||||||
headers.putSingle("X-Content-Type-Options", "nosniff");
|
|
||||||
headers.putSingle("X-XSS-Protection", "1; mode=block");
|
|
||||||
|
|
||||||
// HSTS
|
|
||||||
headers.putSingle("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
||||||
|
|
||||||
// CSP — script-src不要使用'unsafe-inline',会使XSS保护失效;
|
|
||||||
// 请改用nonce或hash
|
|
||||||
headers.putSingle("Content-Security-Policy",
|
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 审计日志
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ApplicationScoped
|
|
||||||
public class AuditService {
|
|
||||||
private static final Logger LOG = Logger.getLogger(AuditService.class);
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SecurityIdentity securityIdentity;
|
|
||||||
|
|
||||||
public void logAccess(String resource, String action) {
|
|
||||||
String user = securityIdentity.isAnonymous()
|
|
||||||
? "anonymous"
|
|
||||||
: securityIdentity.getPrincipal().getName();
|
|
||||||
|
|
||||||
LOG.infof("AUDIT: user=%s action=%s resource=%s timestamp=%s",
|
|
||||||
user, action, resource, Instant.now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 依赖安全扫描
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Maven
|
|
||||||
mvn org.owasp:dependency-check-maven:check
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
./gradlew dependencyCheckAnalyze
|
|
||||||
|
|
||||||
# 检查Quarkus扩展
|
|
||||||
quarkus extension list --installable
|
|
||||||
```
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
- 生产环境始终使用HTTPS
|
|
||||||
- 启用JWT或OIDC进行无状态认证
|
|
||||||
- 使用`@RolesAllowed`进行声明式授权
|
|
||||||
- 使用Bean Validation验证所有输入
|
|
||||||
- 使用BCrypt哈希密码(禁止明文)
|
|
||||||
- 将密钥存储在Vault或环境变量中
|
|
||||||
- 使用参数化查询防止SQL注入
|
|
||||||
- 为所有响应添加安全头
|
|
||||||
- 为公共端点实现速率限制
|
|
||||||
- 审计敏感操作
|
|
||||||
- 保持依赖更新并扫描CVE
|
|
||||||
- 使用SecurityIdentity进行编程式检查
|
|
||||||
- 设置适当的CORS策略
|
|
||||||
- 测试认证和授权路径
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
---
|
|
||||||
name: quarkus-tdd
|
|
||||||
description: 使用JUnit 5、Mockito、REST Assured、Camel测试和JaCoCo的Quarkus 3.x LTS测试驱动开发。用于添加功能、修复错误或重构事件驱动服务。
|
|
||||||
origin: ECC
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quarkus TDD工作流
|
|
||||||
|
|
||||||
面向80%以上覆盖率(单元+集成)的Quarkus 3.x服务TDD指南。针对Apache Camel的事件驱动架构优化。
|
|
||||||
|
|
||||||
## 何时使用
|
|
||||||
|
|
||||||
- 新功能或REST端点
|
|
||||||
- Bug修复或重构
|
|
||||||
- 添加数据访问逻辑、安全规则或响应式流
|
|
||||||
- 测试Apache Camel路由和事件处理器
|
|
||||||
- 测试RabbitMQ事件驱动服务
|
|
||||||
- 测试条件流逻辑
|
|
||||||
- 验证CompletableFuture异步操作
|
|
||||||
- 测试LogContext传播
|
|
||||||
|
|
||||||
## 工作流
|
|
||||||
|
|
||||||
1. 先写测试(应该失败)
|
|
||||||
2. 实现通过测试的最少代码
|
|
||||||
3. 测试通过后重构
|
|
||||||
4. 使用JaCoCo强制覆盖率(80%以上目标)
|
|
||||||
|
|
||||||
## 使用@Nested组织的单元测试
|
|
||||||
|
|
||||||
全面、可读测试的结构化方法:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
@DisplayName("As2ProcessingService Unit Tests")
|
|
||||||
class As2ProcessingServiceTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private InvoiceFlowValidator invoiceFlowValidator;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private EventService eventService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private DocumentJobService documentJobService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private BusinessRulesPublisher businessRulesPublisher;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FileStorageService fileStorageService;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private As2ProcessingService as2ProcessingService;
|
|
||||||
|
|
||||||
private Path testFilePath;
|
|
||||||
private LogContext testLogContext;
|
|
||||||
private InvoiceValidationResult validationResult;
|
|
||||||
private StoredDocumentInfo documentInfo;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
// ARRANGE - 公共测试数据
|
|
||||||
testFilePath = Path.of("/tmp/test-invoice.xml");
|
|
||||||
|
|
||||||
testLogContext = new LogContext();
|
|
||||||
testLogContext.put(As2Constants.STRUCTURE_ID, "STRUCT-001");
|
|
||||||
testLogContext.put(As2Constants.FILE_NAME, "invoice.xml");
|
|
||||||
testLogContext.put(As2Constants.AS2_FROM, "PARTNER-001");
|
|
||||||
|
|
||||||
validationResult = new InvoiceValidationResult();
|
|
||||||
validationResult.setValid(true);
|
|
||||||
validationResult.setSize(1024L);
|
|
||||||
validationResult.setDocumentHash("abc123");
|
|
||||||
|
|
||||||
documentInfo = new StoredDocumentInfo();
|
|
||||||
documentInfo.setPath("s3://bucket/path/invoice.xml");
|
|
||||||
documentInfo.setSize(1024L);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@DisplayName("Tests for processFile")
|
|
||||||
class ProcessFile {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should successfully process non-CHORUS file with all validations")
|
|
||||||
void givenNonChorusFile_whenProcessFile_thenAllValidationsApplied() throws Exception {
|
|
||||||
// ARRANGE
|
|
||||||
testLogContext.put(As2Constants.CHORUS_FLOW, "false");
|
|
||||||
CustomLog.setCurrentContext(testLogContext);
|
|
||||||
|
|
||||||
when(invoiceFlowValidator.validateFlowWithConfig(
|
|
||||||
eq(testFilePath),
|
|
||||||
eq(ValidationFlowConfig.allValidations()),
|
|
||||||
eq(EInvoiceSyntaxFormat.UBL),
|
|
||||||
any(LogContext.class)))
|
|
||||||
.thenReturn(validationResult);
|
|
||||||
|
|
||||||
when(invoiceFlowValidator.computeFlowProfile(any(), any()))
|
|
||||||
.thenReturn(FlowProfile.BASIC);
|
|
||||||
|
|
||||||
when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any()))
|
|
||||||
.thenReturn(CompletableFuture.completedFuture(documentInfo));
|
|
||||||
|
|
||||||
when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), any(), any()))
|
|
||||||
.thenReturn(new BusinessRulesPayload());
|
|
||||||
|
|
||||||
// ACT
|
|
||||||
assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath));
|
|
||||||
|
|
||||||
// ASSERT
|
|
||||||
verify(invoiceFlowValidator).validateFlowWithConfig(
|
|
||||||
eq(testFilePath),
|
|
||||||
eq(ValidationFlowConfig.allValidations()),
|
|
||||||
eq(EInvoiceSyntaxFormat.UBL),
|
|
||||||
any(LogContext.class));
|
|
||||||
|
|
||||||
verify(eventService).createSuccessEvent(any(StoredDocumentInfo.class),
|
|
||||||
eq("PERSISTENCE_BLOB_EVENT_TYPE"));
|
|
||||||
verify(businessRulesPublisher).publishAsync(any(BusinessRulesPayload.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should create error event when file upload fails")
|
|
||||||
void givenUploadFailure_whenProcessFile_thenErrorEventCreated() throws Exception {
|
|
||||||
// ARRANGE
|
|
||||||
testLogContext.put(As2Constants.CHORUS_FLOW, "false");
|
|
||||||
CustomLog.setCurrentContext(testLogContext);
|
|
||||||
|
|
||||||
when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any()))
|
|
||||||
.thenReturn(validationResult);
|
|
||||||
|
|
||||||
when(invoiceFlowValidator.computeFlowProfile(any(), any()))
|
|
||||||
.thenReturn(FlowProfile.BASIC);
|
|
||||||
|
|
||||||
documentInfo.setPath(""); // 空路径触发错误
|
|
||||||
when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any()))
|
|
||||||
.thenReturn(CompletableFuture.completedFuture(documentInfo));
|
|
||||||
|
|
||||||
// ACT & ASSERT
|
|
||||||
As2ServerProcessingException exception = assertThrows(
|
|
||||||
As2ServerProcessingException.class,
|
|
||||||
() -> as2ProcessingService.processFile(testFilePath)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(exception.getMessage())
|
|
||||||
.contains("File path is empty after upload");
|
|
||||||
|
|
||||||
verify(businessRulesPublisher, never()).publishAsync(any());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 关键测试模式
|
|
||||||
|
|
||||||
1. **@Nested类**: 按被测方法分组测试
|
|
||||||
2. **@DisplayName**: 为测试报告提供可读描述
|
|
||||||
3. **命名约定**: 使用`givenX_whenY_thenZ`确保清晰
|
|
||||||
4. **AAA模式**: 明确的`// ARRANGE`、`// ACT`、`// ASSERT`注释
|
|
||||||
5. **@BeforeEach**: 通用测试数据设置以减少重复
|
|
||||||
6. **assertDoesNotThrow**: 不捕获异常的成功场景测试
|
|
||||||
7. **assertThrows**: 带消息验证的异常场景测试
|
|
||||||
8. **全面覆盖**: 测试正常路径、null输入、边界情况、异常
|
|
||||||
9. **验证交互**: 使用Mockito的`verify()`确保方法被正确调用
|
|
||||||
10. **Never验证**: 使用`never()`确保错误场景中方法未被调用
|
|
||||||
|
|
||||||
## 测试Camel路由
|
|
||||||
|
|
||||||
```java
|
|
||||||
@QuarkusTest
|
|
||||||
@DisplayName("Business Rules Camel Route Tests")
|
|
||||||
class BusinessRulesRouteTest {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
CamelContext camelContext;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
ProducerTemplate producerTemplate;
|
|
||||||
|
|
||||||
@InjectMock
|
|
||||||
EventService eventService;
|
|
||||||
|
|
||||||
@InjectMock
|
|
||||||
DocumentValidator documentValidator;
|
|
||||||
|
|
||||||
private BusinessRulesPayload testPayload;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
// ARRANGE - 测试数据
|
|
||||||
testPayload = new BusinessRulesPayload();
|
|
||||||
testPayload.setDocumentId(1L);
|
|
||||||
testPayload.setFlowProfile(FlowProfile.BASIC);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should successfully publish message to RabbitMQ")
|
|
||||||
void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception {
|
|
||||||
// ARRANGE
|
|
||||||
MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class);
|
|
||||||
mockRabbitMQ.expectedMessageCount(1);
|
|
||||||
|
|
||||||
camelContext.getRouteController().stopRoute("business-rules-publisher");
|
|
||||||
AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> {
|
|
||||||
advice.replaceFromWith("direct:business-rules-publisher");
|
|
||||||
advice.weaveByToString(".*spring-rabbitmq.*").replace().to("mock:rabbitmq");
|
|
||||||
});
|
|
||||||
camelContext.getRouteController().startRoute("business-rules-publisher");
|
|
||||||
|
|
||||||
// ACT
|
|
||||||
producerTemplate.sendBody("direct:business-rules-publisher", testPayload);
|
|
||||||
|
|
||||||
// ASSERT
|
|
||||||
mockRabbitMQ.assertIsSatisfied(5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 资源层测试(REST Assured)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@QuarkusTest
|
|
||||||
@DisplayName("DocumentResource API Tests")
|
|
||||||
class DocumentResourceTest {
|
|
||||||
|
|
||||||
@InjectMock
|
|
||||||
DocumentService documentService;
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@DisplayName("Tests for POST /api/documents")
|
|
||||||
class CreateDocument {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should create document and return 201")
|
|
||||||
void givenValidRequest_whenCreate_thenReturns201() {
|
|
||||||
// ARRANGE
|
|
||||||
Document document = createDocument(1L, "DOC-001");
|
|
||||||
when(documentService.create(any())).thenReturn(document);
|
|
||||||
|
|
||||||
// ACT & ASSERT
|
|
||||||
given()
|
|
||||||
.contentType(ContentType.JSON)
|
|
||||||
.body("""
|
|
||||||
{
|
|
||||||
"referenceNumber": "DOC-001",
|
|
||||||
"description": "Test document",
|
|
||||||
"validUntil": "2030-01-01T00:00:00Z",
|
|
||||||
"categories": ["test"]
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
.when().post("/api/documents")
|
|
||||||
.then()
|
|
||||||
.statusCode(201)
|
|
||||||
.body("referenceNumber", equalTo("DOC-001"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should return 400 for invalid input")
|
|
||||||
void givenInvalidRequest_whenCreate_thenReturns400() {
|
|
||||||
given()
|
|
||||||
.contentType(ContentType.JSON)
|
|
||||||
.body("""
|
|
||||||
{
|
|
||||||
"referenceNumber": "",
|
|
||||||
"description": "Test"
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
.when().post("/api/documents")
|
|
||||||
.then()
|
|
||||||
.statusCode(400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## JaCoCo覆盖率
|
|
||||||
|
|
||||||
### Maven配置
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.jacoco</groupId>
|
|
||||||
<artifactId>jacoco-maven-plugin</artifactId>
|
|
||||||
<version>0.8.13</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>prepare-agent</id>
|
|
||||||
<goals><goal>prepare-agent</goal></goals>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>report</id>
|
|
||||||
<phase>verify</phase>
|
|
||||||
<goals><goal>report</goal></goals>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>check</id>
|
|
||||||
<goals><goal>check</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<rules>
|
|
||||||
<rule>
|
|
||||||
<element>BUNDLE</element>
|
|
||||||
<limits>
|
|
||||||
<limit>
|
|
||||||
<counter>LINE</counter>
|
|
||||||
<value>COVEREDRATIO</value>
|
|
||||||
<minimum>0.80</minimum>
|
|
||||||
</limit>
|
|
||||||
</limits>
|
|
||||||
</rule>
|
|
||||||
</rules>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
```
|
|
||||||
|
|
||||||
运行带覆盖率的测试:
|
|
||||||
```bash
|
|
||||||
mvn clean test
|
|
||||||
mvn jacoco:report
|
|
||||||
mvn jacoco:check
|
|
||||||
|
|
||||||
# 报告位于: target/site/jacoco/index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
## 测试依赖
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-junit5</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-junit5-mockito</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.assertj</groupId>
|
|
||||||
<artifactId>assertj-core</artifactId>
|
|
||||||
<version>3.24.2</version>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.rest-assured</groupId>
|
|
||||||
<artifactId>rest-assured</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.camel.quarkus</groupId>
|
|
||||||
<artifactId>camel-quarkus-junit5</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
### 测试组织
|
|
||||||
- 使用`@Nested`类按被测方法分组
|
|
||||||
- 使用`@DisplayName`提供可读的测试描述
|
|
||||||
- 遵循`givenX_whenY_thenZ`命名约定
|
|
||||||
|
|
||||||
### 测试结构
|
|
||||||
- 遵循带明确注释的AAA模式(`// ARRANGE`、`// ACT`、`// ASSERT`)
|
|
||||||
- 成功场景使用`assertDoesNotThrow`
|
|
||||||
- 异常场景使用`assertThrows`并验证消息
|
|
||||||
|
|
||||||
### 断言
|
|
||||||
- 值检查**优先使用AssertJ**(`assertThat`)而非JUnit断言
|
|
||||||
- 使用流式AssertJ API提高可读性
|
|
||||||
- 异常: 使用JUnit `assertThrows`捕获,再用AssertJ验证消息
|
|
||||||
- 成功路径: 使用JUnit `assertDoesNotThrow`
|
|
||||||
|
|
||||||
### 事件驱动测试
|
|
||||||
- 使用`AdviceWith`和`MockEndpoint`测试Camel路由
|
|
||||||
- 验证消息内容、头部和路由逻辑
|
|
||||||
- 单独测试错误处理路由
|
|
||||||
- 单元测试中模拟外部系统(RabbitMQ、S3、数据库)
|
|
||||||
|
|
||||||
### Quarkus特定
|
|
||||||
- 保持最新的LTS版本(Quarkus 3.x)
|
|
||||||
- 使用Quarkus测试配置文件处理不同场景
|
|
||||||
- 使用`@InjectMock`代替`@MockBean`(Quarkus特定)
|
|
||||||
|
|
||||||
**请记住**: 保持测试快速、隔离和确定性。测试行为而非实现细节。
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
---
|
|
||||||
name: quarkus-verification
|
|
||||||
description: "Quarkus项目验证循环:构建、静态分析、带覆盖率的测试、安全扫描、原生编译以及发布或PR前的diff审查。"
|
|
||||||
origin: ECC
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quarkus 验证循环
|
|
||||||
|
|
||||||
在PR前、重大变更后和部署前运行。
|
|
||||||
|
|
||||||
## 何时激活
|
|
||||||
|
|
||||||
- 为Quarkus服务打开PR前
|
|
||||||
- 大规模重构或依赖升级后
|
|
||||||
- 预发布或生产的部署前验证
|
|
||||||
- 运行完整的构建 → lint → 测试 → 安全扫描 → 原生编译流水线
|
|
||||||
- 验证测试覆盖率达到阈值(80%+)
|
|
||||||
- 测试原生镜像兼容性
|
|
||||||
|
|
||||||
## 阶段1: 构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Maven
|
|
||||||
mvn clean verify -DskipTests
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
./gradlew clean assemble -x test
|
|
||||||
```
|
|
||||||
|
|
||||||
构建失败时,停止并修复编译错误。
|
|
||||||
|
|
||||||
## 阶段2: 静态分析
|
|
||||||
|
|
||||||
### Checkstyle、PMD、SpotBugs(Maven)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mvn checkstyle:check pmd:check spotbugs:check
|
|
||||||
```
|
|
||||||
|
|
||||||
### SonarQube(如已配置)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mvn sonar:sonar \
|
|
||||||
-Dsonar.projectKey=my-quarkus-project \
|
|
||||||
-Dsonar.host.url=http://localhost:9000 \
|
|
||||||
-Dsonar.login=${SONAR_TOKEN}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 需要解决的常见问题
|
|
||||||
|
|
||||||
- 未使用的导入或变量
|
|
||||||
- 复杂方法(高圈复杂度)
|
|
||||||
- 潜在的空指针解引用
|
|
||||||
- SpotBugs标记的安全问题
|
|
||||||
|
|
||||||
## 阶段3: 测试 + 覆盖率
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 运行所有测试
|
|
||||||
mvn clean test
|
|
||||||
|
|
||||||
# 生成覆盖率报告
|
|
||||||
mvn jacoco:report
|
|
||||||
|
|
||||||
# 强制覆盖率阈值(80%)
|
|
||||||
mvn jacoco:check
|
|
||||||
|
|
||||||
# 或使用Gradle
|
|
||||||
./gradlew test jacocoTestReport jacocoTestCoverageVerification
|
|
||||||
```
|
|
||||||
|
|
||||||
### 测试类别
|
|
||||||
|
|
||||||
#### 单元测试
|
|
||||||
使用模拟依赖测试服务逻辑:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class UserServiceTest {
|
|
||||||
@Mock UserRepository userRepository;
|
|
||||||
@InjectMocks UserService userService;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUser_validInput_returnsUser() {
|
|
||||||
var dto = new CreateUserDto("Alice", "alice@example.com");
|
|
||||||
|
|
||||||
// Panache的persist()返回void — 使用doNothing + verify
|
|
||||||
doNothing().when(userRepository).persist(any(User.class));
|
|
||||||
|
|
||||||
User result = userService.create(dto);
|
|
||||||
|
|
||||||
assertThat(result.name).isEqualTo("Alice");
|
|
||||||
verify(userRepository).persist(any(User.class));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 集成测试
|
|
||||||
使用真实数据库(Testcontainers)测试:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@QuarkusTest
|
|
||||||
@QuarkusTestResource(PostgresTestResource.class)
|
|
||||||
class UserRepositoryIntegrationTest {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
UserRepository userRepository;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Transactional
|
|
||||||
void findByEmail_existingUser_returnsUser() {
|
|
||||||
User user = new User();
|
|
||||||
user.name = "Alice";
|
|
||||||
user.email = "alice@example.com";
|
|
||||||
userRepository.persist(user);
|
|
||||||
|
|
||||||
Optional<User> found = userRepository.findByEmail("alice@example.com");
|
|
||||||
|
|
||||||
assertThat(found).isPresent();
|
|
||||||
assertThat(found.get().name).isEqualTo("Alice");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### API测试
|
|
||||||
使用REST Assured测试REST端点:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@QuarkusTest
|
|
||||||
class UserResourceTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUser_validInput_returns201() {
|
|
||||||
given()
|
|
||||||
.contentType(ContentType.JSON)
|
|
||||||
.body("""
|
|
||||||
{"name": "Alice", "email": "alice@example.com"}
|
|
||||||
""")
|
|
||||||
.when().post("/api/users")
|
|
||||||
.then()
|
|
||||||
.statusCode(201)
|
|
||||||
.body("name", equalTo("Alice"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUser_invalidEmail_returns400() {
|
|
||||||
given()
|
|
||||||
.contentType(ContentType.JSON)
|
|
||||||
.body("""
|
|
||||||
{"name": "Alice", "email": "invalid"}
|
|
||||||
""")
|
|
||||||
.when().post("/api/users")
|
|
||||||
.then()
|
|
||||||
.statusCode(400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 覆盖率报告
|
|
||||||
|
|
||||||
检查`target/site/jacoco/index.html`获取详细覆盖率:
|
|
||||||
- 总体行覆盖率(目标: 80%+)
|
|
||||||
- 分支覆盖率(目标: 70%+)
|
|
||||||
- 识别未覆盖的关键路径
|
|
||||||
|
|
||||||
## 阶段4: 安全扫描
|
|
||||||
|
|
||||||
### 依赖漏洞(Maven)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mvn org.owasp:dependency-check-maven:check
|
|
||||||
```
|
|
||||||
|
|
||||||
查看`target/dependency-check-report.html`中的CVE。
|
|
||||||
|
|
||||||
### Quarkus安全审计
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查有漏洞的扩展
|
|
||||||
mvn quarkus:audit
|
|
||||||
|
|
||||||
# 列出所有扩展
|
|
||||||
mvn quarkus:list-extensions
|
|
||||||
```
|
|
||||||
|
|
||||||
### 常见安全检查
|
|
||||||
|
|
||||||
- [ ] 所有密钥在环境变量中(不在代码中)
|
|
||||||
- [ ] 所有端点有输入验证
|
|
||||||
- [ ] 认证/授权已配置
|
|
||||||
- [ ] CORS正确配置
|
|
||||||
- [ ] 安全头已设置
|
|
||||||
- [ ] 密码使用BCrypt哈希
|
|
||||||
- [ ] SQL注入保护(参数化查询)
|
|
||||||
- [ ] 公共端点有速率限制
|
|
||||||
|
|
||||||
## 阶段5: 原生编译
|
|
||||||
|
|
||||||
测试GraalVM原生镜像兼容性:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 构建原生可执行文件
|
|
||||||
mvn package -Dnative
|
|
||||||
|
|
||||||
# 或使用容器
|
|
||||||
mvn package -Dnative -Dquarkus.native.container-build=true
|
|
||||||
|
|
||||||
# 测试原生可执行文件
|
|
||||||
./target/*-runner
|
|
||||||
|
|
||||||
# 运行基本冒烟测试
|
|
||||||
curl http://localhost:8080/q/health/live
|
|
||||||
curl http://localhost:8080/q/health/ready
|
|
||||||
```
|
|
||||||
|
|
||||||
### 原生镜像故障排除
|
|
||||||
|
|
||||||
常见问题:
|
|
||||||
- **Reflection**: 为动态类添加反射配置
|
|
||||||
- **Resources**: 使用`quarkus.native.resources.includes`包含资源
|
|
||||||
- **JNI**: 使用原生库时注册JNI类
|
|
||||||
|
|
||||||
反射配置示例:
|
|
||||||
```java
|
|
||||||
@RegisterForReflection(targets = {MyDynamicClass.class})
|
|
||||||
public class ReflectionConfiguration {}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 阶段6: 健康检查
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 存活检查
|
|
||||||
curl http://localhost:8080/q/health/live
|
|
||||||
|
|
||||||
# 就绪检查
|
|
||||||
curl http://localhost:8080/q/health/ready
|
|
||||||
|
|
||||||
# 所有健康检查
|
|
||||||
curl http://localhost:8080/q/health
|
|
||||||
|
|
||||||
# 指标(如启用)
|
|
||||||
curl http://localhost:8080/q/metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验证清单
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
- [ ] 构建无警告通过
|
|
||||||
- [ ] 静态分析干净(无高/中问题)
|
|
||||||
- [ ] 代码遵循团队规范
|
|
||||||
- [ ] PR中无注释代码或TODO
|
|
||||||
|
|
||||||
### 测试
|
|
||||||
- [ ] 所有测试通过
|
|
||||||
- [ ] 代码覆盖率 ≥ 80%
|
|
||||||
- [ ] 使用真实数据库的集成测试
|
|
||||||
- [ ] 安全测试通过
|
|
||||||
- [ ] 性能在可接受范围内
|
|
||||||
|
|
||||||
### 安全
|
|
||||||
- [ ] 无依赖漏洞
|
|
||||||
- [ ] 认证/授权已测试
|
|
||||||
- [ ] 输入验证完成
|
|
||||||
- [ ] 源代码中无密钥
|
|
||||||
- [ ] 安全头已配置
|
|
||||||
|
|
||||||
### 部署
|
|
||||||
- [ ] 原生编译成功
|
|
||||||
- [ ] 容器镜像可构建
|
|
||||||
- [ ] 健康检查正确响应
|
|
||||||
- [ ] 目标环境配置有效
|
|
||||||
|
|
||||||
## 自动化验证脚本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== 阶段1: 构建 ==="
|
|
||||||
mvn clean verify -DskipTests
|
|
||||||
|
|
||||||
echo "=== 阶段2: 静态分析 ==="
|
|
||||||
mvn checkstyle:check pmd:check spotbugs:check
|
|
||||||
|
|
||||||
echo "=== 阶段3: 测试 + 覆盖率 ==="
|
|
||||||
mvn test jacoco:report jacoco:check
|
|
||||||
|
|
||||||
echo "=== 阶段4: 安全扫描 ==="
|
|
||||||
mvn org.owasp:dependency-check-maven:check
|
|
||||||
|
|
||||||
echo "=== 阶段5: 原生编译 ==="
|
|
||||||
mvn package -Dnative -Dquarkus.native.container-build=true
|
|
||||||
|
|
||||||
echo "=== 所有阶段完成 ==="
|
|
||||||
echo "查看报告:"
|
|
||||||
echo " - 覆盖率: target/site/jacoco/index.html"
|
|
||||||
echo " - 安全: target/dependency-check-report.html"
|
|
||||||
echo " - 原生: target/*-runner"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
- 每次PR前运行验证循环
|
|
||||||
- 在CI/CD流水线中自动化
|
|
||||||
- 立即修复问题,不积累技术债务
|
|
||||||
- 保持覆盖率在80%以上
|
|
||||||
- 定期更新依赖
|
|
||||||
- 定期测试原生编译
|
|
||||||
- 监控性能趋势
|
|
||||||
- 记录破坏性变更
|
|
||||||
- 审查安全扫描结果
|
|
||||||
- 验证每个环境的配置
|
|
||||||
Reference in New Issue
Block a user