02

1. 项目概述

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

2. 本期时间点

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

657c17e9dd874b81af77fe4053cf6325

3. 本期计划

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

4. 前端项目开发

1. 优化前端页面

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

先删掉路由,找到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

ee1cf9211d2849e68a6bcb0cff70e9c6
c6f24829caf9446c9447437973886aa1

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

7d8be300d46a4ff383a5c13ca82e9d02

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

6e431e3f2af249c3a56151ba6da6fbac

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

12b0b2e0a7ba4b8f97b8fca736667cbc

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

38f05a5127ba4fb9a8e3ed18d9201755

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

1d0391f8b29940929882df1be9bb779d
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/ 进行登录;

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

f5aff41bf29d46f1a59fc09037cdba16

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

3a223ae5b9334a14a94bd4fd0d4589ece23835a1c59d4c6793abb875c77f4a41

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

982a21dd35894bd9ad87150006e11eb9

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

64e3ca9b13784067b71e840c5798ce3b

2. 实现新建功能

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

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

5168f0ee36c444409b4dbeb81eb14645

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

56e2d368cbc74264ae3fe824b7f5d6ed

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

e341d189878f4e06894cc211f63830ba

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

96004458f9124b8dbb9ce334151eded1

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

db1a8de96caa4707b579040281a3616f

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

6bcbffae596e45498b453a24ab59dd17
<ModalForm
  title={'新建规则'}
  width="400px"
  open={createModalOpen}
  onOpenChange={handleModalOpen}
  onFinish={async (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 原来的模态框。

666f0dfcb607405d9628750df58b71f1

新建的模态框代码粘贴进来。1811757695501524994_0.18934427647998908

644fc4d3a488426a9283ee14a50962b2

删掉 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}
      onOpenChange={handleModalOpen}
      onFinish={async (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 参数中获取,例如让用户填写接口名称、描述等,我们没必要重复编写这些信息

7577412179c949aea227f7ec030e8d10

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

5cfe81571fe745c79796fd838c7ef993

继续编写 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={() => onCancel?.()}>
     {/* 创建一个ProTable组件,设定它为表单类型,通过columns属性设置表格的列,提交表单时调用onSubmit函数 */}
      <ProTable
        type="form"
        columns={columns}
        onSubmit={async (value) => {
          onSubmit?.(value);
        }}
      />
    </Modal>
  );
};
export default CreateModal;

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

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

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

b7e524ba06914d4299c37751673b14ae

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

e951210ac8e340b185884faaff2b97ff

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

81ba932f2cb148a387fdf29e42ad7bde

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

899526fc4c2a4f4eac10974e7cfd1d71

回到接口管理页修复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"
          onClick={() => {
            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"
            onClick={() => {
              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
            onClick={async () => {
              await handleRemove(selectedRowsState);
              setSelectedRows([]);
              actionRef.current?.reloadAndRest?.();
            }}
          >
            批量删除
          </Button>
          <Button type="primary">批量审批</Button>
        </FooterToolbar>
      )}
      <UpdateForm
        onSubmit={async (value) => {
          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 || {}}
      />

      <Drawer
        width={600}
        open={showDetail}
        onClose={() => {
          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以隐藏模态窗口
        onCancel={() => {
          handleModalOpen(false);
        }}
        // 当用户点击提交按钮之后,调用handleAdd函数处理提交的数据,去请求后端添加数据(这里的报错不用管,可能里面组件的属性和外层的不一致)
        onSubmit={(values) => {
          handleAdd(values);
        }}
        // 根据更新窗口的值决定模态窗口是否显示
        visible={createModalOpen}
      />
    </PageContainer>
  );
};
export default TableList;

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

e0de57a59c1a4041a4298918eb5c97f4

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

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

f6acd971cb32464d90fba40a34ef6404

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

252e105cc3bf40d1a30f392edac8fc83

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

7eef92c44a7e4f27aece9a1cd6542392

把默认的错误处理删掉。

c4968dcd895b48a18335ce60cccb8df1
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,重新启动后端项目。

8d43248528df47f498dce9d342927da0

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

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

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

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

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


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

a22de976e3a5471db624ae07249aa453

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

07c8aa9378804e33b5ff872897fa62e7

前端页面的接口名称表单项左边出现红色的*。1811757695501524994_0.3035291077169595

e000e3a122864ad3b713f932f12876b2

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

7c608214e61a4963940506e642c41e9e

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

61895e898759488aa26e80d88ae641d8

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

85c3d134d87349538c9085ec657b971f

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

c7ee6393120b4afc9c2fc69aa7610c3f

创建成功。

46cb19695537468597d2eac1f5a637ae

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

f33ad0fd91c747ffa006876e4f877eac

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

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

fc89434f2e35493ab4381f99da864f5b

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

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={() => onCancel?.()}>
      {/* 创建一个ProTable组件,设定它为表单类型,将表单实例绑定到ref,通过columns属性设置表格的列,提交表单时调用onSubmit函数 */}
      <ProTable
        type="form"
        formRef={formRef}
        columns={columns}
        onSubmit={async (value) => {
          onSubmit?.(value);
        }}
      />
    </Modal>
  );
};
export default UpdateModal;

把接口管理页的UpdateForm改成UpdateModal

a91cae2f09314dc28fbd2ebd84840f2a

修改更新节点。1811757695501524994_0.42349356181898967

f4f571ff46704e8b96f242c2f75067e5

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

3fcd74cd496f412780706b20b4e4bc7a

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

4ea6a96f4c9a4fc18cb0c86a04c84529

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

bf18b7e64c294a44936ae9b4d0a4f537

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

更新模态框输出 values。1811757695501524994_0.7734205278868294

7f6d10ecc9f74a39ac146f4814f62209

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

f5298186f9a045fcb0539a4de8a5caea

记得删掉这条打印。

5b2faa24418b456ba9ed2bc6778c3081

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

2067e1f34a3a402a984cc8dadd3b55b1

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

3b3bdeedfc604e7db18c41a228c72d34

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

168891e3f5024d4ca19239e4edc75784

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

433f4a14119844ceb91c45293cac5b30

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

3db6c806df9943bea4b2ff96b2dc3249

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

76ecbe4475bd4b2992a00792f73c4e7a

先把类型换成自己的。1811757695501524994_0.6697944622936014

c1f8d890eaad4ce881d6bc256202cf0b

然后修改更新节点

706d4273d899490f9f582244ed07d0c7

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

3961dc71337a4e6490d38b4c3c5d4a34

把接口名称改成阿巴巴巴,点击提交。1811757695501524994_0.5670491967490847

93fface7d7ec4e6db08393b8a003d4d3

修改成功。

acd9924af62c4a5dba7547738409545b

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

b8dd6075f92d4f669edf3ba5f480845e

在表单项增加一个删除按钮。1811757695501524994_0.479859262947848

273cd31edd9a498486c856c7d743ed70

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

21ac0d6a09464b728bd15605384f4afc

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

a2ab524afa96490da95874144a667452

前端结束🥳1811757695501524994_0.8314087684947093

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"
          onClick={() => {
            handleUpdateModalOpen(true);
            setCurrentRow(record);
          }}
        >
          修改
        </a>,
        <a
        key="config"
        onClick={() => {
          handleRemove(record);
        }}
      >
        删除
      </a>,
      ],
    },
  ];
  return (
    <PageContainer>
      <ProTable<API.RuleListItem, API.PageParams>
        headerTitle={'查询表格'}
        actionRef={actionRef}
        rowKey="key"
        search={{
          labelWidth: 120,
        }}
        toolBarRender={() => [
          <Button
            type="primary"
            key="primary"
            onClick={() => {
              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
            onClick={async () => {
              await handleRemove(selectedRowsState);
              setSelectedRows([]);
              actionRef.current?.reloadAndRest?.();
            }}
          >
            批量删除
          </Button>
          <Button type="primary">批量审批</Button>
        </FooterToolbar>
      )}
      {/* 之前是UpdateForm,现在改成UpdateModal */}
      <UpdateModal
        // 要传递 columns,不然修改模态框没有表单项
        columns={columns}
        onSubmit={async (value) => {
          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 || {}}
      />

      <Drawer
        width={600}
        open={showDetail}
        onClose={() => {
          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以隐藏模态窗口
        onCancel={() => {
          handleModalOpen(false);
        }}
        // 当用户点击提交按钮之后,调用handleAdd函数处理提交的数据,去请求后端添加数据(这里的报错不用管,可能里面组件的属性和外层的不一致)
        onSubmit={(values) => {
          handleAdd(values);
        }}
        // 根据更新窗口的值决定模态窗口是否显示
        visible={createModalOpen}
      />
    </PageContainer>
  );
};
export default TableList;

5. 后端项目开发

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

模拟接口项目

项目名称:yuapi-interface

提供三个不同种类的模拟接口:1811757695501524994_0.5671123956725748

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

1. 新建后端初始化项目

来到后端,点击左上角 File → New;1811757695501524994_0.2525698808077461

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

2131cd2fd54742a5ac5324ca195a7425

新建一个 SpringBoot 的项目,项目名称yuapi-interface。1811757695501524994_0.03462697609291876

a309eb2e084045fbbd1e767ea3a4bb36

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

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

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

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

61d7ea0bcfff42ef9b87bea42855b2bc

这里选择在新窗口打开。

0fec92d915734675a964cfdb9aefaaa7

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

309b9567c81f477388bc4eccc28152db

2. 创建模拟接口

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

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

7d8d6c0c178743eea526d382de4a0aa1

输入包名。1811757695501524994_0.8192758552675108

464be6459136447c928bed0e8a4c422c

创建成功。

d6a736566cc147b480305058aea85126

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

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

8779a20d44724341ab510dc653049ec0

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

72438235745e4aeebbcf7e8c9e94f431

controller包下新建NameController.java。1811757695501524994_0.6933162531548276

5ac0e410f96e49d3bb9b4953a6e48846

在 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 格式会更精简一些。

3205df697a074a60ab2ccbec026a04bf

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

7b646ceecf92406b931748a72e5f3957

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

d7ce9fa18750448a89dfc450bbb74bdc

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

6e615a8a4dd94c22b96c3d6d2094a9e7

3. 开发调用接口

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

1. 调用接口的方式

HTTP 调用方式:1811757695501524994_0.1798036450517453

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

本期工具的官方文档:

  1. Hutoolopen in new window
  2. Http 客户端工具类open in new window
  3. 1811757695501524994_0.8257798836270303

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

517e328a07a944f98b43e4c8970b7f61
<dependency>
  <groupId>cn.hutool</groupId>
  <artifactId>hutool-all</artifactId>
  <version>5.8.16</version>
</dependency>
62ed841cad494e05bd8edfd69c73c6df27775bb3d67f451c9791a512861e8cdc6946719928fa40f7b35fe20008eb1ce90183583140414a15b6a26bda7622c41a

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

b01cb10d897d407795ec20bcf0065e96

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

d71333b85ac04f6c95997f4c17e6af73

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

3811657179b040738a313b81fba0a3e8

现在有三个接口,那在开发客户端时要写三个调用方法,复制 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) {

    }
}

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

d77e9c7db3294690afb3a9277dbfacd4

这里默认支持 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请求,复制代码。1811757695501524994_0.7575183480628009

65b06b3e19b6434fb436ee0e8542e3c2
String json = ...;
String result2 = HttpRequest.post(url)
    .body(json)
    .execute().body();

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

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

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

2e6ce08dd47542e2ba88be5075a69f56

测试成功。

057bcf2e4e324b12b92ac35830e57f16

4. API 签名认证

1. 详细说明

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

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

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

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

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

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

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

2. API签名认证总结

本质:

  1. 签发签名
  2. 使用签名(校验签名)
  3. 1811757695501524994_0.013459079508099325

为什么需要?

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

签名认证实现1811757695501524994_0.8845064626738413

通过 http request header 头传递参数。

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

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

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

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

**加密方式:**1811757695501524994_0.32313510449174365

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

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

abc + abcdefgh => sajdgdioajdgioa1811757695501524994_0.3466784964204235

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

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

**怎么防重放?**1811757695501524994_0.23057176964688608

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

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

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

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

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

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

3. API签名认证实现

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

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

a45267dad6f147b2af828f7587b30461

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

fc73948f6ec14e6bbfd5bb082980723d
▼bash

复制代码1,老谷牛,yupi,https://yupi.icu/logo.png,,admin,b0dd3697a192885d7c055db46155b26a,2023-04-30 22:45:10,2023-05-14 11:37:02,0

把原先的 user 表删掉。

8a57a8ef625b4d36943f9ef67ba50b64

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

eda904db150246888f48f1d88fe1a672

执行成功。

0c05f5fe3a744e4c96f58503e0235fe3911bfe5d26364faeaca70a4bde3afb1ac9885440c52e446daa57a6e6e823b103243feb5be35044caa647afe028315c97

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


🪔 **小知识:**1811757695501524994_0.6497649185540055

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


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

d084b03a8c1142898481ce3ef5b3bb7f

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

de52d8887fab40bab36f687a8e2bb17c

全部选中。

db00c7a3553541df8a6964fd16301903

创建了构造方法。1811757695501524994_0.5673784671238946

ce9678a0e8a8405d9f5d2689d32d952f

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

68901c6f262c4e5694d3aa6b1eaa6b4e

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

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

b1c0994db9784bdfbc7901a00641b60f

在实际应用中,我们需要执行的操作是什么?我们应该根据提供的 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 模式重启项目。

447d60a0f9364f4382c8934d7ec4cf57

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

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

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

在这里打个断点。

07118269fa534731a1e84a5d6080dfd5

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

2121a3f9ae9c48659cdf8078f2275702

我们拿到了 accessKey、secretKey。

de79ac3777484884b233df16051ad3bf3029503266be4303904ef6b75fe164fa

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

4e3833dafa73429c94816b746d13626d93c1437d14cc4c4d8641bf3fc0f590c1a21bf150de254264a16627df80391c74

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

把 secretKey 随便改成 ab。

133ed2da555e4f0598762dd6650b9424

修改后:1811757695501524994_0.9248580418053756

f8904921acff45cca71294b1c2013e93

再启动测试一下。

da61c55840b14e06ba8ba423a287e861d88205fd8926463f8b42f0b1829b3918

判断直接跳到"无权限"。1811757695501524994_0.6578557479878877

6ae73df100f34a75b642aabedd33ba838c86a613efb3427caab5bb196c4f05f96fc92fe7d4454f9e939d9b3d1597952f

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

c5e3abc7d7fd4c2ea784e45e6836e711

4. 安全传递讲解

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

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

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

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

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

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

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

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

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


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

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

f7f3815dadbd496fb212e3df1d447f3a

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

f6a0af89b186429b98ac8d6bd3939a5b

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


**如何防止重放请求有两种方式可以考虑:**1811757695501524994_0.309283469870812

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

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

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

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

5. 安全传递实现

刚刚我们的客户端只有这两个参数。1811757695501524994_0.626160924191157

e8e50af0dd944bf6a54b03668e306304

现在再加几个参数。

3b6506a06c064319ac12ad8bf694ab1e

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

这里使用 Hutool 的加密算法(摘要加密open in new window)。

8fb8f84bba634d0b92b859deef336a07

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

96a9b9c65a524fb98de9fd56adca23d5

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

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

在客户端继续编写代码。

4c22a5eb39b54e60a0ccc1e72a007ea2
/**
 * 获取请求头的哈希映射
 * @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;
}

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

d340e543815c49738a982f395084cdcc
@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)。1811757695501524994_0.8683091965643523

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

be2b29853e9646949a497efdce408b8b

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

dc921ba075834376a262f7236d16d2a0

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

897d39595c1149f9b3c74698f4b6934e

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

打个断点。1811757695501524994_0.6434513691512727

a2973ea008734bf2b4df807fa1eda16d

debug模式重启后端项目。

71ce076fd6004f50a7c93423148e09c2

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

bb0fb5ee691a4be6af84c3c7f6d62bc5

按[F9]继续执行。1811757695501524994_0.08375432021926144

63e37c53ba6d454da4aa8b70a96880a9

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

b9f02424ab03495b854ae8d7302a6560

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

2068d4beb48c42e49c91101a9609d9b8

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

5e9ff7ce19374edcb57442ed7528ade3

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

0390bac680a744f5805bd823984f0c66

debug模式重启后端项目。

c1900e8a972d48a3b372ae1c59290ef3

启动 Main。1811757695501524994_0.13842804060405456

dc6be3373a69433783da9824d191ea67

这次就没有乱码了。

d7f0f17e2c1f475c91b5dcc9b6b2e05e

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

079bdf0ac7b24fc78a6c44ac339c14eb

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

把客户端的 secretKey 改成 abcde。

89bae8faaf47470c8244e2d66d04f072

debug模式重启后端项目。1811757695501524994_0.32528293534954233

616f76d54a454b52acafb4e0539a1f20

启动 Main。

a0f98516faed4843ac1ab4559b8e7c56

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

8013cdd674604b119ed6b9de9752bd4b

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

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

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

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

5. 开发 SDK

1. Starter讲解

**为什么需要 Starter?**1811757695501524994_0.45319685473124816

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

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

**进一步说明:**1811757695501524994_0.7551476457024766

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

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

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

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

a2b27b03c5cf4a318f66990c71cb563a

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

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

dd5c9be5f83f45dc852fe8e81184d0f9

2. Starter 开发流程

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

选择 idea 左上角 File → New → Project。1811757695501524994_0.08753012762631007

60ce2d704070422386a668bcd4c00361

创建 Spring Boot 项目。

76aa6b73ab6a4a57b38164704259e2f6

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

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

idea 开始初始化项目。

81fb94536c5141f885817f9e631e7b6a

这里选择在新窗口打开。

437176b227ed435584dcd97f43ac09de

选择左上角File→settings。1811757695501524994_0.6852756161095324

700a8bc0097e41c392b216754e0047f9

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

9428571bd7e44c0787b9d8b50fbb7e8d

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

bab883588f074f7bbfd528c946f6fd3b

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

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

d80fb42a22bb4a91aaa63dd242f31218

改成0.0.1(改成多少都可以)。1811757695501524994_0.1361150542534566

d8980a2fd05241c2a0234947070472ad

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

4b2c8fbbe3e1442b95a810afd7b6f96c

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

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

2304dd6cf89548a289124f5619180aa4

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

20e46f56635a48fdbabd103c513d0339

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

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

ea97d90b1a52424eb43f24733f5bc42a

在这里打上几个注解,加上两个参数。1811757695501524994_0.4206824554210373

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;

}
6c21dcfcb2644000a6811d7648d5b7c4

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

d121d84638b04e1fb4b69e9030a05659

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

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

b2a73e704a0a4ae8a0f9de89cac66a20

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

9ca692b549c447eb8783db38c6ecfd9e

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

c765a93a58404afab242b3ae284dca0e

复制 yuapi-interface 项目中的 hutool 依赖。1811757695501524994_0.5957110519160915

937ca148e6944ed4b785b3bf21800a62

粘贴到 yuapi-client-sdk 项目中

ae79caf1959c4d098de9c16003e8eb86

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

ddbf59a5bb8149fe901103937667e504
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 里,刚刚只是测试,现在就是通过读取这个配置拿到这两个值。用这两个值去得到这样一个客户端。1811757695501524994_0.2576073185126728

787615dbb05443259f5732e28a536848

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

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

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

219ca31c83f44ded9b034ae8dbac791c

META-INF目录创建一个文件spring.factories。1811757695501524994_0.885008464999496

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

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

a4ec8bddbbcd42c68dc298e29f9d3ab0

输入文件名。

8fdcdb91644c4f3daf6fbfff6e16bdae

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

2608940d2dfe414687553c68c9aa449f

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

87b58be34c5648e98ba5ebddb01a53aa

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

鼠标右键 YuApiClientConfig.java。

bd0503b33bc54b49afe250bb8e3ce8f3

复制配置类的路径。

3349def9e9744f5bb22428fc1c753b10

粘贴到spring.factories文件中。1811757695501524994_0.454147526755166

c1b9bdb801e54286951d7ba016e5a5bc
# spring boot starter
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.yupi.yuapiclientsdk.YuApiClientConfig

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

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

efea22fbbdf44e079ba01653baf8ca43

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

6665a4dcf06b48f8be8a5ec18d2cd7b0

这里会报错。

bc74358852c4433289d285e9516f9e85

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

6fe2a7f64baa42c3bf66ffb6b7c31e7c

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

  1. 删除:鼠标右键,选择 Delete。
359fc8f3dd334a7e9e3077dd01981aed2db1a415de1e4b748774a0a9bacddab1 2. 1811757695501524994_0.7322159058931681 54dd21f1fbc74c9c9bb5150b1deedd25

再执行一次 install。

db522f4853c540fe9ce524387e85762a

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

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

3fb4be24523449679fa4f2d0954a5f6c

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

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

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

b395fd17ad8e4d0fa8493a54a52165b6

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

32267327f56b4111a150236451ea4735

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

e853837e5c76463c8782a6494de35a11

然后进入 yupi 目录。

4a810ebc3a404f93b4302cdf322c1542

就看见我们打包好的依赖包了。1811757695501524994_0.19411831839413551

2d029807a6b1462eb1805ba11fac4c6a

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


1811757695501524994_0.04890510406114501

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

38987ff297b840b2b40e997348916a71

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

a0ceea3ef0fe43f7ae8e1bacba5c4151

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

46df37e055a0413db999b4dd49c6a124

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

bfffed6816914556be6c64df8f606d54

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

25301252d98f411e8a7740c80a8daf9c

选择第一行。

640df292648a4ff3ac31fe2c9c25adb0

指定 accesskey 为yupi

fa6a4ef6f6d04509b2e7871f0b0ae241

然后指定 secretkey 为abcdefgh

023ebcfd7c6c4bc4bed0ef82da4da40f

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

d3bccbcd563e4942ba01624f3393d6c8

引入 yuapi-client-sdk 包里的。

d1350954dd1e424c9fc5c136f943a297

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

81658236e4b94e1380092cb60812c07a

编写测试方法。1811757695501524994_0.185574651995968

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模式启动后端项目。

f4e648ffa2914103ab6e4b5aa4f4e4c3

启动测试。

44e325c7c1dd45b18a3f532364e17134

成功调用,得到了响应,也得到了用户名。1811757695501524994_0.579464602313662

4b8140c53bfc4e4e90699c6bbde70e3c

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

161c9ac3c8a64664a700c580a4170398

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

c449fb0fefdf447196a283ea665e5758

记得改回正确的 secretkey。1811757695501524994_0.8358421838775445

e4d56ffc867d4035a5b10220b4973afc

3. Starter 总结

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

4f8d2827408043bdb8c6e6c10d2c7add

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

504ca7d7f5bd41b79a5a0fcc585ffef1

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

7c3aa00e967542daa3a3d3abb7d676dc

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

af021ba9c0ad4f14bd6a54cd09cea2d0

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

47c9cbff7582472e9db657e5473ccea2

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

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

a194d708fec64afcaf3f1542bca9e6f0

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

f5f4c74a7e4941be832cbeb6eaa4193a

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

b52a81311a23431088ca240880bf238c

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

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

6. 小作业

怎么把 starter 分享给别人?

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