阅读视图

发现新文章,点击刷新页面。

跟着TRAE SOLO全链路看看项目部署服务器全流程吧

跟着TRAE SOLO全链路看看项目部署服务器全流程吧

接下来我们新建一个项目,然后将项目部署到服务器上,并且配置好以后可以在外网进行访问

安装nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash

1、简单服务器环境搭建

接下来我们就实现把 Node.js 项目部署到 /opt/nexus-node-api 并配置外部访问

进入服务器以后安装环境

# 更新包列表
sudo apt update

# 安装 Node.js 和 npm
sudo apt install nodejs npm

# 验证安装
node --version
npm --version

项目创建

# 创建目录
sudo mkdir -p /opt/nexus-node-api

# 设置所有者和权限
sudo chown -R $USER:$USER /opt/nexus-node-api
chmod -R 755 /opt/nexus-node-api

# 进入目录
cd /opt/nexus-node-api

# 创建一个项目
nano app.js

项目内容

const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World!\n');
});
server.listen(3000, '0.0.0.0', () => {
    console.log(`Server running on port 3000`);
});

测试运行以及外网访问

注意点:一定要注意这个时候必须保证你的服务器里面的防火墙(安全组)规则里面有3000这个端口号

node app.js

现在访问 http://你的服务器IP:3000 应该能看到 "Hello World!"

2、正式项目配置

卸载node环境

这里我们使用nvm来配置我们的环境,如果已经有的,我们删除一下已经有的环境

# 卸载 nodejs 和 npm
sudo apt-get remove nodejs npm
sudo apt-get purge nodejs npm

安装nvm

// 安装 nvm
# 建议安装 nvm,方便版本管理
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash

配置环境变量

# 编辑 .bashrc
nano ~/.bashrc

//添加配置 ---一般系统会自动为我们添加
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"

// 重新加载配置
source ~/.bashrc

// 验证 nvm 安装
nvm --version

安装稳定版本node

ubuntu为例子
// 查看可以安装的稳定版本
nvm ls-remote

// 这里我安转版本
nvm install v22.12.0
// 使用
nvm use v22.12.0

// 设置默认版本
nvm alias default v22.12.0
//   pm2
npm i -g pm2

使用pm2守护进程

PM2 是 Node 应用的进程管理工具,能保证服务在后台持续运行:要不然关闭窗口之后,就无法访问了

# 全局安装 PM2
npm install pm2 -g

# 启动服务并命名(方便管理)
pm2 start app.js --name "node-api-nexus"

# 查看服务状态
pm2 list  # 若 Status 为 online 则表示启动成功

这个时候不管怎么刷新我们的页面或者窗口,可以始终稳定访问我们的接口

pm2 重启对应的服务
pm2 restart "node-api-nexus"

3、服务器安装mysql数据库

环境搭建

接下来我们在服务器上安装mysql数据库,这里需要我们输入服务器密码

# 更新包列表
sudo apt update

# 安装 MySQL 服务器 
// 安装 MySQL 8.0(Ubuntu 默认源即提供 MySQL 8.0)
sudo apt install mysql-server -y

# 安装过程中可能会提示输入服务器密码

# 确认 MySQL 版本
mysql --version

MYSQl数据库安全配置

调整 MySQL 服务器的安全性
# 安全配置(可选但推荐)
sudo mysql_secure_installation

测试可以都选n


按照提示配置:
是否启用强密码   // y 
设置 root 密码   // Le@1996#Lin
移除匿名用户
禁止远程 root 登录(可选)
删除测试数据库
重新加载权限表
登录 MySQL
sudo mysql -u root -p
输入密码即可

配置远程访问(可选)

配置 MySQL 允许本地连接:

sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf

配置信息
[mysqld]
# 确保绑定到本地
bind-address = 127.0.0.1

# 设置端口
port = 3306

# 设置字符集
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
修改mysql数据库配置
[mysqld]
# 注释掉原来的 bind-address 或改为 0.0.0.0
# bind-address = 127.0.0.1
bind-address = 0.0.0.0
port = 3306
重启服务,登录mysql创建远程连接用户
// 重启mysql服务
sudo systemctl restart mysql

// 登录
sudo mysql -u root -p

// 密码
123456

-- 创建远程用户(% 表示允许任何IP连接)
CREATE USER '账号'@'%' IDENTIFIED BY '密码';

-- 授予权限
GRANT ALL PRIVILEGES ON *.* TO '密码'@'%' WITH GRANT OPTION;

-- 或者只授权特定数据库(跳过)
-- GRANT ALL PRIVILEGES ON your_database.* TO 'remote_user'@'%';

-- 刷新权限
FLUSH PRIVILEGES;

-- 查看用户
SELECT User, Host FROM mysql.user;

-- 退出
EXIT;

// 重启 MySQL
sudo systemctl restart mysql

// 设置开机自启(默认应已设置)
sudo systemctl enable mysql

4、navicat远程mysql数据库

切记:一定要保证我们的服务器已经添加了我们的端口3306

服务器允许我们远程连接

# 开放 3306 端口
sudo ufw allow 3306

# 或者只允许特定IP访问(更安全)
sudo ufw allow from 你的本地IP to any port 3306

# 查看防火墙状态
sudo ufw status

远程连接

本地远程mysql数据库,我使用的是navicat工具,这里直接输入我们的信息

连接名:远程服务器,随便起名字
主机:服务器IP
用户名:上面设置的
密码:上面设置的

测试一下,服务器的数据库已经连接成功了

数据库连接测试

新建一个数据库,这里我的名称是nexus

数据库名:nexus
字符集:utf8mb3
排序规则:utf8mb3_bin

新建一个表

DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user`  (
  `user_id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '姓名',
  `age` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '年龄',
  `sex` int(0) NULL DEFAULT NULL COMMENT '用户性别 1男 2女 ',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `address` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '用户的地址',
  `state` tinyint(0) NULL DEFAULT NULL COMMENT '1 正常  0 2  禁用',
  `phone` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '手机号',
  `username` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '用户的登录账号',
  `password` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '123456' COMMENT '用户的登录密码',
  `avatar` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '头像地址',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  `user_height` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '身高',
  `user_weight` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '体重',
  `disease` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '健康状况,是否有疾病',
  PRIMARY KEY (`user_id`, `password`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 55 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = Dynamic;

本地运行项目测试

这里我们现在就本地启动项目连接我们服务器,然后进行测试,这里我以开源的Node项目为例,主要修改四个参数

const dbhost='xx'; // 数据库主机地址,如果是本地数据库则使用localhost
const dbdatabase='xx'; // 数据库名称
const dbuser='xx'; // 数据库用户名
const dbpassword='xxx'; // 数据库密码

本地测试一下,我们的线上数据库已经可以使用了

5、Node项目部署

接下来我们将node项目部署进我们的服务器,首先把我们项目都扔进去

配置环境

这里我用的是yarn,安装一下

npm install yarn -g 


// 配置环境
yarn

// 启动pm2
pm2 start app.js --name "node-api-nexus"

// 重新启动pm2 设置开机自启
pm2 startup
pm2 save

查看详细日志

pm2 logs node-api-nexus

启动以后我们就可以直接在浏览器打开地址对我们的系统后台进行访问了

http://XXXXXX:3200/

6、前端部署

环境安装

接下来我们继续部署我们的前端应用,先用我们的项目连接一下我们的数据库尝试一下 OK,没什么问题,然后我们开始部署前端项目

项目名称为nexus-vue,项目打包好的路径位于 /opt/nexus-vue 下面

// 打包前端项目
yarn build

// 更新和安装nginx 
// 更新可以跳过 之前我们已经进行过
sudo apt update
sudo apt install nginx

// 查看版本
nginx -V

配置nginx

sudo nano /etc/nginx/sites-available/nexus-vue

// 配置如下
server {
    listen 80;
    server_name localhost;  # 替换为你的域名或IP

    root /opt/nexus-vue;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    # 如果需要代理API请求
    location /api {
        proxy_pass http://localhost:3000;
    }
}
server {
    listen 8080;
    server_name localhost;  # 替换为你的域名或IP

    # 前端静态文件
    root /opt/nexus-vue;
    index index.html;

    # 前端路由
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 后端API请求
    location /api {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # WebSocket连接
    location /ws {
        proxy_pass http://localhost:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

开放接口

sudo ufw allow 8080

sudo systemctl restart nginx

处理日志错误

// 检查nginx错误日志
sudo tail -f /var/log/nginx/error.log


//开放文件权限
sudo chmod -R 755 /opt/nexus-vue

// 检查配置
sudo nano /etc/nginx/sites-available/nexus-vue

// 重新启动nginx
sudo systemctl restart nginx

部署

写一个测试页面扔进去

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>我是测试页面</title>
</head>
<body>
<h1>我是测试页面</h1>
</body>
</html>

访问我们的地址http://域名IP:8080/

这个时候已经可以看到我们的项目已经部署上去了

重新加载以后,ok,到这里我们全链路都部署上去了

Maven父子模块Deploy的那些坑

起因

前两天遇到个挺坑的问题。我们有个基础服务框架叫financial-platform,是典型的父子结构,父工程下面挂了common-utils、message-client、db-starter这几个子模块。这次需要升级message-client模块,增加了RocketMQ的一些新特性,版本从1.2.5-SNAPSHOT改到1.3.0-SNAPSHOT。

当时想的挺简单的,就是把整个项目的版本都改了,然后只deploy这个message-client模块上去就行了。毕竟这个模块看起来挺独立的,也不依赖其它兄弟模块,应该没问题吧?

结果被现实教育了。

拉取失败

改完版本号,deploy上去后,业务系统引用这个message-client的时候就报错了:

Could not find artifact com.financial:message-client:jar:1.3.0-SNAPSHOT

我当时就懵了,明明刚deploy上去啊,怎么就找不到呢? 去Nexus私服上看,message-client-1.3.0-SNAPSHOT.jar确实在那儿躺着,但就是拉不下来。

后来发现Maven在尝试下载依赖的时候会报pom找不到的警告:

Could not find artifact com.financial:financial-platform:pom:1.3.0-SNAPSHOT

恍然大悟

这时候才反应过来,虽然message-client不依赖common-utils或db-starter这些兄弟模块,但是它的pom.xml里有这么一段:

<parent>
    <groupId>com.financial</groupId>
    <artifactId>financial-platform</artifactId>
    <version>1.3.0-SNAPSHOT</version>
</parent>

Maven拉取message-client的时候,会先去找它的父pom。父pom找不到,后面的事儿就都黄了。

整个依赖解析的流程是这样的:

sequenceDiagram
    participant B as 业务系统
    participant N as Nexus私服
    participant P as financial-platform
    participant M as message-client
    
    B->>N: 请求message-client:1.3.0-SNAPSHOT
    N->>N: 找到message-client的jar
    N->>N: 读取message-client的pom
    N->>P: 需要financial-platform:1.3.0-SNAPSHOT的pom
    P-->>N: 404 Not Found
    N-->>B: 依赖解析失败

为什么需要父pom

有人可能会问,message-client都已经是个完整的jar了,为什么还要父pom呢?

其实父pom里会定义很多东西:

<!-- financial-platform父pom里通常有这些 -->
<properties>
    <java.version>11</java.version>
    <spring-boot.version>2.7.18</spring-boot.version>
    <rocketmq.version>4.9.7</rocketmq.version>
    ...
</properties>

<dependencyManagement>
    <dependencies>
        <!-- 统一管理RocketMQ、Redis、PostgreSQL等版本 -->
        ...
    </dependencies>
</dependencyManagement>

<build>
    <pluginManagement>
        <!-- 插件配置 -->
        ...
    </pluginManagement>
</build>

message-client的pom可能会引用父pom里定义的属性和配置。Maven需要把父子pom合并起来,才能得到一个完整的、可执行的pom。

Maven构建有效pom的过程很简单:解析子模块pom时,如果发现有parent标签,就去Nexus找父pom。找到后合并父子配置,如果父pom还有parent,就继续往上找。一直找到最顶层,然后从上到下合并所有配置,最后生成一个完整的有效pom。

正确的做法

所以正确的做法是,把父pom和message-client都deploy上去:

# 在financial-platform父工程目录执行
mvn clean deploy

这样Maven会把父pom和所有子模块都发布到Nexus。即使你只改了message-client,父pom也得发上去,因为版本号变了。

Maven的继承和聚合

说到这儿,顺便聊聊Maven的继承和聚合,很多人容易搞混。

继承是子模块继承父pom的配置,通过<parent>标签实现。聚合是父工程管理多个子模块,通过<modules>标签实现。

graph TB
    subgraph 继承关系
    P1[financial-platform<br/>配置和依赖版本] -.继承.-> C1[common-utils<br/>使用父配置]
    P1 -.继承.-> C2[message-client<br/>使用父配置]
    P1 -.继承.-> C3[db-starter<br/>使用父配置]
    end
    
    subgraph 聚合关系
    P2[financial-platform] --聚合--> C4[common-utils]
    P2 --聚合--> C5[message-client]
    P2 --聚合--> C6[db-starter]
    end
    
    style P1 fill:#e1f5ff
    style P2 fill:#ffe1f5

父pom里是这样的:

<!-- 聚合: 管理有哪些子模块 -->
<modules>
    <module>common-utils</module>
    <module>message-client</module>
    <module>db-starter</module>
</modules>

<!-- 继承: 提供给子模块的配置 -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>${rocketmq.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

子模块message-client里是这样的:

<!-- 继承: 指定从哪个父pom继承 -->
<parent>
    <groupId>com.financial</groupId>
    <artifactId>financial-platform</artifactId>
    <version>1.3.0-SNAPSHOT</version>
</parent>

<!-- 实际使用的依赖,版本从父pom继承 -->
<dependencies>
    <dependency>
        <groupId>org.apache.rocketmq</groupId>
        <artifactId>rocketmq-spring-boot-starter</artifactId>
        <!-- 版本号从父pom的dependencyManagement继承 -->
    </dependency>
</dependencies>

这两个是独立的机制,可以单独使用。但大部分时候我们会一起用,既让父工程聚合管理子模块,又让子模块继承父配置。

后来我们的处理

我们现在的做法是,每次版本升级,不管改了几个模块,都执行完整的deploy。虽然会把common-utils、message-client、db-starter都发一遍,有点浪费,但起码不会出幺蛾子。

另外在Jenkins的CI流程里加了个检查,如果pom的版本号变了,必须全量deploy,不允许只deploy单个模块。

#!/bin/bash
# Jenkins里的检查脚本
VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)

if [[ $VERSION == *"SNAPSHOT"* ]]; then
    echo "检测到SNAPSHOT版本: $VERSION"
    echo "执行全量deploy到Nexus"
    mvn clean deploy -DskipTests
else
    echo "Release版本: $VERSION" 
    # release版本走发布审批流程
    echo "需要审批后才能deploy"
    exit 1
fi

实际案例分析

我们再看一个实际的场景。假设业务系统order-service需要引用我们升级后的message-client:

<!-- order-service的pom.xml -->
<dependencies>
    <dependency>
        <groupId>com.financial</groupId>
        <artifactId>message-client</artifactId>
        <version>1.3.0-SNAPSHOT</version>
    </dependency>
</dependencies>

Maven构建order-service的时候,会先从本地或Nexus下载message-client的jar和pom。读取message-client的pom时发现它依赖父pom financial-platform:1.3.0,于是继续去找父pom。如果父pom不存在,整个构建就失败了。找到父pom后,Maven会合并父子配置,然后递归解析所有传递依赖,最后才能成功构建。

所以你看,这是个链式反应。中间任何一环缺失,整个构建都会挂掉。

就这样吧,希望能帮到遇到类似问题的朋友。这个坑我们已经踩过了,你们就别再踩了。下次升级message-client加新功能的时候,记得把整个framework都deploy上去,省得业务系统那边找你麻烦。

专为 LLM 设计的数据格式 TOON,可节省 60% Token

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

随着社交媒体的深度渗透,朋友圈、微博、Instagram 等平台已成为用户展示生活、分享瞬间的核心场景。其中,"九宫格"排版形式凭借其规整的视觉美感和内容叙事性,成为年轻用户(尤其是女性群体)高频使用的图片发布方式。

在接下来的内容中,我们将使用 NextJs 结合 sharp 来实现图片裁剪的功能。

编写基本页面

首先我们将编写一个图片裁剪的功能,以支持用户来生成不同规格的图片,例如 3x3、2x2 等格式。

如下代码所示:

"use client";

import { useState, useRef } from "react";

const Home = () => {
  const [rows, setRows] = useState(3);
  const [columns, setColumns] = useState(3);
  const [image, setImage] = useState<string | null>(null);
  const [splitImages, setSplitImages] = useState<string[]>([]);
  const [isProcessing, setIsProcessing] = useState(false);
  const [imageLoaded, setImageLoaded] = useState(false);
  const imageRef = useRef<HTMLImageElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleFileUpload = (file: File) => {
    if (!file.type.startsWith("image/")) {
      alert("请上传图片文件");

      return;
    }

    // 先重置状态
    setImage(null);
    setSplitImages([]);
    setImageLoaded(false);

    const reader = new FileReader();

    reader.onload = (e) => {
      if (e.target?.result) {
        setImage(e.target.result as string);
      }
    };

    reader.readAsDataURL(file);
  };

  // 修改图片加载完成的处理函数
  const handleImageLoad = () => {
    console.log("Image loaded"); // 添加日志以便调试
    setImageLoaded(true);
  };

  // 渲染切割辅助线
  const renderGuideLines = () => {
    if (!image || !imageRef.current || !imageLoaded) return null;

    const commonLineStyles = "bg-blue-500/50 absolute pointer-events-none";
    const imgRect = imageRef.current.getBoundingClientRect();
    const containerRect =
      imageRef.current.parentElement?.getBoundingClientRect();

    if (!containerRect) return null;

    const imgStyle = {
      left: `${imgRect.left - containerRect.left}px`,
      top: `${imgRect.top - containerRect.top}px`,
      width: `${imgRect.width}px`,
      height: `${imgRect.height}px`,
    };

    return (
      <div className="absolute pointer-events-none" style={imgStyle}>
        {/* 垂直线 */}
        {Array.from({ length: Math.max(0, columns - 1) }).map((_, i) => (
          <div
            key={`v-${i}`}
            className={`${commonLineStyles} top-0 bottom-0 w-[1px] md:w-[2px] backdrop-blur-sm`}
            style={{
              left: `${((i + 1) * 100) / columns}%`,
              transform: "translateX(-50%)",
            }}
          />
        ))}
        {/* 水平线 */}
        {Array.from({ length: Math.max(0, rows - 1) }).map((_, i) => (
          <div
            key={`h-${i}`}
            className={`${commonLineStyles} left-0 right-0 h-[1px] md:h-[2px] backdrop-blur-sm`}
            style={{
              top: `${((i + 1) * 100) / rows}%`,
              transform: "translateY(-50%)",
            }}
          />
        ))}
      </div>
    );
  };

  // 处理图片切割
  const handleSplitImage = async () => {
    if (!image) return;

    setIsProcessing(true);

    try {
      const response = await fetch(image);
      const blob = await response.blob();
      const file = new File([blob], "image.jpg", { type: blob.type });

      const formData = new FormData();
      formData.append("image", file);
      formData.append("rows", rows.toString());
      formData.append("columns", columns.toString());

      const res = await fetch("/api/split-image", {
        method: "POST",
        body: formData,
      });

      const data = await res.json();

      if (data.error) {
        throw new Error(data.error);
      }

      setSplitImages(data.pieces);
    } catch (error) {
      console.error("Failed to split image:", error);
      alert("图片切割失败,请重试");
    } finally {
      setIsProcessing(false);
    }
  };

  // 添加下载单个图片的函数
  const handleDownloadSingle = async (imageUrl: string, index: number) => {
    try {
      const response = await fetch(imageUrl);
      const blob = await response.blob();
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = `piece_${index + 1}.png`;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (error) {
      console.error("下载失败:", error);
      alert("下载失败,请重试");
    }
  };

  // 添加打包下载所有图片的函数
  const handleDownloadAll = async () => {
    try {
      // 如果没有 JSZip,需要先动态导入
      const JSZip = (await import("jszip")).default;
      const zip = new JSZip();

      // 添加所有图片到 zip
      const promises = splitImages.map(async (imageUrl, index) => {
        const response = await fetch(imageUrl);
        const blob = await response.blob();
        zip.file(`piece_${index + 1}.png`, blob);
      });

      await Promise.all(promises);

      // 生成并下载 zip 文件
      const content = await zip.generateAsync({ type: "blob" });
      const url = window.URL.createObjectURL(content);
      const link = document.createElement("a");
      link.href = url;
      link.download = "split_images.zip";
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (error) {
      console.error("打包下载失败:", error);
      alert("打包下载失败,请重试");
    }
  };

  // 修改预览区域的渲染函数
  const renderPreview = () => {
    if (!image) {
      return <p className="text-gray-400">切割后的图片预览</p>;
    }

    if (isProcessing) {
      return <p className="text-gray-400">正在处理中...</p>;
    }

    if (splitImages.length > 0) {
      return (
        <div className="relative w-full h-full flex items-center justify-center">
          <div
            className="grid gap-[3px] bg-[#242c3e]"
            style={{
              gridTemplateColumns: `repeat(${columns}, 1fr)`,
              gridTemplateRows: `repeat(${rows}, 1fr)`,
              width: imageRef.current?.width || "100%",
              height: imageRef.current?.height || "100%",
              maxWidth: "100%",
              maxHeight: "100%",
            }}
          >
            {splitImages.map((src, index) => (
              <div key={index} className="relative group">
                <img
                  src={src}
                  alt={`切片 ${index + 1}`}
                  className="w-full h-full object-cover"
                />
                <button
                  onClick={() => handleDownloadSingle(src, index)}
                  className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity"
                >
                  <svg
                    className="w-6 h-6 text-white"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
                    />
                  </svg>
                </button>
              </div>
            ))}
          </div>
        </div>
      );
    }

    return <p className="text-gray-400">点击切割按钮开始处理</p>;
  };

  return (
    <>
      <div className="fixed inset-0 bg-[#0B1120] -z-10" />
      <main className="min-h-screen w-full py-16 md:py-20">
        <div className="container mx-auto px-4 sm:px-6 max-w-7xl">
          {/* 标题区域 */}
          <div className="text-center mb-12 md:mb-16">
            <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-4 animate-fade-in">
              <span className="bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 text-transparent bg-clip-text bg-[size:400%] animate-gradient">
                图片切割工具
              </span>
            </h1>
            <p className="text-gray-400 text-base md:text-lg max-w-2xl mx-auto animate-fade-in-up">
              上传一张图片,快速将其切割成网格布局,支持自定义行列数。
            </p>
          </div>

          {/* 图片区域 - 调整高度和响应式布局 */}
          <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 md:gap-8 mb-12 md:mb-16">
            {/* 上传区域 - 调整高度 */}
            <div className="relative h-[400px] md:h-[500px] lg:h-[600px] bg-[#1a2234] rounded-xl overflow-hidden">
              <input
                ref={fileInputRef}
                type="file"
                accept="image/*"
                className="hidden"
                onChange={(e) => {
                  const file = e.target.files?.[0];
                  if (file) handleFileUpload(file);
                }}
                id="imageUpload"
              />
              <label
                htmlFor="imageUpload"
                className="absolute inset-0 flex flex-col items-center justify-center cursor-pointer"
              >
                {image ? (
                  <div className="relative w-full h-full flex items-center justify-center">
                    <img
                      ref={imageRef}
                      src={image}
                      alt="上传的图片"
                      className="max-w-full max-h-full object-contain"
                      onLoad={handleImageLoad}
                      key={image}
                    />
                    {renderGuideLines()}
                  </div>
                ) : (
                  <>
                    <div className="p-4 rounded-full bg-[#242c3e] mb-4">
                      <svg
                        className="w-8 h-8 text-gray-400"
                        fill="none"
                        stroke="currentColor"
                        viewBox="0 0 24 24"
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          strokeWidth={1.5}
                          d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
                        />
                      </svg>
                    </div>
                    <p className="text-gray-400">点击或拖拽图片到这里上传</p>
                  </>
                )}
              </label>
            </div>

            {/* 预览区域 - 调整高度 */}
            <div className="h-[400px] md:h-[500px] lg:h-[600px] bg-[#1a2234] rounded-xl flex items-center justify-center">
              {renderPreview()}
            </div>
          </div>

          {/* 控制器 - 添加上下边距的容器 */}
          <div className="py-4 md:py-6">
            <div className="flex flex-col sm:flex-row items-center justify-center gap-4 md:gap-6">
              <div className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto">
                <div className="bg-[#1a2234] rounded-xl px-5 py-2.5 flex items-center gap-4 border border-[#242c3e] w-full sm:w-auto">
                  <span className="text-gray-400 font-medium min-w-[40px]">
                    行数
                  </span>
                  <div className="flex items-center gap-2 bg-[#242c3e] rounded-lg p-1 flex-1 sm:flex-none justify-center">
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setRows(Math.max(1, rows - 1))}
                    >
                      -
                    </button>
                    <span className="text-white min-w-[32px] text-center font-medium">
                      {rows}
                    </span>
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setRows(rows + 1)}
                    >
                      +
                    </button>
                  </div>
                </div>

                <div className="bg-[#1a2234] rounded-xl px-5 py-2.5 flex items-center gap-4 border border-[#242c3e] w-full sm:w-auto">
                  <span className="text-gray-400 font-medium min-w-[40px]">
                    列数
                  </span>
                  <div className="flex items-center gap-2 bg-[#242c3e] rounded-lg p-1 flex-1 sm:flex-none justify-center">
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setColumns(Math.max(1, columns - 1))}
                    >
                      -
                    </button>
                    <span className="text-white min-w-[32px] text-center font-medium">
                      {columns}
                    </span>
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setColumns(columns + 1)}
                    >
                      +
                    </button>
                  </div>
                </div>

                <button
                  className="px-5 py-2.5 bg-[#1a2234] text-gray-400 rounded-xl hover:bg-[#242c3e] hover:text-white transition-all font-medium border border-[#242c3e] w-full sm:w-auto"
                  onClick={() => {
                    setRows(3);
                    setColumns(3);
                  }}
                >
                  重置
                </button>
              </div>

              <div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
                <button
                  className="px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium shadow-lg shadow-blue-500/20 w-full sm:w-auto"
                  disabled={!image || isProcessing}
                  onClick={handleSplitImage}
                >
                  {isProcessing ? "处理中..." : "切割图片"}
                </button>
                <button
                  className="px-6 py-3 bg-gradient-to-r from-red-500 to-red-600 text-white rounded-xl hover:from-red-600 hover:to-red-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium shadow-lg shadow-red-500/20 w-full sm:w-auto"
                  onClick={() => {
                    setImage(null);
                    setSplitImages([]);
                    setImageLoaded(false);

                    if (fileInputRef.current) {
                      fileInputRef.current.value = "";
                    }
                  }}
                  disabled={!image}
                >
                  清除
                </button>
              </div>

              {/* 下载按钮 */}
              {splitImages.length > 0 && (
                <button
                  onClick={handleDownloadAll}
                  className="px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-xl hover:from-green-600 hover:to-green-700 transition-all font-medium shadow-lg shadow-green-500/20 flex items-center justify-center gap-2 w-full sm:w-auto"
                >
                  <svg
                    className="w-5 h-5"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
                    />
                  </svg>
                  打包下载
                </button>
              )}
            </div>
          </div>

          {/* 底部留白 */}
          <div className="h-16 md:h-20"></div>
        </div>
      </main>
    </>
  );
};

export default Home;

在上面的代码中,当用户上传图片兵当图片加载完成之后,renderGuideLines 方法会绘制图片的切割网格,显示垂直和水平的分隔线,便于用户预览切割后的效果。

它会根据用户设置的行数(rows)和列数(columns),这些切割线将被动态渲染。用户设置完行列数后,点击 "切割图片" 按钮,会触发 handleSplitImage 函数,将图片传递到后端进行切割。图片会被发送到后端接口 /api/split-image,并返回切割后的图片数据(即每个小图的 URL)。

最终 ui 效果如下图所示:

20250218163115

设计 API

前面的内容中,我们已经编写了前端的 ui,接下来我们要设计我们的 api 接口以支持前端页面调用。

首先我们要先知道一个概念,sharp 是一个高性能的 Node.js 图像处理库,支持各种常见的图像操作,如裁剪、调整大小、旋转、转换格式等。它基于 libvips(一个高效的图像处理库),与其他一些图像处理库相比,它的处理速度更快,内存消耗也更低。

而 Next.js 是一个服务器端渲染(SSR)的框架,可以通过 API 路由来处理用户上传的文件。在 API 路由中使用 sharp 可以确保图像在服务器端得到处理,而不是在客户端进行,这样可以减轻客户端的负担,并且保证图像在服务器上处理完成后再发送到客户端,从而提高页面加载速度。

如下代码所示:

import sharp from "sharp";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  try {
    const data = await request.formData();
    const file = data.get("image") as File;
    const rows = Number(data.get("rows"));
    const columns = Number(data.get("columns"));

    if (!file || !rows || !columns) {
      return NextResponse.json(
        { error: "Missing required parameters" },
        { status: 400 }
      );
    }

    if (!file.type.startsWith("image/")) {
      return NextResponse.json({ error: "Invalid file type" }, { status: 400 });
    }

    const buffer = Buffer.from(await file.arrayBuffer());

    if (!buffer || buffer.length === 0) {
      return NextResponse.json(
        { error: "Invalid image buffer" },
        { status: 400 }
      );
    }

    const image = sharp(buffer);
    const metadata = await image.metadata();

    if (!metadata.width || !metadata.height) {
      return NextResponse.json(
        { error: "Invalid image metadata" },
        { status: 400 }
      );
    }

    console.log("Processing image:", {
      width: metadata.width,
      height: metadata.height,
      format: metadata.format,
      rows,
      columns,
    });

    const pieces: string[] = [];
    const width = metadata.width;
    const height = metadata.height;
    const pieceWidth = Math.floor(width / columns);
    const pieceHeight = Math.floor(height / rows);

    if (pieceWidth <= 0 || pieceHeight <= 0) {
      return NextResponse.json(
        { error: "Invalid piece dimensions" },
        { status: 400 }
      );
    }

    for (let i = 0; i < rows; i++) {
      for (let j = 0; j < columns; j++) {
        const left = j * pieceWidth;
        const top = i * pieceHeight;
        const currentWidth = j === columns - 1 ? width - left : pieceWidth;
        const currentHeight = i === rows - 1 ? height - top : pieceHeight;

        try {
          const piece = await image
            .clone()
            .extract({
              left,
              top,
              width: currentWidth,
              height: currentHeight,
            })
            .toBuffer();

          pieces.push(
            `data:image/${metadata.format};base64,${piece.toString("base64")}`
          );
        } catch (err) {
          console.error("Error processing piece:", { i, j, err });
          throw err;
        }
      }
    }

    return NextResponse.json({ pieces });
  } catch (error) {
    console.error("Error processing image:", error);

    return NextResponse.json(
      {
        error: "Failed to process image",
        details: error instanceof Error ? error.message : "Unknown error",
      },
      { status: 500 }
    );
  }
}

export const config = {
  api: {
    bodyParser: {
      sizeLimit: "10mb",
    },
  },
};

在上面的这些代码中,它的具体流程如下:

  1. 接收请求:首先通过 POST 方法接收包含图片和切割参数(行数和列数)的表单数据。request.formData() 用来解析表单数据并提取文件和参数。

  2. 参数验证:检查文件类型是否为图片,确保上传数据完整且有效。如果缺少必需的参数或上传的不是图片,返回相应的错误信息。

  3. 图片处理:通过 sharp 库将图片数据转换为可操作的 Buffer,然后获取图片的元数据(如宽度、高度、格式)。如果图片的元数据无效或获取不到,返回错误。

  4. 计算切割尺寸:根据用户输入的行数和列数计算每个小块的宽高,并检查计算出来的尺寸是否有效。如果计算出的尺寸不合适,返回错误。

  5. 图片切割:使用 sharp 的 extract 方法对图片进行切割。每次提取一个小块后,将其转换为 base64 编码的字符串,并保存到 pieces 数组中。

  6. 返回结果:成功处理后,将切割后的图片数据作为 JSON 响应返回,每个图片切块以 base64 编码形式存储。若遇到错误,捕获并返回详细的错误信息。

  7. 配置:通过 config 配置,设置请求体的最大大小限制为 10MB,防止上传过大的文件导致请求失败。

这里的代码完成之后,我们还要设计一下 next.config.mjs 文件,如下:

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config) => {
    config.externals = [...config.externals, "sharp"];
    return config;
  },
};

export default nextConfig;

当我们点击切割图片的时候,最终生成的效果如下图所示:

20250218164009

总结

完整项目地址

如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。

如果该项目对你有帮助或者对这个项目感兴趣,欢迎 Star⭐️⭐️⭐️

最后再来提一些这两个开源项目,它们都是我们目前正在维护的开源项目:

🐒🐒🐒

【LM-PDF】一个大模型时代的 PDF 极速预览方案是如何实现的?

最终效果示例(测试文档:290 页)

Kapture 2025-11-18 at 23.45.35.gif

开源地址: github.com/chennlang/l… (如果觉得还不错,记得留下你的 star,这对我有很大帮助!)

背景

随着 AGI 的日益发展,多模态的大模型也逐渐成为常态,出现在大众视野中,不过对于要求较高的场景,识别效果还是缺点意思,主要还是因为文档解析是一个复杂的流程(layout 分析 + 表格、文字识别 + 切片 + 原文对比、段落划分等),所以传统的 RAG 流程还是主流的方式。

大模型要去 “看” 到世界,首先得理解图片、文档。在 RAG 的流程中,大模型需要去学习本地的文档,从而生成更加专业的回答。而这些存量的文档大多数是 pdf 格式的,或者是以图片存在的。所以我们第一步要解决的问题就是如何把文档中的信息完整、准确的提取出来。

PDF 渲染器其实是文档渲染的一个通用的文档展示方案,如果假设我们是在一个照相机后面看世界,所有图片、文档都能被看做是一张张图片,最终汇总起来就是一本 pdf 文档,所以理论上所有东西都能用 PDF 渲染器显示。

在 AGI 的前端项目中,无论是训练模型语料、还是模型回答原文查看、还是切片来源,都需要把原文档联系起来。所以都需要同时展示原文和回答的功能。

现有 PDF 预览方案

特性 pdf.js react-pdf react-pdf-viewer
类型 JavaScript 库 React 组件库 React 组件库
依赖 pdf.js pdf.js
UI 无(需手动实现) 基础(需手动实现工具栏等) 完整(提供工具栏、缩略图等)
功能 渲染、缩放、搜索等 渲染、页面懒加载等 渲染、搜索、缩放、插件支持等
定制性 高(底层 API) 中(组件化) 中高(插件和主题定制)
性能 取决于 PDF 复杂度和实现 取决于 PDF 复杂度和实现 取决于 PDF 复杂度和实现
学习成本 高(需处理 UI 和交互) 中(React 组件) 中高(API 和插件系统)
适用场景 高度自定义、非 React 环境 React 项目,基础预览 React 项目,功能齐全的查看器

目前主流开源的 pdf 渲染器都是基于 pdf.js 封装实现,其中比较有代表性的就是 react-pdf和 react-pdf-viewer,选型建议:

  • 如果你的项目基于 React,且需要一个开箱即用的 PDF 查看器,推荐选择 react-pdf-viewer
  • 如果你仅需在 React 中渲染 PDF 页面并希望自行设计 UI,建议使用 react-pdf
  • 如果你不在 React 环境中,或需要底层控制,建议直接使用 pdf.js

现存问题

一直以来,我都是使用比较成熟的开源库 react-pdf 渲染 pdf 文档。不过,随着使用的深入,各种问题也随之浮现。例如开源的产品没法满足高度定制化、字体兼容问题导致显示错误.... 而最大的问题,是性能!

  • 场景1: 500 页的 pdf 文档如果不做分页,市面上几乎没有一款 pdf 渲染器能做到流畅的滚动加载。
  • 场景2:加载时间长,100M的文档,需要下载完才能预览,网络差的用户需要等 20分钟后才能看到。

综上问题,本来原文档预览是一个方面使用者快速去对比分片、对比回答结果的快捷方式,却因为以上问题,使用起来特别难受。

react-pdf 兼容性问题可参考:全面解析 React-PDF 的浏览器兼容性及其解决策略背景 最近使用 react-pdf 进行 pdf 文件预览。上线 - 掘金

本文适用范围说明

本文探讨的技术方案基于以下核心需求:

  1. PDF 预览模式:采用无限滚动(Infinite Scroll)方式浏览文件内容,而非传统分页器(Pager)模式(逐页或固定页数翻页)。
  2. 性能要求:需实现 PDF 文件的秒级加载,确保流畅体验。

重点说明: 无限滚动模式更符合现代用户习惯,适用于大多数实际场景。本文内容不涉及分页器模式的实现逻辑。

想法萌生

就在我百思不得其解的时候,我看到了一款闭源的 canvas 实现的 pdf 渲染器。全文只有一个 canvas 元素!当然,简单体验了下,页数很多时依然会很卡,甚至不能用。

受此启发,所以我想,既然 pdf 文件在 OCR 识别之前的第一步,就一定是把每一页切成一张图片,那么基于这个场景下,我们完全可以使用图片来渲染呀,完全不用加载文件。

假设视图内只有一页,那么 canvas 中只会渲染 1 张图片,那速度岂不是秒开?

当然,pdf 文件流是二进制的,也能通过分段获取其中一部分文档。可是如何知道每一页的开始和结束符,这是一个问题。

lm-pdf

为了方便下文讲解,我将此方案先命名为 lm-pdf, 主要是为了体现其出色的加载速度。

技术选型:react-konva

有了以上思路,实现起来就是时间的问题了。我选用了 canvas 作为渲染底座,搜索一圈之后发现 konvajs在这个场景下非常适合。结合 react-konva, 在画布上渲染元素就非常简单了。

示例:在画布上渲染一张图片

import { Stage, Layer, Image } from 'react-konva';

class App extends Component {
  render() {
    return (
      <Stage width={window.innerWidth} height={window.innerHeight}>
        <Layer>
          <Image x={0} y={0} image={...}></ Image>
        </Layer>
      </Stage>
    );
  }
}

当然,使用 canvas 渲染还不够,既然要做到性能最好,我们还需要加上虚拟滚动。

核心功能:canvas 虚拟滚动

很多人会说,都使用 canvas 了还使用什么虚拟滚动?可是你要知道,如果大量的元素常驻在画布上,加上滚动时,所有位置都要偏移,也就是说所有元素的位置都会被重新计算一遍。canvas 是按帧渲染的,这样 GPU 渲染肯定错错有余,不过内存和 CPU 性能却吃不消了!所以,要做就做到最好的! 而虚拟滚动恰好就能解决这个问题,因为视窗内同时显示的元素最多不超过 5 个,那么最多就这 5 个元素的计算量,会非常低。

虚拟滚动是什么

虚拟滚动(Virtual Scrolling)是一种优化长列表渲染性能的技术。其基本原理是只渲染可视区域内的元素,而非整个列表,从而减少DOM节点的数量和提高页面性能。

虚拟滚动本身的原理说起来很简单,无非就是通过容器高度和滚动距离动态渲染子元素。不过,要实现一个基于 canvas 的虚拟滚动器,实现过程中,却有很多小细节值得分享。

传统实现方案

Canvas 的虚拟滚动方案和常规实现方案有所不同,也有相同之处。所以我们需要先了解下传统的滚动条方案是怎么实现的。方便理解文章后面的内容。

完整 Demo 如下:

import React, { useState, useEffect, useRef } from 'react';

// 虚拟滚动列表组件
const VirtualScrollList = ({ items, itemHeight }) => {
    // 状态:可见项的起始和结束索引
    const [startIndex, setStartIndex] = useState(0);
    const [endIndex, setEndIndex] = useState(10);

    // 引用:用于访问滚动容器
    const containerRef = useRef(null);

    const handleScroll = () => {
        // 获取当前滚动位置
        const scrollTop = containerRef.current.scrollTop;

        // 计算新的起始索引
        const newStartIndex = Math.floor(scrollTop / itemHeight);

        // 计算新的结束索引
        const newEndIndex = newStartIndex + Math.ceil(containerRef.current.clientHeight / itemHeight);

        // 更新可见项的索引
        setStartIndex(newStartIndex);
        setEndIndex(newEndIndex);
    };

    // 滚动事件监听
    useEffect(() => {
        const container = containerRef.current;
        container.addEventListener('scroll', handleScroll);

        return () => {
            container.removeEventListener('scroll', handleScroll);
        };
    }, []);

    // 可见项
    const visibleItems = items.slice(startIndex, endIndex);

    // 计算占位符高度
    const placeholderHeight = items.length * itemHeight;

    return (
        <div style={{ height: '300px', overflowY: 'auto' }} ref={containerRef}>
            <div style={{ height: `${placeholderHeight}px`, position: 'relative' }}>
                <div style={{ position: 'absolute', top: `${startIndex * itemHeight}px`, left: 0 }}>
                    {visibleItems.map((item, index) => (
                        // 渲染可见项
                        <div key={index} style={{ height: `${itemHeight}px` }}>
                            {item}
                        </div>
                    ))}
                </div>
            </div>
        </div>
    );
};

// 使用示例
const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`);
const App = () => (
    <div>
        <h1>虚拟滚动列表</h1>
        <VirtualScrollList items={items} itemHeight={30} />
    </div>
);

export default App;

定义一个父容器,在容器中放一个占位符,占位符高度是所以 item 的总和,从而达到撑开父容器,出现滚动条。然后其子元素 item 使用 absolute 的方式悬浮在占位符 元素上。然后通过滚动的距离计算出要显示的子元素 visibleItems,渲染在页面上即可。

Canvas 虚拟滚动实现方案

下面将使用伪代码展示核心原理。

一、canvas 内并没有滚动条的概念,所以我们需要自己实现一个滚动条。

virtual-scroll-bar 组件

// Item
interface Item {
  id: string | number;
  height: number;
  [k: string]: any;
}

interface Props {
    items: Item [];
    onVisibleItemChange: (items: Item[]) => void;
    onScroll?: (scroll: { left: number; top: number }) => void;
}
const VirtualScrollBar = ({ items }: ) => {
    // 滚动触发
    function handleScroll (scroll) {
        updateVisibleItems(scroll)
        onScroll(scroll)
    }

    // 计算可视元素
    function updateVisibleItems (scroll) {
       const visibleItems = []
       // .....省略计算过程
       onVisibleItemChange(visibleItems)
    }

    return <div ref={divRef} className={`v-scroll-bar ${direction}`}>
        <div style={{
            height: items.reduce((sum, item) => (sum += item.height), 0),
        }}>
        </div>
    </div>
}

同样的方式,我们在 div 中加入一个占位符,高度是所以 item 的总和。然后通过监听 divRef 的滚动,计算出视图内出现的元素。

核心逻辑(伪代码)

// 当前显示元素
const [displayItems, setDisplayItems] = useState<PageItem[]>([]);

const onScroll: VirtualScrollBarProps["onScroll"] = ({ left, top }) => {
    // 整体 y 方向偏移, 这里使用 setData 而不是 setData => old,
    // 因为滚动频繁,利用 setData 更新机制可以做到节流,提升性能
    setDisplayItems((old) =>
      old.map((m) => ({
        ...m,
        y: m.top - top,
      }))
)};


// 对比新旧值,更新 Y 的坐标
// 滚动的过程中,y 会偏移,新的 items 进来,如果有公共的 items ,要和旧的保持一致。
function diffAndUpdateY () {}

function onVisibleItemChange(originItems: VirtualScrollBarProps["items"]) {
    const items = originItems as PageItem[];
    // 这里要做一件事,新的 items 会把 y 的坐标全部重新排过,这会有问题,表现为突然弹跳位置。
    // 如果新的 items 中和旧的 items 中有共同的 item, 那么以旧的 item 的 y 为准,保持不变
    // 那么新出现的,排在旧的上面或下面
    startTransition(() => {
      startTransition(() => {
        setDisplayItems((old) => diffAndUpdateY(old, items));
      });
    });
}


<VirtualScrollBar
    items={pages}
    onVisibleItemChange={onVisibleItemChange}
    onScroll={onScroll}
></VirtualScrollBar>

核心功能:如何实现页面平滑切换

不过你会发现,页面是一卡一卡的,像是幻灯片,子元素没有随着滚动而移动的。只是会到达一定滚动距离后,就会全部替换成新的元素。因为我们还没有做偏移,元素要随着滚动在容器内上下移动,直到移动到容器外,才替换成新的元素。实现偏移:所以我们把整体元素 y 值随着滚动偏移, y = y + offsetY, 这样元素就会滚动效果 。

image.png

要想实现流畅滚动(平滑切换),还要实现新旧元素 Diff ,原理如下:

如上图,假设我们当前视图显示了 [元素1、元素2],子元素高度都是 200px,滚动了 30px 后,显示了 [元素1、元素2、元素 3]。

元素 1、元素 2 在是它们的交集。所以元素 1、元素 2 的位置要保持以前的位置不变(用户界面能看到的已有的元素,不能因为切换了新的元素而改变位置),而元素 3 应该在元素 2 后面。

元素 初始坐标(滚动前) 滚动后坐标(向下滚动 30px)
元素1 [0, 0] [0, -30]
元素2 [0, 200] [0, 170]
元素3 [0, 370]

做完 diff 后,从用户的角度就会发现是连续的滚动,而实际是不停的在切换新元素。

性能优化:异步加载,减少线程阻塞

因为滚动的过程中是连续的,例如从第1页滚到 100页,那么中间的 2-99 都会被渲染一遍,其实我只是想看第 100 页,这会严重拖慢页面的渲染性能。

// page.ts
useEffect(() => {
    if (!blocks.length) return;

    // 延迟渲染定时器
    const timer = setTimeout(() => {
      // 开始渲染
      setDisplayBlocks(blocks);
    }, 500);

    return () => {
      clearTimeout(timer);
    };
  }, [blocks]);

上面我用了一个定时器,完美解决了这个问题,只有等组件出现在可视区域,渲染后且 500 毫秒内没有消失,才会真正渲染。这样就能避免无效的渲染任务。

500 毫秒最终使用过程中被我改成了 200,不然会出现明显的等待渲染过程,影响体验。

性能对比

页面加载速度测试,我采用目前开源中用的最多的 react-pdflm-pdf 作对比:

  • 测试指标: 首页渲染时间
  • 测试网速:13.9 Mbps
PDF 测试文件页码 react-pdf lm-pdf
3页 3.5s 1s
50页 7s 1.5s
344页 109s 2.5s
1000页 240s 2.5s

react-pdf 的加载速度取决于文档的大小,下载的网速影响。而 lm-pdf 的优势在于无论多少页,都趋近于 2.5s,打开的速度取决于单页的图片大小。

lm-pdf 优缺点

优点:

  • 极快的首次加载速度(和文件大小、页数无关)
  • 丝滑的滚动体验
  • 极低的内存占用
  • 极少的页面 DOM

缺点:

  • 目前不支持复制 PDF 文本(研究中)
  • PDF 必须先被切成图片

持续优化的点:其实还可以在远端无损压缩图片,进一步提高渲染速度。

总结

如果你看重的是加速速度和性能,lm-pdf 绝对能满足你的需求,不过此方案还存在一些局限,例如强依赖后端生成 pdf 单页图片、不支持复制 PDF 文本等,不过目前已开源,后续还会持续完善,也希望感兴趣的同学一起 PR 共建。

Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

一、序 言

在分布式系统中,网络请求的可靠性直接决定了服务质量。想象一下,当你的支付系统因第三方API超时导致订单状态不一致,或因瞬时网络抖动造成用户操作失败,这些问题往往源于HTTP客户端缺乏完善的超时控制和重试策略。Golang标准库虽然提供了基础的HTTP客户端实现,但在高并发、高可用场景下,我们需要更精细化的策略来应对复杂的网络环境。

二、超时控制的风险与必要性

2024年Cloudflare的网络报告显示,78%的服务中断事件与不合理的超时配置直接相关。当一个HTTP请求因目标服务无响应而长时间阻塞时,不仅会占用宝贵的系统资源,更可能引发级联故障——大量堆积的阻塞请求会耗尽连接池资源,导致新请求无法建立,最终演变为服务雪崩。超时控制本质上是一种资源保护机制,通过设定合理的时间边界,确保单个请求的异常不会扩散到整个系统。

超时配置不当的两大典型风险:

  • DoS攻击放大效应:缺乏连接超时限制的客户端,在遭遇恶意慢响应攻击时,会维持大量半开连接,迅速耗尽服务器文件描述符。
  • 资源利用率倒挂:当ReadTimeout设置过长(如默认的0表示无限制),慢请求会长期占用连接池资源。Netflix的性能数据显示,将超时时间从30秒优化到5秒后,连接池利用率提升了400% ,服务吞吐量增长2.3倍。

三、超时参数示例

永远不要依赖默认的http.DefaultClient,其Timeout为0(无超时)。生产环境必须显式配置所有超时参数,形成防御性编程习惯。

以下代码展示如何通过net.Dialer配置连接超时和keep-alive策略:

transport := &http.Transport{
    DialContext: (&net.Dialer{
        Timeout:   3 * time.Second,  // TCP连接建立超时
        KeepAlive: 30 * time.Second, // 连接保活时间
        DualStack: true,             // 支持IPv4/IPv6双栈
    }).DialContext,
    ResponseHeaderTimeout: 5 * time.Second, // 等待响应头超时
    MaxIdleConnsPerHost:   100,             // 每个主机的最大空闲连接
}
client := &http.Client{
    Transport: transport,
    Timeout:   10 * time.Second, // 整个请求的超时时间
}

四、基于context的超时实现

context.Context为请求超时提供了更灵活的控制机制,特别是在分布式追踪和请求取消场景中。与http.Client的超时参数不同,context超时可以实现请求级别的超时传递,例如在微服务调用链中传递超时剩余时间。

4.1 上下文超时传递

如图所示,context通过WithTimeout或WithDeadline创建超时上下文,在请求过程中逐级传递。当父context被取消时,子context会立即终止请求,避免资源泄漏。

4.2 带追踪的超时控制

func requestWithTracing(ctx context.Context) (*http.Response, error) {
    // 从父上下文派生5秒超时的子上下文
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel() // 确保无论成功失败都取消上下文
    
    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    if err != nil {
        return nil, fmt.Errorf("创建请求失败: %v", err)
    }
    
    // 添加分布式追踪信息
    req.Header.Set("X-Request-ID", ctx.Value("request-id").(string))
    
    client := &http.Client{
        Transport: &http.Transport{
            DialContext: (&net.Dialer{
                Timeout: 2 * time.Second,
            }).DialContext,
        },
        // 注意: 此处不设置Timeout,完全由context控制
    }
    
    resp, err := client.Do(req)
    if err != nil {
        // 区分上下文取消和其他错误
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("请求超时: %w", ctx.Err())
        }
        return nil, fmt.Errorf("请求失败: %v", err)
    }
    return resp, nil
}

关键区别:context.WithTimeout与http.Client.Timeout是叠加关系而非替代关系。当同时设置时,取两者中较小的值。

五、重试策略

网络请求失败不可避免,但盲目重试可能加剧服务负载,甚至引发惊群效应。一个健壮的重试机制需要结合错误类型判断、退避算法和幂等性保证,在可靠性和服务保护间取得平衡。

5.1 指数退避与抖动

指数退避通过逐渐增加重试间隔,避免对故障服务造成二次冲击。Golang实现中需加入随机抖动,防止多个客户端同时重试导致的波峰效应

以下是简单的重试实现示例:

type RetryPolicy struct {
    MaxRetries    int
    InitialBackoff time.Duration
    MaxBackoff    time.Duration
    JitterFactor  float64 // 抖动系数,建议0.1-0.5
}


// 带抖动的指数退避
func (rp *RetryPolicy) Backoff(attempt int) time.Duration {
    if attempt <= 0 {
        return rp.InitialBackoff
    }
    // 指数增长: InitialBackoff * 2^(attempt-1)
    backoff := rp.InitialBackoff * (1 << (attempt - 1))
    if backoff > rp.MaxBackoff {
        backoff = rp.MaxBackoff
    }
    // 添加抖动: [backoff*(1-jitter), backoff*(1+jitter)]
    jitter := time.Duration(rand.Float64() * float64(backoff) * rp.JitterFactor)
    return backoff - jitter + 2*jitter // 均匀分布在抖动范围内
}


// 通用重试执行器
func Retry(ctx context.Context, policy RetryPolicy, fn func() error) error {
    var err error
    for attempt := 0; attempt <= policy.MaxRetries; attempt++ {
        if attempt > 0 {
            // 检查上下文是否已取消
            select {
            case <-ctx.Done():
                return fmt.Errorf("重试被取消: %w", ctx.Err())
            default:
            }
            
            backoff := policy.Backoff(attempt)
            timer := time.NewTimer(backoff)
            select {
            case <-timer.C:
            case <-ctx.Done():
                timer.Stop()
                return fmt.Errorf("重试被取消: %w", ctx.Err())
            }
        }
        
        err = fn()
        if err == nil {
            return nil
        }
        
        // 判断是否应该重试
        if !shouldRetry(err) {
            return err
        }
    }
    return fmt.Errorf("达到最大重试次数 %d: %w", policy.MaxRetries, err)
}

5.2 错误类型判断

盲目重试所有错误不仅无效,还可能导致数据不一致。shouldRetry函数需要精确区分可重试错误类型:

func shouldRetry(err error) bool {
    // 网络层面错误
    var netErr net.Error
    if errors.As(err, &netErr) {
        // 超时错误和临时网络错误可重试
        return netErr.Timeout() || netErr.Temporary()
    }
    
    // HTTP状态码判断
    var respErr *url.Error
    if errors.As(err, &respErr) {
        if resp, ok := respErr.Response.(*http.Response); ok {
            switch resp.StatusCode {
            case 429, 500, 502, 503, 504:
                return true // 限流和服务器错误可重试
            case 408:
                return true // 请求超时可重试
            }
        }
    }
    
    // 应用层自定义错误
    if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrServiceUnavailable) {
        return true
    }
    
    return false
}

行业最佳实践:Netflix的重试策略建议:对5xx错误最多重试3次,对429错误使用Retry-After头指定的间隔,对网络错误使用指数退避(初始100ms,最大5秒)。

六、幂等性保证

重试机制的前提是请求必须是幂等的,否则重试可能导致数据不一致(如重复扣款)。实现幂等性的核心是确保多次相同请求产生相同的副作用,常见方案包括请求ID机制和乐观锁。

6.1 请求ID+Redis实现

基于UUID请求ID和Redis的幂等性检查机制,可确保重复请求仅被处理一次:

type IdempotentClient struct {
    redisClient *redis.Client
    prefix      string        // Redis键前缀
    ttl         time.Duration // 幂等键过期时间
}


// 生成唯一请求ID
func (ic *IdempotentClient) NewRequestID() string {
    return uuid.New().String()
}


// 执行幂等请求
func (ic *IdempotentClient) Do(req *http.Request, requestID string) (*http.Response, error) {
    // 检查请求是否已处理
    key := fmt.Sprintf("%s:%s", ic.prefix, requestID)
    exists, err := ic.redisClient.Exists(req.Context(), key).Result()
    if err != nil {
        return nil, fmt.Errorf("幂等检查失败: %v", err)
    }
    if exists == 1 {
        // 返回缓存的响应或标记为重复请求
        return nil, fmt.Errorf("请求已处理: %s", requestID)
    }
    
    // 使用SET NX确保只有一个请求能通过检查
    set, err := ic.redisClient.SetNX(
        req.Context(),
        key,
        "processing",
        ic.ttl,
    ).Result()
    if err != nil {
        return nil, fmt.Errorf("幂等锁失败: %v", err)
    }
    if !set {
        return nil, fmt.Errorf("并发请求冲突: %s", requestID)
    }
    
    // 执行请求
    client := &http.Client{/* 配置 */}
    resp, err := client.Do(req)
    if err != nil {
        // 请求失败时删除幂等标记
        ic.redisClient.Del(req.Context(), key)
        return nil, err
    }
    
    // 请求成功,更新幂等标记状态
    ic.redisClient.Set(req.Context(), key, "completed", ic.ttl)
    return resp, nil
}

关键设计:幂等键的TTL应大于最大重试周期+业务处理时间。例如,若最大重试间隔为30秒,处理耗时5秒,建议TTL设置为60秒,避免重试过程中键过期导致的重复处理。

6.2 业务层幂等策略

对于写操作,还需在业务层实现幂等逻辑:

  • 更新操作:使用乐观锁(如UPDATE ... WHERE version = ?)
  • 创建操作:使用唯一索引(如订单号、外部交易号)
  • 删除操作:采用"标记删除"而非物理删除

七、性能优化

高并发场景下,HTTP客户端的性能瓶颈通常不在于网络延迟,而在于连接管理和内存分配。通过合理配置连接池和复用资源,可显著提升吞吐量。

7.1 连接池配置

http.Transport的连接池参数优化对性能影响巨大,以下是经过生产验证的配置:

func NewOptimizedTransport() *http.Transport {
    return &http.Transport{
        // 连接池配置
        MaxIdleConns:        1000,  // 全局最大空闲连接
        MaxIdleConnsPerHost: 100,   // 每个主机的最大空闲连接
        IdleConnTimeout:     90 * time.Second, // 空闲连接超时时间
        
        // TCP配置
        DialContext: (&net.Dialer{
            Timeout:   2 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        
        // TLS配置
        TLSHandshakeTimeout: 5 * time.Second,
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: false,
            MinVersion:         tls.VersionTLS12,
        },
        
        // 其他优化
        ExpectContinueTimeout: 1 * time.Second,
        DisableCompression:    false, // 启用压缩
    }
}

Uber的性能测试显示,将MaxIdleConnsPerHost从默认的2提升到100后,针对同一API的并发请求延迟从85ms降至12ms,吞吐量提升6倍。

7.2 sync.Pool内存复用

频繁创建http.Request和http.Response会导致大量内存分配和GC压力。使用sync.Pool复用这些对象可减少90%的内存分配:

var requestPool = sync.Pool{
    New: func() interface{} {
        return &http.Request{
            Header: make(http.Header),
        }
    },
}


// 从池获取请求对象
func AcquireRequest() *http.Request {
    req := requestPool.Get().(*http.Request)
    // 重置必要字段
    req.Method = ""
    req.URL = nil
    req.Body = nil
    req.ContentLength = 0
    req.Header.Reset()
    return req
}


// 释放请求对象到池
func ReleaseRequest(req *http.Request) {
    requestPool.Put(req)
}

八、总结

HTTP请求看似简单,但它连接着整个系统的"血管"。忽视超时和重试,就像在血管上留了个缺口——平时没事,压力一来就大出血。构建高可靠的网络请求需要在超时控制、重试策略、幂等性保证和性能优化之间取得平衡。

记住,在分布式系统中,超时和重试不是可选功能,而是生存必需。

扩展资源:

往期回顾

  1. RN与hawk碰撞的火花之C++异常捕获|得物技术

  2. 得物TiDB升级实践

  3. 得物管理类目配置线上化:从业务痛点到技术实现

  4. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  5. RAG—Chunking策略实战|得物技术

文 /梧

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

我为什么说全栈正在杀死前端?

大家好,我又来了🤣。 打开2025年的招聘软件,十个资深前端岗位,有八个在JD(职位描述)里写着:“有Node.js/Serverless/全栈经验者优先”。 全栈 👉 成了我们前端工程师内卷的一种方

颜色网站为啥都收费?自己做个要花多少钱?

你是小阿巴,一位没有对象的程序员。

这天深夜,你打开了某个颜色网站,准备鉴赏一些精彩的视频教程。

结果一个大大的付费弹窗阻挡了你!

你心想:可恶,为啥颜色网站都要收费啊?

作为一名程序员,你怎能甘心?

于是你决定自己做一个,不就是上传视频、播放视频嘛?

这时,经常给大家分享 AI 和编程知识的 鱼皮 突然从你身后冒了出来:天真!你知道自己做一个要花多少钱么?

你吓了一跳:我又没做过这种网站,怎么知道要花多少?

难道,你做过?

鱼皮一本正经:哼,当然…… 没有。

不过我做过可以看视频的、技术栈完全类似的 编程学习网站,所以很清楚这类网站的成本。

你来了兴趣:哦?愿闻其详。

鱼皮笑了笑:那我就以 编程导航 项目为例,从网站开发、上线到运营的完整流程,给你算算做一个视频网站到底要花多少钱。还能教你怎么省钱哦~

你点了个赞,并递上了两个硬币:好啊,快说快说!


鱼皮特别感谢朋友们的支持,你们的鼓励是我持续创作的动力 🌹!

⚠️ 友情声明:以下成本是基于个人经验 + 专业云服务商价格的估算(不考虑折扣),仅供参考。

⭐️ 推荐观看本文对应视频版:bilibili.com/video/BV1nJ…

服务器

想让别人访问你的网站,首先你要有一台服务器。

你点点头:我知道,代码文件都要放到服务器上运行,用户通过浏览器访问网站,其实是在向服务器请求网页文件和数据。

那服务器怎么选呢?

鱼皮:服务器的配置要看你的网站规模。刚开始做个小型视频网站,可以用入门配置的轻量应用服务器 (比如 2 核 CPU、2G 内存、4M 带宽) ,一年几百块就够了。

等后续用户多了,服务器带宽跟不上了再升级。比如 4 核 CPU、16G 内存、14M 带宽,一年差不多几千块。

你:几百块?比我想的便宜啊。

鱼皮:没错,国内云服务现在竞争很激烈、动不动就搞优惠。

但是要注意,如果你想做 “那种网站”,就要考虑用海外服务器了(好处是不用备案)。

咳咳,我们不谈这个……

数据库

有了服务器,还得有数据库,用来存储网站的用户信息、视频信息、评论点赞这些数据。

你:这个简单,数据库不就是 MySQL、PostgreSQL 这些嘛,装在服务器上不就行了?

鱼皮:是可以的,但我更建议使用云数据库服务,比如阿里云 RDS 或者腾讯云的云数据库。

你:为啥?不是要多花钱吗?

鱼皮:因为云数据库更稳定,而且自带备份、容灾、监控这些功能,你自己搞的话,还要费时费力安装维护,万一数据丢了可就麻烦了。

你:确实,那得多少钱?

鱼皮:入门级的云数据库(比如 2 核 4G 内存、100GB 硬盘)包年大概 2000 元左右。后面用户多了、数据量大了,就要升级配置(比如 4 核 16G),那一年就要 1 万多了。不过那个时候你已经赚麻了……

Redis

鱼皮:对了,我还建议你加个 Redis 缓存。

你挠了挠头:Redis?之前看过你的 讲解视频。这个是必须的吗?

鱼皮:刚开始可以没有,但如果你想让网站数据能更快加载,强烈建议用。

你想啊,视频网站用户一进来都要查看视频列表、热门推荐这些,如果用 Redis 把热点数据缓存起来,响应速度能快好几倍,还能帮数据库分摊查询压力。

你:确实,网站更快用户更爽,也更愿意付费。那 Redis 要多少钱?

鱼皮:Redis 比数据库便宜一些。入门级的 Redis 服务一年大概 1000 元左右。

你松了口气:也还行吧,看来做个视频网站也花不了多少钱啊!

对象存储

鱼皮:别急,接下来才是重点!

我问问你,视频文件保存在哪儿?

你不假思索:当然是存在服务器的硬盘上!

鱼皮哈哈大笑:别开玩笑了,一个高清视频动不动就几百 MB 甚至几个 G,你那点儿服务器硬盘能存几个视频?

而且服务器带宽有限,如果同时有很多用户看视频,服务器根本撑不住!

你:那咋办啊!

鱼皮:更好的做法是用 对象存储,比如阿里云 OSS、腾讯云 COS。

对象存储是专门用来存海量文件的云服务,它容量几乎无限、可以弹性扩展,而且访问速度快、稳定性高,很适合存储图片和音视频这些大文件。

你:贵吗?

鱼皮:存储本身不贵,100GB 一年也就几十块钱。但 真正贵的是流量费用

用户每看一次视频,都要从对象存储下载数据,这就产生了流量。

如果一个 1 GB 的视频被完整播放 1000 次,那就是 1000 GB 的流量,大概 500 块钱。

你看那些视频网站,每天光 1 个视频可能就有 10 万人看过,价格可想而知。

你惊讶地说不出话来:阿巴阿巴……

视频转码

鱼皮接着说:这还不够!对于视频网站,你还要做 视频转码。因为用户上传的视频格式、分辨率、编码方式都不一样,你需要把它们统一转成适合网页播放的格式,还要生成不同清晰度的版本让用户选择(标清、高清、超清)。

你:啊,那不是要多存好几个不同清晰度的视频文件?

鱼皮:没错,而且转码本身也是要钱的!

一般按照清晰度和视频分钟数计费。如果你上传 1000 个小时的高清视频,光转码费就得几千块!

CDN 加速

你急了:怎么做个视频网站处处都要花钱啊!有没有便宜点的办法?

鱼皮笑道:可以用 CDN。

你:CDN是啥?听着就高级!

鱼皮:CDN 叫内容分发网络,简单说就是把你的视频缓存到全国各地的服务器节点上。用户看视频的时候,从最近的节点拿数据,不仅速度更快,而且流量费比对象存储便宜不少。

你眼睛一亮:这么好?那不是必用 CDN!

鱼皮:没错,一般建议对象存储配合 CDN 使用。

而且视频网站 一定要做好流量防刷和安全防护

现在有的平台自带了流量防盗刷功能:

此外,建议手动添加更多流量安全配置。

1)设置访问频率限制,防止短时间被盗刷大量流量

2)还要配置 CDN 的流量告警,超过阈值及时得到通知

3)还要启用 referer 防盗链,防止别人盗用你的视频链接,用你的流量做网站捞钱。

如果不做这些,可能分分钟给你刷破产了!

你:这我知道,之前看过很多你破产和被攻击的视频!

鱼皮:我 ***!

视频点播

你:为了给用户看个视频,我要先用对象存储保存文件、再通过云服务转码视频、再通过 CDN 给用户加速访问,感觉很麻烦啊!

鱼皮神秘一笑:嘿嘿,其实还有更简单的方案 —— 视频点播服务,这是快速实现视频网站的核心。

只需要通过官方提供的 SDK 代码包和示例代码,就能快速完成视频上传、转码、多清晰度切换、加密保护等功能。

此外,还提供了 CDN 内容加速和各端的视频播放器。

你双眼放光:这么厉害,如果我自己从零开发这些功能,至少得好几个月啊!

鱼皮:没错,视频点播服务相当于帮你做了整合,能大幅提高开发效率。

但是它的费用也包含了存储费、转码费和流量费,价格跟前面提到的方案不相上下。

你叹了口气:唉,主要还是流量费太贵了啊……

网站上线还要准备啥?

鱼皮:讲完了开发视频网站需要的技术,接下来说说网站上线还需要的其他东西。

你:啊?还有啥?

鱼皮:首先,你得有个 域名 给用户访问吧?总不能让人家记你的 IP 地址吧?

不过别担心,普通域名一年也就几十块钱(比如我的 codefather.cn 才 38 / 年)。

当然,如果是稀缺的好域名就比较贵了,几百几千万的都有!

你:别说了,俺随便买个便宜的就行……

鱼皮:买了域名还得配 SSL 证书,因为现在做网站都得用 HTTPS 加密传输,不然浏览器会提示 “不安全”,用户看了就跑了。

刚开始可以直接用 Let's Encrypt 提供的免费证书,但只有 3 个月有效期,到期要手动续期,比较麻烦。

想省心的话可以买付费证书,便宜的一年几百块。

你:了解,那我就先用免费的,看来上线也花不了几个钱。

鱼皮:哎,可不能这么说,网站正式上线运营后,花钱的地方可多着呢!尤其是安全防护。

安全防护

做视频网站要面对两大安全威胁。第一个是 内容安全,你总不能让用户随便上传违规视频吧?万一上传了不该传的内容,网站直接就被封了。

你紧张起来:对啊,我人工审核也看不过来啊…… 怎么办?

鱼皮:可以用内容审核服务。视频审核包含画面和声音两部分,比文字审核更贵,审核 1000 小时视频,大概几千块。

你:还有第二个威胁呢?

鱼皮:第二个是最最最难应对的 网络攻击。做视频网站,尤其是有付费内容的,特别容易被攻击。DDoS 流量攻击想把你冲垮、SQL 注入想偷你数据、XSS 攻击想搞你用户、爬虫想盗你视频……

你:这么坏的吗?那我咋防啊!

鱼皮:常用的是 Web 应用防火墙(WAF)和 DDoS 防护服务。Web 防火墙能防 SQL 注入、XSS 攻击这些应用层攻击,而 DDoS 防护能抵御大规模流量冲击。

但是这些商业级服务都挺贵的,可能一年就是几万几十万……

你惊呼:我为了防止被攻击,还要搭这么多钱?!

鱼皮笑了:好消息是,有些云服务商会提供一点点免费的 DDoS 基础防护,还有相对便宜的轻量版 DDoS 防护包。

我的建议是,刚开始就先用免费的,加上代码里做好防 SQL 注入、XSS 这些安全措施,其实够用了。等网站真做起来、有收入了,再花钱买商业级的防护服务就好。

你点了点头:是呀,如果没收入,被攻击就被攻击吧,哼!

鱼皮微笑道:你这心态也不错哈哈。除了刚才说的这些,随着你网站的成熟,还可能会用到很多第三方服务,比如短信验证码、邮件推送、 等等,这些也都是成本。

总成本

讲到这里,你应该已经了解了视频网站的整个技术架构和成本。

最后再总结一下,如果一个人做个小型的视频网站,一年到底要花多少钱?

你看着这个表,倒吸一口凉气:视频网站的成本真高啊……

鱼皮:没错,这还只是保守估计。如果你的网站真火了,每天几万人看视频,一年光流量费就得有几十万吧。

而且刚才说的都只是网站本身的成本,如果你一个人做累了,要组个团队开发呢?

按照一线城市的成本算算,前端开发 + 后端开发 + 测试工程师 + 运维工程师,再加上五险一金,差不多每月要接近 10 万了。

你瞪大眼睛:那一年就是一百万?

鱼皮:没错,人力成本才是最贵的。

你:好了你别说了,我不做了,我不做了!我现在终于理解为什么那些网站都要收费了……

鱼皮:不过说实话,虽然成本不低,但那些网站收费真的太贵了,其实成本远没那么高,更多的是利用人性赚取暴利!

所以比起花钱看那些乱七八糟的网站,把钱和时间投资在学习上,才是最有价值的。

你点了点头:这次一定!再看一期你的教程,我就睡觉啦~

更多

💻 编程学习交流:编程导航 📃 简历快速制作:老鱼简历 ✏️ 面试刷题神器:面试鸭

LangChain 1.0 发布:agent 框架正式迈入生产级

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

随着大型语言模型(LLM)逐渐从「实验」走向「生产可用」,越来越多开发团队意识到:模型本身已不是唯一挑战,更关键的是 agent、流程编排、工具调用、人机  in‑the‑loop 及持久状态管理等机制的落地。 在这样的背景下,开源框架  LangChain 和 LangGraph 同步迈入  v1.0  版本——这不只是版本号的更新,而是“从实验架构迈向生产级 agent 系统”的关键一步。

核心更新概览

LangChain 1.0

  • LangChain 一直定位于“与 LLM  交互 + 构建 agent” 的高层框架。它通过标准的模型抽象、预构建的 agent 模式,帮助开发者快速上线 AI  能力。

  • 在过去几年里,社区反馈主要集中在:抽象过重、包的 surface area(命名空间、模块)过大、希望对 agent loop 有更多控制但又不想回归调用原始 LLM。

  • 为此,1.0  版重点做了:

    1. 新的 create_agent(或在 JS  里 createAgent)抽象 — 最快构建 agent 的方法。
    2. “标准内容块”(standard content blocks) — 提供跨模型/提供商的一致输出规范。
    3. 精简包的内容(streamlined surface area) — 将不常用或遗留功能移到 langchain‑classic(或类似)以简化主包。

LangGraph 1.0

  • LangGraph 定位较低层,主要用于"状态持久化 + 可控流程 + 人机介入"的场景。换句话说,当 agent 不只是"输入 → 模型 → 工具 → 输出"那么简单,而是需要"多节点、可暂停、可恢复、多人协作"的复杂流程时,LangGraph 在背后支撑底层运行。

  • 核心特性包括:

    • 可耐久状态(durable state)– agent 执行状态自动保存、服务器中断后可恢复。
    • 内置持久化机制 – 无需开发者为数据库状态管理写很多 boilerplate。
    • 图(graph)执行模型 – 支持复杂流程、分支、循环,而不是简单线性流程。
  • 值得注意的是:LangChain 1.0 的 agent 实际上是构建在 LangGraph 的运行时之上。这意味着从高层使用 LangChain  时,确实获得了底层更强的支持。

Node.js 示例

下面以 Node.js 演示两种场景:

  1. 快速构建一个天气 agent(使用  LangChain  高层抽象)
  2. 增强为结构化输出 + 工具调用示例

安装

npm install @langchain/langchain@latest
npm install @langchain/langgraph@latest

注:请根据实际包名/版本确认,因为官方可能更新命名空间或路径。

示例  1:快速 agent

import { createAgent } from "@langchain/langchain/agents";

async function runWeatherAgent() {
  // 定义一个工具函数,假设已实现
  const getWeatherTool = {
    name: "getWeather",
    description: "获取指定城市天气",
    async call(input) {
      // 这里是工具调用逻辑,例如调用天气 API
      const { city } = input;
      // 模拟返回
      return { temperature: 26, condition: "Sunny", city };
    },
  };

  const weatherAgent = createAgent({
    model: "openai:gpt‑5", // 根据实际模型提供者调整
    tools: [getWeatherTool],
    systemPrompt: "Help the user by fetching the weather in their city.",
  });

  const result = await weatherAgent.invoke({
    role: "user",
    content: "What's the weather in San Francisco?",
  });

  console.log("Result:", result);
}

runWeatherAgent().catch(console.error);

示例  2:结构化输出 + 工具调用

import { createAgent } from "@langchain/langchain/agents";
import { ToolStrategy } from "@langchain/langchain/agents/structured_output";

// 定义结构化输出类型(用 TypeScript 更佳)
class WeatherReport {
  constructor(temperature, condition) {
    this.temperature = temperature;
    this.condition = condition;
  }
}

async function runStructuredWeatherAgent() {
  const weatherTool = {
    name: "weatherTool",
    description: "Fetch the weather for a city",
    async call({ city }) {
      // 调用外部天气API
      return { temperature: 20.5, condition: "Cloudy", city };
    },
  };

  const agent = createAgent({
    model: "openai:gpt‑4o‑mini",
    tools: [weatherTool],
    responseFormat: ToolStrategy(WeatherReport),
    prompt: "Help the user by fetching the weather in their city.",
  });

  const output = await agent.invoke({
    role: "user",
    content: "What’s the weather in Tokyo today?",
  });

  console.log("Structured output:", output);
}

runStructuredWeatherAgent().catch(console.error);

实践注意事项

  • 模型提供者(如  OpenAI、Anthropic、Azure  等)具体接入方式、身份认证、费用控制,需要在项目中自行配置。
  • 工具(tools)需要自行定义:名称、描述、调用逻辑、输入/输出格式。务必做好错误处理与超时控制。
  • 结构化输出(如  ToolStrategy)可提升模型结果的一致性、安全性,但需定义好对应的类/接口/类型。上面示例仅为简化版。
  • 当流程更复杂(例如多步、环节审核、人机交互、长期挂起)时,建议使用  LangGraph  底层能力。

何时选用  LangChain vs LangGraph?

虽然二者紧密相关,但从实用视角来看,有以下建议:

场景 推荐框架 理由
快速构建、标准 agent 流程(模型 → 工具 → 响应)、不需要复杂流程控制 LangChain 1.0 高层抽象快上手,已封装常用模式。
需要流程编排、状态持久化、长流程运行、人工介入、分支逻辑 LangGraph 1.0 支持图执行、持久状态、人机互动,适合生产级 agent。

换句话说,如果你需要“快速构建一个 agent”去实验或上线 MVP,用  LangChain  足够。如果你要做“真正可用、需运维、需被监控、流程可暂停可恢复”的 agent 系统,那  LangGraph  是更合适的底层框架。官方也指出,LangChain 的 agent 是构建在 LangGraph 之上的。

总结

LangChain 1.0 标志着 agent 框架从实验阶段正式迈入生产级应用。本次更新重点解决了抽象过重、包体积过大等问题,推出了 createAgent 这一快速构建 agent 的核心 API,并引入标准内容块以实现跨模型的一致输出。更重要的是,LangChain 1.0 的 agent 运行在 LangGraph 运行时之上,为开发者提供了更强的底层支持。对于需要快速构建标准 agent 流程的场景,LangChain 1.0 提供了简洁的高层抽象;而当需求涉及复杂流程编排、状态持久化时,可以深入使用底层的 LangGraph 能力。

跨域问题解决方案汇总

跨域 / 同源策略概述 **同源(same-origin)**:协议、域名(host)、端口 三者完全相同称为同源。 例如 https://example.com:443 和 http://exa

原型理解从入门到精通

两个容易混淆但要分清的东西 每个普通对象都有内部隐式属性 [[Prototype]](常见访问名 proto) —— 它指向另一个对象(即原型对象)。 函数(作为构造函数)有 .prot

通用会话控制方案

一、会话控制概念

  • 目的:在无状态的 HTTP 请求间识别/鉴权用户身份并维持登录状态。
  • 核心问题:谁保存“用户状态”?(服务器 / 客户端 / 第三方认证服务器),以及如何安全地在多请求间传递该凭证(Cookie / Authorization header)。

二、传统方案:Cookie + 服务端 Session

1) 流程

  1. 用户登录,后端验证凭证(用户名/密码)。
  2. 后端在服务器端创建 session(比如 sessionId 对应的对象存在 Redis / DB / 内存),并返回 Set-Cookie: sessionId=xxx; HttpOnly; Secure; SameSite=Lax; Path=/ 响应头部信息。
  3. 浏览器自动带上 Cookie(同源或按 CORS 配置带上 credentials: 'include'),后端根据 sessionId 在 session store 查到用户信息并授权。

2) 示例(Express + express-session + Redis)

后端:

const session = require('express-session');
const RedisStore = require('connect-redis')(session);
app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  name: 'sid',
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: true,      // 生产必须 https
    sameSite: 'lax',   // 或 'strict' / 'none'(配合跨域)
    maxAge: 24 * 3600 * 1000
  }
}));

前端(fetch):

fetch('/api/profile', { credentials: 'include' })

3) 优点

  • 简单明确:后端完全控制会话(可以随时废弃/修改)。
  • 支持服务端会话失效(logout 立刻生效)。
  • 易于集成细粒度权限、会话统计与审计(存 Redis 的 session 可包含登录 IP、设备信息等)。

4) 缺点

  • 水平扩展需共享 session store(Redis)。
  • Session 存储和查询带来额外延迟与成本。
  • 跨域场景复杂:需要正确配置 Access-Control-Allow-CredentialsSet-CookieSameSite=None; Secure 等,避免安全问题。

三、JWT(JSON Web Token)方案概述

1) JWT 的基本思想(无状态 token)

  • 后端发放一个签名的 token(通常是 Access Token),包含用户 id、到期时间等。
  • 前端携带 token(通常放在 Authorization header 或者放入 HttpOnly cookie)向后端请求;后端不查 session store,而是直接验证 token 的签名与有效期,若通过则鉴权成功。

JWT 典型格式:header.payload.signature(Base64 编码)

const jwt = require('jsonwebtoken');
const accessToken = jwt.sign({ uid: xxx }, process.env.JWT_SECRET, { expiresIn: '15m' });
const payload = jwt.verify(accessToken, process.env.JWT_SECRET);

2) 常见拓展

  • **短期 Access Token(如 5–15 分钟)**:放在内存或短寿命存储(不放 localStorage 更安全),用于 API 调用。
  • 长期 Refresh Token(如 14 天或更长):安全保存(HttpOnly, Secure cookie),用于换取新的 Access Token。每次刷新都会发送新的 refresh token。服务端会保存 refresh token 的元数据(redis)。
  • Access Token 可放在 Authorization: Bearer <token>,或也可放在 HttpOnly cookie(同域 + credentials)

    推荐将 Refresh Token 放 HttpOnly cookie,Access Token 放内存并通过 Authorization header 发送(减少 CSRF 风险)。

3) 优点

  • 无状态,后端不必保存 session(容易横向扩展,适合微服务 & 无状态后端),更适合移动端 SPA 等分布式鉴权。
  • Token 自包含(本身携带用户身份信息,后端只需验证 token 签名,就直接知道 token 是否有效以及用户信息,无需再查询数据库或者 session 存储),减少每次请求的 DB 查询。
  • 与 OAuth2 兼容,便于做 SSO 与第三方登录。

4) 缺点

  • 无法轻易撤销(access token 在有效期内一直可用,除非引入黑名单/撤销机制)。
  • 如果把 JWT 放在 localStorage(可被 JS 读取)会增加 XSS 风险;如果放 Cookie,则面临 CSRF 风险。

四、Cookie Session 与 JWT 的主要区别

特性 Cookie + Session(服务端) JWT(无状态)
主要存储位置 服务端(Redis) 客户端持 token(或 cookie)
是否无状态 是(默认)
可撤销性 立刻可撤销(删除 session) 需要黑名单等策略
扩展性(横向扩展) 需外部 session store(redis 做 session 共享) 天然适合横向扩展
CSRF 风险 高(Cookie 自动带) 如果 token 放 localStorage,CSRF 低但 XSS 高;如果 token 放 HttpOnly cookie,CSRF 风险同 Cookie Session
操作复杂度 较低 较高(密钥管理、刷新策略)

五、安全策略

CSRF 防护(Cookie 自动带 credential 的情况下)

  • 优先:使用 SameSite=LaxSameSite=Strict
  • 对于必须 SameSite=None 的跨站场景,配合 **CSRF Token(双 submit cookie)**:
    • 后端在登录时设 Set-Cookie: CSRF-Token=xxx; HttpOnly=false; Secure(可被 JS 读取)
    • 前端每次请求在 header 中带 X-CSRF-Token: <value>,后端验证 header 与 cookie 值一致。
  • 或使用 Origin / Referer 检查(只允许来自可信域的请求)。

核心:CSRF 攻击者 可以发请求,但 没法读 Cookie,无法把 token 放到 header 中,所以校验((req.cookies["CSRF-Token"] !== req.headers["X-CSRF-Token"]))。CSRF 攻击能发请求,但拿不到 cookie 内容 。

XSS 防护(避免 token 被窃取)

  • 对可执行 token 的存储避免使用 localStorage(HttpOnly cookie 防止 JS 读取)。

JWT 可撤销性策略

  • 短寿命 access token + refresh token(refresh token 存 HttpOnly cookie):
    • access token 过期快,可减少滥用窗口。
    • refresh token 在服务端有黑名单/DB 记录,必要时撤销(例如用户登出、密码变更)。
  • **token id(jti) + 存在服务端黑名单(Redis)**:在 logout / 强制下线时把 jti 写入黑名单并在验证时检查。黑名单可设置过期与分布式缓存。

前端图形引擎架构设计:可扩展双渲染引擎架构设计-支持自定义渲染器

ECS渲染引擎架构文档

写在前面

之前写过一篇ECS文章,为什么还要再写一个,本质上因为之前的文档,截止到目前来说,变化巨大,底层已经改了很多很多,所以有必要把一些内容拎出来单独去说。

由于字体文件较大,加载时间会比较久😞,可以把项目clone下来本地跑会比较快。

另外如果有性能问题,我会及时修复,引擎改造时间太仓促,只要不是内存泄漏,暂时没去处理。

还有很多东西要做。

体验地址:baiyuze.github.io/design/#/ca…

image.png

image.png

image.png

项目概览

Duck-Core 是一个基于 ECS(Entity-Component-System)架构构建的高性能 Canvas 渲染引擎,专为复杂图形编辑场景设计。引擎的核心特色在于双渲染后端架构插件化系统设计极致的渲染性能优化

核心技术栈

  • CanvasKit-WASM - Google Skia 图形库的 WebAssembly 移植版
  • Canvas2D API - 浏览器原生渲染接口

架构核心亮点

ECS 架构模式 - 数据驱动的实体组件系统,实现逻辑与数据完全解耦

双引擎架构 - Canvas2D 与 CanvasKit 双渲染后端,运行时无缝切换

插件化设计 - 开放式扩展点,支持自定义渲染器、系统和组件

极致性能 - 颜色编码拾取、离屏渲染、渲染节流等多重优化


整体架构设计

整个引擎采用分层架构,从底层的渲染抽象到顶层的用户交互,每一层职责清晰且可独立替换。

graph TB
    subgraph "应用层"
        A[React 组件] --> B[Canvas 画布组件]
    end
    
    subgraph "引擎核心层"
        B --> C[Engine 引擎实例]
        C --> D[Core 状态管理器]
        C --> E[Camera 相机控制]
        C --> F[Entity Manager 实体管理]
    end
    
    subgraph "系统层 - System"
        C --> G[EventSystem 事件系统]
        G --> H[InputSystem 输入系统]
        G --> I[RenderSystem 渲染系统]
        G --> J[PickingSystem 拾取系统]
        G --> K[DragSystem 拖拽系统]
        G --> L[SelectionSystem 选择系统]
        G --> M[ZoomSystem 缩放系统]
        G --> M[FpsSystem FPS]
    end
    
    subgraph "渲染层 - Renderer"
        I --> N[RendererManager 渲染管理器]
        N --> O{选择渲染后端}
        O -->|Canvas2D| P[Canvas2D 渲染器组]
        O -->|CanvasKit| Q[CanvasKit 渲染器组]
        P --> R[RectRender]
        P --> S[EllipseRender]
        P --> T[TextRender]
        Q --> U[RectRender]
        Q --> V[EllipseRender]
        Q --> W[TextRender]
    end
    
    subgraph "数据层 - Component"
        X[StateStore 状态仓库]
        X --> Y[Position]
        X --> Z[Size]
        X --> AA[Color]
        X --> AB[Rotation]
        X --> AC[Selected]
    end
    
    D <--> X
    I --> X
    J --> X
    K --> X
    L --> X
    
    style C fill:#4A90E2,color:#fff
    style N fill:#E94B3C,color:#fff
    style X fill:#6ECB63,color:#fff
    style G fill:#F39C12,color:#fff

ECS 架构深度解析

什么是 ECS 架构?

ECS(Entity-Component-System)是一种源自游戏引擎的设计模式,它彻底改变了传统面向对象的继承体系,转而采用组合优于继承的理念。

三大核心概念:

  1. Entity(实体) - 仅是一个唯一 ID,不包含任何数据和逻辑
  2. Component(组件) - 纯数据结构,描述实体的属性(如位置、颜色、大小)
  3. System(系统) - 纯逻辑处理单元,操作特定组件组合的实体
graph TB
    subgraph "传统 OOP 继承方式"
        A1[GameObject]
        A1 --> A2[Rectangle]
        A1 --> A3[Circle]
        A1 --> A4[Text]
        A2 --> A5[DraggableRectangle]
        A3 --> A6[SelectableCircle]
        style A1 fill:#ff9999
    end
    
    subgraph "ECS 组合方式"
        B1[Entity 123] -.拥有.-> B2[Position]
        B1 -.拥有.-> B3[Size]
        B1 -.拥有.-> B4[Color]
        
        B5[Entity 456] -.拥有.-> B6[Position]
        B5 -.拥有.-> B7[Font]
        B5 -.拥有.-> B8[Selected]
        
        B9[RenderSystem] --> B2 & B3 & B4
        B10[DragSystem] --> B2
        B11[SelectionSystem] --> B8
        
        style B1 fill:#99ccff
        style B5 fill:#99ccff
        style B9 fill:#99ff99
        style B10 fill:#99ff99
        style B11 fill:#99ff99
    end

ECS 架构的核心优势

1. 极致的解耦性

传统 OOP 中,功能通过继承链紧密耦合。而 ECS 中,系统只依赖组件接口,实体的行为完全由组件组合决定。

// ❌ 传统方式:紧耦合的继承链
class Shape {
  render() { /* ... */ }
}
class DraggableShape extends Shape {
  drag() { /* ... */ }
}
class SelectableDraggableShape extends DraggableShape {
  select() { /* ... */ }
}

// ✅ ECS 方式:组件自由组合
const rect = createEntity()
addComponent(rect, Position, { x: 100, y: 100 })
addComponent(rect, Size, { width: 200, height: 150 })
addComponent(rect, Draggable, {})  // 可拖拽
addComponent(rect, Selected, {})   // 可选中
2. 强大的可扩展性

新增功能无需修改现有代码,只需添加新的组件和系统:

image.png

3. 天然的并行处理能力

系统之间无共享状态,可以安全地并行执行:

// 多个系统可以同时读取同一个组件
async function updateFrame() {
  await Promise.all([
    physicsSystem.update(),   // 读取 Position
    renderSystem.update(),    // 读取 Position
    collisionSystem.update(), // 读取 Position
  ])
}
System 系统架构

系统负责处理逻辑,通过查询 StateStore 获取需要的组件数据:

abstract class System {
  abstract update(stateStore: StateStore): void
}

class RenderSystem extends System {
  update(stateStore: StateStore) {
    // 查询所有拥有 Position 组件的实体
    for (const [entityId, position] of stateStore.position) {
      const size = stateStore.size.get(entityId)
      const color = stateStore.color.get(entityId)
      const type = stateStore.type.get(entityId)
      
      // 根据类型调用对应的渲染器
      this.renderMap.get(type)?.draw(entityId)
    }
  }
}

系统完整列表:

graph TB
    A[EventSystem<br/>事件总线] --> B[InputSystem<br/>输入捕获]
    A --> C[HoverSystem<br/>悬停检测]
    A --> D[ClickSystem<br/>点击处理]
    A --> E[DragSystem<br/>拖拽逻辑]
    A --> F[SelectionSystem<br/>选择管理]
    A --> G[ZoomSystem<br/>缩放控制]
    A --> H[ScrollSystem<br/>滚动平移]
    A --> I[PickingSystem<br/>图形拾取]
    A --> J[RenderSystem<br/>渲染绘制]
    A --> K[FpsSystem<br/>性能监控]
    
    style A fill:#F39C12,color:#fff
    style J fill:#E74C3C,color:#fff
    style I fill:#3498DB,color:#fff

双引擎架构设计

架构设计理念

不同的应用场景对渲染引擎有不同的需求:

  • 简单场景:需要快速启动、体积小、兼容性好
  • 复杂场景:需要高性能、丰富特效、大量图形

传统方案通常只支持单一渲染后端,难以兼顾两者。本引擎采用双引擎可切换架构,在运行时动态选择最优渲染后端。

graph TB
    A[应用启动] --> B{检测场景复杂度}
    B -->|简单场景<br/>< 100 图形| C[Canvas2D 引擎]
    B -->|复杂场景<br/>> 100 图形| D[CanvasKit 引擎]
    B -->|用户手动指定| E[用户选择]
    
    C --> F[浏览器原生 API]
    D --> G[Skia WASM 引擎]
    
    C --> H[渲染输出]
    D --> H
    
    I[运行时切换] -.->|热切换| C
    I -.->|热切换| D
    
    style C fill:#90EE90
    style D fill:#87CEEB
    style H fill:#FFD700

渲染后端对比

特性 Canvas2D CanvasKit (Skia)
启动速度 ⚡️ 即时(0ms) 🐢 需加载 WASM(~2s)
包体积 ✅ 0 KB ⚠️ ~1.5 MB
浏览器兼容性 ✅ 100% ⚠️ 需支持 WASM
渲染性能 🟡 中等 🟢 优秀
复杂路径渲染 🟡 一般 🟢 优秀
文字渲染 🟡 质量一般 🟢 亚像素级
滤镜特效 ❌ 有限 ✅ 丰富
离屏渲染 ✅ 支持 ✅ 支持
最佳场景 简单图形、快速原型 复杂设计、高性能需求

RendererManager 渲染管理器

RendererManager 是双引擎架构的核心枢纽,负责渲染器的注册、切换和调度:

class RendererManager {
  rendererName: 'Canvas2D' | 'Canvaskit' = 'Canvaskit'
  
  // 渲染器映射表
  renderer: {
    rect: typeof RectRender
    ellipse: typeof EllipseRender
    text: typeof TextRender
    img: typeof ImgRender
    polygon: typeof PolygonRender
  }
  
  // 切换渲染后端
  setRenderer(name: 'Canvas2D' | 'Canvaskit') {
    this.rendererName = name
    
    if (name === 'Canvas2D') {
      this.renderer = Canvas2DRenderers
    } else {
      this.renderer = CanvaskitRenderers
    }
  }
}

渲染器切换流程:

sequenceDiagram
    participant U as 用户操作
    participant E as Engine
    participant RM as RendererManager
    participant RS as RenderSystem
    participant R1 as Canvas2D Renderer
    participant R2 as CanvasKit Renderer
    
    U->>E: setRenderer('Canvas2D')
    E->>RM: setRenderer('Canvas2D')
    RM->>RM: 加载 Canvas2D 渲染器组
    RM-->>E: 切换完成
    
    E->>RS: 触发重新渲染
    RS->>RM: 获取 rect 渲染器
    RM-->>RS: 返回 Canvas2D.RectRender
    RS->>R1: 调用 draw() 方法
    R1->>R1: 使用 ctx.fillRect()
    
    Note over U,R2: 用户再次切换引擎
    
    U->>E: setRenderer('Canvaskit')
    E->>RM: setRenderer('Canvaskit')
    RM->>RM: 加载 CanvasKit 渲染器组
    RM-->>E: 切换完成
    
    E->>RS: 触发重新渲染
    RS->>RM: 获取 rect 渲染器
    RM-->>RS: 返回 CanvasKit.RectRender
    RS->>R2: 调用 draw() 方法
    R2->>R2: 使用 canvas.drawRect()

渲染器统一接口

所有渲染器实现相同的接口,保证可替换性:

abstract class BaseRenderer extends System {
  constructor(protected engine: Engine) {
    super()
  }
  
  // 统一的渲染接口
  abstract draw(entityId: string): void
  
}

自定义渲染器扩展

引擎支持用户自定义渲染器,只需实现 System 接口:

// 1. 创建自定义渲染器
class CustomStarRender extends System {
  draw(entityId: string) {
    const points = this.getComponent<Polygon>(entityId, 'polygon')
    const color = this.getComponent<Color>(entityId, 'color')
    
    // 自定义绘制逻辑
    const ctx = this.engine.ctx
    ctx.beginPath()
    points.points.forEach((p, i) => {
      i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)
    })
    ctx.closePath()
    ctx.fillStyle = color.fill
    ctx.fill()
  }
}
const customRenderMap = {
  star: CustomStarRender
}
// 2. 注册到引擎
new RendererRegistry().register({
  "custom": customRenderMap
})


字体渲染优化

CanvasKit 需要预加载字体文件,引擎实现了字体管理器:

async function loadFonts(CanvasKit: any) {
  const fontsBase = import.meta.env?.MODE === 'production' 
    ? '/design/fonts/' 
    : '/fonts/'

  const [robotoFont, notoSansFont] = await Promise.all([
    fetch(`${fontsBase}Roboto-Regular.ttf`).then(r => r.arrayBuffer()),
    fetch(`${fontsBase}NotoSansSC-VariableFont_wght_2.ttf`).then(r => r.arrayBuffer()),
  ])

  const fontMgr = CanvasKit.FontMgr.FromData(robotoFont, notoSansFont)
  return fontMgr
}

// 在 CanvasKit 初始化时调用
export async function createCanvasKit() {
  const CanvasKit = await initCanvasKit()
  const FontMgr = await loadFonts(CanvasKit)
  return { CanvasKit, FontMgr }
}

引擎工厂模式

使用工厂函数创建不同配置的引擎实例:

export function createCanvasRenderer(engine: Engine) {
  // Canvas2D 引擎创建器
  const createCanvas2D = (config: DefaultConfig) => {
    const canvas = document.createElement('canvas')
    const dpr = window.devicePixelRatio || 1
    canvas.style.width = config.width + 'px'
    canvas.style.height = config.height + 'px'
    canvas.width = config.width * dpr
    canvas.height = config.height * dpr
    
    const ctx = canvas.getContext('2d', {
      willReadFrequently: true,
    }) as CanvasRenderingContext2D
    ctx.scale(dpr, dpr)
    
    config.container.appendChild(canvas)
    
    return { canvasDom: canvas, canvas: ctx, ctx }
  }

  // CanvasKit 引擎创建器
  const createCanvasKitSkia = async (config: DefaultConfig) => {
    const { CanvasKit, FontMgr } = await createCanvasKit()
    const canvasDom = document.createElement('canvas')
    const dpr = window.devicePixelRatio || 1
    
    canvasDom.style.width = config.width + 'px'
    canvasDom.style.height = config.height + 'px'
    canvasDom.width = config.width * dpr
    canvasDom.height = config.height * dpr
    canvasDom.id = 'canvasKitCanvas'
    
    config.container.appendChild(canvasDom)
    
    const surface = CanvasKit.MakeWebGLCanvasSurface('canvasKitCanvas')
    const canvas = surface!.getCanvas()
    
    return {
      canvasDom,
      surface,
      canvas: canvas,
      FontMgr: FontMgr,
      ck: CanvasKit,
    }
  }

  return {
    createCanvas2D,
    createCanvasKitSkia,
  }
}

Engine 引擎核心

Engine 类是整个渲染系统的中枢,协调所有子系统的运行:

class Engine implements EngineContext {
  camera: Camera = new Camera()
  entityManager: Entity = new Entity()
  SystemMap: Map<string, System> = new Map()
  rendererManager: RendererManager = new RendererManager()
  
  canvas!: Canvas  // 渲染画布(类型取决于渲染后端)
  ctx!: CanvasRenderingContext2D
  ck!: CanvasKit
  
  constructor(public core: Core, rendererName?: string) {
    // 初始化渲染器
    this.rendererManager.rendererName = rendererName || 'Canvaskit'
    this.rendererManager.setRenderer(this.rendererManager.rendererName)
  }
  
  // 添加系统
  addSystem(system: System) {
    this.system.push(system)
    this.SystemMap.set(system.constructor.name, system)
  }
  
  // 获取系统
  getSystemByName<T extends System>(name: string): T | undefined {
    return this.SystemMap.get(name) as T
  }
  
  // 清空画布(适配双引擎)
  clear() {
    const canvas = this.canvas as any
    if (canvas?.clearRect) {
      // Canvas2D 清空方式
      canvas.clearRect(0, 0, this.defaultSize.width, this.defaultSize.height)
    } else {
      // CanvasKit 清空方式
      this.canvas.clear(this.ck.WHITE)
    }
  }
}

插件化系统设计

系统即插件

引擎的所有功能都以 System 形式实现,每个 System 都是独立的插件。这种设计带来极高的灵活性:

graph TB
    A[Engine 核心] --> B{System Manager}
    
    B --> C[核心系统]
    B --> D[可选系统]
    B --> E[自定义系统]
    
    C --> C1[EventSystem<br/>必需]
    C --> C2[RenderSystem<br/>必需]
    
    D --> D1[DragSystem<br/>拖拽功能]
    D --> D2[ZoomSystem<br/>缩放功能]
    D --> D3[FpsSystem<br/>性能监控]
    
    E --> E1[UndoRedoSystem<br/>撤销重做]
    E --> E2[SnappingSystem<br/>吸附对齐]
    E --> E3[AnimationSystem<br/>动画播放]
    
    style C1 fill:#e74c3c,color:#fff
    style C2 fill:#e74c3c,color:#fff
    style D1 fill:#3498db,color:#fff
    style D2 fill:#3498db,color:#fff
    style D3 fill:#3498db,color:#fff
    style E1 fill:#2ecc71,color:#fff
    style E2 fill:#2ecc71,color:#fff
    style E3 fill:#2ecc71,color:#fff

核心系统详解

1. EventSystem - 事件总线

EventSystem 是整个引擎的调度中枢,协调所有其他系统的执行:

class EventSystem extends System {
  private eventQueue: Event[] = []
  
  update(stateStore: StateStore) {
    // 执行系统更新顺序
    this.executeSystem('InputSystem')      // 1. 捕获输入
    this.executeSystem('HoverSystem')      // 2. 检测悬停
    this.executeSystem('ClickSystem')      // 3. 处理点击
    this.executeSystem('DragSystem')       // 4. 处理拖拽
    this.executeSystem('ZoomSystem')       // 5. 处理缩放
    this.executeSystem('SelectionSystem')  // 6. 更新选择
    this.executeSystem('PickingSystem')    // 7. 更新拾取缓存
    this.executeSystem('RenderSystem')     // 8. 最后渲染
  }

}
2. RenderSystem - 渲染系统

RenderSystem 负责将实体绘制到画布:

class RenderSystem extends System {
  private renderMap = new Map<string, BaseRenderer>()
  
  constructor(engine: Engine) {
    super()
    this.engine = engine
    this.initRenderMap()
  }
  
  // 初始化渲染器映射
  initRenderMap() {
    Object.entries(this.engine.rendererManager.renderer).forEach(
      ([type, RendererClass]) => {
        this.renderMap.set(type, new RendererClass(this.engine))
      }
    )
  }
  
  async update(stateStore: StateStore) {
    // 清空画布
    this.engine.clear()
    
    // 应用相机变换
    this.engine.canvas.save()
    this.engine.canvas.translate(
      this.engine.camera.translateX,
      this.engine.camera.translateY
    )
    this.engine.canvas.scale(
      this.engine.camera.zoom,
      this.engine.camera.zoom
    )
    
    // 遍历所有实体进行渲染
    for (const [entityId, pos] of stateStore.position) {
      this.engine.canvas.save()
      this.engine.canvas.translate(pos.x, pos.y)
      
      const type = stateStore.type.get(entityId)
      await this.renderMap.get(type)?.draw(entityId)
      
      this.engine.canvas.restore()
    }
    
    this.engine.canvas.restore()
  }
}

DSL 配置系统


DSL 配置系统

设计目标

DSL(Domain Specific Language)模块的目标是将图形场景序列化为 JSON 格式,实现:

  1. 场景持久化 - 保存到数据库或本地存储
  2. 场景传输 - 前后端数据交换
  3. 场景快照 - 撤销/重做功能的基础
  4. 模板复用 - 创建可复用的图形模板

配置结构

interface DSLParams {
  type: 'rect' | 'ellipse' | 'text' | 'img' | 'polygon'
  id?: string
  position: { x: number; y: number }
  size?: { width: number; height: number }
  color?: { fill: string; stroke: string }
  rotation?: { value: number }
  scale?: { value: number }
  zIndex?: { value: number }
  selected?: { isSelected: boolean }
  // 形状特定属性
  font?: { family: string; size: number; weight: string }
  radius?: { value: number }
  polygon?: { points: Point[] }
}

DSL 解析器

class DSL {
  constructor(params: DSLParams) {
    this.type = params.type
    this.id = params.id || this.generateId()
    this.position = new Position(params.position)
    this.size = params.size ? new Size(params.size) : new Size()
    this.color = params.color ? new Color(params.color) : new Color()
    // ... 初始化其他组件
  }
  
  // 转换为纯数据对象
  toJSON(): DSLParams {
    return {
      type: this.type,
      id: this.id,
      position: { x: this.position.x, y: this.position.y },
      size: { width: this.size.width, height: this.size.height },
      color: { fill: this.color.fill, stroke: this.color.stroke },
      // ...
    }
  }
}

低耦合架构实践

依赖方向

整个引擎严格遵循依赖倒置原则:

graph TB
    A[应用层<br/>React 组件] --> B[引擎接口<br/>Engine API]
    B --> C[系统层<br/>System]
    C --> D[组件层<br/>Component]
    C --> E[实体层<br/>Entity]
    
    F[渲染层<br/>Renderer] --> G[渲染接口<br/>BaseRenderer]
    C --> G
    
    style B fill:#f39c12,color:#fff
    style G fill:#f39c12,color:#fff

关键设计:

  • 上层依赖接口,不依赖具体实现
  • System 不直接依赖 Renderer,通过 RendererManager 解耦
  • Component 纯数据,零依赖

总结

Duck-Core 前端渲染引擎通过以下设计实现了高性能、高扩展性:

核心优势

  1. ECS 架构 - 数据与逻辑完全分离,组件自由组合
  2. 双引擎架构 - Canvas2D 与 CanvasKit 可热切换,兼顾兼容性与性能
  3. 插件化系统 - 所有功能以 System 形式实现,按需加载
  4. 低耦合设计 - 接口隔离、依赖倒置、事件驱动
  5. 极致性能 - 渲染节流、离屏缓存、视口裁剪、内存优化

得物TiDB升级实践

一、背 景

得物DBA自2020年初开始自建TiDB,5年以来随着NewSQL数据库迭代发展、运维体系逐步完善、产品自身能力逐步提升,接入业务涵盖了多个业务线和关键场景。从第一套TIDB v4.0.9 版本开始,到后来v4.0.11、v5.1.1、v5.3.0,在经历了各种 BUG 踩坑、问题调试后,最终稳定在 TIDB 5.3.3 版本。伴随着业务高速增长、数据量逐步增多,对 TiDB 的稳定性及性能也带来更多挑战和新的问题。为了应对这些问题,DBA团队决定对 TiDB 进行一次版本升级,收敛版本到7.5.x。本文基于内部的实践情况,从架构、新特性、升级方案及收益等几个方向讲述 TiDB 的升级之旅。

二、TiDB 架构

TiDB 是分布式关系型数据库,高度强兼容 MySQL 协议和 MySQL 生态,稳定适配 MySQL 5.7 和MySQL 8.0常用的功能及语法。随着版本的迭代,TiDB 在弹性扩展、分布式事务、强一致性基础上进一步针对稳定性、性能、易用性等方面进行优化和增强。与传统的单机数据库相比,TiDB具有以下优势:

  • 分布式架构,拥有良好的扩展性,支持对业务透明灵活弹性的扩缩容能力,无需分片键设计以及开发运维。
  • HTAP 架构支撑,支持在处理高并发事务操作的同时,对实时数据进行复杂分析,天然具备事务与分析物理隔离能力。
  • 支持 SQL 完整生态,对外暴露 MySQL 的网络协议,强兼容 MySQL 的语法/语义,在大多数场景下可以直接替换 MySQL。
  • 默认支持自愈高可用,在少数副本失效的情况下,数据库本身能够自动进行数据修复和故障转移,对业务无感。
  • 支持 ACID 事务,对于一些有强一致需求的场景友好,满足 RR 以及 RC 隔离级别,可以在通用开发框架完成业务开发迭代。

我们使用 SLB 来实现 TiDB 的高效负载均衡,通过调整 SLB 来管理访问流量的分配以及节点的扩展和缩减。确保在不同流量负载下,TiDB 集群能够始终保持稳定性能。在 TiDB 集群的部署方面,我们采用了单机单实例的架构设计。TiDB Server 和 PD Server 均选择了无本地 SSD 的机型,以优化资源配置,并降低开支。TiKV Server则配置在本地 SSD 的机型上,充分利用其高速读写能力,提升数据存储和检索的性能。这样的硬件配置不仅兼顾了系统的性能需求,又能降低集群成本。针对不同的业务需求,我们为各个组件量身定制了不同的服务器规格,以确保在多样化的业务场景下,资源得到最佳的利用,进一步提升系统的运行效率和响应速度。

三、TiDB v7 版本新特性

新版本带来了更强大的扩展能力和更快的性能,能够支持超大规模的工作负载,优化资源利用率,从而提升集群的整体性能。在 SQL 功能方面,它提升了兼容性、灵活性和易用性,从而助力复杂查询和现代应用程序的高效运行。此外,网络 IO 也进行了优化,通过多种批处理方法减少网络交互的次数,并支持更多的下推算子。同时,优化了Region 调度算法,显著提升了性能和稳定性。

四、TiDB升级之旅

4.1 当前存在的痛点

  • 集群版本过低:当前 TiDB 生产环境(现网)最新版本为 v5.3.3,目前官方已停止对 4.x 和 5.x 版本的维护及支持,TiDB 内核最新版本为 v8.5.3,而被用户广泛采用且最为稳定的版本是 v7.5.x。
  • TiCDC组件存在风险:TiCDC 作为增量数据同步工具,在 v6.5.0 版本以前在运行稳定性方面存在一定问题,经常出现数据同步延迟问题或者 OOM 问题。
  • 备份周期时间长:集群每天备份时间大于8小时,在此期间,数据库备份会导致集群负载上升超过30%,当备份时间赶上业务高峰期,会导致应用RT上升。
  • 集群偶发抖动及BUG:在低版本集群中,偶尔会出现基于唯一键查询的慢查询现象,同时低版本也存在一些影响可用性的BUG。比如在 TiDB v4.x 的集群中,TiKV 节点运行超过 2 年会导致节点自动重启。

4.2 升级方案:升级方式

TiDB的常见升级方式为原地升级和迁移升级,我们所有的升级方案均采用迁移升级的方式。

原地升级

  • 优势:方式较为简单,不需要额外的硬件,升级过程中集群仍然可以对外提供服务。
  • 劣势:该升级方案不支持回退、并且升级过程会有长时间的性能抖动。大版本(v4/v5 原地升级到 v7)跨度较大时,需要版本递增升级,抖动时间翻倍。

迁移升级

  • 优势:业务影响时间较短、可灰度可回滚、不受版本跨度的影响。
  • 劣势:搭建新集群将产生额外的成本支出,同时,原集群还需要部署TiCDC组件用于增量同步。

4.3 升级方案:集群调研

4.4 升级方案:升级前准备环境

4.5 升级方案:升级前验证集群

4.6 升级方案:升级中流量迁移

4.7 升级方案:升级后销毁集群

五、升级遇到的问题

5.1 v7.5.x版本查询SQL倾向全表扫描

表中记录数 215亿,查询 SQL存在合理的索引,但是优化器更倾向走全表扫描,重新收集表的统计信息后,执行计划依然为全表扫描。

走全表扫描执行60秒超时KILL,强制绑定索引仅需0.4秒。

-- 查询SQL
SELECT
  *
FROM
  fin_xxx_xxx
WHERE
  xxx_head_id = 1111111111111111
  AND xxx_type = 'XX0002'
  AND xxx_user_id = 11111111
  AND xxx_pay_way = 'XXX00000'
  AND is_del IN ('N', 'Y')
LIMIT
  1;


-- 涉及索引
KEY `idx_xxx` (`xxx_head_id`,`xxx_type`,`xxx_status`),

解决方案:

  • 方式一:通过 SPM 进行 SQL 绑定。
  • 方式二:调整集群参数 tidb_opt_prefer_range_scan,将该变量值设为 ON 后,优化器总是偏好区间扫描而不是全表扫描。

asktug.com/t/topic/104…

5.2 v7.5.x版本聚合查询执行计划不准确

集群升级后,在新集群上执行一些聚合查询或者大范围统计查询时无法命中有效索引。而低版本v4.x、5.x集群,会根据统计信息选择走合适的索引。

v4.0.11集群执行耗时:12秒,新集群执行耗时2分32.78秒

-- 查询SQL
select 
    statistics_date,count(1) 
from 
    merchant_assessment_xxx 
where 
    create_time between '2025-08-20 00:00:00' and '2025-09-09 00:00:00' 
group by 
    statistics_date order by statistics_date;


-- 涉及索引
KEY `idx_create_time` (`create_time`)

解决方案:

方式一:调整集群参数tidb_opt_objective,该变量设为 determinate后,TiDB 在生成执行计划时将不再使用实时统计信息,这会让执行计划相对稳定。

asktug.com/t/topic/104…

六、升级带来的收益

版本升级稳定性增强:v7.5.x 版本的 TiDB 提供了更高的稳定性和可靠性,高版本改进了SQL优化器、增强的分布式事务处理能力等,加快了响应速度和处理大量数据的能力。升级后相比之前整体性能提升40%。特别是在处理复杂 SQL 和多索引场景时,优化器的性能得到了极大的增强,减少了全表扫描的发生,从而显著降低了 TiKV 的 CPU 消耗和 TiDB 的内存使用。

应用平均RT提升44.62%

原集群RT(平均16.9ms)

新集群RT(平均9.36ms)

新集群平均RT提升50%,并且稳定性增加,毛刺大幅减少

老集群RT(平均250ms)

新集群RT(平均125ms)

提升TiCDC同步性能:新版本在数据同步方面有了数十倍的提升,有效解决了之前版本中出现的同步延迟问题,提供更高的稳定性和可靠性。当下游需要订阅数据至数仓或风控平台时,可以使用TiCDC将数据实时同步至Kafka,提升数据处理的灵活性与响应能力。

缩短备份时间:数据库备份通常会消耗大量的CPU和IO资源。此前,由于备份任务的结束时间恰逢业务高峰期,经常导致应用响应时间(RT)上升等问题。通过进行版本升级将备份效率提升了超过50%。

高压缩存储引擎:新版本采用了高效的数据压缩算法,能够显著减少存储占用。同时,通过优化存储结构,能够快速读取和写入数据,提升整体性能。相同数据在 TiDB 中的存储占用空间更低,iDB 的3副本数据大小仅为 MySQL(主实例数据大小)的 55%。

完善的运维体验:新版本引入更好的监控工具、更智能的故障诊断机制和更简化的运维流程,提供了改进的 Dashboard 和 Top SQL 功能,使得慢查询和问题 SQL 的识别更加直观和便捷,降低 DBA 的工作负担。

更秀更实用的新功能:TiDB 7.x版本提供了TTL定期自动删除过期数据,实现行级别的生命周期控制策略。通过为表设置 TTL 属性,TiDB 可以周期性地自动检查并清理表中的过期数据。此功能在一些场景可以有效节省存储空间、提升性能。TTL 常见的使用场景:

  • 定期删除验证码、短网址记录
  • 定期删除不需要的历史订单
  • 自动删除计算的中间结果

docs.pingcap.com/zh/tidb/v7.…

七、选择 TiDB 的原因

我们不是为了使用TiDB而使用,而是去解决一些MySQL无法满足的场景,关系型数据库我们还是优先推荐MySQL。能用分库分表能解决的问题尽量选择MySQL,毕竟运维成本相对较低、数据库版本更加稳定、单点查询速度更快、单机QPS性能更高这些特性是分布式数据库无法满足的。

  • 非分片查询场景:上游 MySQL 采用了分库分表的设计,但部分业务查询无法利用分片。通过自建 DTS 将 MySQL 数据同步到 TiDB 集群,非分片/聚合查询则使用 TiDB 处理,能够在不依赖原始分片结构的情况下,实现高效的数据查询和分析。
  • 分析 SQL 多场景:业务逻辑比较复杂,往往存在并发查询和分析查询的需求。通过自建 DTS 将 MySQL 数据同步到 TiDB,复杂查询在TiDB执行、点查在MySQL执行。TiDB支持水平扩展,其分布式计算和存储能力使其能够高效处理大量的并发查询请求。既保障了MySQL的稳定性,又提升了整体的查询能力。
  • 磁盘使用大场景:在磁盘使用率较高的情况下,可能会出现 CPU 和内存使用率低,但磁盘容量已达到 MySQL 的瓶颈。TiDB 能够自动进行数据分片和负载均衡,将数据分布在多个节点上, 缓解单一节点的磁盘压力,避免了传统 MySQL 中常见的存储瓶颈问题,从而提高系统的可扩展性和灵活性。
  • 数据倾斜场景:在电商业务场景上,每个电商平台都会有一些销量很好的头部卖家,数据量会很大。即使采取了进行分库分表的策略,仍难以避免大卖家的数据会存储在同一实例中,这样会导致热点查询和慢 SQL 问题,尽管可以通过添加索引或进一步分库分表来优化,但效果有限。采用分布式数据库能够有效解决这一问题。可以将数据均匀地分散存储在多个节点上,在查询时则能够并发执行,从而将流量分散,避免热点现象的出现。随着业务的快速发展和数据量的不断增长,借助简单地增加节点,即可实现水平扩展,满足海量数据及高并发的需求。

八、总结

综上所述,在本次 TiDB 集群版本升级到 v7.5.x 版本过程中,实现了性能和稳定性提升。通过优化的查询计划和更高效的执行引擎,数据读取和写入速度显著提升,大幅度降低了响应延迟,提升了在高并发操作下的可靠性。通过直观的监控界面和更全面的性能分析工具,能够更快速地识别和解决潜在问题,降低 DBA 的工作负担。也为未来的业务扩展和系统稳定性提供了强有力的支持。

后续依然会持续关注 TiDB 在 v8.5.x 版本稳定性、性能以及新产品特性带来应用开发以及运维人效收益进展。目前 TiDB 内核版本 v8.5.x 已经具备多模数据库 Data + AI 能力,在JSON函数、ARRAY 索引以及 Vector Index 实现特性。同时已经具备 Resource Control 资源管理能力,适合进行多业务系统数据归集方案,实现数据库资源池化多种自定义方案。技术研究方面我们数据库团队会持续投入,将产品最好的解决方案引入现网环境。

往期回顾

  1. 得物管理类目配置线上化:从业务痛点到技术实现

  2. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  3. RAG—Chunking策略实战|得物技术

  4. 告别数据无序:得物数据研发与管理平台的破局之路

  5. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

文 /岱影

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物管理类目配置线上化:从业务痛点到技术实现

一、引言

在电商交易领域,管理类目作为业务责权划分、统筹、管理核心载体,随着业务复杂性的提高,其规则调整频率从最初的 1 次 / 季度到多次 / 季度,三级类目的规则复杂度也呈指数级上升。传统依赖数仓底层更新的方式暴露出三大痛点:

  • 行业无法自主、快速调管理类目;
  • 业务管理类目规则调整,不支持校验类目覆盖范围是否有重复/遗漏,延长交付周期;
  • 规则变更成功后、下游系统响应滞后,无法及时应用最新类目规则。

本文将从技术视角解析 “管理类目配置线上化” 项目如何通过全链路技术驱动,将规则迭代周期缩短至 1-2 天。

二、业务痛点与技术挑战:为什么需要线上化?

2.1 效率瓶颈:手工流程与

高频迭代的矛盾

问题场景:业务方需线下通过数仓提报规则变更,经数仓开发、测试、BI需要花费大量精力校验确认,一次类目变更需 3-4 周左右时间才能上线生效,上线时间无法保证。

技术瓶颈:数仓离线同步周期长(T+1),规则校验依赖人工梳理,无法应对 “商品类目量级激增”。

2.2 质量风险:规则复杂度与

校验能力的失衡

典型问题:当前的管理类目映射规则,依赖业务收集提报,但从实际操作看管理三级类目映射规则提报质量较差(主要原因为:业务无法及时校验提报规则是否准确,是否穷举完善,是否完全无交叉),存在大量重复 / 遗漏风险。

2.3 系统耦合:底层变更对

下游应用的多米诺效应

连锁影响:管理类目规则变更会需同步更新交易后台、智能运营系统、商运关系工作台等多下游系统,如无法及时同步,可能会影响下游应用如商运关系工作台的员工分工范围的准确性,影响商家找人、资质审批等场景应用。

三、技术方案:从架构设计到核心模块拆解

3.1 分层架构:解耦业务与数据链路

3.2 核心模块技术实现

规则生命周期管理: 规则操作流程

提交管理类目唯一性校验规则

新增:id为空,则为新增

删除:当前db数据不在提交保存列表中

更新:名称或是否兜底类目或规则改变则发生更新【其中如果只有名称改变则只触发审批,不需等待数据校验,业务规则校验逻辑为将所有规则包含id,按照顺序排序拼接之后结果是否相等】

多级类目查询

构建管理类目树

/**
 * 构建管理类目树
 */
public List<ManagementCategoryDTO> buildTree(List<ManagementCategoryEntity> managementCategoryEntities) {
    Map<Long, ManagementCategoryDTO> managementCategoryMap = new HashMap<>();
    for (ManagementCategoryEntity category : managementCategoryEntities) {
        ManagementCategoryDTO managementCategoryDTO = ManagementCategoryMapping.convertEntity2DTO(category);
        managementCategoryMap.put(category.getId(), managementCategoryDTO);
    }
    
    // 找到根节点
    List<ManagementCategoryDTO> rootNodes = new ArrayList<>();
    for (ManagementCategoryDTO categoryNameDTO : managementCategoryMap.values()) {
        //管理一级类目 parentId是0
        if (Objects.equals(categoryNameDTO.getLevel(), ManagementCategoryLevelEnum.FIRST.getId()) && Objects.equals(categoryNameDTO.getParentId(), 0L)) {
            rootNodes.add(categoryNameDTO);
        }
    }
    // 构建树结构
    for (ManagementCategoryDTO node : managementCategoryMap.values()) {
        if (node.getLevel() > ManagementCategoryLevelEnum.FIRST.getId()) {
            ManagementCategoryDTO parentNode = managementCategoryMap.get(node.getParentId());
            if (parentNode != null) {
                parentNode.getItems().add(node);
            }
        }
    }
    return rootNodes;
}

填充管理类目规则



/**
 * 填充规则信息
 */
private void populateRuleData
(List<ManagementCategoryDTO> managementCategoryDTOS, List<ManagementCategoryRuleEntity> managementCategoryRuleEntities) {
    if (CollectionUtils.isEmpty(managementCategoryDTOS) || CollectionUtils.isEmpty(managementCategoryRuleEntities)) {
        return;
    }
    List<ManagementCategoryRuleDTO> managementCategoryRuleDTOS =managementCategoryMapping.convertRuleEntities2DTOS(managementCategoryRuleEntities);
    // 将规则集合按 categoryId 分组
    Map<Long, List<ManagementCategoryRuleDTO>> rulesByCategoryIdMap = managementCategoryRuleDTOS.stream()
            .collect(Collectors.groupingBy(ManagementCategoryRuleDTO::getCategoryId));
    // 递归填充规则到树结构
    fillRulesRecursively(managementCategoryDTOS, rulesByCategoryIdMap);


}


/**
 * 递归填充规则到树结构
 */
private static void fillRulesRecursively
(List<ManagementCategoryDTO> managementCategoryDTOS, Map<Long, List<ManagementCategoryRuleDTO>> rulesByCategoryIdMap) {
    if (CollectionUtils.isEmpty(managementCategoryDTOS) || MapUtils.isEmpty(rulesByCategoryIdMap)) {
        return;
    }
    for (ManagementCategoryDTO node : managementCategoryDTOS) {
        // 获取当前节点对应的规则列表
        List<ManagementCategoryRuleDTO> rules = rulesByCategoryIdMap.getOrDefault(node.getId(), new ArrayList<>());
        node.setRules(rules);
        // 递归处理子节点
        fillRulesRecursively(node.getItems(), rulesByCategoryIdMap);
    }
}

状态机驱动:管理类目生命周期管理

超时机制 :基于时间阈值的流程阻塞保护

其中,为防止长时间运营处于待确认规则状态,造成其他规则阻塞规则修改,定时判断待确认规则状态持续时间,当时间超过xxx时间之后,则将待确认状态改为长时间未操作,放弃变更状态,并飞书通知规则修改人。

管理类目状态变化级联传播策略

类目生效和失效状态为级联操作。规则如下:

  • 管理二级类目有草稿状态时,不允许下挂三级类目的编辑;
  • 管理三级类目有草稿状态时,不允许对应二级类目的规则编辑;
  • 类目生效失效状态为级联操作,上层修改下层级联修改状态,如果下层管理类目存在草稿状态,则自动更改为放弃更改状态。

规则变更校验逻辑

当一次提交,可能出现的情况如下。一次提交可能会产生多个草稿,对应多个审批流程。

新增管理类目规则:

  • 一级管理类目可以直接新增(点击新增一级管理类目)
  • 二级管理类目和三级管理类目不可同时新增
  • 三级管理类目需要在已有二级类目基础上新增

只有名称修改触发直接审批,有规则修改需要等待数仓计算结果之后,运营提交发起审批。

交互通知中心:飞书卡片推送

  • 变更规则数据计算结果依赖数仓kafka计算结果回调。
  • 基于飞书卡片推送数仓计算结果,回调提交审批和放弃变更事件。

飞书卡片:

卡片结果

卡片操作结果

审批流程:多维度权限控制与飞书集成

提交审批的四种情况:

  • 名称修改
  • 一级类目新增
  • 管理类目规则修改
  • 生效失效变更

审批通过,将草稿内容更新到管理类目表中,将管理类目设置为生效中。

审批驳回,清空草稿内容。

审批人分配机制:多草稿并行审批方案

一次提交可能会产生多个草稿,对应多个审批流程。

审批逻辑

public Map<String, List<String>> buildApprover(
        ManagementCategoryDraftEntity draftEntity,
        Map<Long, Set<String>> catAuditorMap,
        Map<String, String> userIdOpenIdMap,
        Integer hasApprover) {
    
    Map<String, List<String>> nodeApprover = new HashMap<>();


    // 无审批人模式,直接查询超级管理员
    if (!Objects.equals(hasApprover, ManagementCategoryUtils.HAS_APPROVER_YES)) {
        nodeApprover.put(ManagementCategoryApprovalField.NODE_SUPER_ADMIN_AUDIT,
                queryApproverList(0L, catAuditorMap, userIdOpenIdMap));
        return nodeApprover;
    }
    
    Integer level = draftEntity.getLevel();
    Integer draftType = draftEntity.getType();
    boolean isEditOperation = ManagementCategoryDraftTypeEnum.isEditOp(draftType);
    
    // 动态构建审批链(支持N级类目)
    List<Integer> approvalChain = buildApprovalChain(level);
    for (int i = 0; i < approvalChain.size(); i++) {
        int currentLevel = approvalChain.get(i);
        Long categoryId = getCategoryIdByLevel(draftEntity, currentLevel);
        
        // 生成节点名称(如:NODE_LEVEL2_ADMIN_AUDIT)
        String nodeKey = String.format(
                ManagementCategoryApprovalField.NODE_LEVEL_X_ADMIN_AUDIT_TEMPLATE,
                currentLevel
        );
        
        // 编辑操作且当前层级等于提交层级时,添加本级审批人 【新增的管理类目没有还没有对应的审批人】
        if (isEditOperation && currentLevel == level) {
            addApprover(nodeApprover, nodeKey, categoryId, catAuditorMap, userIdOpenIdMap);
        }
        
        // 非本级审批人(上级层级)
        if (currentLevel != level) {
            addApprover(nodeApprover, nodeKey, categoryId, catAuditorMap, userIdOpenIdMap);
        }
    }
    
    return nodeApprover;
}


private List<Integer> buildApprovalChain(Integer level) {
    List<Integer> approvalChain = new ArrayList<>();
    if (level == 3) {
        approvalChain.add(2); // 管二审批人
        approvalChain.add(1); // 管一审批人
    } else if (level == 2) {
        approvalChain.add(2); // 管二审批人
        approvalChain.add(1); // 管一审批人
    } else if (level == 1) {
        approvalChain.add(1); // 管一审批人
        approvalChain.add(0); // 超管
    }
    return approvalChain;
}

3.3 数据模型设计

3.4 数仓计算逻辑

同步数据方式

方案一:

每次修改规则之后通过调用SQL触发离线计算

优势:通过SQL调用触发计算,失效性较高

劣势:ODPS 资源峰值消耗与SQL脚本耦合问题

  • 因为整个规则修改是三级类目维度,如果同时几十几百个类目触发规则改变,会同时触发几十几百个离线任务。同时需要大量ODPS 资源;
  • 调用SQL方式需要把当前规则修改和计算逻辑的SQL一起调用计算。

方案二:

优势:同时只会产生一次规则计算

劣势:实时性受限于离线计算周期

  • 实时性取决于离线规则计算的定时任务配置和离线数据同步频率,实时性不如直接调用SQL性能好
  • 不重不漏为当前所有变更规则维度

技术决策:常态化迭代下的最优解

考虑到管理类目规则平均变更频率不高,且变更时间点较为集中(非紧急场景占比 90%),故选择定时任务方案实现:

  • 资源利用率提升:ODPS 计算资源消耗降低 80%,避免批量变更时数百个任务同时触发的资源峰值;
  • 完整性保障:通过全量维度扫描确保规则校验无遗漏,较 SQL 触发方案提升 20% 校验覆盖率;
  • 可维护性优化:减少 SQL 脚本与业务逻辑的强耦合,维护成本降低 80%。

数据取数逻辑

生效中规则计算

草稿+生效中规格计算

如果是新增管理类目,直接参与计算。

如果是删除管理类目,需要将该删除草稿中对应的生效管理类目排除掉。

如果是更新:需要将草稿中的管理类目和规则替换生效中对应的管理类目和规则。

数仓实现

数据流程图

四、项目成果与技术价值

预期效率提升:从 “周级” 到 “日级” 的跨越

  • 管理一级 / 二级类目变更开发零成本,无需额外人力投入
  • 管理三级类目变更相关人力成本降低 100%,无需额外投入开发资源
  • 规则上线周期压缩超 90%,仅需 1 - 2 天即可完成上线

质量保障:自动化校验替代人工梳理

  • 规则重复 / 遗漏检测由人工梳理->自动化计算
  • 下游感知管理类目规则变更由人工通知->实时感知

技术沉淀:规则模型化能力

沉淀管理类目规则配置模型,支持未来四级、五级多级管理类目快速适配。

五、总结

未来优化方向:

  1. 规则冲突预警:基于AI预测高风险规则变更,提前触发校验
  2. 接入flink做到实时计算管理类目和对应商品关系

技术重构的本质是 “释放业务创造力”

管理类目配置线上化项目的核心价值,不仅在于技术层面的效率提升,更在于通过自动化工具链,让业务方从 “规则提报的执行者” 转变为 “业务策略的设计者”。当技术架构能够快速响应业务迭代时,企业才能在电商领域的高频竞争中保持创新活力。

往期回顾

  1. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  2. RAG—Chunking策略实战|得物技术

  3. 告别数据无序:得物数据研发与管理平台的破局之路

  4. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

  5. Apex AI辅助编码助手的设计和实践|得物技术

文 /维山

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

一、背 景

预发环境一个后台服务admin突然启动失败,异常如下:



org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'timeoutNotifyController': Injection of resource dependencies failed; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'spuCheckDomainServiceImpl': Bean with name 'spuCheckDomainServiceImpl' has been injected into other beans [...] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:598)
        at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:90)
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:376)
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1404)
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:592)
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:515)
        at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:320)
        at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
        at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
        at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
        at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:847)
        at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:877)
        at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:549)
        at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:141)
        at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:744)
        at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:391)
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:312)
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1215)
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1204)
        at com.shizhuang.duapp.commodity.interfaces.admin.CommodityAdminApplication.main(CommodityAdminApplication.java:100)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:48)
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:87)
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:51)
        at org.springframework.boot.loader.PropertiesLauncher.main(PropertiesLauncher.java:578)

错误日志中明确写道:“Bean has been injected into other beans ... in its raw version as part of a circular reference, but has eventually been wrapped. ”这不仅仅是一个简单的循环依赖错误。它揭示了一个更深层次的问题:当循环依赖遇上Spring的AOP代理(如@Transactional事务、自定义切面等),Spring在解决依赖的时,不得已将一个“半成品”(原始Bean)注入给了其他30多个Bean。而当这个“半成品”最终被“包装”(代理)成“成品”时,先前那些持有“半成品”引用的Bean们,使用的却是一个错误的版本。

这就像在组装一个精密机器时,你把一个未经质检的零件提前装了进去,等质检完成后,机器里混用着新旧版本的零件,最终的崩溃也就不可避免。

本篇文章将带你一起:

  • 熟悉spring容器的循环依赖以及Spring容器如何解决循环依赖,创建bean相关的流程。
  • 深入解读这条复杂错误日志背后的每一个关键线索;
  • 提供紧急止血方案;
  • 分享如何从架构设计上避免此类问题的实践心得。

二、相关知识点简介

2.1 循环依赖

什么是Bean循环依赖?

循环依赖:说白是一个或多个对象实例之间存在直接或间接的依赖关系,这种依赖关系构成了构成一个环形调用,主要有如下几种情况。

第一种情况:自己依赖自己的直接依赖

第二种情况:两个对象之间的直接依赖

前面两种情况的直接循环依赖比较直观,非常好识别,但是第三种间接循环依赖的情况有时候因为业务代码调用层级很深,不容易识别出来。

循环依赖场景

构造器注入循环依赖:

@Service
public class A {public A(B b) {}}
@Service
public class B {public B(A a) {}}

结果:项目启动失败抛出异常BeanCurrentlyInCreationException

Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?
        at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.beforeSingletonCreation(DefaultSingletonBeanRegistry.java:339)
        at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:215)
        at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
        at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)

构造器注入构成的循环依赖,此种循环依赖方式无论是Singleton模式还是prototype模式都是无法解决的,只能抛出BeanCurrentlyInCreationException异常表示循环依赖。原因是Spring解决循环依赖依靠的是Bean的“中间态”这个概念,而中间态指的是已经实例化,但还没初始化的状态。而完成实例化需要调用构造器,所以构造器的循环依赖无法解决。

Singleton模式field属性注入(setter方法注入)循环依赖:

这种方式是我们最为常用的依赖注入方式:

@Service
public class A {
    @Autowired
    private B b;
    }
@Service
public class B {
    @Autowired
    private A a;
    }

结果:项目启动成功,正常运行

prototype field属性注入循环依赖:

prototype在平时使用情况较少,但是也并不是不会使用到,因此此种方式也需要引起重视。

@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Service
public class A {
    @Autowired
    private B b;
    }
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Service
public class B {
    @Autowired
    private A a;
    }

结果:需要注意的是本例中启动时是不会报错的(因为非单例Bean默认不会初始化,而是使用时才会初始化),所以很简单咱们只需要手动getBean()或者在一个单例Bean内@Autowired一下它即可。

// 在单例Bean内注入
    @Autowired
    private A a;

这样子启动就报错:

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'mytest.TestSpringBean': Unsatisfied dependency expressed through field 'a'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'a': Unsatisfied dependency expressed through field 'b'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'b': Unsatisfied dependency expressed through field 'a'; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:596)
        at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:90)
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:374)

如何解决?可能有的小伙伴看到网上有说使用@Lazy注解解决:

    @Lazy
    @Autowired
    private A a;

此处负责任的告诉你这样是解决不了问题的(可能会掩盖问题),@Lazy只是延迟初始化而已,当你真正使用到它(初始化)的时候,依旧会报如上异常。

对于Spring循环依赖的情况总结如下:

  • 不能解决的情况:构造器注入循环依赖,prototype field属性注入循环依赖
  • 能解决的情况:field属性注入(setter方法注入)循环依赖

Spring如何解决循环依赖

Spring 是通过三级缓存和提前曝光的机制来解决循环依赖的问题。

三级缓存

三级缓存其实就是用三个 Map 来存储不同阶段 Bean 对象。

一级缓存
private final Map<StringObject> singletonObjects = new ConcurrentHashMap<>(256);
二级缓存
private final Map<StringObjectearlySingletonObjects = new HashMap<>(16);
//三级缓存
private final Map<StringObjectFactory<?>> singletonFactories = new HashMap<>(16)
  • singletonObjects:用于存放完全初始化好的 bean,从该缓存中取出的 bean 可以直接使用
  • earlySingletonObjects:提前曝光的单例对象的cache,存放原始的 bean 对象(尚未填充属性),用于解决循环依赖。
  • singletonFactories:单例对象工厂的cache,存放 bean 工厂对象,用于解决循环依赖。

三级缓存解决循环依赖过程

假设现在我们有ServiceA和ServiceB两个类,这两个类相互依赖,代码如下:

@Service
public class ServiceA {
    @Autowired
    private ServiceB serviceB;
    }


@Service
public class ServiceB {
    @Autowired
    private ServiceA serviceA ;
    }

下面的时序图说明了spring用三级缓存解决循环依赖的主要流程:

为什么需要三级缓存?

这是一个理解Spring容器如何解决循环依赖的核心概念。三级缓存是Spring为了解决循环依赖的同时,又能保证AOP代理的正确性而设计的精妙机制。

为了理解为什么需要三级缓存,我们一步步来看。

如果没有缓存(Level 0)

假设有两个Bean:ServiceA  和 ServiceB,它们相互依赖。

Java

@Component
public class ServiceA  {
    @Autowired
    private ServiceB serviceB;
}
@Component
public class ServiceB {
    @Autowired
    private ServiceA serviceA;
}

创建过程(无缓存)

  • 开始创建 ServiceA -> 发现 ServiceA 需要 ServiceB -> 开始创建 ServiceB
  • 开始创建 ServiceB -> 发现 ServiceB 需要 ServiceA -> 开始创建 ServiceA
  • 开始创建 ServiceA -> 发现 ServiceA 需要 ServiceB -> ... 无限循环,StackOverflowError

结论:无法解决循环依赖,直接死循环。

如果只有一级缓存(Singleton Objects)

一级缓存存放的是已经完全创建好、初始化完毕的Bean。

问题:在Bean的创建过程中(比如在填充属性 populateBean 时),ServiceA还没创建完,它本身不应该被放入"已完成"的一级缓存。但如果ServiceB需要ServiceA,而一级缓存里又没有ServiceA的半成品,ServiceB就无法完成创建。这就回到了上面的死循环问题。

结论:一级缓存无法解决循环依赖。

如果使用二级缓存

二级缓存的核心思路是:将尚未完全初始化好的“早期引用”暴露出来。

现在我们有:

  • 一级缓存(成品库) :存放完全准备好的Bean。
  • 二级缓存(半成品库) :存放刚刚实例化(调用了构造方法),但还未填充属性和初始化的Bean的早期引用。

创建过程(二级缓存):

开始创建ServiceA

  • 实例化ServiceA(调用ServiceA的构造方法),得到一个ServiceA的原始对象。
  • 将ServiceA的原始对象放入二级缓存(半成品库)。
  • 开始为ServiceA填充属性 -> 发现需要ServiceB。

开始创建ServiceB

  • 实例化ServiceB(调用B的构造方法),得到一个ServiceB的原始对象。
  • 将ServiceB的原始对象放入二级缓存。
  • 开始为ServiceB填充属性 -> 发现需要ServiceA。

ServiceB从二级缓存中获取A

  • ServiceB成功从二级缓存中拿到了ServiceA的早期引用(原始对象)。
  • ServiceB顺利完成了属性填充、初始化等后续步骤,成为一个完整的Bean。
  • 将完整的ServiceB放入一级缓存(成品库),并从二级缓存移除ServiceB。

ServiceA继续创建:

  • ServiceA拿到了创建好的ServiceB,完成了自己的属性填充和初始化。
  • 将完整的ServiceA放入一级缓存(成品库),并从二级缓存移除ServiceA。

问题来了:如果ServiceA需要被AOP代理怎么办?

如果A类上加了 @Transactional 等需要创建代理的注解,那么最终需要暴露给其他Bean的应该是ServiceA的代理对象,而不是ServiceA的原始对象。

在二级缓存方案中,ServiceB拿到的是A的原始对象。但最终ServiceA完成后,放入一级缓存的是ServiceA的代理对象。这就导致了:

  • ServiceB里面持有的ServiceA是原始对象。
  • 而其他地方注入的ServiceA是代理对象。
  • 这就造成了不一致!如果通过ServiceB的ServiceA去调用事务方法,事务会失效,因为那是一个没有被代理的原始对象。

结论:二级缓存可以解决循环依赖问题,但无法正确处理需要AOP代理的Bean。

三级缓存的登场(Spring的终极方案)

为了解决代理问题,Spring引入了第三级缓存。它的核心不是一个直接存放对象(Object)的缓存,而是一个存放 ObjectFactory(对象工厂) 的缓存。

三级缓存的结构是:Map<String, ObjectFactory<?>> singletonFactories

创建过程(三级缓存,以ServiceA需要代理为例):

  • 开始创建ServiceA
  • 实例化ServiceA,得到ServiceA的原始对象。
  • 三级缓存添加一个ObjectFactory。这个工厂的getObject()方法有能力判断ServiceA是否需要代理,并返回相应的对象(原始对象或代理对象)
  • 开始为ServiceA填充属性 -> 发现需要ServiceB。
  • 开始创建B
  • 实例化ServiceB。
  • 同样向三级缓存添加一个ServiceB的ObjectFactory。
  • 开始为ServiceB填充属性 -> 发现需要ServiceA。
  • ServiceB从缓存中获取ServiceA
  • ServiceB发现一级缓存没有ServiceA,二级缓存也没有ServiceA。
  • ServiceB发现三级缓存有A的ObjectFactory。
  • B调用这个工厂的getObject()方法。此时,Spring会执行一个关键逻辑:
  • 如果ServiceA需要被代理,工厂会提前生成ServiceA的代理对象并返回。
  • 如果ServiceA不需要代理,工厂则返回A的原始对象。
  • 将这个早期引用(可能是原始对象,也可能是代理对象) 放入二级缓存,同时从三级缓存移除A的工厂。
  • ServiceB拿到了ServiceA的正确版本的早期引用。

后续步骤:

  • ServiceB完成创建,放入一级缓存。
  • ServiceA继续用ServiceB完成创建。在ServiceA初始化的最后,Spring会再次检查:如果ServiceA已经被提前代理了(即在第3步中),那么就直接返回这个代理对象;如果没有,则可能在此处创建代理(对于不需要解决循环依赖的Bean)。
  • 最终,将完整的ServiceA(代理对象)放入一级缓存,并清理二级缓存。

总结:为什么需要三级缓存?

需要三级缓存,是因为Spring要解决一个复杂问题:在存在循环依赖的情况下,如何确保所有Bean都能拿到最终形态(可能被AOP代理)的依赖对象,而不是原始的、未代理的对象。 三级缓存通过一个ObjectFactory将代理的时机提前,完美地解决了这个问题。二级缓存主要是为了性能优化而存在的。

spring三级缓存为什么不能解决

@Async注解的循环依赖问题

这触及了 Spring 代理机制的一个深层次区别。@Async注解的循环依赖问题确实比@Transactional 更复杂,三级缓存无法完全解决。让我们深入分析原因。

2.2 Spring创建Bean主要流程

为了容易理解 Spring 解决循环依赖过程,我们先简单温习下 Spring 容器创建 Bean 的主要流程。

从代码看Spring对于Bean的生成过程,步骤还是很多的,我把一些扩展业务代码省略掉:

protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)
          throws BeanCreationException {
    if (mbd.isSingleton()) {
      instanceWrapper = this.factoryBeanInstanceCache.remove(beanName);
    }
    // Bean初始化第一步:默认调用无参构造实例化Bean
    // 如果是只有带参数的构造方法,构造方法里的参数依赖注入,就是发生在这一步
    if (instanceWrapper == null) {
      instanceWrapper = createBeanInstance(beanName, mbd, args);
    }


    //判断Bean是否需要提前暴露对象用来解决循环依赖,需要则启动spring三级缓存
    boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
       isSingletonCurrentlyInCreation(beanName));
   if (earlySingletonExposure) {
     if (logger.isTraceEnabled()) {
       logger.trace("Eagerly caching bean '" + beanName +
             "' to allow for resolving potential circular references");
      }
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}


    // Initialize the bean instance.
    Object exposedObject = bean;
    try {
      // bean创建第二步:填充属性(DI依赖注入发生在此步骤)
      populateBean(beanName, mbd, instanceWrapper);
      // bean创建第三步:调用初始化方法,完成bean的初始化操作(AOP的第三个入口)
      // AOP是通过自动代理创建器AbstractAutoProxyCreator的postProcessAfterInitialization()
//方法的执行进行代理对象的创建的,AbstractAutoProxyCreator是BeanPostProcessor接口的实现
      exposedObject = initializeBean(beanName, exposedObject, mbd);




   if (earlySingletonExposure) {
    Object earlySingletonReference = getSingleton(beanName, false);
     if (earlySingletonReference != null) {
        if (exposedObject == bean) {
          exposedObject = earlySingletonReference;
        }
        else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) {
          String[] dependentBeans = getDependentBeans(beanName);
          Set<String> actualDependentBeans = new LinkedHashSet<>(dependentBeans.length);
          for (String dependentBean : dependentBeans) {
             if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)) {
                actualDependentBeans.add(dependentBean);
             }
          }
          if (!actualDependentBeans.isEmpty()) {
             throw new BeanCurrentlyInCreationException(beanName,
                   "Bean with name '" + beanName + "' has been injected into other beans [" +
                   StringUtils.collectionToCommaDelimitedString(actualDependentBeans) +
                   "] in its raw version as part of a circular reference, but has eventually been " +
                   "wrapped. This means that said other beans do not use the final version of the " +
                   "bean. This is often the result of over-eager type matching - consider using " +
                   "'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.");
          }
       }
    }
}


    } catch (Throwable ex) {
      // ...
    }
    // ...
    return exposedObject;
    }

从上述代码看出,整体脉络可以归纳成 3 个核心步骤:

  • 实例化Bean:主要是通过反射调用默认构造函数创建 Bean 实例,此时Bean的属性都还是默认值null。被注解@Bean标记的方法就是此阶段被调用的。
  • 填充Bean属性:这一步主要是对Bean的依赖属性进行填充,对@Value、@Autowired、@Resource注解标注的属性注入对象引用。
  • 调用Bean初始化方法:调用配置指定中的init方法,如 xml文件指定Bean的init-method方法或注解 @Bean(initMethod = "initMethod")指定的方法。

三、案例分析

3.1 代码分析

以下是我简化后的类之间大体的依赖关系,工程内实际的依赖情况会比这个简化版本复杂一些。

@RestController
public class OldCenterSpuController {
    @Resource
    private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}
@RestController
public class TimeoutNotifyController {
    @Resource
    private SpuCheckDomainServiceImpl spuCheckDomainServiceImpl;
}
@Component
public class NewSpuApplyCheckServiceImpl {
    @Resource
    private SpuCheckDomainServiceImpl spuCheckDomainServiceImpl;
}
@Component
@Slf4j
@Validated
public class SpuCheckDomainServiceImpl {
    @Resource
    private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}

从代码看,主要是SpuCheckDomainServiceImpl和NewSpuApplyCheckServiceImpl 构成了一个依赖环。而我们从正常启动的bean加载顺序发现首先是从OldCenterSpuController开始加载的,具体情况如下所示:

OldCenterSpuController 
    ↓ (依赖)
NewSpuApplyCheckServiceImpl 
    ↓ (依赖)  
SpuCheckDomainServiceImpl 
    ↓ (依赖)
NewSpuApplyCheckServiceImpl 

异常启动的情况bean加载是从TimeoutNotifyController开始加载的,具体情况如下所示:

TimeoutNotifyController 
    ↓ (依赖)
SpuCheckDomainServiceImpl 
    ↓ (依赖)  
NewSpuApplyCheckServiceImpl 
    ↓ (依赖)
SpuCheckDomainServiceImpl 

同一个依赖环,为什么从OldCenterSpuController 开始加载就可以正常启动,而从TimeoutNotifyController 启动就会启动异常呢?下面我们会从现场debug的角度来分析解释这个问题。

3.2 问题分析

在相关知识点简介里面知悉到spring用三级缓存解决了循环依赖问题。为什么后台服务admin启动还会报循环依赖的问题呢?

要得到问题的答案,还是需要回到源码本身,前面我们分析了spring的创建Bean的主要流程,这里为了更好的分析问题,补充下通过容器获取Bean的。

在通过spring容器获取bean时,底层统一会调用doGetBean方法,大体如下:

protected <T> T doGetBean(final String name, @Nullable final Class<T> requiredType,
       @Nullable final Object[] args, boolean typeCheckOnly) throws BeansException {
    
    final String beanName = transformedBeanName(name);
    Object bean;
    
    // 从三级缓存获取bean
    Object sharedInstance = getSingleton(beanName);
    if (sharedInstance != null && args == null) {
       bean = getObjectForBeanInstance(sharedInstance, name, beanName, null);
    }else {
     if (mbd.isSingleton()) {
       sharedInstance = getSingleton(beanName, () -> {
       try {
         //如果是单例Bean,从三级缓存没有获取到bean,则执行创建bean逻辑
          return createBean(beanName, mbd, args);
       }
       catch (BeansException ex) {
          destroySingleton(beanName);
          throw ex;
       }
    });
    bean = getObjectForBeanInstance(sharedInstance, name, beanName, mbd);
  }   
 }

从doGetBean方法逻辑看,在spring从一二三级缓存获取bean返回空时,会调用createBean方法去场景bean,createBean方法底层主要是调用前面我们提到的创建Bean流程的doCreateBean方法。

注意:doGetBean方法里面getSingleton方法的逻辑是先从一级缓存拿,拿到为空并且bean在创建中则又从二级缓存拿,二级缓存拿到为空 并且当前容器允许有循环依赖则从三级缓存拿。并且将对象工厂移到二级缓存,删除三级缓存

doCreateBean方法如下:

protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)
          throws BeanCreationException {
    if (mbd.isSingleton()) {
      instanceWrapper = this.factoryBeanInstanceCache.remove(beanName);
    }
    // Bean初始化第一步:默认调用无参构造实例化Bean
    // 如果是只有带参数的构造方法,构造方法里的参数依赖注入,就是发生在这一步
    if (instanceWrapper == null) {
      instanceWrapper = createBeanInstance(beanName, mbd, args);
    }


    //判断Bean是否需要提前暴露对象用来解决循环依赖,需要则启动spring三级缓存
    boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
       isSingletonCurrentlyInCreation(beanName));
   if (earlySingletonExposure) {
     if (logger.isTraceEnabled()) {
       logger.trace("Eagerly caching bean '" + beanName +
             "' to allow for resolving potential circular references");
      }
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}


    // Initialize the bean instance.
    Object exposedObject = bean;
    try {
      // bean创建第二步:填充属性(DI依赖注入发生在此步骤)
      populateBean(beanName, mbd, instanceWrapper);
      // bean创建第三步:调用初始化方法,完成bean的初始化操作(AOP的第三个入口)
      // AOP是通过自动代理创建器AbstractAutoProxyCreator的postProcessAfterInitialization()
//方法的执行进行代理对象的创建的,AbstractAutoProxyCreator是BeanPostProcessor接口的实现
      exposedObject = initializeBean(beanName, exposedObject, mbd);




   if (earlySingletonExposure) {
    Object earlySingletonReference = getSingleton(beanName, false);
     if (earlySingletonReference != null) {
        if (exposedObject == bean) {
          exposedObject = earlySingletonReference;
        }
        else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) {
          String[] dependentBeans = getDependentBeans(beanName);
          Set<String> actualDependentBeans = new LinkedHashSet<>(dependentBeans.length);
          for (String dependentBean : dependentBeans) {
             if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)) {
                actualDependentBeans.add(dependentBean);
             }
          }
          if (!actualDependentBeans.isEmpty()) {
             throw new BeanCurrentlyInCreationException(beanName,
                   "Bean with name '" + beanName + "' has been injected into other beans [" +
                   StringUtils.collectionToCommaDelimitedString(actualDependentBeans) +
                   "] in its raw version as part of a circular reference, but has eventually been " +
                   "wrapped. This means that said other beans do not use the final version of the " +
                   "bean. This is often the result of over-eager type matching - consider using " +
                   "'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.");
          }
       }
    }
}


    } catch (Throwable ex) {
      // ...
    }
    // ...
    return exposedObject;
    }

将doGetBean和doCreateBean的逻辑转换成流程图如下:

从流程图可以看出,后台服务admin启动失败抛出UnsatisfiedDependencyException异常的必要条件是存在循环依赖,因为不存在循环依赖的情况bean只会存在单次加载,单次加载的情况bean只会被放进spring的第三级缓存。

而触发UnsatisfiedDependencyException异常的先决条件是需要spring的第一二级缓存有当前的bean。所以可以知道当前bean肯定存在循环依赖。在存在循环依赖的情况下,当前bean被第一次获取(即调用doGetBean方法)会缓存进spring的第三级缓存,然后会注入当前bean的依赖(即调用populateBean方法),在当前bean所在依赖环内其他bean都不在一二级缓存的情况下,会触发当前bean的第二次获取(即调用doGetBean方法),由于第一次获取已经将Bean放进了第三级缓存,spring会将Bean从第三级缓存移到二级缓存并删除第三级缓存。

最终会回到第一次获取的流程,调用初始化方法做初始化。最终在初始化有对当前bean做代理增强的并且提前暴露到二级缓存的对象有被其他依赖引用到,而且allowRawInjectionDespiteWrapping=false的情况下,会导致抛出UnsatisfiedDependencyException,进而导致启动异常。

注意:在注入当前bean的依赖时,这里spring将Bean从第三级缓存移到二级缓存并删除第三级缓存后,当前bean的依赖的其他bean会从二级缓存拿到当前bean做依赖。这也是后续抛异常的先决条件

结合admin有时候启动正常,有时候启动异常的情况,这里猜测启动正常和启动异常时bean加载顺序不一致,进而导致启动正常时当前Bean只会被获取一次,启动异常时当前bean会被获取两次。为了验证猜想,我们分别针对启动异常和启动正常的bean获取做了debug。

debug分析

首先我们从启动异常提取到以下关键信息,从这些信息可以知道是spuCheckDomainServiceImpl的加载触发的启动异常。所以我们这里以spuCheckDomainServiceImpl作为前面流程分析的当前bean。

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'timeoutNotifyController': Injection of resource dependencies failed; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'spuCheckDomainServiceImpl': Bean with name 'spuCheckDomainServiceImpl' has been injected into other beans [...] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.

然后提前我们在doCreateBean方法设置好spuCheckDomainServiceImpl加载时的条件断点。我们先debug启动异常的情况。最终断点信息如下:

从红框1里面的两个引用看,很明显调initializeBean方法时spring有对spuCheckDomainServiceImpl做代理增强。导致initializeBean后返回的引用和提前暴露到二级缓存的引用是不一致的。这里spuCheckDomainServiceImpl有二级缓存是跟我们前面分析的吻合,是因为spuCheckDomainServiceImpl被获取了两次,即调了两次doGetBean。

从红框2里面的actualDependentBeans的set集合知道提前暴露到二级缓存的引用有被其他33个bean引用到,也是跟异常提示的bean列表保持一致的。

这里spuCheckDomainServiceImpl的加载为什么会调用两次doGetBean方法呢?

从调用栈分析到该加载链如下:

TimeoutNotifyController  ->spuCheckDomainServiceImpl-> newSpuApplyCheckServiceImpl-> ... ->spuCheckDomainServiceImpl

TimeoutNotifyController注入依赖时第一次调用doGetBean获取spuCheckDomainServiceImpl时,从一二三级缓存获取不到,会调用doCreateBean方法创建spuCheckDomainServiceImpl。

首先会将spuDomainServiceImpl放进spring的第三级缓存,然后开始调populateBean方法注入依赖,由于在循环中间的newSpuApplyCheckServiceImpl是第一次获取,一二三级缓存都获取不到,会调用doCreateBean去创建对应的bean,然后会第二次调用doGetBean获取spuCheckDomainServiceImpl,这时spuCheckDomainServiceImpl在第一次获取已经将bean加载到第三级缓存,所以这次spring会将bean从第三级缓存直接移到第二级缓存,并将第三级缓存里面的spuCheckDomainServiceImpl对应的bean删除,并直接返回二级缓存里面的bean,不会再调doCreateBean去创建spuCheckDomainServiceImpl。最终完成了循环中间的bean的初始化后(这里循环中间的bean初始化时依赖到的bean如果有引用到spuCheckDomainServiceImpl会调用doGetBean方法从二级缓存拿到spuCheckDomainServiceImpl提前暴露的引用),会回到第一次调用doGetBean获取spuCheckDomainServiceImpl时调用的doCreateBean方法的流程。继续调initializeBean方法完成初始化,然后将初始化完成的bean返回。最终拿初始化返回的bean引用跟二级缓存拿到的bean引用做对比,发现不一致,导致抛出UnsatisfiedDependencyException异常。

那么这里为什么spuCheckDomainServiceImpl调用initializeBean方法完成初始化后与提前暴露到二级缓存的bean会不一致呢?

看spuCheckDomainServiceImpl的代码如下:

@Component
@Slf4j
@Validated
public class SpuCheckDomainServiceImpl {
    @Resource
    private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}

发现SpuCheckDomainServiceImpl类有使用到 @Validated注解。查阅资料发现 @Validated的实现是通过在initializeBean方法里面执行一个org.springframework.validation.beanvalidation.MethodValidationPostProcessor后置处理器实现的,MethodValidationPostProcessor会对SpuCheckDomainServiceImpl做一层代理。导致initializeBean方法返回的spuCheckDomainServiceImpl是一个新的代理对象,从而最终导致跟二级缓存的不一致。

debug视图如下:

那为什么有时候能启动成功呢?什么情况下能启动成功?

我们继续debug启动成功的情况。最终观察到spuCheckDomainServiceImpl只会调用一次doGetBean,而且从一二级缓存拿到的spuCheckDomainServiceImpl提前暴露的引用为null,如下图:

这里为什么spuCheckDomainServiceImpl只会调用一次doGetBean呢?

首先我们根据调用栈整理到当前加载的引用栈:

oldCenterSpuController-> newSpuApplyCheckServiceImpl-> ... ->spuCheckDomainServiceImpl -> newSpuApplyCheckServiceImpl

根据前面启动失败的信息我们可以知道,spuCheckDomainServiceImpl处理依赖的环是:

spuCheckDomainServiceImpl ->newSpuApplyCommandServiceImpl-> ... ->spuCheckDomainServiceImpl

失败的情况我们发现是从spuCheckDomainServiceImpl开始创建的,现在启动正常的情况是从newSpuApplyCheckServiceImpl开始创建的。

创建 newSpuApplyCheckServiceImpl时,发现它依赖环中间这些bean会依次调用doCreateBean方法去创建对应的bean。

调用到spuCheckDomainServiceImpl时,由于是第一次获取bean,也会调用doCreateBean方法创建bean,然后回到创建spuCheckDomainServiceImpl的doCreateBean流程,这里由于没有将spuCheckDomainServiceImpl的三级缓存移到二级缓存,所以不会导致抛出UnsatisfiedDependencyException异常,最终回到newSpuApplyCheckServiceImpl的doCreateBean流程,由于newSpuApplyCheckServiceImpl在调用initializeBean方法没有做代理增强,所以也不会导致抛出UnsatisfiedDependencyException异常。因此最后可以正常启动。

这里我们会有疑问?类的创建顺序由什么决定的呢?

通常不同环境下,代码打包后的jar/war结构、@ComponentScan的basePackages配置细微差别,都可能导致Spring扫描和注册Bean定义的顺序不同。Java ClassLoader加载类的顺序本身也有一定不确定性。如果Bean定义是通过不同的配置类引入的,配置类的加载顺序会影响其中所定义Bean的注册顺序。

那是不是所有的类增强在有循环依赖时都会触发UnsatisfiedDependencyException异常呢?

并不是,比如@Transactional就不会导致触发UnsatisfiedDependencyException异常。让我们深入分析原因。

核心区别在于代理创建时机不同。

@Transactional的代理时机如下:

// Spring 为 @Transactional 创建代理的流程1. 实例化原始 Bean
2. 放入三级缓存(ObjectFactory)
3. 当发生循环依赖时,调用 ObjectFactory.getObject()
4. 此时判断是否需要事务代理,如果需要则提前创建代理
5. 将代理对象放入二级缓存,供其他 Bean 使用

@Validated的代理时机:

// @Validated 的代理创建在生命周期更晚的阶段1. 实例化原始 Bean
2. 放入三级缓存(ObjectFactory)
3. 当发生循环依赖时,调用 ObjectFactory.getObject()
4.  ❌ 问题:此时 @Validated 的代理还未创建!
5. 其他 Bean 拿到的是原始对象,而不是异步代理对象

问题根源:@Transactional的代理增强是在三层缓存生成时触发的, @Validated的增强是在初始化bean后通过后置处理器做的代理增强。

3.3 解决方案

短期方案

  • 移除SpuCheckDomainServiceImpl类上的Validated注解
  • @lazy 解耦
    • 原理是发现有@lazy 注解的依赖为其生成代理类,依赖代理类,只有在真正需要用到对象时,再通过getBean的逻辑去获取对象,从而实现了解耦。

长期方案

严格执行DDD代码规范

这里是违反DDD分层规范导致的循环依赖。

梳理解决历史依赖环

通过梳理修改代码解决历史存在的依赖环。我们内部实现了一个能检测依赖环的工具,这里简单介绍一下实现思路,详情如下。

日常循环依赖环:实战检测工具类解析

在实际项目中,即使遵循了DDD分层规范和注入最佳实践,仍有可能因业务复杂或团队协作不充分而引入循环依赖。为了在开发阶段尽早发现这类问题,我们可以借助自定义的循环依赖检测工具类,在Spring容器启动后自动分析并报告依赖环。

功能概述:

  • 条件启用:通过配置circular.dependecy.analysis.enabled=true开启检测;
  • 依赖图构建:扫描所有单例Bean,分析其构造函数、字段、方法注入及depends-on声明的依赖;
  • 循环检测算法:使用DFS遍历依赖图,识别所有循环依赖路径;
  • 通知上报:检测结果通过飞书机器人发送至指定接收人(targetId)。

简洁代码结构如下:

@Component
@ConditionalOnProperty(value = "circular.dependency.analysis.enabled", havingValue = "true")
public class TimingCircularDependencyHandler extends AbstractNotifyHandler<NotifyData>
    implements ApplicationContextAwareBeanFactoryAware {
    
    @Override
    public Boolean handler(NotifyData data) {
        dependencyGraph = new HashMap<>();
        handleContextRefresh(); // 触发依赖图构建与检测
        return Boolean.TRUE;
    }
    
    private void buildDependencyGraph() {
        // 遍历所有Bean,解析其依赖关系
        // 支持:构造器、字段、方法、depends-on
    }
    
    private void detectCircularDependencies() {
        // 使用DFS检测环,记录所有循环路径
        // 输出示例:循环依赖1: A -> B -> C -> A
    }
}

四、总结

循环依赖暴露了代码结构的设计缺陷。理论上应通过分层和抽象来避免,但在复杂的业务交互中仍难以杜绝。虽然Spring利用三级缓存等机制默默解决了这一问题,使程序得以运行,但这绝不应是懈怠设计的借口。我们更应恪守设计原则,从源头规避循环依赖,构建清晰、健康的架构。

往期回顾

1. Apex AI辅助编码助手的设计和实践|得物技术

2. 从 JSON 字符串到 Java 对象:Fastjson 1.2.83 全程解析|得物技术

3. 用好 TTL Agent 不踩雷:避开内存泄露与CPU 100%两大核心坑|得物技术

4. 线程池ThreadPoolExecutor源码深度解析|得物技术

5. 基于浏览器扩展 API Mock 工具开发探索|得物技术

文 /鲁班

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

从 JSON 字符串到 Java 对象:Fastjson 1.2.83 全程解析|得物技术

一、概述

Fastjson 是阿里巴巴开源的高性能 JSON 序列化处理库,其主要以处理小数据时速度最快而著称,功能全面。Fastjson1.X版本目前已停止维护,被Fastjson2.X代替,但1.X版本国内被广泛使用,通过学习其技术架构,剖析架构上优缺点,对技术人员提升软件设计工程实践能力很有价值。

首先我们对“序列化 / 反序列化”概念上建立直观认识,把Java对象转化为JSON格式的字符串的过程叫做序列化操作,反之则叫反序列化。如果把“序列化 / 反序列化”放到整个计算机系统的坐标系里,可以把它看成一次数据的“跨边界搬家”。

对象在“内存世界”里活得很好,但只要一离开进程地址空间(网络、磁盘、数据库、浏览器、异构语言),就必须先打成包裹(序列化),到对岸再拆包裹(反序列化)。

二、核心模块架构

从高层次视图看Fastjson框架的结构,主要可以分为用户接口层、配置管理层、序列化引擎、反序列化引擎和安全防护层。其中用户接口提供了门面类用户编码直接与门面类交互,降低使用复杂度;配置管理层允许用户对框架行为进行配置;序列化引擎是序列化操作的核心实现;反序列引擎是反序列化操作的核心实现;安全模块解决框架安全问题,允许用户针对安全问题设置黑白名单等安全检查功能。下图为Fastjson模块关系图:

模块关系图

三、项目结构

com.alibaba.fastjson/
├── JSON.java                    # 核心入口类
├── annotation/                  # 注解定义
├── asm/                         # ASM字节码精简库
├── parser/                      # 解析器模块
│   ├── DefaultJSONParser.java  # 默认JSON解析器
│   ├── JSONLexer.java          # 词法分析器接口
│   ├── JSONScanner.java        # 词法分析器实现
│   └── deserializer/           # 反序列化器
├── serializer/                  # 序列化器模块
│   ├── JSONSerializer.java     # JSON序列化器
│   ├── SerializeConfig.java    # 序列化配置
│   └── ObjectSerializer.java   # 对象序列化器接口
├── spi/                         # SPI扩展机制
├── support/                     # 框架支持
└── util/                        # 工具类

3.1 项目结构说明

主要可以划分为以下几个核心模块(包):

com.alibaba.fastjson (核心 API 与数据结构)

  • 关键类 :
    • JSON.java: 整个库的门面(Facade),提供了最常用、最便捷的静态方法,如 toJSONString() (序列化), parseObject() (反序列化为对象), parseArray() (反序列化为数组)。通常它是用户最先接触到的类。
    • JSONObject.java: 继承自java.util.HashMap,用于表示 JSON 对象结构( {key: value} )。
    • JSONArray.java: 继承自java.util.ArrayList,用于表示 JSON 数组结构 ( [value1, value2] )。

com.alibaba.fastjson.serializer (序列化模块)

此模块负责将 Java 对象转换为 JSON 格式的字符串

  • 关键类 :
    • JSONSerializer.java: 序列化的核心调度器。它维护了序列化的上下文信息,如对象引用、循环依赖检测、特性( SerializerFeature )开关等,并驱动整个序列化过程。
    • SerializeWriter.java: 一个高度优化的 Writer 实现,专门用于生成 JSON 字符串。它内部使用 char[] 数组来拼接字符串,避免了 String 的不可变性带来的性能损耗,是 Fastjson 高性能写入的关键
    • JavaBeanSerializer.java: 默认的 JavaBean 序列化器。在未启用 ASM 优化时,它通过反射获取对象的属性( getter 方法)并将其序列化。
    • ASMSerializerFactory.java: 性能优化的核心 。它使用 ASM 字节码技术在运行时动态生成序列化器类,这些类直接调用 getter 方法并操作SerializeWriter,避免了反射的性能开销。
    • ObjectSerializer.java: 序列化器接口。用户可以通过实现此接口来为特定类型提供自定义的序列化逻辑。
    • SerializeConfig.java: 序列化配置类。它维护了 Java 类型到 ObjectSerializer 的缓存。 SerializeConfig.getGlobalInstance() 提供了全局唯一的配置实例。
    • SerializerFeature.java: 序列化特性枚举。定义了各种序列化行为的开关,例如 WriteMapNullValue (输出 null 值的字段)、 DisableCircularReferenceDetect (禁用循环引用检测) 等。

com.alibaba.fastjson.parser (反序列化模块)

此模块负责将 JSON 格式的字符串解析为 Java 对象。

  • 关键类 :
    • DefaultJSONParser.java: 反序列化的核心调度器。它负责解析 JSON 字符串的整个过程,管理 JSONLexer进行词法分析,并根据 Token (如 { , } , [ , ] , string , number 等)构建 Java 对象。
    • JSONLexer.java / JSONLexerBase.java: JSON 词法分析器。它负责扫描输入的 JSON 字符串,将其切割成一个个有意义的 Token ,供 DefaultJSONParser 使用。
    • JavaBeanDeserializer.java: 默认的 JavaBean 反序列化器。在未启用 ASM 优化时,它通过反射创建对象实例并设置其属性值。
    • ASMDeserializerFactory.java: 与序列化类似,它动态生成反序列化器字节码,直接调用 setter 方法或直接对字段赋值,避免了反射。
    • ObjectDeserializer.java: 反序列化器接口。用户可以实现此接口来自定义特定类型的反序列化逻辑。
    • ParserConfig.java: 反序列化配置类。维护了 Java 类型到 ObjectDeserializer 缓存,并负责管理 ASM 生成的类的加载。
    • Feature.java: 反序列化特性枚举,用于控制解析行为。

com.alibaba.fastjson.annotation (注解模块)

提供了一系列注解,允许用户通过声明式的方式精细地控制序列化和反序列化的行为。

  • 关键注解 :
    • @JSONField: 最核心的注解,可用于字段或方法上,用于自定义字段名、格式化、序列化/反序列化顺序、是否包含等。
    • @JSONType: 可用于类上,用于配置该类的序列化器、反序列化器、特性开关等。

3.2 项目结构小结

Fastjson 框架在架构设计体现了“关注点分离”的原则,将序列化、反序列化、API、工具类等清晰地划分到不同的模块中。整个框架具有高度的可扩展性,用户可以通过 ObjectSerializer / ObjectDeserializer接口和丰富的注解来满足各种复杂的定制化需求。

四、核心源码分析

为了更直观说明框架实现原理,本文对部分展示的源代码进行了删减,有些使用了伪代码,如需了解更多实现细节请读者阅读项目源码(github.com/alibaba/fas…)

整体上Fastjson通过统一的门面API(JSON.toJSONString/parseObject)调用核心控制器(JSONSerializer/DefaultJSONParser),利用ASM字节码生成反射机制,配合SerializeWriter/JSONLexer进行高效的Java对象与JSON字符串间双向转换,同时提供配置缓存、循环引用检测AutoType安全防护等优化机制。下图为框架处理数据流:

数据流

4.1 序列化原理介绍

序列化步骤主要包括:序列化器查找→JavaBean字段解析→字段值转换和JSON字符串构建等过程。下图为序列化处理时序图:

序列化时序图

序列化入口与初始化

使用JSON.toJSONString()入口,将person对象转换为JSON字符串。

Person person = new Person();
String json = JSON.toJSONString(person);

用户调用toJSONString方法进行对象序列化操作,JSON.java包含了多个toJSONString重载方法,共同完成核心类初始化:SerializeConfig,SerializeWriter,JSONSerializer。

//用户不指定SerializeConfig,默认私有全局配置
public static String toJSONString(Object object, SerializeFilter[] filters, 
                                  SerializerFeature... features) {
   return toJSONString(objectSerializeConfig.globalInstance, filters, nullDEFAULT_GENERATE_FEATURE, features);
}


public static String toJSONString(Object object, 
                                      SerializeConfig config, 
                                      SerializeFilter[] filters, 
                                      String dateFormat, 
                                      int defaultFeatures, 
                                      SerializerFeature... features) {
    SerializeWriter out = new SerializeWriter((Writernull, defaultFeatures, features);
    try {
        JSONSerializer serializer = new JSONSerializer(out);
        //省略其他代码...
        serializer.write(object);  // 核心序列化调用
        return out.toString();
    } finally {
        out.close();
    }
}

序列化控制流程

JSONSerializer.write()核心逻辑

write方法的逻辑比较简单,首先处理null值,然后根据类型查找序列器(ObjectSerializer),最后将序列化逻辑委派给序列化器处理。

public final void write(Object object) {
    //如何序列化对象为null,直接写入"null"字符串
    if (object == null) {
        out.writeNull();
        return;
    }


    Class<?> clazz = object.getClass();
    ObjectSerializer writer = getObjectWriter(clazz);  // 类型识别与序列化器选择


    try {
        writer.write(thisobjectnullnull0);  // 委托给具体序列化器
    } catch (IOException e) {
        throw new JSONException(e.getMessage(), e);
    }
}

类型识别与序列化器策略

框架采用策略化模式将不同类型序列化逻辑封装成不同的序列化器:

  • 基础类型 : 使用专门的Codec(如StringCodec、IntegerCodec)
  • 集合类型 : 使用ListSerializer、MapSerializer等
  • JavaBean : 使用JavaBeanSerializer或ASM动态生成的序列化器
  • 枚举类型 : 使用EnumSerializer

SerializeConfig.getObjectWriter方法负责序列化器查找工作:



public ObjectSerializer getObjectWriter(Class<?> clazz, boolean create) {
    // 第一步:缓存查找
    ObjectSerializer writer = get(clazz);
    if (writer != null) {
        return writer;
    }


    // 第二步:SPI扩展加载(当前线程类加载器)
    try {
        final ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        for (Object o : ServiceLoader.load(AutowiredObjectSerializer.class, classLoader)) {
            if (!(o instanceof AutowiredObjectSerializer)) {
                continue;
            }
            AutowiredObjectSerializer autowired = (AutowiredObjectSerializer) o;
            for (Type forType : autowired.getAutowiredFor()) {
                put(forType, autowired);
            }
        }
    } catch (ClassCastException ex) {
        // skip
    }


    writer = get(clazz);
    if (writer == null) {
        // 第三步:SPI扩展加载(JSON类加载器)
        final ClassLoader classLoader = JSON.class.getClassLoader();
        if (classLoader != Thread.currentThread().getContextClassLoader()) {
            // 重复SPI加载逻辑...
        }
    }


    // 第四步:模块扩展
    for (Module module : modules) {
        writer = module.createSerializer(this, clazz);
        if (writer != null) {
            put(clazz, writer);
            return writer;
        }
    }


    // 第五步:内置类型匹配
    if (writer == null) {
        String className = clazz.getName();
        Class<?> superClass;


        if (Map.class.isAssignableFrom(clazz)) {
            put(clazz, writer = MapSerializer.instance);
        } else if (List.class.isAssignableFrom(clazz)) {
            put(clazz, writer = ListSerializer.instance);
        } else if (Collection.class.isAssignableFrom(clazz)) {
            put(clazz, writer = CollectionCodec.instance);
        } else if (Date.class.isAssignableFrom(clazz)) {
            put(clazz, writer = DateCodec.instance);
        } else if (clazz.isEnum()) {
            // 枚举处理逻辑
        } else if (clazz.isArray()) {
            // 数组处理逻辑
        } else {
            // 第六步:JavaBean序列化器创建
            if (create) {
                writer = createJavaBeanSerializer(clazz);
                put(clazz, writer);
            }
        }
    }


    return writer;
}

JavaBean序列化处理

JavaBeanSerializer的write方法实现了Java对象序列化处理核心逻辑:

方法签名分析:

protected void write(JSONSerializer serializer, //JSON序列化器,提供序列化上下文和输出流
                      Object object//待序列化的Java对象
                      Object fieldName, //字段名称,用于上下文追踪
                      Type fieldType, //字段类型信息
                      int features, //序列化特性标志位
                      boolean unwrapped //是否展开包装,用于嵌套对象处理
    ) throws IOException

序列化流程概览:

// 1. 空值检查和循环引用处理
if (object == null) {
    out.writeNull();
    return;
}


if (writeReference(serializer, object, features)) {
    return;
}


// 2. 字段序列化器选择
final FieldSerializer[] getters;
if (out.sortField) {
    getters = this.sortedGetters;
} else {
    getters = this.getters;
}


// 3. 上下文设置和格式判断
SerialContext parent = serializer.context;
if (!this.beanInfo.beanType.isEnum()) {
    serializer.setContext(parent, object, fieldName, this.beanInfo.features, features);
}


// 4.遍历属性序列化器,完成属性序列化
for (int i = 0; i < getters.length; ++i) {
    FieldSerializer fieldSerializer = getters[i];
    // 获取属性值
    Object propertyValue = this.processValue(serializer, fieldSerializer.fieldContext, object, fieldInfoName,
                                        propertyValue, features);
    // 写入属性值                                    
    fieldSerializer.writeValue(serializer, propertyValue);
}

循环引用检测:

JavaBeanSerializerwriteReference 方法执行循环引用检测,Fastjson使用$ref占位符处理循环引用问题,防止对象循环引用造成解析查询栈溢出。

public boolean writeReference(JSONSerializer serializer, Object object, int fieldFeatures) {
    SerialContext context = serializer.context;
    int mask = SerializerFeature.DisableCircularReferenceDetect.mask;


    // 检查是否禁用循环引用检测
    if (context == null || (context.features & mask) != 0 || (fieldFeatures & mask) != 0) {
        return false;
    }


    // 检查对象是否已存在于引用表中
    if (serializer.references != null && serializer.references.containsKey(object)) {
        serializer.writeReference(object);  // 写入引用标记
        return true;
    }
    return false;
}

上下文管理与引用追踪:

序列化采用DFS(深度优先)算法遍历对象树,使用 IdentityHashMap<Object, SerialContext> references 来追踪对象引用:

  • setContext: 建立序列化上下文,记录对象层次关系
  • containsReference: 检查对象是否已被序列化
  • popContext: 序列化完成后清理上下文
protected IdentityHashMap<ObjectSerialContext> references  = null;
protected SerialContext                          context;
//使用链表建立序列化上下文引用链,记录对象层次关系
public void setContext(SerialContext parent, Object objectObject fieldName, int features, int fieldFeatures) {
    if (out.disableCircularReferenceDetect) {
        return;
    }
    //构建当前上下文到parent上下文引用链
    this.context = new SerialContext(parent, object, fieldName, features, fieldFeatures);
    if (references == null) {
        references = new IdentityHashMap<ObjectSerialContext>();
    }
    this.references.put(object, context);
}
//检查对象是否已被序列化,防止重复序列化
public boolean containsReference(Object value) {
    if (references == null) {
        return false;
    }
    SerialContext refContext = references.get(value);
    if (refContext == null) {
        return false;
    }
    if (value == Collections.emptyMap()) {
        return false;
    }
    Object fieldName = refContext.fieldName;
    return fieldName == null || fieldName instanceof Integer || fieldName instanceof String;
}
//清理上下文,将当前序列化上下文指向父亲节点
public void popContext() {
    if (context != null) {
        this.context = this.context.parent;
    }
}

字段值转换与序列化

FieldSerializer.writeValue()核心逻辑

FieldSerializer 的writeValue方法实现了字段值的序列化操作:

public void writeValue(JSONSerializer serializer, Object propertyValue) throws Exception {
    // 运行时类型识别
    Class<?> runtimeFieldClass = propertyValue != null ? 
        propertyValue.getClass() : this.fieldInfo.fieldClass;


    // 查找属性类型对应的序列化器
    ObjectSerializer fieldSerializer = serializer.getObjectWriter(runtimeFieldClass);


    // 处理特殊格式和注解
    if (format != null && !(fieldSerializer instanceof DoubleSerializer)) {
        serializer.writeWithFormat(propertyValue, format);
        return;
    }


    // 委托给具体序列化器处理
    fieldSerializer.write(serializer, propertyValue, fieldInfo.name, 
                         fieldInfo.fieldType, fieldFeatures);
}

不同类型的序列化策略

基础类型序列化 :

  • 直接调用SerializeWriter的对应方法(writeInt、writeString等)

复杂对象序列化 :

  • 递归调用JSONSerializer.write()方法
  • 维护序列化上下文和引用关系
  • 应用过滤器和特性配置

ASM定制化序列化器加速,下文会进行详细讲解。

  • 为序列化的类动态生成定制化的序列化器,避免反射调用开销

JSON字符串构建

SerializeWriter.java采用线程本地缓冲机制,提供高效的字符串构建:

//用于存储存JSON字符串
private final static ThreadLocal<char[]> bufLocal         = new ThreadLocal<char[]>();
//将字符串转换为UTF-8字节数组
private final static ThreadLocal<byte[]> bytesBufLocal    = new ThreadLocal<byte[]>();
  • 字符缓冲区 : 线程本地char[]数组减少内存分配,避免频繁创建临时数组对象。
  • 动态扩容 : 根据内容长度自动调整缓冲区大小。

bufLocal初始化创建2048字符的缓冲区,回收阶段当缓冲区大小不超过 BUFFER_THRESHOLD (128KB)时,将其放回ThreadLocal缓存,超过阈值的大缓冲区不缓存,避免内存占用过大。

bytesBufLocal专门用于UTF-8编码转换过程,初始缓冲区大小:8KB(1024 * 8),根据字符数量估算所需字节数(字符数 × 3),只有不超过 BUFFER_THRESHOLD 的缓冲区才会被缓存。

4.2 序列化小结

Fastjson通过JSON.toJSONString()门面API调用JSONSerializer控制器,利用ASM字节码生成的高性能序列化器或反射机制遍历Java对象字段,配合SerializeWriter将字段名和值逐步写入缓冲区构建JSON字符串。

4.3 反序列化流程

虽然“序列化”与“反序列化”在概念上是对偶的(Serialize ↔ Deserialize),但在实现层面并不严格对偶,反序列化实现明显比序列化复杂。核心步骤包括:反序列化器查找→ 反序列流程控制→词法分析器(Tokenizer) → 安全检查→反射/ASM 字段填充等,下图为处理时序图:

反序列化入口与反序列化器选择

反序列化从 JSON.java的parseObject方法开始:

// JSON.java - 反序列化入口
public static <T> parseObject(String text, Class<T> clazz, int features) {
    if (text == null) {
        return null;
    }
    DefaultJSONParser parser = new DefaultJSONParser(text, ParserConfig.getGlobalInstance(), features);
    T value = (T) parser.parseObject(clazz);
    parser.handleResovleTask(value);
    parser.close();
    return value;
}

查找反序列化器

在 DefaultJSONParser.java 中选择合适的反序列化器:

// DefaultJSONParser.java - 反序列化器选择
public <T> T parseObject(Type typeObject fieldName) {
    int token = lexer.token();
    if (token == JSONToken.NULL) {
        lexer.nextToken();
        return (T) TypeUtils.optionalEmpty(type);
    }
    //从缓存中查找反序列化器
    ObjectDeserializer deserializer = config.getDeserializer(type);


    try {
        if (deserializer.getClass() == JavaBeanDeserializer.class) {
            return (T) ((JavaBeanDeserializer) deserializer).deserialze(thistype, fieldName, 0);
        } else {
            return (T) deserializer.deserialze(thistype, fieldName);
        }
    } catch (JSONException e) {
        throw e;
    } catch (Throwable e) {
        throw new JSONException(e.getMessage(), e);
    }
}

ParserConfig.java 负责获取对应类型的反序列化器:

// ParserConfig.java - 反序列化器获取
public ObjectDeserializer getDeserializer(Type type) {
    ObjectDeserializer deserializer = this.deserializers.get(type);
    if (deserializer != null) {
        return deserializer;
    }
    //通过Class查找
    if (type instanceof Class<?>) {
        return getDeserializer((Class<?>) typetype);
    }
    //通过泛型参数查找
    if (type instanceof ParameterizedType) {
        Type rawType = ((ParameterizedTypetype).getRawType();
        if (rawType instanceof Class<?>) {
            return getDeserializer((Class<?>) rawType, type);
        } else {
            return getDeserializer(rawType);
        }
    }


    return JavaObjectDeserializer.instance;
}

反序列化控制流程

JavaBeanDeserializer.java 的deserialze实现了反序列化主要处理流程。

// JavaBeanDeserializer.java - 类型识别与字段匹配
public <T> T deserialze(DefaultJSONParser parser, Type type, Object fieldName, int features, int[] setFlags) {
    // 1.特殊类型快速处理
    if (type == JSON.class || type == JSONObject.class) {
        return (T) parser.parse();
    }
    //2.初始化核心组件
    final JSONLexer lexer = parser.lexer;
    //3.反序列化上下文管理
    ParseContext context = parser.getContext();
    if (object != null && context != null) {
       context = context.parent;
    }
    ParseContext childContext = null;
    //保存解析后字段值
    Map<String, Object> fieldValues = null;
    // JSON关键字分支预处理
    if (token == JSONToken.RBRACE) {
        lexer.nextToken(JSONToken.COMMA);
        if (object == null) {
          object = createInstance(parser, type);
        }
        return (T) object;
    }
    //处理其他JSON关键字
    ...


    //4.字段解析主循环
    for (int fieldIndex0, notMatchCount = 0;; fieldIndex++) {
        boolean customDeserializerfalse;
        //这是一个性能优化的设计,通过预排序和索引访问来提高字段匹配的效率,
        //通常情况下JSON串按字段定义顺序排列,因此能快速命中
        if (fieldIndex < sortedFieldDeserializers.length && notMatchCount < 16) {
            fieldDeserializer = sortedFieldDeserializers[fieldIndex];
            fieldInfo = fieldDeserializer.fieldInfo;
            fieldClass = fieldInfo.fieldClass;
            fieldAnnotation = fieldInfo.getAnnotation();
            if (fieldAnnotation != null && fieldDeserializer instanceof DefaultFieldDeserializer) {
              customDeserializer = ((DefaultFieldDeserializer) fieldDeserializer).customDeserilizer;
            }
         }
         Object fieldValue = null;


         if (fieldDeserializer != null) {
            char[] name_chars = fieldInfo.name_chars;
            //指定了自定义发序列化器,后续使用自定义序列化器处理
            if (customDeserializer && lexer.matchField(name_chars)) {
                        matchFieldtrue;
             // 基本类型快速路径匹配
             } else if (fieldClass == int.class || fieldClass == Integer.class) {
                //词法分析,解析int值
                int intVal = lexer.scanFieldInt(name_chars);
                if (intVal == 0 && lexer.matchStat == JSONLexer.VALUE_NULL) {
                    fieldValue = null;
                } else {
                    fieldValue = intVal;
                }
                if (lexer.matchStat > 0) {
                    matchFieldtrue;
                    valueParsedtrue;
                } else if (lexer.matchStat == JSONLexer.NOT_MATCH_NAME) {
                    //增加计算,记录未命中次数以调整匹配策略
                    notMatchCount++;
                    continue;
                }


           } else if(...){
           //省略其他基础类型处理  
           }
         }
         // 快速匹配失败,动态扫描字段名,通过符号表优化:返回的字符串可能是符号表中的缓存实例
         if (!matchField) {
            key = lexer.scanSymbol(parser.symbolTable);
            // $ref 引用处理
            if ("$ref" == key && context != null) {
                handleReferenceResolution(lexer, parser, context)
            }
            // @type 类型处理
            if ((typeKey != null && typeKey.equals(key))
                            || JSON.DEFAULT_TYPE_KEY == key) {
              //AutoType安全检查
              config.checkAutoType(typeName, expectClass, lexer.getFeatures());
              handleTypeNameResolution(lexer, parser, config, beanInfo, type, fieldName);
            }


         }
    }


    // 5.如果对象为空,则创建对象实例
    if (object == null && fieldInfo == null) {
        object = createInstance(parser, type);
        if (object == null) {
            return null;
        }
    }


    //6. 字段值设置
    for (Map.Entry<String, Object> entry : fieldValues.entrySet()) {
        FieldDeserializer fieldDeserializer = getFieldDeserializer(entry.getKey());
        if (fieldDeserializer != null) {
            fieldDeserializer.setValue(object, entry.getValue());
        }
     }


    return (T) object;
}

字符串解析阶段(词法分析)

JSONLexerBase内部维护词法解析状态机,实现词法分析核心逻辑,下面展示了Integer值类型处理源码:

    public int scanFieldInt(char[] fieldName) {
        matchStat = UNKNOWN;
        // 1. 字段名匹配阶段
        if (!charArrayCompare(fieldName)) {
            matchStat = NOT_MATCH_NAME;
            return 0;
        }
        
        int offset = fieldName.length;
        char chLocal = charAt(bp + (offset++));
        // 2. 负号处理
        final boolean negative = chLocal == '-';
        if (negative) {
            chLocal = charAt(bp + (offset++));
        }
        // 3. 数字解析核心算法
        int value;
        if (chLocal >= '0' && chLocal <= '9') {
            value = chLocal - '0';
            for (;;) {
                chLocal = charAt(bp + (offset++));
                if (chLocal >= '0' && chLocal <= '9') {
                    value = value * 10 + (chLocal - '0');// 十进制累加
                } else if (chLocal == '.') {
                    matchStat = NOT_MATCH; // 拒绝浮点数
                    return 0;
                } else {
                    break;
                }
            }
             // 4. 溢出检测
            if (value < 0 //
                    || offset > 11 + 3 + fieldName.length) {
                if (value != Integer.MIN_VALUE //
                        || offset != 17 //
                        || !negative) {
                    matchStat = NOT_MATCH;
                    return 0;
                }
            }
        } else {
            matchStat = NOT_MATCH;
            return 0;
        }
         // 5. JSON 结束符处理
        if (chLocal == ',') {
            bp += offset;
            this.ch = this.charAt(bp);
            matchStat = VALUE;
            token = JSONToken.COMMA;
            return negative ? -value : value;
        }
        
        if (chLocal == '}') {
             // ... 处理对象结束和嵌套结构
            chLocal = charAt(bp + (offset++));
            if (chLocal == ',') {
                token = JSONToken.COMMA;
                bp += offset;
                this.ch = this.charAt(bp);
            } else if (chLocal == ']') {
                token = JSONToken.RBRACKET;
                bp += offset;
                this.ch = this.charAt(bp);
            } else if (chLocal == '}') {
                token = JSONToken.RBRACE;
                bp += offset;
                this.ch = this.charAt(bp);
            } else if (chLocal == EOI) {
                token = JSONToken.EOF;
                bp += (offset - 1);
                ch = EOI;
            } else {
                matchStat = NOT_MATCH;
                return 0;
            }
            matchStat = END;
        } else {
            matchStat = NOT_MATCH;
            return 0;
        }
        
        return negative ? -value : value;
    }

类型安全检查(AutoType检查)

ParserConfig.java 中的checkAutoType方法对反序列化类型做黑白名单检查。

// ParserConfig.java - AutoType安全检查
public Class<?> checkAutoType(String typeName, Class<?> expectClass, int features) {
    if (typeName == null) {
        return null;
    }
    
    if (typeName.length() >= 192 || typeName.length() < 3) {
        throw new JSONException("autoType is not support. " + typeName);
    }
    
    String className = typeName.replace('$''.');
    Class<?> clazz = null;
    
    final long BASIC = 0xcbf29ce484222325L;
    final long PRIME = 0x100000001b3L;
    
    final long h1 = (BASIC ^ className.charAt(0)) * PRIME;
    // hash code编码匹配性能优化
    if (h1 == 0xaf64164c86024f1aL) { 
        throw new JSONException("autoType is not support. " + typeName);
    }
    if ((h1 ^ className.charAt(className.length() - 1)) * PRIME == 0x9198507b5af98f0L) {
        throw new JSONException("autoType is not support. " + typeName);
    }
    
    final long h3 = (((((BASIC ^ className.charAt(0)) 
                        * PRIME) 
                        ^ className.charAt(1)) 
                        * PRIME) 
                        ^ className.charAt(2)) 
                        * PRIME;
    
    if (autoTypeSupport || expectClass != null) {
        long hash = h3;
        for (int i = 3; i < className.length(); ++i) {
            hash ^= className.charAt(i);
            hash *= PRIME;
            if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
                throw new JSONException("autoType is not support. " + typeName);
            }
            if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
                clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
    }


    // ... 更多安全检查逻辑
    return clazz;
}

对象实例化过程

JavaBeanDeserializer.java中的createInstance方法创建对象实例:

// JavaBeanDeserializer.java - 对象实例化
protected Object createInstance(DefaultJSONParser parser, Type type) {
    if (type instanceof Class) {
        if (clazz.isInterface()) {
        // 接口类型使用Java反射创建实例
            Class<?> clazz = (Class<?>) type;
            ClassLoader loader = Thread.currentThread().getContextClassLoader();
            final JSONObject obj = new JSONObject();
            Object proxy = Proxy.newProxyInstance(loader, new Class<?>[] { clazz }, obj);
            return proxy;
        }
    }
    
    if (beanInfo.defaultConstructor == null && beanInfo.factoryMethod == null) {
        return null;
    }
    
    Object object;
    try {
    //通过构造器创建实例
        Constructor<?> constructor = beanInfo.defaultConstructor;
        if (beanInfo.defaultConstructorParameterSize == 0) {
            object = constructor.newInstance();
        } else {
            ParseContext context = parser.getContext();
            if (context == null || context.object == null) {
                throw new JSONException("can't create non-static inner class instance.");
            }


            final Class<?> enclosingClass = constructor.getDeclaringClass().getEnclosingClass();
            object = constructor.newInstance(context.object);
        }
    } catch (JSONException e) {
        throw e;
    } catch (Exception e) {
        throw new JSONException("create instance error, class " + clazz.getName(), e);
    }


    return object;
}

FieldDeserializer.java中的setValue方法通过反射实现字段设置:

// FieldDeserializer.java - 属性赋值的核心实现
public void setValue(Object objectObject value) {
    if (value == null && fieldInfo.fieldClass.isPrimitive()) {
        return;
    } else if (fieldInfo.fieldClass == String.class
            && fieldInfo.format != null
            && fieldInfo.format.equals("trim")) {
        value = ((String) value).trim();
    }
    
    try {
        Method method = fieldInfo.method;
        if (method != null) {
            if (fieldInfo.getOnly) {
                // 处理只读属性的特殊情况
                if (fieldInfo.fieldClass == AtomicInteger.class) {
                    AtomicInteger atomic = (AtomicInteger) method.invoke(object);
                    if (atomic != null) {
                        atomic.set(((AtomicInteger) value).get());
                    }
                } else if (Map.class.isAssignableFrom(method.getReturnType())) {
                    Map map = (Map) method.invoke(object);
                    if (map != null) {
                        map.putAll((Map) value);
                    }
                } else {
                    Collection collection = (Collection) method.invoke(object);
                    if (collection != null && value != null) {
                        collection.clear();
                        collection.addAll((Collection) value);
                    }
                }
            } else {
                // 通过setter方法赋值
                method.invoke(object, value);
            }
        } else {
            // 通过字段直接赋值
            final Field field = fieldInfo.field;
            if (field != null) {
                field.set(object, value);
            }
        }
    } catch (Exception e) {
        throw new JSONException("set property error, " + clazz.getName() + "#" + fieldInfo.name, e);
    }
}

4.4 反序列化小结

Fastjson通过JSON.parseObject()门面API调用DefaultJSONParser控制器,利用JSONLexer进行词法分析解析JSON字符串,经过AutoType安全检查后使用ASM字节码生成动态反序列化器或反射机制创建Java对象实例并逐字段赋值。

五、特性讲解

5.1 ASM性能优化

ASM 是 fastjson 类似于 JIT,在运行时把「反射调用」翻译成「直接字段访问 + 方法调用」的字节码,从而把序列化/反序列化性能提升 20% 以上,当然随着JVM对反射性能的优化性能差正在逐渐被缩小。下图是作者使用工具类读取的动态序列化/反序列化器源码片段。

5.2  AutoType机制

AutoType是 fastjson 的“动态多态还原”方案:

序列化时把具体子类名字写进 "@type",反序列化时先加载类 → 再调 setter → 完成还原。

 速度上“指针引用”即可定位序列化器,功能上靠 @type 字段把被擦除的泛型/接口/父类重新映射回具体实现。

在未开启AutoType机制情况下,在将store对象序列化成JSON串后,再反序列化为对象时由于字段的类型为接口无法转换成具体的Dog类型示例;开启AutoType机制后,序列化时将类型一并写入到JSON串内,后续进行反序列化时可以根据这个类型还原成具体的类型实例。

interface Animal {}


class Dog implements Animal {
    private String name;
    private double weight;


    //省略getter,setter
}


class PetStore {
    private Animal animal;
}




public static void main(String[] args) {
    Animal dog = new Dog("dodi"12);
    PetStore store = new PetStore(dog);
    String jsonString = JSON.toJSONString(store);
    PetStore petStore = JSON.parseObject(jsonString, PetStore.class);
    Dog parsedDog = (Dog) petStore.getAnimal();
}

public static void main(String[] args) {
    Animal dog = new Dog("dodi"12);
    PetStore store = new PetStore(dog);
    String jsonString = JSON.toJSONString(store, SerializerFeature.WriteClassName);
    PetStore petStore = JSON.parseObject(jsonString, PetStore.class);
    Dog parsedDog = (Dog) petStore.getAnimal();
}

AutoType 让 fastjson 在反序列化时根据 @type 字段动态加载任意类,这一“便利”却成为攻击者远程代码执行的快捷通道:通过把JdbcRowSetImpl等 JNDI 敏感类写进 JSON,服务端在调用 setter 的瞬间就会向外部 LDAP/RMI 服务器拉取恶意字节码,完成 RCE;而官方长期依赖“黑名单”堵漏,导致 1.2.25→1.2.80 出现 L 描述符、Throwable 二次反序列化、内部类等连续绕过,形成“补丁-绕过-再补丁”的猫鼠游戏, 虽然在1.2.68 引入 safeMode 但为了兼容性需要使用者手动开启 ,而且实现也不够健壮,开启safeMode仍有利用代码漏洞绕过检查风险,后续版本对safeMode加固并对已知安全漏洞清零,直到最新1.2.83版本安全问题也不能说彻底解决。

5.3 流式解析

Fastjson 提供一套 Streaming API,核心类JSONReader /JSONWriter,行业内惯称「流式解析」或「增量解析」,主要用于处理JSON大文件解析。技术上流式解析采用“拉模式(pull parsing)”,底层维护 8 KB 滑动缓冲,词法分析器(Tokenizer)把字节流切成 token 流,语法状态机根据 token 类型驱动反序列化器(ObjectReader)即时产出 Java 对象,对象一旦交付给用户代码处理后,内部引用立即释放。这种方式内存中不会保存所有对象,对象处理完即被丢弃,因此可以处理数据量远大于内存的数据,而不会出现OOM。下面是使用流式解析的示例代码:

// 依赖:com.alibaba:fastjson:1.2.83
try (JSONReader reader = new JSONReader(
        new InputStreamReader(
                new FileInputStream("huge-array.json"), StandardCharsets.UTF_8))) {
    reader.startArray();                 // 告诉解析器:根节点是 []
    while (reader.hasNext()) {           // 拉取下一条
        Order order = reader.readObject(Order.class); // 瞬时对象
        processOrder(order);//业务处理
        orderRepository.save(order);     // 立即落盘,内存即可回收
    }
    reader.endArray();
}

六、总结

Fastjson核心特性在于高速序列化/反序列化,利用ASM在运行时生成字节码动态创建解析器,减少反射;AutoType字段支持多态,却带来反序列化RCE风险,建议关闭AutoType,开启safeMode。选型建议:在选择JSON序列化框架时对于非极端性能要求推荐Jackson,或者使用Fastjson2,其改用LambdaMetafactory替换ASM,性能再提升30%,默认关闭AutoType安全性有保证。

参考资料:

往期回顾

1. 用好 TTL Agent 不踩雷:避开内存泄露与CPU 100%两大核心坑|得物技术

2. 线程池ThreadPoolExecutor源码深度解析|得物技术

3. 基于浏览器扩展 API Mock 工具开发探索|得物技术

4. 破解gh-ost变更导致MySQL表膨胀之谜|得物技术

5. MySQL单表为何别超2000万行?揭秘B+树与16KB页的生死博弈|得物技术

文 /剑九

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

第一个成功在APP store 上架的APP

XunDoc开发之旅:当AI医生遇上家庭健康管家

当我在生活中目睹家人为管理复杂的健康数据、用药提醒而手忙脚乱时,一个想法冒了出来:我能否打造一个App,像一位贴心的家庭健康管家,把全家人的健康都管起来?它不仅要能记录数据,还要够聪明,能解答健康疑惑,能主动提醒。这就是 XunDoc App。

1. 搭建家庭的健康数据中枢

起初,我转向AI助手寻求架构指导。我的构想很明确:一个以家庭为单位,能管理成员信息、记录多种健康指标(血压、血糖等)的系统。AI很快给出了基于SwiftUI和MVVM模式的代码框架,并建议用UserDefaults来存储数据。

但对于一个完整的应用而言,我马上遇到了第一个问题:数据如何在不同视图间高效、准确地共享? 一开始我简单地使用@State,但随着功能增多,数据流变得一团糟,经常出现视图数据不同步的情况。

接着在Claude解决不了的时候我去询问Deepseek,它一针见血地指出:“你的数据管理太分散了,应该使用EnvironmentObject配合单例模式,建立一个统一的数据源。” 这个建议成了项目的转折点。我创建了FamilyShareManagerHealthDataManager这两个核心管家。当我把家庭成员的增删改查、健康数据的录入与读取都交给它们统一调度后,整个应用的数据就像被接通了任督二脉,立刻流畅稳定了起来。

2. 请来AI医生:集成Moonshot API

基础框架搭好,接下来就是实现核心的“智能”部分了。我想让用户能通过文字和图片,向AI咨询健康问题。我再次找到AI助手,描述了皮肤分析、报告解读等四种咨询场景,它很快帮我写出了调用Moonshot多模态API的代码。

然而,每件事都不能事事如意的。文字咨询很顺利,但一到图片上传就频繁失败。AI给出的代码在处理稍大一点的图片时就会崩溃,日志里满是编码错误。我一度怀疑是网络问题,但反复排查后,我询问Deepseek,他告诉我:“多模态API对图片的Base64编码和大小有严格限制,你需要在前端进行压缩和校验。”

我把他给我的建议给到了Claude。claude帮我编写了一个“图片预处理”函数,自动将图片压缩到4MB以内并确保编码格式正确。当这个“关卡”被设立后,之前桀骜不驯的图片上传功能终于变得温顺听话。看着App里拍张照就能得到专业的皮肤分析建议,那种将前沿AI技术握在手中的感觉,实在令人兴奋。

3. 打造永不遗忘的智能提醒系统

健康管理,贵在坚持,难在记忆。我决心打造一个强大的医疗提醒模块。我的想法是:它不能是普通的闹钟,而要像一位专业的护士,能区分用药、复查、预约等不同类型,并能灵活设置重复。

AI助手根据我的描述,生成了利用UserNotifications框架的初始代码。但很快,我发现了一个新问题:对于“每周一次”的重复提醒,当用户点击“完成”后,系统并不会自动创建下一周的通知。这完全违背了“提醒”的初衷。

“这需要你自己实现一个智能调度的逻辑,在用户完成一个提醒时,计算出下一次触发的时间,并重新提交一个本地通知。” 这是deepseek告诉我的,我把这个需求告诉给了Claude。于是,在MedicalNotificationManager中, claude加入了一个“重新调度”的函数。当您标记一个每周的用药提醒为“已完成”时,App会悄无声息地为您安排好下一周的同一时刻的提醒。这个功能的实现,让XunDoc从一个被动的记录工具,真正蜕变为一个主动的健康守护者。

4. 临门一脚:App Store上架“渡劫”指南

当XunDoc终于在模拟器和我的测试机上稳定运行后,我感觉胜利在望。但很快我就意识到,从“本地能跑”到“商店能下”,中间隔着一道巨大的鸿沟——苹果的审核。证书、描述文件、权限声明、截图尺寸……这些繁琐的流程让我一头雾水。

这次,我直接找到了DeepSeek:“我的App开发完了,现在需要上传到App Store,请给我一个最详细、针对新手的小白教程。”

DeepSeek给出的回复堪称保姆级,它把整个过程拆解成了“配置App ID和证书”、“在App Store Connect中创建应用”、“在Xcode中进行归档打包”三大步。我就像拿着攻略打游戏,一步步跟着操作:

  • 创建App ID:在苹果开发者后台,我按照说明创建了唯一的App ID com.[我的ID].XunDoc
  • 搞定证书:最让我头疼的证书环节,DeepSeek指导我分别创建了“Development”和“Distribution”证书,并耐心解释了二者的区别。
  • 设置权限:因为App需要用到相机(拍照诊断)、相册(上传图片)和通知(医疗提醒),我根据指南,在Info.plist文件中一一添加了对应的权限描述,确保审核员能清楚知道我们为什么需要这些权限。

一切准备就绪,我在Xcode中点击了“Product” -> “Archive”。看着进度条缓缓填满,我的心也提到了嗓子眼。打包成功!随后通过“Distribute App”流程,我将我这两天的汗水上传到了App Store Connect。当然不是一次就通过上传的。

image.png

5. 从“能用”到“好用”:三次UI大迭代的觉醒

应用上架最初的兴奋感过去后,我陆续收到了一些早期用户的反馈:“功能很多,但不知道从哪里开始用”、“界面有点拥挤,找东西费劲”。这让我意识到,我的产品在工程师思维里是“功能完备”,但在用户眼里可能却是“复杂难用”。

我决定重新设计UI。第一站,我找到了国产的Mastergo。我将XunDoc的核心界面截图喂给它,并提示:“请为这款家庭健康管理应用生成几套更现代、更友好的UI设计方案。”

Mastergo给出的方案让我大开眼界。它弱化了我之前强调的“卡片”边界,采用了更大的留白和更清晰的视觉层级。它建议将底部的标签栏导航做得更精致,并引入了一个全局的“+”浮动按钮,用于快速记录健康数据。这是我第一套迭代方案的灵感来源:从“功能堆砌”转向“简洁现代”

image.png 然而,Mastergo的方案虽然美观,但有些交互逻辑不太符合iOS的规范。于是,第二站,我请来了Stitch。我将完整的产品介绍、所有功能模块的说明,以及第一版的设计图都给了它,并下达指令:“请基于这些材料,完全重现XunDoc的完整UI,但要遵循iOS Human Interface Guidelines,并确保信息架构清晰,新用户能快速上手。”等到他设计好了后 我将我的设计图UI截图给Claude,让他尽可能的帮我生成。

image.png (以上是我的Stitch构建出来的页面) Claude展现出了惊人的理解力。它不仅仅是在画界面,而是在重构产品的信息架构。它建议将“AI咨询”的四种模式(皮肤、症状、报告、用药)从并列排列,改为一个主导航入口,进去后再通过图标和简短说明让用户选择。同时,它将“首页”重新定义为真正的“健康概览”,只显示最关键的数据和今日提醒,其他所有功能都规整地收纳入标签栏。这形成了我的第二套迭代方案从“简洁现代”深化为“结构清晰”

image.png

拿着Claude的输出,我结合Mastergo和Stitch的视觉灵感,再让Cluade一步一步的微调。我意识到,颜色不仅是美观,更是传达情绪和功能的重要工具。我将原本统一的蓝色系,根据功能模块进行了区分:健康数据用沉稳的蓝色,AI咨询用代表智慧的紫色,医疗提醒用醒目的橙色。图标也设计得更加线性轻量,减少了视觉负担。(其实这是Deepseek给我的建议)这就是最终的第三套迭代方案在清晰的结构上,注入温暖与亲和力

image.png 这次从Stitch到Claude的UI重塑之旅,让我深刻意识到,一个成功的产品不仅仅是代码的堆砌。它是一次与用户的对话,而设计,就是这门对话的语言。通过让不同的AI助手在我的引导下“协同创作”,我成功地让XunDoc从一個工程师的作品,蜕变成一个真正为用户着想的产品。

现在这款app已经成功上架到了我的App store上 大家可以直接搜索下来进行使用和体验,我希望大家可以在未来可以一起解决问题!

❌