api开发平台

发布于 2024-07-07  92 次阅读


前言


背景

 

1.前端开发需要用到后台接口
2.使用现成的系统的功能(http://api.btstu.cn/)

 

做一个API接口平台:

 

  1. 防止攻击(安全性)
  1. 不能随便调用(限制、开通)
  1. 统计调用次数
  1. 计费
  1. 流量保护
  1. API接入

 

项目介绍

 

做一个提供API接口调用的平台,用户可以注册登录,开通接口调用权限。用户可以使用接口,并且每次调用会进行统计。管理员可以发布接口、下线接口、接入接口,以及可视化接口的调用情况、数据。

 

业务流程

 

 

技术选型

 

前端:

 

  • Ant Design Pro
  • React
  • Ant Design Procomponents
  • Umi
  • Umi Request (Axios的封装)

 

后端:

 

  • Java Spring Boot
  • Spring Boot Starter (SDK开发)
  • Dubbo
  • Nacos
  • Spring Cloud Gateway (网关、限流、日志实现)

 

一、项目初始化


1、Ant Design Pro


快速开始使用,可以查看官方教程

 

初始化

 

# 使用 npm
npm i @ant-design/pro-cli -g

 

打开将要存放项目的文件夹

 

pro create  项目名称

 

选择umi版本

 

? 🐂 使用 umi@4 还是 umi@3 ? (Use arrow keys)
❯ umi@4
  umi@3

 

选择4版的

 

安装依赖

 

yarn 或者  npm install

 

启动

 

package.json里面      点击start

 

这里我遇到了一个坑,登录页面无法登录 状态码404

 

在GitHub issue里找到了解决方案:https://github.com/ant-design/ant-design-pro/issues/10446

 

 

删除不必要的东西

 

  1. 移除国际化
    先跳过 有BUG
    运行package.json中的i18n-remove 然后发现又报错了..

解决方法:执行

 

yarn add eslint-config-prettier

 

yarn add eslint-plugin-unicorn

 

然后修改node_modules/@umijs/lint/dist/config/eslint/index.js

 

// es2022: true把这个注释掉就可以解决问题


然后删除src/locales目录

  1. 删除tests测试

 

2、后端


1、初始化

 

使用SpringBoot 项目初始模板

 

Java SpringBoot 项目初始模板,整合了常用框架和示例代码,大家可以在此基础上快速开发自己的项目。(springboot-init)

 

模板功能

 

  • Spring Boot 2.7.0(贼新)
  • Spring MVC
  • MySQL 驱动
  • MyBatis
  • MyBatis Plus
  • Spring Session Redis 分布式登录
  • Spring AOP
  • Apache Commons Lang3 工具类
  • Lombok 注解
  • Swagger + Knife4j 接口文档
  • Spring Boot 调试工具和项目处理器
  • 全局请求响应拦截器(记录日志)
  • 全局异常处理器
  • 自定义错误码
  • 封装通用响应类
  • 示例用户注册、登录、搜索功能
  • 示例单元测试类
  • 示例 SQL(用户表)

 

需要更改yaml文件中的MySQL、Redis的配置

 

访问 localhost:7529/api/doc.html 就能在线调试接口了,不需要前端配合啦~

 

 

2、数据库设计


基本结构

 

  • id 用户id
  • name 名称
  • description 描述
  • url 接口地址
  • request_header 请求头
  • reponse_header 响应头
  • status 接口状态(0-关闭 1-开启)
  • method 请求类型
  • user_id 创建人
  • create_time 创建时间
  • update_time 更新时间
  • is_delete  逻辑删除 (0-未删 ,1-已删)

 

代码

 

可以用鱼皮写的sql生成工具生成一下代码   SQL之父

 

 

填对应的数据,一键生成即可

 

create database if not exists api_platform;

use api_platform;

-- 接口信息
create table if not exists api_platform.`interface_info`
(
`id` bigint not null auto_increment comment '主键' primary key,
`name` varchar(256) not null comment '名称',
`description` varchar(256) null comment '描述',
`url` varchar(512) not null comment '接口地址',
`request_header` text null comment '请求头',
`response_header` text null comment '响应头',
`status` int default 0 not null comment '接口状态(0-关闭,1-开启)',
`method` varchar(256) not null comment '请求类型',
`user_id` bigint not null comment '创建人',
`create_time` datetime default CURRENT_TIMESTAMP not null comment '创建时间',
`update_time` datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
`is_deleted` tinyint default 0 not null comment '是否删除(0-未删, 1-已删)'
) comment '接口信息';

insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('廖立轩', '脱颖而出', 'www.foster-larkin.co', '龙嘉懿', '秦天磊', 0, 'GET', 1718083101);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('曹明辉', '举一反三', 'www.tony-kiehn.com', '任擎苍', '陈凯瑞', 0, 'GET', 28978);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('金乐驹', '首当其冲', 'www.coleen-prosacco.net', '毛浩', '陆致远', 0, 'GET', 208);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('廖思', '来之不易', 'www.don-sipes.net', '梁彬', '白君浩', 0, 'GET', 470);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('董煜祺', '长治久安', 'www.terry-turner.co', '覃绍齐', '胡雪松', 0, 'GET', 611007);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('侯聪健', '精心设计', 'www.augustus-yost.info', '傅鸿煊', '潘鹏飞', 0, 'GET', 0);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('魏弘文', '玩忽职守', 'www.guadalupe-beatty.biz', '江梓晨', '魏思淼', 0, 'GET', 1162536022);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('于苑博', '各式各样', 'www.nolan-metz.net', '韦果', '金胤祥', 0, 'GET', 0);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('姚炫明', '翻天覆地', 'www.jodie-schultz.info', '许越彬', '毛晋鹏', 0, 'GET', 973);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('孙鑫鹏', '络绎不绝', 'www.liza-sporer.co', '孙彬', '傅鸿煊', 0, 'GET', 30308);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('唐展鹏', '铤而走险', 'www.hayden-purdy.co', '杨哲瀚', '陆凯瑞', 0, 'GET', 473462835);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('曹擎苍', '赞不绝口', 'www.phung-glover.org', '邱志泽', '张健雄', 0, 'GET', 32155653);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('夏烨霖', '哭笑不得', 'www.augustine-funk.org', '宋聪健', '郝鹏涛', 0, 'GET', 3964);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('董浩', '对症下药', 'www.erik-hamill.biz', '黎立果', '廖鹤轩', 0, 'GET', 2275);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('罗荣轩', '喜闻乐见', 'www.gia-hermann.biz', '韩煜城', '阎耀杰', 0, 'GET', 847);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('沈正豪', '统筹兼顾', 'www.isabella-reinger.io', '邓子轩', '廖伟诚', 0, 'GET', 997378602);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('任立果', '出人意料', 'www.geoffrey-koss.name', '覃浩然', '萧雨泽', 0, 'GET', 403);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('张炫明', '名不虚传', 'www.ellan-gleason.com', '黎正豪', '韦炎彬', 0, 'GET', 35127293);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('方雨泽', '衣食住行', 'www.wilton-walsh.biz', '黎越泽', '白远航', 0, 'GET', 62264);
insert into api_platform.`interface_info` (`name`, `description`, `url`, `request_header`, `response_header`, `status`, `method`, `user_id`) values ('袁天翊', '卷土重来', 'www.lynetta-mclaughlin.info', '邹熠彤', '叶潇然', 0, 'GET', 9884455);

 

运行即可

 

3、使用MabatisX插件

 

生成domain、mapper、service

 

打开新建的表,右击选择MybatisX-Generator

 

勾上驼峰

 

 

根据版本跟需要打勾,点击完成

 

 

查看目录

 

 

然后将它们放到我自己的路径下

 

4、Controller

 

接下来到controller层

 

我们只需要将PostController复制一份改名为InterfaceInfoController即可,因为逻辑是差不多,都是进行增删改查

 

然后将post改成interfaceInfo、Post改成InterfaceInfo

 

根据报错信息我们来补充信息

 

5、DTO

 

首先先增加DTO,在InterfaceInfo类从拿我们需要的信息做成三个DTO类(分别是新增、查询、更新)删除的请求我们封装在common包下

 

 

package com.xuan.project.model.dto.interfaceinfo;

import lombok.Data;

import java.io.Serializable;


/**
 * 创建请求
 *
 * @author xuan
 */
@Data
public class InterfaceInfoAddRequest implements Serializable {

	/**
	 * 名称
	 */
	private String name;

	/**
	 * 描述
	 */
	private String description;
	
  // ...
  // ...
  // ...

}

 

6、Service

 

根据报错可知 service层缺少一个方法validInterfaceInfo

 

/**
 * @author xuan
 * @description 针对表【interface_info(接口信息)】的数据库操作Service实现
 * @createDate 2023-01-12 16:11:19
 */
@Service
public class InterfaceInfoServiceImpl extends ServiceImpl<InterfaceInfoMapper, InterfaceInfo>
		implements InterfaceInfoService {

	@Override
	public void validInterfaceInfo(InterfaceInfo interfaceInfo, boolean add) {
		if (interfaceInfo == null) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR);
		}

		String name = interfaceInfo.getName();
		String description = interfaceInfo.getDescription();
		String url = interfaceInfo.getUrl();
		String requestHeader = interfaceInfo.getRequestHeader();
		String responseHeader = interfaceInfo.getResponseHeader();
		Integer status = interfaceInfo.getStatus();
		String method = interfaceInfo.getMethod();
		Long userId = interfaceInfo.getUserId();

		// 创建时,所有参数必须非空
		if (add) {
			if (StringUtils.isAnyBlank(name, description, url, requestHeader, responseHeader, method) || ObjectUtils.anyNull(userId, status)) {
				throw new BusinessException(ErrorCode.PARAMS_ERROR);
			}
		}
		if (StringUtils.isNotBlank(name) && name.length() > 256) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR, "名字过长");
		}
		if (StringUtils.isNotBlank(description) && description.length() > 512) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR, "描述过长");
		}
	}

}

 

这里的大量getter 是使用插件 GenerateAllSetter 生成 macOS在变量上摁住 option + Enter  即可

 

3、前端


1、配置插件

 

为了项目更加规范

 

搜索 eslint 选上自动识别

 

 

搜索prettier 打√  美化代码

 

 

2、接口调用

 

使用 oneapi 插件自动生成

 

如果要前端自动生成,需要将后端的遵循openapi规范的json文档

 

后端的遵循openapi规范的json文档

 

找到我们起的后端主页

 

 

在地址栏输入http://localhost:7529/api/v3/api-docs

 

发现如下所示

 

 

那么我们就可以使用这个url了

 

打开config目录下config.ts 找到openApi 修改如下

 

  openAPI: [
    // {
    //   requestLibPath: "import { request } from '@umijs/max'",
    //   // 或者使用在线的版本
    //   // schemaPath: "https://gw.alipayobjects.com/os/antfincdn/M%24jrzTTYJN/oneapi.json"
    //   schemaPath: join(__dirname, 'oneapi.json'),
    //   mock: false,
    // },
    {
      requestLibPath: "import { request } from '@umijs/max'",
      schemaPath: 'http://localhost:7529/api/v3/api-docs',
      projectName: 'api-platform-backend',
    },
  ],

 

测试一下是否能用

 

找到package.json,执行openapi命令

 

执行成功,我们去service看一下

 

 

由于我们有后端 ,应请求真实环境,所以直接用dev模式运行

 

npm run start:dev

 

可以将项目中的requestErrorConfig.ts改为requestConfig.ts

 

然后在app.tsx 找到 request配置,将其修改成我们改的

 

再打开requestConfig.ts

 

修改名字,并设置一下后端地址

 

 

测试一下

 

使用它提示账户密码登录,失败了

 

我们查看一下发现是前后端接口定义不一致

 

 

3、修改登录的接口

 

找到src/pages/User/Login/index.tsx下的handleSubmit

 

  const handleSubmit = async (values: API.UserLoginRequest) => {
    try {
      // 登录
      const res = await userLoginUsingPOST({ ...values });
      if (res.data) {
        const urlParams = new URL(window.location.href).searchParams;
        history.push(urlParams.get('redirect') || '/');
        return;
      }
    } catch (error) {
      const defaultLoginFailureMessage = intl.formatMessage({
        id: 'pages.login.failure',
        defaultMessage: '登录失败,请重试!',
      });
      console.log(error);
      message.error(defaultLoginFailureMessage);
    }
  };

 

修改用户名和密码的字段和我们后端一样

 

<ProFormText
  name="userAccount"
  fieldProps={{
    size: 'large',
      prefix: <UserOutlined />,
  }}
  placeholder={intl.formatMessage({
    id: 'pages.login.username.placeholder',
    defaultMessage: '用户名: admin or user',
  })}
  rules={[
    {
      required: true,
      message: (
        <FormattedMessage
          id="pages.login.username.required"
          defaultMessage="请输入用户名!"
          />
      ),
    },
  ]}
  />

 

返回登录页面,进行登录

 

 

请求成功但是没跳转

 

为什么没跳转?因为我们没有记录用户的登录态,不知道它是否登录成功

 

设置用户的登录态

 

回到app.tsx

 

找到getInitialState()这个方法

 

这个方法当我们首次访问页面的时候,获取用户的信息,获取当前全局的一些状态,可以把它当成全局变量

 

我们先找到typings.d.ts

 

// 最后面添加
/**
 * 全局状态类型
 */
interface InitialState {
  loginUser?: API.UserVO;
}

 

返回getInitialState()将它改为

 

export async function getInitialState(): Promise<InitialState> {
  // 当页面首次加载时,获取要全局保存的信息比如用户信息
  const state: InitialState = {
    loginUser: undefined,
  };
  try {
    const res = await getLoginUserUsingGET();
    if (res.data) {
      state.loginUser = res.data;
    }
  } catch (error) {
    history.push(loginPath);
  }
  return state;
}

 

返回src/pages/User/Login/index.tsx下的handleSubmit

 

设置登录状态

 

  const handleSubmit = async (values: API.UserLoginRequest) => {
    try {
      // 登录
      const res = await userLoginUsingPOST({ ...values });
      if (res.data) {
        const urlParams = new URL(window.location.href).searchParams;
        history.push(urlParams.get('redirect') || '/');
        setInitialState({
          loginUser: res.data
        });
        return;
      }
    } catch (error) {
      const defaultLoginFailureMessage = intl.formatMessage({
        id: 'pages.login.failure',
        defaultMessage: '登录失败,请重试!',
      });
      console.log(error);
      message.error(defaultLoginFailureMessage);
    }
  };

 

测试

 

 

成功进入

 

But, 有bug,我们刷新一下发现又要重新登录,这是为什么呢?

 

我们推测是前端向后端发送请求的时候没有带上cookie!!!

 

找到requestConfig.ts

 

添加

 

withCredentials: true,

 

export const requestConfig: RequestConfig = {
  baseURL: 'http://localhost:7529',
  withCredentials: true,
  // ...
}

 

刷新测试一下 问题解决

 

4、注销

 

和登录差不多,同理

 

全局搜索logout

 

发现在src/components/RightContent/AvatarDropdown.tsx中有loginOut() 将其改成我们的后端方法

 

  const onMenuClick = useCallback(
    (event: MenuInfo) => {
      const { key } = event;
      if (key === 'logout') {
        flushSync(() => {
          setInitialState((s) => ({ ...s, currentUser: undefined }));
        });
        userLogoutUsingPOST()
        return;
      }
      history.push(`/account/${key}`);
    },
    [setInitialState],
  );

 

自动生成的好处

 

如果我们后端的实体类修改了,我们可以直接运行 openapi 来直接更新

 

5、管理权限

 

是否为管理员

 

打开access.ts

 

/**
 * @see https://umijs.org/zh-CN/plugins/plugin-access
 * */
export default function access(initialState: { currentUser?: API.CurrentUser } | undefined) {
  const { currentUser } = initialState ?? {};
  return {
    canAdmin: currentUser && currentUser.access === 'admin',
  };
}

将canAdmin改成
canAdmin: true,

 

发现前端管理界面出来了,所以逻辑就是在这里控制的

 

 

所以代码修改如下

 

export default function access(initialState: InitialState | undefined) {
  const { loginUser } = initialState ?? {};
  return {
    canUser: loginUser,
    canAdmin: loginUser?.userRole === 'admin',
  };
}

 

6、表格页面

 

找到src/pages/TableList/index.tsx

 

找到columns

 

 

换成我们自己的

 

const columns: ProColumns<API.InterfaceInfo>[] = [
    {
      title: 'id',
      dataIndex: 'id',
      valueType: 'index',
    },

    {
      title: '接口名称',
      dataIndex: 'name',
      valueType: 'text',
    },
    {
      title: '描述',
      dataIndex: 'description',
      valueType: 'textarea',
    },
    {
      title: '请求方法',
      dataIndex: 'method',
      valueType: 'text',
    },
    {
      title: 'url',
      dataIndex: 'url',
      valueType: 'text',
    },
    {
      title: '请求头',
      dataIndex: 'requestHeader',
      valueType: 'textarea',
    },
    {
      title: '响应头',
      dataIndex: 'responseHeader',
      valueType: 'textarea',
    },
    {
      title: '状态',
      dataIndex: 'status',
      hideInForm: true,
      valueEnum: {
        0: {
          text: '关闭',
          status: 'Default',
        },
        1: {
          text: '开启',
          status: 'Processing',
        },

      },
    },
    {
      title: '创建时间',
      dataIndex: 'createTime',
      valueType: 'dateTime',
    },
    {
      title: '更新时间',
      dataIndex: 'updateTime',
      valueType: 'dateTime',
    },
    {
      title: '操作',
      dataIndex: 'option',
      valueType: 'option',
      render: (_, record) => [
        <a
          key="config"
          onClick={() => {
            handleUpdateModalVisible(true);
            setCurrentRow(record);
          }}
        >
          配置
        </a>,
        <a key="subscribeAlert" href="https://procomponents.ant.design/">
          订阅警报
        </a>,
      ],
    },
  ];

 

加载出来了

 

 

但是没有数据,我们需要让它有数据

 

向下找,发现有一个request我们需要将他改成自己的listInterfaceInfoByPageUsingGET 这样就有数据了

 

 

刷新查看页面

 

 

无法加载,但是我们发现数据已经有了

 

对于这种错误,我们需要检查

 

  • 你的请求参数和他的请求参数是否一致
  • 你的响应值和他要求的响应值是否一致

 

所以我们不能完全替换

 

查看request的请求参数

 

request?: (params: U & {
  pageSize?: number;
  current?: number;
  keyword?: string;
}, sort: Record<string, SortOrder>, filter: Record<string, React.ReactText[] | null>) => Promise<Partial<RequestData<T>>>;

 

request在点击RequestData 查看响应参数

 

export type RequestData<T> = {
    data: T[] | undefined;
    success?: boolean;
    total?: number;
} & Record<string, any>;

 

所以刚刚简单替换请求方法的代码我们重新写

 

			request={async (
         params: {
         pageSize?: number;
         current?: number;
         keyword?: string;
        }, sort: Record<string, SortOrder>,  filter: Record<string, React.ReactText[] | null>,) => {
          const res = await listInterfaceInfoByPageUsingGET({ ...params });
          if (res.data) {
            return {
              data: res.data.records || [],
              success: true,
              total: res.data.total,
            };
          } else {
            return {
              data: [],
              success: false,
              total: 0,
            };
          }
        }}

 

刷新页面,显示成功

 

 

二、基础增删改查


1、修改路由


打开config包找到 routes.ts

 

将原来pages下的TableList表单名称改为我们的interfaceInfo,再把接口管理页面配置到管理员页面下

 

{
  name: '接口管理',
  icon: 'table',
  path: '/admin/interface_info',
  component: './InterfaceInfo',
}

 

export default [
  {
    path: '/user',
    layout: false,
    routes: [
      {
        name: 'login',
        path: '/user/login',
        component: './User/Login',
      },
    ],
  },
  {
    path: '/welcome',
    name: 'welcome',
    icon: 'smile',
    component: './Welcome',
  },
  {
    path: '/admin',
    name: 'admin',
    icon: 'crown',
    access: 'canAdmin',
    routes: [
      {
        path: '/admin',
        redirect: '/admin/sub-page',
      },
      {
        path: '/admin/sub-page',
        name: 'sub-page',
        component: './Admin',
      },
      {
        name: '接口管理',
        icon: 'table',
        path: '/admin/interface_info',
        component: './InterfaceInfo',
      },
    ],
  },
  {
    path: '/',
    redirect: '/welcome',
  },
  {
    path: '*',
    layout: false,
    component: './404',
  },
];

 

效果如下

 

 

2、新增接口信息


1)表单模块

 

interfaceInfo中的index.tsx找到新建的Button

 

 

我们点击新建的时候,他会打开一个模态框。往下找,发现它已经给我们提供了这个组件。但是我们需要重新写

 

 

我们可以就像更新模态框一样新建一个CreateModal.tsx

 

 

接下来修改从UpdateForm中粘贴的CreateModal.tsx的代码

 

import { Modal } from 'antd';
import React from 'react';
import { ProColumns, ProTable } from '@ant-design/pro-components';
import '@umijs/max';

export type Props = {
  columns: ProColumns<API.InterfaceInfo>[];
  onCancel: () => void;
  onSubmit: (values: API.InterfaceInfo) => Promise<boolean>;
  open: boolean;
};

const CreateModal: React.FC<Props> = (props) => {
  const { columns, open, onCancel } = props;
  return (
    <Modal open={open} onCancel={() => onCancel?.()}>
      <ProTable columns={columns} />
    </Modal>
  );
};

export default CreateModal;

 

这里我们复用了index中的columns  这里我顺便把取消也写了

 

index.tsx中使用

 

  /**
   * @en-US Pop-up window of new window
   * @zh-CN 新建窗口的弹窗
   *  */
  const [createModalOpen, handleModalOpen] = useState<boolean>(false);
	
 	const columns: ProColumns<API.InterfaceInfo>[] = [
    {
      title: 'id',
      dataIndex: 'id',
      valueType: 'index',
    },
   // ...
  ]

	// ...
	<CreateModal
    columns={columns}
    onCancel={() => handleModalOpen(false)}
    onSubmit={() => {}}
    open={createModalOpen}
  />

 

测试一下

 

 

emmmm... 这是什么玩意??

 

查询官方文档可知,这是ProTable的默认type 所以我们需要给它指定一个form的type

 

const CreateModal: React.FC<Props> = (props) => {
  const { columns, open, onCancel } = props;
  return (
    <Modal open={open} onCancel={() => onCancel?.()}>
      <ProTable columns={columns} type={'form'} />
    </Modal>
  );
};

 

再测试一下就正常啦~

 

 

发现创建时间、更新时间我们并不需要。增加hideInForm属性

 

    {
      title: '创建时间',
      dataIndex: 'createTime',
      valueType: 'dateTime',
      hideInForm: true,
    },
    {
      title: '更新时间',
      dataIndex: 'updateTime',
      valueType: 'dateTime',
      hideInForm: true,
    },

 

2)请求后端

 

先简单处理一下请求报错的情况

 

找到src/requestConfig.ts 修改一下响应拦截器

 

  // 响应拦截器
  responseInterceptors: [
    (response) => {
      // 拦截响应数据,进行个性化处理
      const { data } = response as unknown as ResponseStructure;
      console.log('data', data);
      if (data.code !== 0) {
        throw new Error(data.message);
      }
      return response;
    },
  ],

 

再在interfaceinfo/index.tsx中新增请求后端的方法

 

  const handleAddInterfaceInfo = async (fields: API.InterfaceInfoAddRequest) => {
    const hide = message.loading('正在添加');
    try {
      await addInterfaceInfoUsingPOST({ ...fields });
      hide();
      message.success('创建成功!');
      // 关闭Modal
      handleModalOpen(false);
      return true;
    } catch (error: any) {
      hide();
      console.log(error);
      message.error('创建失败!' + error.message);
      return false;
    }
  };

// ...

<CreateModal
  columns={columns}
  onCancel={() => handleModalOpen(false)}
  onSubmit={(values) => handleAddInterfaceInfo(values)}
  open={createModalOpen}
  />

 

再修改CreateModal.tsx

 

const CreateModal: React.FC<Props> = (props) => {
  const { columns, open, onCancel, onSubmit } = props;
  return (
    <Modal title={'新建接口'} open={open} onCancel={() => onCancel?.()}>
      <ProTable columns={columns} type={'form'} onSubmit={async (value) => onSubmit?.(value)} />
    </Modal>
  );
};

 

测试添加成功

 

 

3、编辑接口信息


先把Modal的footer干掉   footer={null}

 

<Modal title={'更新接口'} footer={null} open={open} onCancel={() => onCancel?.()}>
		  // ...
  );

 

1)表单模块

 

新建文件src/pages/InterfaceInfo/components/UpdateModal.tsx

 

这里使用的useRef、formRef参考了官方文档

 

import { Modal } from 'antd';
import React, {useEffect, useRef} from 'react';
import { ProColumns, ProFormInstance, ProTable } from '@ant-design/pro-components';
import '@umijs/max';

export type Props = {
  value: API.InterfaceInfo;
  columns: ProColumns<API.InterfaceInfoUpdateRequest>[];
  onCancel: () => void;
  onSubmit: (values: API.InterfaceInfoUpdateRequest) => Promise<void>;
  open: boolean;
};

const UpdateModal: React.FC<Props> = (props) => {
  const { value, columns, open, onCancel, onSubmit } = props;

  const formRef = useRef<ProFormInstance>();

  useEffect(() => {
    if (formRef) {
      formRef.current?.setFieldsValue(value);
    }
  }, [value]);

  return (
    <Modal title={'更新接口'} footer={null} open={open} onCancel={() => onCancel?.()}>
      <ProTable
        columns={columns}
        formRef={formRef}
        type={'form'}
        onSubmit={async (value) => onSubmit?.(value)}
        // 设置默认值
        form={{ initialValues: value }}
      />
    </Modal>
  );
};

export default UpdateModal;

 

2)请求后端

 

在interfaceinfo/index.tsx中新增请求后端的方法

 

  /**
   * @en-US Update InterfaceInfo
   * @zh-CN 更新接口信息
   *
   * @param updateValue
   */
  const handleUpdateInterfaceInfo = async (updateValue: API.InterfaceInfoUpdateRequest) => {
    const hide = message.loading('正在更新');
    try {
      let res = await updateInterfaceInfoUsingPOST({ ...updateValue });
      if (res.data) {
        hide();
        handleUpdateModalOpen(false);
        message.success('更新成功!');
        return true;
      }
    } catch (error: any) {
      hide();
      message.error('更新失败!' + error.message);
      return false;
    }
  };      



// 这里的<UpdateModal/>代码是在原有的 <UpdateForm/> 基础上面改的
<UpdateModal
        value={currentRow || {}}
        columns={columns}
        open={updateModalOpen}
        onSubmit={async (value) => {
          const success = await handleUpdateInterfaceInfo(value);
          if (success) {
            handleUpdateModalOpen(false);
            setCurrentRow(undefined);
            if (actionRef.current) {
              actionRef.current.reload();
            }
          }
        }}
        onCancel={() => {
          handleUpdateModalOpen(false);
          if (!showDetail) {
            setCurrentRow(undefined);
          }
        }}
      />

 

出错了,猜测是Ant Design Pro的问题,猜错了,其实是columns中的id的type为index的原因。并没有id字段,所以我手动给一下

 

 

修改代码如下

 

/**
   * @en-US Update InterfaceInfo
   * @zh-CN 更新接口信息
   *
   * @param fields
   */
  const handleUpdateInterfaceInfo = async (fields: API.InterfaceInfoUpdateRequest) => {
    const hide = message.loading('正在更新');
    try {
      if(!currentRow){
        return false;
      }
      let res = await updateInterfaceInfoUsingPOST({
        // 因为columns中的id valueType为index 不会传递 所以我们需要手动赋值id
        id: currentRow.id,
        ...fields,
      });
      if (res.data) {
        hide();
        handleUpdateModalOpen(false);
        message.success('更新成功!');
        // 刷新页面
        actionRef.current?.reload();
        return true;
      }
    } catch (error: any) {
      hide();
      message.error('更新失败!' + error.message);
      return false;
    }
  };

 

测试更新成功~

 

4、删除接口信息


删除按钮

 

// 在columns中添加删除按钮	
{
      title: '操作',
      dataIndex: 'option',
      valueType: 'option',
      render: (_, record) => [
        <a
          key="config"
          onClick={() => {
            handleUpdateModalOpen(true);
            setCurrentRow(record);
          }}
        >
          编辑
        </a>,
        <a
          key="delete"
          onClick={() => {
            handleRemoveInterfaceInfo(record);
          }}
        >
          删除
        </a>,
      ],
    },

 

调用方法

 

  const handleRemoveInterfaceInfo = async (record: API.InterfaceInfo) => {
    const hide = message.loading('正在删除');
    if (!record) return true;
    try {
      await deleteInterfaceInfoUsingPOST({
        id: record.id,
      });
      hide();
      message.success('删除成功!');
      // 刷新页面
      actionRef.current?.reload();
      return true;
    } catch (error: any) {
      hide();
      message.error('删除失败!' + error.message);
      return false;
    }
  };

 

测试删除成功~

 

 

三、API开发


1、模拟接口


创建项目

 

快速创建一个spring Boot项目 勾选SpringWeb、Lombok、Spring Boot DevTools

 

提供三个模拟接口

 

接口大体内容

 

  1. GET 接口
  1. POST 接口(url传参)
  1. POST接口(Restful)

 

简单的项目结构

 

 

package com.xuan.controller;

import com.xuan.model.User;
import org.springframework.web.bind.annotation.*;

/**
 * 模拟接口
 * 
 * @version 1.0
 * @author: 玄
 * @date: 2023/1/14
 */

@RestController
@RequestMapping("/name")
public class NameController {

	@GetMapping("/{name}")
	public String getNameByGet(@PathVariable(value = "name") String name) {
		return "发送GET请求 你的名字是:" + name;
	}

	@PostMapping()
	public String getNameByPost(@RequestParam(value = "name") String name) {
		return "发送POST请求 你的名字是:" + name;
	}

	@PostMapping("/user")
	public String getNameByPostWithJson(@RequestBody User user) {
		return "发送POST请求 JSON中你的名字是:" + user.getName();
	}

}

 

application.yml配置一下端口、然后指定一下全局接口的地址

 

server:
  port: 8123
  servlet:
    context-path: /api

 

2、调用接口


几种HTTP的调用方式:

 

  1. HttpClient
  1. RestTemplate
  1. 第三方库(OKHttp,Hutool)

 

这里我使用了Hutool调用 hutool文档

 

maven中添加

 

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.11</version>
</dependency>

 

查看文档中的Http请求相关用法 编写XuanApiClient类

 

package com.xuan.client;

import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONUtil;
import com.xuan.model.User;

import java.util.HashMap;

/**
 * 调用API使用
 *
 * @version 1.0
 * @author: 玄
 * @date: 2023/1/15
 */
public class XuanApiClient {
	public String getNameByGet(String name) {
		return HttpUtil.get("http://localhost:8123/api/name/" + name);
	}

	public String getNameByPost(String name) {
		// 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中
		HashMap<String, Object> paramMap = new HashMap<>();
		paramMap.put("name", name);
		return HttpUtil.post("http://localhost:8123/api/name", paramMap);
	}

	public String getNameByPostWithJson(User user) {
		String json = JSONUtil.toJsonStr(user);
		HttpResponse response = HttpRequest.post("http://localhost:8123/api/name/user")
				.body(json)
				.execute();
		System.out.println("response = " + response);
		System.out.println("status = " + response.getStatus());
		if (response.isOk()) {
			return response.body();
		}
		return "fail";
	}

}

 

创建测试类调用测试

 

 

四、API签名认证


本质:

 

  1. 签发签名
  1. 使用签名(校验签名)

 

为什么需要

 

保证安全性,不能随便一个人就可以调用

 

怎么实现

 

  • accessKey 调用的标识(复杂,无序,无规律)
  • secretKey 密钥 (复杂,无序,无规律)

 

类似用户名密码,区别:accessKey、secretKey是无状态

 

千万不能把密钥直接在服务器间进行传递,有可能被拦截

 

加密看第二点

 

1、修改数据库


由于我们的user表里面没有access_key、secret_key 所以我们要修改数据库表

 

create table if not exists user
(
    id            bigint auto_increment comment 'id' primary key,
    user_name     varchar(256)                           null comment '用户昵称',
    user_account  varchar(256)                           not null comment '账号',
    user_avatar   varchar(1024)                          null comment '用户头像',
    gender        tinyint                                null comment '性别',
    user_role     varchar(256) default 'user'            not null comment '用户角色:user / admin',
    user_password varchar(512)                           not null comment '密码',
    access_key    varchar(256)                           null comment 'access_key',
    secret_key    varchar(256)                           null comment 'secret_key',
    create_time   datetime     default CURRENT_TIMESTAMP not null comment '创建时间',
    update_time   datetime     default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    is_delete     tinyint      default 0                 not null comment '是否删除',
    constraint uni_user_account
        unique (user_account)
) comment '用户';

 

直接drop掉之前的table重新建表,插入一条测试数据。

 

标准的话 应该新建一个表 主要字段为接口id、使用用户id、access_key、secret_key等等

 

2、加密


1、加密方式

 

将accessKey、secretKey放在Header里明文传递安全吗

 

答案是否定的,因为我们的请求可能被人拦截,而我们把密码放进请求头里面,可能会被别人获取

 

一般是根据密钥,生成签名sign

 

加密方式

 

  1. 对称加密
  1. 非对称加密
  1. md5 签名(不可解密)

 

签名的做法

 

假如 ,我们有用户参数,我们用密钥与他拼接,用签名算法得到一个不可解密的值

 

用户参数 + 密钥 => 签名生成算法(MD5,HMac,Sha1) => 不可解密的值

 

例子: xuan + abc => 7e7b9583aa0bc3e834fe8bcaebda38b5(这里是我随便输的,得到的值是随机的)

 

怎么知道签名对不对?

 

服务端用一模一样的参数和算法去生成签名,只要和用户传的一致,就表示密钥一致

 

怎么防重放?

 

加nonce随机数 只能用一次

 

服务端要保存用过的随机数

 

加timestamp 时间戳,校验它的有效期

 

综上所属

 

传递的参数

 

  1. accessKey
  1. sign (由accessKey(或者使用用户请求参数body等)、secretKey加密而来)
  1. nonce随机数
  1. timestamp
  1. body(用户请求参数 可要可不要)

 

API签名认证是一个很灵活的设计,具体要有哪些参数,尽量服务端调用,参数名如何要根据场景来。

 

2、加密代码

 

我这里直接使用了body、和secretKey进行加密

 

先创建一个加密签名类SignUtil.java

 

package com.xuan.util;

import cn.hutool.crypto.digest.DigestUtil;

/**
 * 签名工具类
 *
 * @version 1.0
 * @author: 玄
 * @date: 2023/1/15
 */
public class SignUtil {
	public static String getSign(String body, String secretKey) {
		String content = body + "." + secretKey;
		return DigestUtil.md5Hex(content);
	}
}

 

在Client类中新增构造Header的方法

 

public class XuanApiClient {

	private final String accessKey;
	private final String secretKey;

	public XuanApiClient(String accessKey, String secretKey) {
		this.accessKey = accessKey;
		this.secretKey = secretKey;
	}
  
	// ...

	public String getNameByPostWithJson(User user) {
		String json = JSONUtil.toJsonStr(user);
		HttpResponse response = HttpRequest.post("http://localhost:8123/api/name/user")
				.addHeaders(getHeaders(json))
				.body(json)
				.execute();
		System.out.println("response = " + response);
		System.out.println("status = " + response.getStatus());
		if (response.isOk()) {
			return response.body();
		}
		return "fail";
	}

	private Map<String, String> getHeaders(String body) throws UnsupportedEncodingException {
		Map<String, String> header = new HashMap<>();
		header.put("accessKey", accessKey);
		header.put("sign", SignUtil.getSign(body, secretKey));
		// 防止中文乱码
		header.put("body", URLEncoder.encode(body, StandardCharsets.UTF_8.name()));
		header.put("nonce", RandomUtil.randomNumbers(5));
		header.put("timestamp", String.valueOf(System.currentTimeMillis()));
		return header;
	}
	
}

 

调用API的时候加密代码已经写好了,显然我们在API中需要用同样的方法来验证加密。这里以携带JSON body的POST请求为例

 

package com.xuan.controller;

import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.StrUtil;
import com.xuan.model.User;
import com.xuan.util.SignUtil;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;

/**
 * 模拟接口
 *
 * @version 1.0
 * @author: 玄
 * @date: 2023/1/14
 */

@RestController
@RequestMapping("/name")
public class NameController {

	@PostMapping("/user")
	public String getNameByPostWithJson(@RequestBody User user, HttpServletRequest request) throws UnsupportedEncodingException {
		String accessKey = request.getHeader("accessKey");
		// 防止中文乱码
		String body = URLDecoder.decode(request.getHeader("body"), StandardCharsets.UTF_8.name());
		String sign = request.getHeader("sign");
		String nonce = request.getHeader("nonce");
		String timestamp = request.getHeader("timestamp");
		boolean hasBlank = StrUtil.hasBlank(accessKey, body, sign, nonce, timestamp);
		// 判断是否有空
		if (hasBlank) {
			return "无权限";
		}
		// TODO 使用accessKey去数据库查询secretKey
		// 假设查到的secret是abc 进行加密得到sign
		String secretKey = "abc";
		String sign1 = SignUtil.getSign(body, secretKey);
		if (!StrUtil.equals(sign, sign1)) {
			return "无权限";
		}
		// TODO 判断随机数nonce
		// 时间戳是否为数字
		if (!NumberUtil.isNumber(timestamp)) {
			return "无权限";
		}
		// 五分钟内的请求有效
		if (System.currentTimeMillis() - Long.parseLong(timestamp) > 5 * 60 * 1000) {
			return "无权限";
		}
		return "发送POST请求 JSON中你的名字是:" + user.getName();
	}

}

 

进行测试secretKey = "abc" 可以正确访问,当secret错误时返回无权限~

 

 

 

五、开发一个SDK(starter)


理想情况:开发者只需要关心调用哪些接口、传递哪些参数。就跟调用自己写的代码一样简单。

 

开发starter的好处:开发者引入之后,可以直接在application.yml中写配置,自动创建客户端

 

1、新建项目

 

创建一个xuanapi-client-sdk的springboot项目 勾选lombok、Spring Configuration Processor(作用:自动生成配置的代码提示)

 

然后处理pom.xml   这个一定需要删除因为这个是maven的构建项目成可运行jar包。现在是制作starter依赖包

 

 

2、编写配置类

 

我们不需要spring boot启动类,将其删去。

 

然后将之前编写好的client、util、model粘贴过来

 

 

再新建配置类

 

@Data
@ComponentScan
@Configuration
@ConfigurationProperties(prefix = "xuan.api.client")
public class XuanApiClientConfig {
	private String accessKey;
	private String secretKey;

	@Bean
	public XuanApiClient xuanApiClient() {
		return new XuanApiClient(accessKey, secretKey);
	}

}

 

3、指定配置类

 

新建resources/META-INF/spring.factories并指定

 

# spring boot starter
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.xuan.XuanApiClientConfig

 

 

4、发布starter

 

双击Maven lifecycle下的install或者命令行mvn install

 

 

[INFO] Installing /Users/xuan/Desktop/project/api-platform/xuanapi-client-sdk/target/xuanapi-client-sdk-0.0.1.jar to /Users/xuan/.m2/repository/com/xuan/xuanapi-client-sdk/0.0.1/xuanapi-client-sdk-0.0.1.jar
[INFO] Installing /Users/xuan/Desktop/project/api-platform/xuanapi-client-sdk/pom.xml to /Users/xuan/.m2/repository/com/xuan/xuanapi-client-sdk/0.0.1/xuanapi-client-sdk-0.0.1.pom

 

5、测试

 

引入依赖

 

回到xuan-Interface项目,把之前的client、util、model全部删掉。然后在pom中引入我们刚刚制作好的starter

 

注意:这里能直接引入,是因为刚刚我们install的stater在我们的本地,可以发布到Maven仓库或者提供jar包供大家使用。

 

<!--自己制作的starter-->
<dependency>
    <groupId>com.xuan</groupId>
    <artifactId>xuanapi-client-sdk</artifactId>
    <version>0.0.1</version>
</dependency>

 

配置信息

 

 

我们在yml文件中配置的时候有提示就是之前引入的Spring Configuration Processor发挥的作用。打开依赖源码分析可得是这个json文件的作用

 

 

在测试类使用@Resource注入XuanApiClient进行测试

 

 

yml中secret不正确也会返回 "无权限"

 

六、接口发布/下线


这个功能首先是仅管理员使用的

 

发布接口

 

  1. 校验该接口是否存在
  1. 判断接口是否可以被调用
  1. 修改数据库接口字段为1

 

下线接口

 

  1. 校验该接口是否存在
  1. 修改数据库接口字段为 0

 

1、后端


  1. 通用请求类
    上下线都是通过id来控制的
/**
 * 通过id发送请求
 *
 * @author xuan
 */
@Data
public class IdRequest implements Serializable {
    /**
     * id
     */
    private Long id;

    private static final long serialVersionUID = 1L;
}

 

  1. 枚举类
    使用枚举类来表示上线/下线状态
package com.xuan.project.model.enums;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 接口状态枚举
 *
 * @author xuan
 */
public enum InterfaceInfoStatusEnum {

    OFFLINE("下线", 0),
    ONLINE("上线", 1);

    private final String text;

    private final int value;

    InterfaceInfoStatusEnum(String text, int value) {
        this.text = text;
        this.value = value;
    }

    /**
     *
     * @return 获取值列表
     */
    public static List<Integer> getValues() {
        return Arrays.stream(values()).map(item -> item.value).collect(Collectors.toList());
    }

    public int getValue() {
        return value;
    }

    public String getText() {
        return text;
    }
}

 

  1. 在controller里编写上下线代码
    这里有个TODO 判断该接口是否可以调用时,由XuanApiClient固定方法名改为根据测试地址来调用
/**
 * API信息接口
 *
 * @author xuan
 */
@RestController
@RequestMapping("/interfaceInfo")
@Slf4j
public class InterfaceInfoController {

	@Resource
	private InterfaceInfoService interfaceInfoService;

	@Resource
	private UserService userService;

	@Resource
	private XuanApiClient xuanApiClient;

	/**
	 * 上线接口
	 *
	 * @param idRequest 携带id
	 * @return 是否上线成功
	 */
	@PostMapping("/online")
	@AuthCheck(mustRole = "admin")
	public BaseResponse<Boolean> onlineInterfaceInfo(@RequestBody IdRequest idRequest) throws UnsupportedEncodingException {
		if (idRequest == null || idRequest.getId() < 0) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR);
		}
		// 判断接口是否存在
		long id = idRequest.getId();
		InterfaceInfo oldInterfaceInfo = interfaceInfoService.getById(id);
		if (oldInterfaceInfo == null) {
			throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
		}
		// 判断接口是否能使用
		// TODO 根据测试地址来调用
		// 这里我先用固定的方法进行测试,后面来改
		com.xuan.model.User user = new com.xuan.model.User();
		user.setName("MARS");
		String name = xuanApiClient.getNameByPostWithJson(user);
		if (StrUtil.isBlank(name)) {
			throw new BusinessException(ErrorCode.SYSTEM_ERROR, "接口验证失败");
		}
		// 更新数据库
		InterfaceInfo interfaceInfo = new InterfaceInfo();
		interfaceInfo.setId(id);
		interfaceInfo.setStatus(InterfaceInfoStatusEnum.ONLINE.getValue());
		boolean isSuccessful = interfaceInfoService.updateById(interfaceInfo);
		return ResultUtils.success(isSuccessful);
	}

	/**
	 * 下线接口
	 *
	 * @param idRequest 携带id
	 * @return 是否下线成功
	 */
	@PostMapping("/offline")
	@AuthCheck(mustRole = "admin")
	public BaseResponse<Boolean> offlineInterfaceInfo(@RequestBody IdRequest idRequest) {
		if (idRequest == null || idRequest.getId() < 0) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR);
		}
		// 判断接口是否存在
		long id = idRequest.getId();
		InterfaceInfo oldInterfaceInfo = interfaceInfoService.getById(id);
		if (oldInterfaceInfo == null) {
			throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
		}
		// 更新数据库
		InterfaceInfo interfaceInfo = new InterfaceInfo();
		interfaceInfo.setId(id);
		interfaceInfo.setStatus(InterfaceInfoStatusEnum.OFFLINE.getValue());
		boolean isSuccessful = interfaceInfoService.updateById(interfaceInfo);
		return ResultUtils.success(isSuccessful);
	}

}

 

  1. 权限控制
    这里添加权限校验,这里用到@AuthCheck(mustRole = "admin") 的切面注解,对应的实现方法在aop/AuthInterceptor
package com.xuan.project.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 权限校验
 *
 * @author xuan
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {

    /**
     * 有任何一个角色
     *
     * @return
     */
    String[] anyRole() default "";

    /**
     * 必须有某个角色
     *
     * @return
     */
    String mustRole() default "";

}

aop/AuthInterceptor.java

package com.xuan.project.aop;

import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.xuan.project.common.ErrorCode;
import com.xuan.project.exception.BusinessException;
import com.xuan.project.model.entity.User;
import com.xuan.project.service.UserService;
import com.xuan.project.annotation.AuthCheck;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;


/**
 * 权限校验 AOP
 *
 * @author xuan
 */
@Aspect
@Component
public class AuthInterceptor {

    @Resource
    private UserService userService;

    /**
     * 执行拦截
     *
     * @param joinPoint
     * @param authCheck
     * @return
     */
    @Around("@annotation(authCheck)")
    public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
        List<String> anyRole = Arrays.stream(authCheck.anyRole()).filter(StringUtils::isNotBlank).collect(Collectors.toList());
        String mustRole = authCheck.mustRole();
        RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
        // 当前登录用户
        User user = userService.getLoginUser(request);
        // 拥有任意权限即通过
        if (CollectionUtils.isNotEmpty(anyRole)) {
            String userRole = user.getUserRole();
            if (!anyRole.contains(userRole)) {
                throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
            }
        }
        // 必须有所有权限才通过
        if (StringUtils.isNotBlank(mustRole)) {
            String userRole = user.getUserRole();
            if (!mustRole.equals(userRole)) {
                throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
            }
        }
        // 通过权限校验,放行
        return joinPoint.proceed();
    }
}

userService.getLoginUser(request)

	/**
	 * 获取当前登录用户
	 *
	 * @param request
	 * @return
	 */
	@Override
	public User getLoginUser(HttpServletRequest request) {
		// 先判断是否已登录
		Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
		User currentUser = (User) userObj;
		if (currentUser == null || currentUser.getId() == null) {
			throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
		}
		// 从数据库查询(追求性能的话可以注释,直接走缓存)
		long userId = currentUser.getId();
		currentUser = this.getById(userId);
		if (currentUser == null) {
			throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
		}
		return currentUser;
	}

 

 

2、前端


1、添加发布按钮和下线按钮

 

查看了Ant Design Button的官方文档

 

发布/下线做成一个按钮。通过status来动态判断

 

{
      title: '操作',
      dataIndex: 'option',
      valueType: 'option',
      render: (_, record) => [
        <Button
          key="config"
          type={"link"}
          onClick={() => {
            handleUpdateModalOpen(true);
            setCurrentRow(record);
          }}
        >
          编辑
        </Button>,
        record.status === 0 ? (
          <Button
            key="online"
            type={'link'}
            onClick={() => {
              handleOnlineInterface(record);
            }}
          >
            发布
          </Button>
        ) : (
          <Button
            key="offline"
            type={'text'}
            // danger={true}
            onClick={() => {
              handleOfflineInterface(record);
            }}
          >
            下线
          </Button>
        ),
        <Button
          key="delete"
          type={'text'}
          danger={true}
          onClick={() => {
            handleRemoveInterfaceInfo(record);
          }}
        >
          删除
        </Button>,
      ],
    },

 

效果如下

 

 

2、编写发布/下线的方法

 

因为后端新增了代码,所以还是使用openapi自动生成前端方法

 

跟之前操作一样,去http://localhost:7529/api/v3/api-docs复制json到config/oneapi.json 然后运行openapi

 

 

新增方法

 

/**
   * @en-US Online Interface
   * @zh-CN 发布接口
   *
   * @param fields
   */
  const handleOnlineInterface = async (fields: API.IdRequest) => {
    const hide = message.loading('正在发布');
    try {
      let res = await onlineInterfaceInfoUsingPOST({ ...fields });
      if (res.data) {
        hide();
        message.success('发布成功!');
        // 刷新页面
        actionRef.current?.reload();
        return true;
      }
    } catch (error: any) {
      hide();
      message.error('发布失败!' + error.message);
      return false;
    }
  };

  /**
   * @en-US Offline Interface
   * @zh-CN 下线接口
   *
   * @param fields
   */
  const handleOfflineInterface = async (fields: API.IdRequest) => {
    const hide = message.loading('正在下线');
    try {
      let res = await offlineInterfaceInfoUsingPOST({ ...fields });
      if (res.data) {
        hide();
        message.success('下线成功!');
        // 刷新页面
        actionRef.current?.reload();
        return true;
      }
    } catch (error: any) {
      hide();
      message.error('下线失败!' + error.message);
      return false;
    }
  };

 

网页进行测试没有问题~

 

七、用户主页


前端浏览接口,查看接口文档,申请签名(注册)

 

1、浏览接口


在src/pages目录下新建Index目录并把Welcome.tsx拖入其中改名为index.tsx

 

配置路由

 

 

测试一下 主页能够正常访问,接下来再来编写页面

 

编写页面

 

这里参考了 Ant Design List组件

 

import { PageContainer } from '@ant-design/pro-components';
import { List, message } from 'antd';
import React, { useEffect, useState } from 'react';
import { listInterfaceInfoByPageUsingGET } from '@/services/api-platform-backend/interfaceInfoController';

const Index: React.FC = () => {
  const [loading, setLoading] = useState(false);
  const [list, setList] = useState<API.InterfaceInfo[]>([]);
  const [total, setTotal] = useState<number>(0);

  const loadData = async (current = 1, pageSize = 10) => {
    setLoading(true);
    try {
      const res = await listInterfaceInfoByPageUsingGET({
        current,
        pageSize,
      });
      setList(res?.data?.records ?? []);
      setTotal(res?.data?.total ?? 0);
      setLoading(false);
    } catch (error: any) {
      setLoading(false);
      message.error('请求失败,' + error.message);
    }
  };
	
  useEffect(() => {
    loadData();
  }, []);

  return (
    <PageContainer title={'主页'}>
      <List
        className="interfaceInfo-list"
        loading={loading}
        itemLayout="horizontal"
        dataSource={list}
        pagination={{
          showSizeChanger: true,
          total: total,
          showTotal(total, range) {
            return `${range[0]}-${range[1]} / ${total}`;
          },
          onChange(page, pageSize) {
            loadData(page, pageSize);
          },
        }}
        renderItem={(item) => (
          <List.Item actions={[<a key="list-more">查看详情</a>]}>
            <List.Item.Meta
              title={<a href="https://ant.design">{item.name}</a>}
              description={item.description}
            />
            <div>{item.method}</div>
          </List.Item>
        )}
      />
    </PageContainer>
  );
};

export default Index;

 

效果如下

 

 

2、查看接口文档


  1. 新建文件
    在pages下新建 InterfaceInfo/index.tsx
  1. 配置动态路由
    这里需要查看 umi文档
  {
    // 动态路由
    path: '/interface_info/:id',
    name: 'interface info',
    component: './InterfaceInfo',
    // 不在菜单页显示
    hideInMenu: true
  }

  1. 修改跳转
    点击页面即可查看详情

    主页代码片段修改
return (
    <PageContainer title={'接口开放平台'}>
      <List
        className="interfaceInfo-list"
        loading={loading}
        itemLayout="horizontal"
        dataSource={list}
        pagination={{
          showSizeChanger: true,
          total: total,
          showTotal(total, range) {
            return `${range[0]}-${range[1]} / ${total}`;
          },
          onChange(page, pageSize) {
            loadData(page, pageSize);
          },
        }}
        // 修改的地方
        renderItem={(item) => {
          const infoLink = `/interface_info/${item.id}`;
          return (
            <List.Item
              actions={[
                <a key="list-more" href={infoLink}>
                  查看详情
                </a>,
              ]}
            >
              <List.Item.Meta
                title={<a href={infoLink}>{item.name}</a>}
                description={item.description}
              />
              <div>{item.method}</div>
            </List.Item>
          );
        }}
      />
    </PageContainer>
  );

 

  1. 编写InterfaceInfo/index.tsx
    这里需要查看Ant Design中的Card 和 Descriptions 组件 已经umi中动态路由如何获取路径中的id

    如官方文档所示,这里我们使用useParams()
import { PageContainer } from '@ant-design/pro-components';
import { Badge, Card, Descriptions, message } from 'antd';
import React, { useEffect, useState } from 'react';
import { getInterfaceInfoByIdUsingGET } from '@/services/api-platform-backend/interfaceInfoController';
import { useParams } from 'react-router';
import moment from "moment";

const InterfaceInfo: React.FC = () => {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState<API.InterfaceInfo>();

  const params = useParams();

  const loadData = async () => {
    if (!params.id) {
      message.error('无数据,请重试');
    }
    setLoading(true);
    try {
      const res = await getInterfaceInfoByIdUsingGET({
        id: Number(params.id),
      });
      setData(res?.data);
      setLoading(false);
    } catch (error: any) {
      setLoading(false);
      message.error('请求失败,' + error.message);
    }
  };

  useEffect(() => {
    loadData();
  }, []);

  return (
    <PageContainer title={'接口详情'}>
      <Card loading={loading}>
        {data ? (
          <Descriptions title={data.name} column={2} layout="vertical" bordered={true}>
            <Descriptions.Item label="描述">{data.description}</Descriptions.Item>
            <Descriptions.Item label="接口状态">
              {data.status === 0 ? (
                <Badge text={'关闭'} status={'default'} />
              ) : (
                <Badge text={'启用'} status={'processing'} />
              )}
            </Descriptions.Item>
            <Descriptions.Item label="请求地址">{data.url}</Descriptions.Item>
            <Descriptions.Item label="请求方法">{data.method}</Descriptions.Item>
            <Descriptions.Item label="请求头">{data.requestHeader}</Descriptions.Item>
            <Descriptions.Item label="响应头">{data.responseHeader}</Descriptions.Item>
            <Descriptions.Item label="创建时间">{moment(data.createTime).format('yyyy-MM-DD HH:mm:ss')}</Descriptions.Item>
            <Descriptions.Item label="更新时间">{moment(data.updateTime).format('yyyy-MM-DD HH:mm:ss')}</Descriptions.Item>
          </Descriptions>
        ) : (
          <>接口不存在</>
        )}
      </Card>
    </PageContainer>
  );
};

export default InterfaceInfo;

 

  1. 效果如下
    点击后跳转详情

 

3、申请签名


注册用户的时候就给他分配一个签名

 

先在User类和UserMapper.xml中加一下accessKey、secretKey的字段

 

更新注册方法

 

@Override
	public long userRegister(String userAccount, String userPassword, String checkPassword) {
		// 1. 校验
		if (StringUtils.isAnyBlank(userAccount, userPassword, checkPassword)) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
		}
		if (userAccount.length() < 4) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短");
		}
		if (userPassword.length() < 8 || checkPassword.length() < 8) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短");
		}
		// 密码和校验密码相同
		if (!userPassword.equals(checkPassword)) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
		}
		synchronized (userAccount.intern()) {
			// 账户不能重复
			LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
			lambdaQueryWrapper.eq(User::getUserAccount, userAccount);
			long count = userMapper.selectCount(lambdaQueryWrapper);
			if (count > 0) {
				throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复");
			}
			// 2. 加密
			String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
			// 3. 分配accessKey、secretKey
			String accessKey = "cli_" + DigestUtil.md5Hex(SALT + userAccount + RandomUtil.randomNumbers(4));
			String secretKey = DigestUtil.md5Hex(SALT + userAccount + RandomUtil.randomNumbers(8));
			// 4. 插入数据
			User user = new User();
			user.setUserAccount(userAccount);
			user.setUserPassword(encryptPassword);
			user.setAccessKey(accessKey);
			user.setSecretKey(secretKey);
			boolean saveResult = this.save(user);
			if (!saveResult) {
				throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
			}
			return user.getId();
		}
	}

 

前往http://localhost:7529/api/doc.html注册。 分配成功~

 

 

更换签名

 

扩展:用户可以申请更换签名

 

八、在线调用


发现少了一个「请求参数」字段...现在给补上

 

数据库、后端、前端都需要补上。 不做过多阐述

 

这里设计的其实不太完美,只是跑通了。后续做优化

 

1、前端简单样式


这里我拿了Ant Design官网例子改了一下

 

<Card title={'在线调用'}>
  <Form
    name="basic"
    layout={'vertical'}
    onFinish={onFinish}
    >
    <Form.Item
      label="请求参数"
      name="requestParams"
      >
      <Input.TextArea />
    </Form.Item>
    <Form.Item >
      <Button type="primary" htmlType="submit">
        调用
      </Button>
    </Form.Item>
  </Form>
</Card>

 

 

2、修改后端


这里我们其实有两种方案

 

  • 走后端调用
  • 直接请求模拟接口

 

 

这里用第一种流方案,更安全更规范。模拟接口的地址就不用暴露出来

 

大概流程如下

 

  1. 前端将用户输入的请求参数和要测试的接口 id发给平台后端
  1. 调用前校验
  1. 平台后端去调用模拟接口

 

新增DTO类

 

package com.xuan.project.model.dto.interfaceinfo;

import lombok.Data;

import java.io.Serializable;


/**
 * 调用接口参数
 *
 * @author xuan
 */
@Data
public class InvokeInterfaceRequest implements Serializable {

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

	/**
	 * 请求参数
	 */
	private String requestParams;

}

 

controller类新增方法

 

/**
	 * 在线调用接口
	 *
	 * @param invokeInterfaceRequest 携带id、请求参数
	 * @return data
	 */
	@PostMapping("/invoke")
	public BaseResponse<Object> invokeInterface(@RequestBody InvokeInterfaceRequest invokeInterfaceRequest, HttpServletRequest request) throws UnsupportedEncodingException {
		if (invokeInterfaceRequest == null || invokeInterfaceRequest.getId() < 0) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR);
		}
		// 判断接口是否存在
		long id = invokeInterfaceRequest.getId();
		InterfaceInfo interfaceInfo = interfaceInfoService.getById(id);
		if (interfaceInfo == null) {
			throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
		}
		if (interfaceInfo.getStatus() != InterfaceInfoStatusEnum.ONLINE.getValue()) {
			throw new BusinessException(ErrorCode.SYSTEM_ERROR, "接口未上线");
		}
		// 得到当前用户
		User loginUser = userService.getLoginUser(request);
		String accessKey = loginUser.getAccessKey();
		String secretKey = loginUser.getSecretKey();
		XuanApiClient client = new XuanApiClient(accessKey, secretKey);
		// 先写死请求
		String userRequestParams = invokeInterfaceRequest.getRequestParams();
		com.xuan.model.User user = JSONUtil.toBean(userRequestParams, com.xuan.model.User.class);
		String result = client.getNameByPostWithJson(user);
		return ResultUtils.success(result);
	}

 

3、修改前端


Ant Design中 Form组件 onFinish: 提交表单且数据验证成功后回调事件

 

所以我们来编写onFinish方法

 

  const onFinish = async (requestData: API.InvokeInterfaceRequest) => {
    if (!params.id) {
      message.error('无数据,请重试');
    }
    try {
      const res = await invokeInterfaceUsingPOST({
        id: Number(params.id),
        ...requestData,
      });
      setResData(res.data);
      message.success('调用成功!');
    } catch (error: any) {
      message.error('请求失败,' + error.message);
    }
  };

 

再修改一下样式

 

import { PageContainer } from '@ant-design/pro-components';
import { Badge, Card, Descriptions, message, Form, Input, Button, Divider } from 'antd';
import React, { useEffect, useState } from 'react';
import {
  getInterfaceInfoByIdUsingGET,
  invokeInterfaceUsingPOST,
} from '@/services/api-platform-backend/interfaceInfoController';
import { useParams } from 'react-router';
import moment from 'moment';

const InterfaceInfo: React.FC = () => {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState<API.InterfaceInfo>();
  const [resData, setResData] = useState<any>();

  const params = useParams();

  const loadData = async () => {
    if (!params.id) {
      message.error('无数据,请重试');
    }
    setLoading(true);
    try {
      const res = await getInterfaceInfoByIdUsingGET({
        id: Number(params.id),
      });
      setData(res?.data);
      setLoading(false);
    } catch (error: any) {
      setLoading(false);
      message.error('请求失败,' + error.message);
    }
  };

  useEffect(() => {
    loadData();
  }, []);

  const onFinish = async (requestData: API.InvokeInterfaceRequest) => {
    if (!params.id) {
      message.error('无数据,请重试');
    }
    try {
      const res = await invokeInterfaceUsingPOST({
        id: Number(params.id),
        ...requestData,
      });
      setResData(res.data);
      message.success('调用成功!');
    } catch (error: any) {
      message.error('请求失败,' + error.message);
    }
  };

  return (
    <PageContainer title={'接口详情'}>
      {data ? (
        <>
          <Card loading={loading}>
            <Descriptions title={data.name} column={2} layout="vertical" bordered={true}>
              <Descriptions.Item label="描述">{data.description}</Descriptions.Item>
              <Descriptions.Item label="接口状态">
                {data.status === 0 ? (
                  <Badge text={'关闭'} status={'default'} />
                ) : (
                  <Badge text={'启用'} status={'processing'} />
                )}
              </Descriptions.Item>
              <Descriptions.Item label="请求地址">{data.url}</Descriptions.Item>
              <Descriptions.Item label="请求方法">{data.method}</Descriptions.Item>
              <Descriptions.Item label="请求头">{data.requestHeader}</Descriptions.Item>
              <Descriptions.Item label="请求参数">{data.requestParams}</Descriptions.Item>
              <Descriptions.Item label="响应头">{data.responseHeader}</Descriptions.Item>
              <Descriptions.Item label="创建时间">
                {moment(data.createTime).format('yyyy-MM-DD HH:mm:ss')}
              </Descriptions.Item>
              <Descriptions.Item label="更新时间">
                {moment(data.updateTime).format('yyyy-MM-DD HH:mm:ss')}
              </Descriptions.Item>
            </Descriptions>
          </Card>
          <Divider />
          <Card title={'在线调用'}>
            <Form name="basic" layout={'vertical'} onFinish={onFinish}>
              <Form.Item label="请求参数" name="requestParams">
                <Input.TextArea />
              </Form.Item>
              <Form.Item>
                <Button type="primary" htmlType="submit">
                  调用
                </Button>
              </Form.Item>
            </Form>
          </Card>
          {resData ? <Card title={'调用结果'}>{resData}</Card> : null}
        </>
      ) : (
        '接口不存在'
      )}
    </PageContainer>
  );
};

export default InterfaceInfo;

 

测试如下

 

 

4、TODO


  • 判断该接口是否可以调用时由固定方法名改为根据测试地址来调用
  • 用户测试接口固定方法名改为根据测试地址来调用
  • 模拟接囗改为从数据库校验accessKey、secretKey

 

九、接口调用次数统计


需求

 

  1. 用户每次调用接口成功,次数+1
  1. 给用户分配或者用户自主申请调用次数

 

业务流程

 

  1. 用户调用接口(之前已完成)
  1. 修改数据库,调用次数+1

 

1、设计库表

 

哪个用户?哪个接口?
用户=>接口(多对多)

 

用户调用接口关系表

 

create table if not exists api_platform.`user_interface_info`
(
    `id` bigint not null auto_increment comment '主键' primary key,
    `user_id` bigint not null comment '调用用户Id',
    `interface_info_id` bigint not null comment '接口Id',
    `total_num` int default 0 not null comment '总调用次数',
	  `left_num` int default 0 not null comment '剩余调用次数',
    `status` int default 0 not null comment '0-正常 ,1-禁用',
    `create_time` datetime default CURRENT_TIMESTAMP not null comment '创建时间',
    `update_time` datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    `is_delete` tinyint default 0 not null comment '是否删除(0-未删, 1-已删)'
) comment '用户调用接口关系表';

 

执行sql语句

 

使用MybatisX插件

 

生成user_interface_info表的代码

 

在isDelete上增加@TableLogic 注释 代表逻辑删除

 

/**
 * 用户调用接口关系表
 * @TableName user_interface_info
 */
@TableName(value ="user_interface_info")
@Data
public class UserInterfaceInfo implements Serializable {
    /**
     * 主键
     */
    @TableId(type = IdType.AUTO)
    private Long id;

		...

    /**
     * 是否删除(0-未删, 1-已删)
     */
    @TableLogic
    private Integer isDelete;

    @TableField(exist = false)
    private static final long serialVersionUID = 1L;
}

 

2、基础增删改查

 

先需要在dto中创建类

 

 

再复制之前的controller改名替换

 

package com.xuan.project.controller;

/**
 * API信息接口
 *
 * @author xuan
 */
@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);
		// 设置当前用户id
		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")
	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")
	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);
		System.out.println(user);
		long id = userInterfaceInfoUpdateRequest.getId();
		// 判断是否存在
		UserInterfaceInfo oldUserInterfaceInfo = userInterfaceInfoService.getById(id);
		if (oldUserInterfaceInfo == null) {
			throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
		}
		// 仅本人或管理员可修改
		userService.isAdmin(request);
		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")
	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 = "admin")
	@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
	 */
	@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);
	}

}

 

3、调用次数统计

 

用户每次调用接口成功,次数+1(service)

 

编写方法

 

service层的UserInterfaceInfoService编写方法

 

这里只是过流程,实际应该多校验

 

	@Override
	public boolean invokeInterfaceCount(long userId, long interfaceInfoId) {
		if (userId <= 0 || interfaceInfoId <= 0) {
			throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
		}

		LambdaUpdateWrapper<UserInterfaceInfo> updateWrapper = new LambdaUpdateWrapper<>();
		updateWrapper.eq(UserInterfaceInfo::getUserId, userId)
				.eq(UserInterfaceInfo::getInterfaceInfoId, interfaceInfoId)
				.gt(UserInterfaceInfo::getLeftNum, 0)
				.setSql("left_num = left_num -1, total_num = total_num + 1");

		return update(updateWrapper);
	}

 

注意:其实这里应该添加事务,添加锁

 

接口测试成功

 

4、问题

 

如果每个接口的方法都写调用次数+1,是不是比较麻烦?

 

致命问题:接口开发者需要自己去添加统计代码

 

就想到可以使用AOP、网关

 

逻辑图

 

 

AOP切面的优点:独立于接口,在每个接口调用后统计次数+1
AOP切面的缺点:只存在于单个项目中,如果每个团队都要开发自己的模拟接口,那么都要写一个切面

 

所以最终我们在这个项目选择使用网关

 

十、网关


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

 

网关优点: 统一进行操作,去处理一些问题

 

1、网关作用


  1. 路由
  1. 负载均衡
  1. 统一鉴权
  1. 统一处理跨域
  1. 统一业务处理(缓存)
  1. 访问控制
  1. 发布控制
  1. 流量染色
  1. 统一接口保护
  1. 限制请求
  2. 信息脱敏
  3. 降级(熔断)
  4. 限流 学习令牌桶算法,学习露桶算法,学习一下RedislimitHandler
  5. 超时时间
  6. 重试(业务保护)
  1. 统一日志
  1. 统一文档

 

2、具体作用


路由

 

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

 

用户a调用接口A

 

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

 

https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories

 

负载均衡

 

在路由的基础上可以转发到某一个服务器

 

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

 

uri从固定地址改成b:xx

 

统一鉴权

 

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

 

统一处理跨域

 

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

 

https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#cors-configuration

 

统一业务处理

 

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

 

访问控制

 

黑白名单,比如限制DDOS IP

 

发布控制

 

灰度发布,比如上线新接口,先给新接口分配 20%流量,老接口80% ,再慢慢调整比例

 

https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-weight-route-predicate-factory

 

流量染色

 

区分用户来源

 

给请求(流量)添加一些标识,一般是设置请求头中,添加新的请求头
https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-addrequestheader-gatewayfilter-factory

 

全局染色https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#default-filters

 

接口保护

 

  1. 限制请求
    https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#requestheadersiz-gatewayfilter-factory
  1. 信息脱敏
    https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-removerequestheader-gatewayfilter-factory
  1. 降级(熔断) 进行兜底
    https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#fallback-headers
  1. 限流
    https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-requestratelimiter-gatewayfilter-factory
  1. 超时时间    超时就中断
    https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#http-timeouts-configuration
  1. 重试(业务保护):
    https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-retry-gatewayfilter-factory

 

统一日志

 

统一的请求,响应信息记录

 

统一文档

 

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

 

建议用:https://doc.xiaominfo.com/docs/middleware-sources/aggregation-introduction

 

网关的分类

 

  • 全局网关(接入层网关)作用是负载均衡、请求日志等,不和业务逻辑绑定
  • 业务网关(微服务网关)会有一些业务逻辑,作用是将请求转发到不同的业务/项目/接口/服务

 

参考文章:https://blog.csdn.net/qq_21040559/article/details/122961395

 

实现

 

  1. Nginx (全局网关),Kong网关(API网关),  编程成本相对较高
  1. Spring Cloud Gateway(取代了Zuul)性能高 可以用java代码来写逻辑  适于学习

 

网关技术选型:https://zhuanlan.zhihu.com/p/500587132

 

十一、Spring Cloud Gateway


全部内容基本来自官网

 

官网:https://spring.io/projects/spring-cloud-gateway

 

官方文档:https://docs.spring.io/spring-cloud-gateway/docs/current/reference//html/

 

新建项目

 

在IDEA中新建项目 勾选Gateway、Lombok

 

参考官网get started中的实例代码

 

@SpringBootApplication
public class DemogatewayApplication {
	@Bean
	public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
		return builder.routes()
			.route("path_route", r -> r.path("/get")
				.uri("http://httpbin.org"))
			.route("host_route", r -> r.host("*.myhost.org")
				.uri("http://httpbin.org"))
			.route("rewrite_route", r -> r.host("*.rewrite.org")
				.filters(f -> f.rewritePath("/foo/(?<segment>.*)", "/${segment}"))
				.uri("http://httpbin.org"))
			.route("hystrix_route", r -> r.host("*.hystrix.org")
				.filters(f -> f.hystrix(c -> c.setName("slowcmd")))
				.uri("http://httpbin.org"))
			.route("hystrix_fallback_route", r -> r.host("*.hystrixfallback.org")
				.filters(f -> f.hystrix(c -> c.setName("slowcmd").setFallbackUri("forward:/hystrixfallback")))
				.uri("http://httpbin.org"))
			.route("limit_route", r -> r
				.host("*.limited.org").and().path("/anything/**")
				.filters(f -> f.requestRateLimiter(c -> c.setRateLimiter(redisRateLimiter())))
				.uri("http://httpbin.org"))
			.build();
	}
}

 

编写代码:

 

package com.xuan;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class XuanapiGatewayApplication {

	public static void main(String[] args) {
		SpringApplication.run(XuanapiGatewayApplication.class, args);
	}

	@Bean
	public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
		return builder.routes()
				.route("to_baidu", r -> r.path("/baidu")
						.uri("http://www.baidu.com/"))
				.build();
	}
}

 

测试百度成功

 

1、核心概念


1、Glossary

 

官方文档如下

 

  • Route: The basic building block of the gateway. It is defined by an ID, a destination URI, a collection of predicates, and a collection of filters. A route is matched if the aggregate predicate is true.
  • Filter: These are instances of GatewayFilter that have been constructed with a specific factory. Here, you can modify requests and responses before or after sending the downstream request.

 

  1. 路由(根据什么条件,转发到哪里)
  1. 断言(一组规则,条件,用来确定如何转发路由)
  1. 过滤器:对请求进行一系列的处理,比如添加请求头,添加请求参数

 

2、请求流程

 

  1. 客户端发起请求
  1. Handler Mapping :根据断言,去将请求转发到对应的路由
  1. Web Handler:处理请求(一层层经过过滤器)
  1. 实际调用服务

 

 

2、两种配置方式


  1. 配置式 (方便,规范)能用就用
  1. 简化版
spring:
  cloud:
    gateway:
      routes:
      - id: after_route
        uri: https://example.org
        predicates:
        - Cookie=mycookie,mycookievalue

 

  1. 全称
spring:
  cloud:
    gateway:
      routes:
      - id: after_route
        uri: https://example.org
        predicates:
        - name: Cookie
          args:
            name: mycookie
            regexp: mycookievalue

 

  1. 编程式 (灵活,相对麻烦)

 

3、路由的各种断言


官网地址:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories

 

目录

 

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

 

The After Route Predicate Factory

 

当前时间在这个时间之后,就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: after_route
        uri: https://example.org
        predicates:
        - After=2017-01-20T17:42:47.789-07:00[America/Denver]

 

The Before Route Predicate Factory

 

当前时间在这个时间之前,就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: before_route
        uri: https://example.org
        predicates:
        - Before=2017-01-20T17:42:47.789-07:00[America/Denver]

 

The Between Route Predicate Factory

 

当前时间在这个时间之间,就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: between_route
        uri: https://example.org
        predicates:
        - Between=2017-01-20T17:42:47.789-07:00[America/Denver], 2017-01-21T17:42:47.789-07:00[America/Denver]

 

The Cookie Route Predicate Factory

 

如果你的请求头cookie的是chocolate,它的值是ch.p,就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: cookie_route
        uri: https://example.org
        predicates:
        - Cookie=chocolate, ch.p

 

The Header Route Predicate Factory

 

如果你的请求头包含X-Request-Id这样一个请求头,并且,它的值符合正则表达式的规则,就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: header_route
        uri: https://example.org
        predicates:
        - Header=X-Request-Id, \d+

 

The Host Route Predicate Factory

 

如果你的访问的是这个.somehost.org,.anotherhost.org域名,就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: host_route
        uri: https://example.org
        predicates:
        - Host=**.somehost.org,**.anotherhost.org

 

The Method Route Predicate Factory

 

如果你的请求类别是这个GETPOST,就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: method_route
        uri: https://example.org
        predicates:
        - Method=GET,POST

 

The Path Route Predicate Factory

 

如果你的访问的地址是以这些/red/{segment},/blue/{segment}路径作为前缀,就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: path_route
        uri: https://example.org
        predicates:
        - Path=/red/{segment},/blue/{segment}

 

The Query Route Predicate Factory

 

根据查询条件,比如red greet green,就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: query_route
        uri: https://example.org
        predicates:
        - Query=red, gree.

 

The RemoteAddr Route Predicate Factory

 

根据远程地址,比如你的用户的ip地址是192.168.1.1/24,就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: remoteaddr_route
        uri: https://example.org
        predicates:
        - RemoteAddr=192.168.1.1/24

 

The Weight Route Predicate Factory

 

根据你设置的权重,给你把同一个访问的地址,重定到不同的服务,轻松实现发布控制

 

spring:
  cloud:
    gateway:
      routes:
      - id: weight_high
        uri: https://weighthigh.org
        predicates:
        - Weight=group1, 8
      - id: weight_low
        uri: https://weightlow.org
        predicates:
        - Weight=group1, 2

 

The XForwarded Remote Addr Route Predicate Factory

 

从请求头中如果拿到XForwarded这个请求头的地址192.168.1.1/24 就会访问当前这个路由

 

spring:
  cloud:
    gateway:
      routes:
      - id: xforwarded_remoteaddr_route
        uri: https://example.org
        predicates:
        - XForwardedRemoteAddr=192.168.1.1/24

 

4、过滤器


官网文档https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gatewayfilter-factories

 

基本功能:对请求头、请求参数、响应头的增删改查
1.添加清求头
2.添加请求参数
3.添加响应头
4.降级
5.限流
6.重试

 

The AddRequestHeader GatewayFilter Factory

 

增加请求头 (可以用作流量染色)

 

spring:
  cloud:
    gateway:
      routes:
      - id: add_request_header_route
        uri: https://example.org
        filters:
        - AddRequestHeader=X-Request-red, blue

 

使用xuan-api做测试

 

server:
  port: 8090

spring:
  cloud:
    gateway:
      routes:
        - id: name_api_route
          uri: http://localhost:8123
          predicates:
            - Path=/api/**
          filters:
            - AddRequestHeader=color, blue
            - AddRequestParameter=name, mars

 

在地址栏访问:http://localhost:8090/api/name/xuan

 

得到结果如下

 

 

The AddRequestParameter GatewayFilter Factory

 

增加请求参数

 

spring:
  cloud:
    gateway:
      routes:
      - id: add_request_parameter_route
        uri: https://example.org
        filters:
        - AddRequestParameter=red, blue

 

The AddResponseHeader GatewayFilter Factory

 

添加响应头

 

spring:
  cloud:
    gateway:
      routes:
      - id: add_response_header_route
        uri: https://example.org
        filters:
        - AddResponseHeader=X-Response-Red, Blue

 

The DedupeResponseHeader GatewayFilter Factory

 

如果响应头中有重复的,去重

 

spring:
  cloud:
    gateway:
      routes:
      - id: dedupe_response_header_route
        uri: https://example.org
        filters:
        - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin

 

保留策略,第一,最后,随机

 

The DedupeResponseHeader filter also accepts an optional strategy parameter. The accepted values are RETAIN_FIRST (default), RETAIN_LAST, and RETAIN_UNIQUE.

 

Spring Cloud CircuitBreaker GatewayFilter Factory

 

降级

 

需要引入spring-cloud-starter-circuitbreaker-reactor-resilience4j

 

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

 

spring:
  cloud:
    gateway:
      routes:
      - id: circuitbreaker_route
        uri: lb://backing-service:8088
        predicates:
        - Path=/consumingServiceEndpoint
        filters:
        - name: CircuitBreaker
          args:
            name: myCircuitBreaker
            fallbackUri: forward:/inCaseOfFailureUseThis
        - RewritePath=/consumingServiceEndpoint, /backingServiceEndpoint

 

The FallbackHeaders GatewayFilter Factory

 

降级处理器,写一下降级规则

 

spring:
  cloud:
    gateway:
      routes:
      - id: ingredients
        uri: lb://ingredients
        predicates:
        - Path=//ingredients/**
        filters:
        - name: CircuitBreaker
          args:
            name: fetchIngredients
            fallbackUri: forward:/fallback
      - id: ingredients-fallback
        uri: http://localhost:9994
        predicates:
        - Path=/fallback
        filters:
        - name: FallbackHeaders
          args:
            executionExceptionTypeHeaderName: Test-Header

 

The MapRequestHeader GatewayFilter Factory

 

如果你的请求头里面有Blue,会把Blue的值给X-Request-Red,相当于做了映射

 

spring:
  cloud:
    gateway:
      routes:
      - id: map_request_header_route
        uri: https://example.org
        filters:
        - MapRequestHeader=Blue, X-Request-Red

 

The PrefixPath GatewayFilter Factory

 

前缀处理器

 

spring:
  cloud:
    gateway:
      routes:
      - id: prefixpath_route
        uri: https://example.org
        filters:
        - PrefixPath=/mypath

 

这会将/mypath作为所有匹配请求的路径的前缀。因此,对/hello的请求将发送到/mypath/hello。

 

The PreserveHostHeader GatewayFilter Factoryatewayfilter-factory)

 

请求头转发的时候,有时候host值会变,这个可以保证不变

 

spring:
  cloud:
    gateway:
      routes:
      - id: preserve_host_route
        uri: https://example.org
        filters:
        - PreserveHostHeader

 

The RequestRateLimiter GatewayFilter Factory

 

限流

 

 

一般会使用redis+令牌桶算法

 

spring:
  cloud:
    gateway:
      routes:
      - id: requestratelimiter_route
        uri: https://example.org
        filters:
        - name: RequestRateLimiter
          args:
            redis-rate-limiter.replenishRate: 10
            redis-rate-limiter.burstCapacity: 20
            redis-rate-limiter.requestedTokens: 1

 

RequestHeaderSize GatewayFilter Factory

 

限制请求头大小 请求保护

 

spring:
  cloud:
    gateway:
      routes:
      - id: requestheadersize_route
        uri: https://example.org
        filters:
        - RequestHeaderSize=1000B

 

The RemoveRequestHeader Gateway Filter Factory

 

移除请求头 (脱敏)

 

spring:
  cloud:
    gateway:
      routes:
      - id: removerequestheader_route
        uri: https://example.org
        filters:
        - RemoveRequestHeader=X-Request-Foo

 

This removes the X-Request-Foo header before it is sent downstream.

 

The RewritePath GatewayFilter Factory

 

改写特殊的请求参数

 

spring:
  cloud:
    gateway:
      routes:
      - id: rewritepath_route
        uri: https://example.org
        predicates:
        - Path=/red/**
        filters:
        - RewritePath=/red/?(?<segment>.*), /$\{segment}

 

The Retry GatewayFilter Factory

 

自动帮你重试接口,降级重试

 

spring:
  cloud:
    gateway:
      routes:
      - id: retry_test
        uri: http://localhost:8080/flakey
        predicates:
        - Host=*.retry.com
        filters:
        - name: Retry
          args:
            retries: 3
            statuses: BAD_GATEWAY
            methods: GET,POST
            backoff:
              firstBackoff: 10ms
              maxBackoff: 50ms
              factor: 2
              basedOnPreviousValue: false

 

Default Filters

 

默认过滤器 可以用作全局染色

 

spring:
  cloud:
    gateway:
      default-filters:
      - AddResponseHeader=X-Response-Default-Red, Default-Blue
      - PrefixPath=/httpbin

 

5、其他配置


1、全局过滤器

 

Global Filters

 

定义过滤器

 

@Bean
public GlobalFilter customFilter() {
    return new CustomGlobalFilter();
}

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;
    }
}

 

2、Http timeouts configuration

 

Global  timeouts

 

配置http超时

 

spring:
  cloud:
    gateway:
      httpclient:
        connect-timeout: 1000
        response-timeout: 5s

 

3、CORS Configuration

 

跨域配置

 

spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOrigins: "https://docs.spring.io"
            allowedMethods:
            - GET

 

小作业:

 

通过阅读源码:https://spring.io/projects/spring-cloud-gateway/#samples 来了解gateway编程式开发

 

十二、项目整合网关


  1. 实现统一的用户鉴权 ,统一的接口调用次数统计(把API网关用到项目中)
  1. 完善功能

 

会用到的特性

 

  1. 路由(转发请求到模拟接口项目)
  1. 负载均衡(需要用到注册中心)
  1. 统一鉴权(accessKey,secretKey)
  1. 统一处理跨域
  1. 统一业务处理(每次请求接口后,接口调用次数+1)
  1. 访问控制(黑白名单)
  1. 发布控制
  1. 流量染色(记录请求是否为网关来的)
  1. 统一接口保护
  1. 限制请求
  2. 信息脱敏
  3. 降级(熔断)
  4. 限流 学习令牌桶算法,学习露桶算法,学习一下RedislimitHandler
  5. 超时时间
  6. 重试(业务保护)
  1. 统一日志(记录每次的请求和响应)
  1. 统一文档

 

业务逻辑

 

  1. 用户发送请求到API网关
  1. 请求日志
  1. 黑白名单
  1. 用户鉴权(判断ak,sk是否合法)
  1. 请求的模拟接口是否存在?
  1. 请求转发,调用模拟接口
  1. 响应日志
  1. 调用成功,接口调用次数+1
  1. 调用失败,返回规范错误码

1、请求转发


使用Path匹配断言

 

所有前缀为:/api/ 的请求进行转发,转发到http://localhost:8123/api

 

比如请求网关:http://localhost:8090/api/name/?name=archer转发到 http://localhost:8123/api/name/?name=archer

 

server:  
  port: 8090  
  
spring:  
  cloud:  
    gateway:  
      routes:  
        - id: api_route  
          uri: http://localhost:8123  
          predicates:  
            - Path=/api/**

 

测试没有问题

 

 

2、Global Filter

 

使用了Global Filters,全局请求拦截处理(类似aop)

 

查看官网,使用模板代码为基础进行编写程序

 

package com.xuan.filter;
/**
 * 全局过滤器
 *
 * @version 1.0
 * @author: 玄
 * @date: 2023/2/1
 */
@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;
	}

}

 

1、请求日志

 

我们参考之前的AOP的写法,从exchange这个路由交换机里面拿到我们所有的请求的信息

 

@Override
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
		// 1. 请求日志
		ServerHttpRequest request = exchange.getRequest();
		log.info("请求id: {}", request.getId());
		log.info("请求路径: {}", request.getPath());
		log.info("请求方法: {}", request.getMethod());
		log.info("请求参数: {}", request.getQueryParams());
		log.info("请求头: {}", request.getHeaders());
		log.info("请求地址: {}", request.getRemoteAddress());
		return chain.filter(exchange);
	}

 

 

2、添加黑白名单

 

建议用白名单,更安全些

 

如果这个来源地址不是白名单里面的,我们就直接设个状态码(这里设置403),然后拦截掉 response.setComplete() 可以理解为设置响应完成

 

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

@Override
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
		// 1. 请求日志
		ServerHttpRequest request = exchange.getRequest();
		String remoteAddress = request.getRemoteAddress().getHostString();
		log.info("请求地址: {}", remoteAddress);
		// 2. 访问控制 - 黑白名单
		if (!IP_WHITE_LIST.contains(remoteAddress)){
			ServerHttpResponse response = exchange.getResponse();
			response.setStatusCode(HttpStatus.FORBIDDEN);
			return response.setComplete();
		}
		return chain.filter(exchange);
	}

 

将IP_WHITE_LIST设置为黑名单 测试被拒

 

 

3、用户鉴权

 

找到之前用户鉴权的代码 复制过来 修改一下 需要倒入我之前做的starter

 

<dependency>
    <groupId>com.xuan</groupId>
    <artifactId>xuanapi-client-sdk</artifactId>
    <version>0.0.1</version>
</dependency>

 

	@Override
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
		// 1. 请求日志
		ServerHttpRequest request = exchange.getRequest();
		log.info("请求id: {}", request.getId());
		log.info("请求路径: {}", request.getPath());
		log.info("请求方法: {}", request.getMethod());
		log.info("请求参数: {}", request.getQueryParams());
		log.info("请求头: {}", request.getHeaders());
		String remoteAddress = request.getRemoteAddress().getHostString();
		log.info("请求地址: {}", remoteAddress);

		// 2. 访问控制 - 黑白名单
		if (!IP_WHITE_LIST.contains(remoteAddress)) {
			return handleNoAuth(exchange.getResponse());
		}

		// 3. 用户鉴权
		HttpHeaders headers = request.getHeaders();
		String accessKey = headers.getFirst("accessKey");
		// 防止中文乱码
		String body = null;
		try {
			body = URLDecoder.decode(headers.getFirst("body"), StandardCharsets.UTF_8.name());
		} catch (UnsupportedEncodingException e) {
			throw new RuntimeException(e);
		}
		String sign = headers.getFirst("sign");
		String nonce = headers.getFirst("nonce");
		String timestamp = headers.getFirst("timestamp");
		boolean hasBlank = StrUtil.hasBlank(accessKey, body, sign, nonce, timestamp);
		// 判断是否有空
		if (hasBlank) {
			return handleInvokeError(exchange.getResponse());
		}
		// TODO 使用accessKey去数据库查询secretKey
		// 假设查到的secret是abc 进行加密得到sign
		String secretKey = "abc";
		String sign1 = SignUtil.getSign(body, secretKey);
		if (!StrUtil.equals(sign, sign1)) {
			return handleInvokeError(exchange.getResponse());
		}
		// TODO 判断随机数nonce
		// 时间戳是否为数字
		if (!NumberUtil.isNumber(timestamp)) {
			return handleInvokeError(exchange.getResponse());
		}
		// 五分钟内的请求有效
		if (System.currentTimeMillis() - Long.parseLong(timestamp) > FIVE_MINUTES) {
			return handleInvokeError(exchange.getResponse());
		}
		// 4. 请求的模拟接口是否存在?
		// 5. 请求转发,调用模拟接口
		// 6. 响应日志
		// 7. 调用成功,接口调用次数+1
		// 8. 调用失败,返回规范错误码
		return chain.filter(exchange);
	}

	private Mono<Void> handleNoAuth(ServerHttpResponse response) {
		response.setStatusCode(HttpStatus.FORBIDDEN);
		return response.setComplete();
	}

	private Mono<Void> handleInvokeError(ServerHttpResponse response) {
		response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
		return response.setComplete();
	}

 

4、判读请求的接口是否存在

 

我们可以从数据库中查询模拟接口是否存在,以及请求方法是否匹配(还可以校验请求参数) 因为网关项目没引入MyBatis等操作数据库的类库,如果该孩操作较为复杂,可以由backend增删改查项目提供接口,我们直接调用,不用再重复写逻辑了。

 

  • HTTP请求(用HTTPClient、.用RestTemplate、Feign)
  • RPC(Dubbo)

 

5、请求转发 调用模拟接口

 

		// 5. 请求转发,调用模拟接口
		Mono<Void> filter = chain.filter(exchange);
		// 6. 响应日志
		log.info("响应状态码:{}", response.getStatusCode());
		if (response.getStatusCode() == HttpStatus.OK) {
			// 7. 调用成功,接口调用次数+1
		} else {
			// 8. 调用失败,返回规范错误码
			return handleInvokeError(response);
		}
		return filter;

 

接下来需要修改客户端的地址,让它经过网关

 

找到SDK修改地址

 

 

6、异步返回问题

 

又出现一个问题,我们的接口调用,是在过滤器完成之后进行的,是个异步操作

 

 

预期是等模拟接口调用完成,才记录响应日志、统计调用次数。
但现实是 chain.fitter 方法立刻返回了,直到 filter 过滤器 return 后才调用了模拟接口。
原因是:chain.filter 是个异步操作,理解为前端的 promise

 

解決方案:利用response 装饰者,增强原有 response 的处理能力
参考博客:https://blog.csdn.net/qq_19636353/article/details/126759522(以这个为主)
其他参考:
• https://blog.csdn.net/mo_67595943/article/details/124667975
• https://blog.csdn.net/weixin_43933728/article/details/121359727
• https://blog.csdn.net/zx156955/article/details/121670681
• https://blog.csdn.net/qq_39529562/article/details/108911983

 

这些代码不用记忆 搜「Spring Cloud Gateway 响应日志」就有了

 

复制https://blog.csdn.net/qq_19636353/article/details/126759522  中的Response log代码。并改写

 

/**
	 * 处理响应
	 *
	 * @param exchange
	 * @param chain
	 * @return
	 */
	private Mono<Void> handleResponse(ServerWebExchange exchange, GatewayFilterChain chain) {
		try {
			// 从交换机拿到原始response
			ServerHttpResponse originalResponse = exchange.getResponse();
			// 缓冲区工厂 拿到缓存数据
			DataBufferFactory bufferFactory = originalResponse.bufferFactory();
			// 拿到状态码
			HttpStatus statusCode = originalResponse.getStatusCode();

			if (statusCode == HttpStatus.OK) {
				// 装饰,增强能力
				ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(originalResponse) {
					// 等调用完转发的接口后才会执行
					@Override
					public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
						log.info("body instanceof Flux: {}", (body instanceof Flux));
						// 对象是响应式的
						if (body instanceof Flux) {
							// 我们拿到真正的body
							Flux<? extends DataBuffer> fluxBody = Flux.from(body);
							// 往返回值里面写数据
							// 拼接字符串
							return super.writeWith(fluxBody.map(dataBuffer -> {
								// TODO 7. 调用成功,接口调用次数+1
								// data从这个content中读取
								byte[] content = new byte[dataBuffer.readableByteCount()];
								dataBuffer.read(content);
								DataBufferUtils.release(dataBuffer);// 释放掉内存
								// 6.构建日志
								List<Object> rspArgs = new ArrayList<>();
								rspArgs.add(originalResponse.getStatusCode());
								String data = new String(content, StandardCharsets.UTF_8);// data
								rspArgs.add(data);
								log.info("<--- status:{} data:{}"// data
										, rspArgs.toArray());// log.info("<-- {} {}", originalResponse.getStatusCode(), data);
								return bufferFactory.wrap(content);
							}));
						} else {
							// 8.调用失败返回错误状态码
							log.error("<--- {} 响应code异常", getStatusCode());
						}
						return super.writeWith(body);
					}
				};
				// 设置 response 对象为装饰过的
				return chain.filter(exchange.mutate().response(decoratedResponse).build());
			}
			return chain.filter(exchange);// 降级处理返回数据
		} catch (Exception e) {
			log.error("gateway log exception.\n" + e);
			return chain.filter(exchange);
		}

	}

 

十二、RPC


RPC(Remote Procedure Call)远程过程调用

 

网关业务逻辑

 

问题: 网关项目比较存粹,没有操作数据库的包,并且还要调用我们之前写过的代码?复制粘贴维护麻烦
理想:直接请求到其他项目的方法

 

怎么调用其他项目的方法?

 

  1. 复制代码和依赖,环境
  1. HTTP请求(提供接口,供其他项目调用)
  1. RPC
  1. 把公共的代码打个jar包,其他项目去引用

 

HTTP请求怎么调用

 

  1. 提供方提供一个接口(地址,请求方法,参数,返回值)
  1. 调用方使用HTTP Client之类的代码包去发送HTTP请求

 

RPC作用

 

像调用本地方法一样去调用远程方法

 

RPC优点

 

  1. 对开发者更透明,减少很多的沟通成本
  1. RPC向远程服务器发送请求时,未必使用HTTP协议,比如还可以使用TCP/IP,性能更高。(内部服务更实适用)

 

 

注意: 这里注册中心只提供信息,并不会帮助调用

 

1、Dubbo框架(RPC实现)


官网:https://cn.dubbo.apache.org/zh/

 

常见框架还有GRPC、TRPC

 

最好的学习方式:阅读官方文档

 

1、两种使用方式

 

  1. Spring Boot代码(注解+编程式):写Java接口,服务提供者和消费者都去引用这个接口
    偏程导
  1. DL(接口调用语言):创建一个公共的接口定义文件,服务提供者和消费者读取这个文件。优点是跨语言,所有的框架都认识

 

底层是Triple协议:
https://dubbo.incubator.apache.org/zh/docs3-v2/java-sdk/concepts-and-architecture/triple/

 

2、快速使用 (Spring Boot)

 

按照官网步骤来

 

下载源码

 

git clone -b master https://github.com/apache/dubbo-samples.git

 

在IDEA中打开

 

 

看一下结构

 

 

consumer和provider的配置都如下

 

dubbo:
  application:
    name: dubbo-springboot-demo-provider
  protocol:
    name: dubbo
    port: -1
  registry: # 注册中心
    id: zk-registry
    address: zookeeper://127.0.0.1:2181
  config-center:
    address: zookeeper://127.0.0.1:2181
  metadata-report:
    address: zookeeper://127.0.0.1:2181

 

EmbeddedZooKeeper 提个一个内置的ZooKeeper作为注册中心

 

启动项目

 

先后启动 注册中心(provider内置)、provider、consumer 测试跑通。

 

十三、项目整合Dubbo、Nacos


  1. backend项目作为服务提供者,提供3个方法:
  1. 实际情况应该是去数据库中查是否已分配给用户
  2. 从数据库中查询模拟接口是否存在,以及请求方法是否匹配(还可以校验请求参数)
  3. 调用成功,接口调用次数+1 invokeCount
  1. gateway项日作为服务调用者,调用这3个方法

 

1、安装启动Nacos

 

整合Nacos注册中:Nacos | Apache Dubbo
Nacos下载地址:Nacos 快速开始

 

启动命令(standalone代表着单机模式运行,非集群模式)

 

sh startup.sh -m standalone

 

 

用户名、密码都是nacos

 

 

2、项目跑通


添加依赖

 

在api-platform-backend、api-platform-gateway中添加如下依赖

 

<dependency>
    <groupId>org.apache.dubbo</groupId>
    <artifactId>dubbo</artifactId>
    <version>3.1.5</version>
</dependency>

<dependency>
    <groupId>com.alibaba.nacos</groupId>
    <artifactId>nacos-client</artifactId>
    <version>2.2.0</version>
</dependency>

 

这里的nacos是我下载的版本

 

添加配置

 

dubbo:
  application:
    name: dubbo-api-platform-backend-provider
  protocol:
    name: dubbo
    port: -1
  registry:
    id: nacos-registry
    address: nacos://localhost:8848

 

注意:

 

  1. 服务接口类必须要在同一个包下,建议是抽象出一个公共项日(放接口、实体类等)
  1. 置注解(比如启动类的EnableDubbo、接口实现类和Bean引用的注解:@DubboService、@DubboReference)
  1. 添加配置
  1. 服务调用项目和提供者项目尽量引入相同的依赖和配置

 

1、api-platform-backend

 

在主包下添加rpc包(com.xuan.project.rpc)

 

 

RpcDemoServer.java

 

public interface RpcDemoService {
	String sayHello(String name);
}

 

RpcDemoServerImpl.java

 

@DubboService
public class RpcDemoServiceImpl implements RpcDemoService {
	@Override
	public String sayHello(String name) {
		System.out.println("Hello " + name + ", request from consumer: " + RpcContext.getContext().getRemoteAddress());
		return "Hello " + name;
	}
}

 

Application主类新增@EnableDubbo注解

 

@SpringBootApplication
@EnableDubbo
@MapperScan("com.xuan.project.mapper")
public class MyApplication {

    public static void main(String[] args) {
       	SpringApplication.run(MyApplication.class, args);
    }

}

 

启动主类查看Nacos 注册成功

 

 

2、api-platform-gateway

 

在和backend一样的路径下新建rpc包 (com.xuan.project.rpc) 新增接口类 代码复制过来即可

 

 

前往测试类做测试

 

@SpringBootTest
class ApiPlatformGatewayApplicationTests {

	@DubboReference
	private RpcDemoService rpcDemoService;

	@Test
	void testRpc() {
		System.out.println(rpcDemoService.sayHello("world"));
	}

}

 

测试成功~

 

 

3、抽象公共服务


项目名:api-platform-common
目的是让方法、实体类在多个项目间复用,减少重复编写

 

1、抽取的服务

 

  1. 数据库中查是否已分配给用户秘钥(根据 accessKey 拿到用户信息,返回用户信息,为空表示不存在)
  1. 从数据库中查询模拟接口是否存在(请求路径、请求方法、请求参数,返回接口信息,为空表示不存在)
  1. 接口调用次数+ 1 invokeCount (accessKey、secretKey(标识用户),请求接口路径)

 

2、具体操作

 

  1. 新建maven项目
    取名为api-platform-common
    依赖才api-platform-backend里复制后摘出我需要的
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.xuan</groupId>
    <artifactId>api-platform-common</artifactId>
    <version>0.0.1</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>

        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.2</version>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.1</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.9.0</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.12.0</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/junit/junit -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

 

  1. 复制之前的model包下的实体类
  1. 在common包下新建service层
public interface InnerInterfaceInfoService {

	/**
	 * 根据path、method查询接口信息
	 *
	 * @param path   请求路径
	 * @param method 请求方法
	 * @return InterfaceInfo
	 */
	InterfaceInfo getInvokeInterfaceInfo(String path, String method);
}

public interface InnerUserService {

    /**
     * 根据accessKey查询用户
     *
     * @param accessKey accessKey
     * @return User
     */
    User getInvokeUser(String accessKey);

}

public interface InnerUserInterfaceInfoService {

    /**
	 * 是否还有调用次数
	 *
	 * @param userId          用户id
	 * @param interfaceInfoId 接口id
	 * @return boolean
	 */
	boolean hasInvokeNum(long userId, long interfaceInfoId);


	/**
	 * 根据userId、interfaceInfoId计数
	 *
	 * @param userId          用户id
	 * @param interfaceInfoId 接口id
	 * @return boolean
	 */
	boolean invokeInterfaceCount(long userId, long interfaceInfoId);

}

 

  1. 打包
    使用maven install打包
    api-platform-backend引入依赖
    <dependency>
        <groupId>com.xuan</groupId>
        <artifactId>api-platform-common</artifactId>
        <version>0.0.1</version>
    </dependency>

 

  1. 编写impl进行测试
/**
 * @version 1.0
 * @author: 玄
 * @date: 2023/2/6
 */

@DubboService
public class InnerUserInterfaceInfoServiceImpl implements InnerUserInterfaceInfoService {

	@Resource
	private UserInterfaceInfoMapper userInterfaceInfoMapper;

	@Override
	public boolean hasInvokeNum(long userId, long interfaceInfoId) {
		if (userId <= 0 || interfaceInfoId <= 0) {
			throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
		}

		LambdaQueryWrapper<UserInterfaceInfo> queryWrapper = new LambdaQueryWrapper<>();
		queryWrapper.eq(UserInterfaceInfo::getUserId, userId)
				.eq(UserInterfaceInfo::getInterfaceInfoId, interfaceInfoId)
				.gt(UserInterfaceInfo::getLeftNum, 0);

		UserInterfaceInfo userInterfaceInfo = userInterfaceInfoMapper.selectOne(queryWrapper);
		return userInterfaceInfo != null;
	}

	@Override
	public boolean invokeInterfaceCount(long userId, long interfaceInfoId) {
		if (userId <= 0 || interfaceInfoId <= 0) {
			throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
		}

		LambdaUpdateWrapper<UserInterfaceInfo> updateWrapper = new LambdaUpdateWrapper<>();
		updateWrapper.eq(UserInterfaceInfo::getUserId, userId)
				.eq(UserInterfaceInfo::getInterfaceInfoId, interfaceInfoId)
				.gt(UserInterfaceInfo::getLeftNum, 0)
				.setSql("left_num = left_num -1, total_num = total_num + 1");

		int updateCount = userInterfaceInfoMapper.update(null, updateWrapper);
		return updateCount > 0;
	}

}

 

  1. gateway启动报错
Description:

Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.

Reason: Failed to determine a suitable driver class


Action:

Consider the following:
	If you want an embedded database (H2, HSQL or Derby), please put it on the classpath.
	If you have database settings to be loaded from a particular profile you may need to activate it (no profiles are currently active).


经分析我们需要在主类上排除数据库的类加载(google springboot忽略数据库启动得到)

package com.xuan.project;

import org.apache.dubbo.config.spring.context.annotation.EnableDubbo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;

@EnableDubbo
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class,
		DataSourceTransactionManagerAutoConfiguration.class,
		HibernateJpaAutoConfiguration.class})
public class ApiPlatformGatewayApplication {

	public static void main(String[] args) {
		SpringApplication.run(ApiPlatformGatewayApplication.class, args);
	}

}

再次启动网关成功~

  1. 测试跑通

 

3、impl具体实现

 

在backend新建 service/impl/inner包

 

 

/**
 * @version 1.0
 * @author: 玄
 * @date: 2023/2/6
 */

@DubboService
public class InnerInterfaceInfoServiceImpl implements InnerInterfaceInfoService {

	@Resource
	private InterfaceInfoMapper interfaceInfoMapper;

	@Override
	public InterfaceInfo getInvokeInterfaceInfo(String url, String method) {
		if (StrUtil.hasBlank(url, method)) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR);
		}
		LambdaQueryWrapper<InterfaceInfo> lambdaQueryWrapper = new LambdaQueryWrapper<>();
		lambdaQueryWrapper.eq(InterfaceInfo::getUrl, url).eq(InterfaceInfo::getMethod, method);
		return interfaceInfoMapper.selectOne(lambdaQueryWrapper);
	}

}

 

/**
 * @version 1.0
 * @author: 玄
 * @date: 2023/2/6
 */

@DubboService
public class InnerUserServiceImpl implements InnerUserService {

	@Resource
	private UserMapper userMapper;

	@Override
	public User getInvokeUser(String accessKey) {
		if (StrUtil.isBlank(accessKey)) {
			throw new BusinessException(ErrorCode.PARAMS_ERROR);
		}
		LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
		lambdaQueryWrapper.eq(User::getAccessKey, accessKey);
		return userMapper.selectOne(lambdaQueryWrapper);
	}
}

 

4、优化网关Global Filter

 


/**
 * 全局过滤器
 *
 * @version 1.0
 * @author: 玄
 * @date: 2023/2/1
 */

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

	@DubboReference
	private InnerUserService innerUserService;

	@DubboReference
	private InnerInterfaceInfoService innerInterfaceInfoService;

	@DubboReference
	private InnerUserInterfaceInfoService innerUserInterfaceInfoService;

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

	private static final long FIVE_MINUTES = 5 * 60 * 1000L;

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

	@Override
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
		// 1. 请求日志
		ServerHttpRequest request = exchange.getRequest();
		String path = INTERFACE_HOST + request.getPath().value();
		String method = Objects.requireNonNull(request.getMethod()).toString();
		log.info("请求id: {}", request.getId());
		log.info("请求路径: {}", path);
		log.info("请求方法: {}", method);
		log.info("请求参数: {}", request.getQueryParams());
		log.info("请求头: {}", request.getHeaders());
		String remoteAddress = Objects.requireNonNull(request.getRemoteAddress()).getHostString();
		log.info("请求地址: {}", remoteAddress);

		// 2. 访问控制 - 黑白名单
		ServerHttpResponse response = exchange.getResponse();
		if (!IP_WHITE_LIST.contains(remoteAddress)) {
			return handleNoAuth(response);
		}

		// 3. 用户鉴权
		HttpHeaders headers = request.getHeaders();
		String accessKey = headers.getFirst("accessKey");
		// 防止中文乱码
		String body = null;
		try {
			body = URLDecoder.decode(headers.getFirst("body"), StandardCharsets.UTF_8.name());
		} catch (UnsupportedEncodingException e) {
			throw new RuntimeException(e);
		}
		String sign = headers.getFirst("sign");
		String nonce = headers.getFirst("nonce");
		String timestamp = headers.getFirst("timestamp");
		boolean hasBlank = StrUtil.hasBlank(accessKey, body, sign, nonce, timestamp);
		// 判断是否有空
		if (hasBlank) {
			return handleInvokeError(response);
		}
		// 使用accessKey去数据库查询secretKey
		User invokeUser = null;
		try {
			invokeUser = innerUserService.getInvokeUser(accessKey);
		} catch (Exception e) {
			log.error("getInvokeUser error", e);
		}
		if (invokeUser == null) {
			return handleInvokeError(response);
		}
		String secretKey = invokeUser.getSecretKey();
		String sign1 = SignUtil.getSign(body, secretKey);
		if (!StrUtil.equals(sign, sign1)) {
			return handleInvokeError(response);
		}
		// TODO 判断随机数nonce
		// 时间戳是否为数字
		if (!NumberUtil.isNumber(timestamp)) {
			return handleInvokeError(response);
		}
		// 五分钟内的请求有效
		if (System.currentTimeMillis() - Long.parseLong(timestamp) > FIVE_MINUTES) {
			return handleInvokeError(response);
		}
		// 4. 请求的模拟接口是否存在
		InterfaceInfo invokeInterfaceInfo = null;
		try {
			invokeInterfaceInfo = innerInterfaceInfoService.getInvokeInterfaceInfo(path, method);
		} catch (Exception e) {
			log.error("getInvokeInterfaceInfo error", e);
		}
		if (invokeInterfaceInfo == null) {
			return handleInvokeError(response);
		}
		//  是否有调用次数
		if (!innerUserInterfaceInfoService.hasInvokeNum(invokeUser.getId(), invokeInterfaceInfo.getId())) {
			return handleInvokeError(response);
		}
		// 5. 请求转发,调用模拟接口
		return handleResponse(exchange, chain, invokeUser.getId(), invokeInterfaceInfo.getId());

	}

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

	/**
	 * 处理响应
	 *
	 * @param exchange
	 * @param chain
	 * @return
	 */
	private Mono<Void> handleResponse(ServerWebExchange exchange, GatewayFilterChain chain, long userId, long interfaceInfoId) {
		try {
			// 从交换机拿到原始response
			ServerHttpResponse originalResponse = exchange.getResponse();
			// 缓冲区工厂 拿到缓存数据
			DataBufferFactory bufferFactory = originalResponse.bufferFactory();
			// 拿到状态码
			HttpStatus statusCode = originalResponse.getStatusCode();

			if (statusCode == HttpStatus.OK) {
				// 装饰,增强能力
				ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(originalResponse) {
					// 等调用完转发的接口后才会执行
					@Override
					public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
						log.info("body instanceof Flux: {}", (body instanceof Flux));
						// 对象是响应式的
						if (body instanceof Flux) {
							// 我们拿到真正的body
							Flux<? extends DataBuffer> fluxBody = Flux.from(body);
							// 往返回值里面写数据
							// 拼接字符串
							return super.writeWith(fluxBody.map(dataBuffer -> {
								// 7. 调用成功,接口调用次数+1
								try {
									innerUserInterfaceInfoService.invokeInterfaceCount(userId, interfaceInfoId);
								} catch (Exception e) {
									log.error("invokeInterfaceCount error", e);
								}
								// data从这个content中读取
								byte[] content = new byte[dataBuffer.readableByteCount()];
								dataBuffer.read(content);
								DataBufferUtils.release(dataBuffer);// 释放掉内存
								// 6.构建日志
								List<Object> rspArgs = new ArrayList<>();
								rspArgs.add(originalResponse.getStatusCode());
								String data = new String(content, StandardCharsets.UTF_8);// data
								rspArgs.add(data);
								log.info("<--- status:{} data:{}"// data
										, rspArgs.toArray());// log.info("<-- {} {}", originalResponse.getStatusCode(), data);
								return bufferFactory.wrap(content);
							}));
						} else {
							// 8.调用失败返回错误状态码
							log.error("<--- {} 响应code异常", getStatusCode());
						}
						return super.writeWith(body);
					}
				};
				// 设置 response 对象为装饰过的
				return chain.filter(exchange.mutate().response(decoratedResponse).build());
			}
			return chain.filter(exchange);// 降级处理返回数据
		} catch (Exception e) {
			log.error("gateway log exception.\n" + e);
			return chain.filter(exchange);
		}

	}

	private Mono<Void> handleNoAuth(ServerHttpResponse response) {
		response.setStatusCode(HttpStatus.FORBIDDEN);
		return response.setComplete();
	}

	private Mono<Void> handleInvokeError(ServerHttpResponse response) {
		response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
		return response.setComplete();
	}

}

 

十四、统计分析


需求

 

各接口的总调用次数占比(饼图)取调用最多的前 3个接口,从而分析出哪些接口没有人用(降低资源、或者下线),高频接口(增加资源、提高收费)。用饼图展示。

 

1、后端


1、编写SQL

 

SELECT
	interface_info_id,
	SUM( total_num ) AS invoke_num 
FROM
	user_interface_info 
GROUP BY
	interface_info_id 
ORDER BY
	invoke_num DESC 
	LIMIT 3

 

 

SQL语句确认没问题后 再在代码里编写

 

2、编写接口

 

新增VO、Mapper、Service、Controller

 

VO

 

@EqualsAndHashCode(callSuper = true)
@Data
public class InvokeInterfaceInfoVO extends InterfaceInfo {

    /**
     * 接口调用次数
     */
    private Integer invokeNum;

    private static final long serialVersionUID = 1L;

}

 

Mapper

 

public interface UserInterfaceInfoMapper extends BaseMapper<UserInterfaceInfo> {
    
	List<InvokeInterfaceInfoVO> listTopInvokeInterfaceInfo(int limit);

}

 

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xuan.project.mapper.UserInterfaceInfoMapper">
   
    <select id="listTopInvokeInterfaceInfo" resultType="com.xuan.project.model.vo.InvokeInterfaceInfoVO">
        SELECT interface_info_id AS id,
               SUM(total_num)    AS invoke_num
        FROM user_interface_info
        GROUP BY interface_info_id
        ORDER BY invoke_num DESC LIMIT #{limit}
    </select>
    
</mapper>

 

注意 :SELECT interface_info_id AS id, 这里一定要AS id 因为VO类继承的InterfaceInfo类。这里面只有id字段

 

Service

 

/**
 * @version 1.0
 * @author: 玄
 * @date: 2023/2/7
 */

@Service
public class ChartServiceImpl implements ChartService {

	@Resource
	private UserInterfaceInfoMapper userInterfaceInfoMapper;

	@Resource
	private InterfaceInfoService interfaceInfoService;

	@Override
	public List<InvokeInterfaceInfoVO> listTopInvokeInterfaceInfo(int limit) {
		List<InvokeInterfaceInfoVO> vos = userInterfaceInfoMapper.listTopInvokeInterfaceInfo(limit);
		if (vos == null || vos.size() == 0) {
			throw new BusinessException(ErrorCode.SYSTEM_ERROR);
		}
		// 根据id查询接口名称
		LinkedHashMap<Long, InvokeInterfaceInfoVO> voHashMap = new LinkedHashMap<>(vos.size());
		for (InvokeInterfaceInfoVO vo : vos) {
			voHashMap.put(vo.getId(), vo);
		}
		LambdaQueryWrapper<InterfaceInfo> queryWrapper = new LambdaQueryWrapper<>();
		queryWrapper.in(InterfaceInfo::getId, voHashMap.keySet());
		List<InterfaceInfo> infoList = interfaceInfoService.list(queryWrapper);

		for (InterfaceInfo interfaceInfo : infoList) {
			voHashMap.get(interfaceInfo.getId()).setName(interfaceInfo.getName());
		}

		return new ArrayList<>(voHashMap.values());
	}
	
}

 

也可以使用stream流来实现

 

Controller

 

/**
 * 图表
 *
 * @version 1.0
 * @author: 玄
 * @date: 2023/2/7
 */

@Slf4j
@RestController
@RequestMapping("/chart")
public class ChartController {

	@Resource
	private ChartService chartService;

	@GetMapping("/top/interface/invoke")
	BaseResponse<List<InvokeInterfaceInfoVO>> listTopInvokeInterfaceInfo () {
		List<InvokeInterfaceInfoVO> listTopInvokeInterfaceInfo = chartService.listTopInvokeInterfaceInfo(3);
		return ResultUtils.success(listTopInvokeInterfaceInfo);
	}

}

 

2、前端


图表强烈推荐用现成的库!!!
比如:

 

  • BizCharts

 

使用步骤都大同小异

 

  1. 看官网
  1. 找到快速入门、按文档去引入库
  1. 进入示例页面
  1. 找到你要的图
  1. 在线调试
  1. 复制代码
  1. 改为真实数据

 

这里选择使用了Echars再加上使用的是react 所以用这个库:https://github.com/hustcc/echarts-for-react

 

config/routes.ts下新增路由

 

src/pages/Admin中使用上面步骤写了一个简单页面

 

import { PageContainer } from '@ant-design/pro-components';
import ReactECharts from 'echarts-for-react';
import React, { useEffect, useState } from 'react';
import { listTopInvokeInterfaceInfoUsingGET } from '@/services/api-platform-backend/chartController';

const InterfaceChart: React.FC = () => {
  
  const [data, setData] = useState<API.InvokeInterfaceInfoVO[]>([]);

  const [loading, setLoading] = useState(true);

  useEffect(() => {
    listTopInvokeInterfaceInfoUsingGET().then((res) => {
      if (res.data) {
        setData(res.data);
        setLoading(false);
      }
    });
  }, []);

  const chartInterface = data.map((item) => {
    return {
      value: item.invokeNum,
      name: item.name,
    };
  });

  const option = {
    tooltip: {
      trigger: 'item',
    },
    legend: {
      top: '5%',
      left: 'center',
    },
    series: [
      {
        name: 'Access From',
        type: 'pie',
        radius: ['40%', '70%'],
        avoidLabelOverlap: false,
        itemStyle: {
          borderRadius: 10,
          borderColor: '#fff',
          borderWidth: 2,
        },
        label: {
          show: false,
          position: 'center',
        },
        emphasis: {
          label: {
            show: true,
            fontSize: 20,
            fontWeight: 'bold',
          },
        },
        labelLine: {
          show: false,
        },
        data: chartInterface,
      },
    ],
  };

  return (
    <PageContainer title={'接口调用情况'}>
      <ReactECharts showLoading={loading} option={option} />
    </PageContainer>
  );
};

export default InterfaceChart;

 

效果如下

 

 

十五、拓展点

 

  1. 用户可以申请更换签名
  1. 怎么让其他用户也上传接口?
    需要提供一个机制 (界面),让用户输入自己的接口host (服务器地址)、接口信息,将接口信息写入数据库。
    可以在 interfacelnto 表里加个 host 字段,区分服务器地址,让接口提供者更灵活地接入系统。
    将接口信息写入数据库之前,要对接口进行校验(比如检查他的地址是否遵循规则,测试调用),保证他是正常的。
    将接口信息写入数据库之前遵循咱们的要求(井且使用咱们的 sdk),在接入时,平台需要测试调用这个接口,保证他是正常的。
  1. 网关校验是否还有调用次数
    需要考虑井发问题,防止瞬间调用超额。
  1. 网关优化
    比如增加限流 /降级保护,提高性能等。还可以考虑搭配 Nginx 网关使用。
  1. 功能增强
    可以针对不同的请求头或者接口类型来设计前端界面和表单,便于用户调用,获得更好的体验。
    可以参考 swagger、postman、knife4j 的页面。