# ycAPI平台 **Repository Path**: yichenwun/yc-app-platform ## Basic Information - **Project Name**: ycAPI平台 - **Description**: 一个API开放平台 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2024-07-29 - **Last Updated**: 2024-09-05 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## API 开放平台 --- #### 一、项目概述 > 本项目是一个面向开发者的 API 平台,提供 API 接口供开发者调用。用户通过注册登录,可以开通接口调用权限,并可以浏览和调用接口。每次调用都会进行统计,用户可以根据统计数据进行分析和优化。管理员可以发布接口、下线接口、接入接口,并可视化接口的调用情况和数据。 #### 二、项目介绍 **一个提供 API 接口供开发者调用的平台:** 管理员可以发布接口,同时统计分析各接口的调用情况,用户可以注册登录并开通接口调用权限,浏览接口以及在线进行调试,并可以使用 **SDK** 轻松地在代码中调用接口。 **使用自己开发的客户端 SDK,一行代码调用接口:** img ![](API 开放平台/API 开放平台/1685607960011-5617fabe-61de-4828-a10b-ca5d3a1bc787.png) #### 三、项目业务流程 ##### 流程概述 **5个子系统:** 1. **模拟接口系统**:提供各种模拟接口供开发者使用和测试,例如,提供一个随机头像生成接口。 2. **后台管理系统**:管理员可以发布接口、设置接口的调用数量、设定是否下线接口等功能,以及查看用户使用接口的情况,例如使用次数,错误调用等。 3. **用户前台系统**:提供一个访问界面,供开发者浏览所有的接口,可以购买或开通接口,并获得一定量的调用次数。 4. **API 网关系统**:负责接口的流量控制,计费统计,安全防护等功能,提供一致的接口服务质量,和简化 API 的管理工作。 5. **第三方调用 SDK 系统**:提供一个简化的工具包,使得开发者可以更方便地调用接口,例如提供预封装的 HTTP 请求方法、接口调用示例等。 **关键问题:** 1. **接口设计**:需要设计清晰易用的 API 接口,并且提供详细的接口文档,以方便开发者使用。 2. **性能和可用性**:平台需要承载大量的接口请求,因此需要考虑到性能和可用性问题。例如,设计高效的数据存储和检索策略,确保 API 网关的高性能等。 3. **安全**:平台需要防止各种安全攻击,例如 DDOS 攻击,也需要保护用户的隐私和数据安全。 4. **计费和流量控制**:需要设计合理的计费策略和流量控制机制,以确保平台的稳定运行和收入来源。 5. **易用性和用户体验**:需要为开发者提供简单易用的接口调用工具和友好的用户界面,提供优质的用户体验。 ##### 架构图 image-20240729140201693 **什么是 SDK?** >SDK 是软件开发工具包的缩写,是一种为软件开发者提供支持的一系列工具、接口和规范的集合。举个例子,比如腾讯云提供了一些接口,比如创建 VPC 等,如果开发者直接向腾讯云服务器发请求,需要输入密钥、做签名认证等操作,非常繁琐。因此,在构建第三方 API 平台时,一般都会提供一套 SDK,**让使用者能够轻松地调用接口,无需自己编写和封装 HTTP 请求**。可以把 SDK 理解为 Java 语法中的工具包,使用者只需要最少量的代码即可调用接口 #### 四、项目选型 - Java Spring Boot - MySQL 数据库 - MyBatis-Plus 及 MyBatis X - API 签名认证(Http 调用) - Spring Boot Starter(SDK 开发) - Dubbo 分布式(RPC、Nacos) - Swagger + Knife4j 接口文档生成 - Spring Cloud Gateway 微服务网关 - Hutool、Apache Common Utils、Gson 等工具库 #### 五、项目实现 ##### 1. 后端项目初始化 **数据库表设计:** ```sql -- 接口信息 create table if not exists `interface_info` ( `id` bigint not null auto_increment primary key comment '主键', `name` varchar(256) not null comment '名称', `description` varchar(256) null comment '描述', `url` varchar(512) not null comment '接口地址', `requestParams` text null comment '请求参数', `requestHeader` text null comment '请求头', `responseHeader` text null comment '响应头', `status` int default 0 not null comment '接口状态(0-关闭,1-开启)', `method` varchar(256) not null comment '请求类型', `userId` bigint not null comment '创建人', `createTime` datetime default CURRENT_TIMESTAMP not null comment '创建时间', `updateTime` datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', `isDelete` tinyint default 0 not null comment '是否删除(0-未删, 1-已删)' ) comment '接口信息'; ``` ------ 表crud补充 ##### 2. 模拟 (新建) 接口项目 先来真实的发布一个给开发者使用的接口,创建一个模拟接口项目 ycAPI-interface image-20240730002044316 **提供三个不同种类的模拟接口:** 1. GET 接口 2. POST 接口(url 传参) 3. POST 接口(Restful) ```java @RestController @RequestMapping("name") public class NameController { @GetMapping("/") public String getNameByGet(String name) { return "GET 你的名字是" + name; } @PostMapping("/") public String getNameByPost(@RequestParam String name) { return "POST 你的名字是" + name; } @PostMapping("/user") public String getUserNameByPost(@RequestBody User user) { return "POST 用户名字是" + user.getUsername(); } } ``` ---- 测试调用接口 ###### 2.1 调用接口的方式 HTTP 调用方式: 1. HttpClient 2. RestTemplate 3. 第三方库(OKHTTP、Hutool √ ) **采用 Hutool 实现:** 1. 创建一个`client层`:客户端层,负责与用户交互、处理用户请求,以及调用服务端提供的 API 接口等任务的部分。 ```java /** * 调用第三方接口的客户端 * */ public class YcApiClient { public String getNameByGet(String name) { } public String getNameByPost(@RequestParam String name) { } public String getUserNameByPost(@RequestBody User user) { } } ``` 2. 调用外部接口 >支持 get 和 post 的请求 ```java GET: // 最简单的HTTP请求,可以自动通过header等信息判断编码,不区分HTTP和HTTPS String result1= HttpUtil.get("https://www.baidu.com"); // 当无法识别页面编码的时候,可以自定义请求页面的编码 String result2= HttpUtil.get("https://www.baidu.com", CharsetUtil.CHARSET_UTF_8); //可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中 HashMap paramMap = new HashMap<>(); paramMap.put("city", "北京"); String result3= HttpUtil.get("https://www.baidu.com", paramMap); ``` ```java POST: HashMap paramMap = new HashMap<>(); paramMap.put("city", "北京"); String result= HttpUtil.post("https://www.baidu.com", paramMap); ``` >支持JSON类型的请求 ```java Restful请求: String json = ...; String result2 = HttpRequest.post(url) .body(json) .execute().body(); ``` ```java /** * 调用第三方接口的客户端 */ public class YcApiClient { public static final String GLOBAL_URL_PREFIX = "http://localhost:8112/api/name/"; public String getNameByGet(String name) { //可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中 HashMap paramMap = new HashMap<>(); paramMap.put("name", "yichen"); String result = HttpUtil.get(GLOBAL_URL_PREFIX, paramMap); System.out.println("GET:" + result); return result; } public String getNameByPost(@RequestParam String name) { HashMap paramMap = new HashMap<>(); paramMap.put("name", "yichen"); String result = HttpUtil.post(GLOBAL_URL_PREFIX, paramMap); System.out.println("POST:" + result); return result; } public String getUserNameByPost(@RequestBody User user) { String json = JSONUtil.toJsonStr(user.getUserName()); String url = GLOBAL_URL_PREFIX + "/user"; String result = HttpRequest.post(url) .body(json) .execute().body(); System.out.println("POST Restful:" + result); return result; } } ``` ---- 测试 ##### 3. API 签名认证 >为接口设置保护措施,例如限制每个用户每秒只能调用十次接口,即实施请求频次的限额控制。如果在后期,你的业务扩大,可能还需要收费。因此,我们必须知道谁在调用接口,并且不能让无权限的人随意调用。 >现在,我们需要设计一个方法,来确定谁在调用接口。在我们之前开发后端时,我们会进行一些权限检查。例如,当管理员执行删除操作时,后端需要检查这个用户是否为管理员。那么,我们如何获取用户信息呢?是否直接从后端的 session 中获取?但问题来了,当我们**调用接口时**,**我们有 session 吗?**比如说,我是前端直接发起请求,**我没有登录操作,我没有输入用户名和密码,我怎么去调用呢?**因此,一般情况下,我们会采用一个叫API签名认证的机制。 API 签名认证主要包括两个过程 : 1. 签发签名 2. 使用签名(校验签名) 这就像一些短信接口的 key 一样 >为什么我们需要API签名认证呢? > >第一,为了保证安全性,不能让任何人都能调用接口。那么,我们如何在后端实现签名认证呢?我们需要两个东西,即 accessKey 和 secretKey。这和用户名和密码类似,不过**每次调用接口都需要带上,实现无状态的请求。**这样,**即使你之前没来过,只要这次的状态正确,你就可以调用接口。**所以我们需要这两个东西来**标识用户**。 总结: 1. **保证安全性**,不能随便一个人调用 2. 适用于**无需保存登录态的场景**。只认签名,**不关注用户登录态**。 **签名认证实现** 通过 http **request header** 头传递参数。 - 参数 1:**accessKey**:调用的标识 userA, userB(复杂、无序、无规律) - 参数 2:**secretKey**:密钥(复杂、无序、无规律)**该参数不能放到请求头中** >为什么需要两个key? > >如果仅凭一个key就可以调用接口,那么**任何拿到这个key的人都可以无限制地调用这个接口**。这就好比,为什么你在登录网站时需要输入密码,而不是只输入用户名就可以了? >其实这两者的原理是一样的。如果像token一样,一个key不行吗?token本质上也是不安全的,有可能会通过重放等等方式来攻破的。 (类似用户名和密码,区别:ak、sk 是无状态的) **!!! 千万不能把密钥直接在服务器之间传递,有可能会被拦截** - 参数 3:用户请求参数 - 参数 4:sign **加密方式:** 对称加密、非对称加密、md5 签名(不可解密) + **用户参数 + 密钥** => **签名生成算法(MD5、HMac、Sha1)** => 不可解密的值 eg: abc + abcdefgh => sajdgdioajdgioa **怎么知道这个签名对不对?** 服务端用一模一样的参数和算法去生成签名,只要和用户传的的一致,就表示一致。 **怎么防重放?** - 参数 5:加 **nonce** 随机数,只能用一次 服务端要保存用过的随机数 - 参数 6:加 **timestamp** 时间戳,校验时间戳是否过期。 **API 签名认证是一个很灵活的设计,具体要有哪些参数、参数名如何一定要根据场景来。** **(比如 userId、appId、version、固定值等)** **实现:** ```java -- 用户表 create table if not exists user ( id bigint auto_increment comment 'id' primary key, userAccount varchar(256) not null comment '账号', userPassword varchar(512) not null comment '密码', userName varchar(256) null comment '用户昵称', userAvatar varchar(1024) null comment '用户头像', userProfile varchar(512) null comment '用户简介', userRole varchar(256) default 'user' not null comment '用户角色:user/admin/ban', accessKey varchar(512) not null comment '通行证', secretKey varchar(512) not null comment '密钥', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', isDelete tinyint default 0 not null comment '是否删除', index idx_Id (id) ) comment '用户' collate = utf8mb4_unicode_ci; ``` >我们需要获取用户传递的 accessKey 和 secretKey。对于这种数据,建议不要直接在 URL 中传递,而是选择在请求头中传递会更为妥当。因为 **GET 请求的 URL 存在最大长度限制**,如果你传递的其他参数过多,可能会导致关键数据被挤出。因此,建议**从请求头中**获取这些数据。 1. 根据提供的 Key 去数据库中查询,检查此 Key 是否已经被分配过,以及关联的用户是否被禁用、是否合法。现在我们只是简单模拟这一过程。 ```java @RestController @RequestMapping("/name") public class NameController { @PostMapping("/user") public String getUserNameByPost(@RequestBody User user, HttpServletRequest request) { // todo: 从数据库中取出,这里写死为简单模拟 String accessKey = request.getHeader("accessKey"); String secretKey = request.getHeader("secretKey"); if (!accessKey.equals("yichen") || !secretKey.equals("yichenwu")){ throw new RuntimeException("无权限"); } return "POST 用户名字是" + user.getUserName(); } } ``` 2. 改造一下 YuApiClient.java,发请求可以带上 header ```java public String getUserNameByPost(@RequestBody User user) { String json = JSONUtil.toJsonStr(user.getUserName()); String url = GLOBAL_URL_PREFIX + "/user"; String result = HttpRequest.post(url) .addHeaders(getHeaderMap()) .body(json) .execute().body(); System.out.println("POST Restful:" + result); return result; } // 创建一个私有方法,用于构造请求头 private Map getHeaderMap() { // 创建一个新的 HashMap 对象 Map hashMap = new HashMap<>(); // 将 "accessKey" 和其对应的值放入 map 中 hashMap.put("accessKey", accessKey); // 将 "secretKey" 和其对应的值放入 map 中 hashMap.put("secretKey", secretKey); // 返回构造的请求头 map return hashMap; } ``` 目前为止的安全性问题 1: >我们的请求有可能被人拦截,我们将密码放在**请求头中**,如果有中间人拦截到了你的请求,他们就可以直接**从请求头中获取你的密码,然后使用你的密码发送请求。** 解决: > 在标准的 API 签名认证中,我们需要**传递一个签名**。通常我们**不是直接将其传递给后台**,而是**根据该密钥生成一个签名**。**因为密码不能直接在服务器中传递,有可能会被拦截。** > > 所以我们需要对该密码进行**加密**,这里通常称之为签名。我们可以将用户传递的参数(例如ABC参数)与该密钥拼接在一起,然后使用签名算法进行加密。 > > > > 我们的加密算法可以分为: > > 单向加密(md5 签名)、对称加密、非对称加密。 > > 对称加密 : 它是分配一组密钥,你可以加密和解密。 > > 非对称加密 : 你可以使用公钥加密,私钥解密,有些情况下也可以使用私钥加密,公钥解密。 > > **单向加密 : 加密后无法解密。这种是安全性最高的** > > > > **验证:再次使用相同的参数进行生成,并与你传递的参数进行对比,看它们是否一致。** 问题 2: 重放 (重放XHR) >不要相信前端加密。前端的加密是有用的,但是前端的加密不能完全保证安全,所以不要依赖前端。之前说过**所有的请求都是可以重放的**。也就是说,**无法直接加密,无论你如何加密,只要被人拦截了,他们只需使用你传递的加密内容再次发送给后台,结果是一样的**。因此,实际上也不安全。我们现在讨论的这种方法还不是很安全,还存在更复杂的问题。 > >假设你的电脑使用了代理、代理服务,请小心操作。在这种情况下,减少使用一些乱七八糟的操作,尤其是在公司进行开发时尽量少用代理。 > >如果是一个代理软件,他如果想搞你的话,你只要发出请求走了他的代理,他点一下重放就好了。 > >**添加时间戳也不能防止重放攻击** image-20240729195350811 解决: >第一种方式是通过**加入一个随机数实现标准的签名认证**。 > >每次请求时,发送一个随机数给后端。**后端只接受并认可该随机数一次**,一旦随机数被使用过,后端将不再接受相同的随机数。 > >这种方式解决了请求重放的问题,然而,这种方法**需要后端额外开发**来保存已使用的随机数。并且,**如果接口的并发量很大,每次请求都需要一个随机数,那么可能会面临处理百万、千万甚至亿级别请求的情况。**因此,除了使用随机数之外,我们还**需要其他机制来定期清理已使用的随机数**。 > > > >第二种方式是**加入一个时间戳(timestamp)**。 > >每个请求在发送时携带一个时间戳,并且后端会**验证该时间戳是否在指定的时间范围内**,例如不超过10分钟或5分钟。 > >这可以**防止对方使用昨天的请求在今天进行重放**。 > >通过这种方式,我们**可以一定程度上控制随机数的过期时间**。因为后端需要同时验证这两个参数,只要时间戳过期或随机数被使用过,后端会拒绝该请求。因此,**时间戳可以在一定程度上减轻后端保存随机数的负担。通常情况下,这两种方法可以相互配合使用。** 因此,在**标准的签名认证算法中**,建议至少添加以下五个参数:==accessKey、secretKey、sign、nonce(随机数)、timestamp(时间戳)==。 此外,建议将用户请求的其他参数,例如接口中的 name 参数,**也添加到签名中,以增加安全性**。 💡 类似于 HTTPS 协议,**签名认证的本质是确保密码不在服务器之间传输**。因为**任何在服务器之间传输的内容都有可能被拦截。**所以,请记住==密码绝不能在服务器之间传输==。不能错误地认为密码可以在前端传输,千万不要这样做,不要在前端调用时传输这些**敏感信息**。 ##### 4. 开发 SDK >对于具体的随机数生成和签名生成过程,开发者有必要关心吗?显然是不需要的。如果每次都要求开发者编写这么多代码,肯定会让他们感到沮丧😢。因此,我们需要为开发者提供一个易于使用的 SDK,使其能够便捷地调用接口。 **为什么需要 Starter?** 理想情况:开发者只需要关心调用哪些接口、传递哪些参数,就跟调用自己写的代码一样简单。 **开发 starter 的好处**:开发者引入之后,可以直接在 **application.yml 中写配置**,自动创建客户端。 image-20240729233951126 **进一步说明:** 为了方便开发者的调用,我们不能让他们每次都自己编写签名算法,这显然很繁琐。因此,我们需要开发一个简单易用的 SDK,使开发者只需关注调用哪些接口、传递哪些参数,就像调用自己编写的代码一样简单。实际上,RPC(远程过程调用)就是为了实现这一目的而设计的。类似的例子是小程序开发或者调用第三方 API,如腾讯云的 API,它们都提供了相应的 SDK。 1. **创建SDK项目** image-20240729234222783 2. **删掉 maven 构建项目的方式,我们现在是要构建依赖包,而不是直接运行 jar 包的项目** image-20240729234555108 3. 我们希望用户能够通过引入 starter 的方式直接使用客户端,而不需要手动创建,所以我们需要**编写一个配置类** image-20240729234855868 4. 给用户**提供 ApiClient**,把 yuapi-interface 项目中的 client包、model包、utils包复制。 image-20240729235331242 5. 去掉多余的 spring **注解** 6. 生成**客户端**的方法 ```java @Configuration // 能够读取application.yml的配置,读取到配置之后,把这个读到的配置设置到我们这里的属性中, // 这里给所有的配置加上前缀为"ycapi.client" @ConfigurationProperties("ycapi.client") @Data // @ComponentScan 注解用于自动扫描组件,使得 Spring 能够自动注册相应的 Bean @ComponentScan public class YcApiClientConfig { private String accessKey; private String secretKey; @Bean public YcApiClient ycApiClient() { return new YcApiClient(accessKey, secretKey); } } ``` 7. 在 resources 目录下创建一个目录`META-INF` ( 注意要大写 ) ​ 在`META-INF`目录创建一个文件`spring.factories` image-20240730000144207 在 `spring.factories`文件内编写配置项为自动引入配置的类。 ![image-20240730000220545](.\API 开放平台\image-20240730000220545.png) >上述配置项指定了要自动配置的类 com.yichen.ycapiclientSDK.YcApiClientConfig,它是我们刚刚编写的配置类。通过在 spring.factories 文件中配置我们的配置类,Spring Boot 将会在应用启动时自动加载和实例化 YuApiClientConfig,并将其应用于我们的应用程序中。这样,我们就可以使用自动配置生成的 ycApiClient 对象,而无需手动创建和配置。 8. 安装为本地的依赖 (下在了本地的maven仓库中) image-20240730000414091 9. 把**模拟接口**中之前的 client包、model包、utils包全删了 10. 引入我们写好的 SDK 依赖、 ```xml com.yichen ycAPI-client-SDK 0.0.1 ``` 11. **模拟接口**中 application.yml 进行配置,能够自动识别 SDK 中的配置 image-20240730001202946 ------ 测试 之所以能够在写配置时看到提示,就是因为这个 spring-configuration-metadata.json image-20240730001821279 这东西怎么生成的呢? image-20240730001916072 ##### 5. 接口发布和下线功能 **发布接口(仅管理员可操作)** 1. 校验该接口是否存在 2. 判断该接口是否可以调用 3. 修改接口数据库中的状态字段为 1 ```java /** * 发布接口 * * @param onOffLineInterfaceRequest * @return */ @PostMapping("/onLine") @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) public BaseResponse onLineInterfaceInfo(@RequestBody OnOffLineInterfaceRequest onOffLineInterfaceRequest) { if (onOffLineInterfaceRequest == null || onOffLineInterfaceRequest.getId() <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } // 1. 校验该接口是否存在 InterfaceInfo interfaceInfo = interfaceInfoService.getById(onOffLineInterfaceRequest.getId()); ThrowUtils.throwIf(ObjectUtils.isEmpty(interfaceInfo), ErrorCode.PARAMS_ERROR, "接口不存在"); // 2. 判断该接口是否可以调用 // (这里先模拟一下,搞个假数据) com.yichen.ycapiclientSDK.model.User user = new com.yichen.ycapiclientSDK.model.User(); user.setUserName("逸尘"); String userNameByPost = ycApiClient.getUserNameByPost(user); ThrowUtils.throwIf(StringUtils.isBlank(userNameByPost), ErrorCode.NOT_FOUND_ERROR, "接口验证失败"); // 3. 修改接口数据库中的状态字段为 1 interfaceInfo.setStatus(InterfaceStatusEnum.ONLINE.getValue()); boolean result = interfaceInfoService.updateById(interfaceInfo); return ResultUtils.success(result); } ``` **下线接口(仅管理员可操作)** 1. 校验该接口是否存在 2. 修改接口数据库中的状态字段为 0 ##### 6. 后端开发申请签名 (在线调用) 在用户注册impl中添加 accessKey,secretKey 生成规则 ```java String accessKey = DigestUtils.md5DigestAsHex((SALT + userAccount + RandomUtil.randomNumbers(4)).getBytes()); String secretKey = DigestUtils.md5DigestAsHex((SALT + userAccount + RandomUtil.randomNumbers(4)).getBytes()); ``` 💡 在线调用中有一个**关键点**,那就是确定前端向后端发送请求时所需的一些信息,例如请求参数的类型。直接使用 JSON 类型即可。 7. 后端在线接口调用**流程** (**如何将这些请求传递给真实的第三方接口呢?**) image-20240730014056134 >前端在调用接口时,首先将要调用的接口以及请求参数传递给后端,然后后端作为一个中转角色,再向模拟接口发送请求。除了中转功能,后端可能还需要进行一些判断,例如判断前端的测试频率是否过高,或者判断前端是否有权限进行该接口的测试。 在用户进行测试调用时,我们需要告知后端用户的签名信息,这样我们才能判断用户是否具有调用接口的权限。 在这里有三种考虑方式,具体取决于你对用户体验的考虑: - 第一种方式:要求用户**必须具有接口权限**才能进行调用。 - 第二种方式:即使用户没有权限,也允许其进行调用,以便体验接口功能。如果选择进行体验,建议为用户**分配临时的签名**,类似于测试环境,**给予一定数量的调用次数**。这可能需要新增两个字段,例如在数据库中添加一个测试次数字段,稍微复杂一些。 - 第三种方式:可以**直接为每个用户提供几十次调用机会**,这样就简单了。假设你本来给用户提供了 1 万次调用次数,现在你可以直接给每个用户送 50 次。这样做起来更方便,这种方式非常巧妙。 ```java /** * 在线调用接口 * * @param interfaceInfoInvokeRequest * @return */ @PostMapping("/invoke") public BaseResponse offLineInterfaceInfo(@RequestBody InterfaceInfoInvokeRequest interfaceInfoInvokeRequest, HttpServletRequest request) { if (interfaceInfoInvokeRequest == null || interfaceInfoInvokeRequest.getId() <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } String userRequestParams = interfaceInfoInvokeRequest.getUserRequestParams(); // 1. 校验该接口是否存在 InterfaceInfo interfaceInfo = interfaceInfoService.getById(interfaceInfoInvokeRequest.getId()); ThrowUtils.throwIf(ObjectUtils.isEmpty(interfaceInfo), ErrorCode.PARAMS_ERROR, "接口不存在"); // 2. 当前接口是否下线 ThrowUtils.throwIf(interfaceInfo.getStatus().equals(InterfaceStatusEnum.OFFLINE.getValue()), ErrorCode.PARAMS_ERROR, "接口已下线"); // 3. 测试调用 // 获取当前登录用户的ak和sk,这样相当于用户自己的这个身份去调用, // 也不会担心它刷接口,因为知道是谁刷了这个接口,会比较安全 // todo : 可以给这个地方加上锁 -- 限制调用频繁 User loginUser = userService.getLoginUser(request); String accessKey = loginUser.getAccessKey(); String secretKey = loginUser.getSecretKey(); com.yichen.ycapiclientSDK.model.User user = JSONUtil.toBean(userRequestParams, com.yichen.ycapiclientSDK.model.User.class); // 使用用户自己的密钥 YcApiClient userYcApiClient = new YcApiClient(accessKey, secretKey); String userNameByPost = userYcApiClient.getUserNameByPost(user); ThrowUtils.throwIf(StringUtils.isBlank(userNameByPost), ErrorCode.NOT_FOUND_ERROR, "接口验证失败"); return ResultUtils.success(userNameByPost); } ``` 8. **接口调用次数的统计** **需求:** 1. 用户每次调用接口成功,次数 + 1 2. 给用户分配或者用户自主申请接口调用次数 **业务流程:** 1. 用户调用接口 2. 修改数据库,调用次数 +1 💡 **减 1 吗?** 对于加 1 或减 1 的问题,实际上可以根据你的需求进行选择,这是灵活的。如果你打算进行计费,无论是加 1 还是减 1 都可以判断调用次数是否达到了限额或超出了限额。你可以记录上一次开通和当前开通的调用次数,以及总的限额,从而计算出当前的调用次数。加 1 的话,可以从 0 增加到 1 万,根据你的业务逻辑进行操作。 减 1 也是可以的,但是减 1 会带来一个问题,那就是你好像无法记录总的调用次数,可能需要再添加一个字段。 **用户调用接口关系表 :** ```sql create table if not exists `user_interface_info` ( `id` bigint not null auto_increment primary key comment '主键', `userId` bigint not null comment '调用用户 id', `interfaceInfoId` bigint not null comment '接口 id', `totalNum` int default 0 not null comment '总调用次数', `leftNum` int default 0 not null comment '剩余调用次数', `status` int default 0 not null comment '0-正常,1-禁用 是否允许其调用特定接口', `createTime` datetime default CURRENT_TIMESTAMP not null comment '创建时间', `updateTime` datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', `isDelete` tinyint default 0 not null comment '是否删除(0-未删, 1-已删)' ) comment '用户调用接口关系'; ``` >根据系统的量级和需求,我们可以选择使用**日志存储**来记录**接口调用信息**,而不是直接存储在数据库中。这样可以更好地管理和分析接口调用数据。 > >💡 **调用时间**,如果我们还要在数据库中添加调用时间字段的话,那会更加复杂。不建议将调用时间直接写入数据库。原因是,如果每个用户每次调用接口都要在数据库中新增一条数据,那么数据库表可能会变得非常庞大。建议使用**日志**来存储这些调用信息,可以将其记录在文件中,使用类似 ELK 等工具进行日志存储和分析。这样,我们不用将这些调用信息直接存储在数据库中。 ---- CRUD **实现调用接口次数加一** ```java /** * 调用接口次数加一 * * @param interfaceInfoId * @param userId * @return */ @Override public Boolean invokeCountPlus(Long interfaceInfoId, Long userId) { Boolean flag = false; // 校验 if (interfaceInfoId <= 0 || userId <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } User user = new User(); user.setId(userId); if (ObjectUtils.isEmpty(userService.getById(user))) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在"); } InterfaceInfo interfaceInfo = new InterfaceInfo(); interfaceInfo.setId(interfaceInfoId); InterfaceInfo byId = interfaceInfoService.getById(interfaceInfo); if (ObjectUtils.isEmpty(byId) || byId.getStatus().equals(InterfaceStatusEnum.OFFLINE.getValue())) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "接口不存在或接口已下线"); } UserInterfaceInfo userInterfaceInfo = this.getOne(new QueryWrapper() .eq("interfaceInfoId", interfaceInfoId) .eq("userId", userId)); if (ObjectUtils.isEmpty(userInterfaceInfo)) { userInterfaceInfo.setUserId(userId); userInterfaceInfo.setInterfaceInfoId(interfaceInfoId); userInterfaceInfo.setTotalNum(1); flag = this.save(userInterfaceInfo); ThrowUtils.throwIf(!flag, ErrorCode.OPERATION_ERROR, "新建数据库调用次数失败"); } else { flag = this.update(new UpdateWrapper() .eq("interfaceInfoId", interfaceInfoId) .eq("userId", userId) .setSql("leftNum = leftNum - 1, totalNum = totalNum + 1") ); ThrowUtils.throwIf(!flag, ErrorCode.OPERATION_ERROR, "修改数据库调用次数失败"); } return flag; } ``` 由于用户可能会**瞬间调用大量接口次数**,为了**避免统计出错**,需要涉及到事务和锁的知识。在这种情况 下,如果我们是在分布式环境中运行的,那么可能需要使用**分布式锁**来保证数据的一致性。 事务是一组操作的集合,要么全部成功,要么全部失败回滚。在这个场景中,我们希望在更新用户接口 信息的时候,保证原子性,即要么用户接口信息全部更新成功,要么全部不更新。 (伙伴匹配系统 ) **问题:** 如果每个方法调用成功后,返回结果之前都要调用一次`invokeCount`方法,会显得非常繁琐 image-20240730142031634 **解决方法一:AOP 切面** **优点:独立于接口**,在每个接口调用后统计次数 + 1 **缺点:**只存在于**单个项目**中,如果每个团队都要开发自己的模拟接口,那么都要写一个切面。 实现:在interface接口中实现切面 ---- 缺点暴露(如果每个团队都要开发自己的模拟接口,那么都要写一个切面) **解决方案二:网关** 网关负责找到对应的接口并返回结果。对于**开发者**来说,他们也**不需要关心统计次数**。只要把自己的接口接入到网关中,让网关能找到并调用即可,**网关会自动帮他们统计次数** 处理不同项目的请求,**用户和开发者都不需要关心具体的细节**,简化了操作,提高了系统的可用性和可维护性 ##### 7. 网关 **作用:** 1. **路由** >起到**转发**的作用,比如有接口 A 和接口 B,网关会记录这些信息,根据用户访问的地址和参数,转发请求到对应的接口(服务器 / 集群)。 > >/a => 接口A > >/b => 接口B > >参考文档:[The After Route Predicate Factory](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-after-route-predicate-factory) 2. **负载均衡** >在路由的基础上。 > >/c => 服务 A / 集群 A(随机转发到其中的某一个机器) > >uri 从固定地址改成 lb:xxxx 3. **统一鉴权** >判断用户是否有权限进行操作,无论访问什么接口,我都统一去判断权限,不用重复写 4. **统一业务处理(缓存)** >把一些每个项目中都要做的通用逻辑放到上层(网关),统一处理,比如本项目的次数统计 5. **统一日志** >统一的请求、响应信息记录 6. **统一文档** >将下游项目的文档进行聚合,在一个页面统一查看 建议用:[knife4j 文档](https://doc.xiaominfo.com/docs/middleware-sources/aggregation-introduction) 7. **跨域** >网关统一处理跨域,不用在每个项目里单独处理。 > >参考文档:[Global CORS Configuration](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#global-cors-configuration) 8. **访问控制** >黑白名单,比如限制 DDOS IP 9. **发布控制** >灰度发布,比如上线新接口,先给新接口分配 20% 的流量,老接口 80%,再慢慢调整比重。 > >参考文档:[The Weight Route Predicate Factory](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-weight-route-predicate-factory) 10. **流量染色** >给请求(流量)添加一些标识,一般是设置请求头中,添加新的请求头。 > >参考文档:[TheAddRequestHeaderGatewayFilterFactory](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-addrequestheader-gatewayfilter-factory) > >全局染色:[Default Filters](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#default-filters) > >假设现在有一个用户要访问我的接口。但是有一个问题,我希望用户不能绕过网关直接调用我的接口,我想要防止这种情况发生。那么我应该如何防止绕过网关呢? > >- 一个方法是要确定请求的来源。我们可以为用户通过网关来的请求打上一个标识,比如添加一个请求头 source=gateway。只要经过网关的请求,网关就会给它打上 source=gateway 的标识。接口 A 就可以根据这个请求头来判断,如果请求没有 source=gateway 这个标识,就直接拒绝掉它。这样,如果用户尝试绕过网关,没有这个请求头的话,我们的项目就不会认可它。这就是流量染色的一种应用。流量染色还有其他应用,比如区分用户的来源,这和鉴权是不同的概念,属于不同的应用场景。 >- 另外一个常见的应用是用于排查用户调用接口时出现的问题。我们为每个用户的每次调用都打上一个唯一的 traceid,这是分布式链路追踪的概念。通过这个 traceid,当出现问题时,下游服务可以根据 traceid 追踪到具体的请求,从而逐层排查问题。这也是流量染色的作用之一。 11. **接口保护** ​ a. **限制请求** [requestheadersize-gatewayfilter-factory](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#requestheadersize-gatewayfilter-factory) ​ b. **信息脱敏** [the-removerequestheader-gatewayfilter-factory](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-removerequestheader-gatewayfilter-factory) ​ c. **降级**(熔断)[fallback-headers](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#fallback-headers) >比如向用户提示接口已下线,或引导用户访问其他功能,从而确保用户始终能够得到有意义的响应。降级也被称为兜底,作为一种保险措施,即使正式服务不可用,仍能提供有用的反馈。 ​ d. **限流**:学习令牌桶算法、学习漏桶算法,学习一下 RedisLimitHandler [the-requestratelimiter-gatewayfilter-factory](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-requestratelimiter-gatewayfilter-factory) ​ e. **超时时间** [http-timeouts-configuration](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#http-timeouts-configuration) ​ f. **重试**(业务保护):[the-retry-gatewayfilter-factory](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-retry-gatewayfilter-factory) **参考文章:**[网关的介绍](https://blog.csdn.net/qq_21040559/article/details/122961395) 1. 全局网关(接入层网关):作用是负载均衡、请求日志等,不和业务逻辑绑定。 2. 业务网关(微服务网关):会有一些业务逻辑,作用是将请求转发到不同的业务 / 项目 / 接口 / 服务。 **参考文章:**[网关技术选型](https://zhuanlan.zhihu.com/p/500587132) 1. Nginx(全局网关)、Kong 网关(API 网关,[**Kong**](https://github.com/Kong/kong)),编程成本相对高一点。 2. Spring Cloud Gateway(取代了 Zuul)性能高、可以用 Java 代码来写逻辑,适于学习。 **实现:**[官网](https://spring.io/projects/spring-cloud-gateway/) **核心概念** 在这里我们定义的是一个匹配器,或者更明确地说,在 Spring Cloud Gateway 中它被称为"断言" (predicate) image-20240730144916955 **路由**(根据什么条件,转发请求到哪里) **断言:**一组规则、条件,用来确定如何转发路由 **过滤器:**对请求进行一系列的处理,比如添加请求头、添加请求参数 **请求流程:** 1. 客户端发起请求 2. Handler Mapping:根据断言,去将请求转发到对应的**路由** 3. Web Handler:处理请求(一层层经过**过滤器**) 4. **实际调用服务** image-20240730145052191 **两种配置方式:** 1. 配置式(方便、规范,**推荐**) 2. 1. 简化版 2. 全称版 3. 编程式(灵活、相对麻烦) **断言:(predicate)** 根据一组条件来进行路由,可以将它理解为一组规则 1. After 在 xx 时间之后 2. Before 在 xx 时间之前 3. Between 在 xx 时间之间 4. 请求类别 5. 请求头(包含 Cookie) 6. 查询参数 7. 客户端地址 8. **权重** **过滤器:** 基本功能:对请求头、请求参数、响应头的增删改查。 1. 添加请求头 2. 添加请求参数 3. 添加响应头 4. 降级 5. 限流 6. 重试 **测试:**新建 gateway 项目 编程式(灵活、相对麻烦) image-20240730145352464 ```java @SpringBootApplication public class YcApiGatewayApplication { public static void main(String[] args) { SpringApplication.run(YcApiGatewayApplication.class, args); } // 这个注解用于创建一个 Spring Bean,即一个路由规则的构建器 @Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { // 创建路由规则的构建器 return builder.routes() // 定义路由规则,给该规则起一个名字 "tobaidu" .route("tobaidu", r -> r.path("/baidu") // 将满足 "/baidu" 路径的请求转发到 "https://www.baidu.com" .uri("https://www.baidu.com")) ps.重定向到百度不行,可能百度有一些限制🐶 // 定义路由规则,给该规则起一个名字 "toyupiicu" .route("toyupiicu", r -> r.path("/yupiicu") // 将满足 "/yupiicu" 路径的请求转发到 "http://yupi.icu" .uri("http://yupi.icu")) // 创建并返回路由规则配置对象 .build(); } } ``` **工作原理:** 首先,请求经过了这个 **Gateway Handler Mapping**。它的作用就是**根据你的请求找到对应的路由**。 也就是说,当用户发送请求时,Gateway Handler Mapping 会根据请求的信息来确定该请求应该被路 由到哪个地方。 Gateway Handler Mapping 它的作用就是处理请求,它要经过一系列的过滤器,也就是说这个**过滤器** **可以去定义多个**。比如说我定义**一个鉴权过滤器,专门用来从请求头中去鉴权。再定一个日志过滤** **器,专门用来去记日志。再定一个跨域过滤器都是可以的**。它可以经过多种过滤器,过滤器之间也 可以有顺序,先经过哪个后经过哪个。 最后,就是实际调用我们的服务。那实际调用服务,调用的就是我们的真实提供的接口,就是 ycapiinterface 项目,它就不是网关做的事情了。他最后就真正的去调用这个接口,这个项目的接口。 image-20240730145052191 **配置式:** - **spring.cloud.gateway.routes:** 这是配置路由的属性。 - **- id: after_route:** 这是路由的唯一标识符,用于区分不同的路由。 - **uri: [https://example.org](https://example.org/):** 这是路由将请求转发到的目标 URI,即请求经过此路由后将被转发到 [https://example.org](https://example.org/) 这个地址。 - **predicates:** 这是断言的配置属性,用于定义请求是否满足路由条件。**(可以加多个规则)** - **- Cookie=mycookie,mycookievalue:** 这是一个断言条件,它指定了请求必须具有名为 mycookie - 的 Cookie,且其值必须为 mycookievalue,才能匹配这个路由。 通过这个配置,当满足请求带有特定`mycookie` 的 Cookie 并且其值为`mycookievalue`时,请求将被路 由到`https://example.org`这个目标 URI。 ![image-20240730152403617](.\API 开放平台\image-20240730152403617.png) ```yaml spring: cloud: gateway: routes: - id: after_route uri: https://example.org predicates: - After=2017-01-20T17:42:47.789-07:00[America/Denver] - ... ``` ```yaml server: port: 8090 spring: cloud: gateway: routes: # 将请求路径以/api/**开头的请求转发到目标URI https://example.org - id: path_route uri: https://example.org predicates: - Path=/api/** # 将请求路径以/baidu/**开头的请求转发到目标URI https://baidu.com - id: path_route2 uri: https://baidu.com predicates: - Path=/baidu/** ``` 💡 有个问题,**当我输入一个地址后,不知道它重定向到哪个规则进行处理,即无法确定应用了哪个路由** **规则**。有时候只能通过输入类似百度这种特别明显的网址来区分。因此,在开发过程中,可以加上这个 image-20240730152846633 以下配置是将 Spring Cloud Gateway 的日志级别设置为 "**trace**",这意味着 Spring Cloud Gateway 将 **输出最详细的日志信息**,**包括所有的跟踪信息**。通过设置这个日志级别,我们可以查看每个请求在网关 中的处理流程、断言、过滤器的执行情况以及最终路由的结果,有助于调试和排查问题。 **需要注意的是,"trace" 级别会产生大量的日志,仅在调试和排查问题时使用,生产环境应该将日志级** **别设置为更适合的水平,以避免过多的日志输出影响性能。** **Weight Route**,这个功能非常重要,它允许我们**根据权重来将请求重定向到不同的目标**。 看一下它给我们的例子,首先定义了两个 URL,一个是 weight_high(高权重的目标),另一个是 weight_low(低权重的目标)。然后,设置了一组权重断言,其中高权重为 8(百分之八十),低权重 为 2(百分之二十)。最后,我们只需定义这个规则,假设用户访问了十次,那么就会有八次请求导向 高权重的目标,而有两次请求导向低权重的目标。通过这种方式,我们轻松实现了刚刚提到的**发布控制** **或者灰度发布的需求**。 ![image-20240730153620657](.\API 开放平台\image-20240730153620657.png) **拦截器** 拦截器的主要作用是对请求进行修改或处理响应等操作 (以请求头为示例) ```yaml spring: cloud: gateway: routes: - id: add_request_header_route uri: https://example.org filters: # 拦截器 AddRequestHeader,作用是添加请求头 - AddRequestHeader=X-Request-red, blue ``` **请求染色**其实就是给请求打上标识,以证明这个请求是从我这儿发起的,这样下游服务就能识别它。通 过在请求头中添加特定的标识,比如`yichen`字段,我们可以实现对接口的保护,确保只有带有这个请 求头的请求才能被下游服务认可并允许调用。**这是染色的方式之一**,还可以增加额外的参数等等。 **CircuitBreaker(断路器)** 实现服务降级。当你访问某个接口地址时,如果这个**接口出现错误**,断路器会将你的请求降级,**从而转** **而请求另外一个接口** 引入包 ```xml org.springframework.cloud spring-cloud-starter-circuitbreaker-reactor-resilience4j ``` image-20240730154620017 CacheRequestBody 让原本的请求信息中的 body 参数可以被多次读取。默认情况下,请求的 body 参数只能被读取一次, 但是使用了这个配置后,就可以多次读取请求的 body 参数,并将其作为一个持久化的缓存 DedupeResponseHeader 请求经过了多个服务器,每个服务器都添加了一层跨域头。但是由于重复添加跨域头,可能导致最终跨域失败并出现错误。为了解决这个问题,我们可以使用 DedupeResponseHeader 来去除重复的响应头。 DedupeResponseHeader 的作用就是检查响应头中是否包含重复的头信息,并进行去重处理。它还提供了一些去重策略,例如保留最后一个重复头信息或随便保留一个。 ..... 查看文档 **RequestRateLimiter(限流)** 在 Spring Cloud Gateway 中实现限流非常简单,官方推荐使用 **Redis** 来进行限流,因为网关通常是重 要的组件,不建议在单机上实现限流。大多数情况下,网站是分布式的,使用多个机器提供服务。因 此,建议将限流逻辑集中存储到 Redis 中,让 Redis 作为集中式存储帮助我们统计是否达到限流阈值。 首先需要引入`spring-boot-starter-data-redis-reactive`这个库。然后文档介绍了限流所使用的 是令牌桶算法。对于令牌桶算法,有一些参数需要配置,例如生成令牌的速率、桶的容量以及初始的令 牌数等等。理解这些参数可能有助于更好地理解限流的实现方式。 **Default Filters(默认过滤器)** filters 是写在单个路由下,对**某一个**路径生效,或者只对某一个断言生效。但是现在,你可以直接给整 个网关定一些默认的过滤器,比如说我们刚刚讲的染色功能 直接给所有的经过网关的请求都加一个,这就是全局的染色了 ![image-20240730155215422](.\API 开放平台\image-20240730155215422.png) ##### 8. 实现统一的用户鉴权和接口调用次数统计 **业务逻辑:** 1. 用户发送请求到 API 网关 2. 请求日志 3. (黑白名单) 4. 用户鉴权(判断 ak、sk 是否合法) 5. 请求的模拟接口是否存在? 6. **请求转发,调用模拟接口** 7. 响应日志 8. 调用成功,接口调用次数 + 1 9. 调用失败,返回一个规范的错误码 **注意不要导错包 spring-cloud-starter-gateway-mvc 这个是错的** ````xml 1.8 2021.0.8 org.springframework.cloud spring-cloud-starter-gateway ```` **配置:** **路由** ```yaml spring: application: name: ycAPI-gateway cloud: gateway: routes: # 定义了一个名为"api_route"的路由规则,该规则将匹配以"/api/"开头的路径,例如"/api/user", # 并将这些请求转发到"http://localhost:8112"这个目标地址 - id: api_route # 随便写 uri: http://localhost:8112 predicates: - Path=/api/** default-filters: - AddResponseHeader=source, yichen ``` **全局过滤器:** 业务逻辑写在 filter 中 ```java /** * 全局过滤器 */ @Component @Slf4j // Ordered - 执行顺序 多个过滤器 public class CustomGlobalFilter implements GlobalFilter, Ordered { @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 业务逻辑 log.info("custom global filter"); return chain.filter(exchange); } @Override public int getOrder() { return -1; } } ``` **请求日志:** + ***exchange**( 路由交换机 ):*我们所有的**请求的信息、响应的信息、响应体、请求体**都能从这里拿 到。 + ***chain**( 责任链模式 ):*因为我们的所有过滤器是按照从上到下的顺序依次执行,形成了一个链 条。所以这里用了一个chain,如果当前过滤器对请求进行了过滤后发现可以放行,就要调用责任 链中的next方法,相当于直接找到下一个过滤器,这里称为filter。有时候我们需要在责任链中 使用next,而在这里它使用了filter来找到下一个过滤器,从而**正常地放行请求**。 **和 AOP 的思想差不多** **黑白名单:** 通常情况下,经常使用的是**封禁 IP**。例如,如果某个远程地址频繁访问,我们可以将其添加到黑名 单并拒绝访问。现在我们来试试设置一个规则,如果请求的来源地址不是127.0.0.1,就拒绝它的访问。 先写一个全局的常量。在这里我们用一个白名单,**通常建议在权限管理中尽量使用白名单**,少用黑名 单。白名单的原则是只允许特定的调用,这样可能会更加安全,或者你可以默认情况下全禁止。 **拒绝:** 通过刚刚用到的 exchange,获取到响应对象,从而控制该响应,**直接设置状态码为403(禁止访问)**,然后拦截掉。 **用户鉴权(判断 ak、sk 是否合法):** 之前的判断逻辑 **请求的模拟接口是否存在** 需要查询数据库 ---- 远程调用接口 **请求转发,调用模拟接口** 进行放行操作 // 异步的导致业务逻辑不正确 ---- 解决异步之前代码 ```java /** * 全局过滤器 */ @Component @Slf4j public class CustomGlobalFilter implements GlobalFilter, Ordered { public static final List IP_WHITE_LIST = Arrays.asList("127.0.0.1"); @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 2. 请求日志 ServerHttpRequest request = exchange.getRequest(); log.info("请求唯一标识: {}", request.getId()); log.info("请求方法: {}", request.getMethod()); log.info("请求路径: {}", request.getPath().value()); log.info("请求参数: {}", request.getQueryParams()); String hostString = request.getLocalAddress().getHostString(); log.info("请求来源地址1: {}", hostString); log.info("请求来源地址: {}", request.getRemoteAddress()); // 3. (黑白名单) ServerHttpResponse response = exchange.getResponse(); if (!IP_WHITE_LIST.contains(hostString)) { // 直接设置状态码为403(禁止访问) return handleNoAuth(response); } // 4. 用户鉴权(判断 ak、sk 是否合法) HttpHeaders headers = request.getHeaders(); String accessKey = Optional.ofNullable(headers.getFirst("accessKey")).orElse(" "); String nonce = Optional.ofNullable(headers.getFirst("nonce")).orElse(" "); String timestamp = Optional.ofNullable(headers.getFirst("timestamp")).orElse(" "); String sign = Optional.ofNullable(headers.getFirst("sign")).orElse(" "); String requestBody = Optional.ofNullable(headers.getFirst("requestBody")).orElse(" "); // todo 实际情况应该是去数据库中查是否已分配给用户 if (!accessKey.equals("yichen")) { return handleNoAuth(response); } // 校验随机数,模拟一下,直接判断nonce是否大于10000 if (Long.parseLong(nonce) > 10000L) { return handleNoAuth(response); } // 时间和当前时间不能超过5分钟 final long FIVE_MINUTES = 60 * 5L; if ((System.currentTimeMillis() / 1000) - Long.parseLong(timestamp) >= FIVE_MINUTES) { return handleNoAuth(response); } String serverSign = SignUtils.getSign(requestBody, "yichenwu"); if (!serverSign.equals(sign)) { return handleNoAuth(response); } // 5. 请求的模拟接口是否存在? // todo: 需要查询数据库 // 6. 请求转发,调用模拟接口 // 异步的导致业务逻辑不正确 Mono filter = chain.filter(exchange); // return handleResponse(exchange, chain); // 7. 响应日志 HttpStatus statusCode = response.getStatusCode(); log.info("响应:{}", statusCode); if (statusCode != HttpStatus.OK) { // 9. 调用失败,返回一个规范的错误码 return handleInvokeError(response); } else { // 8. 调用成功,接口调用次数 + 1 } log.info("网关全局过滤器 global filter"); return filter; } @Override public int getOrder() { return -1; } /** * 无权限处理 * * @param response * @return */ public Mono handleNoAuth(ServerHttpResponse response) { // 直接设置状态码为403(禁止访问) response.setStatusCode(HttpStatus.FORBIDDEN); // 处理完成的响应 -- 返回的 Mono 是异步的 类似 前端的 promise return response.setComplete(); } /** * 调用接口失败 * * @param response * @return */ public Mono handleInvokeError(ServerHttpResponse response) { // 直接设置状态码为500 response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); // 处理完成的响应 -- 返回的 Mono 是异步的 类似 前端的 promise return response.setComplete(); } ``` **自定义响应处理** **问题:** 预期是等模拟接口调用完成,才记录响应日志、统计调用次数。 但现实是 chain.filter 方法立刻返回了,直到 filter 过滤器 return 后才调用了模拟接口。 原因是:chain.filter 是个异步操作,理解为前端的 promise **解决方案:**利用 response 装饰者,增强原有 response 的处理能力 **(装饰者模式)** 参考博客:https://blog.csdn.net/qq_19636353/article/details/126759522(以这个为主) 其他参考: - https://blog.csdn.net/m0_67595943/article/details/124667975 - [https://blog.csdn.net/weixin_43933728/article/details/121359727](https://blog.csdn.net/weixin_43933728/article/details/121359727?spm=1001.2014.3001.5501) - https://blog.csdn.net/zx156955/article/details/121670681 - https://blog.csdn.net/qq_39529562/article/details/108911983 ```java /** * 处理响应 * * @param exchange * @param chain * @return */ public Mono handleResponse(ServerWebExchange exchange, GatewayFilterChain chain) { try { // 获取原始的响应对象 ServerHttpResponse originalResponse = exchange.getResponse(); // 获取数据缓冲工厂 DataBufferFactory bufferFactory = originalResponse.bufferFactory(); // 获取响应的状态码 HttpStatus statusCode = originalResponse.getStatusCode(); // 判断状态码是否为200 OK(按道理来说,现在没有调用,是拿不到响应码的,对这个保持怀疑 沉思.jpg) if(statusCode == HttpStatus.OK) { // 创建一个装饰后的响应对象(开始穿装备,增强能力) ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(originalResponse) { // 重写writeWith方法,用于处理响应体的数据 // 这段方法就是只要当我们的模拟接口调用完成之后,等它返回结果, // 就会调用writeWith方法,我们就能根据响应结果做一些自己的处理 @Override public Mono writeWith(Publisher body) { log.info("body instanceof Flux: {}", (body instanceof Flux)); // 判断响应体是否是Flux类型 if (body instanceof Flux) { Flux fluxBody = Flux.from(body); // 返回一个处理后的响应体 // (这里就理解为它在拼接字符串,它把缓冲区的数据取出来,一点一点拼接好) return super.writeWith(fluxBody.map(dataBuffer -> { // 读取响应体的内容并转换为字节数组 byte[] content = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(content); DataBufferUtils.release(dataBuffer);//释放掉内存 // 构建日志 StringBuilder sb2 = new StringBuilder(200); sb2.append("<--- {} {} \n"); List rspArgs = new ArrayList<>(); rspArgs.add(originalResponse.getStatusCode()); //rspArgs.add(requestUrl); String data = new String(content, StandardCharsets.UTF_8);//data sb2.append(data); log.info(sb2.toString(), rspArgs.toArray());//log.info("<-- {} {}\n", originalResponse.getStatusCode(), data); // 将处理后的内容重新包装成DataBuffer并返回 return bufferFactory.wrap(content); })); } else { log.error("<--- {} 响应code异常", getStatusCode()); } return super.writeWith(body); } }; // 对于200 OK的请求,将装饰后的响应对象传递给下一个过滤器链,并继续处理(设置repsonse对象为装饰过的) return chain.filter(exchange.mutate().response(decoratedResponse).build()); } // 对于非200 OK的请求,直接返回,进行降级处理 return chain.filter(exchange); }catch (Exception e){ // 处理异常情况,记录错误日志 log.error("gateway log exception.\n" + e); return chain.filter(exchange); } } ``` 解决之后------ ```java ``` ##### 9. 远程调用 思考:你在项目 A 中编写了一个非常有用的函数,现在你在项目 B 中也想要使用这个函数。但问题 是,项目 A 和项目 B 是独立运行的,它们不共享同一片内存,也不在同一个进程中。那么,你怎么做 才能调用项目 A 中的那个函数呢? **怎么调用其他项目的方法?** 1. 复制代码和依赖、环境 >引发环境依赖和代码问题,因为项目之间各自有独特的设置和条件 2. HTTP 请求(提供一个接口,供其他项目调用) >1. 提供方开发一个接口(地址、请求方法、参数、返回值) >2. 调用方使用 HTTP Client 之类的代码包去发送 HTTP 请求 3. RPC >像调用本地方法一样调用远程方法 > >**和直接 HTTP 调用的区别:** > >1. 对开发者更透明,减少了很多的沟通成本。 >2. RPC 向远程服务器发送请求时,未必要使用 HTTP 协议,比如还可以用 TCP / IP,性能更高。(内部服务更适用)。 img HTTP 需要自行封装 HTTP 请求,将参数打包成一个参数映射(map),然后按照客户端工具类的方式发送请求。此外,你还需要解析返回值,将其中的 code、data 以及 message 等信息提取出来。 RPC 方式,就能够实现与调用本地方法类似的体验。你可以直接指定要传递的参数,并且也能够直接获得返回值,无论是布尔类型还是字符串。RPC 方式不需要像 HTTP 请求那样进行额外的封装,但如果你需要封装,当然也可以根据需求自行处理。 RPC 的主要职责就是这个,它的最大作用在于模拟本地方法调用的体验。看上去是请求本地代码,实际上,它可能会请求到其他项目、其他服务器等等。这就是 RPC 的最大价值所在。 RPC 最大的优势在于它的透明性,你不需要了解它是如何在 HTTP Client 中怎么封装参数,只需直接调用现成的方法即可,这样可以大大减少沟通成本。 💡:**feign 不也是动态生成的 httpclient 吗?** **Feign 本质上也是动态生成的 HTTP 客户端**,但它在调用远程方法时更加精简了 HTTP 请求的步骤。尽管 Feign 使远程调用看起来像是调用本地方法,但实际上与 RPC 仍然有一些微小的区别。虽然两者都可以实现类似的功能,但它们在底层协议上存在差异。 RPC(Remote Procedure Call 远程过程调用) 的一个关键优势在于,**它可以使用多种底层协议进行远程调用,而不限于 HTTP 协议。**虽然 HTTP 协议可以实现类似的功能,但考虑到性能,RPC 可以选择更原生的协议,如 TCP/IP。而且,网络上还存在其他性能更高的协议,可以根据需要进行选择。 在微服务项目中,对于内部接口,使用 RPC 可能会获得更好的性能。然而,选择使用 Feign 还是 RPC 取决于具体的技术需求,没有绝对的优劣之分。需要注意的是,RPC 和 HTTP 请求可以结合使用,因为 RPC 的底层协议可以是 HTTP,也可以是其他协议,如 TCP/IP 或自定义协议。 综上所述,RPC 和 HTTP 请求是可以互相结合的,但 RPC 在协议的选择上更加灵活。**当面试被问到这方面的问题时,首先强调 RPC 的主要作用,然后阐述 RPC 和 HTTP 之间的关系。** img 需要注意的是在整个流程中,**最终的调用并不是由注册中心来完成的**。虽然注册中心会提供信息,但实际上**调用方需要自己进行最后的调用动作**。**注册中心的作用是告诉调用方提供者的地址**等信息,然后调用方会根据这些信息来完成最后的调用。 一般情况下,**调用方会直接寻找提供者进行调用**,而不是依赖注册中心来完成实际的调用过程。注册中心主要的功能是提供地址信息,而并不会承担将调用方所需的内容传送到提供者的角色,整个过程遵循这样的流程。 4. 把公共的代码打个 jar 包,其他项目去引用(客户端 SDK) ##### 10. Dubbo 框架 [Dubbo 框架官方文档](https://dubbo.incubator.apache.org/zh/docs3-v2/java-sdk/quick-start/spring-boot/) **两种使用方式:** 1. Spring Boot 代码(注解 + 编程式):写 Java 接口,服务提供者和消费者都去引用这个接口。 2. IDL(接口调用语言):创建一个公共的接口定义文件,服务提供者和消费者读取这个文件。优点是跨语言,所有的框架都认识。 Dubbo底层用的是 Triple 协议:[Triple 协议](https://dubbo.incubator.apache.org/zh/docs3-v2/java-sdk/concepts-and-architecture/triple/)。 1. **Zookeeper 是什么呢?** Zookeeper 就是我们之前图中所提到的注册中心。Dubbo 的作用就是将你的提供者、调用方和注册中心这三者通过其框架整合在一起,从而完成整个远程过程调用(RPC)。因此,Dubbo 可以被视为一个实现了 RPC 功能的框架,而 Zookeeper 则是一个可作为注册中心的存储, Nacos 也可以作为注册中心**。** 2. **为什么一定要知道注册中心的地址?** 因为我们的服务提供者需要将自己提供的接口方法告知注册中心,所以它必须知道注册中心的地址,这样才能将自己的信息上报给注册中心,这也是为什么我们必须要配置注册中心。同样的道理,我们的消费者调用方也需要知道注册中心的地址,以便将注册中心的地址配置到项目中。 3. **IDL 是什么呢?** IDL(接口定义语言)是一种约定俗成的语言,用于定义接口和数据结构的语法。它是一种人为约定的语言,通过这种语法,可以明确地定义接口和数据的结构,使各方在交流和协作时能够达成一致。这种约定的语言为不同的系统、平台或语言提供了一种统一的描述方式,使得不同环境下的应用程序能够理解和交互。 **运用:** 1. backend 项目作为服务提供者,**提供 3 个方法:** 2. 1. 实际情况应该是去数据库中查是否已分配给用户 2. 从数据库中查询模拟接口是否存在,以及请求方法是否匹配(还可以校验请求参数) 3. 调用成功,接口调用次数 + 1 invokeCount 3. gateway 项目作为服务调用者,调用这 3 个方法 ###### **Nacos** [Nacos 官方文档](https://nacos.io/zh-cn/docs/quick-start.html) **路径名称不能包含中文字符** [Dubbo 配置 Nacos 文档](https://dubbo.apache.org/zh-cn/overview/mannual/java-sdk/reference-manual/registry/nacos/) 1. 启动nacos 2. 加载 pom ```xml org.apache.dubbo dubbo 3.0.9 com.alibaba.nacos nacos-client 2.1.0 ``` 3. 配置 ```yaml # 以下配置指定了应用的名称、使用的协议(Dubbo)、注册中心的类型(Nacos)和地址 dubbo: application: # 设置应用的名称 name: dubbo-springboot-demo-provider # 指定使用 Dubbo 协议,且端口设置为 -1,表示随机分配可用端口 protocol: name: dubbo port: -1 registry: # 配置注册中心为 Nacos,使用的地址是 nacos://localhost:8848 id: nacos-registry address: nacos://localhost:8848 ``` 4. 整合官方给的示例代码 -- 文件夹provider 给启动类加上 @EnableDubbo 注解 5. 如果端口调用报错: dubbo 端口配置是`-1`(随机端口),但是这个系统啊,竟然把两个项目都随机成相同的端口🤣 所以我们只需把这两个项目端口配置的端口号定死即可。 ##### 11. 抽象公共服务 校验用户 API 签名 --- interface 我们不能指望开发人员自行引入 MyBatis 并调用我们的公共数据库,这显然是不切实际的。 因此,应该为开发人员提供一个远程调用的服务,以便帮助他们进行验证,或者我们可以提供一个 SDK 供他们引入,以便进行验证操作。在这种情况下,我们需要依赖一个公共的服务来实现这个功能。 **服务抽取:** 1. 数据库中查是否已分配给用户秘钥(根据 accessKey 拿到用户信息,返回用户信息,为空表示不存在) 2. 从数据库中查询模拟接口是否存在(请求路径、请求方法、请求参数,返回接口信息,为空表示不存在) 3. 接口调用次数 + 1 invokeCount(accessKey、secretKey(标识用户),请求接口路径) **步骤:** 1. 新建干净的 maven 项目,只保留必要的公共依赖 2. 抽取 service 和实体类 3. install 本地 maven 包 4. 让服务提供者引入 common 包,测试是否正常运行 5. 让服务消费者引入 common 包 1. 将需要的 3 个方法 在common 中 写好接口 -- 接口通译命名为 InnerXXXService 2. 引入实体类 3. 在 backend 中将这三个方法实现 写号实现类 ---- InnerXXXServiceImpl 4. 在实现类上添加`@DubboService`注解 5. 完善网关的业务逻辑 -------- todo SDK 的地址 ,以及整个项目的业务逻辑的理解 ------------ ##### 12. 业务扩展 ###### 1. **如何让其他用户上传自己编写的接口?** 需要提供一个注册机制。在这个机制下,其他用户可以上传他们自己编写的接口信息。为了简化流程,可以设计一个用户友好的界面。在这个界面上,用户可以输入他们的接口信息,包括服务器地址(host)、请求路径等内容。也可以规定,在接入我们的平台时,用户必须使用我们提供的 SDK 或遵循一定的要求。 **如何进行接入和要求的遵循?**在用户上传接口的时候,我们需要对接口信息进行测试调用,以确保接口的正常运行,这可以通过我们的平台来完成。同时,我们也可以要求用户标明该接口是否是由我们的网关调用,这可能需要用户在代码中加入判断代码,或者引入我们提供的 SDK 来实现。 **接口信息的组织和存储:**当用户上传接口信息时,这些信息将被存储在 InterfaceInfo 接口中。除了 URL 外,还应该添加一个 host 字段,用于明确区分不同服务器的地址。这样,可以更清晰地区分请求路径和服务器地址,提高接口信息的可读性和可维护性。 2. 统计分析功能 ## 七、扩展思路 1. **用户可以申请更换签名** 2. **怎么让其他用户也上传接口?** - 需要提供一个机制(界面),让用户输入自己的接口 host(服务器地址)、接口信息,将接口信息写入数据库。 - 可以在 interfaceInfo 表里加个 host 字段,区分服务器地址,让接口提供者更灵活地接入系统。 - 将接口信息写入数据库之前,要对接口进行校验(比如检查他的地址是否遵循规则,测试调用),保证他是正常的。 - 将接口信息写入数据库之前遵循咱们的要求(并且使用咱们的 sdk), - 在接入时,平台需要测试调用这个接口,保证他是正常的。 1. **网关校验是否还有调用次数** - 需要考虑并发问题,防止瞬间调用超额。 1. **网关优化** - 比如增加限流 / 降级保护,提高性能等。还可以考虑搭配 Nginx 网关使用。 1. **功能增强** - 可以针对不同的请求头或者接口类型来设计前端界面和表单,便于用户调用,获得更好的体验。 - 可以参考 swagger、postman、knife4j 的页面。 上面提到的要检查用户提供的地址是否符合规则。我们的项目中,所有的模拟接口都是以 /api 开头,或者说,我们规定所有模拟接口的地址都必须以 [http://localhost:8123](http://localhost:8123/) 开头。你可以根据需要自行设置规则,然而,在制定这些规则时,最好不要过于严格,最好像之前提到的,可以在数据库中存储一个 host 的信息,只需满足一些特定的后缀条件,比如以 /api 开头等。 这个项目实际上有很多可以发展的方向,有许多要考虑的要点。如果你希望项目取得成功,就需要考虑许多方面。你需要制定规范,关注安全性、性能等方面。此外,还需要考虑如何防止被滥用。 **?:Dubbo 不是需要暴露服务才可以吗?** 我们这里不是那个模拟接口用 Dubbo 去暴露的,**我们暴露的是什么?**暴露是系统内部用的接口,什么查询数据库中是否给用户分配密钥、接调用接口次数统计。暴露的不是开发者提供的接口,开发者提供的接我们是通过网关去转发的。然后这里你如果想让网端直接通过同一套地址去调用,那你就让用户遵循这个地址规则。 **?:现在的 SDK 不是固定了方法吗?** 对于这个问题,需要明确一个**核心点:**一旦接口投入使用后,肯定要针对这个接口进行 SDK 的开发。你需要不断地完善 SDK,使其适应不断变化的需求。当然,也可以让 SDK 从接口信息表中读取信息,然后动态生成方法等等,这种做法也是可行的,然而,并不建议这样做。实际上,接口的发布可能不太像我们在应用商店发布应用那样灵活,在这种接口的发布过程中,建议还是介入一点人工。 以腾讯云的 SDK 手册为例,它有许多不同的 SDK,尽管代码可能是自动生成的,但是它的 SDK 是根据接口动态变化的。这意味着它并不是为 100 个接口提供同一个代码供调用,相反,它会为每个方法、每个接口生成相应的方法,同样,它也会根据不同的地址生成相应的方法。这一点非常重要,请大家多去使用一下第三方的 API 接口,这样就会更了解对方的 SDK 是如何设计的。 你可以去腾讯云下载他们的 SDK,会发现其中包含大量方法,基本上每个接口都有相应的方法。这样做的目的是让使用者更加便利,他们只需要输入方法名就能实现操作,无需关心具体的接口地址。1802583560277569537_0.9866454027204241 **?:就是每次发布一个接口,都需要更新一次 SDK?** 是的,实际上每次发布一个新的接口,都需要对 SDK 进行更新。**建议的做法**是这样的:由于用户并不关心具体的接口地址,因此你可以让他们直接调用方法名,然后根据这些方法名去动态生成对应的方法。每次发布新接口时,更新 SDK 的操作可以做得非常简单,可以采用脚本的方式,从数据库中读取接口地址,与之前已有的地址进行对比,然后补充相应的方法,这个过程是可行的,事实上,很多公司都在这样做。 https://gitee.com/yichenwun/yc-api-client-sdk.git https://gitee.com/yichenwun/yc-api-interface.git ##### 问题!!!!!!!! 在将 User实体类 放入 common项目中 的时候,因为之前登录过,所以redis中存在了之前包里的User的信息,所以swegger打不开!报错找不到User实体类 !!!!!!一定要记得清除 redis!!!!!