跳至主要內容

Da-API平台-API网关&接口调用统计

holic-x...大约 80 分钟项目Da-API平台

API网关&接口调用统计

1.构建思路

【1】接口调用统计思考

​ 目前后端项目有 api-platform-backend、api-platform-interface,api-platform-client-sdk,在实际开发中可能会思考一个问题:为什么不在一个工程呢?(类似父子模块构建)

​ 从项目开发设计上来说,这些项目本来就不应该在一个工程。举个例子,假设团队中有一个负责开发平台的团队,还有一个负责提供接口的团队。这两个团队有必要将项目放在同一个目录下吗?其实并没有必要。因为平台可以接入任何系统开发的接口,不一定是同一个团队或者公司内部的项目。而且可以将设计的范围扩大一点,除了提供给用户使用,还可以帮助开发者实现变现。

​ 例如,某个团队开发了一个图片接口,可以将接口接入到平台上,然后向其他用户提供服务,从中获取收益。当然,这样的系统规模会更大一些。(针对企业级项目开发的扩展)

​ 这样做涉及到的安全性和不确定性太多了。需要审核对方的接口,但无法确定对方是否会给自身的系统带来问题,例如泄露敏感信息或敏感数据。因此,在开发网站时,需要考虑的不仅仅是技术方面,更多的是业务方面和合法合规性。尤其是在公司内部开发这样的系统时,需要格外小心。

【2】接口调用统计业务流程分析

需求分析

(1)用户每次调用接口成功,则进行数据统计

(2)给用户分配或者提供用户自助申请接口调用的次数(限制用户请求调用接口次数)

业务流程

​ 思考一下关于接口调用次数统计的功能。从需求出发,需要考虑这个功能应该具有怎样的流程。需求是每次用户成功调用接口,次数加 1;如果是由于网络原因导致的失败,而不是参数错误等问题,也应该算作成功。(需求写得越明确,我们在进行设计时就会更清晰)

​ 从业务流程角度分析,实际上,整个接口调用次数加 1 的业务流程是嵌入在我们调用接口的业务流程中的。参考之前绘制的业务流程图:

​ 首先用户在前端看到接口,要开通接口获取调用次数,获取调用次数后发起调用。通常情况下,只需完成调用即可,但现在在调用之后需要额外进行一次统计,即增加次数记录,这个步骤实际上是在进行统计次数的位置。实现:只需要在接口调用成功后,在数据库中保存它的接口调用次数加 1 即可

​ 实现步骤:在原有的调用接口成功基础上,再添加一个步骤,即在统计次数的位置进行记录。进一步思考,既然每次接口调用成功次数都加 1,那么是否可以考虑给用户分配或者用户自主申请接口调用次数。这个需求可以是后续的需求,目前核心是先实现接口调用次数的统计。因为如果不进行统计,即使给用户分配这些次数也没有多大意义和用处。即使不限制次数,统计接口调用次数仍然是有用的。如果有人对系统发起攻击,刷了 100 万次,能追踪出是谁吗?统计调用接口次数的核心是限流和跟踪,至于其他功能扩展则结合实际业务需求进行开展

​ 例如此处+1、-1的问题,可能会想如果提供给用户调用接口次数申请的入口,直接用这个可用调用次数-1即可,但是基于这种情况还是需要一个统计的字段去统计接口调用的总次数,本质上是没有冲突的,这点取决于具体的业务逻辑设计

数据表设计

​ 既然每次调用接口成功都要加 1 次数,则需要区分是哪个用户调用了哪个接口,这意味着用户和接口之间存在一种关系。根据需求分析,这是一个多对多的关系,因为一个用户可以调用多个接口,而一个接口也可以被多个用户调用。因此,需要设计一个新的表来存储用户和接口之间的关系,可以称之为"用户调用接口关系表"。(此处不是分表概念,分表是把一个数据量级很大的表给它拆成多个小表)

-- 用户调用接口关系表
create table if not exists `user_interface_info`
(
    `id` bigint not null auto_increment comment '主键' primary key,
  	`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 等工具进行日志存储和分析。这样,我们不用将这些调用信息直接存储在数据库中。当然,是否使用日志存储还要考虑系统的规模和量级。如果系统非常庞大,每天有大量的接口调用记录产生,那么使用日志存储是更为合适的选择。日志存储可以更好地处理大量的数据,并提供更强大的查询和分析功能。 ​ 综上所述,根据系统的量级和需求,可以选择使用日志存储来记录接口调用信息,而不是直接存储在数据库中。这样可以更好地管理和分析接口调用数据

总调用次数:指用户从第一次开通接口开始至今累计的调用次数

剩余调用次数:指用户每次购买接口后剩余的可调用次数

状态字段(status):决定是否允许其调用特定接口

​ 总调用次数是一直累加的,记录了用户在整个使用期间的累计调用次数。无论用户购买多少次接口,总调用次数都会随着每次调用的增加而增加。

​ 剩余调用次数则是在用户购买接口后,根据购买的次数和已使用的次数计算得出的。每次购买接口都会增加一定的调用次数,而每次实际调用接口后,剩余调用次数会相应减少。

​ 为了增加安全性,可以考虑为每个用户设置一个状态字段(status),来决定是否允许其调用特定接口。这样,如果用户触发了某些规则或违反了规定,我们可以将其状态设置为不允许调用该接口。通过添加一个状态字段,可以灵活地管理用户对接口的访问权限。例如,当用户违反规则时,可以限制其对某些接口的调用,而对其他接口仍然保持开放。这个状态字段可以帮助系统实现精确的接口访问控制。

2.模块开发

【1】后端接口开发

UserInterfaceInfo的CRUD

(1)借助MyBatisX插件生成代码,将其引入到后台系统项目中

​ 代码生成目录参考:

​ 迁移代码:将代码移动到对应的模块中

  • UserInterfaceInfo:移动到model/entity文件夹,其中isDelete字段需要添加逻辑删除设置@TableLogic
  • UserInterfaceInfoMapper:移动到mapper文件夹
  • UserInterfaceInfoService、UserInterfaceInfoServiceImpl:移动到service以及对应impl文件夹
(2)模块接口开发(controller构建)

​ 随后构建controller(可以从原有的interfaceInfoController进行改造),其思路就是将InterfaceInfo的CRUD操作替换成对UserInterfaceInfo的CRUD操作,结合实际业务扩展再进行调整(与此同时需要对接口方法进行访问限制,例如此处一些修改、删除操作只能提供给管理员进行访问)

接口调用请求参数定义

​ 引入用户接口调用情况的一些请求参数定义UserInterfaceInfoAddRequest、UserInterfaceInfoUpdateRequest、UserInterfaceInfoQueryRequest(相应思考用户在执行某个操作的时候需要去请求什么信息、处理什么信息即可),补充一些添加、修改的信息验证实现。

UserInterfaceInfoAddRequest

@Data
public class UserInterfaceInfoAddRequest implements Serializable {

    /**
     * 调用用户 id
     */
    private Long userId;

    /**
     * 接口 id
     */
    private Long interfaceInfoId;

    /**
     * 总调用次数
     */
    private Integer totalNum;

    /**
     * 剩余调用次数
     */
    private Integer leftNum;

}

UserInterfaceInfoUpdateRequest

@Data
public class UserInterfaceInfoUpdateRequest implements Serializable {

    /**
     * 主键
     */
    private Long id;

    /**
     * 总调用次数
     */
    private Integer totalNum;

    /**
     * 剩余调用次数
     */
    private Integer leftNum;

    /**
     * 0-正常,1-禁用
     */
    private Integer status;

    private static final long serialVersionUID = 1L;
}

UserInterfaceInfoQueryRequest

@EqualsAndHashCode(callSuper = true)
@Data
public class UserInterfaceInfoQueryRequest extends PageRequest implements Serializable {
    /**
     * 主键
     */
    private Long id;

    /**
     * 调用用户 id
     */
    private Long userId;

    /**
     * 接口 id
     */
    private Long interfaceInfoId;

    /**
     * 总调用次数
     */
    private Integer totalNum;

    /**
     * 剩余调用次数
     */
    private Integer leftNum;

    /**
     * 0-正常,1-禁用
     */
    private Integer status;

}
接口调用验证方法补充

​ 针对新增、修改的一些验证方法补充,controller中调用,在service中进行定义实现


# UserInterfaceInfoService
public interface UserInterfaceInfoService extends IService<UserInterfaceInfo> {
    void validUserInterfaceInfo(UserInterfaceInfo userInterfaceInfo, boolean add);
}

# UserInterfaceInfoServiceImpl
@Service
public class UserInterfaceInfoServiceImpl extends ServiceImpl<UserInterfaceInfoMapper, UserInterfaceInfo>
    implements UserInterfaceInfoService{

    @Override
    public void validUserInterfaceInfo(UserInterfaceInfo userInterfaceInfo, boolean add) {
        if (userInterfaceInfo == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        // 创建时,所有参数必须非空
        if (add) {
            if (userInterfaceInfo.getInterfaceInfoId() <= 0 || userInterfaceInfo.getUserId() <= 0) {
                throw new BusinessException(ErrorCode.PARAMS_ERROR, "接口或用户不存在");
            }
        }
        if (userInterfaceInfo.getLeftNum() < 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "剩余次数不能小于 0");
        }
    }
}

接口调用controller完善

​ 相应引入上述构建的内容,调整细节

@RestController
@RequestMapping("/userInterfaceInfo")
@Slf4j
public class UserInterfaceInfoController {

    @Resource
    private UserInterfaceInfoService userInterfaceInfoService;

    @Resource
    private UserService userService;


    // region 增删改查
    /**
     * 创建
     *
     * @param userInterfaceInfoAddRequest
     * @param request
     * @return
     */
    @PostMapping("/add")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<Long> addUserInterfaceInfo(@RequestBody UserInterfaceInfoAddRequest userInterfaceInfoAddRequest, HttpServletRequest request) {
        if (userInterfaceInfoAddRequest == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        UserInterfaceInfo userInterfaceInfo = new UserInterfaceInfo();
        BeanUtils.copyProperties(userInterfaceInfoAddRequest, userInterfaceInfo);
        // 校验
        userInterfaceInfoService.validUserInterfaceInfo(userInterfaceInfo, true);
        User loginUser = userService.getLoginUser(request);
        userInterfaceInfo.setUserId(loginUser.getId());
        boolean result = userInterfaceInfoService.save(userInterfaceInfo);
        if (!result) {
            throw new BusinessException(ErrorCode.OPERATION_ERROR);
        }
        long newUserInterfaceInfoId = userInterfaceInfo.getId();
        return ResultUtils.success(newUserInterfaceInfoId);
    }

    /**
     * 删除
     *
     * @param deleteRequest
     * @param request
     * @return
     */
    @PostMapping("/delete")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<Boolean> deleteUserInterfaceInfo(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {
        if (deleteRequest == null || deleteRequest.getId() <= 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        User user = userService.getLoginUser(request);
        long id = deleteRequest.getId();
        // 判断是否存在
        UserInterfaceInfo oldUserInterfaceInfo = userInterfaceInfoService.getById(id);
        if (oldUserInterfaceInfo == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
        }
        // 仅本人或管理员可删除
        if (!oldUserInterfaceInfo.getUserId().equals(user.getId()) && !userService.isAdmin(request)) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
        }
        boolean b = userInterfaceInfoService.removeById(id);
        return ResultUtils.success(b);
    }

    /**
     * 更新
     *
     * @param userInterfaceInfoUpdateRequest
     * @param request
     * @return
     */
    @PostMapping("/update")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<Boolean> updateUserInterfaceInfo(@RequestBody UserInterfaceInfoUpdateRequest userInterfaceInfoUpdateRequest,
                                                         HttpServletRequest request) {
        if (userInterfaceInfoUpdateRequest == null || userInterfaceInfoUpdateRequest.getId() <= 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        UserInterfaceInfo userInterfaceInfo = new UserInterfaceInfo();
        BeanUtils.copyProperties(userInterfaceInfoUpdateRequest, userInterfaceInfo);
        // 参数校验
        userInterfaceInfoService.validUserInterfaceInfo(userInterfaceInfo, false);
        User user = userService.getLoginUser(request);
        long id = userInterfaceInfoUpdateRequest.getId();
        // 判断是否存在
        UserInterfaceInfo oldUserInterfaceInfo = userInterfaceInfoService.getById(id);
        if (oldUserInterfaceInfo == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
        }
        // 仅本人或管理员可修改
        if (!oldUserInterfaceInfo.getUserId().equals(user.getId()) && !userService.isAdmin(request)) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
        }
        boolean result = userInterfaceInfoService.updateById(userInterfaceInfo);
        return ResultUtils.success(result);
    }

    /**
     * 根据 id 获取
     *
     * @param id
     * @return
     */
    @GetMapping("/get")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<UserInterfaceInfo> getUserInterfaceInfoById(long id) {
        if (id <= 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        UserInterfaceInfo userInterfaceInfo = userInterfaceInfoService.getById(id);
        return ResultUtils.success(userInterfaceInfo);
    }

    /**
     * 获取列表(仅管理员可使用)
     *
     * @param userInterfaceInfoQueryRequest
     * @return
     */
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    @GetMapping("/list")
    public BaseResponse<List<UserInterfaceInfo>> listUserInterfaceInfo(UserInterfaceInfoQueryRequest userInterfaceInfoQueryRequest) {
        UserInterfaceInfo userInterfaceInfoQuery = new UserInterfaceInfo();
        if (userInterfaceInfoQueryRequest != null) {
            BeanUtils.copyProperties(userInterfaceInfoQueryRequest, userInterfaceInfoQuery);
        }
        QueryWrapper<UserInterfaceInfo> queryWrapper = new QueryWrapper<>(userInterfaceInfoQuery);
        List<UserInterfaceInfo> userInterfaceInfoList = userInterfaceInfoService.list(queryWrapper);
        return ResultUtils.success(userInterfaceInfoList);
    }

    /**
     * 分页获取列表
     *
     * @param userInterfaceInfoQueryRequest
     * @param request
     * @return
     */
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    @GetMapping("/list/page")
    public BaseResponse<Page<UserInterfaceInfo>> listUserInterfaceInfoByPage(UserInterfaceInfoQueryRequest userInterfaceInfoQueryRequest, HttpServletRequest request) {
        if (userInterfaceInfoQueryRequest == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        UserInterfaceInfo userInterfaceInfoQuery = new UserInterfaceInfo();
        BeanUtils.copyProperties(userInterfaceInfoQueryRequest, userInterfaceInfoQuery);
        long current = userInterfaceInfoQueryRequest.getCurrent();
        long size = userInterfaceInfoQueryRequest.getPageSize();
        String sortField = userInterfaceInfoQueryRequest.getSortField();
        String sortOrder = userInterfaceInfoQueryRequest.getSortOrder();
        // 限制爬虫
        if (size > 50) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        QueryWrapper<UserInterfaceInfo> queryWrapper = new QueryWrapper<>(userInterfaceInfoQuery);
        queryWrapper.orderBy(StringUtils.isNotBlank(sortField),
                sortOrder.equals(CommonConstant.SORT_ORDER_ASC), sortField);
        Page<UserInterfaceInfo> userInterfaceInfoPage = userInterfaceInfoService.page(new Page<>(current, size), queryWrapper);
        return ResultUtils.success(userInterfaceInfoPage);
    }

    // endregion

}

​ 基础构建完成,启动项目测试接口是否正常调通

用户调用成功后自动统计调用次数

(1)代码实现

​ 用户调用成功后统计调用次数+1(即关联找到用户调用信息,修改相应的调用统计次数字段),在service中新增统计次数统计方法

public interface UserInterfaceInfoService extends IService<UserInterfaceInfo> {
    /**
     * 调用接口统计
     * @param interfaceInfoId
     * @param userId
     * @return
     */
    boolean invokeCount(long interfaceInfoId, long userId);
}


@Service
public class UserInterfaceInfoServiceImpl extends ServiceImpl<UserInterfaceInfoMapper, UserInterfaceInfo>
    implements UserInterfaceInfoService{

    @Override
    public boolean invokeCount(long interfaceInfoId, long userId) {
        // 判断(其实这里还应该校验存不存在,这里就不用校验了,因为它不存在,也更新不到那条记录)
        if (interfaceInfoId <= 0 || userId <= 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        // 使用 UpdateWrapper 对象来构建更新条件
        UpdateWrapper<UserInterfaceInfo> updateWrapper = new UpdateWrapper<>();
        // 在 updateWrapper 中设置了两个条件:interfaceInfoId 等于给定的 interfaceInfoId 和 userId 等于给定的 userId。
        updateWrapper.eq("interfaceInfoId", interfaceInfoId);
        updateWrapper.eq("userId", userId);
        // setSql 方法用于设置要更新的 SQL 语句。这里通过 SQL 表达式实现了两个字段的更新操作:
        // leftNum=leftNum-1和totalNum=totalNum+1。意思是将leftNum字段减一,totalNum字段加一。
        updateWrapper.setSql("leftNum = leftNum - 1, totalNum = totalNum + 1");
        // 最后,调用update方法执行更新操作,并返回更新是否成功的结果
        return this.update(updateWrapper);
    }

}

​ 在这里需要注意的是,由于用户可能会瞬间调用大量接口次数,为了避免统计出错,需要涉及到事务和锁的知识。在这种情况下,如果是在分布式环境中运行的,那么可能需要使用分布式锁来保证数据的一致性。事务是一组操作的集合,要么全部成功,要么全部失败回滚。在这个场景中,我们希望在更新用户接口信息的时候,保证原子性,即要么用户接口信息全部更新成功,要么全部不更新。锁的作用是为了防止多个线程或进程同时修改同一个数据,造成数据不一致的情况。在分布式环境中,我们需要使用分布式锁来确保在多个节点上对数据的访问是互斥的。

​ 然而,基于上述实现的代码中并没有实现事务和锁的逻辑。这里只是演示了整体的流程,并没有具体实现细节。所以,如果要在实际项目中应用这个功能,还需要进一步考虑并实现事务和锁的机制,以确保数据的一致性和安全性。

待扩展:确保分布式部署环境下数据更新的一致性

(2)测试

​ 上述代码实现执行的SQL参考:可以执行测试是否修改成功

​ 也可构建测试用例测试是否正常修改

@SpringBootTest
public class UserInterfaceInfoServiceTest {

    @Resource
    private UserInterfaceInfoService userInterfaceInfoService;

    @Test
    public void invokeCount() {
        // 调用了userInterfaceInfoService的invokeCount方法,并传入两个参数(1L, 1L)
        boolean b = userInterfaceInfoService.invokeCount(1L, 1L);
        // 表示断言b的值为true,即测试用例期望invokeCount方法返回true
        Assertions.assertTrue(b);
    }
}

(3)问题扩展:AOP概念引入

​ 调用次数统计方法编写完成,接下来就是要考虑在什么是否去触发统计次数修改这个操作?

​ 例如用户调用每个接口成功之后,是不是就要开始调用咱们刚刚的这个方法 invokeCount,然后给当前接口的这个次数加 1。但如果说每一个方法的调用成功之后,返回结果之前都要调用一次invokeCount方法,会显得非常繁琐。

​ 例如在前面设计的逻辑中如果在api-platform-interface中去完成这个接口调用统计操作的话,就会变成每个接口请求调用都要在此基础上进行统计,变成每个接口开发者都要去添加统计代码。为了解决这个问题,可以考虑引入AOP切面概念,将一些通用的逻辑操作抽离出来,在原有的业务逻辑基础上增加额外的操作而不需要改动原有的业务逻辑(参考接口调用日志记录访问调调)

​ 假设有很多接口A、接口B、接口C 等,每个接口都需要调用成功后进行调用次数加 1 的操作。如果每个接口的实现都手动去添加这个统计次数的逻辑,那将非常繁琐,而且容易出错。 为了解决这个问题,需要将统计次数的逻辑抽取出来,让它成为一个通用的功能,最大的问题是对于接口提供者来说,他们是无感知的,会出现一些问题。在这里,可以使用 AOP(面向切面编程)来实现这个功能。AOP 允许在原有业务逻辑的基础上,增加额外的操作,而不需要改动原有代码。具体来说,可以通过 AOP 切面、拦截器或者过滤器来实现这个统计次数的逻辑。在接口调用成功后,AOP 切面或拦截器可以自动触发调用次数加 1 的方法,从而实现统一的统计功能。(还有一种方式是可以考虑将这个统计次数逻辑封装为一个通用的方法进行处理,可以解决一些负责业务逻辑场景的应用),此处借助AOP将统计次数的逻辑从业务逻辑中解耦出来实现统一的处理

​ 还有一种考虑是,既然用户调用接口是通过api-platform-backend转发的,是否可以考虑在转发响应成功之后直接在api-platform-backend中进行数据统计(这种是最基础的一种处理方式),即现有接口调用统计可以有两种设计思路

【1】用户请求调用,由api-platform-backend转发调用api-platform-interface接口,随后api-platform-interface接收请求进行权限校验,满足调用条件则在响应返回结果之前进行调用次数统计(这种方式要求接口提供方进行调用次数统计,如果一个来源需要提供多个不同的接口,则可通过AOP进行解耦避免重复代码编写,但不同的接口提供方又要相应去做统计)

【2】用户请求调用,由api-platform-backend转发调用api-platform-interface接口,随后api-platform-interface接收请求进行权限校验,满足调用条件则在响应返回结果,这个时候再由后台对这个响应结果进行校验并进行调用次数统计(此处可能设计对接口调用响应结果的一些比较细化的校验,例如调用成功、失败、权限验证失败等一些情况是否纳入统计范畴等)

3.网关

【1】概念引入

​ 基于目前项目架构设计,要实现的统计功能设计到多个项目之间的调用,而不仅仅是单个项目内的统计。虽然 AOP 切面是一个不错的解决方案,但它有一个缺点:它是独立于单个项目的,每个项目都需要自己实现统计逻辑,并引入相应的 AOP切面包。(即相当于每个接口提供方都要去关注接口调用统计这块的实现)

​ 或许基于最简单的实现方式,就是将统计逻辑封装为一个方法,在api-platform-backend中实现统计逻辑(因为接口调用都是通过这个后台系统转发的,而不是直接调用接口平台提供方)

​ 但考虑到实际项目架构设计场景,可能往往不止”调用接口统计”这个业务需要处理,因此希望实现一种通用的统计方案,可以统一处理所有项目的接口调用情况,并且能够更好地扩展其他业务功能。因此,可以采用网关概念来实现这个功能。修改刚刚的架构图:

img

​ 按照之前提到的 AOP 切面方案,项目A 的开发者需要引入 AOP 切面并编写相关代码,同样地,项目B 的开发者也需要进行类似的操作。这样的做法会导致每个开发者都要关注统计功能的实现,需要引入一些代码并编写额外的逻辑。为了避免这种情况,将统计次数的功能再抽出来一层。可以将统计次数的逻辑放在一个公共的位置,就像进入火车站一样,无论你乘坐哪趟列车,都需要经过这个统一的检票口。同样地,无论哪个模拟接口被调用,都会经过这个统一的统计次数逻辑。

​ 目前现在做的这些事情,可能从实现上来说没有什么是加一层解决不了的。只要在同一个项目或者某一个层级中,存在相似或重复的东西,都可以将其抽象成一层,将其往上或往后抽出。这个抽象过程就是网关网关位于项目的最前面,统一处理不同项目之间的请求,是一个不断抽象的过程。

​ 网关就像刚才提到的火车站,在进站前必须经过一个统一的检票口,这个检票口就是网关。通过网关进行检票后,再去找到不同的车厢。

​ 现在同理,假设用户要调用接口 A:

​ 如果没有网关,其操作流程:先调用接口 A,然后接口 A 再调用统计次数方法,接着调用接口 B,再去调用统计次数方法……以此类推。

​ 现在用户直接调用网关,由网关负责根据用户请求的地址,找到对应的接口,比如接口 A,然后调用接口 A,并在调用后统计次数加 1。同样,用户调用接口 B 或接口 C,也是先调用网关,然后网关再去找相应的接口并进行调用。

​ 现在有一个问题,用户是否需要关心自己调用的接口是由项目 A 或团队 A 开发的,还是团队 B 开发的?实际上,用户根本不需要关心。他只需要知道自己需要什么功能,然后调用对应的网关即可。网关负责找到对应的接口并返回结果。对于开发者来说,他们也不需要关心统计次数。只要把自己的接口接入到网关中,让网关能找到并调用即可,网关会自动帮他们统计次数。 通过这种设计,实现了一个统一的网关来处理不同项目的请求,用户和开发者都不需要关心具体的细节,简化了操作,提高了系统的可用性和可维护性。

【2】网关概念分析

什么是网关?

什么是网关?理解成火车站的检票口,统一 去检票。

作用:统一去进行一些操作、处理一些问题。

(1)路由

(2)负载均衡

(3)统一鉴权

(4)跨域

(5)统一业务处理(缓存)

(6)访问控制

(7)发布控制

(8)流量染色

(9)接口保护:限制请求、信息脱敏、降级(熔断)、限流(学习令牌桶算法、漏桶算法、RedisLimitHandler )、超时时间

(10)统一日志

(11)统一文档

简略说明:

路由:起到转发的作用,比如有接口 A 和接口 B,网关会记录这些信息,根据用户访问的地址和参数,转发请求到对应的接口(服务器 / 集群)。

/a => 接口A;/b => 接口B

参考文档:The After Route Predicate Factoryopen in new window

负载均衡:在路由的基础上

/c => 服务 A / 集群 A(随机转发到其中的某一个机器)

uri 从固定地址改成 lb:xxxx

统一处理跨域:网关统一处理跨域,不用在每个项目里单独处理。

参考文档:Global CORS Configurationopen in new window

发布控制:灰度发布,比如上线新接口,先给新接口分配 20% 的流量,老接口 80%,再慢慢调整比重。

参考文档:The Weight Route Predicate Factoryopen in new window

流量染色:给请求(流量)添加一些标识,一般是设置请求头中,添加新的请求头。

参考文档:TheAddRequestHeaderGatewayFilterFactoryopen in new window

全局染色:Default Filtersopen in new window

统一接口保护

统一业务处理:把一些每个项目中都要做的通用逻辑放到上层(网关),统一处理,比如本项目的次数统计

统一鉴权:判断用户是否有权限进行操作,无论访问什么接口,我都统一去判断权限,不用重复写

访问控制:黑白名单,比如限制 DDOS IP

统一日志:统一的请求、响应信息记录

统一文档:将下游项目的文档进行聚合,在一个页面统一查看。建议用:knife4j 文档

​ 什么是网关呢?可以将网关看作是网络端口,类似于火车站或者机场的检票口,它负责统一进行检票。在火车站里,不同车厢需要单独检票吗?实际上不需要,因为网关在入口处已经完成了检票,节省了很多人力成本。之后你可以自己去找对应的车厢。这里有一个关键词,网关的主要作用就是统一,它可以统一执行很多操作。 ​ 可以通过应用场景,逐渐了解为什么需要网关。它对于用户来说屏蔽了底层的调用细节,并能保护接口。用户不需要直接调用接口,也不需要关心统计次数,只需在调用之前进入网关才能成功调用。

网关的应用场景

网关的应用场景

​ 结合目前项目设计架构,分析网关在这个项目场景中的应用

路由:路由实际上就像一个中转站,类似于路由器。

​ 参考上述图示,假设用户要访问某个接口 A,但现在用户不需要直接调用接口 A,而是通过网关统一接收用户的请求。网关记录了用户调用的接口,并将其转发到对应的项目和接口进行处理,有点类似于前台接待。

​ 路由在这里起到了转发的作用。举个例子,假设有接口 A 和接口 B,网关会记录这些信息,并根据用户访问的地址和参数,将请求转发到对应的接口(服务器/集群)。为了更好地理解,可以设置以下示例路由:如果用户访问接口 A,网关将转发请求到接口 A;如果用户访问接口 B,网关将转发请求到接口 B,这种转发过程就叫做路由。此外,还有一种情况是后面可能对接到一个集群。比如,当用户访问接口 C 时,网关可以将请求转发给服务 A 或者集群中的某个机器。在集群中,请求可能会随机转发到其中的某个机器上。

统一鉴权:判断用户是否有权限进行操作,无论访问什么接口,我都统一去判断权限,不用重复写。

​ 之前的鉴权逻辑写在 api-platform-interface 项目中的方法里,用于判断用户是否有权限进行操作。但是如果每个方法或者是每个项目都要单独写鉴权逻辑,显然是不可行的。所以此处可以将鉴权逻辑和统计次数一样,抽取出来放到网关里面

​ 在网关中,鉴权的重点是实现统一鉴权。无论用户要访问哪个接口,网关都会统一判断权限,不需要重复编写鉴权逻辑,这是网关的强调点之一。网关的作用在很多方面都是强调统一性,将重复的逻辑进行抽象和集中。

统一处理跨域:网关统一处理跨域,不用在每个项目里单独处理。

​ 在开发单个 Spring Boot 项目或 Web 项目时,跨域问题是一个常见的挑战。特别是在接口项目中,可能存在多个项目如项目 A、项目 B 等,每个项目都可能面临跨域问题。如果每个项目都要单独处理跨域,就会出现重复劳动的情况。

​ 为了避免重复的跨域处理,可以将跨域处理逻辑统一放到网关中,让网关来帮助我们处理跨域问题。这样,项目 A 和项目 B 就不再需要单独处理跨域,而是统一由网关处理。这是一种统一处理跨域的方法。

统一业务处理:把一些每个项目中都要做的通用逻辑放到上层(网关),统一处理,比如本项目的次数统计。

​ 在项目中,统一的业务处理指的是将项目中重复的业务逻辑抽取出来,放到网关这一层进行处理。例如,项目中可能存在一些通用的逻辑,比如统计调用次数和鉴权等。如果我们把这些逻辑写在每个项目的方法里,就会导致重复代码和维护困难。为了避免重复的代码,可以将这些统一的业务逻辑放到网关层面进行处理。也就是说,可以把统计调用次数和鉴权的代码直接写在网关中,而不是每个方法里。这样,只需要在网关里写一次这些逻辑,就可以统一处理所有项目的调用次数统计和鉴权需求。通过网关的统一处理,我们可以将一些通用的业务逻辑进行封装,让项目中的方法更加清晰、简洁,同时也避免了重复劳动。

访问控制:黑白名单,比如限制 DDOS IP

​ 访问控制,又称为黑白名单,实际上也是一种权限控制机制。它与鉴权有一些区别。鉴权通常指授权,即判断用户是否有访问某种资源的权限。而黑白名单则主要用于判断每个用户是否可以访问特定资源,它是一种与业务逻辑独立的控制方式。

​ 举个例子,如果有人恶意刷系统流量,进行 DDOS 攻击,可以将这些恶意 IP 加入黑名单,限制它们的访问。这样,这些 IP 就无法访问我们的服务,从而保护了系统接口和服务不受恶意攻击。

发布控制:灰度发布,比如上线新接口,先给新接口分配 20% 的流量,老接口 80%,再慢慢调整比重。

​ 举个例子,假设团队开发了一个名为项目 A 的接口 A,现在要对接口 A 进行升级,推出一个新版本的接口 A-V2。但并不确定新版本是否稳定可靠,所以想先让一部分用户试用这个新接口。我们可以将流量按照比例划分,比如 80% 的流量继续访问旧版本的接口 A,而 20% 的流量则引导到新版本的接口 A-V2。这样就实现了灰度测试的效果,然后开发者会观察 V2 的表现,如果测试没有问题,就可以逐步增加流量比例,比如 50%、70%、80%,直到 100%。最后,当确认新版本的接口稳定可靠时,就可以完全替换掉旧版本,下线接口 A。

​ 这个流量分配的过程就是发布控制,而它通常是在网关层进行。因为网关是整个流量的入口,所以它可以担当请求流量分配的角色。通过在网关层进行发布控制,我们能够更加灵活地控制用户访问不同版本接口的比例,而无需在每个服务中进行单独处理。这种方式让我们能够更加安全和可靠地进行接口的升级和发布。

流量染色:给请求(流量)添加一些标识,一般是设置请求头中,添加新的请求头。

​ 假设现在有一个用户要访问某个接口。但是有一个问题,接口提供方希望用户不能绕过网关直接调用接口,我想要防止这种情况发生。那么应该如何防止绕过网关呢?

​ 一个方法是要确定请求的来源。可以为用户通过网关来的请求打上一个标识,比如添加一个请求头 source=gateway。只要经过网关的请求,网关就会给它打上 source=gateway 的标识。接口 A 就可以根据这个请求头来判断,如果请求没有 source=gateway 这个标识,就直接拒绝掉它。这样,如果用户尝试绕过网关,没有这个请求头的话,项目就不会认可它。这就是流量染色的一种应用。流量染色还有其他应用,比如区分用户的来源,这和鉴权是不同的概念,属于不同的应用场景。

​ 另外一个常见的应用是用于排查用户调用接口时出现的问题。我们为每个用户的每次调用都打上一个唯一的 traceid,这是分布式链路追踪的概念。通过这个 traceid,当出现问题时,下游服务可以根据 traceid 追踪到具体的请求,从而逐层排查问题。这也是流量染色的作用之一。

​ 总的来说,流量染色的作用就是给请求添加一些标识,通常通过设置请求头来实现。这样可以更好地控制请求的访问和识别请求的来源,同时方便排查问题。流量染色还有许多其他的应用,但它们都是基于给请求添加标识这个基本概念。

​ 扩展问题:**用户怎么绕过网关?用户只要知道服务器的 IP 地址,尤其你的服务又在外网上公开时,用户就可以直接绕过网关进行访问。**这种情况下,开发者不能让这些接口直接对外暴露,而需要网关来隐藏这些接口信息。

统一接口保护:接口保护涉及多种方式,例如限制请求信息、数据脱敏、降级、限流、超时时间等措施。在网关中,可以统一进行请求大小的限制和数据脱敏处理,强调了统一的管理。

​ 对于接口保护,可以通过网关统一限制请求的大小,确保接收到的请求在合理的范围内,避免恶意请求或者大量请求对后端服务造成不必要的负担。同时,也可以统一对请求和响应头进行处理。举个例子,有些接口原本会在响应头中返回服务器的 IP 地址等敏感信息,但通过网关的操作,可以将这些敏感信息抹掉或删除,保护服务器的隐私和安全。

​ 另一个重要的保护机制是降级。当接口调用失败或接口下线时,可以采取降级逻辑,比如向用户提示接口已下线,或引导用户访问其他功能,从而确保用户始终能够得到有意义的响应。降级也被称为兜底,作为一种保险措施,即使正式服务不可用,仍能提供有用的反馈。

​ 限流也是接口保护的重要手段。通过限制用户每分钟或每秒钟访问接口的次数,可以避免过多的请求对服务器造成压力。此外,设置超时时间也是保护服务器的一种方式,当接口调用时长超过设定时间,强制中断请求,保证服务器的稳定性。这些保护措施都是在网关层面进行统一处理,确保接口的安全和可靠性。

统一日志:统一的请求、响应信息记录。

​ 在微服务项目中,这样的情况是相当常见的。例如,有两个项目:项目 A 和项目 B,它们各自可能记录一些日志。而在网关层,可以再加一层日志记录,以便记录每个用户请求的详细信息,包括鉴权情况以及每次响应的成功或失败信息。这种做法类似于之前提到的 AOP 切面,只不过现在把这个逻辑放到了网关层而已。

统一文档:将下游项目的文档进行聚合,在一个页面统一查看。

​ 举个例子,假设有两个项目,分别是项目 A 和项目 B,并且每个项目都有一个对应的接口文档。通常情况下,如果要访问这两个项目的接口文档,需要分别访问项目 A 和项目 B 的接口文档地址,对吧? ​ 但是,如果在网关层将这两个接口文档聚合在一起,会有什么作用呢?这实际上类似于语雀(一个在线文档协作平台)的作用。通过在网关层聚合接口文档,可以在一个统一的地方查看和管理所有项目的接口文档,不再需要分别去访问不同的地址。

​ 假设原本每个项目都有一个独立的接口文档,现在可以在网关层将这些文档进行聚合,统一放到一起。这样用户在查看接口文档时就会更加方便,不再需要关注项目 A 的文档在哪个地址,项目 B 的文档在哪个地址,而是可以直接在网关处找到所有项目的接口文档。这种做法简化了文档查阅的步骤,让用户不再需要分别去查找每个项目的文档,从而提高了工作效率。总体来说,网关层的接口文档聚合功能为用户提供了更便捷的文档管理和查阅体验。

网关分类

全局网关(接入层网关):作用是负载均衡、请求日志等,不和业务逻辑绑定。

业务网关(微服务网关):会有一些业务逻辑,作用是将请求转发到不同的业务 / 项目 / 接口 / 服务。

参考文章:网关的介绍open in new window

进一步说明:

​ 当涉及网关分类时,一般情况下有两种主要类型:业务网关和全局网关,其中接入层网关也是全局网关的一种。它们有着一些区别。业务网关通常位于多个项目或微服务之上,负责根据用户请求将其转发到不同的业务、项目、接口或服务。另一方面,全局网关更多地关注请求本身,主要用于负载均衡。全局网关在大多数情况下并不涉及复杂的业务逻辑。相比之下,业务网关可能会包含一定的业务逻辑,比如之前提到的统计次数功能,这会影响你在技术选型上的决策。

​ 然而,实际上你并不一定要明确区分业务网关和全局网关,因为它们的分类对于技术选型并不是绝对的要求。重要的是根据系统需求来选择适合的网关类型。全局网关的主要功能是负载均衡,将大量的请求平均分摊到系统中的多台机器上。它通常不涉及过多的业务逻辑,而更注重处理请求日志等任务。业务网关则更多地关注业务逻辑,例如统计次数、请求鉴权等,同时也会负责转发请求到具体的业务处理单元

网关技术选型

网关技术选型open in new window

​ Nginx(全局网关)、Kong 网关(API 网关,Kong),编程成本相对高一点

​ Spring Cloud Gateway(取代了 Zuul)性能高、可以用 Java 代码来写逻辑,适于学习

​ 为什么要做网关的分类?在实现网关时,一般情况下,会遇到两种网关:业务网关和全局网关,而接入层网关其实是全局网关的一种。

​ 这两种网关有什么区别呢?全局网关通常层级较高,可能覆盖多个项目或微服务,并负责将用户的请求转发到不同的业务、项目、接口或服务。它主要用于请求的负载均衡等功能,较少涉及具体的业务逻辑。可以使用 Nginx 或类似的网关,例如 Kong,Kong 是专门为 API 服务提供的网关。但是不推荐使用 Kong 的原因是,它有商业版本和免费版本,而免费版本可能会有一些限制。如果没有必要使用商业版本的特性,不建议个人用户使用 Kong,因为它可能会限制一些自由度和灵活性。

​ 相比之下,Nginx 是比较推荐的全局网关,也称为接入层网关。Nginx 可以部署前端和后端,还能提供文件访问服务等多种功能,非常灵活。甚至可以在 Nginx 中编写业务逻辑,但是并不推荐这样做,因为它并不像 Spring Cloud Gateway 那样方便。

​ 对于业务网关,特别是使用 Spring Boot 技术栈的情况下,强烈推荐使用 Spring Cloud Gateway ,它可以说是取代了 Zuul。Zuul 的架构设计有一些问题,例如并发量有限。而 Spring Cloud Gateway 则使用了 NIO 和多路复用等技术,底层采用了 native 和 react 模型,因此性能更高。

​ Spring Cloud Gateway 的最大优点是它允许开发者使用 Java 代码来编写逻辑。相比于 Nginx 或 Kong,需要学习额外的语言和编程,但在现阶段来说并不是必要的。使用 Spring Cloud Gateway,你只需要掌握 Java 编程就足够了,这也是为什么推荐使用它的原因。如果对 Nginx 或 Kong 感兴趣,需要知道 Spring Cloud Gateway 的实现,再去查阅 Nginx 或 Kong 的文档就能快速上手。最重要的是理解网关的实现思想,这对于实际的开发非常关键。

【3】Spring Cloud Gateway

DEMO构建

Spring Cloud Gateway官网open in new windowSpring Cloud Gateway官方文档open in new window

定义的是一个匹配器,或者更明确地说,在 Spring Cloud Gateway 中它被称为"断言"。

  • 路由(根据什么条件,转发请求到哪里)

  • 断言:一组规则、条件,用来确定如何转发路由

  • 过滤器:对请求进行一系列的处理,比如添加请求头、添加请求参数

请求流程:

  • 客户端发起请求

  • Handler Mapping:根据断言,去将请求转发到对应的路由

  • Web Handler:处理请求(一层层经过过滤器)

实际调用服务:

两种配置方式:

配置式(方便、规范,推荐):简化版、全称版

编程式(灵活、相对麻烦)

断言:

  • After 在 xx 时间之后
  • Before 在 xx 时间之前
  • Between 在 xx 时间之间
  • 请求类别
  • 请求头(包含 Cookie)
  • 查询参数
  • 客户端地址
  • 权重

过滤器:

  • 基本功能:对请求头、请求参数、响应头的增删改查
  • 添加请求头
  • 添加请求参数
  • 添加响应头
  • 降级
  • 限流
  • 重试

案例测试

​ 创建api-platform-gateway项目,引入相关依赖配置,修改maven仓库配置并更新

​ 修改application.peoperties文件为application.yml文件,并配置server启动端口为8090

server:
  port: 8090

​ 修改ApiPlatformGatewayApplication进行测试

@SpringBootApplication
public class ApiPlatformGatewayApplication {


    public static void main(String[] args) {
        SpringApplication.run(ApiPlatformGatewayApplication.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"))
                // 定义路由规则,给该规则起一个名字 "toMyBlog"
                .route("toMyBlog", r -> r.path("/toMyBlog")
                        // 将满足 "/toMyBlog" 路径的请求转发到 "http://blog.holic-x.com"
                        .uri("http://blog.holic-x.com"))
                // 创建并返回路由规则配置对象
                .build();
    }
}

​ 启动项目,访问http://localhost:8090,跳转出错是因为没有配置要访问的路由

​ 重新分别访问http://localhost:8090/baidu、http://localhost:8090/toMyBlog(百度访问出错可能是因为百度做了限制转发导致,此处可以设置访问自己的一些网站尝试)

​ 访问toMyBlog路由可以看到URL被重定向到https://blog.holic-x.com/toMyBlog

官方文档参考

(1)如何使用

Spring Cloud Gatewayopen in new window

​ 如何使用参考上述demo构建,完成一个最简单的小测试。

(2)核心概念

核心概念分析

​ 基于简单案例,了解其核心概念。

路由:用于根据请求的网址进行转发。

predicate(断言):根据一组条件来进行路由,可以将它理解为一组规则。(例如上述案例中根据请求的地址来进行转发,实际上就是一个断言)

​ filter(过滤器):其作用 Servlet 过滤器。它可以对请求进行集中处理,例如进行一些校验或添加请求头等操作。实际上,过滤器的功能有点类似于 AOP 切面或 Java 拦截器,只不过在这里被称为网关的过滤器,但作用和处理请求的目的是一样的

(3)工作原理

了解Spring Cloud Gateway的工作原理

​ 根据图示理解其工作原理,客户端就是我们的用户,就是要请求网站的人。首先,请求经过了 Gateway Handler Mapping(它的作用是根据你的请求找到对应的路由)。也就是说,当用户发送请求时,Gateway Handler Mapping 会根据请求的信息来确定该请求应该被路由到哪个地方。

​ Gateway Handler Mapping 它的作用就是处理请求,它要经过一系列的过滤器,也就是说这个过滤器可以去定义多个。比如说定义一个鉴权过滤器,专门用来从请求头中去鉴权。再定一个日志过滤器,专门用来去记日志。再定一个跨域过滤器都是可以的。它可以经过多种过滤器,过滤器之间也可以有顺序,先经过哪个后经过哪个。

​ 最后,就是实际调用我们的服务。那实际调用服务,调用的就是我们的真实提供的接口,例如本项目的api-platform-interface

(4)如何配置?

​ 基于上述三个核心概念,思考如何配置路由、过滤器、断言

​ 此处Spring Cloud Gateway提供了两种配置网关的方式。两种方式各有其特点,结合实际开发场景进行应用

方式1:配置式/声明式配置,即在 application.yml文件中写配置(推荐)

​ 通过yml配置方式可以参考上述4.1、4.2提供的样例,其中4.1种为简单参数配置方式;4.2中提供的参数比较全面

【1】简单参数配置

spring.cloud.gateway.routes:   // 配置路由的属性
- id: after_route              // 路由的唯一标识符,用于区分不同的路由
  uri: https://example.org     // 路由将请求转发到的目标 URI,即请求经过此路由后将被转发到 https://example.org 这个地址
  predicates:                  // 这是断言的配置属性,用于定义请求是否满足路由条件
	- Cookie=mycookie,mycookievalue // 断言条件:指定请求必须具有名为mycookie的 Cookie,且其值必须为 mycookievalue,才能匹配这个路由
	
上述配置实现:当满足请求带有特定mycookie的Cookie并且其值为mycookievalue时,请求将被路由到https://example.org这个目标 URI

【2】复杂参数配置

复杂参数配置前面的内容基本没变,主要针对predicates进行更细化的配置说明
spring:
  cloud:
    gateway:
      routes:
      - id: after_route
        uri: https://example.org
        predicates:
        - name: Cookie
          args:
            name: mycookie
            regexp: mycookievalue
            
上述配置实现:当请求带有名为mycookie的Cookie,并且其值为 mycookievalue 时,请求将被路由到 https://example.org 这个目标 URI。注意这里使用了正则表达式来匹配 Cookie 的值

方式2:编程式配置,类似刚刚demo中通过编写代码实现配置

(5)路由的各种断言

predicate 它用的是复数,说明可以设定多个断言规则同时使用

​ 可以参考官方提供的各种示例对其应用进一步了解

​ 5.1、5.2示例:

The After Route Predicate Factory:当你的当前时间,在它设置的时间之后,它就会访问 https://example.org 这个路由

The Before Route Predicate Factory :当你的当前时间,在它设置的时间之前,它就会访问 https://example.org 这个路由

​ 这个类似于现实生活中某些活动的开始和结束。举个例子,假设提供了两个接口:接口 A 用于在活动结束前进行访问,而接口 B 则在今天零点活动结束后提供访问。

​ 换句话说,如果用户在活动结束前访问接口 A,他将获得活动相关的内容和服务。然而,一旦活动结束,用户将被引导到接口 B,从而获得其他类型的服务或内容。这样的接口设计可以根据活动时间的变化提供不同的功能和响应,为用户带来更好的体验

​ 测试:取消原有编程式配置,在application.yml配置,测试访问http://localhost:8090/,可以看到页面自动被重定向到指定的URL

image-20240315154614259

​ 继续浏览官方文档,其主要配置都是和前面的大同小异,主要是梳理断言规则

5.3:between 为在两个时间之间

5.4:Cookie Route 为如果请求头中有了 cookie 且 cookie 的值是ch.p,重定向到地址 https://example.org

5.5:Header Route,你的请求头中包含了一个名为 X-Request-Id 的请求头,并且它的值符合正则表达式 \d+(即为一个或多个数字),那么将被重定向到相应的地址。

5.6:Host Route,如果访问的是指定的域名,那么就会被重定向到对应的地址。同时,这里可以使用通配符,即如果访问的是这两个域名中的任何一个,都会被重定向到相应的地址

5.7:Method Route,如果请求方法、请求类别,它是 get 或者 post 的请求,就重定向到相应地址。

5.8:Path Route,如果访问的地址是以对应路径作为前缀的,那么就访问到对应的地址

application.yml配置
server:
  port: 8090
spring:
  cloud:
    gateway:
      routes:
        # 将请求路径以/api/**开头的请求转发到目标URI https://blog.holic-x.com
        - id: path_route1
          uri: https://blog.holic-x.com
          predicates:
            - Path=/api/**
        # 将请求路径以/baidu/**开头的请求转发到目标URI https://baidu.com
        - id: path_route2
          uri: https://baidu.com
          predicates:
            - Path=/baidu/**

启动项目访问http://localhost:8090/api/name,路由转发:https://blog.holic-x.com/name(访问404是因为该网站下没有这个路径)

同理访问http://localhost:8090/baidu/xxx,路由转发https://baidu.com/xxx(访问404是因为该百度下没有这个路径)

​ 此处分析一个问题,当输入一个地址后,不知道它重定向到哪个规则进行处理,即无法确定应用了哪个路由规则。有时候只能通过输入类似百度这种特别明显的网址来区分。因此,在开发过程中,可以加上配置:以下配置是将 Spring Cloud Gateway 的日志级别设置为 "trace",这意味着 Spring Cloud Gateway 将输出最详细的日志信息,包括所有的跟踪信息。通过设置这个日志级别,我们可以查看每个请求在网关中的处理流程、断言、过滤器的执行情况以及最终路由的结果,有助于调试和排查问题。需要注意的是,"trace" 级别会产生大量的日志,仅在调试和排查问题时使用,生产环境应该将日志级别设置为更适合的水平,以避免过多的日志输出影响性能。

​ 可根据其日志信息跟踪查看其符合哪条匹配规则进行转发

5.9:Query Route,通过它可以根据查询条件进行路由匹配,比如说你的请求参数中包含一个名为 "green" 的查询参数,那它就会匹配到这个地址

5.10:Remoteaddr Route,它允许我们根据远程地址来进行路由匹配。比如访问用户的 IP 地址是 192.168.1.1/24,就重定向到指定的地址 https://example.org

5.11:Weight Route,这个功能非常重要,它允许我们根据权重来将请求重定向到不同的目标

​ 例子中定义了两个 URL,一个是 weight_high(高权重的目标),另一个是 weight_low(低权重的目标)。然后,设置了一组权重断言,其中高权重为 8(百分之八十),低权重为 2(百分之二十)。最后,我们只需定义这个规则,假设用户访问了十次,那么就会有八次请求导向高权重的目标,而有两次请求导向低权重的目标。通过这种方式,我们轻松实现了刚刚提到的发布控制或者灰度发布的需求

5.12:XForwarded Remote Addr Route,这个功能允许我们从请求头中获取 X-Forwarded-Remote-Addr 这个字段,如果它的值是 192.168.1.1/24,那么就会将请求重定向到地址 https://example.org

(6)拦截器

​ 拦截器的作用是对请求进行处理(对请求进行修改或者处理响应操作)

6.1:AddRequestHeader,它的作用是添加请求头

​ 类似请求染色概念,请求染色其实就是给请求打上标识,以证明这个请求是从我这儿发起的,这样下游服务就能识别它。通过在请求头中添加特定的标识,比如某个指定的字段,以此可以实现对接口的保护,确保只有带有这个请求头的请求才能被下游服务认可并允许调用。这是染色的方式之一,还可以增加额外的参数等等。

​ 参考api-platform-interface工程配置,请求一个接口:localhsot:8080/api/getNameByGet(设置断点访问确认是否正常转发)

​ 在api-platform-gateway中配置添加请求头过滤器

​ 如果console能够正常打印swag文本说明请求转发配置正常,且请求头被正常添加。

6.3:AddRequestParameter,增加请求参数

​ 类似的,请求添加参数,可以看作是一种控制请求信息的方法。在后端开发中,经常会遇到一层一层的服务器调用的情况。在这种场景下,每一层的服务器都可能会给请求添加自己的请求头,逐层补充信息。这个过程有点类似于计算机网络中数据包的封装,其中包含了网络头和其他一些信息。

​ 例如此处基于上述案例中,继续补充配置:AddRequestParameter,随后访问http://localhost:8090/api/getMyName,可以看到接口正常响应并返回数据(也可跟踪console打印)

6.4:AddResponseHeader 添加响应头,类似功能参考上述案例

6.5:CircuitBreaker(断路器),断路器的作用是实现服务降级。当访问某个接口地址时,如果这个接口出现错误,断路器会将请求降级,从而转而请求另外一个接口

​ 在api-platform-gateway项目中引入spring-cloud-starter-circuitbreaker-reactor-resilience4j,它是由 Spring Cloud Starter 整合的。resilience4j 是一个面向 Java 的降级库,它能够帮助我们定义各种降级逻辑,根据不同的情况触发不同的降级策略。例如,当访问本地接口出现问题时,我们可以触发一种降级策略;当访问其他服务出现异常时,我们也可以采取不同的降级逻辑。

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>

​ CircuitBreaker: 定义了一个断路器过滤器,用于实现降级功能。该断路器的名字是 "myCircuitBreaker",当路由目标地址出现故障时,会触发降级策略,将请求转发到 "/fallback" 路径。

​ 路由 "noob-fallback",定义了一组断言,这里使用 "Path=/fallback" 断言,表示请求的路径为 "/fallback" 时,会匹配到该路由。

​ 以下配置中,"add_request_header_route" 路由会对请求添加请求头和请求参数,并通过断路器实现降级功能。当该路由的目标地址出现故障时,请求会被转发到 "/fallback" 路由,即 "my_fallback" 路由,从而实现了降级处理。

​ 降级是一个不太容易测试的功能,但它会在特定条件下触发。如果希望在触发降级时能够进行全局的异常处理或者执行特殊的处理逻辑,可以自己定义不同的降级策略,根据不同的触发条件来实现这些逻辑。

​ 例如此处配置完成重新启动访问http://localhost:8090/api/getMyName,正常情况下应该响应获取到数据,但是如果此时将api-platform-interface项目关闭,则其转发不能访问到指定的接口,则会降级转入my_fallback路由进行匹配

6.6:CacheRequestBody。这个功能在企业项目中使用较少,可能很多人并不了解它的作用。实际上,它的作用是让原本的请求信息中的 body 参数可以被多次读取。默认情况下,请求的 body 参数只能被读取一次,但是使用了这个配置后,就可以多次读取请求的 body 参数,并将其作为一个持久化的缓存。

6.7:DedupeResponseHeader,去重复的。

​ 有时候在开发中,可能会遇到这样的场景:请求经过了多个服务器,每个服务器都添加了一层跨域头。但是由于重复添加跨域头,可能导致最终跨域失败并出现错误。为了解决这个问题,我们可以使用 DedupeResponseHeader 来去除重复的响应头。DedupeResponseHeader 的作用就是检查响应头中是否包含重复的头信息,并进行去重处理。它还提供了一些去重策略,例如保留最后一个重复头信息或随便保留一个。

6.9:JsonToGrpc,转换序列化格式。

6.11: MapRequestHeader,假设你的原始请求中有一个请求头 A,这个过滤器会将请求头 A 的值映射给另一个请求头 B,实际上就是为你添加了一个新的请求头,并将原始请求头 A 的值填充到这个新请求头 B 中。

6.12:ModifyRequestBody,修改请求的参数。比如原本的请求参数是小写的 "aa",然后你可以通过 ModifyRequestBody 将它改成大写的 "AA"。这里没有使用声明式的配置,而是采用了编程式的定义方式。因为像修改请求参数这种情况,通常都需要比较灵活的处理方式,所以使用编程式的定义会更加合适。

6.13:ModifyResponseBody,修改响应参数。比如说原本返回的是 "A",但可以强行将它改成 "B"。或者可以给响应值再封装一层,封装一个 "code",封装一个 "message",封装一个状态码。

6.14:PrefixPath(前缀处理器),就是说当你访问原本的地址 https://example.org 时,如果你本来要访问的是 /hello,这个过滤器会给你添加一个前缀 /mypath,最终实际访问的地址会变成 /mypath/hello。

6.15:PreserveHostHeader(持久化host),这个大家先不用过于纠结。就是说有时候我们的请求在服务期间转发时,会导致请求头中的 host 值发生改变。而通过使用持久化 host 的方式,我们可以让请求转发后的 host 值保持不变,不发生改变。

6.16:RedirectTo(重定向),就是访问 https://example.org,它会自动给你重定向到 https://acme.org

6.17:RemoveRequestHeader,刚才可以添加请求头,同理我们可以移除请求头

6.19:RemoveRequestParameter,删除请求参数,比如原本的请求参数有 name,可以把它删掉

6.20:RemoveResponseHeader,删除响应头

6.21:RequestHeaderSize,限制请求的大小,如果请求的大小超过 1000B 就直接报错,这个就是一些请求保护方面的东西。

6.22:RequestRateLimiter(限流),这个非常重要的特性。在 Spring Cloud Gateway 中实现限流非常简单,官方推荐使用 Redis 来进行限流,因为网关通常是重要的组件,不建议在单机上实现限流。大多数情况下,网站是分布式的,使用多个机器提供服务。因此,建议将限流逻辑集中存储到 Redis 中,让 Redis 作为集中式存储帮助我们统计是否达到限流阈值。 官方文档中提到,如果要使用 Redis 的限流功能,首先需要引入spring-boot-starter-data-redis-reactive这个库。然后文档介绍了限流所使用的是令牌桶算法。对于令牌桶算法,有一些参数需要配置,例如生成令牌的速率、桶的容量以及初始的令牌数等等。理解这些参数可能有助于更好地理解限流的实现方式,当然,这种限流方法可能稍显复杂。 如果觉得官方文档理解起来有困难,可以通过百度搜索查找一些博客或其他资源来学习更简单的限流方法。

6.23:RewriteLocationResponseHeader,改写特殊的请求头

6.24:RewritePath(改写路径),比如说原本你访问的是 /red,然后经过网关,它会把 /red 这个路径去掉,最终转发给后端服务。这种操作在 Nginx 里也经常配置过,如果大家有学过的话应该很熟悉

6.25:RewriteResponseHeader,改写响应头

6.26:SaveSession,session 的持久化

6.27:SecureHeaders(安全),这部分可能与 Spring Security 框架有关,它涉及一些安全方面的请求头,不需要过于关注这部分内容

6.28:SetPath,它的作用是设置请求的路径。假如你原本访问的是 /red/blue,现在通过使用 SetPath,你可以直接将请求路径修改为 /blue

6.29:SetRequestHeader,改写请求头

6.30:SetResponseHeader,改写响应头

​ 这个处理器的主要作用是对请求头和请求参数进行增删改查,我们可以用一个常用的术语来描述它。它实际上在操作请求头和响应头。此外,它还有一些流量保护的功能。总的来说,这个处理器的功能非常简单,参考官方文档即可。

6.32:StripPrefix,它的作用是移除请求的前缀。比如当你访问网关的时候,请求的地址是 /name/blue/red,但是你的服务实际上只需要处理 /red 这个地址,这时候你就可以使用 StripPrefix,它会帮助你将前缀去掉,直接将两层前缀 /name/blue 去掉,甚至可以去掉三层或更多

6.33:Retry(重试处理器),它能够帮助你自动重试接口。比如你的接口访问一次失败了,Retry 会自动帮你再次尝试访问。而且它还可以控制重试的时间间隔,采用指数级递增的方式。比如第一次访问失败后,间隔一秒再重试;第二次访问失败后,间隔两秒再重试;第三次访问失败后,间隔四秒再重试,以此类推,这叫做降频重试。这个 Retry 过滤器是非常值得学习的。 这个就不带大家演示了,因为重试的情况通常在实际的项目中才会发生,你需要根据你自己的业务异常来定义在什么情况下进行重试。它本质上不算是一个接口保护,但在某种程度上它也是一个业务的保护,保证你的业务一定能成功完成。

6.34:RequestSize,刚刚讲的是请求头的大小限制,这个是请求大小。如果你请求大小超过数量,它就会给你报错

6.35:SetRequestHostHeader,设置请求的 host 头

6.37:Default Filters(默认过滤器),刚刚讲的 filters 是写在单个路由下,对某一个路径生效,或者只对某一个断言生效。但是现在,你可以直接给整个网关定一些默认的过滤器,比如说我们刚刚讲的染色功能

例如api-platform-gateway中可以配置默认过滤器,实现全局染色(即给所有经过网关的请求加一个请求头)

(7)过滤器

​ 全局过滤器,它教你怎么自定义过滤器,就是一个编程式的过滤器。结合具体业务场景应用网关,使用编程式和配置式结合的方式来实现过滤器。

(8)HttpHeadersFilters

​ HttpHeadersFilters,它会向下游发送请求之前应用到每个请求。

(9)TSL and SSL

配置 SSL 证书、加密之类

(12)Http timeouts configuration

设置了一个 HTTP 超时时间。当你的网关接收到请求并转发到下游服务时,如果超过了预设的时间限制,例如连接超时或响应超时,它会抛出异常并报错。这相当于是对全局接口超时进行了判断和设置

(16)跨域配置

​ 一个跨域配置,这是非常重要的功能。通过这个跨域配置,可以直接在网关这里定义你想要的跨域设置。可以指定允许跨域的请求头,哪些请求需要跨域支持,以及允许的跨域方法。实际上,这和我们自己编写跨域代码是相似的。但是通过网关配置,可以更简单和集中地管理跨域设置

(18)Troubleshooting

Troubleshooting,其中有一些建议,就是在调试过程中将日志级别降低为 trace 或 debug 级别,这样可以更详细地查看日志信息,帮助你排查问题

(19)Developer Guide

​ 介绍了自定义路由工厂、断言工厂和处理器工厂,但一般情况下我们不需要关注和修改这些工厂的逻辑。我们通常只会关注自定义处理器的逻辑和自定义路由的逻辑,而不会去改变这些更底层的工厂相关的内容,因为在大多数情况下,我们用不到这些工厂的自定义功能。

​ 参考:spring cloud gateway sampleopen in new window

4.网关模块开发

【1】项目功能梳理

网关概念引入

​ 目前需要为 API 接口项目实现统一的用户鉴权和接口调用次数统计。且两个功能都被直接嵌入到模拟接口项目本身中。

​ 例如,当用户调用 getUserNameByPost 接口时,然后根据用户查询用户名。这里的代码中包含了鉴权逻辑和调用次数统计逻辑。正常情况下,调用成功后,我们还需要手动增加调用次数,这是一个业务流程。

​ 如果把这个流程写在每个接口里面,这显然是不规范且不合理的做法。例如,当我们新增一个接口时,我们就不得不再次复制粘贴这段代码。即使我们将其抽出成一个公共方法,其他开发者在提供接口时也必须调用这个公共方法,遵守我们的约定,这会增加一些理解成本。如果其他开发者不按规定操作,可能会导致接口调用次数没有统计,这就会产生安全漏洞,使接口被无限调用。

​ 基于Spring Cloud Gateway搭建一个简单的 API 网关。之所以选择它,主要是因为它相比于原本的 Spring Cloud Zuul 网关,性能更高。Spring Cloud Gateway 利用了一些 Netty 和 WebFlux 的响应式编程技术,使得它在性能上有着显著的优势。此外,Spring Cloud Gateway 对于 Java 开发者来说更加友好。相较于像 Nginx 或者 Kong 这类非全局的网关,Spring Cloud Gateway 的学习成本较低。这使得开发者能够更快上手并且轻松地使用它。

要使用的特性说明

  • [x] 路由
  • [ ] 负载均衡(需用到注册中心)
  • [x] 统一鉴权
  • [ ] 跨域
  • [x] 统一业务处理(每次请求接口后接口调用次数+1)
  • [x] 访问控制(黑白名单)
  • [ ] 发布控制
  • [x] 流量染色(记录请求是否为网关过来的)
  • [ ] 接口保护(限制请求、信息脱敏、降级/熔断、限流、超时时间)
  • [x] 统一日志(记录每次请求和响应日志)
  • [ ] 统一文档

​ 主要目标是实现统一的用户鉴权和接口调用统计,项目中可能会使用到网关的一些特性:

【1】路由:肯定会用到路由功能。为什么呢?因为现在用户原本是直接请求模拟接口,然后再进行鉴权。但现在要让用户请求网关,然后由网关将请求重定向到模拟接口项目。在这个过程中,鉴权是在网关中完成的,所以路由转发请求肯定是要用到的。

【2】负载均衡:(暂时不会演示) 负载均衡需要一个注册中心的支持。使用 Nachos 或者 Eureka 等不同的注册中心可能会稍微麻烦一些。这个可以在学习完 Spring Cloud 微服务之后再来实践,就修改一下服务地址而已。

【3】统一鉴权:使用了 accessKey、secretKey 来进行鉴权,并非通过 session 获取用户信息。(鉴权是请求级别的,而不是基于会话级别的)

【4】跨域:如果需要用到跨域,将在适当的时候进行处理。跨域实现其实相对简单,可以通过配置方式或编程式配置实现,集中在网关层面解决。

【5】统一业务处理:例如给用户的所有查询添加一层缓存。对于项目,主要用到两个统一的业务处理,用户鉴权和接口调用次数统计。需要记录每次调用接口成功后,调用次数加一,从而统计总共调用次数(在 API 网关中实现)。

【6】访问控制:这里更多指黑白名单的使用。举个例子,如果有用户发现系统漏洞并不断刷接口,一秒钟调用数十万次或数百万次,这显然不行的。这种情况下,可以将该用户的IP地址或访问密钥(AKSK)加入黑名单,进行访问控制,类似于防火墙的功能。访问控制也可以涉及不同权限的许可,与鉴权略有相似但也有区别,需要细细品味。

【7】发布控制:(暂时不会演示,因为已经在之前的直播中讲过并实践过了,像灰度发布这类的访问控制) 免费版和企业版之间也可看作一种访问控制,鉴权一般是判断是否有权限执行某个操作,这些大家可以在上次的直播回放中学习了。

【8】流量染色:就是判断一个请求是否经过网关,若用户绕过网关直接请求模拟接口,相当于绕过防火墙直接进入服务器,因此可以记录请求是否来自网关,并在请求中添加相应标识。但是要实现这个功能,还是需要在最终被调用的接口层面进行判断。是否实现流量染色要看实际情况,有利有弊。一般情况下,进行流量染色是为了链路追踪。举个例子,你的一个接口可能A调用B,B调用C,C再调用D,形成了一条很长的调用链。为了更好地追踪一个请求的完整过程,需要知道每次调用中的请求是从哪里发过来的。因此,通常会给请求打上一个唯一的标识,而流量染色的作用就是实现这种功能。尽管这个概念可能有点抽象,但只要在遇到对应的场景时,能想起这个东西就可以了。

【9】接口保护:(不需要过于关注,因为会涉及一些微服务的知识,大家学完微服务之后再去深入了解会更好理解) 不过可以简单地理解它为一种兜底的策略,或者说是一种防止接口出现问题后的补救方法。举个例子,如果我的接口受到攻击,那么可以强制进行降级处理。原本接口是实际调用模拟接口的,但现在可以将其降级为直接返回一个失败结果或者给用户一个提示,告知他稍后再试。这样可以提供用户一个稍微友好的提示,也能在一定程度上增强用户体验。总比抛出一串500错误代码或者乱码要好得多。

【10】统一日志:记录每次请求和响应的日志。

【11】统一文档:(暂时不会演示,因为这个相对来说并不复杂) 以前可能会使用 Swagger 等整合网关的方式来实现统一集合文档。但现在有更简单的方式,不需要使用微服务网关进行整合。可以直接引入 Knife4j 文档库,并按照它的方式进行配置即可。所以这部分没什么特别需要讲解的,大家可以参考官方文档。

网关开发思路

业务逻辑:

(1)用户发送请求到 API 网关

(2)请求日志

(3)(黑白名单)

(4)用户鉴权(判断 ak、sk 是否合法)

(5)请求的模拟接口是否存在?

(6)请求转发,调用模拟接口

(7)响应日志

(8)调用成功,接口调用次数 + 1

(9)调用失败,返回一个规范的错误码

进一步说明:

​ 先整理一下整个网关需要处理的事情,从用户发送请求开始,到最终调用的模拟接口,期间网关需要执行哪些步骤?来梳理一下业务逻辑,在开始编写代码之前,要将这些步骤理清楚,因为代码不会可以上网查,但是业务逻辑需要深入理解,因为不同的业务肯定逻辑是不一样的,所以这是要重点关注和把握的地方:

​ 首先用户发送请求到 API 网关,API 网关需要对用户进行鉴权,判断 ak、sk 是否合法。如果密钥合法,接下来要做的是判断请求的模拟接口是否存在。这一步和用户鉴权的先后顺序不用纠结,可以根据性能考虑,先判断哪个查库操作比较少,就放在前面,提前拦截掉不合法的请求。

​ 接下来可能需要校验请求的参数是否合法。

​ 然后,如果模拟接口存在,就调用模拟接口。这里涉及到请求转发,即调用模拟接口。如果调用成功,需要记录接口调用次数,需要操作数据库来自增 1。如果调用失败,要返回一个规范的错误码,比如自己定义的 5001。

​ 此外,还要记录请求日志,在用户发起请求后记录日志,这里也涉及到黑白名单的问题,虽然黑白名单可以省略,但是建议大家都打上请求日志。

​ 最后,调用完成后,需要记录响应日志,响应日志可以放在上面或者最下面,具体位置不影响逻辑。

​ 以上就是业务逻辑的流程。

【2】后端项目开发

(1)初始化api-platform-gateway

​ 基于上述gateway项目完成,引入核心依赖spring-cloud-starter-gateway、如果要使用降级相关的包则参考spring-cloud-starter-circuitbreaker-reactor-resilience4j

​ 虽然 Spring Cloud Gateway 内置了很多过滤器,比如添加请求头的过滤器,但是如果想要统计接口调用次数这样的功能,它没有现成的过滤器供系统使用。因此,需要自己编写代码来实现这个功能。官方提供了一个示例代码,通过这个示例代码,可以学习如何使用编程式的 Spring Cloud Gateway。

​ 查看官方示例spring-cloud-gateway-sampleopen in new window,进入页面可以点击跳转到VSCode在线编辑器。可以运行一下这些实例,可查看其具体实现的功能

(2)请求转发调用模拟接口

实现请求准发

​ 要定义一个路由的规则,就是符合这个规则的请求转发到咱们的这种模拟接口项目中,可以使用前缀匹配路由器。

​ 假设提供的接口地址都是以: http://localhost:8080/api/ 开头,并且都有一个共同的路径前缀 /api。可以配置一个前缀匹配路由器,使得所有路径前缀为 /api/** 的请求都被匹配到,并且进行转发到对应的路径 http://localhost:8080/api/**。

​ 举个例子,如果有一个请求网关的地址为:http://localhost:8090/api/name/get?name=noob,可以使用前缀匹配路由器,将这个请求转发到:http://localhost:8080/api/name/get?name=8080。这样就可以统一处理所有以 /api 开头的请求,并将它们转发到后端服务的相应路径上。

# api-platform-gateway配置
spring:
  cloud:
    gateway:
      routes:
        - id: api_route
          uri: http://localhost:8080
          predicates:
            - Path=/api/{api_url}

api-platform-interface项目配置:

​ 启动项目:http://localhost:8090/api/getMyName/?name=hello,实际上是调用了api-platform-interface(8080)的接口返回的响应结果,因此转发得以验证

添加全局过滤器

​ 添加业务逻辑:可使用全局过滤器,它的作用是可以自己定义对每个请求的规则,对所有经过网关的请求都执行相同的逻辑

import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Slf4j
@Component
public class CustomGlobalFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        log.info("custom global filter");
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return -1;
    }
}

​ 添加CustomGlobalFilter:编写全局过滤器,随后启动项目测试全局过滤器是否引入成功

​ 访问链接:http://localhost:8090/api/getMyName/?name=hello,可以看到日志打印信息,说明全局过滤器生效

(3)编写业务逻辑

​ 在CustomGlobalFilter.java中编写业务逻辑,梳理的流程一步步完善业务逻辑

​ 因为项目中要调用api-platform-interface接口,因此此处需引入api-platform-client-sdk相关jar

日志打印

​ 日志打印:参考api-platform-backend项目的AuthInterceptor,通过拿到request对象进而获取到相应的请求信息(请求头、请求参数)。而Spring Cloud Gateway 示例代码提供了两个参数

  • exchange(路由交换机):我们所有的请求的信息、响应的信息、响应体、请求体都能从这里拿到。
  • chain(责任链模式):因为所有过滤器是按照从上到下的顺序依次执行,形成了一个链条。所以这里用了一个chain,如果当前过滤器对请求进行了过滤后发现可以放行,就要调用责任链中的next方法,相当于直接找到下一个过滤器,这里称为filter。有时候我们需要在责任链中使用 next,而在这里它使用了 filter 来找到下一个过滤器,从而正常地放行请求。

​ 借助exchange的getRequest()方法获取到请求信息,随后打印信息到日志中(项目启动访问http://localhost:8090/api/getMyName/?name=hello会发现请求来源地址没有显示)

​ 访问:http://127.0.0.1:8090/api/getMyName/?name=hello,请求来源地址可以正常打印(可以先将本地测试请求来源设为127.0.0.1,模拟黑白名单操作)

黑白名单

​ 一般情况下经常使用的是封禁 IP。例如,如果某个远程地址频繁访问,可以将其添加到黑名单并拒绝访问。现在来模拟设置一个规则,如果请求的来源地址不是 127.0.0.1,就拒绝它的访问。此处用一个白名单,通常建议在权限管理中尽量使用白名单,少用黑名单。白名单的原则是只允许特定的调用,这样可能会更加安全,或者可以默认情况下全禁止。

​ 访问控制——黑白名单的设定,通过校验访问IP是否在白名单列表,如果在则放行,如果要拒绝则直接通过exchange获取到响应对象并设置状态码为403(禁止访问),然后拦截掉

​ 例如此处设置白名单为127.0.0.1,当使用localhost访问的时候(该host不在设定范围)则请求会遭到拒绝,使用http://127.0.0.1:8090/api/getMyName/?name=hello则正常访问

用户鉴权

​ 用户鉴权主要用于校验ak、sk是否合法,ak、sk可以从request请求中获取到,随后对ak、sk进行校验(可以参考此前api-platform-interface的实现)

​ 启动项目如果直接访问链接肯定是没有权限的(参考前面都是通过api-platform-backend进行模拟调用,如果此处直接访问则请求头的数据肯定是没有的),可以先编写后面的业务逻辑,然后再慢慢调通流程

请求模拟接口

​ 此处校验请求的模拟接口是否存在以及请求方法是否匹配、请求参数等校验。因为接口注册信息是保存在后台系统里面的,因此此处如果要校验请求的模拟接口的有效性,一种方式是直接编写数据库操作逻辑代码实现操作,但这种业务层操作的内容不建议直接写在网关中。因为api-platform-gateway项目中,并没有引入操作数据库的依赖,如 MyBatis 等。但之前api-platform-backend 项目中引入了这些依赖,因此在网关中再引入的话,可能会造成重复。如果我们已经有现成的访问数据库的方法,或者用可以操作数据库的现成接口,如果那个方法比较复杂,建议使用远程调用的方式调用可以操作数据库的项目提供的接口,这样会更方便。也就是说在api-platform-gateway中不直接对数据库进行操作,而是通过远程调用的方式访问api-platform-backend提供的后台接口进行校验。

​ 那怎么调用呢?有好几种方法,其中包括 HTTP 请求和 RPC。对于 HTTP 请求,可以自己编写客户端,使用一些常见的库比如 HTTPClient、RestTemplate 或者 Feign。而对于 RPC,也有多种实现方式,例如 Java 中可以使用 Dubbo 框架。

剩余业务逻辑

​ 类似的,后面请求响应成功之后调用次数+1也是对数据库的操作,可以通过远程调用的方式实现

核心流程测试

​ 在api-platform-client-sdk中提供一个方法,使得接口调用通过网关转发,构建完成需要重新导入到maven仓库

在api-platform-interface中的GatewayController中定义getInfo接口用于通过网关访问测试

在api-platform-backend中刷新maven依赖(确保更新后的sdk包能够正常引入),修改两处内容:一个是上线时确认接口是否调通;二是用户在线调试的时候调用接口(将原来直接访问api-platform-interface项目接口调整为通过api-platform-gateway访问)

​ 启动项目测试:启动api-platform-frontend、api-platform-backend、api-platform-interface、api-platform-gateway进行测试(测试过程中如果出现问题,需一步步打断点确认,一般是权限、访问方式等问题,一一排查)

处理响应数据

​ 对于响应数据处理,目前面临一个问题,就是希望在调用完远程接口后,再输出响应日志,但由于异步操作的原因,当前的方法等待返回时,远程接口还没有被调用,导致顺序冲突。为了解决这个问题,Spring Cloud Gateway 提供了一个自定义响应处理的装饰器,可以查阅相关资料来了解如何使用

(4)代码参考

​ 理解网关模块构建的作用,接入网关需要从何处接入。

【1】api-platform-backend:原有直接调用api-platform-interface接口测试调用调整为通过网关访问(因为是通过api-platform-client-sdk进行调用,因此此处在api-platform-client-sdk补充方法,实现从网关接入)

【2】api-platform-gateway:拦截请求、转发到指定URL,包括请求日志、访问控制、用户鉴权、响应日志、接口调用统计等功能

【3】api-platform-interface:提供一个接口供网关访问调用(也可以沿用原有的接口,此处为了不对前面的接口做调整,更好地理解流程,直接提供一个新的接口)

【4】此处需注意api-platform-client-sdk作为公共引入的依赖,每次更新后相关项目引入也要更新同步

api-platform-backend:InterfaceInfoController
// String username = apiClient.getUserNameByPostBySign(user); // 直接调用api-platform-interface接口
String username = apiClient.getUserNameByGateway(user); // 通过api-platform-gateway网关调用api-platform-interface接口
api-platform-client-sdk:ApiClient
public class ApiClient {
		// 补充方法
    public String getUserNameByGateway(User user) throws UnsupportedEncodingException {
        String json = JSONUtil.toJsonStr(user);
        HttpResponse httpResponse = ((HttpRequest)HttpRequest.post("http://localhost:8090/api/getInfo/").addHeaders(this.getHeaderMap(json))).body(json).execute();
        System.out.println(httpResponse.getStatus());
        String result = httpResponse.body();
        System.out.println(result);
        return result;
    }
}
api-platform-interface:GatewayController
public class GatewayController {
    // 2.gateway 提供接口访问获取姓名
    @RequestMapping("/getInfo")
    public String getInfo(@RequestBody User user, HttpServletRequest request)
    {
//        String body = request.getHeader("body");
//        System.out.println("body:"+ body);
//        return "传入信息:" + body;
        return "GET 你的名字是" + user.getUsername();
    }
}
api-platform-gateway:CustomGlobalFilter(自定义全局过滤器)
# 网关配置
spring:
  cloud:
    gateway:
      # config default-filters
      default-filters:
        - AddRequestHeader=myGatewayHeader,swag
      routes:
        - id: api_route
          uri: http://localhost:8080
          predicates:
            - Path=/api/{api_url}
import com.noob.apiclientsdk.utils.SignUtil;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

@Slf4j
@Component
public class CustomGlobalFilter implements GlobalFilter, Ordered {


    private static final String INTERFACE_HOST = "http://localhost:8080";


    private static final List<String> IP_WHITE_LIST = Arrays.asList("127.0.0.1");

    @SneakyThrows
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //(1)用户发送请求到 API 网关

        //(2)请求日志
        ServerHttpRequest request = exchange.getRequest();
        String path = INTERFACE_HOST + request.getPath().value();
        String method = request.getMethod().toString();
        log.info("请求唯一标识:" + request.getId());
        log.info("请求路径:" + path);
        log.info("请求方法:" + method);
        log.info("请求参数:" + request.getQueryParams());
        String sourceAddress = request.getLocalAddress().getHostString();
        log.info("请求来源地址:" + sourceAddress);
        log.info("请求来源地址:" + request.getRemoteAddress());
        ServerHttpResponse response = exchange.getResponse();

        //(3)访问控制(黑白名单)
        if (!IP_WHITE_LIST.contains(sourceAddress)) {
            response.setStatusCode(HttpStatus.FORBIDDEN);
            return response.setComplete();
        }

        //(4)用户鉴权(判断 ak、sk 是否合法)
        HttpHeaders headers = request.getHeaders();
        String accessKey = headers.getFirst("accessKey");
        String nonce = headers.getFirst("nonce");
        String timestamp = headers.getFirst("timestamp");
        String sign = headers.getFirst("sign");
//        String body = headers.getFirst("body");

        // 指定utf-8编码格式处理中文乱码问题 String body = request.getHeader("body");
        String body = URLDecoder.decode(headers.getFirst("body"), "utf-8");

        // todo 实际情况应该是去数据库中查是否已分配给用户
        if (!accessKey.equals("noob")) {
            return handleNoAuth(response);
        }

        // 直接校验如果随机数大于1万,则抛出异常,并提示"无权限"
        if (Long.parseLong(nonce) > 10000) {
            return handleNoAuth(response);
        }

        // 校验时间和当前时间不能超过5分钟
        Long currentTime = System.currentTimeMillis() / 1000;
        // 定义一个常量FIVE_MINUTES,表示五分钟的时间间隔(乘以60,将分钟转换为秒,得到五分钟的时间间隔)。
        final Long FIVE_MINUTES = 60 * 5L;
        // 判断当前时间与传入的时间戳是否相差五分钟或以上,Long.parseLong(timestamp)将传入的时间戳转换成长整型
        // 然后计算当前时间与传入时间戳之间的差值(以秒为单位),如果差值大于等于五分钟,则返回true,否则返回false
        if ((currentTime - Long.parseLong(timestamp)) >= FIVE_MINUTES) {
            // 如果时间戳与当前时间相差五分钟或以上,调用handleNoAuth(response)方法进行处理
            return handleNoAuth(response);
        }

        // 校验生成签名
        String secretKey = "abcdefg";// todo 模拟数据,实际要从数据库中获取
        String serverSign = SignUtil.genSignByBody(body, secretKey); // 需对body进行解码,处理中文乱码问题
//        String serverSign = SignUtil.genSignByBody(URLDecoder.decode(body,"utf-8"),secretKey);
        if (!sign.equals(serverSign)) {
            return handleNoAuth(response);
        }

        //(5)请求的模拟接口是否存在?todo 调用后台数据库访问校验请求模拟接口是否存在以及请求方法是否匹配,或校验请求参数

        //(6)请求转发,调用模拟接口,调用成功打印响应日志
        Mono<Void> filter = chain.filter(exchange);
        log.info("响应:" + response.getStatusCode());

        //(7)响应日志
        return handleResponse(exchange,chain);

        //(8)调用成功,接口调用次数 + 1
//        if (response.getStatusCode() == HttpStatus.OK) {
//            // todo 调用次数 + 1
//        } else {
//            //(9)调用失败,返回一个规范的错误码
//            return handleInvokeError(response);
//        }
//
//        log.info("custom global filter");
//        return chain.filter(exchange);


    }

    @Override
    public int getOrder() {
        return -1;
    }

    /**
     * 处理无权限访问,禁止访问
     *
     * @param response
     * @return
     */
    public Mono<Void> handleNoAuth(ServerHttpResponse response) {
        response.setStatusCode(HttpStatus.FORBIDDEN);
        return response.setComplete();
    }


    /**
     * 处理调用失败(返回一个规范的错误码,此处返回500调用失败)
     *
     * @param response
     * @return
     */
    public Mono<Void> handleInvokeError(ServerHttpResponse response) {
        response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
        return response.setComplete();
    }


    /**
     * 处理响应信息
     * @param exchange
     * @param chain
     * @return
     */
    public Mono<Void> handleResponse(ServerWebExchange exchange, GatewayFilterChain chain) {
        try {
            // 获取原始的响应对象
            ServerHttpResponse originalResponse = exchange.getResponse();
            // 获取数据缓冲工厂
            DataBufferFactory bufferFactory = originalResponse.bufferFactory();
            // 获取响应的状态码
            HttpStatus statusCode = originalResponse.getStatusCode();

            // 判断状态码是否为200 OK(按道理来说,现在没有调用,是拿不到响应码的,对这个保持怀疑)
            if (statusCode == HttpStatus.OK) {
                // 创建一个装饰后的响应对象(开始穿装备,增强能力)
                ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(originalResponse) {
                    // 重写writeWith方法,用于处理响应体的数据
                    // 方法实现:模拟接口调用完成之后,等它返回结果,调用writeWith方法,就能根据响应结果做一些自己的处理
                    @Override
                    public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
                        log.info("body instanceof Flux: {}", (body instanceof Flux));
                        // 判断响应体是否是Flux类型
                        if (body instanceof Flux) {
                            Flux<? extends DataBuffer> 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<Object> 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);

                                // 打印日志信息
                                log.info("响应结果"+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);
        }
    }
}

​ 上述构建思路,能够打通网关模块流程,实现前端用户请求数据调用后台->网关->接口实现数据响应,但仍存在部分细节需完善,例如ak/sk鉴权、网关校验接口信息(通过RPC远程调用)等

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3