API开放平台笔记
API开放平台
前言
背景
1.前端开发需要用到后台接口
2.使用现成的系统的功能(http://api.btstu.cn/)
做一个API接口平台:
- 防止攻击(安全性)
- 不能随便调用(限制、开通)
- 统计调用次数
- 计费
- 流量保护
- 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
删除不必要的东西
-
移除国际化
先跳过 有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目录
- 删除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
提供三个模拟接口
接口大体内容
- GET 接口
- POST 接口(url传参)
- 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的调用方式:
- HttpClient
- RestTemplate
- 第三方库(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签名认证
本质:
- 签发签名
- 使用签名(校验签名)
为什么需要
保证安全性,不能随便一个人就可以调用
怎么实现
- 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
加密方式
-
对称加密
-
非对称加密
-
md5 签名(不可解密)
签名的做法
假如 ,我们有用户参数,我们用密钥与他拼接,用签名算法得到一个不可解密的值
用户参数 + 密钥 => 签名生成算法(MD5,HMac,Sha1) => 不可解密的值
例子: xuan + abc => 7e7b9583aa0bc3e834fe8bcaebda38b5(这里是我随便输的,得到的值是随机的)
怎么知道签名对不对?
服务端用一模一样的参数和算法去生成签名,只要和用户传的一致,就表示密钥一致
怎么防重放?
加nonce随机数 只能用一次
服务端要保存用过的随机数
加timestamp 时间戳,校验它的有效期
综上所属
传递的参数
- accessKey
- sign (由accessKey(或者使用用户请求参数body等)、secretKey加密而来)
- nonce随机数
- timestamp
- 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
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
下线接口
- 校验该接口是否存在
- 修改数据库接口字段为 0
1、后端
-
通用请求类
上下线都是通过id来控制的
/** * 通过id发送请求 * * @author xuan */ @Data public class IdRequest implements Serializable { /** * id */ private Long id; private static final long serialVersionUID = 1L; }
-
枚举类
使用枚举类来表示上线/下线状态
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; } }
-
在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); } }
-
权限控制
这里添加权限校验,这里用到@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、查看接口文档
-
新建文件
在pages下新建 InterfaceInfo/index.tsx
-
配置动态路由
这里需要查看 umi文档
{ // 动态路由 path: '/interface_info/:id', name: 'interface info', component: './InterfaceInfo', // 不在菜单页显示 hideInMenu: true }
-
修改跳转
点击页面即可查看详情
主页代码片段修改
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> );
-
编写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;
-
效果如下
点击后跳转详情
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、修改后端
这里我们其实有两种方案
- 走后端调用
- 直接请求模拟接口
这里用第一种流方案,更安全更规范。模拟接口的地址就不用暴露出来
大概流程如下
-
前端将用户输入的请求参数和要测试的接口 id发给平台后端
-
调用前校验
-
平台后端去调用模拟接口
新增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、设计库表
哪个用户?哪个接口?
用户=>接口(多对多)
用户调用接口关系表
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、网关作用
- 路由
- 负载均衡
- 统一鉴权
- 统一处理跨域
- 统一业务处理(缓存)
- 访问控制
- 发布控制
- 流量染色
- 统一接口保护
- 限制请求
- 信息脱敏
- 降级(熔断)
- 限流 学习令牌桶算法,学习露桶算法,学习一下RedislimitHandler
- 超时时间
- 重试(业务保护)
- 统一日志
- 统一文档
2、具体作用
路由
起到转发的作用,比如有接口A和接口B,网关会记录这些信息,根据用户访问的地址和参数,转发请求到对应的接口(服务器/集群)
用户a调用接口A
/a => 接口A
/b => 接口B
负载均衡
在路由的基础上可以转发到某一个服务器
/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-addrequestheader-gatewayfilter-factory
全局染色:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#default-filters
接口保护
-
限制请求
-
信息脱敏
-
降级(熔断) 进行兜底
https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#fallback-headers
-
限流
-
超时时间 超时就中断
https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#http-timeouts-configuration
-
重试(业务保护):
统一日志
统一的请求,响应信息记录
统一文档
将下游项目的文档进行聚合,在一个页面统一查看
建议用:https://doc.xiaominfo.com/docs/middleware-sources/aggregation-introduction
网关的分类
- 全局网关(接入层网关)作用是负载均衡、请求日志等,不和业务逻辑绑定
- 业务网关(微服务网关)会有一些业务逻辑,作用是将请求转发到不同的业务/项目/接口/服务
参考文章:https://blog.csdn.net/qq_21040559/article/details/122961395
实现
- Nginx (全局网关),Kong网关(API网关), 编程成本相对较高
- 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.
- Predicate: This is a Java 8 Function Predicate. The input type is a Spring Framework
ServerWebExchange
. This lets you match on anything from the HTTP request, such as headers or parameters. - 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.
-
路由(根据什么条件,转发到哪里)
-
断言(一组规则,条件,用来确定如何转发路由)
-
过滤器:对请求进行一系列的处理,比如添加请求头,添加请求参数
2、请求流程
- 客户端发起请求
- Handler Mapping :根据断言,去将请求转发到对应的路由
- Web Handler:处理请求(一层层经过过滤器)
- 实际调用服务
2、两种配置方式
-
配置式 (方便,规范)能用就用
-
简化版
spring: cloud: gateway: routes: - id: after_route uri: https://example.org predicates: - Cookie=mycookie,mycookievalue
-
全称
spring: cloud: gateway: routes: - id: after_route uri: https://example.org predicates: - name: Cookie args: name: mycookie regexp: mycookievalue
-
-
编程式 (灵活,相对麻烦)
3、路由的各种断言
目录
- After 在xx时间之后
- Before 在xx时间之前
- Between 在xx时间之间
- 请求类别
- 请求头(包含Cookie)
- 查涧参数
- 客户端地址
- 权重
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
如果你的请求类别是这个GET、POST,就会访问当前这个路由
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编程式开发
十二、项目整合网关
- 实现统一的用户鉴权 ,统一的接口调用次数统计(把API网关用到项目中)
- 完善功能
会用到的特性
- 路由(转发请求到模拟接口项目)
负载均衡(需要用到注册中心)- 统一鉴权(accessKey,secretKey)
- 统一处理跨域
- 统一业务处理(每次请求接口后,接口调用次数+1)
- 访问控制(黑白名单)
发布控制- 流量染色(记录请求是否为网关来的)
统一接口保护- 限制请求
- 信息脱敏
- 降级(熔断)
- 限流 学习令牌桶算法,学习露桶算法,学习一下RedislimitHandler
- 超时时间
- 重试(业务保护)
- 统一日志(记录每次的请求和响应)
统一文档
业务逻辑
- 用户发送请求到API网关
- 请求日志
- 黑白名单
- 用户鉴权(判断ak,sk是否合法)
- 请求的模拟接口是否存在?
- 请求转发,调用模拟接口
- 响应日志
- 调用成功,接口调用次数+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)远程过程调用
网关业务逻辑
问题: 网关项目比较存粹,没有操作数据库的包,并且还要调用我们之前写过的代码?复制粘贴维护麻烦
理想:直接请求到其他项目的方法
怎么调用其他项目的方法?
- 复制代码和依赖,环境
- HTTP请求(提供接口,供其他项目调用)
- RPC
- 把公共的代码打个jar包,其他项目去引用
HTTP请求怎么调用
- 提供方提供一个接口(地址,请求方法,参数,返回值)
- 调用方使用HTTP Client之类的代码包去发送HTTP请求
RPC作用
像调用本地方法一样去调用远程方法
RPC优点
- 对开发者更透明,减少很多的沟通成本
- RPC向远程服务器发送请求时,未必使用HTTP协议,比如还可以使用TCP/IP,性能更高。(内部服务更实适用)
注意: 这里注册中心只提供信息,并不会帮助调用
1、Dubbo框架(RPC实现)
官网:https://cn.dubbo.apache.org/zh/
常见框架还有GRPC、TRPC
最好的学习方式:阅读官方文档
1、两种使用方式
- Spring Boot代码(注解+编程式):写Java接口,服务提供者和消费者都去引用这个接口
偏程导 - 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
- backend项目作为服务提供者,提供3个方法:
- 实际情况应该是去数据库中查是否已分配给用户
- 从数据库中查询模拟接口是否存在,以及请求方法是否匹配(还可以校验请求参数)
- 调用成功,接口调用次数+1 invokeCount
- 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
注意:
-
服务接口类必须要在同一个包下,建议是抽象出一个公共项日(放接口、实体类等)
-
置注解(比如启动类的EnableDubbo、接口实现类和Bean引用的注解:@DubboService、@DubboReference)
-
添加配置
-
服务调用项目和提供者项目尽量引入相同的依赖和配置
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、抽取的服务
- 数据库中查是否已分配给用户秘钥(根据 accessKey 拿到用户信息,返回用户信息,为空表示不存在)
- 从数据库中查询模拟接口是否存在(请求路径、请求方法、请求参数,返回接口信息,为空表示不存在)
- 接口调用次数+ 1 invokeCount (accessKey、secretKey(标识用户),请求接口路径)
2、具体操作
-
新建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>
-
复制之前的model包下的实体类
-
在common包下新建service层
```java
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);
}
```
-
打包
使用maven install打包
api-platform-backend引入依赖
<dependency> <groupId>com.xuan</groupId> <artifactId>api-platform-common</artifactId> <version>0.0.1</version> </dependency>
-
编写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; } }
-
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忽略数据库启动得到)
```java
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);
}
}
```
再次启动网关成功~
- 测试跑通
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、前端
图表强烈推荐用现成的库!!!
比如:
使用步骤都大同小异
- 看官网
- 找到快速入门、按文档去引入库
- 进入示例页面
- 找到你要的图
- 在线调试
- 复制代码
- 改为真实数据
这里选择使用了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;
效果如下
十五、拓展点
- 用户可以申请更换签名
- 怎么让其他用户也上传接口?
需要提供一个机制 (界面),让用户输入自己的接口host (服务器地址)、接口信息,将接口信息写入数据库。
可以在 interfacelnto 表里加个 host 字段,区分服务器地址,让接口提供者更灵活地接入系统。
将接口信息写入数据库之前,要对接口进行校验(比如检查他的地址是否遵循规则,测试调用),保证他是正常的。
将接口信息写入数据库之前遵循咱们的要求(井且使用咱们的 sdk),在接入时,平台需要测试调用这个接口,保证他是正常的。 - 网关校验是否还有调用次数
需要考虑井发问题,防止瞬间调用超额。 - 网关优化
比如增加限流 /降级保护,提高性能等。还可以考虑搭配 Nginx 网关使用。 - 功能增强
可以针对不同的请求头或者接口类型来设计前端界面和表单,便于用户调用,获得更好的体验。
可以参考 swagger、postman、knife4j 的页面。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)