diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..29f81d812f3e768fa89638d1f72920dbfd1413a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/pom.xml b/pom.xml index 9c62529e88194b2f24be2b366878550ca77d93eb..0960df5f0580761fb82e063944e7a29c0fadc2d3 100644 --- a/pom.xml +++ b/pom.xml @@ -37,6 +37,8 @@ 2.6.0 5.8.29 + + 4.10.0 @@ -75,6 +77,11 @@ fastjson 1.2.83 + + com.squareup.okhttp3 + okhttp + ${okhttp.version} + org.openjdk.jmh jmh-core @@ -152,6 +159,7 @@ + -parameters --module-path ${project.build.directory} diff --git a/quick-landing/dynamic-rest-caller/module-info.md b/quick-landing/dynamic-rest-caller/module-info.md new file mode 100644 index 0000000000000000000000000000000000000000..b2657924eb2cff57ef74f331a0d029954d4fb69f --- /dev/null +++ b/quick-landing/dynamic-rest-caller/module-info.md @@ -0,0 +1,60 @@ + +> 问题地址:[mercyblitz-训练营-问答互动](https://www.yuque.com/mercyblitz/tech-weekly/lmdkv8x1xm4z6n7k) + +### 模块背景 +#### 问题:小马哥,openfeign 如何动态化,就是A服务要调用其他服务,但是一开始我是不知道要调用谁的,所以一开始我没法写好feign接口给A服务用。 怎么做到运行时,调用任意服务的任意接口呢?有点像泛化调用。 +#### 答: +- 解决方案一:的确是泛化调用,这个需要设计一个特殊的接口,可以参考Dubbo 泛化接口设计,也可以把这个功能放到网关上 +- 解决方案二:搞个 API 网关,OpenFeign 接口去调用网关 +前提:具备一个服务提供接口目录,提供具体 Web Service URI,请求参数(HTTP Request -> 具体方法参数),通过契约/约定的方式让方法参数转换成 HTTP Request +- 建议:RestTemplate、WebClient + +- - - + +> 这里使用了 RestTemplate 作为示例,以下是对本模块的具体描述 + +### 动态REST调用器 + +这个项目展示了一个使用 Spring Boot 和 DDD 原则实现的健壮、高性能和可扩展的动态服务调用系统。 + +### 功能特性 + +- 动态服务发现和调用 +- 可配置的 HTTP 客户端,支持 HttpClient 5 和 OkHttp +- 可扩展的服务发现机制 +- 使用 Java 17 新特性 + +### 快速开始 + +1. 克隆仓库 +2. 构建项目:`mvn clean install` +3. 运行应用:`java -jar target/dynamic-rest-caller-0.0.1-SNAPSHOT.jar` + +### 使用方法 + +向 `/call` 发送 POST 请求,使用以下 JSON 结构: +```java +{ + "serviceName":"userService", + "endpointName":"getUser", + "payload":{}, + "uriVariables":{ + "id":"123" + } +} +``` +### 配置 + +调整 `application.yml` 文件以自定义 HttpClient 和 OkHttp 设置。 + +### 扩展服务发现 + +要添加新的服务发现机制: + +1. 实现 `ServiceDiscovery` 接口 +2. 在 `ServiceDiscoveryAutoConfiguration` 中添加新的 bean 创建方法 +3. 在 `application.yml` 中更新 `service-discovery.type` 属性 + +### 贡献 +欢迎提交 Pull Requests 来改进这个项目。对于重大更改,请先开 issue 讨论您想要改变的内容。 + diff --git a/quick-landing/dynamic-rest-caller/pom.xml b/quick-landing/dynamic-rest-caller/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..bb3bd17b2a9c16eb113be57b5ac1e3fa9514114a --- /dev/null +++ b/quick-landing/dynamic-rest-caller/pom.xml @@ -0,0 +1,69 @@ + + 4.0.0 + + cc.magicjson + quick-landing + ${revision} + ../pom.xml + + + dynamic-rest-caller + jar + + dynamic-rest-selector + https://gitee.com/MagicJson/learning-training.git + 基于 DDD 的动态服务调用器 + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.apache.httpcomponents.client5 + httpclient5 + + + + com.squareup.okhttp3 + okhttp + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/DynamicServiceApplication.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/DynamicServiceApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..4dbf28999d1ee0343a8991b0fa381c40efe317ae --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/DynamicServiceApplication.java @@ -0,0 +1,16 @@ +package cc.magicjson.caller; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * + * @author MagicJson + * @since 1.0.0 + */ +@SpringBootApplication +public class DynamicServiceApplication { + public static void main(String[] args) { + SpringApplication.run(DynamicServiceApplication.class, args); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/application/service/DynamicServiceCaller.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/application/service/DynamicServiceCaller.java new file mode 100644 index 0000000000000000000000000000000000000000..49d1ed26bfba76fc4298d0fdb5166a786e064495 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/application/service/DynamicServiceCaller.java @@ -0,0 +1,89 @@ +package cc.magicjson.caller.application.service; + + +import cc.magicjson.caller.domain.discovery.ServiceDiscovery; +import cc.magicjson.caller.domain.model.Endpoint; +import cc.magicjson.caller.domain.model.Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; + +import java.util.Map; + +/** + * 动态调用其他服务的服务类。 + * 这个类使用 ServiceDiscovery 来定位服务及其端点, + * 然后使用 RestTemplate 来进行实际的 HTTP 调用。 + * + * @author MagicJson + * @since 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DynamicServiceCaller { + private final ServiceDiscovery serviceDiscovery; + private final RestTemplate restTemplate; + + /** + * 动态调用服务端点。 + * + * @param serviceName 要调用的服务名称 + * @param endpointName 要调用的端点名称 + * @param request 请求体 + * @param responseType 预期的响应类型 + * @param uriVariables 要替换在 URL 路径中的变量 + * @param 响应的类型 + * @return 来自服务调用的响应 + * @throws IllegalArgumentException 如果找不到服务或端点 + */ + public T callService(String serviceName, String endpointName, Object request, Class responseType, Map uriVariables) { + Service service = serviceDiscovery.getService(serviceName) + .orElseThrow(() -> new IllegalArgumentException("Service not found: " + serviceName)); + + Endpoint endpoint = serviceDiscovery.getEndpoint(serviceName, endpointName) + .orElseThrow(() -> new IllegalArgumentException("Endpoint not found: " + endpointName)); + + String baseUrl = service.url(); + String path = endpoint.path(); + + // 构建 URI + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl).path(path); + + // 添加查询参数(如果有的话) + // 处理 URI 变量和查询参数 + if (uriVariables != null) { + for (Map.Entry entry : uriVariables.entrySet()) { + String key = entry.getKey(); + String value = (String) entry.getValue(); + if (path.contains("{" + key + "}")) { + // 这是一个路径变量 + path = path.replace("{" + key + "}", UriUtils.encodePathSegment(value, "UTF-8")); + } else { + // 这是一个查询参数 + builder.queryParam(key, value); + } + } + } + + // 构建最终的 URI + String uri = builder.build(false).toUriString(); + + + // 打印拼接后的 URI + log.info("Calling service with URI: {}", uri); + + return restTemplate.exchange( + uri, + HttpMethod.valueOf(endpoint.method()), + new HttpEntity<>(request), + responseType, + uriVariables + ).getBody(); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/domain/discovery/ServiceDiscovery.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/domain/discovery/ServiceDiscovery.java new file mode 100644 index 0000000000000000000000000000000000000000..ade22a896af595b12280e9431e2c2f1b586b6b6a --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/domain/discovery/ServiceDiscovery.java @@ -0,0 +1,45 @@ +package cc.magicjson.caller.domain.discovery; + + +import cc.magicjson.caller.domain.model.Endpoint; +import cc.magicjson.caller.domain.model.Service; + +import java.util.List; +import java.util.Optional; + +/** + * 服务发现操作的接口。 + * 这个接口定义了发现服务及其端点的契约。 + * + * @author MagicJson + * @since 1.0.0 + * + */ +public interface ServiceDiscovery { + /** + * 通过名称检索服务。 + * + * @param serviceName 要检索的服务名称 + * @return 包含 Service 的 Optional,如果找到则有值,否则为空 + */ + Optional getService(String serviceName); + + /** + * 检索给定服务的所有端点。 + * + * @param serviceName 要检索端点的服务名称 + * @return 给定服务的 Endpoint 列表 + */ + List getEndpoints(String serviceName); + + + + /** + * 获取指定服务的指定端点 + * + * @param serviceName 服务名称 + * @param endpointName 端点名称 + * @return 包含 Endpoint 的 Optional,如果找到则有值,否则为空 + */ + Optional getEndpoint(String serviceName, String endpointName); +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/domain/model/Endpoint.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/domain/model/Endpoint.java new file mode 100644 index 0000000000000000000000000000000000000000..da2e8b01830d8d752d2f55623b8b2bebbcb254a1 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/domain/model/Endpoint.java @@ -0,0 +1,14 @@ +package cc.magicjson.caller.domain.model; + +/** + * 表示服务的一个端点。 + * 这个记录包含了关于端点的基本信息 + * + * @author MagicJson + * @since 1.0.0 + * + * @param name 端点的名称 + * @param path 端点的路径 + * @param method 端点的 HTTP 方法 + */ +public record Endpoint(String name, String path, String method) {} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/domain/model/Service.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/domain/model/Service.java new file mode 100644 index 0000000000000000000000000000000000000000..5180f94a778b71064026f874b7c4201cc4d9aeba --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/domain/model/Service.java @@ -0,0 +1,13 @@ +package cc.magicjson.caller.domain.model; + +/** + * 表示系统中的一个服务。 + * 这个记录包含了关于服务的基本信息 + * + * @author MagicJson + * @since 1.0.0 + * + * @param name 服务的名称 + * @param url 服务的基础 URL + */ +public record Service(String name, String url) {} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/adapter/external/mock/MockServicesController.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/adapter/external/mock/MockServicesController.java new file mode 100644 index 0000000000000000000000000000000000000000..6368b96d4801857483e16e23842c1207022179ca --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/adapter/external/mock/MockServicesController.java @@ -0,0 +1,205 @@ +package cc.magicjson.caller.infrastructure.adapter.external.mock; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * + * 模拟服务控制器 其中注释示例为动态Rest服务调用案例 + * + * @author MagicJson + * @since 1.0.0 + */ +@RestController +public class MockServicesController { + + /** + * 获取用户信息 + * 示例请求体: + * { + * "serviceName": "userService", + * "endpointName": "getUserInfo", + * "payload": { + * "userId": "12345" + * }, + * "uriVariables": { + * "id": "12345" + * } + * } + * @param id 用户ID + * @return 包含用户信息的ResponseEntity + */ + @GetMapping("/users/{id}") + public ResponseEntity getUserInfo(@PathVariable String id) { + User user = new User(id, "John Doe", "john.doe@example.com"); + return ResponseEntity.ok(user); + } + + /** + * 创建新用户 + * 示例请求体: + * { + * "serviceName": "userService", + * "endpointName": "createUser", + * "payload": { + * "name": "John Doe", + * "email": "EMAIL" + * }, + * "uriVariables": { + * "id": "12345" + * } + * } + * @param user 要创建的用户信息 + * @return 包含创建的用户信息的ResponseEntity + */ + @PostMapping("/users") + public ResponseEntity createUser(@RequestBody User user) { + return ResponseEntity.ok(new User(user.name, user.email)); + } + + /** + * 创建新订单 + * 示例请求体: + * { + * "serviceName": "orderService", + * "endpointName": "createOrder", + * "payload": { + * "customerId": "67890", + * "items": [ + * { + * "productId": "P001", + * "quantity": 2 + * }, + * { + * "productId": "P002", + * "quantity": 1 + * } + * ], + * "shippingAddress": { + * "street": "123 Main St", + * "city": "Anytown", + * "country": "USA", + * "zipCode": "12345" + * } + * }, + * "uriVariables": {} + * } + * @param order 要创建的订单信息 + * @return 包含创建的订单信息的ResponseEntity + */ + @PostMapping("/orders") + public ResponseEntity createOrder(@RequestBody Order order) { + return ResponseEntity.ok( + new Order(order.customerId, order.items, order.shippingAddress)); + } + + /** + * 获取订单详情 + * 示例请求体: + * { + * "serviceName": "orderService", + * "endpointName": "getOrderDetails", + * "payload": {}, + * "uriVariables": { + * "id": "12345" + * } + * } + * @param id 订单ID + * @return 包含订单详情的ResponseEntity + */ + @GetMapping("/orders/{id}") + public ResponseEntity getOrderDetails(@PathVariable String id) { + Order order = new Order(id, "67890", List.of( + new OrderItem("P001", 2), + new OrderItem("P002", 1) + ), new Address("123 Main St", "Anytown", "USA", "12345")); + return ResponseEntity.ok(order); + } + + /** + * 搜索产品 + * 示例请求体: + * { + * "serviceName": "productService", + * "endpointName": "searchProducts", + * "payload": {}, + * "uriVariables": { + * "category": "electronics", + * "minPrice": "100", + * "maxPrice": "1000" + * } + * } + * @param category 产品类别 + * @param minPrice 最低价格 + * @param maxPrice 最高价格 + * @return 包含符合条件的产品列表的ResponseEntity + */ + @GetMapping("/products/search") + public ResponseEntity> searchProducts( + @RequestParam String category, + @RequestParam double minPrice, + @RequestParam double maxPrice) { + List products = List.of( + new Product("P001", "Smartphone", "electronics", 599.99), + new Product("P002", "Laptop", "electronics", 999.99), + new Product("P003", "Headphones", "electronics", 199.99) + ); + List filteredProducts = products.stream() + .filter(p -> p.category.equals(category)) + .filter(p -> p.price >= minPrice && p.price <= maxPrice) + .toList(); + return ResponseEntity.ok(filteredProducts); + } + + /** + * 获取产品详情 + * 示例请求体: + * { + * "serviceName": "productService", + * "endpointName": "getProductDetails", + * "payload": {}, + * "uriVariables": { + * "id": "P001" + * } + * } + * @param id 产品ID + * @return 包含产品详情的ResponseEntity + */ + @GetMapping("/products/{id}") + public ResponseEntity getProductDetails(@PathVariable String id) { + Product product = new Product(id, "Sample Product", "electronics", 299.99); + return ResponseEntity.ok(product); + } + + + record User(String id, String name, String email){ + User(String name, String email) { + this(UUID.randomUUID().toString(), name, email); + } + } + + record Order(String id, String customerId, List items + , Address shippingAddress){ + Order(String customerId, List items, Address shippingAddress){ + this(UUID.randomUUID().toString(), customerId, items, shippingAddress); + } + } + + record OrderItem(String productId, double quantity) { + OrderItem(double quantity) { + this(UUID.randomUUID().toString(), quantity); + } + } + + record Address(String street, String city, String country, String zipCode) { + } + + record Product(String id, String name, String category, double price) { + Product( String name, String category, double price) { + this(UUID.randomUUID().toString(), name, category, price); + } + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/autoconfigure/ServiceDiscoveryAutoConfiguration.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/autoconfigure/ServiceDiscoveryAutoConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..77048fbdd510fff7a131a90c0a8984fa4f884c82 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/autoconfigure/ServiceDiscoveryAutoConfiguration.java @@ -0,0 +1,50 @@ +package cc.magicjson.caller.infrastructure.autoconfigure; + + +import cc.magicjson.caller.domain.discovery.ServiceDiscovery; + +import cc.magicjson.caller.infrastructure.discovery.SimpleServiceDiscovery; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +/** + * 服务发现的自动配置类。 + * 这个类根据配置设置适当的 ServiceDiscovery bean + * + * @author MagicJson + * @since 1.0.0 + */ +@AutoConfiguration +public class ServiceDiscoveryAutoConfiguration { + + /** + * 如果没有其他 ServiceDiscovery bean 存在, + * 且 service-discovery.type 属性设置为 'simple' 或未设置, + * 则创建一个 SimpleServiceDiscovery bean。 + * + * @return SimpleServiceDiscovery 实例 + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "service-discovery.type", havingValue = "simple", matchIfMissing = true) + public ServiceDiscovery simpleServiceDiscovery() { + return new SimpleServiceDiscovery(); + } + + // TODO 预埋-在这里添加其他服务发现实现 + +// @Bean +// @ConditionalOnProperty(name = "service-discovery.type", havingValue = "nacos") +// public ServiceDiscovery nacosServiceDiscovery() { +// return new NacosServiceDiscovery(); +// } +// +// @Bean +// @ConditionalOnProperty(name = "service-discovery.type", havingValue = "zookeeper") +// public ServiceDiscovery zookeeperServiceDiscovery() { +// return new ZookeeperServiceDiscovery(); +// } + +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/discovery/SimpleServiceDiscovery.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/discovery/SimpleServiceDiscovery.java new file mode 100644 index 0000000000000000000000000000000000000000..4372eef525d241e4a4ec1eeeb16267aaeb0a22fe --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/discovery/SimpleServiceDiscovery.java @@ -0,0 +1,102 @@ +package cc.magicjson.caller.infrastructure.discovery; + +import cc.magicjson.caller.domain.discovery.ServiceDiscovery; +import cc.magicjson.caller.domain.model.Endpoint; +import cc.magicjson.caller.domain.model.Service; + +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * SimpleDiscovery 类实现了 ServiceDiscovery 接口,提供了一个简单的服务发现机制。 + * 这个类模拟了一个服务注册表,存储了预定义的服务和端点信息 + * + * @author MagicJson + * @since 1.0.0 + */ +@Component +public class SimpleServiceDiscovery implements ServiceDiscovery { + + // 存储服务信息的 Map,键为服务名,值为 Service 对象 + private final Map services; + // 存储端点信息的 Map,键为服务名,值为该服务的端点列表 + private final Map> endpoints; + + /** + * 构造函数,初始化服务和端点信息 + */ + public SimpleServiceDiscovery() { + services = new HashMap<>(); + endpoints = new HashMap<>(); + + // 初始化预定义的服务和端点 + initializeServices(); + } + + /** + * 初始化预定义的服务和端点信息 + * 这个方法模拟了服务注册的过程 + */ + private void initializeServices() { + // 初始化 userService + Service userService = new Service("userService", "http://localhost:8080"); + services.put("userService", userService); + endpoints.put("userService", List.of( + new Endpoint("getUserInfo", "/users/{id}", "GET"), + new Endpoint("createUser", "/users", "POST") + )); + + // 初始化 orderService + Service orderService = new Service("orderService", "http://localhost:8080"); + services.put("orderService", orderService); + endpoints.put("orderService", List.of( + new Endpoint("createOrder", "/orders", "POST"), + new Endpoint("getOrderDetails", "/orders/{id}", "GET") + )); + + // 初始化 productService + Service productService = new Service("productService", "http://localhost:8080"); + services.put("productService", productService); + endpoints.put("productService", List.of( + new Endpoint("searchProducts", "/products/search", "GET"), + new Endpoint("getProductDetails", "/products/{id}", "GET") + )); + } + + /** + * 根据服务名获取服务信息 + * @param serviceName 服务名 + * @return 包含 Service 对象的 Optional,如果服务不存在则返回空 Optional + */ + @Override + public Optional getService(String serviceName) { + return Optional.ofNullable(services.get(serviceName)); + } + + /** + * 获取指定服务的所有端点 + * @param serviceName 服务名 + * @return 端点列表,如果服务不存在则返回空列表 + */ + @Override + public List getEndpoints(String serviceName) { + return endpoints.getOrDefault(serviceName, List.of()); + } + + /** + * 获取指定服务的特定端点 + * @param serviceName 服务名 + * @param endpointName 端点名 + * @return 包含 Endpoint 对象的 Optional,如果端点不存在则返回空 Optional + */ + @Override + public Optional getEndpoint(String serviceName, String endpointName) { + return getEndpoints(serviceName).stream() + .filter(endpoint -> endpoint.name().equals(endpointName)) + .findFirst(); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/HttpClientFactoryProvider.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/HttpClientFactoryProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..18dc5a048068a411c8965e17c00218625e18fb6b --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/HttpClientFactoryProvider.java @@ -0,0 +1,31 @@ +package cc.magicjson.caller.infrastructure.http; + +import cc.magicjson.caller.infrastructure.http.config.HttpClientConfig; +import cc.magicjson.caller.infrastructure.http.factory.ApacheHttpClientFactory; +import cc.magicjson.caller.infrastructure.http.factory.HttpClientFactory; +import cc.magicjson.caller.infrastructure.http.factory.OkHttpClientFactory; +import org.springframework.stereotype.Component; + +/** + * HTTP 客户端工厂提供器 + * + * @author MagicJson + * @since 1.0.0 + */ +@Component +public class HttpClientFactoryProvider { + + private final HttpClientConfig config; + + public HttpClientFactoryProvider(HttpClientConfig config) { + this.config = config; + } + + public HttpClientFactory getHttpClientFactory() { + return switch (config.getType()) { + case APACHE -> new ApacheHttpClientFactory(config); + case OKHTTP -> new OkHttpClientFactory(config); + // 在这里添加其他 HTTP 客户端类型的 case + }; + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/config/HttpClientConfig.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/config/HttpClientConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..0e6fd45cdfdd802e56f6c7fdbe411e7404c87915 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/config/HttpClientConfig.java @@ -0,0 +1,26 @@ +package cc.magicjson.caller.infrastructure.http.config; + +import cc.magicjson.caller.infrastructure.http.type.HttpClientType; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + + +/** + * http客户端配置类 + * + * @author MagicJson + * @since 1.0.0 + */ +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "http.client") +public class HttpClientConfig { + private HttpClientType type = HttpClientType.APACHE; + private int maxTotal = 100; + private int defaultMaxPerRoute = 20; + private int connectTimeout = 5000; + private int socketTimeout = 65000; +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/config/RestTemplateConfig.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/config/RestTemplateConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..75034738504e567207096d81bbeb06ff037b1b63 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/config/RestTemplateConfig.java @@ -0,0 +1,26 @@ +package cc.magicjson.caller.infrastructure.http.config; + + +import cc.magicjson.caller.infrastructure.http.HttpClientFactoryProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +/** + * RestTemplate 配置类 + * + * @author MagicJson + * @since 1.0.0 + */ +@Configuration +@RequiredArgsConstructor +public class RestTemplateConfig { + + private final HttpClientFactoryProvider httpClientFactoryProvider; + + @Bean + public RestTemplate restTemplate() { + return httpClientFactoryProvider.getHttpClientFactory().createRestTemplate(); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/factory/ApacheHttpClientFactory.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/factory/ApacheHttpClientFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..716a1f601377068543305edb7c740f3f4e83f1fe --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/factory/ApacheHttpClientFactory.java @@ -0,0 +1,43 @@ +package cc.magicjson.caller.infrastructure.http.factory; + +import cc.magicjson.caller.infrastructure.http.config.HttpClientConfig; +import lombok.RequiredArgsConstructor; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.core5.util.Timeout; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +/** + * Apache HttpClient 5 的工厂实现 + * + * @author MagicJson + * @since 1.0.0 + */ +@Component +@ConditionalOnProperty(name = "http.client.type", havingValue = "apache", matchIfMissing = true) +@RequiredArgsConstructor +public class ApacheHttpClientFactory implements HttpClientFactory { + + private final HttpClientConfig config; + + @Override + public RestTemplate createRestTemplate() { + PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); + connectionManager.setMaxTotal(config.getMaxTotal()); + connectionManager.setDefaultMaxPerRoute(config.getDefaultMaxPerRoute()); + + CloseableHttpClient httpClient = HttpClients.custom() + .setConnectionManager(connectionManager) + .build(); + + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); + requestFactory.setConnectTimeout(Timeout.ofMilliseconds(config.getConnectTimeout()).toDuration()); + requestFactory.setConnectionRequestTimeout(Timeout.ofMilliseconds(config.getSocketTimeout()).toDuration()); + + return new RestTemplate(requestFactory); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/factory/HttpClientFactory.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/factory/HttpClientFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..41d1acf701e394d5711996cb93bd2c36b821fe86 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/factory/HttpClientFactory.java @@ -0,0 +1,19 @@ +package cc.magicjson.caller.infrastructure.http.factory; + +import org.springframework.web.client.RestTemplate; + +/** + * HTTP 客户端工厂接口 + * 用于创建 RestTemplate 实例 + * + * @author MagicJson + * @since 1.0.0 + */ +public interface HttpClientFactory { + /** + * 创建 RestTemplate 实例 + * + * @return 配置好的 RestTemplate + */ + RestTemplate createRestTemplate(); +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/factory/OkHttpClientFactory.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/factory/OkHttpClientFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..4e6d08bad95a64ac11306ff8c0358965be30fd58 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/factory/OkHttpClientFactory.java @@ -0,0 +1,55 @@ +package cc.magicjson.caller.infrastructure.http.factory; + +import cc.magicjson.caller.infrastructure.http.config.HttpClientConfig; +import cc.magicjson.caller.infrastructure.http.request.OkHttpClientHttpRequest; +import lombok.RequiredArgsConstructor; +import okhttp3.OkHttpClient; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.TimeUnit; + +/** + * OkHttp 的工厂实现 + * + * @author MagicJson + * @since 1.0.0 + */ +@Component +@ConditionalOnProperty(name = "http.client.type", havingValue = "okhttp") +@RequiredArgsConstructor +public class OkHttpClientFactory implements HttpClientFactory { + + private final HttpClientConfig config; + + @Override + public RestTemplate createRestTemplate() { + OkHttpClient okHttpClient = new OkHttpClient.Builder() + .connectTimeout(config.getConnectTimeout(), TimeUnit.MILLISECONDS) + .readTimeout(config.getSocketTimeout(), TimeUnit.MILLISECONDS) + .writeTimeout(config.getSocketTimeout(), TimeUnit.MILLISECONDS) + .build(); + + ClientHttpRequestFactory requestFactory = new OkHttpClientHttpRequestFactory(okHttpClient); + return new RestTemplate(requestFactory); + } + + /** + * 自定义的 ClientHttpRequestFactory,使用 OkHttpClient + */ + private record OkHttpClientHttpRequestFactory(OkHttpClient okHttpClient) implements ClientHttpRequestFactory { + + @NotNull + @Override + public ClientHttpRequest createRequest(@NotNull URI uri, @NotNull HttpMethod httpMethod){ + return new OkHttpClientHttpRequest(okHttpClient, uri, httpMethod); + } + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/request/OkHttpClientHttpRequest.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/request/OkHttpClientHttpRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..a3ebad0c928cbc41277ad1c434a814da9a7c24f1 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/request/OkHttpClientHttpRequest.java @@ -0,0 +1,85 @@ +package cc.magicjson.caller.infrastructure.http.request; + +import cc.magicjson.caller.infrastructure.http.response.OkHttpClientHttpResponse; +import okhttp3.*; +import org.jetbrains.annotations.NotNull; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.util.List; +import java.util.Map; + +/** + * OkHttp 的 ClientHttpRequest 实现 + * + * @author MagicJson + * @since 1.0.0 + */ +public class OkHttpClientHttpRequest implements ClientHttpRequest { + + private final OkHttpClient client; + private final URI uri; + private final HttpMethod method; + private final HttpHeaders headers; + private final ByteArrayOutputStream bodyStream; + + public OkHttpClientHttpRequest(OkHttpClient client, URI uri, HttpMethod method) { + this.client = client; + this.uri = uri; + this.method = method; + this.headers = new HttpHeaders(); + this.bodyStream = new ByteArrayOutputStream(1024); + } + + @NotNull + @Override + public HttpMethod getMethod() { + return method; + } + + @NotNull + @Override + public URI getURI() { + return uri; + } + + @NotNull + @Override + public HttpHeaders getHeaders() { + return headers; + } + + @NotNull + @Override + public OutputStream getBody() throws IOException { + return bodyStream; + } + + @NotNull + @Override + public ClientHttpResponse execute() throws IOException { + byte[] bytes = bodyStream.toByteArray(); + RequestBody requestBody = RequestBody.create(bytes, null); + + Request.Builder requestBuilder = new Request.Builder() + .url(uri.toURL()) + .method(method.name(), method == HttpMethod.GET || method == HttpMethod.HEAD ? null : requestBody); + + for (Map.Entry> entry : headers.entrySet()) { + for (String value : entry.getValue()) { + requestBuilder.addHeader(entry.getKey(), value); + } + } + + Request request = requestBuilder.build(); + Response response = client.newCall(request).execute(); + + return new OkHttpClientHttpResponse(response); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/response/OkHttpClientHttpResponse.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/response/OkHttpClientHttpResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..2cfe064190ae335b85e5a6b52d04b55c10e82dd0 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/response/OkHttpClientHttpResponse.java @@ -0,0 +1,53 @@ +package cc.magicjson.caller.infrastructure.http.response; + +import okhttp3.Response; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; +import java.io.InputStream; + +/** + * OkHttp 的 ClientHttpResponse 实现 + * + * @author MagicJson + * @since 1.0.0 + */ +public class OkHttpClientHttpResponse implements ClientHttpResponse { + + private final Response response; + + public OkHttpClientHttpResponse(Response response) { + this.response = response; + } + + @Override + public HttpStatus getStatusCode() throws IOException { + return HttpStatus.valueOf(response.code()); + } + + @Override + public String getStatusText() throws IOException { + return response.message(); + } + + @Override + public void close() { + response.close(); + } + + @Override + public InputStream getBody() throws IOException { + return response.body().byteStream(); + } + + @Override + public HttpHeaders getHeaders() { + HttpHeaders headers = new HttpHeaders(); + for (String name : response.headers().names()) { + headers.addAll(name, response.headers(name)); + } + return headers; + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/type/HttpClientType.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/type/HttpClientType.java new file mode 100644 index 0000000000000000000000000000000000000000..f0f386d5a2e61b6c2a2d00dd540a634bcbe003da --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/http/type/HttpClientType.java @@ -0,0 +1,13 @@ +package cc.magicjson.caller.infrastructure.http.type; + +/** + * HTTP 客户端类型枚举类 + * + * @author MagicJson + * @since 1.0.0 + */ +public enum HttpClientType { + APACHE, + OKHTTP + // 可以在此添加其他 HTTP 客户端类型 +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/openapi/OpenApiConfig.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/openapi/OpenApiConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..5305d8e5f885cf89826108dca5658b687dab0d9c --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/infrastructure/openapi/OpenApiConfig.java @@ -0,0 +1,39 @@ +package cc.magicjson.caller.infrastructure.openapi; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * OpenAPI 配置类 + * + * @author MagicJson + * @since 1.0.0 + */ +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Spring Boot 3 API 示例") + .version("1.0") + .description("这是一个使用 Spring Boot 3 和 SpringDoc 的 API 文档示例") + .termsOfService("http://swagger.io/terms/") + .contact(new Contact().name("API 支持团队").email("support@example.com")) + .license(new License().name("Apache 2.0").url("http://springdoc.org"))); + } + + @Bean + public GroupedOpenApi publicApi() { + return GroupedOpenApi.builder() + .group("springshop-public") + .pathsToMatch("/**") + .build(); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/interfaces/rest/DynamicServiceController.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/interfaces/rest/DynamicServiceController.java new file mode 100644 index 0000000000000000000000000000000000000000..878cf167811466b57bc9a4008157da5b55e327bf --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/interfaces/rest/DynamicServiceController.java @@ -0,0 +1,50 @@ +package cc.magicjson.caller.interfaces.rest; + +import cc.magicjson.caller.application.service.DynamicServiceCaller; + +import cc.magicjson.caller.domain.discovery.ServiceDiscovery; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * 服务调用控制器 + * + * @author MagicJson + * @since 1.0.0 + */ +@RequiredArgsConstructor +@RestController +public class DynamicServiceController { + + private final DynamicServiceCaller serviceCaller; + private final ServiceDiscovery serviceDiscovery; + + /** + * 服务调用 + * + * @param request 服务调用请求 包含服务名称、端点名称、请求参数、URI变量 + * @return 对应服务调用的响应 + */ + @PostMapping("/call") + public ResponseEntity callService(@RequestBody ServiceCallRequest request) { + // 检查服务是否存在 + if (serviceDiscovery.getService(request.getServiceName()).isEmpty()) { + return ResponseEntity.badRequest().body("Service not found: " + request.getServiceName()); + } + if (serviceDiscovery.getEndpoint(request.getServiceName(), request.getEndpointName()).isEmpty()) { + return ResponseEntity.badRequest().body("Endpoint not found: " + request.getServiceName()); + } + + Object response = serviceCaller.callService( + request.getServiceName(), + request.getEndpointName(), + request.getPayload(), + Object.class, + request.getUriVariables() + ); + return ResponseEntity.ok(response); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/interfaces/rest/ServiceCallRequest.java b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/interfaces/rest/ServiceCallRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..896125e50b2e7a28e501cabd5c39a2780209aa43 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/java/cc/magicjson/caller/interfaces/rest/ServiceCallRequest.java @@ -0,0 +1,22 @@ +package cc.magicjson.caller.interfaces.rest; + +import lombok.Getter; +import lombok.Setter; + +import java.util.Map; + +/** + * 服务调用请求对象 + * 这个类定义了动态服务调用所需的参数 + * + * @author MagicJson + * @since 1.0.0 + */ +@Getter +@Setter +public class ServiceCallRequest { + private String serviceName; // 要调用的服务名称 + private String endpointName; // 要调用的端点名称 + private Object payload; // 请求负载 + private Map uriVariables; // URL 路径变量 +} diff --git a/quick-landing/dynamic-rest-caller/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/quick-landing/dynamic-rest-caller/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000000000000000000000000000000000..3c5c0bb1f55a598cc6d5b14acfc62cd2f87064d1 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +cc.magicjson.caller.infrastructure.autoconfigure.ServiceDiscoveryAutoConfiguration + diff --git a/quick-landing/dynamic-rest-caller/src/main/resources/application.yml b/quick-landing/dynamic-rest-caller/src/main/resources/application.yml new file mode 100644 index 0000000000000000000000000000000000000000..5e493b575c8b07847e76fcb19dfc31594ede80f9 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/main/resources/application.yml @@ -0,0 +1,25 @@ +spring: + application: + name: dynamic-rest-caller +springdoc: + api-docs: + path: /v3/api-docs + enabled: true + swagger-ui: + path: /swagger-ui.html + use-management-port: false + use-javadoc: true + + + +http: + client: + type: APACHE # 或者 OKHTTP + max-total: 100 # 连接池最大连接数 + default-max-per-route: 20 # 每个路由的最大连接数 + connect-timeout: 5000 # 连接超时时间(毫秒) + socket-timeout: 65000 # Socket 读取超时时间(毫秒) + +# 服务发现配置 +service-discovery: + type: simple # 服务发现类型,可以是 'simple'、'nacos'、'zookeeper' 等 diff --git a/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/application/service/DynamicServiceCallerTest.java b/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/application/service/DynamicServiceCallerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..5837ea0a52f782e1c191c59368ca7fc1183e11c3 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/application/service/DynamicServiceCallerTest.java @@ -0,0 +1,113 @@ +package cc.magicjson.caller.application.service; + + +import cc.magicjson.caller.domain.model.Endpoint; +import cc.magicjson.caller.domain.model.Service; +import cc.magicjson.caller.domain.discovery.ServiceDiscovery; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class DynamicServiceCallerTest { + + @Mock + private ServiceDiscovery serviceDiscovery; + + @Mock + private RestTemplate restTemplate; + + @InjectMocks + private DynamicServiceCaller serviceCaller; + + /** + * 测试成功调用服务的场景 + */ + @Test + public void callServiceShouldSucceed() { + // 准备测试数据 + String serviceName = "testService"; + String endpointName = "testEndpoint"; + String request = "testRequest"; + Map uriVars = Map.of("id", "123"); + + Service service = new Service(serviceName, "http://test.com"); + Endpoint endpoint = new Endpoint(endpointName, "/api/test/{id}", "GET"); + + // 配置模拟行为 + when(serviceDiscovery.getService(serviceName)).thenReturn(Optional.of(service)); + when(serviceDiscovery.getEndpoints(serviceName)).thenReturn(List.of(endpoint)); + + ResponseEntity expectedResponse = ResponseEntity.ok("testResponse"); + when(restTemplate.exchange( + eq("http://test.com/api/test/{id}"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(uriVars) + )).thenReturn(expectedResponse); + + // 执行测试 + String response = serviceCaller.callService(serviceName, endpointName, request, String.class, uriVars); + + // 验证结果 + assertEquals("testResponse", response, "响应应匹配预期值"); + verify(serviceDiscovery).getService(serviceName); + verify(serviceDiscovery).getEndpoints(serviceName); + verify(restTemplate).exchange( + eq("http://test.com/api/test/{id}"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(uriVars) + ); + } + + /** + * 参数化测试:验证异常情况 + */ + @ParameterizedTest + @MethodSource("exceptionTestCases") + public void callServiceShouldThrowException(String serviceName, String endpointName, + Optional serviceOpt, + List endpoints, + Class expectedException) { + // 配置模拟行为 + when(serviceDiscovery.getService(serviceName)).thenReturn(serviceOpt); + when(serviceDiscovery.getEndpoints(serviceName)).thenReturn(endpoints); + + // 执行测试并验证结果 + assertThrows(expectedException, () -> + serviceCaller.callService(serviceName, endpointName, "request", String.class, Map.of())); + } + + /** + * 提供异常测试用例 + */ + private static Stream exceptionTestCases() { + return Stream.of( + Arguments.of("nonExistentService", "testEndpoint", Optional.empty(), List.of(), IllegalArgumentException.class), + Arguments.of("testService", "nonExistentEndpoint", Optional.of(new Service("testService", "http://test.com")), List.of(), IllegalArgumentException.class) + ); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/infrastructure/http/HttpClientFactoryProviderTest.java b/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/infrastructure/http/HttpClientFactoryProviderTest.java new file mode 100644 index 0000000000000000000000000000000000000000..32a31f698755a5c499d0fb3ce771cfe347fc1056 --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/infrastructure/http/HttpClientFactoryProviderTest.java @@ -0,0 +1,49 @@ +package cc.magicjson.caller.infrastructure.http; + +import cc.magicjson.caller.infrastructure.http.config.HttpClientConfig; +import cc.magicjson.caller.infrastructure.http.factory.ApacheHttpClientFactory; +import cc.magicjson.caller.infrastructure.http.factory.HttpClientFactory; +import cc.magicjson.caller.infrastructure.http.factory.OkHttpClientFactory; +import cc.magicjson.caller.infrastructure.http.type.HttpClientType; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class HttpClientFactoryProviderTest { + + @Mock + private HttpClientConfig config; + + @InjectMocks + private HttpClientFactoryProvider provider; + + /** + * 测试获取工厂方法 + * 验证不同HTTP客户端类型是否返回正确的工厂实例 + * @param type HTTP客户端类型 + */ + @ParameterizedTest + @EnumSource(HttpClientType.class) + public void getFactoryShouldMatchType(HttpClientType type) { + // 设置模拟配置返回指定类型 + when(config.getType()).thenReturn(type); + + // 获取工厂实例 + HttpClientFactory factory = provider.getHttpClientFactory(); + + // 根据类型确定期望的工厂类 + Class expectedClass = type == HttpClientType.APACHE ? + ApacheHttpClientFactory.class : OkHttpClientFactory.class; + + // 验证返回的工厂实例类型是否符合预期 + assertTrue(expectedClass.isInstance(factory), + String.format("工厂实例应为 %s 类型", expectedClass.getSimpleName())); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/infrastructure/http/config/RestTemplateConfigTest.java b/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/infrastructure/http/config/RestTemplateConfigTest.java new file mode 100644 index 0000000000000000000000000000000000000000..e0a54ebc6b46898aa60814e44254723675dbef7b --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/infrastructure/http/config/RestTemplateConfigTest.java @@ -0,0 +1,49 @@ +package cc.magicjson.caller.infrastructure.http.config; + + +import cc.magicjson.caller.infrastructure.http.HttpClientFactoryProvider; +import cc.magicjson.caller.infrastructure.http.factory.HttpClientFactory; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.web.client.RestTemplate; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class RestTemplateConfigTest { + + @Mock + private HttpClientFactoryProvider factoryProvider; + + @Mock + private HttpClientFactory clientFactory; + + @InjectMocks + private RestTemplateConfig restTemplateConfig; + + /** + * 测试RestTemplate的创建过程 + * 验证是否正确使用HttpClientFactory创建RestTemplate + */ + @Test + public void createRestTemplateShouldUseFactory() { + // 准备测试数据 + RestTemplate expectedTemplate = new RestTemplate(); + + // 设置模拟行为 + when(factoryProvider.getHttpClientFactory()).thenReturn(clientFactory); + when(clientFactory.createRestTemplate()).thenReturn(expectedTemplate); + + // 执行测试 + RestTemplate actualTemplate = restTemplateConfig.restTemplate(); + + // 验证结果 + assertSame(expectedTemplate, actualTemplate, "应返回由HttpClientFactory创建的RestTemplate"); + verify(factoryProvider).getHttpClientFactory(); + verify(clientFactory).createRestTemplate(); + } +} diff --git a/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/interfaces/rest/DynamicServiceControllerIntegrationTest.java b/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/interfaces/rest/DynamicServiceControllerIntegrationTest.java new file mode 100644 index 0000000000000000000000000000000000000000..05296383b0a73d6059c28b050fc54bc642697b9f --- /dev/null +++ b/quick-landing/dynamic-rest-caller/src/test/java/cc/magicjson/caller/interfaces/rest/DynamicServiceControllerIntegrationTest.java @@ -0,0 +1,92 @@ +package cc.magicjson.caller.interfaces.rest; + +import cc.magicjson.caller.application.service.DynamicServiceCaller; + +import cc.magicjson.caller.domain.model.Endpoint; +import cc.magicjson.caller.domain.model.Service; +import cc.magicjson.caller.domain.discovery.ServiceDiscovery; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(DynamicServiceController.class) +public class DynamicServiceControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private DynamicServiceCaller serviceCaller; + + @MockBean + private ServiceDiscovery serviceDiscovery; + + /** + * 测试有效请求的服务调用 + */ + @Test + public void callServiceShouldSucceed() throws Exception { + // 准备测试数据 + String serviceName = "testService"; + String endpointName = "testEndpoint"; + Map uriVars = Map.of("id", "123"); + + ServiceCallRequest request = new ServiceCallRequest(); + request.setServiceName(serviceName); + request.setEndpointName(endpointName); + request.setPayload("testPayload"); + request.setUriVariables(uriVars); + + String expectedResponse = "Test response"; + + // 配置模拟行为 + when(serviceDiscovery.getService(serviceName)).thenReturn(Optional.of(new Service(serviceName, "http://test.com"))); + when(serviceDiscovery.getEndpoints(serviceName)).thenReturn(List.of(new Endpoint(endpointName, "/api/test", "GET"))); + when(serviceCaller.callService(eq(serviceName), eq(endpointName), any(), eq(Object.class), eq(uriVars))) + .thenReturn(expectedResponse); + + // 执行测试并验证结果 + mockMvc.perform(post("/call") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(content().string(expectedResponse)); + } + + /** + * 测试服务不存在时的错误处理 + */ + @Test + public void callServiceShouldFailWhenServiceNotFound() throws Exception { + // 准备测试数据 + ServiceCallRequest request = new ServiceCallRequest(); + request.setServiceName("nonExistentService"); + request.setEndpointName("testEndpoint"); + + // 配置模拟行为 + when(serviceDiscovery.getService("nonExistentService")).thenReturn(Optional.empty()); + + // 执行测试并验证结果 + mockMvc.perform(post("/call") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().string("Service not found: nonExistentService")); + } +} diff --git a/quick-landing/pom.xml b/quick-landing/pom.xml index 46f86b9c93b49b358094060ae74040287cc3404b..7dfb4caf567730eef5152f37aa67153ca011b91c 100644 --- a/quick-landing/pom.xml +++ b/quick-landing/pom.xml @@ -13,6 +13,19 @@ quick-landing https://gitee.com/MagicJson/learning-training.git - + + dynamic-rest-caller + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + com.github.therapi + therapi-runtime-javadoc + + +