项目笔记(二)

一、项目概述

本项目是一个面向开发者的 API 平台,提供 API 接口供开发者调用。用户通过注册登录,可以开通接口调用权限,并可以浏览和调用接口。每次调用都会进行统计,用户可以根据统计数据进行分析和优化。管理员可以发布接口、下线接口、接入接口,并可视化接口的调用情况和数据。本项目侧重于后端,涉及多种编程技巧和架构设计层面的知识。

二、本期时间点

经过 7 场直播,总时长近 20 小时。

4iciofhNq1pBTvkd-8738d574

三、本期计划(时间点 07:31-11:14)

  1. 继续开发接口管理前端页面 15 min
  2. 开发模拟 API 接口 5min
  3. 开发调用这个接口的代码 10 - 20 min
  4. 保证调用的安全性(API 签名认证) 15 min - 20 min
  5. 客户端 SDK 的开发 15 min
  6. 管理员接口发布与调用 15 min (下次一定)
  7. 接口文档展示、接口在线调用 15 min (下次一定)

四、前端项目开发(时间点 11:14-58:10)

1. 优化前端页面(时间点 11:19-21:23)

我们先来优化一下页面,比如:欢迎页、管理页... 没用到的全部删掉。

先删掉路由,找到routes.ts修改路由。

export default [

  {

    path: '/user',

    layout: false,

    routes: [{ name: '登录', path: '/user/login', component: './User/Login' }],

  },

  // { path: '/welcome', name: '欢迎', icon: 'smile', component: './Welcome' },

  {

    path: '/admin',

    name: '管理页',

    icon: 'crown',

    // 权限控制可以去看 and design pro 的官方文档,不用纠结为什么这么写,就是人家设定的规则而已

    access: 'canAdmin',

    routes: [

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

    ],

  },

  // { path: '/', redirect: '/welcome' },

  { path: '*', layout: false, component: './404' },

];

把接口管理页的目录名(TableList)改为InterfaceInfo

Dnw7mS7KWkagJqnP-8f175d8b lZv7wJaHMIAVQpit-b14d1c7f

ps.尽量与后端保持一致;webstorm 点击目录后,按[shift+F6]重构。

LgB9AbnrSuvMcf2L-644168fd

然后去优化接口管理页,找到 InterfaceInfo 目录下的 index.tsx,虽然这里有一点小小的报错,但影响不大🐶。

o2sL2Yw377YZMg8s-fb5fdaaa

当然还是要注意看一下,比如这个报错,它说:不是所有的代码路径都返回了值。

tkBcRAnJCX0Tl3n0-83a88d6b dqEy2DCRX9SGHQxF-a86f3b28

ps.vscode 的是单个单个地说没有读取其值。

KeKp0DCzo016jmET-3712cf85 AuMoUUzmOCZ6c8Si-6b5209ba

有时它类型的一些报错,想忽略也可以强行忽略,在 if 补充上 return。

ye5Rbi2wdCeCVVdu-28d7e5e6
request={async (params, sort: Record<string, SortOrder>, filter: Record<string, React.ReactText[] | null>) => {

          const res: any = await listInterfaceInfoByPageUsingGET({

            ...params

          })

          // 如果后端请求给你返回了接口信息

          if (res?.data) {

            // 返回一个包含数据、成功状态和总数的对象

            return {

              data: res?.data.records || [],

              success: true,

              total: res?.data.total || 0,

            };

          } else {

            // 如果数据不存在,返回一个空数组,失败状态和零总数

            return {

              data: [],

              success: false,

              total: 0,

            };

          }

        }}

启动 redis、后端项目、前端项目,访问 http://localhost:8000/ 进行登录;

现在可以看到主页没有任何页面,不用管,等会开发一个给非管理员(用户)看的接口信息页。

zELvwlBbd5iEnb9x-85064e9d

点击管理页,查看接口管理页。

V6s1AAqwZKnDFwci-b67e51fa

右边蓝色的 RvhVvcegySKU7lBb-121279af可以设置各种样式,样式调完还能复制配置。

复制配置后,粘贴到 defaultSettings.ts。

QXFftfwo2MFRfgkA-ee61b2da

然后把 app.tsx 的 initialState?.settings 换成 defaultSetting 就可以生效了。

CW8vCMQ4rwWimb7d-0e1deeac

2. 实现新建功能(时间点 21:23-58:10)

接下来把新建这个接口的流程跑通,上次我们知识把查询表格给展示出来,但是表格上的一些功能按钮还不能用,现在去实现一下。

回到接口管理页,把配置改成修改,删掉订阅警报

wTUfeiNmXuAUAhBj-9ea6c8ab

往下滑,当我们点击新建,它就会触发一个事件:把模态框打开,它打开的模态框是哪个组件呢?

SQTMsE25eChD3n9B-7dfc52f5 pQx29uKZSorflUcn-aa23722f

继续往下滑,它在这已经提供好了一个新建的组件。

Efts5ZABJ6uQMh5u-866c2abd

但是这里建议大家不要写在这里,要把它单拉出来,就像下图的更新模态框一样。

0YR3aYdDLm4tAsWn-b4690dfc

复制 UpdateForm.tsx,粘贴至 components 目录,并修改名为CreateModal.tsx

vv5Vdl8OoaYAOY3R-5f788337

然后把里面的内容改一下,因为更新模态框的流程很复杂,没必要那么多,把新建的模态框代码剪切粘贴至 CreateModal.tsx。

4D3c0qu4BEUSQg3a-246c960d
<ModalForm title={'新建规则'} width="400px" open={createModalOpen} (value) => { const success = await handleAdd(value as API.RuleListItem); if (success) { handleModalOpen(false); if (actionRef.current) { actionRef.current.reload(); } } }} > <ProFormText rules={[ { required: true, message: '规则名称为必填项', }, ]} width="md" name="name" /> <ProFormTextArea width="md" name="desc" /> </ModalForm>

删掉 CreateModal.tsx 原来的模态框。

8q086WTQT3l3KZyD-6993419a

新建的模态框代码粘贴进来。

EetKccSZupQQGjNy-f43387b5

删掉 CreateModal.tsx 多余的内容。

import { ModalForm, ProFormText, ProFormTextArea } from '@ant-design/pro-components'; import '@umijs/max'; import React from 'react'; // 这里要定义一些这个组件要接收什么参数、属性 export type Props = { onCancel: (flag?: boolean, formVals) => void; onSubmit: (values) => Promise<void>; updateModalOpen: boolean; values: Partial<API.RuleListItem>; }; const CreateModal: React.FC<Props> = (props) => { return ( <ModalForm title={'新建规则'} width="400px" open={createModalOpen} (value) => { const success = await handleAdd(value as API.RuleListItem); if (success) { handleModalOpen(false); if (actionRef.current) { actionRef.current.reload(); } } }} > <ProFormText rules={[ { required: true, message: '规则名称为必填项', }, ]} width="md" name="name" /> <ProFormTextArea width="md" name="desc" /> </ModalForm> ); }; export default CreateModal;

让我们观察一下这个组件需要的属性;首先,我们需要在模态框中设定用户需要填写的表单字段。

在使用模态框组件的地方传递一个columns,为什么需要 columns 呢?

因为在这个模态框中,会有很多需要用户填写的表单项。然而,这些信息其实完全可以从 columns 参数中获取,例如让用户填写接口名称、描述等,我们没必要重复编写这些信息。

VVtZe55nCx4ST3tC-c6c4d359

所以要把 columns 作为属性传递过来。

h2IhXxdaicWg0pi2-1865576c

继续编写 CreateModal.tsx。

import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; import '@umijs/max'; import { Modal } from 'antd'; import React from 'react'; export type Props = { columns: ProColumns<API.InterfaceInfo>[]; // 当用户点击取消按钮时触发 onCancel: () => void; // 当用户提交表单时,将用户输入的数据作为参数传递给后台 onSubmit: (values: API.InterfaceInfo) => Promise<void>; // 模态框是否可见 visible: boolean; // values不用传递 // values: Partial<API.RuleListItem>; }; const CreateModal: React.FC<Props> = (props) => { // 使用解构赋值获取props中的属性 const { visible, columns, onCancel, onSubmit } = props; return ( // 创建一个Modal组件,通过visible属性控制其显示或隐藏,footer设置为null把表单项的'取消'和'确认'按钮去掉 <Modal visible={visible} footer={null} => onCancel?.()}> {/* 创建一个ProTable组件,设定它为表单类型,通过columns属性设置表格的列,提交表单时调用onSubmit函数 */} <ProTable type="form" columns={columns} (value) => { onSubmit?.(value); }} /> </Modal> ); }; export default CreateModal;

现在我们把 CreateModal.tsx 引入到接口管理页,找到 InterfaceInfo 目录下的 index.tsx。

2ilzfrw1hAscYtFM-7d405ef6
{/* 创建一个CreateModal组件,用于在点击新增按钮时弹出 */} <CreateModal columns={columns} // 当取消按钮被点击时,设置更新模态框为false以隐藏模态窗口 => { handleModalOpen(false); }} // 当用户点击提交按钮之后,调用handleAdd函数处理提交的数据,去请求后端添加数据(这里的报错不用管,可能里面组件的属性和外层的不一致) => { handleAdd(values); }} // 根据更新窗口的值决定模态窗口是否显示 visible={createModalOpen} />

回到前端页面,在接口管理页点击新建,弹出的模态框(如下图所示)。

EnsVidgo2lWKU2gH-2325f5e8

点击x按钮也能正常关闭。

NXi5FOAgiKuChah3-015cd3f0

然后这里出现创建时间更新时间,这个不用让用户自己填,应该让后台根据当前用户提交的时间自动生成,所以要在表单项隐藏这两个选项。

KjgmIpSTuGbXcO0f-5c54f73e

回到前端页面,在接口管理页点击新建,弹出的模态框没有了创建时间更新时间

2rhUDrz3z8KKV0K7-23fd754f

回到接口管理页修复onSubmit,因为它之前生成的代码有一些我们并没有用到,比如管理规则... 所以要修改一下。

import { removeRule, updateRule } from '@/services/ant-design-pro/api'; import { addInterfaceInfoUsingPOST, listInterfaceInfoByPageUsingGET, } from '@/services/yuapi-backend/interfaceInfoController'; import { PlusOutlined } from '@ant-design/icons'; import type { ActionType, ProColumns, ProDescriptionsItemProps } from '@ant-design/pro-components'; import { FooterToolbar, PageContainer, ProDescriptions, ProTable, } from '@ant-design/pro-components'; import '@umijs/max'; import { Button, Drawer, message } from 'antd'; import React, { useRef, useState } from 'react'; import CreateModal from './components/CreateModal'; const TableList: React.FC = () => { /** * @en-US Pop-up window of new window * @zh-CN 新建窗口的弹窗 * */ const [createModalOpen, handleModalOpen] = useState<boolean>(false); /** * @en-US The pop-up window of the distribution update window * @zh-CN 分布更新窗口的弹窗 * */ const [updateModalOpen, handleUpdateModalOpen] = useState<boolean>(false); const [showDetail, setShowDetail] = useState<boolean>(false); const actionRef = useRef<ActionType>(); const [currentRow, setCurrentRow] = useState<API.RuleListItem>(); const [selectedRowsState, setSelectedRows] = useState<API.RuleListItem[]>([]); // 模态框的变量在TableList组件里,所以把增删改节点都放进来 /** * @en-US Add node * @zh-CN 添加节点 * @param fields */ // 把参数的类型改成InterfaceInfo const handleAdd = async (fields: API.InterfaceInfo) => { const hide = message.loading('正在添加'); try { // 把addRule改成addInterfaceInfoUsingPOST await addInterfaceInfoUsingPOST({ ...fields, }); hide(); // 如果调用成功会提示'创建成功' message.success('创建成功'); // 创建成功就关闭这个模态框 handleModalOpen(false); return true; } catch (error: any) { hide(); // 否则提示'创建失败' + 报错信息 message.error('创建失败,' + error.message); return false; } }; /** * @en-US Update node * @zh-CN 更新节点 * * @param fields */ const handleUpdate = async (fields: FormValueType) => { const hide = message.loading('Configuring'); try { await updateRule({ name: fields.name, desc: fields.desc, key: fields.key, }); hide(); message.success('Configuration is successful'); return true; } catch (error) { hide(); message.error('Configuration failed, please try again!'); return false; } }; /** * Delete node * @zh-CN 删除节点 * * @param selectedRows */ const handleRemove = async (selectedRows: API.RuleListItem[]) => { const hide = message.loading('正在删除'); if (!selectedRows) return true; try { await removeRule({ key: selectedRows.map((row) => row.key), }); hide(); message.success('Deleted successfully and will refresh soon'); return true; } catch (error) { hide(); message.error('Delete failed, please try again'); return false; } }; /** * @en-US International configuration * @zh-CN 国际化配置 * */ 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', hideInForm: true, }, { title: '更新时间', dataIndex: 'updateTime', valueType: 'dateTime', hideInForm: true, }, { title: '操作', dataIndex: 'option', valueType: 'option', render: (_, record) => [ <a key="config" => { handleUpdateModalOpen(true); setCurrentRow(record); }} > 修改 </a>, ], }, ]; return ( <PageContainer> <ProTable<API.RuleListItem, API.PageParams> headerTitle={'查询表格'} actionRef={actionRef} rowKey="key" search={{ labelWidth: 120, }} toolBarRender={() => [ <Button type="primary" key="primary" => { handleModalOpen(true); }} > <PlusOutlined /> 新建 </Button>, ]} request={async ( params, sort: Record<string, SortOrder>, filter: Record<string, React.ReactText[] | null>, ) => { const res: any = await listInterfaceInfoByPageUsingGET({ ...params, }); // 如果后端请求给你返回了接口信息 if (res?.data) { // 返回一个包含数据、成功状态和总数的对象 return { data: res?.data.records || [], success: true, total: res?.data.total || 0, }; } else { // 如果数据不存在,返回一个空数组,失败状态和零总数 return { data: [], success: false, total: 0, }; } }} columns={columns} rowSelection={{ onChange: (_, selectedRows) => { setSelectedRows(selectedRows); }, }} /> {selectedRowsState?.length > 0 && ( <FooterToolbar extra={ <div> 已选择{' '} <a style={{ fontWeight: 600, }} > {selectedRowsState.length} </a>{' '} 项 &nbsp;&nbsp; <span> 服务调用次数总计 {selectedRowsState.reduce((pre, item) => pre + item.callNo!, 0)} 万 </span> </div> } > <Button () => { await handleRemove(selectedRowsState); setSelectedRows([]); actionRef.current?.reloadAndRest?.(); }} > 批量删除 </Button> <Button type="primary">批量审批</Button> </FooterToolbar> )} <UpdateForm (value) => { const success = await handleUpdate(value); if (success) { handleUpdateModalOpen(false); setCurrentRow(undefined); if (actionRef.current) { actionRef.current.reload(); } } }} => { handleUpdateModalOpen(false); if (!showDetail) { setCurrentRow(undefined); } }} updateModalOpen={updateModalOpen} values={currentRow || {}} /> <Drawer width={600} open={showDetail} => { setCurrentRow(undefined); setShowDetail(false); }} closable={false} > {currentRow?.name && ( <ProDescriptions<API.RuleListItem> column={2} title={currentRow?.name} request={async () => ({ data: currentRow || {}, })} params={{ id: currentRow?.name, }} columns={columns as ProDescriptionsItemProps<API.RuleListItem>[]} /> )} </Drawer> {/* 创建一个CreateModal组件,用于在点击新增按钮时弹出 */} <CreateModal columns={columns} // 当取消按钮被点击时,设置更新模态框为false以隐藏模态窗口 => { handleModalOpen(false); }} // 当用户点击提交按钮之后,调用handleAdd函数处理提交的数据,去请求后端添加数据(这里的报错不用管,可能里面组件的属性和外层的不一致) => { handleAdd(values); }} // 根据更新窗口的值决定模态窗口是否显示 visible={createModalOpen} /> </PageContainer> ); }; export default TableList;

回到前端页面在接口管理页,鼠标右键选择检查网络,然后点击新建

0yv3F9j7GjKh858v-3203a9ff

显示名称过长,但仍提示创建成功(其实是没有创建成功);

因为我们还未全局校验接口返回值,也没有确认其返回的状态码是否为 0。

TobmqG58e0iTrKOU-d98ae365 K8reCVxYveQsGN3H-6007ca03

去加一下校验,找到 requestConfig.ts。

1JGn2CuTPmyRu09N-cc97c6e9

在全局请求响应拦截器中,添加判断。

3HHqt8rXbIUUyqSl-1b60c441

把默认的错误处理删掉。

AbuVsL2Bv1qFStMr-22c61de5
import type { RequestOptions } from '@@/plugin-request/request'; import type { RequestConfig } from '@umijs/max'; // 与后端约定的响应数据格式 interface ResponseStructure { success: boolean; data: any; errorCode?: number; errorMessage?: string; } /** * @name 错误处理 * pro 自带的错误处理, 可以在这里做自己的改动 * @doc https://umijs.org/docs/max/request#配置 */ export const requestConfig: RequestConfig = { baseURL:'http://localhost:7529', withCredentials: true, // 请求拦截器 requestInterceptors: [ (config: RequestOptions) => { // 拦截请求配置,进行个性化处理。 const url = config?.url?.concat('?token = 123'); return { ...config, url }; }, ], // 响应拦截器 responseInterceptors: [ (response) => { // 拦截响应数据,进行个性化处理 const { data } = response as unknown as ResponseStructure; // 打印响应数据用于调试 console.log('data', data); // 当响应的状态码不为0,抛出错误 if (data.code !== 0) { throw new Error(data.message); } // 如果一切正常,返回原始的响应数据 return response; }, ], };

去后端 InterfaceInfoServiceImpl.java 改一下长度判断,上一期写错了(写成小于 50),应该是长度大于 50,重新启动后端项目。

s3uxryKoAWvAJBR7-31a8a6a7

🪔 如何在表单项中添加校验规则,比如是否必填。

如果你面临这样的问题,你会如何解决?

如何去了解这些校验规则的添加方法?

例如,如果你要自己探索,你会从哪里开始?

其实,解答这类问题很简单。当你面临这类校验规则的问题时,你应该直接查阅官方文档。


来看一下 官网,找一下 Columns 列定义,列定义有一个 formItemProps:传递表单项配置,你可以配置规则,这个规则就是校验相关的内容。

MRW0dpm9o4hsIXo6-3a4e9899

试一下,在接口名称表单项添加 formItemProps。

CQIELvSDYhechJgl-501e0a22

前端页面的接口名称表单项左边出现红色的*

Eznlaugu6aztFtEc-1824b1f7

直接点击提交,发现它是直接发给后台,并没有校验,不靠谱。

bMp7smZhXjxQxeI0-bf1fd1a4

在 formItemProps 里增加 rules 进行校验,在 rules 里可以写一个数组来定义多条规则。

gKNYD7ttQeI89pOy-6edd51f5

回到前端页面测试一下,测试结果如下图所示。

NDTGLOxgIYP5cvwH-a5c8a2e9

然后测试一下能否创建成功。

MjokMx1nZWTd7Scd-7f67d8c4

创建成功。

PHiY9l3W63iCkaVq-ab742834

点击表单的刷新按钮,在第 2 页可以查看到添加的数据。

gNsrMoSIL8hFq1Zq-5152b46d

新建按钮完成,接下来整一下修改按钮;

复制 CreateModal.tsx,粘贴至 components 目录下,并修改名为UpdateModal.tsx

3xWR8roP74k7hLKB-4f7c003d

修改模态框无非就是在新建模态框的基础上换一个调用方法,来修改一下。

import type { ProColumns, ProFormInstance } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; import '@umijs/max'; import { Modal } from 'antd'; import React, { useEffect, useRef } from 'react'; // 定义组件的属性类型 export type Props = { // 表单中需要编辑的数据 values: API.InterfaceInfo; // 表格的列定义 columns: ProColumns<API.InterfaceInfo>[]; // 当用户点击取消按钮时触发 onCancel: () => void; // 当用户提交表单时,将用户输入的数据作为参数传递给后台 onSubmit: (values: API.InterfaceInfo) => Promise<void>; // 控制模态框是否可见 visible: boolean; }; // 定义更新模态框组件 const UpdateModal: React.FC<Props> = (props) => { // 从props中获取属性 const { values, visible, columns, onCancel, onSubmit } = props; // 使用React的useRef创建一个引用,以访问ProTable中的表单实例 const formRef = useRef<ProFormInstance>(); // 防止修改的表单内容一直是同一个内容,要监听values的变化 // 使用React的useEffect在值改变时更新表单的值 useEffect(() => { if (formRef) { formRef.current?.setFieldsValue(values); } }, [values]); // 返回模态框组件 return ( // 创建一个Modal组件,通过visible属性控制其显示或隐藏,footer设置为null把表单项的'取消'和'确认'按钮去掉 <Modal visible={visible} footer={null} => onCancel?.()}> {/* 创建一个ProTable组件,设定它为表单类型,将表单实例绑定到ref,通过columns属性设置表格的列,提交表单时调用onSubmit函数 */} <ProTable type="form" formRef={formRef} columns={columns} (value) => { onSubmit?.(value); }} /> </Modal> ); }; export default UpdateModal;

把接口管理页的UpdateForm改成UpdateModal

jI1yBPGcyOwmcx8x-0fba9c9d

修改更新节点

1bv4QlBGVrvsoZaw-380cd9da

回到前端页面,试一下更新,点击修改

2Fuz53g8c4oIjG4Y-e123d8dd

接口名称改成阿巴巴巴,点击提交

RI6FOQ2oyEe7LY03-cb6dda08

提示操作失败,id 没有传递给后端。

6V3lcptTc1ZvGxYv-a9f1ca50

ps.本期没有测试更新,所以没有解决更新bug,第三期视频中解决了,这里提前解决掉。

更新模态框输出 values。

5wwRrccQk1xhypG4-611b4d3c

回到前端页面,发现有 id,但是为什么没有带到后台呢?

CYSMyYoy2412j3BY-87181bfd

记得删掉这条打印。

tVFKQoiEcvgUF9Gz-e9d3a626

是这样的,我们首先需要理解用户点击提交后,将会触发外部的 onSubmit 方法。

cow2GYHrMDD9nTTP-823c30e8

这个 onSubmit 方法中,执行了 handleUpdate,并传递了一个名为 value 的参数,这个参数实际上就是来自内部组件传递的值。

Augh79afE6zYFHgL-2192c42f

在内部组件中,我们传递了一个名为 columns 的属性,这个属性是在哪里定义的呢?

6sw28IwfcJnGj5fO-ba992a72

实际上就是我们在外部定义了 columns 并将其传递进来。

vN8btEKHJFDzaBBR-839f7abf

在这些 columns 中,我们把 id 列的属性定义为了index。这样定义的结果是,id 列不会出现在表单项里,也就不会被填入表单。

iIBvjtq3RflC6LmL-e9c60500

我们需要保存用户当前点击的数据项的 id,这里有个现成的currentRow,直接用这个去取 id 就行了。

4Yh9GTjd0BAGfWkC-535dbd08

先把类型换成自己的。

5sTIG8CQtZqPWETt-722fcac8

然后修改更新节点

Ur9lw3tYBa7rMldk-34ac2a25

回到前端页面,试一下更新,点击修改

3u41q6ZZCz1xl1r0-4c0e7f85

把接口名称改成阿巴巴巴,点击提交

qsH42S7u7774LqFz-e97ba9a7

修改成功。

bwpzhTHHxB6FtaLr-688cdbe6

更新节点搞定,接下来修改删除节点

BSP68Xde0jY2dLDn-4c566b89

在表单项增加一个删除按钮

4ZYNswGUon5W5Vkl-266a3648

回到前端页面,试一下删除,点击删除

Bet13p37ppMJVRTt-942bd1e9

提示删除成功,表格内也没有那条数据了。

8FciwvLPM0JeHPWM-114eb02f

前端结束🥳

import { addInterfaceInfoUsingPOST, listInterfaceInfoByPageUsingGET, updateInterfaceInfoUsingPOST, deleteInterfaceInfoUsingPOST } from '@/services/yuapi-backend/interfaceInfoController'; import { PlusOutlined } from '@ant-design/icons'; import type { ActionType, ProColumns, ProDescriptionsItemProps } from '@ant-design/pro-components'; import { FooterToolbar, PageContainer, ProDescriptions, ProTable, } from '@ant-design/pro-components'; import '@umijs/max'; import { Button, Drawer, message } from 'antd'; import React, { useRef, useState } from 'react'; import CreateModal from './components/CreateModal'; import UpdateModal from './components/UpdateModal'; const TableList: React.FC = () => { /** * @en-US Pop-up window of new window * @zh-CN 新建窗口的弹窗 * */ const [createModalOpen, handleModalOpen] = useState<boolean>(false); /** * @en-US The pop-up window of the distribution update window * @zh-CN 分布更新窗口的弹窗 * */ const [updateModalOpen, handleUpdateModalOpen] = useState<boolean>(false); const [showDetail, setShowDetail] = useState<boolean>(false); const actionRef = useRef<ActionType>(); const [currentRow, setCurrentRow] = useState<API.InterfaceInfo>(); const [selectedRowsState, setSelectedRows] = useState<API.InterfaceInfo[]>([]); // 模态框的变量在TableList组件里,所以把增删改节点都放进来 /** * @en-US Add node * @zh-CN 添加节点 * @param fields */ // 把参数的类型改成InterfaceInfo const handleAdd = async (fields: API.InterfaceInfo) => { const hide = message.loading('正在添加'); try { // 把addRule改成addInterfaceInfoUsingPOST await addInterfaceInfoUsingPOST({ ...fields, }); hide(); // 如果调用成功会提示'创建成功' message.success('创建成功'); // 创建成功就关闭这个模态框 handleModalOpen(false); return true; } catch (error: any) { hide(); // 否则提示'创建失败' + 报错信息 message.error('创建失败,' + error.message); return false; } }; /** * @en-US Update node * @zh-CN 更新节点 * * @param fields */ const handleUpdate = async (fields: API.InterfaceInfo) => { // 如果没有选中行,则直接返回 if(!currentRow){ return; } const hide = message.loading('修改中'); try { // 调用更新接口,传入当前行的id和更新的字段 await updateInterfaceInfoUsingPOST({ id:currentRow.id, ...fields, }); hide(); message.success('操作成功'); return true; } catch (error: any) { hide(); message.error('操作失败,' + error.message); return false; } }; /** * Delete node * @zh-CN 删除节点 * * @param selectedRows */ // 把参数的类型改成InterfaceInfo const handleRemove = async (record: API.InterfaceInfo) => { // 设置加载中的提示为'正在删除' const hide = message.loading('正在删除'); if (!record) return true; try { // 把removeRule改成deleteInterfaceInfoUsingPOST await deleteInterfaceInfoUsingPOST({ // 拿到id就能删除数据 id: record.id }); hide(); // 如果调用成功会提示'删除成功' message.success('删除成功'); // 删除成功自动刷新表单 actionRef.current?.reload(); return true; } catch (error: any) { hide(); // 否则提示'删除失败' + 报错信息 message.error('删除失败,' + error.message); return false; } }; /** * @en-US International configuration * @zh-CN 国际化配置 * */ const columns: ProColumns<API.InterfaceInfo>[] = [ { title: 'id', dataIndex: 'id', valueType: 'index', }, { title: '接口名称', dataIndex: 'name', valueType: 'text', formItemProps: { rules: [{ // 必填项 required: true, // 不设置提示信息,就默认提示'请输入' + title // message:'阿巴巴', }] } }, { 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', hideInForm: true, }, { title: '更新时间', dataIndex: 'updateTime', valueType: 'dateTime', hideInForm: true, }, { title: '操作', dataIndex: 'option', valueType: 'option', render: (_, record) => [ <a key="config" => { handleUpdateModalOpen(true); setCurrentRow(record); }} > 修改 </a>, <a key="config" => { handleRemove(record); }} > 删除 </a>, ], }, ]; return ( <PageContainer> <ProTable<API.RuleListItem, API.PageParams> headerTitle={'查询表格'} actionRef={actionRef} rowKey="key" search={{ labelWidth: 120, }} toolBarRender={() => [ <Button type="primary" key="primary" => { handleModalOpen(true); }} > <PlusOutlined /> 新建 </Button>, ]} request={async ( params, sort: Record<string, SortOrder>, filter: Record<string, React.ReactText[] | null>, ) => { const res: any = await listInterfaceInfoByPageUsingGET({ ...params, }); // 如果后端请求给你返回了接口信息 if (res?.data) { // 返回一个包含数据、成功状态和总数的对象 return { data: res?.data.records || [], success: true, total: res?.data.total || 0, }; } else { // 如果数据不存在,返回一个空数组,失败状态和零总数 return { data: [], success: false, total: 0, }; } }} columns={columns} rowSelection={{ onChange: (_, selectedRows) => { setSelectedRows(selectedRows); }, }} /> {selectedRowsState?.length > 0 && ( <FooterToolbar extra={ <div> 已选择{' '} <a style={{ fontWeight: 600, }} > {selectedRowsState.length} </a>{' '} 项 &nbsp;&nbsp; <span> 服务调用次数总计 {selectedRowsState.reduce((pre, item) => pre + item.callNo!, 0)} 万 </span> </div> } > <Button () => { await handleRemove(selectedRowsState); setSelectedRows([]); actionRef.current?.reloadAndRest?.(); }} > 批量删除 </Button> <Button type="primary">批量审批</Button> </FooterToolbar> )} {/* 之前是UpdateForm,现在改成UpdateModal */} <UpdateModal // 要传递 columns,不然修改模态框没有表单项 columns={columns} (value) => { const success = await handleUpdate(value); if (success) { handleUpdateModalOpen(false); setCurrentRow(undefined); if (actionRef.current) { actionRef.current.reload(); } } }} => { handleUpdateModalOpen(false); if (!showDetail) { setCurrentRow(undefined); } }} // 要传递的信息改成visible visible={updateModalOpen} values={currentRow || {}} /> <Drawer width={600} open={showDetail} => { setCurrentRow(undefined); setShowDetail(false); }} closable={false} > {currentRow?.name && ( <ProDescriptions<API.RuleListItem> column={2} title={currentRow?.name} request={async () => ({ data: currentRow || {}, })} params={{ id: currentRow?.name, }} columns={columns as ProDescriptionsItemProps<API.RuleListItem>[]} /> )} </Drawer> {/* 创建一个CreateModal组件,用于在点击新增按钮时弹出 */} <CreateModal columns={columns} // 当取消按钮被点击时,设置更新模态框为false以隐藏模态窗口 => { handleModalOpen(false); }} // 当用户点击提交按钮之后,调用handleAdd函数处理提交的数据,去请求后端添加数据(这里的报错不用管,可能里面组件的属性和外层的不一致) => { handleAdd(values); }} // 根据更新窗口的值决定模态窗口是否显示 visible={createModalOpen} /> </PageContainer> ); }; export default TableList;

五、后端项目开发(时间点 **58:10-**02:37:29)

我们现在有很多的接口信息,但全是假的,所以我们先来真实的发布一个给开发者使用的接口,创建一个模拟接口项目。

模拟接口项目

项目名称:yuapi-interface

提供三个不同种类的模拟接口:

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

1. 新建后端初始化项目(时间点 58:46-01:00:26)

来到后端,点击左上角 File → New;

新建一个模拟接口项目(专门提供给大家使用的接口项目)。

iQNfrvmzr3SjLLxZ-0c9a6721

新建一个 SpringBoot 的项目,项目名称yuapi-interface

7OuxLoQw7qG9Oi6D-6c0d0373

然后选择依赖:SpringWeb、Lombok、Spring Boot DevTools 后,点击Finish

NqpJKnxkEJ9lU5QJ-a5a36508 MAjQT2LoM8Xh7cNg-bc6eb7da QNM0Dg1doLa0Oh3T-48c80547
  • SpringWeb:是 Spring Boot 的一个模块,主要用于创建基于 Web 的应用程序。
  • Lombok:是一个 Java 库,可以通过简单的注解来帮助我们消除 Java 代码的模板化,例如 getter 和 setter 方法、构造函数等。
  • Spring Boot DevTools:Spring Boot 的一个模块,主要用于在开发环境中提高开发效率。提供了自动重启、热交换、模板缓存等功能。

注意:目前无论是学习还是做项目,都不要用 SpringBoot 3.x 版本。

idea 就开始初始化项目,中间蓝条咻咻地加载。

kpKzkrLO5k9PigFN-a3667444

这里选择在新窗口打开。

umTSlFsHyQXOnubP-f19b8554

底下的蓝条开始咻咻地加载依赖,等一会。

ddSZXD3ooXJvhBwD-894a3762

2. 创建模拟接口(时间点 01:00:26-01:07:54)

依赖加载好了之后,我们来提供三种接口给开发者模拟调用,不用纠结这个接口具体要做什么。

新建一个controller层:控制层,负责处理用户请求,并根据请求调用相应的业务逻辑,然后返回对应的视图或数据。

IgUKKtEQWWcGZqZR-4d9a2ba6

输入包名。

0pmD4xIdEafReCDn-33996f05

创建成功。

YeDMaii7mFZ8aENw-27f1abf7

假设我们要给开发者提供一个查询自己名字的服务。

新建一个model层:数据模型层,负责数据的处理和业务逻辑;在 model 层中,我们经常会为每一个实体或者对象创建一个对应的类。

CGgmWMLc7fxlwHSd-41143a0d

model层创建一个User类,在 User 类写一个用户名属性。

Ks5b2y9folD6D2uu-c9c38b7c

controller包下新建NameController.java

g2hBjLHjBd17rM1d-589a04c9

在 NameController.java 写三个模拟接口。

package com.yupi.yuapiinterface.controller; import com.yupi.yuapiinterface.model.User; import org.springframework.web.bind.annotation.*; /** * 名称 API * * @author yupi */ @RestController @RequestMapping("name") public class NameController { @GetMapping("/") public String getNameByGet(String name) { return "GET 你的名字是" + name; } @PostMapping("/") public String getNameByPost(@RequestParam String name) { return "POST 你的名字是" + name; } @PostMapping("/") public String getUserNameByPost(@RequestBody User user) { return "POST 用户名字是" + user.getUsername(); } }

点击application.properties按[Shift+F6]重构成application.yml,yml 格式会更精简一些。

Hu8rDpC9DPUP1ScD-fbda59be

在 application.yml 指定后端项目的端口号为8123,指定全局接口地址,加一个 api 前缀。

V1b8yPqUXRUiJSBs-978d58cf

启动这个项目,假设现在你就是这个开发者,你去调用这个接口 http://localhost:8123/api/name/?name=yupi。

c9f9M46W3xj2hZmL-04d4501e

在浏览器访问它,得到返回结果,搞定。

rPegjCIei6nj93LT-dfadee31

3. 开发调用接口(时间点 01:07:54-01:17:55)

我们已经成功地开发出了这个接口,但对于开发者来说,总不能每次都通过在浏览器地址栏输入接口地址来调用它,对吧?那么开发者通常是如何调用接口的呢?要么在前端进行调用,要么在后端,即我们的后端系统调用你的接口。出于安全考虑,我们通常会选择在后端调用第三方 API,因为这样可以避免在前端暴露诸如密码这样的敏感信息。所以下一步,将演示如何在项目中调用第三方的接口。

调用接口的方式

HTTP 调用方式:

  1. HttpClient
  2. RestTemplate
  3. 第三方库(OKHTTP、Hutool)

本期工具的官方文档:

  1. Hutool
  2. Http 客户端工具类

访问 Hutool 的官方文档,点击安装,复制依赖。

DMhevEDKsj31TyHP-44f0b267
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.16</version> </dependency>

粘贴到pom.xml后,点击 HaDy38uclj4HPqqG-90ce7432按钮。

TabzvIbJHNciYP3x-935033f0

点击右侧菜单栏的 Maven → 点击 Gi46VbrIc184uVfn-17ed91de按钮(如下图所示,推荐)。

2NxuQpasLVXS90yE-4cc980c6

Hutool 引入之后,就可以用它各种各样的工具了,在 Hutool 找到Http 客户端工具类-HttpUtil,我们就用它来快速的调用其他的 http 请求。

FEJubUioY9ZHfJXw-8b06098e

创建一个client层:客户端层,负责与用户交互、处理用户请求,以及调用服务端提供的 API 接口等任务的部分。

ZDZRPwMW44VqrFrL-63c18d85

client层新建一个客户端YuApiClient.java:负责调用第三方接口。

75KHpPH95kt3lkYY-cd49a839

现在有三个接口,那在开发客户端时要写三个调用方法,复制 NameController.java 中的三个接口,粘贴到 YuApiClient.java 修改一下。

package com.yupi.yuapiinterface.client; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; /** * 调用第三方接口的客户端 * * @author yupi */ public class YuApiClient { public String getNameByGet(String name) { } public String getNameByPost(@RequestParam String name) { } public String getUserNameByPost(@RequestBody User user) { } }

只不过我们不是提供外部接口,而是去调用外部接口了,怎么调用呢?看一下文档。

MvUc6mCy0aRmgyA6-872c0c38 peWN6iUbL7zHEDX0-28a82b4e

这里默认支持 get 和 post 的请求,包括怎么传参数,复制代码。

// 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中 HashMap<String, Object> paramMap = new HashMap<>(); paramMap.put("city", "北京"); String result3= HttpUtil.get("https://www.baidu.com", paramMap);

再找一个支持JSON类型的请求,找到Http请求-HttpRequestRestful请求,复制代码。

cILAbrrFsXP19d6z-5a62aae4
String json = ...; String result2 = HttpRequest.post(url) .body(json) .execute().body();

把复制的两段代码粘贴到 YuApiClient.java 修改一下。

package com.yupi.yuapiinterface.client; import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; import cn.hutool.http.HttpUtil; import cn.hutool.json.JSONUtil; import com.yupi.yuapiinterface.model.User; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import java.util.HashMap; /** * 调用第三方接口的客户端 * * @author yupi */ public class YuApiClient { // 使用GET方法从服务器获取名称信息 public String getNameByGet(String name) { // 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中 HashMap<String, Object> paramMap = new HashMap<>(); // 将"name"参数添加到映射中 paramMap.put("name", name); // 使用HttpUtil工具发起GET请求,并获取服务器返回的结果 String result= HttpUtil.get("http://localhost:8123/api/name/", paramMap); // 打印服务器返回的结果 System.out.println(result); // 返回服务器返回的结果 return result; } // 使用POST方法从服务器获取名称信息 public String getNameByPost(@RequestParam String name) { // 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中 HashMap<String, Object> paramMap = new HashMap<>(); paramMap.put("name", name); // 使用HttpUtil工具发起POST请求,并获取服务器返回的结果 String result= HttpUtil.post("http://localhost:8123/api/name/", paramMap); System.out.println(result); return result; } // 使用POST方法向服务器发送User对象,并获取服务器返回的结果 public String getUserNameByPost(@RequestBody User user) { // 将User对象转换为JSON字符串 String json = JSONUtil.toJsonStr(user); // 使用HttpRequest工具发起POST请求,并获取服务器的响应 HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name/") .body(json) // 将JSON字符串设置为请求体 .execute(); // 执行请求 // 打印服务器返回的状态码 System.out.println(httpResponse.getStatus()); // 获取服务器返回的结果 String result = httpResponse.body(); // 打印服务器返回的结果 System.out.println(result); // 返回服务器返回的结果 return result; } }

这三个接口调用就写好了,现在创建一个测试类Main.java,编写测试方法。

package com.yupi.yuapiinterface; import com.yupi.yuapiinterface.client.YuApiClient; import com.yupi.yuapiinterface.model.User; public class Main { public static void main(String[] args) { YuApiClient yuApiClient = new YuApiClient(); String result1 = yuApiClient.getNameByGet("鱼皮"); String result2 = yuApiClient.getNameByPost("鱼皮"); User user = new User(); user.setUsername("鲤鱼旗"); String result3 = yuApiClient.getUserNameByPost(user); System.out.println(result1); System.out.println(result2); System.out.println(result3); } }

点击绿色 8lciNeYIwXw68lrB-351b0061运行按钮。

Kydcgdfh7usyBQsk-b1ddad49

ps.YuapiInterfaceApplication.java 一定要是启动的状态,然后再启动 Main.java,不然它无法和 http://localhost:8123/api/name/ 建立连接,因为你没启动本地在 8123 端口的项目。

rrBIh5WzMt5dfwpC-6e2fbf74

测试成功。

sttN66SDvRHn1fU1-7fa74786

4. API 签名认证(时间点 01:17:55-02:12:06)

4.1 详细说明(时间点 01:17:55-01:25:18)

我们现在要思考一个重要问题:如果我们为开发者提供了一个接口,却对调用者一无所知。假设我们的服务器只能允许 100 个人同时调用接口。如果有攻击者疯狂地请求这个接口,那将极其危险。一方面这可能会损害我们的安全性,另一方面也可能耗尽服务器性能,影响正常用户的使用。

因此,我们必须为接口设置保护措施,例如限制每个用户每秒只能调用十次接口,即实施请求频次的限额控制。如果在后期,你的业务扩大,可能还需要收费。因此,我们必须知道谁在调用接口,并且不能让无权限的人随意调用。

现在,我们需要设计一个方法,来确定谁在调用接口。在我们之前开发后端时,我们会进行一些权限检查。例如,当管理员执行删除操作时,后端需要检查这个用户是否为管理员。那么,我们如何获取用户信息呢?是否直接从后端的 session 中获取?但问题来了,当我们调用接口时,我们有 session 吗?比如说,我是前端直接发起请求,我没有登录操作,我没有输入用户名和密码,我怎么去调用呢?因此,一般情况下,我们会采用一个叫API签名认证的机制。这是一个重要的概念。

那么,什么是 API 签名认证?简单地说,如果你想来我家做客,我不可能随便让任何陌生人进来。所以我会提前给你发一个类似于请帖的东西,作为授权或许可证。当你来访问我的时候,你需要带上这个许可证。我可能并不认识你,但我认识你的请帖。只要你有这个请帖,我就允许你进来。

所以,API 签名认证主要包括两个过程。第一个是签发签名,第二个是使用签名或校验签名。这就像一些短信接口的 key 一样。

为什么我们需要API签名认证呢?简单地说,第一,为了保证安全性,不能让任何人都能调用接口。那么,我们如何在后端实现签名认证呢?我们需要两个东西,即 accessKey 和 secretKey。这和用户名和密码类似,不过每次调用接口都需要带上,实现无状态的请求。这样,即使你之前没来过,只要这次的状态正确,你就可以调用接口。所以我们需要这两个东西来标识用户。

下面将为大家演示如何签发 accessKey 和 secretKey,以及如何使用和验证它们。在签发过程中,你可以自己编写一个生成 accessKey 和 secretKey 的工具。一般来说,accessKey 和 secretKey 需要尽可能复杂,以防止黑客尝试破解,特别是密码,需要尽可能复杂,无规律。

4.2 API签名认证总结

本质:

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

为什么需要?

  1. 保证安全性,不能随便一个人调用
  2. 适用于无需保存登录态的场景。只认签名,不关注用户登录态。

签名认证实现

通过 http request header 头传递参数。

  • 参数 1:accessKey:调用的标识 userA, userB(复杂、无序、无规律)
  • 参数 2:secretKey:密钥(复杂、无序、无规律)该参数不能放到请求头中

(类似用户名和密码,区别:ak、sk 是无状态的)

ps.大家可以自己写代码来给用户生成 ak、sk,千万不能把密钥直接在服务器之间传递,有可能会被拦截

  • 参数 3:用户请求参数
  • 参数 4:sign

加密方式:

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

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

abc + abcdefgh => sajdgdioajdgioa

怎么知道这个签名对不对?

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

怎么防重放?

  • 参数 5:加 nonce 随机数,只能用一次

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

  • 参数 6:加 timestamp 时间戳,校验时间戳是否过期。

API 签名认证是一个很灵活的设计,具体要有哪些参数、参数名如何一定要根据场景来。

(比如 userId、appId、version、固定值等)

思考:难道开发者每次调用接口都要自己写签名算法?

4.3 API签名认证实现(时间点 01:25:18-01:36:24)

在我们之前的用户表中,并没有包含 accessKey 和 secretKey。现在,假设为每个用户分配一个唯一的 accessKey 和 secretKey。这样,当用户调用接口时,只需携带 accessKey,我们的后端就能知道是哪个用户在进行调用。

找到 yupi-backend 项目内的sql包下的ddl.sql,增加两个字段。

2BeyqCf7RM6vZy9z-ceb929b8

点击数据库中的 user 表,点击左侧选中整行数据,按[Ctrl+C]复制。

mLETbNzOTYbZt6dG-7b75a3da
1,老鱼皮,yupi,https://yupi.icu/logo.png,,admin,b0dd3697a192885d7c055db46155b26a,2023-04-30 22:45:10,2023-05-14 11:37:02,0

把原先的 user 表删掉。

W16sOGeTz4C19Zbv-915bfb86

选中 user 表,鼠标右键选择Run 'ddl.sql'

rWRNlvlkPe0wKhYE-6adadcd6 9Z3MvwJAkMvMkad1-4d0cbbca

执行成功。

quCqnuDav4xeTFOU-39107c59

然后点击 wcpr6n9VhcSOmHyl-6bbf61f4按钮,,新增数据,按[Ctrl+V]将之前的数据粘贴过来;

设置 accessKey 为yupi,secretKey 为abcdefgh,让创建时间变成默认,然后点击 4ARzDSrvhTarVRKF-74446518更新按钮。

nGqmt6RVL1jTWQmx-e35295d3 ksCdN4sq47Cinmjo-6948c5e6

ps.关于 accessKey、secretKey 的生成方法,大家可以自行编写代码实现。在此就不做演示了,可能在之后会补充这部分内容。(下次一定🐶)


🪔 小知识:

**为什么需要两个 key?**如果仅凭一个 key 就可以调用接口,那么任何拿到这个 key 的人都可以无限制地调用这个接口。这就好比,为什么你在登录网站时需要输入密码,而不是只输入用户名就可以了?其实这两者的原理是一样的。如果像 token 一样,一个 key 不行吗?token 本质上也是不安全的,有可能会通过重放等等方式来攻破的。


接下来回到 yuapi-interface 项目中的YuApiClient.java调用。

7g7IFonijhZKBTMQ-c63c1e14

按[Alt+ Insert]创建构造方法。

bC5YyH0iLdEnjJj0-6e25fcba

全部选中。

h7OWRr9r3iIWwA6h-a95b217a

创建了构造方法。

rcgtcX4OyePnlEQW-992ac085

在调用 YuApiClient 的地方,把 accessKey、secretKey 拿到,客户端改造完成。

KOtpgExLop4tewdo-48d1ffb7

接下来服务端肯定要校验它,以 restful 接口为例进行说明:

我们需要获取用户传递的 accessKey 和 secretKey。对于这种数据,建议不要直接在 URL 中传递,而是选择在请求头中传递会更为妥当。因为 GET 请求的 URL 存在最大长度限制,如果你传递的其他参数过多,可能会导致关键数据被挤出。因此,建议从请求头中获取这些数据。

gfCzKOGOYvkXcwum-d0640e8d zY1TiU1900V7S5vR-8543fdcf

在实际应用中,我们需要执行的操作是什么?我们应该根据提供的 Key 去数据库中查询,检查此 Key 是否已经被分配过,以及关联的用户是否被禁用、是否合法。现在我们只是简单模拟这一过程。

@PostMapping("/user") public String getUserNameByPost(@RequestBody User user, HttpServletRequest request) { // 从请求头中获取名为 "accessKey" 的值 String accessKey = request.getHeader("accessKey"); // 从请求头中获取名为 "secretKey" 的值 String secretKey = request.getHeader("secretKey"); // 如果 accessKey 不等于 "yupi" 或者 secretKey 不等于 "abcdefgh" if (!accessKey.equals("yupi") || !secretKey.equals("abcdefgh")){ // 抛出一个运行时异常,表示权限不足 throw new RuntimeException("无权限"); } // 如果权限校验通过,返回 "POST 用户名字是" + 用户名 return "POST 用户名字是" + user.getUsername(); }

debug 模式重启项目。

6jNDHe5dvV4jrdvx-1bb92089

改造一下 YuApiClient.java,发请求可以带上 header,用这个就可以去添加很多的请求头;

这里新建一个函数,用于构造请求头。

x3QeEG7JZyAscNdl-09b9b474
package com.yupi.yuapiinterface.client; import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; import cn.hutool.http.HttpUtil; import cn.hutool.json.JSONUtil; import com.yupi.yuapiinterface.model.User; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import java.util.HashMap; import java.util.Map; /** * 调用第三方接口的客户端 * * @author yupi */ public class YuApiClient { private String accessKey; private String secretKey; public YuApiClient(String accessKey, String secretKey) { this.accessKey = accessKey; this.secretKey = secretKey; } public String getNameByGet(String name) { // 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中 HashMap<String, Object> paramMap = new HashMap<>(); paramMap.put("name", name); String result = HttpUtil.get("http://localhost:8123/api/name/", paramMap); System.out.println(result); return result; } public String getNameByPost(@RequestParam String name) { // 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中 HashMap<String, Object> paramMap = new HashMap<>(); paramMap.put("name", name); String result= HttpUtil.post("http://localhost:8123/api/name/", paramMap); System.out.println(result); return result; } // 创建一个私有方法,用于构造请求头 private Map<String, String> getHeaderMap() { // 创建一个新的 HashMap 对象 Map<String, String> hashMap = new HashMap<>(); // 将 "accessKey" 和其对应的值放入 map 中 hashMap.put("accessKey", accessKey); // 将 "secretKey" 和其对应的值放入 map 中 hashMap.put("secretKey", secretKey); // 返回构造的请求头 map return hashMap; } public String getUserNameByPost(@RequestBody User user) { String json = JSONUtil.toJsonStr(user); HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name/user") // 添加前面构造的请求头 .addHeaders(getHeaderMap()) .body(json) .execute(); System.out.println(httpResponse.getStatus()); String result = httpResponse.body(); System.out.println(result); return result; } }

在这里打个断点。

St1OW34C2Meso3KR-89c1fff6

来测试一下发送客户端调用,启动测试类。

IxJNyC5nS86wFJXs-0f77435e

我们拿到了 accessKey、secretKey。

xRbSp9sz24QdCQjb-8ba71681

按[F7] 或点击 n9mqpdTvXCCHyE9Z-76d42803按钮,执行下一步;

因为我们设置的和 accessKey、secretKey 一样,所以就验证通过了,能正常返回。

JAz3XjXoRHyLKrWr-876b5181

按[F9]或按 3nYSAd1Orxjs9Yn7-50ba49f9继续执行。

6yOR1IP091Yyyuiw-8c436207

如果客户端调用时,随便输错一个,它就不认识了;

把 secretKey 随便改成 ab。

sAONZZQQjABq4GyC-def07003

修改后:

XZaAnKJdV5QGtAXN-90489d77

再启动测试一下。

mXvFaeWa1EEX4A05-4c017b2f 9XHTKmfAIU1Uqgut-4a19ebef

按[F7] 或点击 n9mqpdTvXCCHyE9Z-76d42803按钮,执行下一步;

判断直接跳到"无权限"。

OproGeg3nmG95NpT-2fdac089

按[F9]或按 3nYSAd1Orxjs9Yn7-50ba49f9继续执行。

7LcPFyz0SswAZpMK-6034c9be

记得把随便设置的改回来哦🐶

4hDWI3LWX2Vdncfz-53b42d24

4.4 安全传递讲解(时间点 01:36:24-01:50:17)

让我们思考一下,现在所做的事情是否存在问题?这种做法真的安全吗?尽管安全性可能略有提高,但是否涉及到破解的问题还不确定。那么现在问题出在哪里?是什么导致的?问题在于我们的请求有可能被人拦截,我们将密码放在请求头中,如果有中间人拦截到了你的请求,他们就可以直接从请求头中获取你的密码,然后使用你的密码发送请求。

需要注意的问题是,密码绝对不能传递。也就是说,在向对方发送请求时,密码绝对不能以明文的方式传递,必须通过特殊的方式进行传递。因此,我们目前的做法是行不通的。绝对不能直接在服务端或服务器之间传递密钥,这样是不安全的。那么,我们应该如何使其安全呢?在标准的 API 签名认证中,我们需要传递一个签名。有同学提到了传递一个许可证,是的,通常我们不是直接将其传递给后台,而是根据该密钥生成一个签名。因为密码不能直接在服务器中传递,有可能会被拦截。

所以我们需要对该密码进行加密,这里通常称之为签名。那么这个签名是如何生成的呢?让我们思考一下,我们可以将用户传递的参数(例如ABC参数)与该密钥拼接在一起,然后使用签名算法进行加密。但这里实际上并不是真正的加密,也可以使用加密算法。

我们的加密算法可以分为:单向加密(md5 签名)、对称加密、非对称加密。对称加密是什么?它是分配一组密钥,你可以加密和解密。还有非对称加密,你可以使用公钥加密,私钥解密,有些情况下也可以使用私钥加密,公钥解密。此外,还有一种单向加密,即加密后无法解密。这种是安全性最高的,大家想一想。

md5 本质上是一种签名算法。例如,百度网盘上传文件时,每个文件都有一个唯一的值。就像 md5,一般情况下是不可逆的,即无法解密。理论上,md5 这种方式最安全。所以我们的思路不是给用户分配的密钥来进行加密。,而是我们将其与用户的参数进行拼接。例如,我们的密钥是 ABCDEFGH,经过签名算法后,最后得到的值可能是 fgthtrgerge。这个值是无法解密的。然后我们只需将此值发送给服务器,服务器只需验证签名是否正确即可。这样也不会暴露密码,因为我们根本不传递密码,而是根据密码生成一个值,然后将生成后的值传递给服务器。

然而问题来了,既然这个值无法解密,作为我的 API 接口,我如何知道你传递的签名是否正确呢?如何判断?这个很简单,我可以再次使用相同的参数进行生成,并与你传递的参数进行对比,看它们是否一致。

有同学问,前端发送请求时是否可以直接加密传输?有一个问题,就是不要相信前端加密。前端的加密是有用的,但是前端的加密不能完全保证安全,所以不要依赖前端。之前说过所有的请求都是可以重放的。也就是说,无法直接加密,无论你如何加密,只要被人拦截了,他们只需使用你传递的加密内容再次发送给后台,结果是一样的。因此,实际上也不安全。我们现在讨论的这种方法还不是很安全,还存在更复杂的问题。

因此,在 API 签名时,需要传递额外的参数,即签名。如何实现呢?通过 HTTP 请求头传递参数。另外,添加时间戳也不能防止重放攻击。那么如何生成签名呢?就像我刚刚提到的,将用户传递的参数以及其他一些内容使用签名算法转换为无法解密的值,并将该值传递过去。这本质上需要使用一个签名生成算法,这是关键。除了这些之外,刚刚提到,既然我们的请求可以被拦截,即使加了签名,如果我将该签名重放一次,后端也无法知道是中间人还是正常用户。

那么我们如何解决这种重放攻击的问题呢?即如何防止他人复制并重复之前发布的请求呢?举个例子,假设你的电脑使用了代理、代理服务,请小心操作。在这种情况下,减少使用一些乱七八糟的操作,尤其是在公司进行开发时尽量少用代理。


💡 **拿知识星球举例:**进入知识星球网站后,鼠标右键检查(或按F12) → 网络

选择一个请求,点击鼠标右键,就会看到一个选项重放XHR,点击重放XHR

rqDSPX2OHdbeX5HA-ac664ffa

你会发现这个请求又被重新发送了一次。

ZkLnL5BexwyOgGFB-f674ecfb

如果是一个代理软件,他如果想搞你的话,你只要发出请求走了他的代理,他点一下重放就好了。


如何防止重放请求有两种方式可以考虑:

第一种方式是通过加入一个随机数实现标准的签名认证。每次请求时,发送一个随机数给后端。后端只接受并认可该随机数一次,一旦随机数被使用过,后端将不再接受相同的随机数。这种方式解决了请求重放的问题,因为即使对方使用之前的时间和随机数进行请求,后端会认识到该请求已经被处理过,不会再次处理。然而,这种方法需要后端额外开发来保存已使用的随机数。并且,如果接口的并发量很大,每次请求都需要一个随机数,那么可能会面临处理百万、千万甚至亿级别请求的情况。因此,除了使用随机数之外,我们还需要其他机制来定期清理已使用的随机数。

第二种方式是加入一个时间戳(timestamp)。每个请求在发送时携带一个时间戳,并且后端会验证该时间戳是否在指定的时间范围内,例如不超过10分钟或5分钟。这可以防止对方使用昨天的请求在今天进行重放。通过这种方式,我们可以一定程度上控制随机数的过期时间。因为后端需要同时验证这两个参数,只要时间戳过期或随机数被使用过,后端会拒绝该请求。因此,时间戳可以在一定程度上减轻后端保存随机数的负担。通常情况下,这两种方法可以相互配合使用。

因此,在标准的签名认证算法中,建议至少添加以下五个参数:accessKey、secretKey、sign、nonce(随机数)、timestamp(时间戳)。此外,建议将用户请求的其他参数,例如接口中的 name 参数,也添加到签名中,以增加安全性。

💡 类似于 HTTPS 协议,签名认证的本质是确保密码不在服务器之间传输。因为任何在服务器之间传输的内容都有可能被拦截。所以,请记住密码绝不能在服务器之间传输。如果只能从本次直播中记住这句话,那也是一种收获。因为很多同学会错误地认为密码可以在前端传输,千万不要这样做,不要在前端调用时传输这些敏感信息。

4.5 安全传递实现(时间点 01:50:17-02:12:06)

刚刚我们的客户端只有这两个参数。

EpWYz7Arq9EhG4Lu-032c20e5

现在再加几个参数。

guBX3z7iqaZaIkTQ-b7d654bb

我们要把用户参数进行拼接,经过签名算法生成唯一的字符串;

这里使用 Hutool 的加密算法(摘要加密)。

cM0bD24X7EC13ibG-02a0dcfe

新建一个utils包,在 utils 包下新建SignUtils.java(签名工具)。

3AJ6oPZj0b6W5mjh-d584bb54

在 SignUtils.java 编写生成签名的代码。

package com.yupi.yuapiinterface.utils; import cn.hutool.crypto.digest.DigestAlgorithm; import cn.hutool.crypto.digest.Digester; import java.util.Map; /** * 签名工具 */ public class SignUtils { /** * 生成签名 * @param hashMap 包含需要签名的参数的哈希映射 * @param secretKey 密钥 * @return 生成的签名字符串 */ public static String genSign(Map<String, String> hashMap, String secretKey) { // 使用SHA256算法的Digester Digester md5 = new Digester(DigestAlgorithm.SHA256); // 构建签名内容,将哈希映射转换为字符串并拼接密钥 String content = hashMap.toString() + "." + secretKey; // 计算签名的摘要并返回摘要的十六进制表示形式 return md5.digestHex(content); } }

在客户端继续编写代码。

myUqI2FD2DM0QGFK-034d7234
/** * 获取请求头的哈希映射 * @param body 请求体内容 * @return 包含请求头参数的哈希映射 */ private Map<String, String> getHeaderMap(String body) { Map<String, String> hashMap = new HashMap<>(); hashMap.put("accessKey", accessKey); // 注意:不能直接发送密钥 // hashMap.put("secretKey", secretKey); // 生成随机数(生成一个包含100个随机数字的字符串) hashMap.put("nonce", RandomUtil.randomNumbers(100)); // 请求体内容 hashMap.put("body", body); // 当前时间戳 // System.currentTimeMillis()返回当前时间的毫秒数。通过除以1000,可以将毫秒数转换为秒数,以得到当前时间戳的秒级表示 // String.valueOf()方法用于将数值转换为字符串。在这里,将计算得到的时间戳(以秒为单位)转换为字符串 hashMap.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000)); // 生成签名 hashMap.put("sign", genSign(hashMap, secretKey)); return hashMap; } /** * 通过POST请求获取用户名 * @param user 用户对象 * @return 从服务器获取的用户名 */ public String getUserNameByPost(@RequestBody User user) { // 将用户对象转换为JSON字符串 String json = JSONUtil.toJsonStr(user); HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name/user") // 添加请求头 .addHeaders(getHeaderMap(json)) // 设置请求体 .body(json) // 发送POST请求 .execute(); // 打印响应状态码 System.out.println(httpResponse.getStatus()); // 打印响应体内容 String result = httpResponse.body(); System.out.println(result); return result; }

接下来改造一下服务端,刚刚服务端直接校验秘钥是很憨的操作🐶。

XvVZAXsFaXEyltFR-48004016
@PostMapping("/user") public String getUserNameByPost(@RequestBody User user, HttpServletRequest request) { // 1.拿到这五个我们可以一步一步去做校验,比如 accessKey 我们先去数据库中查一下 // 从请求头中获取参数 String accessKey = request.getHeader("accessKey"); String nonce = request.getHeader("nonce"); String timestamp = request.getHeader("timestamp"); String sign = request.getHeader("sign"); String body = request.getHeader("body"); // 不能直接获取秘钥 // String secretKey = request.getHeader("secretKey"); // 2.校验权限,这里模拟一下,直接判断 accessKey 是否为"yupi",实际应该查询数据库验证权限 if (!accessKey.equals("yupi")){ throw new RuntimeException("无权限"); } // 3.校验一下随机数,因为时间有限,就不带大家再到后端去存储了,后端存储用hashmap或redis都可以 // 校验随机数,模拟一下,直接判断nonce是否大于10000 if (Long.parseLong(nonce) > 10000) { throw new RuntimeException("无权限"); } // 4.校验时间戳与当前时间的差距,交给大家自己实现 // if (timestamp) {} return "POST 用户名字是" + user.getUsername(); } @PostMapping("/user") public String getUserNameByPost(@RequestBody User user, HttpServletRequest request) { // 从请求头中获取参数 String accessKey = request.getHeader("accessKey"); String nonce = request.getHeader("nonce"); String timestamp = request.getHeader("timestamp"); String sign = request.getHeader("sign"); String body = request.getHeader("body"); // todo 实际情况应该是去数据库中查是否已分配给用户 if (!accessKey.equals("yupi")){ throw new RuntimeException("无权限"); } // 校验随机数,模拟一下,直接判断nonce是否大于10000 if (Long.parseLong(nonce) > 10000) { throw new RuntimeException("无权限"); } // todo 时间和当前时间不能超过5分钟 // if (timestamp) {} return "POST 用户名字是" + user.getUsername(); }

还有一个要考虑的是对 body 进行校验,这是可选的,可以选择是否进行校验。但我们需要获取 body,因为我们要用 body 来拼接生成 sign。

那么问题来了,我们如何拼接这个 sign?答案是,我们就按照客户端的拼接方式来进行。所以genSign方法实际上可以共用(utils包 下的 SignUtils.java)。

使用 genSign 方法,在客户端用同样的方法生成签名。

mwpB5FZ9FcL8WmxW-883ee743

这个 hashmap 还需要进行拼接,我们传递的是用户的这些参数,但其实没有必要传递那么多参数,直接将 body 作为参数传递进来(在这里,我们也可以传递 hashmap,只要有一些共同的参数,能让客户端和服务端之间保持一致即可)。

TvHC6OSm7Fm9lSgc-f54532b8 kEOvbmhVYJdcvPCy-e62e367e

然后服务端校验时无需拼接 hashmap 了,直接传递 body 即可。在服务端,我们可以轻松获取到 secretKey,因为 secretKey 是由服务端签发的,所以服务端肯定有这个信息。当我们从数据库中查询是否分配给用户时,可以同时将与用户相关的 secretKey 一并查询出来,这样就可以在校验过程中使用了。

hToqmonmriRZOsMR-04a4a643

举个例子,假设服务端从请求头中获取了 accessKey,那么我们可以使用该 accessKey 在数据库中查找对应的记录。通过查找,我们可以获得相应的 secretKey,将其作为签名的密钥进行签名操作。

打个断点。

NYlk4E0AyALRRz38-0a4f5e28

debug模式重启后端项目。

AEXxAQKUPwLSeZno-ef21492f

现在客户端去调用一下,启动 Main。

TfiSAA9Rw0MUxIgA-456bfaee

按[F9]继续执行。

mtX1oTVXVJiJGEcA-2dc47791

发现执行到这一步时报错了。

eZEDiX4nxE5SFMAY-e39fa8dd

而且测试的时候 body 还乱码了。

qaHatXZL0WG0F0Lt-ab4f1d7a

因为这里我们用了中文,hutool 使用中文会乱码。

8auDI7oXR1wlwrhs-504197ec

为了节省时间,直接改成英文liyupi

9plNxmMShROBpsLf-bdf18ef0

debug模式重启后端项目。

AEXxAQKUPwLSeZno-ef21492f

启动 Main。

TfiSAA9Rw0MUxIgA-456bfaee

这次就没有乱码了。

Dy9FRN2auLLh2zIT-f75777df

按[F9]继续执行,这次就调用成功了,不是无权限了。

WxsLIKWNU8gG4jHC-59c6aadd

假设张三是一个攻击者,张三并不知道密码,所以张三会随便传递一些值作为密码,然而,由于张三不知道正确的密码,张三传递的密码与服务端生成的签名所使用的密钥肯定不一致,所以无法执行有效的操作。因此,客户端生成的签名和服务端生成的签名是不同的。这是因为客户端和服务端使用的密钥不一致导致的。

把客户端的 secretKey 改成 abcde。

xl8vIW20BqNOlJwU-3485e197

debug模式重启后端项目。

AEXxAQKUPwLSeZno-ef21492f

启动 Main。

TfiSAA9Rw0MUxIgA-456bfaee

按[F9]继续执行,抛出异常,并提示"无权限"。

9syjHTMJDB7cHAFe-273978de

整个签名认证算法的流程就是这样。需要强调的是,API签名认证是一种非常灵活的设计,具体需要哪些参数以及参数名的选择都应根据具体场景来确定。尽量避免在前端进行签名认证,而是由服务端来处理。这里提供的是一种相对规范的设计方法。

例如,某些公司或项目的签名认证可能会包含 userId 字段以区分用户。还可能包含 appId 和 version 字段来表示应用程序的版本号。有时还会添加一些固定的盐值等等。所以,给大家提供的方法是相对标准的一种方式。

现在我们面临另一个问题:作为开发者,每次调用接口都需要处理这一堆繁琐的事情,这确实有些麻烦,我们需要自己生成时间戳,编写签名算法,生成随机数等等,这些都是相当繁琐的工作。因此,当我们构建接口开放平台时,我们需要想办法让开发者能够以最简单的方式调用接口。开发者只需要关心传递哪些参数以及他们的密钥、APP等信息。一旦告诉了他们这些信息,他们就可以轻松地进行调用了。

对于具体的随机数生成和签名生成过程,开发者有必要关心吗?显然是不需要的。如果每次都要求开发者编写这么多代码,肯定会让他们感到沮丧😢。因此,我们需要为开发者提供一个易于使用的 SDK,使其能够便捷地调用接口。

5. 开发 SDK(时间点 02:12:06-02:37:29)

5.1 Starter 讲解(时间点 02:12:06-02:16:03)

为什么需要 Starter?

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

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

进一步说明:

为了方便开发者的调用,我们不能让他们每次都自己编写签名算法,这显然很繁琐。因此,我们需要开发一个简单易用的 SDK,使开发者只需关注调用哪些接口、传递哪些参数,就像调用自己编写的代码一样简单。实际上,RPC(远程过程调用)就是为了实现这一目的而设计的。可能有些同学学过 RPC,它就是追求简单化调用的理想情况。类似的例子是小程序开发或者调用第三方 API,如腾讯云的 API,它们都提供了相应的 SDK。

现在的问题是如何开发这样一个 SDK,其实很简单。这里为了让开发者更方便使用 SDK,我们给它提供一个 starter。大家有没有用过 spring boot 的 starter?

以 yuapi-backend 项目举例说明,找到 pom.xml;

在这里我们看到引入 mybatis、redis、 swagger 接口文档的时候,都使用了 starter。

TvSG2QuWMUWKNbaD-a2f54f34 tPFENNxqO0oBdOkz-d9151740

**还记得我们使用 starter 之后,我们有哪些好处?**比如,对于 Redis 的 starter,我们可以直接在 application.yml 配置文件中进行相关配置。我们可以在配置文件中简单地定义一个连接到 Redis 的配置块,或者对于 Swagger 接口文档,我们也可以在配置文件中进行相应的配置。这样做的好处是,我们无需手动编写繁琐的配置代码或者创建客户端实例。通过引入适当的 starter,我们就可以直接使用它们提供的代码和客户端。只需在配置文件中进行简单的配置,整个过程就自动完成了。

这正是 starter 的作用所在。使用 starter 的好处就是,开发者引入后可以直接在 application.yml 中进行配置,自动创建相应的客户端。这样使得开发过程更加简单便捷,无需过多关注底层实现细节,而是专注于配置和使用。所以接下来就带大家来做这件事情,让我们编写的 starter 能够为开发者写配置时提供提示,并自动创建客户端(如下图所示的提示)。

MT0JvzEMRjvZHe3k-afd8ee32

5.2 Starter 开发流程(时间点 02:16:03-02:31:56)

我们首先创建一个新项目 —— 项目名:yuapi-client-sdk;

选择 idea 左上角 File → New → Project。

cRLvXXDKMuLXWRc9-306d5e7d

创建 Spring Boot 项目。

Y0aapXGrd1qoQKmV-95fad3ad

然后选择依赖:Lombok、Spring Configuration Processor 后,点击Finish

  • Spring Configuration Processor:它的作用就是帮助开发者自动生成配置的代码提示。
QJ1lt8n6rqFynNaO-b2ed0264 xbDjzrYc4xdMxlP9-b92f2ead

idea 开始初始化项目。

ruQUXaZSNiC6I34v-6df1dd1d

这里选择在新窗口打开。

4ibEpy1ikROA3iYt-14b3c3fd

选择左上角File→settings

aY3NPPrXY7VV0Lri-8e38efec

修改 maven 的设置,我这里默认会变成在 C 盘。

P1Q40yCjZPnMy731-fdc41629

底下的蓝条开始咻咻地加载依赖,等一会。

KmuxXWXYClUHmR52-d8a231be

首先来看这个项目的依赖,查看 pom.xml;

既然写 SDK,肯定是有版本号的。

VK6kvOYW0ytMBvyF-1cebd9c6

改成0.0.1(改成多少都可以)。

pDgncP9YUcxuhM1M-1340d034

往下看,这个测试可以去掉,不去掉也不影响运行(这里就不去掉了)。

vI8HJyXRUde1D8Ne-6dd27074

往下看,下面这个东西要删掉,不然会报错(一定要删掉);

这个是 maven 构建项目的方式,我们现在是要构建依赖包,而不是直接运行 jar 包的项目。

tU0hcrA7m5sEjphZ-c4b132be

在创建完这个项目之后,默认会生成一个 Spring Boot 的主类。然而,我们并不打算运行一个 Web 项目,而是提供一个现成的客户端对象给用户使用。

nvx4LZMkeqTvd9ei-9885bbef

现在我们的目标是为用户生成一个可用的客户端对象。刚才我们是如何生成的呢?我们手动创建了一个新的对象实例。而现在,我们希望用户能够通过引入 starter 的方式直接使用客户端,而不需要手动创建,所以我们需要编写一个配置类。

删掉主类,创建配置类YuApiClientConfig.java

4PiGvBSdNWneabte-70b45260

在这里打上几个注解,加上两个参数。

package com.yupi.yuapiclientsdk; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; // 通过 @Configuration 注解,将该类标记为一个配置类,告诉 Spring 这是一个用于配置的类 @Configuration // 能够读取application.yml的配置,读取到配置之后,把这个读到的配置设置到我们这里的属性中, // 这里给所有的配置加上前缀为"yuapi.client" @ConfigurationProperties("yuapi.client") // @Data 注解是一个 Lombok 注解,自动生成了类的getter、setter方法 @Data // @ComponentScan 注解用于自动扫描组件,使得 Spring 能够自动注册相应的 Bean @ComponentScan public class YuApiClientConfig { private String accessKey; private String secretKey; }
LyJFTmQHYUcX72xI-68fe6fbb

@ComponentScan 这个注解是做什么的呢?Spring Boot 项目是不是都有一个@SpringBootApplication注解(拿 yuapi-interface 项目举例说明)。

Dnbj4Wx2ov6UIaaD-4778b5bb

按[Ctrl + 鼠标左键]点进去,这个注解帮你封装了很多注解,比如:ComponentScan。

但是如果你不用这个注解,就要自己去写这个扫包。

6tAEVsUYHfWZ0bdf-7ccf817c

现在我们要给用户提供 ApiClient,把 yuapi-interface 项目中的 client包、model包、utils包复制。

dTIRUAoE9msW3Xuu-2f4d295a

粘贴到 yuapi-client-sdk 项目中。

5N4LIvcmY6g0UWe2-51867eda

复制 yuapi-interface 项目中的 hutool 依赖。

VGrO3onxkzuR0Fby-e5991298

粘贴到 yuapi-client-sdk 项目中。

HYGpeBOx33fj2fjf-42eed98e

然后给 yuapi-client-sdk 项目 中的YuApiClient.java重新引入包,删掉多余的注解。

2lTumRIk8iituCce-c7cb70bf AMbqAJHGGbcSJgFp-7cc5a7b2
package com.yupi.yuapiclientsdk.client; import cn.hutool.core.util.RandomUtil; import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; import cn.hutool.http.HttpUtil; import cn.hutool.json.JSONUtil; import com.yupi.yuapiclientsdk.model.User; import java.util.HashMap; import java.util.Map; import static com.yupi.yuapiclientsdk.utils.SignUtils.genSign; /** * 调用第三方接口的客户端 * * @author yupi */ public class YuApiClient { private String accessKey; private String secretKey; public YuApiClient(String accessKey, String secretKey) { this.accessKey = accessKey; this.secretKey = secretKey; } public String getNameByGet(String name) { // 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中 HashMap<String, Object> paramMap = new HashMap<>(); paramMap.put("name", name); String result = HttpUtil.get("http://localhost:8123/api/name/", paramMap); System.out.println(result); return result; } public String getNameByPost(String name) { // 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中 HashMap<String, Object> paramMap = new HashMap<>(); paramMap.put("name", name); String result= HttpUtil.post("http://localhost:8123/api/name/", paramMap); System.out.println(result); return result; } private Map<String, String> getHeaderMap(String body) { Map<String, String> hashMap = new HashMap<>(); hashMap.put("accessKey", accessKey); // 一定不能直接发送 // hashMap.put("secretKey", secretKey); hashMap.put("nonce", RandomUtil.randomNumbers(4)); hashMap.put("body", body); hashMap.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000)); hashMap.put("sign", genSign(body, secretKey)); return hashMap; } public String getUserNameByPost(User user) { String json = JSONUtil.toJsonStr(user); HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name/user") .addHeaders(getHeaderMap(json)) .body(json) .execute(); System.out.println(httpResponse.getStatus()); String result = httpResponse.body(); System.out.println(result); return result; } }

回到 yuapi-client-sdk 项目 中的 YuApiClientConfig.java, 写一下生成客户端的方法。

这个地方不像我们之前在 yuapi-interface 项目中写死在 Main.java 里,刚刚只是测试,现在就是通过读取这个配置拿到这两个值。用这两个值去得到这样一个客户端。

WkKbh3oXfArgnWeS-277556d3

我们已经完成了实现。现在,如果我们引入这个库并配置好了 accessKey 和 密secretKey 的相关属性,Spring Boot 将自动为我们生成一个名为yuApiClient的对象。然后,我们可以在项目中使用这个对象了。等会儿我们来演示一下怎么使用。

做到现在这样还没完,还要做一件事情;

在 resources 目录下创建一个目录META-INF(注意要大写,小写没试过行不行🐶)。

g5NSYsBiPt8059Jj-3e808392

META-INF目录创建一个文件spring.factories

  • spring.factories:定义了 Spring Boot 的自动配置。它是一个标准的 Java Properties 文件,用于指定要自动配置的类。这个文件中的每一行都是一个配置项,包含两个部分:配置项的全限定类名和对应的自动配置类。它们之间使用等号(=)进行分隔。在 Spring Boot 应用启动时,它会加载这个 spring.factories 文件,并根据其中的配置项自动进行相应的配置。

鼠标右键META-INF目录,选择 New。

KsK0vfHQFRqHdYBS-84a22580

输入文件名。

kb8pQY0hCh0W7bcE-b0082f4d

创建成功,同时也被识别成功,文件名前面会出现小图标。

8nKR3obQ4yrnVhwB-06260687

spring.factories文件内编写配置项为自动引入配置的类。

26ZAkGx6YVKJr6Ax-ad76798f

然后指定为我们的配置类,这样它才能加载到,它一定要被 Spring Boot 能识别出来的;

鼠标右键 YuApiClientConfig.java。

5KpGw1N0H2n21Mtc-c6bcf395

复制配置类的路径。

Jon1UT4Hy3QyAYGY-f3566553

粘贴到spring.factories文件中。

LeGzUzkBdbfDmNNG-5b71fa26
# spring boot starter org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.yupi.yuapiclientsdk.YuApiClientConfig

上述配置项指定了要自动配置的类 com.yupi.yuapiclientsdk.YuApiClientConfig,它是我们刚刚编写的配置类。通过在 spring.factories 文件中配置我们的配置类,Spring Boot 将会在应用启动时自动加载和实例化 YuApiClientConfig,并将其应用于我们的应用程序中。这样,我们就可以使用自动配置生成的 yuApiClient 对象,而无需手动创建和配置。

然后我们来测试一下,对已经写好的依赖包进行打包,点击右侧菜单栏中的 maven。

3GkQizddZkTXeeJL-3d5a30f1

点击 Lifecycle → install,把它安装为本地的依赖。

WeiTw64t6g6yncAU-711ef535

这里会报错。

KpJzlejAkVXx23cR-10942cc0

因为它执行了这个测试,我们之前把主类删掉了,所以它执行不了了。

l7vMoUnv7buz00ht-25c61e3c

可以选择在这里删除该测试或将其直接过滤掉,这里我们就直接删掉。

  1. 删除:鼠标右键,选择 Delete。
GM1LhE5VgjNsiebS-4fc3a728
  1. 过滤:点击 J0VGmVl4VDSq4V7U-43dd5434过滤按钮。
8Hit5Xotio4b2FOR-a7a59e2d

再执行一次 install。

MAtmplRanKE2al7r-a35a6bc3

无需刻意去记这些东西的写法。写过一次之后,如果要再次写类似的"starter",可以直接将这个项目作为初始模板使用。

现在已经打包成功,成功之后,就可以测试一下能不能用了。

qPIpI8g5NBOjsRdE-e97f82cb

💡 打包后的依赖包在哪里?

你设置的 maven 仓库在哪里,包就在哪里。我这里设置的 maven 仓库在 D:/星球项目2/maven-res。

ps.如果没有设置 maven 也可以查看 Local repository 的路径位置,去查找依赖包。

Qr2bvGhQbpjQdaj2-8b5d4e9e

根据 yuapi-client-sdk 项目中 pom.xml 的groupId,说明这个依赖包在 D:/星球项目2/maven-res/com/yupi 下。

UU56YHgvS7TVs7u2-00537cae

根据提示,先进入 com 目录。

ihaSb7oGTfGBedX4-250f6e4a

然后进入 yupi 目录。

9On4g6KGnNjVK9in-7ebbcd51

就看见我们打包好的依赖包了。

tUQIEBCu7PA8KLpq-39f0a272

如果你想给别人使用的话,要去 maven 仓库官网注册一个账号,然后上传上去。


这里就不新开一个项目了,来到 yuapi-interface 项目,把之前的 client包、model包、utils包、Main.java 全删了。

S34gy9ZANXqJyF73-924c3e89

然后去引入我们写好的依赖,回到 yuapi-client-sdk 项目复制依赖名称。

emq56y9yTK8O3uOQ-0b8e9317

粘贴到 yuapi-interface 项目中的 pom.xml。

GgfHbXrqzjlaCeC2-2c6250c3

加载依赖后,没有报错,说明我们现在已经引入这个客户端了(版本红色没关系🐶)。

AA2FgsH1HrkffMhL-15a11769

点击 application.yml 进行配置,依次输入 yuapi,弹出提示,它现在识别出来了我们另外一个项目中写的配置。

yAsI1f1WLzRQJLwY-12647c75

选择第一行。

KWvkIQuXsSXLiueK-514c52b5 0VGS8dWI70TZoowf-095b231b

指定 accesskey 为yupi

wm6Td4fbl2aN3phd-5e2e9dfc

然后指定 secretkey 为abcdefgh

qTYwRf26sOUSgh1v-412097c4

来到 NameController.java,这里有一些报错。

T909yeDZoywSFsF3-81d0c897

引入 yuapi-client-sdk 包里的。

2hmF1vVmZLaElVPK-dee15fe4

然后在这里写一个测试类。

uAiekfu2PexxkMaE-8698d039

编写测试方法。

package com.yupi.yuapiinterface; import com.yupi.yuapiclientsdk.client.YuApiClient; import com.yupi.yuapiclientsdk.model.User; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import javax.annotation.Resource; // 表示这是一个基于Spring Boot的测试类 @SpringBootTest class YuapiInterfaceApplicationTests { // 注入一个名为yuApiClient的Bean @Resource private YuApiClient yuApiClient; // 表示这是一个测试方法 @Test void contextLoads() { // 调用yuApiClient的getNameByGet方法,并传入参数"yupi",将返回的结果赋值给result变量 String result = yuApiClient.getNameByGet("yupi"); // 创建一个User对象 User user = new User(); // 设置User对象的username属性为"liyupi" user.setUsername("liyupi"); // 调用yuApiClient的getUserNameByPost方法,并传入user对象作为参数,将返回的结果赋值给usernameByPost变量 String usernameByPost = yuApiClient.getUserNameByPost(user); // 打印result变量的值 System.out.println(result); // 打印usernameByPost变量的值 System.out.println(usernameByPost); } }

debug模式启动后端项目。

5fRRD3xYirAZnEqh-5a5eaf13

启动测试。

6XTpkofiQYQ8qZtZ-dbc1b5d4

成功调用,得到了响应,也得到了用户名。

YlVrURrgRqVbgFEF-fbfee7a4

所以现在只要把 secretkey 改一下。

x3mO3sByuzo7ylgk-37f9d8ec

再运行测试,就调用不通了,500 了,因为 secretkey 不对。

ZReE7GAhoW9sbZlS-546f8a9b

记得改回正确的 secretkey。

1YHcmB3cAw4CdxOU-af84cd00

5.3 Starter 总结(时间点 02:31:56-02:37:29)

大家认为开发一个 starter 难吗?其实并不难,关键步骤只有几个。首先确认所需依赖。然后写 META-INF,指定配置注册类。

Y5GTUjSYEsjzwfdo-30cd83df

在这个地方就是通过这个注解来读取到这个配置信息。

QYT3ozxG1kgASj2d-1a43dd31

有没有觉得很方便?现在,无论是我的任何一个项目,只要引入这个包,也就是这个 starter,只需要引入这个SDK。

SYwTfyMGrbgkyFaF-d23fd78c

然后在这里填写开发者账户和密码。

8Ilp2Vs5kowDMOqK-752e63f5

就可以直接在需要调用的地方引入这个客户端。

1R1k2Oo9iWH5XJNN-b5da5778

关于签名等细节,不需要担心,作为开发者,不需要关心这些,这正是 starter 的优势所在。

至于它是怎么实现了注解,就是这个配置的提示,简单地给大家说明一下。

ZHsJlLwytHQiN2d0-28781756

我们可以看一下引入的这个包里面都有什么,之所以能够在写配置时看到提示,就是因为这个 spring-configuration-metadata.json。

WjcUBjEDJMjqCKQT-52b42b23

这东西怎么生成的呢?其实就是这个库生成的。

9MBOmldASkSLDdlj-a93478a0

通过之前的解释,大家应该已经明白为什么能够自动生成这个配置了。原因在于在这里定义了配置的名称、类型以及对应源码的位置,同时还有传递给源码的配置类在哪里。

这一期的内容,是🐟改了五百多份简历,从来没有见过有同学写过在简历上写过开发过 starter 的经历。🐟个人认为,在简历上写开发过 starter 的经验绝对是加分项。希望大家学完这个项目后,可以多开发这种类型的项目,尤其是将其发布到 maven 仓库中,这绝对能够给自己加分。如果大家之前有开发过项目,写过一些工具类,都可以尝试将其转化为 starter。starter 的好处是我们不需要手动创建客户端、编写配置文件,还有代码提示的功能。

六、小作业(时间点 02:36:00-02:36:31)

怎么把 starter 分享给别人?

  1. 把 jar 包给别人
  2. 把打好的包发到 maven 仓库中

项目分区导航:⬅️ 10-项目笔记(三) | 11-项目笔记(二) | ➡️ 12-项目笔记(五)