智能BI项目第四期

开发图表管理功能

规划思路

首先需要做一个列表页。后端已经在星球提供了一个基础的万能项目模板,包含增删改查接口,我们只需要在此基础上进行定制化开发即可。所以本期后端的开发量不多,只需要复用即可,主要是前端。

规划功能设计

后端: 复用 springboot-init 初始化模板的增删改查代码 核心:获取个人创建的图表列表 listMyChartByPage

前端:

  1. 开发一个列表页
  2. 支持按照图表名称搜索
前端开发
第一步

创建路由,进入routes.ts,图标可到 ant.design 组件库 挑选 , 点击图标自动复制。

java 复制代码
  { name:'我的图表',path: '/my_chart', icon: 'pieChart', component: './MyChart' },

例如: , 可简写为:pieChart

(删除尾后Outlined , 首字母改为小写)

国内用户可以访问Ant Design 镜像网站

第二步

创建页面,复制AddChart目录。 粘贴至page目录下,并重命名为MyChart

第三步

这个时候可以试着访问一下,能不能访问嘚通

第四步

修改页面。 对MyChart目录下的index.tsx进行修改,把多余的内容删除。

第五步

获取数据:首先我们需要获取到最原始的数据,然后根据数据进行一步一步的美化处理。

java 复制代码
import { listMyChartByPageUsingPOST } from '@/services/yubi/chartController';
import React, { useState } from 'react';

/**
 * 我的图表页面
 * @constructor
 */
const MyChartPage: React.FC = () => {
  // 把初始条件分离出来,便于后面恢复初始条件
  const initSearchParams = {
    // 初始情况下返回每页12条数据
    pageSize: 12,
  };
  /* 
    定义了一个状态(searchParams)和它对应的更新函数(setSearchParams),并初始化为initSearchParams;
    searchParams是我们要发送给后端的查询条件,它的参数类型是API.ChartQueryRequest;
     {...} 是展开语法,它将 initSearchParams 中的所有属性展开并复制到一个新对象中,而不改变原始对象,因此可以避免在现有对象上直接更改值的对象变异操作。
     因为在 React 中,不推荐直接修改状态或属性,而是创建一个新对象并将其分配给状态或属性,这个方法就非常有用。
  */
  const [searchParams, setSearchParams] = useState<API.ChartQueryRequest>({ ...initSearchParams });
  // 定义一个获取数据的异步函数
  const loadData = async () => {
    /* 
      调用后端的接口,并传入searchParams作为请求参数,返回一个响应res;
      listMyChartByPageUsingPOST方法是通过openapi根据Swagger接口文档自动生成的;
      当searchParams状态改变时,可以通过setSearchParams更新该状态并重新获取数据
    */
    const res = await listMyChartByPageUsingPOST(searchParams);
  }

  return (
    <div className="my-chart-page">

    </div>
  );
};
export default MyChartPage;

在实际的开发中,前端和后端的职责是需要明确划分的。前端主要负责页面展示和与用户的交互,而后端则负责业务逻辑的实现和数据的处理。尽管前端的逻辑相对较少,但为了提高整个应用的性能和用户体验,我们应该尽可能地减少前端的计算复杂度,让后端来处理这些复杂的运算。这样,前端只需要调用后端的接口,传递需要的参数即可,后端则负责返回处理好的数据给前端,让前端根据数据进行页面展示。这样的划分可以使得前后端的开发更加高效和有效。

继续优化

java 复制代码
import { listMyChartByPageUsingPOST } from '@/services/yubi/chartController';
import { message } from 'antd';
import React, { useEffect, useState } from 'react';

/**
 * 我的图表页面
 * @constructor
 */
const MyChartPage: React.FC = () => {

  const initSearchParams = {
    pageSize: 12,
  };

  const [searchParams, setSearchParams] = useState<API.ChartQueryRequest>({ ...initSearchParams });
  // 定义变量存储图表数据
  const [chartList, setChartList] = useState<API.Chart[]>();
  // 数据总数,类型为number,默认为0 
  const [total, setTotal] = useState<number>(0);

  const loadData = async () => {
    try {
      const res = await listMyChartByPageUsingPOST(searchParams);

      if (res.data) {
        // 如果成功,把图表数据回显到前端;如果为空,传一个空数组
        // 这里返回的是分页,res.data.records拿到数据列表
        setChartList(res.data.records ?? []);
        // 数据总数如果为空就返回0
        setTotal(res.data.total ?? 0);
      } else {
        // 如果后端返回的数据为空,抛出异常,提示'获取我的图表失败'
        message.error('获取我的图表失败');
      }

    } catch (e:any) {
      // 如果出现异常,提示'获取我的图表失败'+错误原因
      message.error('获取我的图表失败,' + e.message);
    }
  }

  // 首次页面加载时,触发加载数据
  useEffect(() => {
    // 这个页面首次渲染的时候,以及这个数组中的搜索条件发生变化的时候,会执行loadData方法,自动触发重新搜索
    loadData();
  },[searchParams]);

  return (
    <div className="my-chart-page">
      {/* 先把数据展示出来。直接展示对象会报错,所以要把后端拿到的对象数组进行格式化;把对象转为JSON字符串*/}
      数据列表:
      {JSON.stringify(chartList) }

      {/* 换行 */}
      <br/>
      总数:{total}
    </div>
  );
};
export default MyChartPage;
第六步

看看是否展示出了数据,想办法优化

第七步

美化数据:这里需要引入 Ant Design 的列表组件(list),访问Ant Design 组件库; 找一个符合我们要求的,点击显示代码按钮。

复制List组件到return里的div标签中

第八步

修改List组件

java 复制代码
<List
        itemLayout="vertical"
        size="large"
        pagination={{
          onChange: (page) => {
            console.log(page);
          },
          pageSize: 3,
        }}
        // 把数据源改成图表数据;列表组件就会自动把我们的数据列表展示成一条一条的形式
        dataSource={chartList}
        footer={
          <div>
            <b>ant design</b> footer part
          </div>
        }
        renderItem={(item) => (
          // List.Item就是你要怎么展示每一条数据
          <List.Item
            // key改成图表的id
            key={item.id}
            // 这里要展示图表(先不改)
            extra={
              <
                width={272}
                alt="logo"
                src="https://gw.alipayobjects.com/zos/rmsportal/mqaQswcyDLcXyDKnZfES.png"
                />
            }
            >
            {/* 你要展示的列表的元素信息 */}
            <List.Item.Meta
              // 先把头像写死
              avatar={<Avatar src={'https://randomuser.me/api/portraits/men/34.jpg'} />}
              // 图表的名称
              title={item.name}
              // 描述改成图表类型,如果没有图表类型,就不展示了
              description={item.chartType ? '图表类型' + item.chartType : undefined}
              />
            {/* 最终展示的内容 */}
            {'分析目标' + item.goal}
          </List.Item>
        )}
        />
      总数:{total}
    </div>

继续优化

java 复制代码
import { listMyChartByPageUsingPOST } from '@/services/yubi/chartController';
import { Avatar, List, message } from 'antd';
import React, { useEffect, useState } from 'react';
import ReactECharts from 'echarts-for-react';

/**
 * 我的图表页面
 * @constructor
 */
const MyChartPage: React.FC = () => {
  const initSearchParams = {
    pageSize: 12,
  };

  const [searchParams, setSearchParams] = useState<API.ChartQueryRequest>({ ...initSearchParams });
  const [chartList, setChartList] = useState<API.Chart[]>();
  const [total, setTotal] = useState<number>(0);

  const loadData = async () => {
    try {
      const res = await listMyChartByPageUsingPOST(searchParams);

      if (res.data) {
        setChartList(res.data.records ?? []);
        setTotal(res.data.total ?? 0);
      } else {
        message.error('获取我的图表失败');
      }
    } catch (e: any) {
      message.error('获取我的图表失败,' + e.message);
    }
  };

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

  return (
    <div className="my-chart-page">
      <List
        itemLayout="vertical"
        size="large"
        pagination={{
          onChange: (page) => {
            console.log(page);
          },
          pageSize: 3,
        }}
        dataSource={chartList}
        footer={
          <div>
            <b>ant design</b> footer part
          </div>
        }
        renderItem={(item) => (
          <List.Item
            key={item.id}
            // 在extra展示图表默认没有width(宽度),需要自己设置,无法适配
            // extra={
            // }
            >
            <List.Item.Meta
              avatar={<Avatar src={'https://randomuser.me/api/portraits/men/34.jpg'} />}
              title={item.name}
              description={item.chartType ? '图表类型' + item.chartType : undefined}
              />
            {'分析目标' + item.goal}	
             {/* 
              把在智能分析页的图表展示复制粘贴到此处;
              要把后端返回的图表字符串改为对象数组,如果后端返回空字符串,就返回'{}' 
            */}
            <ReactECharts option={JSON.parse(item.genChart ?? '{}')} />
          </List.Item>
        )}
        />
      总数:{total}
    </div>
  );
};
export default MyChartPage;

这个时候访问可能会出现访问不了的情况,大概率是因为后端AI生成了脏数据

可以通过检查genChart字段的数据,判断数据是否合法。比如:

  • 检查开头是否有中文;
  • 检查前后是否有回车、空行;
  • 检查 xAxis(yAxis、series、type、data等)是否被双引号包裹。 等等。
  • 这里的标题没必要再出现了
java 复制代码
{
  "title": {
    "text": "用户增长情况"
  },
  "tooltip": {
    "trigger": "axis"
  },
  "legend": {
    "data": ["用户数"]
  },
  "grid": {
    "left": "3%",
    "right": "4%",
    "bottom": "3%",
    "containLabel": true
  },
  "toolbox": {
    "feature": {
      "saveAsImage": {}
    }
  },
  "xAxis": {
    "type": "category",
    "boundaryGap": false,
    "data": ["1号", "2号", "3号", "4号", "5号", "6号", "7号", "8号", "9号"]
  },
  "yAxis": {
    "type": "value"
  },
  "series": [
    {
      "name": "用户数",
      "type": "line",
      "stack": "总量",
      "data": [10, 20, 90, 70, 20, 50, 110, 0, 8]
    }
  ]
}

继续美化页面内容,对数据进行处理,统一隐藏图表标题、增加分页、搜索框。

java 复制代码
import { listMyChartByPageUsingPOST } from '@/services/yubi/chartController';
import { useModel } from '@@/exports';
import {Avatar, Card, List, message} from 'antd';
import ReactECharts from 'echarts-for-react';
import React, { useEffect, useState } from 'react';
import Search from "antd/es/input/Search";

/**
 * 我的图表页面
 * @constructor
 */
const MyChartPage: React.FC = () => {
  const initSearchParams = {
    // 默认第一页
    current: 1,
    // 每页展示4条数据
    pageSize: 4,
  };

  const [searchParams, setSearchParams] = useState<API.ChartQueryRequest>({ ...initSearchParams });
  // 从全局状态中获取到当前登录的用户信息
  const { initialState } = useModel('@@initialState');
  const { currentUser } = initialState ?? {};
  const [chartList, setChartList] = useState<API.Chart[]>();
  const [total, setTotal] = useState<number>(0);
  // 加载状态,用来控制页面是否加载,默认正在加载
  const [loading, setLoading] = useState<boolean>(true);
  
  const loadData = async () => {
    // 获取数据中,还在加载中,把loading设置为true
    setLoading(true);
    try {
      const res = await listMyChartByPageUsingPOST(searchParams);
      if (res.data) {
        setChartList(res.data.records ?? []);
        setTotal(res.data.total ?? 0);
        // 有些图表有标题,有些没有,直接把标题全部去掉
        if (res.data.records) {
          res.data.records.forEach(data => {
            // 要把后端返回的图表字符串改为对象数组,如果后端返回空字符串,就返回'{}'
            const chartOption = JSON.parse(data.genChart ?? '{}');
            // 把标题设为undefined
            chartOption.title = undefined;
            // 然后把修改后的数据转换为json设置回去
            data.genChart = JSON.stringify(chartOption);
          })
        }
      } else {
        message.error('获取我的图表失败');
      }
    } catch (e: any) {
      message.error('获取我的图表失败,' + e.message);
    }
    // 获取数据后,加载完毕,设置为false
    setLoading(false);
  };

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

  return (
    <div className="my-chart-page">
      {/* 引入搜索框 */}
      <div>
        {/* 
          当用户点击搜索按钮触发 一定要把新设置的搜索条件初始化,要把页面切回到第一页;
          如果用户在第二页,输入了一个新的搜索关键词,应该重新展示第一页,而不是还在搜第二页的内容
        */}
        <Search placeholder="请输入图表名称" enterButton loading={loading} onSearch={(value) => {
          // 设置搜索条件
          setSearchParams({
            // 原始搜索条件
            ...initSearchParams,
            // 搜索词
            name: value,
          })
        }}/>
      </div>
      <List
        /*
          栅格间隔16像素;xs屏幕<576px,栅格数1;
          sm屏幕≥576px,栅格数1;md屏幕≥768px,栅格数1;
          lg屏幕≥992px,栅格数2;xl屏幕≥1200px,栅格数2;
          xxl屏幕≥1600px,栅格数2
        */
        grid={{
          gutter: 16,
          xs: 1,
          sm: 1,
          md: 1,
          lg: 2,
          xl: 2,
          xxl: 2,
        }}
        pagination={{
          /*
            page第几页,pageSize每页显示多少条;
            当用户点击这个分页组件,切换分页时,这个组件就会去触发onChange方法,会改变咱们现在这个页面的搜索条件
          */
          onChange: (page, pageSize) => {
            // 当切换分页,在当前搜索条件的基础上,把页数调整为当前的页数
            setSearchParams({
              ...searchParams,
              current: page,
              pageSize,
            })
          },
          // 显示当前页数
          current: searchParams.current,
          // 页面参数改成自己的
          pageSize: searchParams.pageSize,
          // 总数设置成自己的
          total: total,
        }}
        // 设置成我们的加载状态
        loading={loading}
        dataSource={chartList}
        renderItem={(item) => (
          <List.Item key={item.id}>
            {/* 用卡片包裹 */}
            <Card style={{ width: '100%' }}>
              <List.Item.Meta
                // 把当前登录用户信息的头像展示出来
                avatar={<Avatar src={currentUser && currentUser.userAvatar} />}
                title={item.name}
                description={item.chartType ? '图表类型:' + item.chartType : undefined}
              />
              {/* 在元素的下方增加16像素的外边距 */}
              <div style={{ marginBottom: 16 }} />
              <p>{'分析目标:' + item.goal}</p>
              {/* 在元素的下方增加16像素的外边距 */}
              <div style={{ marginBottom: 16 }} />
              <ReactECharts option={item.genChart && JSON.parse(item.genChart)} />
            </Card>
          </List.Item>
        )}
      />
    </div>
  );
};
export default MyChartPage;

把常用的样式设定成固定的 css 样式(俗称:原子化 css); 找到global.less(全局样式)。

系统优化

现在的网站足够安全么?

a. 如果用户上传一个超大的文件怎么办?

b. 如果用户用科技疯狂点击提交,怎么办?

c. 如果 AI 的生成太慢(比如需要一分钟),又有很多用户要同时生成,给系统造成了压力,怎么兼顾用户体验和系统的可用性?

🚨 现在我们的网站有哪几方面都不足?

  1. 安全性:如果用户上传一个超大的文件怎么办?比如 1000 G?

  2. 数据存储:我们将每个图表的原始数据全部存放在同一个数据表(chart表)中,后期数据量大的情况下,会导致查询图表或查询 chart表等操作变得缓慢。

  3. 限流:在做真正上线的系统中,如果系统需要付费才能使用,比如每次用户调用聪明 AI 发送一条消息,AI 给出一个回答,这背后都需要进行成本的扣除。

只要涉及到用户自主上传的操作,一定要校验文件(图像) 校验的维度:

  1. 文件的大小
  2. 文件的后缀
  3. 文件的内容(成本要高一些)
  4. 文件的合规性(比如敏感内容,建议用第三方的审核功能) 扩展点:接入腾讯云的图片万象数据审核(COS 对象存储的审核功能)

后端校验

来到后端,找到ChartController.java下的genChartByAi接口,编写校验文件代码:

事实上,仅仅校验文件后缀并不能完全保证文件的安全性,因为攻击者可以通过将非法内容更改后缀名来绕过校验。通过修改文件后缀的方式欺骗校验机制,将一些恶意文件伪装成安全的文件类型。 现在这个校验的维度是从浅到深,仅仅依靠校验后缀是远远不够的,还需要结合其他严格措施来加强文件的安全性。给大家提供了一些思路,这是安全性的一个优化点。


  1. 一般文件多大考虑分片? 有人认为,当我们处理大型文件时,考虑分片上传可以提高上传速度和稳定性。分片上传可以使得当文件上传失败时,不用重新上传整个文件,而是只需要重新上传未完成的那部分分片。然而,对于分片上传的具体实现,建议使用现有的第三方组件,如腾讯云 TOS 对象存储,而不是自行实现。 因为没有一个标准的实现方式,自行实现可能会导致代码质量不稳定。一般来说,建议对于百兆到几个 G 的文件,考虑使用分片上传方式。对于大小不到十几兆的文件,可能没有必要进行分片上传。 如果能开发一个秒传系统,在简历中会起到很大的亮点作用,因为秒传这个功能涉及到技术含量较高的领域,如断点上传、文件校验和数据分片等。此外,还需要注意的是,秒传系统的实现需要考虑很多细节,例如如何保证文件的完整性和隐私安全,如何在高并发环境下实现高效的上传和下载等问题,这些也是最终系统能否得以成功运作的关键。
  2. @Validated 推荐用吗? 在选择技术时,往往需要根据具体的场景来进行判断和决策。当涉及到校验字段的规则时,是否采用@Validated 注解并没有一个绝对的答案,而是需要根据具体情况来考虑。如果你的校验规则相对简单,可以通过@Validated 注解中已经提供的一些规则来实现,那么直接使用@Validated 注解便是一个非常好的选择。 但如果你的校验规则比较复杂,可能涉及到多个条件和计算,这时候可以直接在业务代码中进行校验并灵活处理。所以说,我们需要根据具体的情况来选择合适的技术和方法来解决问题。

存在的不足

**现状:**我们把每个图表的原始数据全部存放在了同一个数据表(chart表)的字段里。 问题:

  1. 如果用户上传的原始数据量很大、图表数日益增多,查询 chart表就会很慢
  2. 对于 BI 平台,用户是有查看原始数据、对原始数据进行简单查询的需求的。现在如果把所有数据存放在一个字段(列)中,查询时,只能取出这个列的所有内容。

解决方案构思: 如果将原始数据以表格的形式存储在一个独立的数据表中,而不是放在一个小的格子里,实际上会更方便高效。由于数据表采用了标准的结构方式存储,我们可以通过使用 SQL 语句进行高效的数据检索,仅查询需要的列或行。 此外,我们还可以利用数据库的索引等高效技术,更快、更精确地对数据进行定位和查询,从而提高查询效率和系统的响应速度。

解决方案 => 分库分表: 把每个图表对应的原始数据单独保存为一个新的数据表,而不是都存在一个字段里。 比如 :我们的网站数据.xlsx,如果要保存这个数据,就单独保存为一个新的数据表,表名为chart_{图表id}。 新建表,然后填入下图所示的数据,分开查询测试时会用到。

  1. 存储时,能够分开存储,互不影响(也能增加安全性)
  2. 查询时,可以使用各种 sql 语句灵活取出需要的字段,查询性能更快

优点构思: 使用分开存储的方式可以带来很多好处,其中一个好处就是存储的值相互独立,不会互相影响。例如,如果我们将一个 100 G 的数据保存到同一个表中,其他用户在访问这个数据表时会受到很大的影响,甚至在读取这个数据时可能会非常慢。 而通过将每个表单独存储,即使一个用户上传了很大的数据,其他用户在访问时也不会受到影响。这样可以保证数据的安全性和稳定性,同时也能提高系统的处理能力和效率。 以后进行图表数据查询时,可以先根据图表的 ID 来查找,然后进行数据查询,方便我们排查问题。甚至返回用户原始数据,通过全标扫描的方式直接捞出所有数据,这比对数据库查询数据进行处理更加快速和高效。

💡 分库分表的思路: 在数据库设计中考虑使用分库分表的思路可以有效地解决大数据量和高并发的问题。可以分水平分表和垂直分库两种方式。 水平分表指在数据量大的情况下,将表按照某个字段的值进行拆分和分散存储,例如拆分出前 1 万个用户一个表,后 1 万个用户一个表。 垂直分库则是将不同的业务按照相关性进行划分,例如将用户中心用户相关的内容划分到一个库中,订单、支付信息和订单相关的划分到另一个库中,从而提高系统的可扩展性和稳定性。 分库分表是数据库设计中重要的一部分,能有效地优化系统的性能,提高用户体验,也是一个优秀的简历亮点。

分库分表介绍:

在大型互联网应用中,为了应对高并发、海量数据等挑战,往往需要对数据库进行拆分。常见的拆分方式有水平分表和垂直分库两种。

水平分表(Sharding)

水平分表是将同一张表中的数据按一定的规则划分到不同的物理存储位置上,以达到分摊单张表的数据及访问压力的目的。对于 SQL 分为两类:id-based 分表和 range-based 分表。

水平分表的优点:

  • 单个表的数据量减少,查询效率提高。
  • 可以通过增加节点,提高系统的扩展性和容错性。

水平分表的缺点:

  • 事务并发处理复杂度增加,需要增加分布式事务的管理,性能和复杂度都有所牺牲。
  • 跨节点查询困难,需要设计跨节点的查询模块。
垂直分库(Vertical Partitioning)

垂直分库,指的是根据业务模块的不同,将不同的字段或表分到不同的数据库中。垂直分库基于数据库内核支持,对应用透明,无需额外的开发代码,易于维护升级。

垂直分库的优点:

  • 减少单个数据库的数据量,提高系统的查询效率。
  • 增加了系统的可扩展性,比水平分表更容易实现。

垂直分库的缺点:

  • 不同数据库之间的维护和同步成本较高。
  • 现有系统的改造存在一定的难度。
  • 系统的性能会受到数据库之间互相影响的影响。

需要根据实际的业务场景和技术架构情况,综合考虑各种因素来选择适合自己的分库分表策略。

XML 复制代码
<!--  
queryChartData唯一标识符;parameterType查询语句的参数类型,类型为字符串;
resultType查询结果的返回类型,类型为map类型;
${querySql}是SQL查询语句的占位符;
select * from chart_#{chartId} 不够灵活,${querySql}是最灵活的方式,
就是把sql语句完全交给程序去传递,有一定的风险;
一旦使用$符号,就有sql注入的风险。
-->
<select id="queryChartData" parameterType="string" resultType="map">
  ${querySql}
</select>
<!-- 
可以在程序里面去做校验。只要保证这个SQL是通过你的后端生成的,
在生成的过程中做了校验,就不会有这种漏洞的风险。 
-->
java 复制代码
package com.yupi.springbootinit.mapper;

import com.yupi.springbootinit.model.entity.Chart;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import java.util.List;
import java.util.Map;

/**
 * @Entity com.yupi.springbootinit.model.entity.Chart
 */
public interface ChartMapper extends BaseMapper<Chart> {
   /*
    * 方法的返回类型是 List<Map<String, Object>>,
    * 表示返回的是一个由多个 map 组成的集合,每个map代表了一行查询结果,
    * 并将其封装成了一组键值对形式的对象。其中,String类型代表了键的类型为字符串,
    * Object 类型代表了值的类型为任意对象,使得这个方法可以适应不同类型的数据查询。
    *
    */
    List<Map<String, Object>> queryChartData(String querySql);
}

然后创建测试类,进行测试

把光标放在类名上,Alt + 回车,会有创建测试类的快捷方式,使用junit5

java 复制代码
package com.yupi.springbootinit.mapper;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;

import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class ChartMapperTest {

    @Resource
    private ChartMapper chartMapper;

    @Test
    void queryChartData() {
        String chartId = "1659210482555121666";
        String querySql = String.format("select * from chart_%s", chartId);
        List<Map<String, Object>> resultData = chartMapper.queryChartData(querySql);
        System.out.println(resultData);
    }
}

限流

使用系统是需要消耗成本的,用户有可能疯狂刷量,让你破产。 解决方案:

  1. 控制成本 => 限制用户调用总次数
  2. 用户在短时间内疯狂使用,导致服务器资源被占满,其他用户无法使用 => 限流

思考: 限流阈值多大合适?参考正常用户的使用,比如限制单个用户在每秒只能使用 1 次。

建议 阅读文章

本地限流(单机限流)

每个服务器单独限流,一般适用于单体项目,就是你的项目只有一个服务器

举个例子,假设你的系统有三台服务器,每台服务器限制用户每秒只能请求一次。你可以为每台服务器单独设置限流策略,这样每个服务器都能够独立地控制用户的请求频率。但是这种限流方式并不是很可靠,因为你并不知道用户的请求会落在哪台服务器上,它的分布是有一定的偶然性的。即使你采用负载均衡技术,让用户请求轮流发送到每台服务器,仍然存在一定的风险。

在 Java 中,有很多第三方库可以用来实现单机限流: Guava RateLimiter:这是谷歌 Guava 库提供的限流工具,可以对单位时间内的请求数量进行限制。

分布式限流(多机限流)

如果你的项目有多个服务器,比如微服务,那么建议使用分布式限流。

  1. 把用户的使用频率等数据放到一个集中的存储进行统计; 比如 Redis,这样无论用户的请求落到了哪台服务器,都以集中存储中的数据为准。 (Redisson -- 是一个操作 Redis 的工具库)
  2. 在网关集中进行限流和统计(比如 Sentinel、Spring Cloud Gateway)
java 复制代码
import org.redisson.Redisson;
import org.redisson.api.RSemaphore;
import org.redisson.api.RedissonClient;

public static void main(String[] args) {
    // 创建RedissonClient
    RedissonClient redisson = Redisson.create();

    // 获取限流器
    RSemaphore semaphore = redisson.getSemaphore("mySemaphore");

    // 尝试获取许可证
    boolean result = semaphore.tryAcquire();
    if (result) {
        // 处理请求
    } else {
        // 超过流量限制,需要做何处理
    }
}
Redisson 限流实现

[官方项目仓库和文档]

1.引入依赖
XML 复制代码
<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.36.0</version>
</dependency>  
2.创建 RedissonConfig 配置类

用于初始化 RedissonClient 对象单例; 在config目录下新建RedissonConfig.java

java 复制代码
package com.yupi.springbootinit.config;

import lombok.Data;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
// 从application.yml文件中读取前缀为"spring.redis"的配置项
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedissonConfig {

    private Integer database;

    private String host;

    private Integer port;
    // 如果redis默认没有密码,则不用写
    //private String password;

    // spring启动时,会自动创建一个RedissonClient对象
    @Bean
    public RedissonClient getRedissonClient() {
        // 1.创建配置对象
        Config config = new Config();
        // 添加单机Redisson配置
        config.useSingleServer()
        // 设置数据库
        .setDatabase(database)
        // 设置redis的地址
        .setAddress("redis://" + host + ":" + port);
        // 设置redis的密码(redis有密码才设置)
        //                .setPassword(password);

        // 2.创建Redisson实例
        RedissonClient redisson = Redisson.create(config);
        return redisson;
    }
}

怎么知道 redis 有没有密码? 在本地安装的 redis 目录下找到redis-server.exe,双击启动,放那别关掉。

然后在 redis 目录下找到redis-cli.exe,输入命令config get requirepass。 没有设置密码,所以2)为空。

3.创建 redis 客户端

去写一个管理类; 在manager目录下创建RedisLimiterManager.java

java 复制代码
package com.yupi.springbootinit.manager;

import com.yupi.springbootinit.common.ErrorCode;
import com.yupi.springbootinit.exception.BusinessException;
import org.redisson.api.RRateLimiter;
import org.redisson.api.RateIntervalUnit;
import org.redisson.api.RateType;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

/**
 * 专门提供 RedisLimiter 限流基础服务的(提供了通用的能力,放其他项目都能用)
 */
@Service
public class RedisLimiterManager {

    @Resource
    private RedissonClient redissonClient;

    /**
     * 限流操作
     *
     * @param key 区分不同的限流器,比如不同的用户 id 应该分别统计
     */
    public void doRateLimit(String key) {
        // 创建一个名称为user_limiter的限流器,每秒最多访问 2 次
        RRateLimiter rateLimiter = redissonClient.getRateLimiter(key);
        // 限流器的统计规则(每秒2个请求;连续的请求,最多只能有1个请求被允许通过)
        // RateType.OVERALL表示速率限制作用于整个令牌桶,即限制所有请求的速率
        rateLimiter.trySetRate(RateType.OVERALL, 2, 1, RateIntervalUnit.SECONDS);
        // 每当一个操作来了后,请求一个令牌
        boolean canOp = rateLimiter.tryAcquire(1);
        // 如果没有令牌,还想执行操作,就抛出异常
        if (!canOp) {
            throw new BusinessException(ErrorCode.TOO_MANY_REQUEST);
        }
    }
}

大家看不懂源码的话,点击到源码的包里。右上方有一个download下载按钮,就可以看解析了

4.测试

同样将鼠标放在类上,Alt + Enter,会出现创建测试类的快捷键,使用junit5

java 复制代码
package com.yupi.springbootinit.manager;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class RedisLimiterManagerTest {

    @Resource
    private RedisLimiterManager redisLimiterManager;

    @Test
    void doRateLimit() throws InterruptedException {
        // 模拟一下操作
        String userId = "1";
        // 瞬间执行2次,每成功一次,就打印'成功'
        for (int i = 0; i < 2; i++) {
            redisLimiterManager.doRateLimit(userId);
            System.out.println("成功");
        }
        // 睡1秒
        Thread.sleep(1000);
        // 瞬间执行5次,每成功一次,就打印'成功'
        for (int i = 0; i < 5; i++) {
            redisLimiterManager.doRateLimit(userId);
            System.out.println("成功");
        }
    }
}
5.应用

在controller层中注入RedisLimiterManager

java 复制代码
// 引用
@Resource
private RedisLimiterManager redisLimiterManager;


// 限流判断,每个用户一个限流器
redisLimiterManager.doRateLimit("genChartByAi_" + loginUser.getId());

优化点:实现分库分表操作,减小查询压力

开发编辑图表的功能,允许用户再次发送请求

相关推荐
xlsw_3 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
神仙别闹4 小时前
基于java的改良版超级玛丽小游戏
java
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭4 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫4 小时前
泛型(2)
java
超爱吃士力架5 小时前
邀请逻辑
java·linux·后端
南宫生5 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石5 小时前
12/21java基础
java
李小白665 小时前
Spring MVC(上)
java·spring·mvc
GoodStudyAndDayDayUp5 小时前
IDEA能够从mapper跳转到xml的插件
xml·java·intellij-idea
装不满的克莱因瓶6 小时前
【Redis经典面试题六】Redis的持久化机制是怎样的?
java·数据库·redis·持久化·aof·rdb