--- title: "11-项目笔记(二)" created: 2025-12-02 tags: - 项目 aliases: - 项目笔记(二) --- # 项目笔记(二) ## 一、项目概述 本项目是一个面向开发者的 API 平台,提供 API 接口供开发者调用。用户通过注册登录,可以开通接口调用权限,并可以浏览和调用接口。每次调用都会进行统计,用户可以根据统计数据进行分析和优化。管理员可以发布接口、下线接口、接入接口,并可视化接口的调用情况和数据。本项目侧重于后端,涉及多种编程技巧和架构设计层面的知识。 ## 二、本期时间点 经过 7 场直播,总时长近 20 小时。 ![[4iciofhNq1pBTvkd-8738d574.webp]] ## 三、本期计划(时间点 **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`修改路由。 ```javascript 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.png]] ![[lZv7wJaHMIAVQpit-b14d1c7f.png]] ps.尽量与后端保持一致;webstorm 点击目录后,按[shift+F6]重构。 ![[LgB9AbnrSuvMcf2L-644168fd.webp]] 然后去优化接口管理页,找到 InterfaceInfo 目录下的 index.tsx,虽然这里有一点小小的报错,但影响不大🐶。 ![[o2sL2Yw377YZMg8s-fb5fdaaa.webp]] 当然还是要注意看一下,比如这个报错,它说:不是所有的代码路径都返回了值。 ![[tkBcRAnJCX0Tl3n0-83a88d6b.webp]] ![[dqEy2DCRX9SGHQxF-a86f3b28.webp]] ps.vscode 的是单个单个地说没有读取其值。 ![[KeKp0DCzo016jmET-3712cf85.webp]] ![[AuMoUUzmOCZ6c8Si-6b5209ba.webp]] 有时它类型的一些报错,想忽略也可以强行忽略,在 if 补充上 return。 ![[ye5Rbi2wdCeCVVdu-28d7e5e6.webp]] ```java request={async (params, sort: Record, filter: Record) => { 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、后端项目、前端项目,访问 进行登录; 现在可以看到主页没有任何页面,不用管,等会开发一个给非管理员(用户)看的接口信息页。 ![[zELvwlBbd5iEnb9x-85064e9d.webp]] 点击管理页,查看接口管理页。 ![[V6s1AAqwZKnDFwci-b67e51fa.webp]] 右边蓝色的 ![[RvhVvcegySKU7lBb-121279af.png]]可以设置各种样式,样式调完还能复制配置。 复制配置后,粘贴到 defaultSettings.ts。 ![[QXFftfwo2MFRfgkA-ee61b2da.webp]] 然后把 app.tsx 的 initialState?.settings 换成 defaultSetting 就可以生效了。 ![[CW8vCMQ4rwWimb7d-0e1deeac.webp]] ### **2. 实现新建功能**(时间点 **21:23-58:10**) 接下来把新建这个接口的流程跑通,上次我们知识把查询表格给展示出来,但是表格上的一些功能按钮还不能用,现在去实现一下。 回到接口管理页,把`配置`改成`修改`,删掉`订阅警报`。 ![[wTUfeiNmXuAUAhBj-9ea6c8ab.webp]] 往下滑,当我们点击`新建`,它就会触发一个事件:把模态框打开,它打开的模态框是哪个组件呢? ![[SQTMsE25eChD3n9B-7dfc52f5.webp]] ![[pQx29uKZSorflUcn-aa23722f.webp]] 继续往下滑,它在这已经提供好了一个`新建`的组件。 ![[Efts5ZABJ6uQMh5u-866c2abd.webp]] 但是这里建议大家不要写在这里,要把它单拉出来,就像下图的`更新模态框`一样。 ![[0YR3aYdDLm4tAsWn-b4690dfc.png]] 复制 UpdateForm.tsx,粘贴至 components 目录,并修改名为`CreateModal.tsx`; ![[vv5Vdl8OoaYAOY3R-5f788337.webp]] 然后把里面的内容改一下,因为`更新模态框`的流程很复杂,没必要那么多,把`新建的模态框`代码剪切粘贴至 CreateModal.tsx。 ![[4D3c0qu4BEUSQg3a-246c960d.webp]] ```java { const success = await handleAdd(value as API.RuleListItem); if (success) { handleModalOpen(false); if (actionRef.current) { actionRef.current.reload(); } } }} > ``` 删掉 CreateModal.tsx 原来的模态框。 ![[8q086WTQT3l3KZyD-6993419a.webp]] 把`新建的模态框`代码粘贴进来。 ![[EetKccSZupQQGjNy-f43387b5.webp]] 删掉 CreateModal.tsx 多余的内容。 ```typescript 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; updateModalOpen: boolean; values: Partial; }; const CreateModal: React.FC = (props) => { return ( { const success = await handleAdd(value as API.RuleListItem); if (success) { handleModalOpen(false); if (actionRef.current) { actionRef.current.reload(); } } }} > ); }; export default CreateModal; ``` 让我们观察一下这个组件需要的属性;首先,我们需要在模态框中设定用户需要填写的表单字段。 在使用模态框组件的地方传递一个`columns`,为什么需要 columns 呢? 因为在这个模态框中,会有很多需要用户填写的表单项。然而,这些信息其实完全可以从 columns 参数中获取,例如让用户填写接口名称、描述等,我们没必要重复编写这些信息。 ![[VVtZe55nCx4ST3tC-c6c4d359.webp]] 所以要把 columns 作为属性传递过来。 ![[h2IhXxdaicWg0pi2-1865576c.webp]] 继续编写 CreateModal.tsx。 ```typescript 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[]; // 当用户点击取消按钮时触发 onCancel: () => void; // 当用户提交表单时,将用户输入的数据作为参数传递给后台 onSubmit: (values: API.InterfaceInfo) => Promise; // 模态框是否可见 visible: boolean; // values不用传递 // values: Partial; }; const CreateModal: React.FC = (props) => { // 使用解构赋值获取props中的属性 const { visible, columns, onCancel, onSubmit } = props; return ( // 创建一个Modal组件,通过visible属性控制其显示或隐藏,footer设置为null把表单项的'取消'和'确认'按钮去掉 onCancel?.()}> {/* 创建一个ProTable组件,设定它为表单类型,通过columns属性设置表格的列,提交表单时调用onSubmit函数 */} { onSubmit?.(value); }} /> ); }; export default CreateModal; ``` 现在我们把 CreateModal.tsx 引入到接口管理页,找到 InterfaceInfo 目录下的 index.tsx。 ![[2ilzfrw1hAscYtFM-7d405ef6.webp]] ```java {/* 创建一个CreateModal组件,用于在点击新增按钮时弹出 */} { handleModalOpen(false); }} // 当用户点击提交按钮之后,调用handleAdd函数处理提交的数据,去请求后端添加数据(这里的报错不用管,可能里面组件的属性和外层的不一致) onSubmit={(values) => { handleAdd(values); }} // 根据更新窗口的值决定模态窗口是否显示 visible={createModalOpen} /> ``` 回到前端页面,在接口管理页点击`新建`,弹出的模态框(如下图所示)。 ![[EnsVidgo2lWKU2gH-2325f5e8.webp]] 点击`x`按钮也能正常关闭。 ![[NXi5FOAgiKuChah3-015cd3f0.webp]] 然后这里出现`创建时间`和`更新时间`,这个不用让用户自己填,应该让后台根据当前用户提交的时间自动生成,所以要在表单项隐藏这两个选项。 ![[KjgmIpSTuGbXcO0f-5c54f73e.webp]] 回到前端页面,在接口管理页点击`新建`,弹出的模态框没有了`创建时间`和`更新时间`。 ![[2rhUDrz3z8KKV0K7-23fd754f.webp]] 回到接口管理页修复`onSubmit`,因为它之前生成的代码有一些我们并没有用到,比如管理规则... 所以要修改一下。 ```typescript 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(false); /** * @en-US The pop-up window of the distribution update window * @zh-CN 分布更新窗口的弹窗 * */ const [updateModalOpen, handleUpdateModalOpen] = useState(false); const [showDetail, setShowDetail] = useState(false); const actionRef = useRef(); const [currentRow, setCurrentRow] = useState(); const [selectedRowsState, setSelectedRows] = useState([]); // 模态框的变量在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[] = [ { 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) => [ { handleUpdateModalOpen(true); setCurrentRow(record); }} > 修改 , ], }, ]; return ( headerTitle={'查询表格'} actionRef={actionRef} rowKey="key" search={{ labelWidth: 120, }} toolBarRender={() => [ , ]} request={async ( params, sort: Record, filter: Record, ) => { 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 && ( 已选择{' '} {selectedRowsState.length} {' '} 项    服务调用次数总计 {selectedRowsState.reduce((pre, item) => pre + item.callNo!, 0)} 万 } > )} { const success = await handleUpdate(value); if (success) { handleUpdateModalOpen(false); setCurrentRow(undefined); if (actionRef.current) { actionRef.current.reload(); } } }} onCancel={() => { handleUpdateModalOpen(false); if (!showDetail) { setCurrentRow(undefined); } }} updateModalOpen={updateModalOpen} values={currentRow || {}} /> { setCurrentRow(undefined); setShowDetail(false); }} closable={false} > {currentRow?.name && ( column={2} title={currentRow?.name} request={async () => ({ data: currentRow || {}, })} params={{ id: currentRow?.name, }} columns={columns as ProDescriptionsItemProps[]} /> )} {/* 创建一个CreateModal组件,用于在点击新增按钮时弹出 */} { handleModalOpen(false); }} // 当用户点击提交按钮之后,调用handleAdd函数处理提交的数据,去请求后端添加数据(这里的报错不用管,可能里面组件的属性和外层的不一致) onSubmit={(values) => { handleAdd(values); }} // 根据更新窗口的值决定模态窗口是否显示 visible={createModalOpen} /> ); }; export default TableList; ``` 回到前端页面在接口管理页,鼠标右键选择`检查`→`网络`,然后点击`新建`; ![[0yv3F9j7GjKh858v-3203a9ff.webp]] 显示`名称过长`,但仍提示创建成功(其实是没有创建成功); 因为我们还未全局校验接口返回值,也没有确认其返回的状态码是否为 0。 ![[TobmqG58e0iTrKOU-d98ae365.webp]] ![[K8reCVxYveQsGN3H-6007ca03.png]] 去加一下校验,找到 requestConfig.ts。 ![[1JGn2CuTPmyRu09N-cc97c6e9.webp]] 在全局请求响应拦截器中,添加判断。 ![[3HHqt8rXbIUUyqSl-1b60c441.webp]] 把默认的错误处理删掉。 ![[AbuVsL2Bv1qFStMr-22c61de5.webp]] ```java 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.webp]] --- 🪔 **如何在表单项中添加校验规则**,比如是否必填。 如果你面临这样的问题,你会如何解决? 如何去了解这些校验规则的添加方法? 例如,如果你要自己探索,你会从哪里开始? 其实,解答这类问题很简单。当你面临这类校验规则的问题时,你应该直接查阅官方文档。 --- 来看一下 [官网](https://procomponents.ant.design/components/table),找一下 `Columns 列定义`,列定义有一个 formItemProps:传递表单项配置,你可以配置规则,这个规则就是校验相关的内容。 ![[MRW0dpm9o4hsIXo6-3a4e9899.webp]] 试一下,在接口名称表单项添加 formItemProps。 ![[CQIELvSDYhechJgl-501e0a22.webp]] 前端页面的接口名称表单项左边出现红色的`*`。 ![[Eznlaugu6aztFtEc-1824b1f7.webp]] 直接点击`提交`,发现它是直接发给后台,并没有校验,不靠谱。 ![[bMp7smZhXjxQxeI0-bf1fd1a4.webp]] 在 formItemProps 里增加 rules 进行校验,在 rules 里可以写一个数组来定义多条规则。 ![[gKNYD7ttQeI89pOy-6edd51f5.webp]] 回到前端页面测试一下,测试结果如下图所示。 ![[NDTGLOxgIYP5cvwH-a5c8a2e9.webp]] 然后测试一下能否创建成功。 ![[MjokMx1nZWTd7Scd-7f67d8c4.webp]] 创建成功。 ![[PHiY9l3W63iCkaVq-ab742834.webp]] 点击表单的刷新按钮,在第 2 页可以查看到添加的数据。 ![[gNsrMoSIL8hFq1Zq-5152b46d.webp]] `新建`按钮完成,接下来整一下`修改`按钮; 复制 CreateModal.tsx,粘贴至 components 目录下,并修改名为`UpdateModal.tsx`。 ![[3xWR8roP74k7hLKB-4f7c003d.png]] `修改模态框`无非就是在`新建模态框`的基础上换一个调用方法,来修改一下。 ```typescript 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[]; // 当用户点击取消按钮时触发 onCancel: () => void; // 当用户提交表单时,将用户输入的数据作为参数传递给后台 onSubmit: (values: API.InterfaceInfo) => Promise; // 控制模态框是否可见 visible: boolean; }; // 定义更新模态框组件 const UpdateModal: React.FC = (props) => { // 从props中获取属性 const { values, visible, columns, onCancel, onSubmit } = props; // 使用React的useRef创建一个引用,以访问ProTable中的表单实例 const formRef = useRef(); // 防止修改的表单内容一直是同一个内容,要监听values的变化 // 使用React的useEffect在值改变时更新表单的值 useEffect(() => { if (formRef) { formRef.current?.setFieldsValue(values); } }, [values]); // 返回模态框组件 return ( // 创建一个Modal组件,通过visible属性控制其显示或隐藏,footer设置为null把表单项的'取消'和'确认'按钮去掉 onCancel?.()}> {/* 创建一个ProTable组件,设定它为表单类型,将表单实例绑定到ref,通过columns属性设置表格的列,提交表单时调用onSubmit函数 */} { onSubmit?.(value); }} /> ); }; export default UpdateModal; ``` 把接口管理页的`UpdateForm`改成`UpdateModal`。 ![[jI1yBPGcyOwmcx8x-0fba9c9d.webp]] 修改`更新节点`。 ![[1bv4QlBGVrvsoZaw-380cd9da.webp]] 回到前端页面,试一下更新,点击`修改`。 ![[2Fuz53g8c4oIjG4Y-e123d8dd.webp]] 接口名称改成`阿巴巴巴`,点击`提交`。 ![[RI6FOQ2oyEe7LY03-cb6dda08.webp]] 提示`操作失败`,id 没有传递给后端。 ![[6V3lcptTc1ZvGxYv-a9f1ca50.webp]] ps.本期没有测试更新,所以没有解决更新bug,第三期视频中解决了,这里提前解决掉。 在`更新模态框`输出 values。 ![[5wwRrccQk1xhypG4-611b4d3c.webp]] 回到前端页面,发现有 id,但是为什么没有带到后台呢? ![[CYSMyYoy2412j3BY-87181bfd.webp]] 记得删掉这条打印。 ![[tVFKQoiEcvgUF9Gz-e9d3a626.webp]] 是这样的,我们首先需要理解用户点击提交后,将会触发外部的 onSubmit 方法。 ![[cow2GYHrMDD9nTTP-823c30e8.webp]] 这个 onSubmit 方法中,执行了 handleUpdate,并传递了一个名为 value 的参数,这个参数实际上就是来自内部组件传递的值。 ![[Augh79afE6zYFHgL-2192c42f.webp]] 在内部组件中,我们传递了一个名为 columns 的属性,这个属性是在哪里定义的呢? ![[6sw28IwfcJnGj5fO-ba992a72.webp]] 实际上就是我们在外部定义了 columns 并将其传递进来。 ![[vN8btEKHJFDzaBBR-839f7abf.webp]] 在这些 columns 中,我们把 id 列的属性定义为了index。这样定义的结果是,id 列不会出现在表单项里,也就不会被填入表单。 ![[iIBvjtq3RflC6LmL-e9c60500.webp]] 我们需要保存用户当前点击的数据项的 id,这里有个现成的`currentRow`,直接用这个去取 id 就行了。 ![[4Yh9GTjd0BAGfWkC-535dbd08.webp]] 先把类型换成自己的。 ![[5sTIG8CQtZqPWETt-722fcac8.webp]] 然后修改`更新节点`。 ![[Ur9lw3tYBa7rMldk-34ac2a25.webp]] 回到前端页面,试一下更新,点击`修改`。 ![[3u41q6ZZCz1xl1r0-4c0e7f85.webp]] 把接口名称改成`阿巴巴巴`,点击`提交`。 ![[qsH42S7u7774LqFz-e97ba9a7.webp]] 修改成功。 ![[bwpzhTHHxB6FtaLr-688cdbe6.webp]] `更新节点`搞定,接下来修改`删除节点`。 ![[BSP68Xde0jY2dLDn-4c566b89.webp]] 在表单项增加一个`删除按钮`。 ![[4ZYNswGUon5W5Vkl-266a3648.webp]] 回到前端页面,试一下删除,点击`删除`。 ![[Bet13p37ppMJVRTt-942bd1e9.webp]] 提示`删除成功`,表格内也没有那条数据了。 ![[8FciwvLPM0JeHPWM-114eb02f.webp]] 前端结束🥳 ```typescript 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(false); /** * @en-US The pop-up window of the distribution update window * @zh-CN 分布更新窗口的弹窗 * */ const [updateModalOpen, handleUpdateModalOpen] = useState(false); const [showDetail, setShowDetail] = useState(false); const actionRef = useRef(); const [currentRow, setCurrentRow] = useState(); const [selectedRowsState, setSelectedRows] = useState([]); // 模态框的变量在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[] = [ { 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) => [ { handleUpdateModalOpen(true); setCurrentRow(record); }} > 修改 , { handleRemove(record); }} > 删除 , ], }, ]; return ( headerTitle={'查询表格'} actionRef={actionRef} rowKey="key" search={{ labelWidth: 120, }} toolBarRender={() => [ , ]} request={async ( params, sort: Record, filter: Record, ) => { 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 && ( 已选择{' '} {selectedRowsState.length} {' '} 项    服务调用次数总计 {selectedRowsState.reduce((pre, item) => pre + item.callNo!, 0)} 万 } > )} {/* 之前是UpdateForm,现在改成UpdateModal */} { const success = await handleUpdate(value); if (success) { handleUpdateModalOpen(false); setCurrentRow(undefined); if (actionRef.current) { actionRef.current.reload(); } } }} onCancel={() => { handleUpdateModalOpen(false); if (!showDetail) { setCurrentRow(undefined); } }} // 要传递的信息改成visible visible={updateModalOpen} values={currentRow || {}} /> { setCurrentRow(undefined); setShowDetail(false); }} closable={false} > {currentRow?.name && ( column={2} title={currentRow?.name} request={async () => ({ data: currentRow || {}, })} params={{ id: currentRow?.name, }} columns={columns as ProDescriptionsItemProps[]} /> )} {/* 创建一个CreateModal组件,用于在点击新增按钮时弹出 */} { handleModalOpen(false); }} // 当用户点击提交按钮之后,调用handleAdd函数处理提交的数据,去请求后端添加数据(这里的报错不用管,可能里面组件的属性和外层的不一致) onSubmit={(values) => { handleAdd(values); }} // 根据更新窗口的值决定模态窗口是否显示 visible={createModalOpen} /> ); }; 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.webp]] 新建一个 SpringBoot 的项目,项目名称`yuapi-interface`。 ![[7OuxLoQw7qG9Oi6D-6c0d0373.webp]] 然后选择依赖:SpringWeb、Lombok、Spring Boot DevTools 后,点击`Finish`。 ![[NqpJKnxkEJ9lU5QJ-a5a36508.webp]] ![[MAjQT2LoM8Xh7cNg-bc6eb7da.webp]] ![[QNM0Dg1doLa0Oh3T-48c80547.webp]] - SpringWeb:是 Spring Boot 的一个模块,主要用于创建基于 Web 的应用程序。 - Lombok:是一个 Java 库,可以通过简单的注解来帮助我们消除 Java 代码的模板化,例如 getter 和 setter 方法、构造函数等。 - Spring Boot DevTools:Spring Boot 的一个模块,主要用于在开发环境中提高开发效率。提供了自动重启、热交换、模板缓存等功能。 注意:目前无论是学习还是做项目,都不要用 SpringBoot 3.x 版本。 idea 就开始初始化项目,中间蓝条咻咻地加载。 ![[kpKzkrLO5k9PigFN-a3667444.webp]] 这里选择在新窗口打开。 ![[umTSlFsHyQXOnubP-f19b8554.webp]] 底下的蓝条开始咻咻地加载依赖,等一会。 ![[ddSZXD3ooXJvhBwD-894a3762.webp]] ### 2. 创建模拟接口(时间点 01:00:26-01:07:54) 依赖加载好了之后,我们来提供三种接口给开发者模拟调用,不用纠结这个接口具体要做什么。 新建一个`controller层`:控制层,负责处理用户请求,并根据请求调用相应的业务逻辑,然后返回对应的视图或数据。 ![[IgUKKtEQWWcGZqZR-4d9a2ba6.webp]] 输入包名。 ![[0pmD4xIdEafReCDn-33996f05.webp]] 创建成功。 ![[YeDMaii7mFZ8aENw-27f1abf7.webp]] 假设我们要给开发者提供一个查询自己名字的服务。 新建一个`model层`:数据模型层,负责数据的处理和业务逻辑;在 model 层中,我们经常会为每一个实体或者对象创建一个对应的类。 ![[CGgmWMLc7fxlwHSd-41143a0d.png]] 在`model层`创建一个`User类`,在 User 类写一个用户名属性。 ![[Ks5b2y9folD6D2uu-c9c38b7c.webp]] 在`controller包`下新建`NameController.java`。 ![[g2hBjLHjBd17rM1d-589a04c9.webp]] 在 NameController.java 写三个模拟接口。 ```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.webp]] 在 application.yml 指定后端项目的端口号为`8123`,指定全局接口地址,加一个 api 前缀。 ![[V1b8yPqUXRUiJSBs-978d58cf.webp]] 启动这个项目,假设现在你就是这个开发者,你去调用这个接口 [http://localhost:8123/api/name/?name=yupi。](http://localhost:8123/api/name/?name=yupi%E3%80%82) ![[c9f9M46W3xj2hZmL-04d4501e.webp]] 在浏览器访问它,得到返回结果,搞定。 ![[rPegjCIei6nj93LT-dfadee31.png]] ### 3. 开发调用接口(时间点 01:07:54-01:17:55) 我们已经成功地开发出了这个接口,但对于开发者来说,总不能每次都通过在浏览器地址栏输入接口地址来调用它,对吧?那么开发者通常是如何调用接口的呢?要么在前端进行调用,要么在后端,即我们的后端系统调用你的接口。出于安全考虑,我们通常会选择在后端调用第三方 API,因为这样可以避免在前端暴露诸如密码这样的敏感信息。所以下一步,将演示如何在项目中调用第三方的接口。 #### 调用接口的方式 HTTP 调用方式: 1. HttpClient 2. RestTemplate 3. 第三方库(OKHTTP、Hutool) **本期工具的官方文档:** 1. [Hutool](https://hutool.cn/docs/#/) 2. [Http 客户端工具类](https://hutool.cn/docs/#/http/Http%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%B7%A5%E5%85%B7%E7%B1%BB-HttpUtil) 访问 Hutool 的官方文档,点击`安装`,复制依赖。 ![[DMhevEDKsj31TyHP-44f0b267.webp]] ```xml cn.hutool hutool-all 5.8.16 ``` 粘贴到`pom.xml`后,点击 ![[HaDy38uclj4HPqqG-90ce7432.png]]按钮。 ![[TabzvIbJHNciYP3x-935033f0.webp]] **或** 点击右侧菜单栏的 Maven → 点击 ![[Gi46VbrIc184uVfn-17ed91de.png]]按钮(如下图所示,推荐)。 ![[2NxuQpasLVXS90yE-4cc980c6.png]] Hutool 引入之后,就可以用它各种各样的工具了,在 Hutool 找到`Http 客户端工具类-HttpUtil`,我们就用它来快速的调用其他的 http 请求。 ![[FEJubUioY9ZHfJXw-8b06098e.webp]] 创建一个`client层`:客户端层,负责与用户交互、处理用户请求,以及调用服务端提供的 API 接口等任务的部分。 ![[ZDZRPwMW44VqrFrL-63c18d85.webp]] 在`client层`新建一个客户端`YuApiClient.java`:负责调用第三方接口。 ![[75KHpPH95kt3lkYY-cd49a839.webp]] 现在有三个接口,那在开发客户端时要写三个调用方法,复制 NameController.java 中的三个接口,粘贴到 YuApiClient.java 修改一下。 ```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.webp]] ![[peWN6iUbL7zHEDX0-28a82b4e.webp]] 这里默认支持 get 和 post 的请求,包括怎么传参数,复制代码。 ```java // 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中 HashMap paramMap = new HashMap<>(); paramMap.put("city", "北京"); String result3= HttpUtil.get("https://www.baidu.com", paramMap); ``` 再找一个支持JSON类型的请求,找到`Http请求-HttpRequest` → `Restful请求`,复制代码。 ![[cILAbrrFsXP19d6z-5a62aae4.webp]] ```java String json = ...; String result2 = HttpRequest.post(url) .body(json) .execute().body(); ``` 把复制的两段代码粘贴到 YuApiClient.java 修改一下。 ```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 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 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`,编写测试方法。 ```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.png]]运行按钮。 ![[Kydcgdfh7usyBQsk-b1ddad49.webp]] ps.YuapiInterfaceApplication.java 一定要是启动的状态,然后再启动 Main.java,不然它无法和 建立连接,因为你没启动本地在 8123 端口的项目。 ![[rrBIh5WzMt5dfwpC-6e2fbf74.png]] 测试成功。 ![[sttN66SDvRHn1fU1-7fa74786.png]] ### 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.webp]] 点击数据库中的 user 表,点击左侧选中整行数据,按[Ctrl+C]复制。 ![[mLETbNzOTYbZt6dG-7b75a3da.webp]] ```text 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.webp]] 选中 user 表,鼠标右键选择`Run 'ddl.sql'`。 ![[rWRNlvlkPe0wKhYE-6adadcd6.webp]] ![[9Z3MvwJAkMvMkad1-4d0cbbca.webp]] 执行成功。 ![[quCqnuDav4xeTFOU-39107c59.webp]] 然后点击 ![[wcpr6n9VhcSOmHyl-6bbf61f4.png]]按钮,,新增数据,按[Ctrl+V]将之前的数据粘贴过来; 设置 accessKey 为`yupi`,secretKey 为`abcdefgh`,让创建时间变成默认,然后点击 ![[4ARzDSrvhTarVRKF-74446518.png]]更新按钮。 ![[nGqmt6RVL1jTWQmx-e35295d3.webp]] ![[ksCdN4sq47Cinmjo-6948c5e6.webp]] ps.关于 accessKey、secretKey 的生成方法,大家可以自行编写代码实现。在此就不做演示了,可能在之后会补充这部分内容。(下次一定🐶) --- 🪔 **小知识:** **为什么需要两个 key?**如果仅凭一个 key 就可以调用接口,那么任何拿到这个 key 的人都可以无限制地调用这个接口。这就好比,为什么你在登录网站时需要输入密码,而不是只输入用户名就可以了?其实这两者的原理是一样的。如果像 token 一样,一个 key 不行吗?token 本质上也是不安全的,有可能会通过重放等等方式来攻破的。 --- 接下来回到 yuapi-interface 项目中的`YuApiClient.java`调用。 ![[7g7IFonijhZKBTMQ-c63c1e14.webp]] 按[Alt+ Insert]创建构造方法。 ![[bC5YyH0iLdEnjJj0-6e25fcba.webp]] 全部选中。 ![[h7OWRr9r3iIWwA6h-a95b217a.webp]] 创建了构造方法。 ![[rcgtcX4OyePnlEQW-992ac085.webp]] 在调用 YuApiClient 的地方,把 accessKey、secretKey 拿到,客户端改造完成。 ![[KOtpgExLop4tewdo-48d1ffb7.webp]] 接下来服务端肯定要校验它,以 restful 接口为例进行说明: 我们需要获取用户传递的 accessKey 和 secretKey。对于这种数据,建议不要直接在 URL 中传递,而是选择在请求头中传递会更为妥当。因为 GET 请求的 URL 存在最大长度限制,如果你传递的其他参数过多,可能会导致关键数据被挤出。因此,建议从请求头中获取这些数据。 ![[gfCzKOGOYvkXcwum-d0640e8d.webp]] ![[zY1TiU1900V7S5vR-8543fdcf.webp]] 在实际应用中,我们需要执行的操作是什么?我们应该根据提供的 Key 去数据库中查询,检查此 Key 是否已经被分配过,以及关联的用户是否被禁用、是否合法。现在我们只是简单模拟这一过程。 ```java @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.webp]] 改造一下 YuApiClient.java,发请求可以带上 header,用这个就可以去添加很多的请求头; 这里新建一个函数,用于构造请求头。 ![[x3QeEG7JZyAscNdl-09b9b474.webp]] ```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; 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 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 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 getHeaderMap() { // 创建一个新的 HashMap 对象 Map 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.webp]] 来测试一下发送客户端调用,启动测试类。 ![[IxJNyC5nS86wFJXs-0f77435e.webp]] 我们拿到了 accessKey、secretKey。 ![[xRbSp9sz24QdCQjb-8ba71681.webp]] 按[F7] 或点击 ![[n9mqpdTvXCCHyE9Z-76d42803.png]]按钮,执行下一步; 因为我们设置的和 accessKey、secretKey 一样,所以就验证通过了,能正常返回。 ![[JAz3XjXoRHyLKrWr-876b5181.webp]] 按[F9]或按 ![[3nYSAd1Orxjs9Yn7-50ba49f9.png]]继续执行。 ![[6yOR1IP091Yyyuiw-8c436207.webp]] 如果客户端调用时,随便输错一个,它就不认识了; 把 secretKey 随便改成 ab。 ![[sAONZZQQjABq4GyC-def07003.webp]] 修改后: ![[XZaAnKJdV5QGtAXN-90489d77.webp]] 再启动测试一下。 ![[mXvFaeWa1EEX4A05-4c017b2f.webp]] ![[9XHTKmfAIU1Uqgut-4a19ebef.webp]] 按[F7] 或点击 ![[n9mqpdTvXCCHyE9Z-76d42803.png]]按钮,执行下一步; 判断直接跳到"无权限"。 ![[OproGeg3nmG95NpT-2fdac089.webp]] 按[F9]或按 ![[3nYSAd1Orxjs9Yn7-50ba49f9.png]]继续执行。 ![[7LcPFyz0SswAZpMK-6034c9be.webp]] 记得把随便设置的改回来哦🐶 ![[4hDWI3LWX2Vdncfz-53b42d24.webp]] #### 4.4 安全传递讲解(时间点 01:36:24-01:50:17) 让我们思考一下,现在所做的事情是否存在问题?这种做法真的安全吗?尽管安全性可能略有提高,但是否涉及到破解的问题还不确定。那么现在问题出在哪里?是什么导致的?问题在于我们的请求有可能被人拦截,我们将密码放在请求头中,如果有中间人拦截到了你的请求,他们就可以直接从请求头中获取你的密码,然后使用你的密码发送请求。 需要注意的问题是,密码绝对不能传递。也就是说,在向对方发送请求时,密码绝对不能以明文的方式传递,必须通过特殊的方式进行传递。因此,我们目前的做法是行不通的。绝对不能直接在服务端或服务器之间传递密钥,这样是不安全的。那么,我们应该如何使其安全呢?在标准的 API 签名认证中,我们需要传递一个签名。有同学提到了传递一个许可证,是的,通常我们不是直接将其传递给后台,而是根据该密钥生成一个签名。因为密码不能直接在服务器中传递,有可能会被拦截。 所以我们需要对该密码进行加密,这里通常称之为签名。那么这个签名是如何生成的呢?让我们思考一下,我们可以将用户传递的参数(例如ABC参数)与该密钥拼接在一起,然后使用签名算法进行加密。但这里实际上并不是真正的加密,也可以使用加密算法。 我们的加密算法可以分为:单向加密(md5 签名)、对称加密、非对称加密。对称加密是什么?它是分配一组密钥,你可以加密和解密。还有非对称加密,你可以使用公钥加密,私钥解密,有些情况下也可以使用私钥加密,公钥解密。此外,还有一种单向加密,即加密后无法解密。这种是安全性最高的,大家想一想。 md5 本质上是一种签名算法。例如,百度网盘上传文件时,每个文件都有一个唯一的值。就像 md5,一般情况下是不可逆的,即无法解密。理论上,md5 这种方式最安全。所以我们的思路不是给用户分配的密钥来进行加密。,而是我们将其与用户的参数进行拼接。例如,我们的密钥是 ABCDEFGH,经过签名算法后,最后得到的值可能是 fgthtrgerge。这个值是无法解密的。然后我们只需将此值发送给服务器,服务器只需验证签名是否正确即可。这样也不会暴露密码,因为我们根本不传递密码,而是根据密码生成一个值,然后将生成后的值传递给服务器。 然而问题来了,既然这个值无法解密,作为我的 API 接口,我如何知道你传递的签名是否正确呢?如何判断?这个很简单,我可以再次使用相同的参数进行生成,并与你传递的参数进行对比,看它们是否一致。 有同学问,前端发送请求时是否可以直接加密传输?有一个问题,就是不要相信前端加密。前端的加密是有用的,但是前端的加密不能完全保证安全,所以不要依赖前端。之前说过所有的请求都是可以重放的。也就是说,无法直接加密,无论你如何加密,只要被人拦截了,他们只需使用你传递的加密内容再次发送给后台,结果是一样的。因此,实际上也不安全。我们现在讨论的这种方法还不是很安全,还存在更复杂的问题。 因此,在 API 签名时,需要传递额外的参数,即签名。如何实现呢?通过 HTTP 请求头传递参数。另外,添加时间戳也不能防止重放攻击。那么如何生成签名呢?就像我刚刚提到的,将用户传递的参数以及其他一些内容使用签名算法转换为无法解密的值,并将该值传递过去。这本质上需要使用一个签名生成算法,这是关键。除了这些之外,刚刚提到,既然我们的请求可以被拦截,即使加了签名,如果我将该签名重放一次,后端也无法知道是中间人还是正常用户。 那么我们如何解决这种重放攻击的问题呢?即如何防止他人复制并重复之前发布的请求呢?举个例子,假设你的电脑使用了代理、代理服务,请小心操作。在这种情况下,减少使用一些乱七八糟的操作,尤其是在公司进行开发时尽量少用代理。 --- 💡 **拿知识星球举例:**进入知识星球网站后,鼠标右键`检查`(或按F12) → `网络`; 选择一个请求,点击鼠标右键,就会看到一个选项`重放XHR`,点击`重放XHR`。 ![[rqDSPX2OHdbeX5HA-ac664ffa.webp]] 你会发现这个请求又被重新发送了一次。 ![[ZkLnL5BexwyOgGFB-f674ecfb.webp]] 如果是一个代理软件,他如果想搞你的话,你只要发出请求走了他的代理,他点一下重放就好了。 --- **如何防止重放请求有两种方式可以考虑:** 第一种方式是通过**加入一个随机数实现标准的签名认证**。每次请求时,发送一个随机数给后端。后端只接受并认可该随机数一次,一旦随机数被使用过,后端将不再接受相同的随机数。这种方式解决了请求重放的问题,因为即使对方使用之前的时间和随机数进行请求,后端会认识到该请求已经被处理过,不会再次处理。然而,这种方法需要后端额外开发来保存已使用的随机数。并且,如果接口的并发量很大,每次请求都需要一个随机数,那么可能会面临处理百万、千万甚至亿级别请求的情况。因此,除了使用随机数之外,我们还需要其他机制来定期清理已使用的随机数。 第二种方式是**加入一个时间戳(timestamp)**。每个请求在发送时携带一个时间戳,并且后端会验证该时间戳是否在指定的时间范围内,例如不超过10分钟或5分钟。这可以防止对方使用昨天的请求在今天进行重放。通过这种方式,我们可以一定程度上控制随机数的过期时间。因为后端需要同时验证这两个参数,只要时间戳过期或随机数被使用过,后端会拒绝该请求。因此,时间戳可以在一定程度上减轻后端保存随机数的负担。通常情况下,这两种方法可以相互配合使用。 因此,在标准的签名认证算法中,建议至少添加以下五个参数:accessKey、secretKey、sign、nonce(随机数)、timestamp(时间戳)。此外,建议将用户请求的其他参数,例如接口中的 name 参数,也添加到签名中,以增加安全性。 💡 类似于 HTTPS 协议,签名认证的本质是确保密码不在服务器之间传输。因为任何在服务器之间传输的内容都有可能被拦截。所以,请记住密码绝不能在服务器之间传输。如果只能从本次直播中记住这句话,那也是一种收获。因为很多同学会错误地认为密码可以在前端传输,千万不要这样做,不要在前端调用时传输这些敏感信息。 #### 4.5 安全传递实现(时间点 01:50:17-02:12:06) 刚刚我们的客户端只有这两个参数。 ![[EpWYz7Arq9EhG4Lu-032c20e5.webp]] 现在再加几个参数。 ![[guBX3z7iqaZaIkTQ-b7d654bb.webp]] 我们要把用户参数进行拼接,经过签名算法生成唯一的字符串; 这里使用 Hutool 的加密算法([摘要加密](https://hutool.cn/docs/#/crypto/%E6%91%98%E8%A6%81%E5%8A%A0%E5%AF%86-Digester))。 ![[cM0bD24X7EC13ibG-02a0dcfe.webp]] 新建一个`utils包`,在 utils 包下新建`SignUtils.java`(签名工具)。 ![[3AJ6oPZj0b6W5mjh-d584bb54.webp]] 在 SignUtils.java 编写生成签名的代码。 ```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 hashMap, String secretKey) { // 使用SHA256算法的Digester Digester md5 = new Digester(DigestAlgorithm.SHA256); // 构建签名内容,将哈希映射转换为字符串并拼接密钥 String content = hashMap.toString() + "." + secretKey; // 计算签名的摘要并返回摘要的十六进制表示形式 return md5.digestHex(content); } } ``` 在客户端继续编写代码。 ![[myUqI2FD2DM0QGFK-034d7234.webp]] ```java /** * 获取请求头的哈希映射 * @param body 请求体内容 * @return 包含请求头参数的哈希映射 */ private Map getHeaderMap(String body) { Map 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.webp]] ```java @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.webp]] 这个 hashmap 还需要进行拼接,我们传递的是用户的这些参数,但其实没有必要传递那么多参数,直接将 body 作为参数传递进来(在这里,我们也可以传递 hashmap,只要有一些共同的参数,能让客户端和服务端之间保持一致即可)。 ![[TvHC6OSm7Fm9lSgc-f54532b8.webp]] ![[kEOvbmhVYJdcvPCy-e62e367e.webp]] 然后服务端校验时无需拼接 hashmap 了,直接传递 body 即可。在服务端,我们可以轻松获取到 secretKey,因为 secretKey 是由服务端签发的,所以服务端肯定有这个信息。当我们从数据库中查询是否分配给用户时,可以同时将与用户相关的 secretKey 一并查询出来,这样就可以在校验过程中使用了。 ![[hToqmonmriRZOsMR-04a4a643.webp]] 举个例子,假设服务端从请求头中获取了 accessKey,那么我们可以使用该 accessKey 在数据库中查找对应的记录。通过查找,我们可以获得相应的 secretKey,将其作为签名的密钥进行签名操作。 打个断点。 ![[NYlk4E0AyALRRz38-0a4f5e28.webp]] 以`debug模式`重启后端项目。 ![[AEXxAQKUPwLSeZno-ef21492f.webp]] 现在客户端去调用一下,启动 Main。 ![[TfiSAA9Rw0MUxIgA-456bfaee.webp]] 按[F9]继续执行。 ![[mtX1oTVXVJiJGEcA-2dc47791.webp]] 发现执行到这一步时报错了。 ![[eZEDiX4nxE5SFMAY-e39fa8dd.webp]] 而且测试的时候 body 还乱码了。 ![[qaHatXZL0WG0F0Lt-ab4f1d7a.webp]] 因为这里我们用了中文,hutool 使用中文会乱码。 ![[8auDI7oXR1wlwrhs-504197ec.webp]] 为了节省时间,直接改成英文`liyupi`。 ![[9plNxmMShROBpsLf-bdf18ef0.webp]] 以`debug模式`重启后端项目。 ![[AEXxAQKUPwLSeZno-ef21492f.webp]] 启动 Main。 ![[TfiSAA9Rw0MUxIgA-456bfaee.webp]] 这次就没有乱码了。 ![[Dy9FRN2auLLh2zIT-f75777df.webp]] 按[F9]继续执行,这次就调用成功了,不是无权限了。 ![[WxsLIKWNU8gG4jHC-59c6aadd.webp]] 假设张三是一个攻击者,张三并不知道密码,所以张三会随便传递一些值作为密码,然而,由于张三不知道正确的密码,张三传递的密码与服务端生成的签名所使用的密钥肯定不一致,所以无法执行有效的操作。因此,客户端生成的签名和服务端生成的签名是不同的。这是因为客户端和服务端使用的密钥不一致导致的。 把客户端的 secretKey 改成 abcde。 ![[xl8vIW20BqNOlJwU-3485e197.webp]] 以`debug模式`重启后端项目。 ![[AEXxAQKUPwLSeZno-ef21492f.webp]] 启动 Main。 ![[TfiSAA9Rw0MUxIgA-456bfaee.webp]] 按[F9]继续执行,抛出异常,并提示"无权限"。 ![[9syjHTMJDB7cHAFe-273978de.webp]] 整个签名认证算法的流程就是这样。需要强调的是,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.webp]] ![[tPFENNxqO0oBdOkz-d9151740.webp]] **还记得我们使用 starter 之后,我们有哪些好处?**比如,对于 Redis 的 starter,我们可以直接在 application.yml 配置文件中进行相关配置。我们可以在配置文件中简单地定义一个连接到 Redis 的配置块,或者对于 Swagger 接口文档,我们也可以在配置文件中进行相应的配置。这样做的好处是,我们无需手动编写繁琐的配置代码或者创建客户端实例。通过引入适当的 starter,我们就可以直接使用它们提供的代码和客户端。只需在配置文件中进行简单的配置,整个过程就自动完成了。 这正是 starter 的作用所在。使用 starter 的好处就是,开发者引入后可以直接在 application.yml 中进行配置,自动创建相应的客户端。这样使得开发过程更加简单便捷,无需过多关注底层实现细节,而是专注于配置和使用。所以接下来就带大家来做这件事情,让我们编写的 starter 能够为开发者写配置时提供提示,并自动创建客户端(如下图所示的**提示**)。 ![[MT0JvzEMRjvZHe3k-afd8ee32.webp]] #### 5.2 Starter 开发流程(时间点 02:16:03-02:31:56) 我们首先创建一个新项目 —— 项目名:yuapi-client-sdk; 选择 idea 左上角 File → New → Project。 ![[cRLvXXDKMuLXWRc9-306d5e7d.webp]] 创建 Spring Boot 项目。 ![[Y0aapXGrd1qoQKmV-95fad3ad.webp]] 然后选择依赖:Lombok、Spring Configuration Processor 后,点击`Finish`。 - Spring Configuration Processor:它的作用就是帮助开发者自动生成配置的代码提示。 ![[QJ1lt8n6rqFynNaO-b2ed0264.webp]] ![[xbDjzrYc4xdMxlP9-b92f2ead.webp]] idea 开始初始化项目。 ![[ruQUXaZSNiC6I34v-6df1dd1d.webp]] 这里选择在新窗口打开。 ![[4ibEpy1ikROA3iYt-14b3c3fd.png]] 选择左上角`File→settings`。 ![[aY3NPPrXY7VV0Lri-8e38efec.webp]] 修改 maven 的设置,我这里默认会变成在 C 盘。 ![[P1Q40yCjZPnMy731-fdc41629.webp]] 底下的蓝条开始咻咻地加载依赖,等一会。 ![[KmuxXWXYClUHmR52-d8a231be.webp]] 首先来看这个项目的依赖,查看 pom.xml; 既然写 SDK,肯定是有版本号的。 ![[VK6kvOYW0ytMBvyF-1cebd9c6.webp]] 改成`0.0.1`(改成多少都可以)。 ![[pDgncP9YUcxuhM1M-1340d034.webp]] 往下看,这个测试可以去掉,不去掉也不影响运行(这里就不去掉了)。 ![[vI8HJyXRUde1D8Ne-6dd27074.webp]] 往下看,下面这个东西要删掉,不然会报错(**一定要删掉**); 这个是 maven 构建项目的方式,我们现在是要构建依赖包,而不是直接运行 jar 包的项目。 ![[tU0hcrA7m5sEjphZ-c4b132be.webp]] 在创建完这个项目之后,默认会生成一个 Spring Boot 的主类。然而,我们并不打算运行一个 Web 项目,而是提供一个现成的客户端对象给用户使用。 ![[nvx4LZMkeqTvd9ei-9885bbef.png]] 现在我们的目标是为用户生成一个可用的客户端对象。刚才我们是如何生成的呢?我们手动创建了一个新的对象实例。而现在,我们希望用户能够通过引入 starter 的方式直接使用客户端,而不需要手动创建,所以我们需要编写一个配置类。 删掉主类,创建配置类`YuApiClientConfig.java`。 ![[4PiGvBSdNWneabte-70b45260.webp]] 在这里打上几个注解,加上两个参数。 ```java 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.webp]] @ComponentScan 这个注解是做什么的呢?Spring Boot 项目是不是都有一个@SpringBootApplication注解(拿 yuapi-interface 项目举例说明)。 ![[Dnbj4Wx2ov6UIaaD-4778b5bb.webp]] 按[Ctrl + 鼠标左键]点进去,这个注解帮你封装了很多注解,比如:ComponentScan。 但是如果你不用这个注解,就要自己去写这个扫包。 ![[6tAEVsUYHfWZ0bdf-7ccf817c.webp]] 现在我们要给用户提供 ApiClient,把 yuapi-interface 项目中的 client包、model包、utils包复制。 ![[dTIRUAoE9msW3Xuu-2f4d295a.webp]] 粘贴到 yuapi-client-sdk 项目中。 ![[5N4LIvcmY6g0UWe2-51867eda.png]] 复制 yuapi-interface 项目中的 hutool 依赖。 ![[VGrO3onxkzuR0Fby-e5991298.webp]] 粘贴到 yuapi-client-sdk 项目中。 ![[HYGpeBOx33fj2fjf-42eed98e.webp]] 然后给 yuapi-client-sdk 项目 中的`YuApiClient.java`重新引入包,删掉多余的注解。 ![[2lTumRIk8iituCce-c7cb70bf.webp]] ![[AMbqAJHGGbcSJgFp-7cc5a7b2.webp]] ```java 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 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 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 getHeaderMap(String body) { Map 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.webp]] 我们已经完成了实现。现在,如果我们引入这个库并配置好了 accessKey 和 密secretKey 的相关属性,Spring Boot 将自动为我们生成一个名为`yuApiClient`的对象。然后,我们可以在项目中使用这个对象了。等会儿我们来演示一下怎么使用。 做到现在这样还没完,还要做一件事情; 在 resources 目录下创建一个目录`META-INF`(注意要大写,小写没试过行不行🐶)。 ![[g5NSYsBiPt8059Jj-3e808392.png]] 在`META-INF`目录创建一个文件`spring.factories`。 - spring.factories:定义了 Spring Boot 的自动配置。它是一个标准的 Java Properties 文件,用于指定要自动配置的类。这个文件中的每一行都是一个配置项,包含两个部分:配置项的全限定类名和对应的自动配置类。它们之间使用等号(=)进行分隔。在 Spring Boot 应用启动时,它会加载这个 spring.factories 文件,并根据其中的配置项自动进行相应的配置。 鼠标右键`META-INF`目录,选择 New。 ![[KsK0vfHQFRqHdYBS-84a22580.webp]] 输入文件名。 ![[kb8pQY0hCh0W7bcE-b0082f4d.png]] 创建成功,同时也被识别成功,文件名前面会出现小图标。 ![[8nKR3obQ4yrnVhwB-06260687.png]] 在 `spring.factories`文件内编写配置项为自动引入配置的类。 ![[26ZAkGx6YVKJr6Ax-ad76798f.png]] 然后指定为我们的配置类,这样它才能加载到,它一定要被 Spring Boot 能识别出来的; 鼠标右键 YuApiClientConfig.java。 ![[5KpGw1N0H2n21Mtc-c6bcf395.webp]] 复制配置类的路径。 ![[Jon1UT4Hy3QyAYGY-f3566553.webp]] 粘贴到`spring.factories`文件中。 ![[LeGzUzkBdbfDmNNG-5b71fa26.png]] ```text # 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.webp]] 点击 Lifecycle → install,把它安装为本地的依赖。 ![[WeiTw64t6g6yncAU-711ef535.webp]] 这里会报错。 ![[KpJzlejAkVXx23cR-10942cc0.webp]] 因为它执行了这个测试,我们之前把主类删掉了,所以它执行不了了。 ![[l7vMoUnv7buz00ht-25c61e3c.webp]] 可以选择在这里删除该测试或将其直接过滤掉,这里我们就直接删掉。 1. 删除:鼠标右键,选择 Delete。 ![[GM1LhE5VgjNsiebS-4fc3a728.webp]] 1. 过滤:点击 ![[J0VGmVl4VDSq4V7U-43dd5434.png]]过滤按钮。 ![[8Hit5Xotio4b2FOR-a7a59e2d.webp]] 再执行一次 install。 ![[MAtmplRanKE2al7r-a35a6bc3.webp]] 无需刻意去记这些东西的写法。写过一次之后,如果要再次写类似的"starter",可以直接将这个项目作为初始模板使用。 现在已经打包成功,成功之后,就可以测试一下能不能用了。 ![[qPIpI8g5NBOjsRdE-e97f82cb.webp]] --- **💡** **打包后的依赖包在哪里?** 你设置的 maven 仓库在哪里,包就在哪里。我这里设置的 maven 仓库在 D:/星球项目2/maven-res。 ps.如果没有设置 maven 也可以查看 Local repository 的路径位置,去查找依赖包。 ![[Qr2bvGhQbpjQdaj2-8b5d4e9e.webp]] 根据 yuapi-client-sdk 项目中 pom.xml 的`groupId`,说明这个依赖包在 D:/星球项目2/maven-res/com/yupi 下。 ![[UU56YHgvS7TVs7u2-00537cae.webp]] 根据提示,先进入 com 目录。 ![[ihaSb7oGTfGBedX4-250f6e4a.webp]] 然后进入 yupi 目录。 ![[9On4g6KGnNjVK9in-7ebbcd51.webp]] 就看见我们打包好的依赖包了。 ![[tUQIEBCu7PA8KLpq-39f0a272.png]] 如果你想给别人使用的话,要去 maven 仓库官网注册一个账号,然后上传上去。 --- 这里就不新开一个项目了,来到 yuapi-interface 项目,把之前的 client包、model包、utils包、Main.java 全删了。 ![[S34gy9ZANXqJyF73-924c3e89.webp]] 然后去引入我们写好的依赖,回到 yuapi-client-sdk 项目复制依赖名称。 ![[emq56y9yTK8O3uOQ-0b8e9317.webp]] 粘贴到 yuapi-interface 项目中的 pom.xml。 ![[GgfHbXrqzjlaCeC2-2c6250c3.webp]] 加载依赖后,没有报错,说明我们现在已经引入这个客户端了(版本红色没关系🐶)。 ![[AA2FgsH1HrkffMhL-15a11769.webp]] 点击 application.yml 进行配置,依次输入 yuapi,弹出提示,它现在识别出来了我们另外一个项目中写的配置。 ![[yAsI1f1WLzRQJLwY-12647c75.webp]] 选择第一行。 ![[KWvkIQuXsSXLiueK-514c52b5.webp]] ![[0VGS8dWI70TZoowf-095b231b.png]] 指定 accesskey 为`yupi`。 ![[wm6Td4fbl2aN3phd-5e2e9dfc.png]] 然后指定 secretkey 为`abcdefgh`。 ![[qTYwRf26sOUSgh1v-412097c4.webp]] 来到 NameController.java,这里有一些报错。 ![[T909yeDZoywSFsF3-81d0c897.webp]] 引入 yuapi-client-sdk 包里的。 ![[2hmF1vVmZLaElVPK-dee15fe4.webp]] 然后在这里写一个测试类。 ![[uAiekfu2PexxkMaE-8698d039.webp]] 编写测试方法。 ```java 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.png]] 启动测试。 ![[6XTpkofiQYQ8qZtZ-dbc1b5d4.webp]] 成功调用,得到了响应,也得到了用户名。 ![[YlVrURrgRqVbgFEF-fbfee7a4.webp]] 所以现在只要把 secretkey 改一下。 ![[x3mO3sByuzo7ylgk-37f9d8ec.webp]] 再运行测试,就调用不通了,500 了,因为 secretkey 不对。 ![[ZReE7GAhoW9sbZlS-546f8a9b.webp]] 记得改回正确的 secretkey。 ![[1YHcmB3cAw4CdxOU-af84cd00.webp]] #### 5.3 Starter 总结(时间点 02:31:56-02:37:29) 大家认为开发一个 starter 难吗?其实并不难,关键步骤只有几个。首先确认所需依赖。然后写 META-INF,指定配置注册类。 ![[Y5GTUjSYEsjzwfdo-30cd83df.webp]] 在这个地方就是通过这个注解来读取到这个配置信息。 ![[QYT3ozxG1kgASj2d-1a43dd31.webp]] 有没有觉得很方便?现在,无论是我的任何一个项目,只要引入这个包,也就是这个 starter,只需要引入这个SDK。 ![[SYwTfyMGrbgkyFaF-d23fd78c.webp]] 然后在这里填写开发者账户和密码。 ![[8Ilp2Vs5kowDMOqK-752e63f5.webp]] 就可以直接在需要调用的地方引入这个客户端。 ![[1R1k2Oo9iWH5XJNN-b5da5778.webp]] 关于签名等细节,不需要担心,作为开发者,不需要关心这些,这正是 starter 的优势所在。 至于它是怎么实现了注解,就是这个配置的提示,简单地给大家说明一下。 ![[ZHsJlLwytHQiN2d0-28781756.webp]] 我们可以看一下引入的这个包里面都有什么,之所以能够在写配置时看到提示,就是因为这个 spring-configuration-metadata.json。 ![[WjcUBjEDJMjqCKQT-52b42b23.webp]] 这东西怎么生成的呢?其实就是这个库生成的。 ![[9MBOmldASkSLDdlj-a93478a0.webp]] 通过之前的解释,大家应该已经明白为什么能够自动生成这个配置了。原因在于在这里定义了配置的名称、类型以及对应源码的位置,同时还有传递给源码的配置类在哪里。 这一期的内容,是🐟改了五百多份简历,从来没有见过有同学写过在简历上写过开发过 starter 的经历。🐟个人认为,在简历上写开发过 starter 的经验绝对是加分项。希望大家学完这个项目后,可以多开发这种类型的项目,尤其是将其发布到 maven 仓库中,这绝对能够给自己加分。如果大家之前有开发过项目,写过一些工具类,都可以尝试将其转化为 starter。starter 的好处是我们不需要手动创建客户端、编写配置文件,还有代码提示的功能。 ## 六、小作业(时间点 02:36:00-02:36:31) **怎么把 starter 分享给别人?** 1. 把 jar 包给别人 2. 把打好的包发到 maven 仓库中 --- **项目分区导航**:⬅️ [[10-项目笔记(三)|10-项目笔记(三)]] | 11-项目笔记(二) | ➡️ [[12-项目笔记(五)|12-项目笔记(五)]]