普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月7日首页

ofd文件

作者 林太白
2026年1月7日 15:24

ofd文件处理预览

文章内容所涉及的源码地址,求朋友们给个star啊

::: tip

个人网站 ([https://nexuslin.github.io/](https://nexuslin.github.io/))

文章内容所涉及的源码地址,求朋友们给个star啊


同路之人,幸得君顾,盼得君之一赞!

与君同行,愿得青眼相加!

你的star

如春风化雨,润物无声;

如山间清泉,滋润心田;

如长河落日,映照初心;

亦如暗夜明灯,照亮前路;

是吾辈前行之明灯,亦是我坚持的动力!

愿君前程似锦,代码如诗,人生如画!

【GIthub地址】([https://github.com/lintaibai/TG](https://gitee.com/lintaibai/TG))

【Gitee地址】([https://gitee.com/lintaibai/TG](https://gitee.com/lintaibai/TG))


:::

OFD文件是什么

本质上就是一种国产的压缩文件

2016年成为国家标准(GB/T 33190-2016// 国家标准网站
https://openstd.samr.gov.cn/bzgk/gb/newGbInfo?hcno=3AF6682D939116B6F5EED53D01A9DB5D

OFD(Open Fixed-layout Document)开放式版式文件

中国自主研发的一种电子文档格式‌

中国国家标准版的电子文件格式,类似于 PDF

具有以下特点:

  • 基于XML和ZIP技术
  • 支持数字签名
  • 支持版式固定
  • 支持国产密码算法
  • 支持长期保存格式

OFD 文件结构

OFD 文件本质上是一个 ZIP 压缩包,包含以下结构

document.ofd
├── OFD.xml          // 文档根文件
├── Doc_0/
│   ├── Doc_0/       // 文档目录
│   │   ├── Document.xml
│   │   └── Pages/
│   │       └── Page_0/
│   │           ├── Page.xml
│   │           └── Res/        // 资源目录
│   │               ├── image/
│   │               └── font/
└── Public.xml       // 公共资源

相关库的认识

想要解决ofd文件的预览,我们必须认识相关的两个依赖库

jszip 
JSZipUtils 

这两个库存的作用和认识

jszip的认识使用

认识

JSZip 是一个用于创建、读取和编辑 .zip 文件的JavaScript库。它可以在浏览器和Node.js环境中使用。

主要功能:
  • 创建新的 ZIP 文件
  • 读取现有的 ZIP 文件
  • 向 ZIP 文件中添加或删除文件
  • 生成 ZIP 文件并下载
使用示例
// 创建一个新的 ZIP 文件
var zip = new JSZip();

// 添加一个文本文件
zip.file("hello.txt", "Hello World\n");

// 添加一个文件夹和其中的文件
zip.folder("images").file("smile.gif", "base64数据", {base64: true});

// 生成 ZIP 文件
zip.generateAsync({type:"blob"})
.then(function(content) {
    // 在浏览器中下载
    saveAs(content, "example.zip");
});
常见应用场景:
  • 在前端打包多个文件供用户下载
  • 动态生成包含多个文件的压缩包
  • 处理上传的 ZIP 文件内容

JSZipUtils 的认识使用

认识

JSZipUtils 是 JSZip 的一个辅助工具库,主要用于处理二进制数据,特别是在获取远程文件时非常有用。

主要功能:
  • 获取二进制数据
  • 处理跨域请求
  • 提供便捷的文件获取方法
基本使用示例:
// 获取远程二进制数据
JSZipUtils.getBinaryContent("path/to/file.zip", function(err, data) {
    if(err) {
        throw err;
    }
    
    // 使用获取到的数据创建 JSZip 对象
    JSZip.loadAsync(data)
    .then(function(zip) {
        // 处理 zip 文件内容
        return zip.file("hello.txt").async("string");
    })
    .then(function(text) {
        console.log(text);
    });
});
常见应用场景:
  • 下载并处理远程 ZIP 文件
  • 获取二进制文件内容
  • 处理跨域的二进制数据请求

为什么 JSZipUtils 被废弃

JSZipUtils 被废弃的主要原因有:

  1. 现代浏览器原生支持更好:fetch API 已经成为现代浏览器的标准,提供了更强大和灵活的功能
  2. 维护问题:JSZipUtils 已经很久没有更新,可能存在安全漏洞
  3. 功能冗余:fetch API 可以完全覆盖 JSZipUtils 的功能,而且提供更多特性
使用 fetch API 替代的示例
原来的 JSZipUtils 写法:
JSZipUtils.getBinaryContent("path/to/file.zip", function(err, data) {
    if(err) {
        throw err;
    }
    JSZip.loadAsync(data)
    .then(function(zip) {
        // 处理 zip 文件
    });
});
使用 fetch API 的现代写法:
fetch("path/to/file.zip")
    .then(response => {
        if (!response.ok) {
            throw new Error("Network response was not ok");
        }
        return response.arrayBuffer();
    })
    .then(data => {
        return JSZip.loadAsync(data);
    })
    .then(zip => {
        // 处理 zip 文件
    })
    .catch(error => {
        console.error("Error:", error);
    });
fetch API 的优势
  1. 更好的错误处理:
fetch(url)
    .then(response => {
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.arrayBuffer();
    })
  1. 支持更多数据类型:
// 获取不同类型的数据
fetch(url)
    .then(response => response.arrayBuffer())  // 二进制数据
    .then(response => response.blob())        // Blob 对象
    .then(response => response.text())        // 文本数据
    .then(response => response.json())        // JSON 数据
  1. 支持请求配置:
fetch(url, {
    method: 'GET',
    headers: {
        'Authorization': 'Bearer token',
        'Content-Type': 'application/octet-stream'
    },
    mode: 'cors',
    credentials: 'include'
})
  1. 支持异步/等待语法:
async function loadZip(url) {
    try {
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error('Network response was not ok');
        }
        const data = await response.arrayBuffer();
        const zip = await JSZip.loadAsync(data);
        return zip;
    } catch (error) {
        console.error('Error:', error);
        throw error;
    }
}
完整的迁移示例

假设我们要下载一个 ZIP 文件,解压后获取其中的文件内容:

// 使用 async/await 的现代写法
async function processZipFile(url) {
    try {
        // 1. 获取文件
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        // 2. 转换为二进制数据
        const arrayBuffer = await response.arrayBuffer();
        
        // 3. 加载到 JSZip
        const zip = await JSZip.loadAsync(arrayBuffer);
        
        // 4. 处理文件内容
        const files = Object.keys(zip.files);
        for (const filename of files) {
            if (!zip.files[filename].dir) {
                const content = await zip.file(filename).async('string');
                console.log(`${filename}: ${content}`);
            }
        }
        
        return zip;
    } catch (error) {
        console.error('Error processing zip file:', error);
        throw error;
    }
}

// 使用示例
processZipFile('path/to/file.zip')
    .then(zip => {
        console.log('Zip file processed successfully');
    })
    .catch(error => {
        console.error('Failed to process zip file:', error);
    });
兼容性考虑

如果需要支持较老的浏览器,可以添加 polyfill:

// 添加 fetch polyfill(如果需要)
import 'whatwg-fetch';

// 或者使用条件加载
if (!window.fetch) {
    // 加载 fetch polyfill
}
总结

迁移到 fetch API 的好处:

  1. 更现代的 API 设计
  2. 更好的错误处理机制
  3. 更灵活的数据处理能力
  4. 更好的性能和浏览器支持
  5. 更少的依赖,减少包大小
  6. 更好的 TypeScript 支持

建议在新的项目中直接使用 fetch API,在现有项目中逐步将 JSZipUtils 的调用替换为 fetch API 的实现。

JSZipUtils和jszip的配合使用

通常在实际应用中,这两个库会配合使用:

// 下载远程文件并创建新的 ZIP 包
JSZipUtils.getBinaryContent("path/to/image.jpg", function(err, data) {
    if(err) throw err;
    
    var zip = new JSZip();
    zip.file("image.jpg", data, {binary: true});
    
    zip.generateAsync({type:"blob"})
    .then(function(content) {
        saveAs(content, "new.zip");
    });
});
注意事项
  1. 在浏览器中使用时,如果处理大文件,要注意内存使用情况
  2. 处理远程文件时要注意跨域问题
  3. JSZipUtils 已经被标记为废弃,推荐使用原生的 fetch API 替代
  4. 现代项目中,也可以考虑使用更新的替代方案,如 JSZip 的最新版本配合 fetch API

这两个库在前端处理文件压缩和解压缩任务时非常实用,特别是在需要动态处理文件内容的场景中。

vue3之中预览ofd.js文件

接下来我们就简单实现一下ofd.js文件的预览,我们的想法是在vue3之中替代掉老旧的JSZipUtils库,当然,和之前一样我们还是以实现功能然后进行优化为主

因为ofd文件的本质上是一种压缩包,所以我们需要先解压 OFD 文件,然后解析其中的内容。

先来看看标准的ofd文件是什么样子的

{
    "name": "OFD.xml",
    "dir": false,
    "date": "2020-08-22T16:21:20.000Z",
    "comment": null,
    "unixPermissions": null,
    "dosPermissions": 32,
    "_data": {
        "compressedSize": 446,
        "uncompressedSize": 1269,
        "crc32": -2125441896,
        "compression": {
            "magic": "\b\u0000"
        },
        "compressedContent": {
            "0": 157,
            "1": 84,
            "2": 209,
            "3": 110,
             xxx
        }
    },
    "_dataBinary": true,
    "options": {
        "compression": null,
        "compressionOptions": null
    },
    "unsafeOriginalName": "OFD.xml"
}

方式1-使用easyofd的方式预览

推荐的预览方式

安装相关依赖
pnpm i jszip x2js jb2 opentype.js easyofd
预览文件
<script setup>
import EasyOFD from "easyofd";
import { onMounted } from 'vue'

onMounted(() => {
  let yourElement=document.getElementById("1111111");
  let ofd=new EasyOFD('myofdID', yourElement);
})

</script>

<template>
      <div id="1111111"> </div>

</template>

<style >
 .OfdButton{
      padding: 10px 20px;
      background-color: #007bff;
      color: #fff;
      border: none;
      border-radius: 5px;
      cursor: pointer;
      margin-right: 10px;
    }
</style>

方式2-使用ofd的方式预览

相关源码地址
https://github.com/DLTech21/ofd.js
安装依赖

这里我们先安装必须的依赖,后面剔除

pnpm install jszip
pnpm install jszip-utils(后面剔除)
pnpm i @lapo/asn1js
pnpm i js-sha1
pnpm i ofd-xml-parser js-md5 jsrsasign jsrsasign-util sm-crypto

安装成功以后我们首要的就是将ofd之前的版本兼容到vue3

这里需要我们main.TS之中设置一下全局

// 设置全局变量
if (typeof window !== 'undefined') {
  window.global = window;
}
文件之中使用
<template>
  <el-container style="width:100vw; height: 100vh;">
    <el-header style="background:#F5F5F5;display: flex; height: 40px; border: 1px solid #e8e8e8; align-items: center;">
      <div class="upload-icon" @click="uploadFile">
        <div class="upload-icon">打开OFD</div>
        <font-awesome-icon icon="cloud-upload-alt"/>
        <input type="file" ref="fileRef" class="hidden" accept=".ofd" @change="fileChanged">
      </div>

      <div class="upload-icon" @click="uploadPdfFile">
        <div class="upload-icon">PDF2OFD</div>
        <font-awesome-icon icon="cloud-upload-alt"/>
        <input type="file" ref="pdfFileRef" class="hidden" accept=".pdf" @change="pdfFileChanged">
      </div>

      <div style="display: flex;align-items: center" v-if="ofdObj">
        <div class="upload-icon" style="margin-left: 10px" @click="downPdf" v-if="ofdBase64">
          下载PDF
          <font-awesome-icon icon="download"/>
        </div>

        <div class="scale-icon" style="margin-left: 10px" @click="plus">
          <font-awesome-icon icon="search-plus"/>
        </div>

        <div class="scale-icon" @click="minus">
          <font-awesome-icon icon="search-minus" />
        </div>
        <div class="scale-icon">
          <font-awesome-icon icon="step-backward" @click="firstPage"/>
        </div>

        <div class="scale-icon" style="font-size: 18px" @click="prePage">
          <font-awesome-icon icon="caret-left"/>
        </div>

        <div class="scale-icon">
          {{pageIndex}}/{{pageCount}}
        </div>

        <div class="scale-icon" style="font-size: 18px" @click="nextPage">
          <font-awesome-icon icon="caret-right"/>
        </div>
        <div class="scale-icon" @click="lastPage">
          <font-awesome-icon icon="step-forward"/>
        </div>
      </div>
    </el-header>

    <el-main style="height: auto;background: #808080;;padding: 0" v-loading="loading">
      <div id="leftMenu" class="left-section">
        <div class="text-icon" @click="demo(1)">
          <p>电子发票</p>
        </div>
        <div class="text-icon" @click="demo(2)">
          <p>电子公文</p>
        </div>
        <div class="text-icon" @click="demo(3)">
          <p>骑缝章</p>
        </div>
        <div class="text-icon" @click="demo(4)">
          <p>多页文档</p>
        </div>
      </div>
      <div class="main-section" id="content" ref="contentDivRef" @mousewheel="scrool"></div>
    </el-main>

    <div class="SealContainer" id="sealInfoDiv" hidden="hidden" ref="sealInfoDivRef">
      <div class="SealContainer mask" @click="closeSealInfoDialog"></div>
      <div class="SealContainer-layout">
        <div class="SealContainer-content">
          <p class="content-title">签章信息</p>
          <div class="subcontent">
            <span class="title">签章人</span>
            <span class="value" id="spSigner">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">签章提供者</span>
            <span class="value" id="spProvider">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">原文摘要值</span>
            <span class="value" id="spHashedValue" @click="showMore('原文摘要值', 'spHashedValue')" style="cursor: pointer">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">签名值</span>
            <span class="value" id="spSignedValue" @click="showMore('签名值', 'spSignedValue')" style="cursor: pointer">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">签名算法</span>
            <span class="value" id="spSignMethod">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">版本号</span>
            <span class="value" id="spVersion">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">验签结果</span>
            <span class="value" id="VerifyRet">[无效的签章结构]</span>
          </div>

          <p class="content-title">印章信息</p>
          <div class="subcontent">
            <span class="title">印章标识</span>
            <span class="value" id="spSealID">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">印章名称</span>
            <span class="value" id="spSealName">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">印章类型</span>
            <span class="value" id="spSealType">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">有效时间</span>
            <span class="value" id="spSealAuthTime">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">制章日期</span>
            <span class="value" id="spSealMakeTime">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">印章版本</span>
            <span class="value" id="spSealVersion">[无效的签章结构]</span>
          </div>
        </div>
        <input style="position:absolute;right:1%;top:1%;" type="button" name="" id="" value="X" @click="closeSealInfoDialog()"/>
      </div>
    </div>

    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible">
      <span style="text-align: left">{{dialogValue}}</span>
      <div slot="footer" class="dialog-footer">
        <el-button type="primary" @click="dialogVisible = false">确 定</el-button>
      </div>
    </el-dialog>
  </el-container>
</template>

<script setup>
import { ref, onMounted, reactive } from 'vue'
import { parseOfdDocument, renderOfd, renderOfdByScale, digestCheck, getPageScale, setPageScale } from "@/utils/ofd/ofd.js"
import JSZipUtils from "jszip-utils"
import { ElMessage, ElMessageBox } from 'element-plus'
// 响应式数据
const fileRef = ref(null)
const pdfFileRef = ref(null)
const contentDivRef = ref(null)
const sealInfoDivRef = ref(null)

const state = reactive({
  pdfFile: null,
  ofdBase64: null,
  loading: false,
  pageIndex: 1,
  pageCount: 0,
  scale: 0,
  dialogTitle: null,
  dialogValue: null,
  dialogVisible: false,
  ofdObj: null,
  screenWidth: document.body.clientWidth
})

// 方法
const uploadFile = () => {
  state.pdfFile = null
  fileRef.value.click()
}

const fileChanged = (e) => {
  const file = e.target.files[0]
  const ext = file.name.replace(/.+\./, "")
  
  if (["ofd"].indexOf(ext) === -1) {
    ElMessageBox.alert('仅支持ofd类型', 'error', {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
    return
  }
  
  if (file.size > 100 * 1024 * 1024) {
    ElMessageBox.alert('文件大小需 < 100M', 'error', {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
    return
  }
  
  const reader = new FileReader()
  reader.readAsDataURL(file)
  reader.onload = (e) => {
    state.ofdBase64 = e.target.result.split(',')[1]
  }
  
  getOfdDocumentObj(file, state.screenWidth)
  fileRef.value.value = null
}

const uploadPdfFile = () => {
  state.pdfFile = null
  pdfFileRef.value.click()
}

const pdfFileChanged = (e) => {
  const file = e.target.files[0]
  const ext = file.name.replace(/.+\./, "")
  
  if (["pdf"].indexOf(ext) === -1) {
    ElMessageBox.alert('仅支持pdf类型', 'error', {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
    return
  }
  
  if (file.size > 100 * 1024 * 1024) {
    ElMessageBox.alert('文件大小需 < 100M', 'error', {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
    return
  }
  
  const reader = new FileReader()
  reader.readAsDataURL(file)
  reader.onload = (e) => {
    const pdfBase64 = e.target.result.split(',')[1]
    downOfd(pdfBase64)
  }
  
  pdfFileRef.value.value = null
}

const downOfd = (pdfBase64) => {
  state.loading = true
  axios({
    method: "post",
    url: "https://51shouzu.xyz/api/ofd/convertOfd",
    data: {
      pdfBase64,
    }
  }).then(response => {
    state.loading = false
    const binary = atob(response.data.data.replace(/\s/g, ''))
    const len = binary.length
    const buffer = new ArrayBuffer(len)
    const view = new Uint8Array(buffer)
    for (let i = 0; i < len; i++) {
      view[i] = binary.charCodeAt(i)
    }
    const blob = new Blob([view], null)
    const url = URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.style.display = 'none'
    link.href = url
    link.setAttribute('download', 'ofd.ofd')
    document.body.appendChild(link)
    link.click()
  }).catch(error => {
    console.log(error, "error")
    ElMessageBox.alert('PDF打开失败', error, {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
  })
}

const downPdf = () => {
  state.loading = true
  axios({
    method: "post",
    url: "https://51shouzu.xyz/api/ofd/convertPdf",
    data: {
      ofdBase64: state.ofdBase64
    }
  }).then(response => {
    state.loading = false
    const binary = atob(response.data.data.replace(/\s/g, ''))
    const len = binary.length
    const buffer = new ArrayBuffer(len)
    const view = new Uint8Array(buffer)
    for (let i = 0; i < len; i++) {
      view[i] = binary.charCodeAt(i)
    }
    const blob = new Blob([view], { type: "application/pdf" })
    const url = URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.style.display = 'none'
    link.href = url
    link.setAttribute('download', 'ofd.pdf')
    document.body.appendChild(link)
    link.click()
  }).catch(error => {
    console.log(error, "error")
    ElMessageBox.alert('OFD打开失败', error, {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
  })
}

const plus = () => {
  setPageScale(++state.scale)
  const divs = renderOfdByScale(state.ofdObj)
  displayOfdDiv(divs)
}

const minus = () => {
  setPageScale(--state.scale)
  const divs = renderOfdByScale(state.ofdObj)
  displayOfdDiv(divs)
}

const prePage = () => {
  const contentDiv = document.getElementById('content')
  const ele = contentDiv.children.item(state.pageIndex - 2)
  ele?.scrollIntoView(true)
  if (ele) state.pageIndex = state.pageIndex - 1
}

const firstPage = () => {
  const contentDiv = document.getElementById('content')
  const ele = contentDiv.firstElementChild
  ele?.scrollIntoView(true)
  if (ele) state.pageIndex = 1
}

const nextPage = () => {
  const contentDiv = document.getElementById('content')
  const ele = contentDiv.children.item(state.pageIndex)
  ele?.scrollIntoView(true)
  if (ele) ++state.pageIndex
}

const lastPage = () => {
  const contentDiv = document.getElementById('content')
  const ele = contentDiv.lastElementChild
  ele?.scrollIntoView(true)
  if (ele) state.pageIndex = contentDiv.childElementCount
}

const demo = (value) => {
  let ofdFile = null
  switch (value) {
    case 1:
      ofdFile = '999.ofd'
      break
    case 2:
      ofdFile = 'n.ofd'
      break
    case 3:
      ofdFile = 'h.ofd'
      break
    case 4:
      ofdFile = '2.ofd'
      break
  }
  JSZipUtils.getBinaryContent(ofdFile, (err, data) => {
    if (err) {
      console.log("JSZipUtils===1");
      console.log(err)
    } else {
      const base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(data)))
      console.log("JSZipUtils===2");
      state.ofdBase64 = base64String
    }
  })
  getOfdDocumentObj(ofdFile, state.screenWidth)
}

const getOfdDocumentObj = (file, screenWidth) => {
  const t = new Date().getTime()
  state.loading = true
  
  parseOfdDocument({
    ofd: file,
    success(res) {
      console.log(res)
      const t1 = new Date().getTime()
      console.log('解析ofd', t1 - t)
      state.ofdObj = res[0]
      state.pageCount = res[0].pages.length
      const divs = renderOfd(screenWidth, res[0])
      const t2 = new Date().getTime()
      console.log('xml转svg', t2 - t1)
      displayOfdDiv(divs)
      const t3 = new Date().getTime()
      console.log('svg渲染到页面', t3 - t2)
      state.loading = false
    },
    fail(error) {
      console.log(error)
      state.loading = false
      ElMessageBox.alert('OFD打开失败', error, {
        confirmButtonText: '确定',
        callback: action => {
          ElMessage({
            type: 'info',
            message: `action: ${action}`
          })
        }
      })
    }
  })
}

const displayOfdDiv = (divs) => {
  state.scale = getPageScale()
  const contentDiv = document.getElementById('content')
  contentDiv.innerHTML = ''
  
  for (const div of divs) {
    contentDiv.appendChild(div)
  }
  
  for (const ele of document.getElementsByName('seal_img_div')) {
    addEventOnSealDiv(ele, JSON.parse(ele.dataset.sesSignature), JSON.parse(ele.dataset.signedInfo))
  }
}

const addEventOnSealDiv = (div, SES_Signature, signedInfo) => {
  try {
    global.HashRet = null
    global.VerifyRet = signedInfo.VerifyRet
    
    div.addEventListener("click", () => {
      document.getElementById('sealInfoDiv').hidden = false
      document.getElementById('sealInfoDiv').setAttribute('style', 'display:flex;align-items: center;justify-content: center;')
      
      if (SES_Signature.realVersion < 4) {
        document.getElementById('spSigner').innerText = SES_Signature.toSign.cert['commonName']
        document.getElementById('spProvider').innerText = signedInfo.Provider['@_ProviderName']
        document.getElementById('spHashedValue').innerText = SES_Signature.toSign.dataHash.replace(/\n/g, '')
        document.getElementById('spSignedValue').innerText = SES_Signature.signature.replace(/\n/g, '')
        document.getElementById('spSignMethod').innerText = SES_Signature.toSign.signatureAlgorithm.replace(/\n/g, '')
        document.getElementById('spSealID').innerText = SES_Signature.toSign.eseal.esealInfo.esID
        document.getElementById('spSealName').innerText = SES_Signature.toSign.eseal.esealInfo.property.name
        document.getElementById('spSealType').innerText = SES_Signature.toSign.eseal.esealInfo.property.type
        document.getElementById('spSealAuthTime').innerText = "从 " + SES_Signature.toSign.eseal.esealInfo.property.validStart + " 到 " + SES_Signature.toSign.eseal.esealInfo.property.validEnd
        document.getElementById('spSealMakeTime').innerText = SES_Signature.toSign.eseal.esealInfo.property.createDate
        document.getElementById('spSealVersion').innerText = SES_Signature.toSign.eseal.esealInfo.header.version
      } else {
        document.getElementById('spSigner').innerText = SES_Signature.cert['commonName']
        document.getElementById('spProvider').innerText = signedInfo.Provider['@_ProviderName']
        document.getElementById('spHashedValue').innerText = SES_Signature.toSign.dataHash.replace(/\n/g, '')
        document.getElementById('spSignedValue').innerText = SES_Signature.signature.replace(/\n/g, '')
        document.getElementById('spSignMethod').innerText = SES_Signature.signatureAlgID.replace(/\n/g, '')
        document.getElementById('spSealID').innerText = SES_Signature.toSign.eseal.esealInfo.esID
        document.getElementById('spSealName').innerText = SES_Signature.toSign.eseal.esealInfo.property.name
        document.getElementById('spSealType').innerText = SES_Signature.toSign.eseal.esealInfo.property.type
        document.getElementById('spSealAuthTime').innerText = "从 " + SES_Signature.toSign.eseal.esealInfo.property.validStart + " 到 " + SES_Signature.toSign.eseal.esealInfo.property.validEnd
        document.getElementById('spSealMakeTime').innerText = SES_Signature.toSign.eseal.esealInfo.property.createDate
        document.getElementById('spSealVersion').innerText = SES_Signature.toSign.eseal.esealInfo.header.version
      }
      
      document.getElementById('spVersion').innerText = SES_Signature.toSign.version
      document.getElementById('VerifyRet').innerText = "文件摘要值后台验证中,请稍等... " + (global.VerifyRet ? "签名值验证成功" : "签名值验证失败")
      
      if (global.HashRet == null || global.HashRet == undefined || Object.keys(global.HashRet).length <= 0) {
        setTimeout(() => {
          const signRetStr = global.VerifyRet ? "签名值验证成功" : "签名值验证失败"
          global.HashRet = digestCheck(global.toBeChecked.get(signedInfo.signatureID))
          const hashRetStr = global.HashRet ? "文件摘要值验证成功" : "文件摘要值验证失败"
          document.getElementById('VerifyRet').innerText = hashRetStr + " " + signRetStr
        }, 1000)
      }
    })
  } catch (e) {
    console.log(e)
  }
  
  if (!global.VerifyRet) {
    div.setAttribute('class', 'gray')
  }
}

const closeSealInfoDialog = () => {
  sealInfoDivRef.value.setAttribute('style', 'display: none')
  document.getElementById('spSigner').innerText = "[无效的签章结构]"
  document.getElementById('spProvider').innerText = "[无效的签章结构]"
  document.getElementById('spHashedValue').innerText = "[无效的签章结构]"
  document.getElementById('spSignedValue').innerText = "[无效的签章结构]"
  document.getElementById('spSignMethod').innerText = "[无效的签章结构]"
  document.getElementById('spSealID').innerText = "[无效的签章结构]"
  document.getElementById('spSealName').innerText = "[无效的签章结构]"
  document.getElementById('spSealType').innerText = "[无效的签章结构]"
  document.getElementById('spSealAuthTime').innerText = "[无效的签章结构]"
  document.getElementById('spSealMakeTime').innerText = "[无效的签章结构]"
  document.getElementById('spSealVersion').innerText = "[无效的签章结构]"
  document.getElementById('spVersion').innerText = "[无效的签章结构]"
  document.getElementById('VerifyRet').innerText = "[无效的签章结构]"
}

const showMore = (title, id) => {
  state.dialogVisible = true
  state.dialogValue = document.getElementById(id).innerText
  state.dialogTitle = title
}

const scrool = () => {
  const scrolled = contentDivRef.value.firstElementChild?.getBoundingClientRect()?.top - 60
  let top = 0
  let index = 0
  
  for (let i = 0; i < contentDivRef.value.childElementCount; i++) {
    top += (Math.abs(contentDivRef.value.children.item(i)?.style.height.replace('px', '')) + Math.abs(contentDivRef.value.children.item(i)?.style.marginBottom.replace('px', '')))
    if (Math.abs(scrolled) < top) {
      index = i
      break
    }
  }
  
  state.pageIndex = index + 1
}

// 生命周期钩子
onMounted(() => {
  state.screenWidth = document.body.clientWidth - document.getElementById('leftMenu').getBoundingClientRect().width
  
  contentDivRef.value.addEventListener('scroll', scrool)
  
  window.onresize = () => {
    state.screenWidth = (document.body.clientWidth - 88)
    const divs = renderOfd(state.screenWidth, state.ofdObj)
    displayOfdDiv(divs)
  }
})
</script>

<style scoped>
/* 保持原有的样式不变 */
.upload-icon {
  display: flex;
  cursor: pointer;
  justify-content: center;
  align-items: center;
  height: 28px;
  padding-left: 10px;
  padding-right: 10px;
  background-color: rgb(59, 95, 232);
  border-radius: 1px;
  border-color: #5867dd;
  font-weight: 500;
  font-size: 12px;
  color: white;
  margin: 1px;
}

.scale-icon {
  display: flex;
  cursor: pointer;
  justify-content: center;
  align-items: center;
  width: 33px;
  height: 28px;
  background-color: #F5F5F5;;
  border-radius: 1px;
  font-weight: 500;
  font-size: 12px;
  color: #333333;
  text-align: center;
  padding: 2px;
}

.scale-icon :active {
  color: rgb(59, 95, 232);
}

.scale-icon :hover {
  color: rgb(59, 95, 232);
}

.text-icon {
  display: flex;
  cursor: pointer;
  justify-content: center;
  align-items: center;
  height: 28px;
  width: 90%;
  background-color: rgb(59, 95, 232);
  border-radius: 1px;
  border-color: #5867dd;
  font-weight: 500;
  font-size: 10px;
  color: white;
  margin-top: 20px;
}

.hidden {
  display: none !important;
}

.SealContainer {
  z-index: 99999;
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
}

.SealContainer .mask {
  background: #000000;
  opacity: 0.3;
}

.content-title {
  font-size: 16px;
  text-align: center;
  border-bottom: 1px solid rgb(59, 95, 232);
  color: rgb(59, 95, 232);
  margin-top: 10px;
}

.SealContainer-content {
  position: relative;
  width: 100%;
  height: 100%;
  overflow-y: auto;
  background: white;
  display: flex;
  flex-direction: column;
  padding: 10px;
  align-items: center;
}

.SealContainer-layout {
  position: relative;
  width: 60%;
  height: 80vh;
  overflow-y: auto;
  background: white;
  z-index: 100;
  display: flex;
  flex-direction: column;
  padding: 10px;
  align-items: center;
}

.subcontent {
  width: 80%;
  display: flex;
  flex-direction: column;
  text-align: left;
  margin-bottom: 10px;
  font-family: simsun;
}

.subcontent .title {
  font-weight: 600;
}

.subcontent .value {
  font-weight: 400;
  -webkit-line-clamp: 1;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.left-section {
  position: fixed;
  width: 88px;
  height: 100%;
  background:#F5F5F5;
  border: 1px solid #e8e8e8;
  align-items: center;
  display: flex;
  flex-direction: column
}

.main-section {
  padding-top: 20px;
  margin-left:88px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: #808080;
  overflow: hidden
}

@media (max-width: 767px) {
  .SealContainer-layout {
    position: relative;
    width: 90%;
    height: 90vh;
    overflow-y: auto;
    background: white;
    z-index: 100;
    display: flex;
    flex-direction: column;
    padding: 10px;
    align-items: center;
  }

  .subcontent {
    width: 95%;
    display: flex;
    flex-direction: column;
    text-align: left;
    margin-bottom: 10px;
    font-family: simsun;
  }

  .left-section {
    position: fixed;
    width: 0px;
    height: 100%;
    background:#F5F5F5;
    border: 1px solid #e8e8e8;
    align-items: center;
    display: none;
    flex-direction: column;
  }

  .main-section {
    padding-top: 20px;
    margin-left:0px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: #808080;
    overflow: hidden
  }
}
</style>

方式3-ofd.js文件预览以及使用(废弃)

查看了一下npm上已经需要付费了,所以我果断放弃了

// npm地址
https://www.npmjs.com/package/ofd.js

// 安装
pnpm install ofd.js
昨天 — 2026年1月6日首页

用填充表格法吃透01背包及其变形-3

作者 颜酱
2026年1月6日 11:46

三、01背包的经典变形

01背包的核心是「选/不选」,实际考题中很少直接考查基础模型,更多是结合具体场景转化为变形问题。但无论场景如何变化,只要抓住「每个物品最多选一次」的本质,就能用DP解题5步「万能钥匙」轻松破解。以下是4类最经典的01背包变形:

3.1 变形1:目标和(分割子集和/是否能装满背包)

LeetCode 链接494. 目标和

问题描述:给定一个非负整数数组nums和一个目标数target,向数组中每个整数前添加+-,使得所有整数的和等于target,求有多少种不同的添加符号的方法。

核心转化:设添加+的数的和为left,添加-的数的和为right,则有:

left - right = target
left + right = sum(nums)
两式相加得:left = (target + sum(nums)) / 2

问题转化为:从nums中选择若干元素,使得其和恰好为left,求这样的选择方案数——这是「01背包求方案数」的典型场景(每个元素选或不选,选则计入和,不选则不计)。

目标和问题核心表格(空表,后续逐步填充):

处理阶段\和为j 0 1 2 ...(和递增) left(目标和)
初始状态 待填充 待填充 待填充 待填充 待填充
处理第1个元素 待填充 待填充 待填充 待填充 待填充
处理第2个元素 待填充 待填充 待填充 待填充 待填充

表格说明:表格中每个单元格dp[i][j]代表「处理前i个元素后,能凑出和为j的方案数」,最终右下角dp[n][left]即为目标和的解法总数(n为nums数组长度)。

3.1.1 步骤1:确定dp数组及下标的含义

定义二维数组dp[i][j]:表示「处理前i个元素,能凑出和为j的方案数」。后续可优化为一维数组dp[j](空间优化思路与基础01背包一致),这里先从直观的二维数组入手。

对应表格维度:i(行)表示处理的元素个数(从0到n,0代表未处理任何元素),j(列)表示要凑的和(从0到left,0代表和为0),表格共n+1行、left+1列。

3.1.2 步骤2:确定递推公式

对于第i个元素(值为nums[i-1],数组索引从0开始,i从1开始),核心决策仍是「选或不选」,方案数为两种决策的总和:

  1. 不选第i个元素:凑出和为j的方案数 = 处理前i-1个元素凑出和为j的方案数,即dp[i][j] += dp[i-1][j]

  2. 选第i个元素:需保证j ≥ nums[i-1](当前元素值不大于目标和j),此时方案数 = 处理前i-1个元素凑出和为j-nums[i-1]的方案数,即dp[i][j] += dp[i-1][j - nums[i-1]]

最终递推公式(两种决策方案数相加):

if (j >= nums[i - 1]) {
  dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];
} else {
  dp[i][j] = dp[i - 1][j];
}

3.1.3 步骤3:dp数组如何初始化

初始化核心是确定边界条件,即无需推导就能直接确定的方案数:

  1. i=0(未处理任何元素),j=0(和为0):不选任何元素即可凑出和为0,因此方案数为1,即dp[0][0] = 1

  2. i=0(未处理任何元素),j>0(和大于0):没有元素可选,无法凑出任何正和,方案数为0,即dp[0][j] = 0(j>0);

  3. j=0(和为0),i>0(处理过元素):初始时可先设为1(后续通过递推更新),表示不选当前及之前元素的基础方案。

结合示例理解:假设nums = [1,1,1,1],target = 2,先计算sum(nums) = 4,left = (2 + 4)/2 = 3。初始化后的表格(第0行已填充):

处理阶段\和为j 0 1 2 3
初始状态(i=0) 1 0 0 0
处理第1个元素(1) 1 待填 待填 待填
处理第2个元素(1) 1 待填 待填 待填
处理第3个元素(1) 1 待填 待填 待填
处理第4个元素(1) 1 待填 待填 待填

3.1.4 步骤4:确定遍历顺序(表格填充顺序)

与基础01背包二维解法一致:先遍历元素(i从1到n),再遍历和(j从0到left),即逐行填充表格。原因:计算dp[i][j]时,仅依赖上一行(i-1行)的dp[i-1][j]dp[i-1][j - nums[i-1]],逐行填充可确保依赖的单元格已提前计算完成。

3.1.5 步骤5:打印dp数组(验证)

以示例nums = [1,1,1,1]target = 2(left=3)为例,逐步填充表格验证逻辑:

  1. 填充第1行(i=1,元素1:1)

    • j=0:不选元素1,方案数=dp[0][0]=1;
    • j=1:j≥1,方案数=dp[0][1](不选)+ dp[0][0](选)=0+1=1;
    • j=2:j>1,无法选,方案数=dp[0][2]=0;
    • j=3:j>1,无法选,方案数=dp[0][3]=0;
  2. 填充第2行(i=2,元素2:1)

    • j=0:方案数=dp[1][0]=1;
    • j=1:j≥1,方案数=dp[1][1](不选)+ dp[1][0](选)=1+1=2;
    • j=2:j≥1,方案数=dp[1][2](不选)+ dp[1][1](选)=0+1=1;
    • j=3:j>1,无法选,方案数=dp[1][3]=0;
  3. 填充第3行(i=3,元素3:1)

    • j=0:方案数=dp[2][0]=1;
    • j=1:j≥1,方案数=dp[2][1](不选)+ dp[2][0](选)=2+1=3;
    • j=2:j≥1,方案数=dp[2][2](不选)+ dp[2][1](选)=1+2=3;
    • j=3:j≥1,方案数=dp[2][3](不选)+ dp[2][2](选)=0+1=1;
  4. 填充第4行(i=4,元素4:1)

    • j=0:方案数=dp[3][0]=1;
    • j=1:j≥1,方案数=dp[3][1](不选)+ dp[3][0](选)=3+1=4;
    • j=2:j≥1,方案数=dp[3][2](不选)+ dp[3][1](选)=3+3=6;
    • j=3:j≥1,方案数=dp[3][3](不选)+ dp[3][2](选)=1+3=4;

最终填充完成的表格:

处理阶段\和为j 0 1 2 3
初始状态(i=0) 1 0 0 0
处理第1个元素(1) 1 1 0 0
处理第2个元素(1) 1 2 1 0
处理第3个元素(1) 1 3 3 1
处理第4个元素(1) 1 4 6 4

表格右下角dp[4][3] = 4,即该示例的目标和解法总数为4,与实际情况一致(+1+1+1-1、+1+1-1+1、+1-1+1+1、-1+1+1+1)。

3.1.6 目标和问题完整代码(二维+一维优化)

/**
 * 目标和(二维DP解法)
 * @param {number[]} nums - 非负整数数组
 * @param {number} target - 目标和
 * @returns {number} - 不同的添加符号方法数
 */
function findTargetSumWays_2d(nums, target) {
  const sum = nums.reduce((a, b) => a + b, 0);
  // 边界条件:target的绝对值大于sum,或(target + sum)为奇数,均无可行方案
  if (Math.abs(target) > sum || (target + sum) % 2 !== 0) return 0;
  const left = (target + sum) / 2;
  const n = nums.length;
  // 初始化二维dp数组:dp[i][j]表示处理前i个元素凑出和为j的方案数
  const dp = new Array(n + 1).fill(0).map(() => new Array(left + 1).fill(0));
  dp[0][0] = 1; // 未处理元素时,凑出和为0的方案数为1

  // 遍历顺序:先遍历元素,再遍历和(逐行填充)
  for (let i = 1; i <= n; i++) {
    for (let j = 0; j <= left; j++) {
      // 递推公式
      if (j >= nums[i - 1]) {
        dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];
      } else {
        dp[i][j] = dp[i - 1][j];
      }
    }
  }

  // 打印dp数组验证
  console.log('目标和二维DP数组(表格):');
  for (let i = 0; i <= n; i++) {
    console.log(dp[i].join('\t'));
  }

  return dp[n][left];
}

/**
 * 目标和(一维DP空间优化解法)
 * @param {number[]} nums - 非负整数数组
 * @param {number} target - 目标和
 * @returns {number} - 不同的添加符号方法数
 */
function findTargetSumWays_1d(nums, target) {
  const sum = nums.reduce((a, b) => a + b, 0);
  if (Math.abs(target) > sum || (target + sum) % 2 !== 0) return 0;
  const left = (target + sum) / 2;
  // 初始化一维dp数组:dp[j]表示凑出和为j的方案数
  const dp = new Array(left + 1).fill(0);
  dp[0] = 1; // 基础方案:不选任何元素凑出和为0

  // 遍历顺序:先遍历元素,再倒序遍历和(避免重复选择)
  for (let num of nums) {
    for (let j = left; j >= num; j--) {
      dp[j] += dp[j - num]; // 递推公式简化(复用数组)
    }
    console.log(`处理完元素${num}后,dp数组:`, [...dp]);
  }

  return dp[left];
}

// 测试用例
const nums = [1, 1, 1, 1];
const target = 2;
console.log('二维DP解法:', findTargetSumWays_2d(nums, target)); // 输出:4
console.log('一维DP解法:', findTargetSumWays_1d(nums, target)); // 输出:4

3.2 变形2:分割等和子集(是否能装满背包)

LeetCode 链接416. 分割等和子集

问题描述:给定一个只包含正整数的非空数组nums,判断是否可以将这个数组分割成两个子集,使得两个子集的和相等。

核心转化:两个子集和相等,即每个子集的和为数组总和的一半(记为target)。问题转化为:从nums中选择若干元素,使得其和恰好为target——这是「01背包判断可行性」的典型场景(每个元素选或不选,判断是否能装满容量为target的背包)。

核心表格(空表):

处理阶段\和为j 0 1 2 ...(和递增) target(目标和)
初始状态 待填充 待填充 待填充 待填充 待填充
处理第1个元素 待填充 待填充 待填充 待填充 待填充
处理第2个元素 待填充 待填充 待填充 待填充 待填充

表格说明:表格中每个单元格dp[i][j]代表「处理前i个元素后,能否凑出和为j」(布尔值),最终右下角dp[n][target]即为问题答案。

3.2.1 步骤1:确定dp数组及下标的含义

定义二维布尔数组dp[i][j]:表示「处理前i个元素,能否凑出和为j」。可优化为一维布尔数组dp[j],空间复杂度从O(n*target)降至O(target)

对应表格维度:i(行)表示处理的元素个数(从0到n,0代表未处理任何元素),j(列)表示要凑的和(从0到target,0代表和为0),表格共n+1行、target+1列。

3.2.2 步骤2:确定递推公式

对于第i个元素(值为nums[i-1]),决策为「选或不选」,可行性为两种决策的或运算:

  1. 不选第i个元素:能否凑出j = 处理前i-1个元素能否凑出j,即dp[i][j] = dp[i-1][j]

  2. 选第i个元素:需j ≥ nums[i-1],能否凑出j = 处理前i-1个元素能否凑出j-nums[i-1],即dp[i][j] = dp[i-1][j - nums[i-1]]

最终递推公式(两种决策有一个可行则整体可行):

if (j >= nums[i - 1]) {
  dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
} else {
  dp[i][j] = dp[i - 1][j];
}

3.2.3 步骤3:dp数组如何初始化

初始化核心是确定边界条件,即无需推导就能直接确定的可行性:

  1. i=0(未处理任何元素),j=0(和为0):不选任何元素可凑出和为0,因此dp[0][0] = true

  2. i=0(未处理任何元素),j>0(和大于0):没有元素可选,无法凑出任何正和,可行性为false,即dp[0][j] = false(j>0);

  3. j=0(和为0),i>0(处理过元素):初始时可先设为true(后续通过递推更新),表示不选当前及之前元素的基础方案。

结合示例理解:假设nums = [1,5,11,5],sum = 22,target = 11。初始化后的表格(第0行已填充):

处理阶段\和为j 0 1 2 3 ...(和递增) 11(目标和)
初始状态(i=0) true false false false false false
处理第1个元素(1) true 待填 待填 待填 待填 待填
处理第2个元素(5) true 待填 待填 待填 待填 待填
处理第3个元素(11) true 待填 待填 待填 待填 待填
处理第4个元素(5) true 待填 待填 待填 待填 待填

3.2.4 步骤4:确定遍历顺序(表格填充顺序)

与基础01背包二维解法一致:先遍历元素(i从1到n),再遍历和(j从0到target),即逐行填充表格。原因:计算dp[i][j]时,仅依赖上一行(i-1行)的dp[i-1][j]dp[i-1][j - nums[i-1]],逐行填充可确保依赖的单元格已提前计算完成。

一维解法:先遍历元素,再倒序遍历和(避免重复选择),与基础01背包空间优化逻辑一致。

3.2.5 步骤5:打印dp数组(验证)

以示例nums = [1,5,11,5]sum = 22target = 11为例,逐步填充表格验证逻辑:

  1. 填充第1行(i=1,元素1:1)

    • j=0:不选元素1,dp[1][0] = dp[0][0] = true;
    • j=1:j≥1,dp[1][1] = dp[0][1](不选)|| dp[0][0](选)= false || true = true;
    • j=2-11:j<1,无法选,dp[1][j] = dp[0][j] = false;
  2. 填充第2行(i=2,元素2:5)

    • j=0:dp[2][0] = dp[1][0] = true;
    • j=1-4:j<5,无法选,dp[2][j] = dp[1][j](继承上一行);
    • j=5:j≥5,dp[2][5] = dp[1][5](不选)|| dp[1][0](选)= false || true = true;
    • j=6:j≥5,dp[2][6] = dp[1][6](不选)|| dp[1][1](选)= false || true = true;
    • j=7-11:j≥5,dp[2][j] = dp[1][j](不选)|| dp[1][j-5](选),其中j=11时,dp[2][11] = false || false = false;
  3. 填充第3行(i=3,元素3:11)

    • j=0-10:j<11,无法选,dp[3][j] = dp[2][j](继承上一行);
    • j=11:j≥11,dp[3][11] = dp[2][11](不选)|| dp[2][0](选)= false || true = true;
  4. 填充第4行(i=4,元素4:5)

    • j=0-4:j<5,无法选,dp[4][j] = dp[3][j](继承上一行);
    • j=5-11:j≥5,dp[4][j] = dp[3][j](不选)|| dp[3][j-5](选),其中j=11时,dp[4][11] = true || false = true;

最终填充完成的表格:

处理阶段\和为j 0 1 2 3 4 5 6 7 8 9 10 11
初始状态(i=0) true false false false false false false false false false false false
处理第1个元素(1) true true false false false false false false false false false false
处理第2个元素(5) true true false false false true true false false false false false
处理第3个元素(11) true true false false false true true false false false false true
处理第4个元素(5) true true false false false true true false false false false true

表格右下角dp[4][11] = true,即该示例可以分割成两个和相等的子集(子集[1,5,5]和[11]),与预期结果一致。

3.2.6 分割等和子集完整代码

/**
 * 分割等和子集(二维DP解法)
 * @param {number[]} nums - 正整数数组
 * @returns {boolean} - 是否可以分割成两个和相等的子集
 */
function canPartition_2d(nums) {
  const sum = nums.reduce((a, b) => a + b, 0);
  if (sum % 2 !== 0) return false; // 总和为奇数,无法分割
  const target = sum / 2;
  const n = nums.length;
  // 初始化二维dp数组:dp[i][j]表示处理前i个元素能否凑出和为j
  const dp = new Array(n + 1).fill(0).map(() => new Array(target + 1).fill(false));
  dp[0][0] = true; // 边界条件

  // 遍历顺序:先遍历元素,再遍历和
  for (let i = 1; i <= n; i++) {
    for (let j = 0; j <= target; j++) {
      if (j >= nums[i - 1]) {
        dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
      } else {
        dp[i][j] = dp[i - 1][j];
      }
    }
  }

  // 打印dp数组验证
  console.log('分割等和子集二维DP数组(表格):');
  for (let i = 0; i <= n; i++) {
    console.log(dp[i].map(val => (val ? 'true' : 'false')).join('\t'));
  }

  return dp[n][target];
}

/**
 * 分割等和子集(一维DP空间优化解法)
 * @param {number[]} nums - 正整数数组
 * @returns {boolean} - 是否可以分割成两个和相等的子集
 */
function canPartition_1d(nums) {
  const sum = nums.reduce((a, b) => a + b, 0);
  if (sum % 2 !== 0) return false;
  const target = sum / 2;
  // 初始化一维dp数组:dp[j]表示能否凑出和为j
  const dp = new Array(target + 1).fill(false);
  dp[0] = true; // 边界条件

  // 遍历顺序:先遍历元素,再倒序遍历和
  for (let num of nums) {
    for (let j = target; j >= num; j--) {
      dp[j] = dp[j] || dp[j - num];
    }
    console.log(
      `处理完元素${num}后,dp数组:`,
      dp.map(val => (val ? 'true' : 'false'))
    );
  }

  return dp[target];
}

// 测试用例
const nums1 = [1, 5, 11, 5];
console.log('二维DP解法:', canPartition_2d(nums1)); // 输出:true
console.log('一维DP解法:', canPartition_1d(nums1)); // 输出:true

const nums2 = [1, 2, 3, 5];
console.log('二维DP解法:', canPartition_2d(nums2)); // 输出:false
console.log('一维DP解法:', canPartition_1d(nums2)); // 输出:false

3.3 变形3:最后一块石头的重量II(最小背包剩余容量)

LeetCode 链接1049. 最后一块石头的重量 II

问题描述:有一堆石头,每块石头的重量都是正整数。每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为x和y,且x ≤ y。那么粉碎的可能结果如下:如果x == y,那么两块石头都会被完全粉碎;如果x != y,那么重量为x的石头会被完全粉碎,而重量为y的石头会变成y - x的重量。最后,最多只会剩下一块石头。返回此石头的最小可能重量。

核心转化:要使最后剩余石头重量最小,需将石头尽可能分成两堆重量接近的石头——两堆重量差越小,剩余重量越小。设总重量为sum,目标是找到一堆石头的最大重量maxWeight(≤ sum/2),则剩余重量为sum - 2*maxWeight。问题转化为:从石头重量数组中选择若干元素,使得其和不超过sum/2的最大值——这是「01背包求最大价值(重量即价值)」的场景(背包容量为sum/2,物品重量和价值均为石头重量)。

最后一块石头的重量II核心表格(空表,后续逐步填充):

处理阶段\容量j 0 1 2 ...(容量递增) target(sum/2)
初始状态 待填充 待填充 待填充 待填充 待填充
处理第1块石头 待填充 待填充 待填充 待填充 待填充
处理第2块石头 待填充 待填充 待填充 待填充 待填充

表格说明:表格中每个单元格dp[j]代表「容量为j的背包能容纳的最大重量」(一维DP),最终dp[target]即为不超过sum/2的最大子集重量,剩余重量 = sum - 2*dp[target]。

3.3.1 步骤1:确定dp数组及下标的含义

定义一维数组dp[j]:表示「容量为j的背包,能容纳的最大重量」(即选若干石头的最大和)。

对应表格维度:仅保留"容量j"这一列维度(j从0到target,target = sum/2向下取整),形成单行表格,每次遍历石头时,滚动更新这一行的数值(覆盖上一行的结果)。

3.3.2 步骤2:确定递推公式

对于第i块石头(重量stones[i],价值也为stones[i]),有两种核心决策:选或不选。

  1. 不选第i块石头:容量为j的最大重量 = 不选当前石头时的最大重量,即dp[j] = dp[j](保持不变);

  2. 选第i块石头:需保证背包容量j ≥ 第i块石头的重量,此时最大重量 = 容量j-stones[i]的最大重量 + 第i块石头的重量,即dp[j] = dp[j - stones[i]] + stones[i]

最终递推公式(取两种决策的最大值):

if (j >= stones[i]) {
  dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
} else {
  dp[j] = dp[j]; // 容量不足,无法选
}

简化后(因为容量不足时dp[j]不变):

for (let j = target; j >= stones[i]; j--) {
  dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
}

3.3.3 步骤3:dp数组如何初始化

初始化逻辑与基础01背包一维DP一致:容量为0时,最大重量为0,因此dp[0] = 0;其他容量的初始值也为0(因为初始无石头可放,最大重量为0),即dp = new Array(target + 1).fill(0)

初始化后的单行表格:[0,0,0,0,...](j从0到target)

结合示例理解:假设stones = [2,7,4,1,8,1],sum = 23,target = Math.floor(23/2) = 11。初始化后的表格:

容量j 0 1 2 3 4 5 6 7 8 9 10 11
初始 0 0 0 0 0 0 0 0 0 0 0 0

3.3.4 步骤4:确定遍历顺序(表格填充顺序)

一维DP的遍历顺序有严格要求,核心是「倒序遍历容量」,对应单行表格的「从右往左填充」:

  1. 必须先遍历石头,再遍历容量:逐个处理每块石头,每次处理时更新整个单行表格(覆盖上一行结果);

  2. 容量必须倒序遍历(j从target到stones[i]):从最大容量往小容量填充,确保计算dp[j]时,dp[j - stones[i]]仍是上一行(未处理当前石头)的旧值,避免同一石头被多次选择。

3.3.5 步骤5:打印dp数组(验证)

通过打印单行表格的滚动更新过程,验证填充规则的正确性。仍用测试用例 stones = [2,7,4,1,8,1]sum = 23target = 11,演示一维DP数组(单行表格)的填充变化:

  1. 初始状态:dp = [0,0,0,0,0,0,0,0,0,0,0,0]

  2. 处理石头1(w=2),j从11到2倒序:更新后:dp = [0,0,2,2,2,2,2,2,2,2,2,2]

    • j=11:dp[11] = max(0, dp[9]+2) = max(0,0+2)=2;
    • j=10:dp[10] = max(0, dp[8]+2)=2;
    • ...(j=2到9同理);
    • j=2:dp[2] = max(0, dp[0]+2)=2;
  3. 处理石头2(w=7),j从11到7倒序:更新后:dp = [0,0,2,2,2,2,2,7,7,9,9,9]

    • j=11:max(2, dp[4]+7)=max(2,2+7)=9;
    • j=10:max(2, dp[3]+7)=max(2,2+7)=9;
    • j=9:max(2, dp[2]+7)=max(2,2+7)=9;
    • j=8:max(2, dp[1]+7)=max(2,0+7)=7;
    • j=7:max(2, dp[0]+7)=max(2,0+7)=7;
  4. 处理石头3(w=4),j从11到4倒序:更新后:dp = [0,0,2,2,4,4,6,7,7,9,9,11]

    • j=11:max(9, dp[7]+4)=max(9,7+4)=11;
    • j=10:max(9, dp[6]+4)=max(9,2+4)=9;
    • j=9:max(9, dp[5]+4)=max(9,2+4)=9;
    • j=8:max(7, dp[4]+4)=max(7,2+4)=7;
    • j=7:max(7, dp[3]+4)=max(7,2+4)=7;
    • j=6:max(2, dp[2]+4)=max(2,2+4)=6;
    • j=5:max(2, dp[1]+4)=max(2,0+4)=4;
    • j=4:max(2, dp[0]+4)=max(2,0+4)=4;
  5. 处理石头4(w=1),j从11到1倒序:更新后:dp = [0,1,2,3,4,5,6,7,8,9,10,11]

    • j=11:max(11, dp[10]+1)=max(11,9+1)=11;
    • j=10:max(9, dp[9]+1)=max(9,9+1)=10;
    • ...(其他位置类似更新);
  6. 处理石头5(w=8),j从11到8倒序:更新后:dp = [0,1,2,3,4,5,6,7,8,9,10,11]

    • j=11:max(11, dp[3]+8)=max(11,3+8)=11;
    • j=10:max(10, dp[2]+8)=max(10,2+8)=10;
    • j=9:max(9, dp[1]+8)=max(9,1+8)=9;
    • j=8:max(8, dp[0]+8)=max(8,0+8)=8;
  7. 处理石头6(w=1),j从11到1倒序:最终:dp = [0,1,2,3,4,5,6,7,8,9,10,11]

最终单行表格dp[11] = 11,剩余重量 = 23 - 2*11 = 1,与预期结果一致。

3.3.6 最后一块石头的重量II完整代码(一维DP)

/**
 * 最后一块石头的重量II(一维DP解法)
 * @param {number[]} stones - 石头重量数组
 * @returns {number} - 最后剩余石头的最小可能重量
 */
function lastStoneWeightII(stones) {
  const sum = stones.reduce((a, b) => a + b, 0);
  const target = Math.floor(sum / 2);
  // 初始化一维dp数组:dp[j]表示容量为j的背包能容纳的最大重量
  const dp = new Array(target + 1).fill(0);

  // 遍历顺序:先遍历石头,再倒序遍历容量
  for (let stone of stones) {
    for (let j = target; j >= stone; j--) {
      dp[j] = Math.max(dp[j], dp[j - stone] + stone);
    }
    console.log(`处理完石头${stone}后,dp数组:`, [...dp]);
  }

  // 剩余重量 = 总重量 - 2*最大子集重量
  return sum - 2 * dp[target];
}

// 测试用例
const stones = [2, 7, 4, 1, 8, 1];
console.log('最后剩余石头的最小重量:', lastStoneWeightII(stones)); // 输出:1

3.4 变形4:一和零(二维背包容量)

LeetCode 链接474. 一和零

问题描述:给你一个二进制字符串数组strs和两个整数mn。请你找出并返回strs的最大子集的长度,该子集中最多有m个0和n个1。

核心转化:每个字符串是一个"物品",选择该物品会消耗"0的数量"和"1的数量"两种容量,目标是在两种容量均不超过限制(m、n)的前提下,选择最多的物品——这是「二维容量01背包求最大物品数」的场景(背包有两个维度的容量限制,价值为1,求最大价值即最大物品数)。

一和零问题核心表格(空表,后续逐步填充):

0的数量\1的数量j 0 1 2 ...(1的数量递增) n(最大1的数量)
0(0的数量为0) 待填充 待填充 待填充 待填充 待填充
1(0的数量为1) 待填充 待填充 待填充 待填充 待填充
...(0的数量递增) 待填充 待填充 待填充 待填充 待填充
m(最大0的数量) 待填充 待填充 待填充 待填充 待填充(最终答案:最多字符串数量)

表格说明:表格中每个单元格dp[i][j]代表「最多使用i个0和j个1时,能选择的最大字符串数量」,我们的目标是按规则填充表格,最终右下角dp[m][n]即为一和零问题的答案。

3.4.1 步骤1:确定dp数组及下标的含义

定义二维数组dp[i][j]:表示「最多用i个0和j个1能选择的最大字符串数量」。

对应表格维度:i(行)表示0的数量(从0到m,0代表0个0),j(列)表示1的数量(从0到n,0代表0个1),表格共m+1行、n+1列。

3.4.2 步骤2:确定递推公式

对于每个字符串(含zero个0、one个1),有两种核心决策:选或不选。

  1. 不选当前字符串:最多用i个0和j个1的最大字符串数量 = 不选当前字符串时的最大数量,即dp[i][j] = dp[i][j](保持不变);

  2. 选当前字符串:需保证i ≥ zero且j ≥ one(0和1的数量都足够),此时最大数量 = 用i-zero个0和j-one个1的最大数量 + 1(当前字符串),即dp[i][j] = dp[i - zero][j - one] + 1

最终递推公式(取两种决策的最大值):

if (i >= zero && j >= one) {
  dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
} else {
  dp[i][j] = dp[i][j]; // 容量不足,无法选
}

简化后(因为容量不足时dp[i][j]不变,且使用倒序遍历):

for (let i = m; i >= zero; i--) {
  for (let j = n; j >= one; j--) {
    dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
  }
}

3.4.3 步骤3:dp数组如何初始化

初始化逻辑:初始无字符串可选,无论有多少0和1,最大字符串数量都为0,因此dp[i][j] = 0(所有单元格初始化为0),即dp = new Array(m+1).fill(0).map(() => new Array(n+1).fill(0))

初始化后的表格(所有单元格为0):

0的数量\1的数量j 0 1 2 ... n
0 0 0 0 0 0
1 0 0 0 0 0
... 0 0 0 0 0
m 0 0 0 0 0

结合示例理解:假设strs = ["10","0001","111001","1","0"],m = 5,n = 3。初始化后的表格(5+1行,3+1列):

0的数量\1的数量j 0 1 2 3
0 0 0 0 0
1 0 0 0 0
2 0 0 0 0
3 0 0 0 0
4 0 0 0 0
5 0 0 0 0

3.4.4 步骤4:确定遍历顺序(表格填充顺序)

二维容量01背包的遍历顺序有严格要求:

  1. 必须先遍历字符串(物品),再遍历0的数量,最后遍历1的数量:逐个处理每个字符串,每次处理时更新整个二维表格;

  2. 0和1的数量都必须倒序遍历

    • 0的数量倒序遍历(i从m到zero):确保计算dp[i][j]时,dp[i - zero][j - one]仍是上一轮(未处理当前字符串)的旧值;
    • 1的数量倒序遍历(j从n到one):同样确保依赖的单元格是旧值。

倒序遍历避免同一字符串被多次选择,完美契合01背包「每个物品选一次」的规则。

3.4.5 步骤5:打印dp数组(验证)

以示例strs = ["10","0001","111001","1","0"]m = 5n = 3为例,逐步填充表格验证逻辑:

  1. 处理字符串1("10":zero=1, one=1)

    • 更新dp[1][1]到dp[5][3]范围内所有满足i≥1且j≥1的位置
    • dp[1][1] = max(0, dp[0][0]+1) = max(0,0+1) = 1
    • dp[2][2] = max(0, dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)
  2. 处理字符串2("0001":zero=3, one=1)

    • 更新dp[3][1]到dp[5][3]范围内所有满足i≥3且j≥1的位置
    • dp[3][1] = max(dp[3][1], dp[0][0]+1) = max(0,0+1) = 1
    • dp[4][2] = max(dp[4][2], dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)
  3. 处理字符串3("111001":zero=2, one=4)

    • 由于one=4 > n=3,无法选择此字符串,dp数组不变
  4. 处理字符串4("1":zero=0, one=1)

    • 更新dp[0][1]到dp[5][3]范围内所有满足j≥1的位置
    • dp[0][1] = max(0, dp[0][0]+1) = max(0,0+1) = 1
    • dp[1][2] = max(dp[1][2], dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)
  5. 处理字符串5("0":zero=1, one=0)

    • 更新dp[1][0]到dp[5][3]范围内所有满足i≥1的位置
    • dp[1][0] = max(0, dp[0][0]+1) = max(0,0+1) = 1
    • dp[2][1] = max(dp[2][1], dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)

最终填充完成的表格(简化展示关键部分):

0的数量\1的数量j 0 1 2 3
0 0 1 1 1
1 1 2 2 2
2 1 2 3 3
3 1 2 3 3
4 1 2 3 4
5 1 2 3 4

表格右下角dp[5][3] = 4,即该示例的最大子集长度为4,与预期结果一致。

3.4.6 一和零完整代码(二维DP)

/**
 * 一和零(二维DP解法)
 * @param {string[]} strs - 二进制字符串数组
 * @param {number} m - 最多允许的0的数量
 * @param {number} n - 最多允许的1的数量
 * @returns {number} - 最大子集长度
 */
function findMaxForm(strs, m, n) {
  // 初始化二维dp数组:dp[i][j]表示i个0和j个1能选的最大字符串数
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 遍历每个字符串(物品)
  for (let str of strs) {
    // 统计当前字符串的0和1的数量
    let zero = 0,
      one = 0;
    for (let c of str) {
      c === '0' ? zero++ : one++;
    }

    // 倒序遍历0的数量,再倒序遍历1的数量(避免重复选择)
    for (let i = m; i >= zero; i--) {
      for (let j = n; j >= one; j--) {
        dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
      }
    }

    // 打印每次处理后的dp数组(简化打印,只打印部分关键行)
    console.log(`处理完字符串"${str}"后,dp数组(前5行前5列):`);
    for (let i = 0; i <= Math.min(m, 5); i++) {
      console.log(dp[i].slice(0, Math.min(n, 5)).join('\t'));
    }
  }

  return dp[m][n];
}

// 测试用例
const strs = ['10', '0001', '111001', '1', '0'];
const m = 5,
  n = 3;
console.log('最大子集长度:', findMaxForm(strs, m, n)); // 输出:4

四、01背包问题总结

01背包的核心是「选或不选」的二选一决策,所有变形都围绕这一核心逻辑,通过转化「物品」「容量」「目标」的含义,适配不同的实际场景。掌握以下关键点,可轻松破解所有01背包相关问题:

  1. 表格可视化核心:DP解题的本质是填充表格,先明确表格形态(dp数组含义),再按规则填充,表格填完即得答案;

  2. 5步万能钥匙:确定dp含义→递推公式→初始化→遍历顺序→验证,这是所有DP问题的通用拆解思路,尤其适用于背包问题;

  3. 空间优化技巧:二维DP可通过「倒序遍历容量」优化为一维DP,核心是复用数组空间,避免重复选择物品;

  4. 变形转化逻辑:无论场景如何变化,只要每个物品最多选一次,都可转化为01背包模型——关键是找到「物品」(待选择的元素/字符串等)、「容量」(限制条件,如重量、和、0/1数量等)、「目标」(最大价值、可行性、方案数等)。

通过基础模型+变形练习,熟练掌握表格填充逻辑和5步拆解方法,就能将复杂的DP问题转化为有序的表格填充过程,彻底攻克01背包这一DP核心模型。

用填充表格法吃透01背包及其变形-2

作者 颜酱
2026年1月6日 11:39

dp2.png

2.3 步骤3:dp数组如何初始化

初始化逻辑与二维一致:容量为0时,最大价值为0,因此dp[0] = 0;其他容量的初始值也为0(因为初始无物品可放,最大价值为0),即dp = new Array(capacity + 1).fill(0)

初始化后的单行表格:[0,0,0,0,0,0,0,0,0](j从0到8)

2.4 步骤4:确定遍历顺序(表格填充顺序)

一维DP的遍历顺序有严格要求,核心是「倒序遍历容量」,对应单行表格的「从右往左填充」——明确单行表格的填充顺序是避免重复选择物品的关键:

  1. 必须先遍历物品,再遍历容量:逐个处理每个物品,每次处理时更新整个单行表格(覆盖上一行结果);

  2. 容量必须倒序遍历(j从C到weights[i-1]):从最大容量往小容量填充,确保计算dp[j]时,dp[j - weights[i-1]]仍是上一行(未处理当前物品)的旧值,避免同一物品被多次选择。

关键原因:一维DP的核心是用单行表格复用二维表格的空间,表格中每个位置的数值都依赖“上一轮未更新的旧值”(对应二维的dp[i-1][j - w[i]])。若正序遍历容量,dp[j - w[i]]会被提前更新(相当于二维的dp[i][j - w[i]]),导致同一个物品被多次选择(变成完全背包);倒序遍历能保证计算dp[j]时,dp[j - w[i]]仍是上一行(未选当前物品)的结果,对应单行表格从右往左填充,完美契合01背包「每个物品选一次」的规则。

反例(正序遍历容量):若j从weights[i-1]到C正序遍历,处理物品1(w=2,v=3)时,j=2会更新dp[2]=3,j=4时会用到dp[2]的新值(3),计算dp[4] = dp[4] + 3 = 3,相当于把物品1放入了两次,违背01背包规则。

2.5 步骤5:打印dp数组(验证)

通过打印单行表格的滚动更新过程,验证填充规则的正确性。仍用测试用例 weights = [2,3,4,5]values = [3,4,5,6]capacity = 8,演示一维DP数组(单行表格)的填充变化:

  1. 初始状态:dp = [0,0,0,0,0,0,0,0,0]

  2. 处理物品1(w=2,v=3),j从8到2倒序

    更新后:dp = [0,0,3,3,3,3,3,3,3]

    • j=8:dp[8] = max(0, dp[8-2]+3) = max(0,0+3)=3;

    • j=7:dp[7] = max(0, dp[5]+3)=3;

    • ...(j=2到6同理);

    • j=2:dp[2] = max(0, dp[0]+3)=3;

  3. 处理物品2(w=3,v=4),j从8到3倒序

    更新后:dp = [0,0,3,4,4,7,7,7,7]

    • j=8:max(3, dp[5]+4)=max(3,3+4)=7;

    • j=7:max(3, dp[4]+4)=max(3,3+4)=7;

    • j=6:max(3, dp[3]+4)=max(3,3+4)=7;

    • j=5:max(3, dp[2]+4)=max(3,3+4)=7;

    • j=4:max(3, dp[1]+4)=max(3,0+4)=4;

    • j=3:max(3, dp[0]+4)=max(3,0+4)=4;

  4. 处理物品3(w=4,v=5),j从8到4倒序

    更新后:dp = [0,0,3,4,5,5,8,9,9]

    • j=8:max(7, dp[4]+5)=max(7,4+5)=9;

    • j=7:max(7, dp[3]+5)=max(7,4+5)=9;

    • j=6:max(7, dp[2]+5)=max(7,3+5)=8;

    • j=5:max(7, dp[1]+5)=max(7,0+5)=7;

    • j=4:max(4, dp[0]+5)=max(4,0+5)=5;

  5. 处理物品4(w=5,v=6),j从8到5倒序

    更新后:dp = [0,0,3,4,5,6,8,9,10]

    • j=8:max(9, dp[3]+6)=max(9,4+6)=10;

    • j=7:max(9, dp[2]+6)=max(9,3+6)=9;

    • j=6:max(8, dp[1]+6)=max(8,0+6)=8;

    • j=5:max(5, dp[0]+6)=max(5,0+6)=6;

最终单行表格dp[8] = 10,与二维DP结果一致,验证了优化的正确性。

2.6 一维DP空间优化完整代码(JavaScript)

/**
 * 基础01背包(一维DP空间优化解法)
 * @param {number[]} weights - 物品重量数组
 * @param {number[]} values - 物品价值数组
 * @param {number} capacity - 背包最大容量
 * @returns {number} - 背包能容纳的最大价值
 */
function knapsack_1d(weights, values, capacity) {
  const n = weights.length;
  // 1. 初始化一维dp数组:dp[j]表示容量j的背包的最大价值,初始值0
  const dp = new Array(capacity + 1).fill(0);

  // 2. 遍历顺序:先遍历物品(i从0到n-1),再倒序遍历容量(j从capacity到weights[i])(从右往左填充)
  for (let i = 0; i < n; i++) {
    // 倒序遍历避免重复选择当前物品
    for (let j = capacity; j >= weights[i]; j--) {
      // 3. 递推公式:不选当前物品的最大价值 vs 选当前物品的最大价值
      dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
    }
    // 打印每次处理物品后的dp数组(单行表格更新过程)
    console.log(`处理完物品${i + 1}后,dp数组:`, [...dp]);
  }

  // 最终答案:容量为capacity的背包的最大价值
  return dp[capacity];
}

// 测试用例
const weights1 = [2, 3, 4, 5];
const values1 = [3, 4, 5, 6];
const capacity1 = 8;
console.log('最大价值:', knapsack_1d(weights1, values1, capacity1)); // 输出:10

Node.js 事件循环(Event Loop)详解

作者 明月_清风
2026年1月6日 11:18

Node.js 是单线程的 JavaScript 运行时,但它能高效处理大量并发 I/O 操作(如网络请求、文件读写),核心机制就是事件循环。事件循环由底层库 libuv 实现,它允许 Node.js 在不阻塞主线程的情况下处理异步任务。

为什么需要事件循环?

  • JavaScript 是单线程的,同步代码会阻塞执行。
  • 但大多数操作(如网络 I/O)是耗时的,如果阻塞主线程,程序就无法响应其他请求。
  • Node.js 将异步操作“卸载”到系统内核或线程池,完成后通过回调通知主线程。
  • 事件循环负责不断检查是否有完成的异步任务,并执行对应的回调函数,从而实现非阻塞 I/O

简单来说:主线程执行同步代码 → 遇到异步操作 → 交给 libuv 处理 → libuv 完成时将回调放入队列 → 事件循环轮询队列并执行回调。

事件循环的阶段(Phases)

Node.js 的事件循环分为 6 个主要阶段(基于 libuv),它们按顺序循环执行。每轮循环(称为一个 “tick”)会依次进入这些阶段:

  1. timers(定时器阶段)
    执行已到期的 setTimeout()setInterval() 回调。
    注意:定时器不是精确的,只保证“至少”在指定时间后执行(可能因其他任务延迟)。

  2. pending callbacks(待定回调阶段)
    执行某些系统级 I/O 回调(如 TCP 错误报告)。

  3. idle, prepare(闲置/准备阶段)
    Node.js 内部使用,仅用于准备下一个阶段。

  4. poll(轮询阶段)
    最重要、最复杂的阶段:

    • 检索新的 I/O 事件(网络、文件等)。
    • 执行 I/O 相关的回调(几乎所有异步 I/O 回调在这里处理)。
    • 如果没有定时器,会在这里阻塞等待新事件到来。
    • 如果有 setImmediate(),会尽快进入 check 阶段。
  5. check(检查阶段)
    执行 setImmediate() 回调。

  6. close callbacks(关闭回调阶段)
    执行关闭事件的回调(如 socket.close())。

循环流程简图

timers → pending callbacks → idle/prepare → poll → checkclose callbacks → (返回 timers)

每轮循环结束后,Node.js 会检查是否还有待处理的异步任务。如果没有,进程会优雅退出。

微任务(Microtasks)和 nextTick 的特殊处理

  • 与浏览器不同,Node.js 的微任务(如 Promise.then()queueMicrotask())和 process.nextTick() 在每个阶段结束后、进入下一个阶段前执行
  • 优先级:process.nextTick() > Promise(微任务队列)。
  • 这意味着微任务会“插队”在宏任务(阶段回调)之间执行,确保更高优先级。

注意变化(从 Node.js 20+ / libuv 1.45.0 开始):定时器回调现在只在 poll 阶段后执行(以前可能在 poll 前后都检查)。

执行顺序示例

考虑以下代码:

console.log('start');

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

Promise.resolve().then(() => console.log('promise'));

process.nextTick(() => console.log('nextTick'));

console.log('end');

输出通常是:

start
end
nextTick
promise
immediate  // 或 timeout(取决于 poll 阶段)
timeout    // 或 immediate
  • 同步代码先执行:startend
  • 微任务立即执行:nextTickpromise
  • 然后进入事件循环:先 timers(timeout),或先 check(immediate),具体取决于 poll 阶段的行为。

与浏览器事件循环的区别

  • 浏览器:宏任务(macrotask,如 setTimeout)和微任务(microtask,如 Promise)交替执行,一个宏任务后清空所有微任务。
  • Node.js:有多个阶段的宏任务队列,微任务在每个阶段之间执行,更复杂,适合服务器端 I/O 密集场景。

实际建议

  • 避免在事件循环中执行 CPU 密集任务(如大循环计算),会阻塞其他回调,导致延迟。
  • 使用 process.nextTick() 或 Promise 处理高优先级异步逻辑。
  • 监控事件循环延迟(可用 perf_hooks)以优化性能。

理解事件循环是掌握 Node.js 异步编程的关键,能帮助你调试回调顺序、避免阻塞和构建高并发应用。

昨天以前首页

Vercel部署全攻略:从GitHub到上线,10分钟让你的前端项目免费拥有自己的域名

作者 冬奇Lab
2026年1月4日 22:57

写在前面

你有没有遇到过这样的情况:

你: 熬夜做了个酷炫的前端项目
朋友: 能给我看看吗?
你: 呃...你先在本地clone下来,然后npm install,再npm run dev...
朋友: 算了算了 (转身离开)

这就是没有部署上线的尴尬。你辛辛苦苦写的代码,躺在GitHub仓库里无人问津,想展示给别人看还得让对方搭建开发环境。

今天这篇文章,我将手把手教你如何用Vercel把你的前端项目部署到公网,让任何人都能通过一个链接访问你的作品。最重要的是:完全免费,无需服务器,10分钟搞定!

为什么选择Vercel?

在众多前端部署平台中(Netlify、GitHub Pages、Cloudflare Pages等),我为什么推荐Vercel?

Vercel的核心优势

特性 Vercel GitHub Pages 传统服务器
免费额度 ⭐⭐⭐⭐⭐ 个人用足够 ⭐⭐⭐⭐ 静态网站 ❌ 需付费
部署速度 ⭐⭐⭐⭐⭐ 秒级 ⭐⭐⭐ 分钟级 ⭐⭐ 需手动
CDN加速 ✅ 全球CDN ✅ GitHub CDN ❌ 需自己配置
自动部署 ✅ Git推送即部署 ✅ 推送到gh-pages ❌ 需CI/CD
环境变量 ✅ 支持 ❌ 不支持 ✅ 支持
自定义域名 ✅ 免费SSL ✅ 免费SSL ✅ 需自己配置SSL
框架支持 ⭐⭐⭐⭐⭐ 智能识别 ⭐⭐ 仅静态 ⭐⭐⭐⭐ 任意

最大亮点:

  • 零配置部署 - 自动识别Next.js、React、Vue等框架
  • 全球CDN - 访问速度飞快
  • 预览环境 - 每个PR都有独立预览URL
  • 回滚机制 - 一键回退到任意历史版本

💡 我踩过的坑: 刚开始我用传统VPS部署前端,每次更新都要SSH登录、git pull、npm build、重启Nginx...烦不胜烦。换到Vercel后,只需git push,剩下的全自动!


前置准备:你需要什么?

在开始之前,请确保你已经准备好:

必备条件

✅ 一个GitHub账号
✅ 一个前端项目(React/Vue/Next.js等)
✅ 项目代码已推送到GitHub仓库
✅ (可选)一个自己的域名

支持的前端框架

Vercel对以下框架有原生支持,可以零配置部署:

  • Next.js - Vercel亲儿子,完美支持
  • React (Create React App, Vite)
  • Vue (Vue CLI, Vite)
  • Angular
  • Svelte
  • Nuxt.js
  • 纯静态HTML/CSS/JS

第一步:注册Vercel账号

1.1 访问Vercel官网

打开浏览器,访问 vercel.com

Vercel官网首页,点击右上角的"Sign Up"按钮

1.2 使用GitHub账号登录

强烈推荐使用GitHub账号登录,这样可以直接授权访问你的仓库,省去后续连接的麻烦。

点击 "Continue with GitHub":

选择GitHub登录方式

1.3 授权Vercel访问GitHub

首次登录时,GitHub会要求你授权Vercel访问你的仓库。

授权范围:
✅ 读取仓库列表
✅ 读取仓库代码
✅ 添加部署状态(在PR中显示部署预览)

点击 "Authorize Vercel" 完成授权:

授权Vercel访问你的GitHub仓库

⚠️ 隐私说明: Vercel只会读取你主动导入的仓库,不会访问其他私有仓库。


第二步:导入GitHub项目到Vercel

2.1 进入项目导入页面

登录成功后,你会看到Vercel的Dashboard。点击 "Add New...""Project":

vercel-add-project.png

Dashboard页面,点击添加新项目

2.2 选择要部署的仓库

Vercel会列出你GitHub账号下的所有仓库。找到你想部署的项目,点击 "Import":

从GitHub仓库列表中选择项目

找不到你的仓库?

可能有以下原因:

❌ 仓库是私有的,但未授权Vercel访问
   → 解决: 去GitHub Settings重新授权

❌ 仓库属于Organization,需要额外授权
   → 解决: 在Organization设置中授权Vercel

❌ 仓库名称被搜索框过滤了
   → 解决: 清空搜索框或手动输入仓库名

2.3 配置项目设置

导入项目后,会进入配置页面。Vercel会自动检测你的项目类型和构建命令。

vercel-project-config.png

Vercel自动检测到的项目配置

核心配置项说明

1. Project Name (项目名称)

默认: 你的GitHub仓库名
用途: 决定默认的vercel域名 (如 my-app.vercel.app)

建议: 使用简洁、有意义的名称

2. Framework Preset (框架预设)

Vercel会自动识别你的框架:

检测到的框架 自动配置
Next.js Build: next build, Output: .next
Create React App Build: npm run build, Output: build
Vite Build: npm run build, Output: dist
Vue CLI Build: npm run build, Output: dist

💡 智能识别: Vercel会读取你的package.json来判断框架类型。

3. Root Directory (根目录)

默认: ./
用途: 如果你的前端代码在子目录(如monorepo),在这里指定

示例:
monorepo-project/
├── packages/
│   ├── frontend/   ← 前端代码在这里
│   └── backend/
└── package.json

配置: ./packages/frontend

4. Build Command (构建命令)

这是最重要的配置!Vercel会执行这个命令来构建你的项目。

常见框架的构建命令:

# Next.js
next build

# Create React App
npm run build

# Vite (React/Vue)
vite build
# 或
npm run build

# Vue CLI
vue-cli-service build

# 自定义脚本
npm run build:prod

如何确认你的构建命令?

打开项目的package.json,查看scripts字段:

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",    ← 这就是构建命令
    "preview": "vite preview"
  }
}

在Vercel配置中填写: npm run build

5. Output Directory (输出目录)

构建完成后,静态文件的输出位置。

常见框架的输出目录:

框架 默认输出目录
Next.js .next
Create React App build
Vite dist
Vue CLI dist
Angular dist/<project-name>

如何确认输出目录?

在本地运行构建命令:

npm run build

查看生成的文件夹名称,那就是输出目录!

6. Install Command (安装命令)

默认情况下,Vercel会自动检测并使用:

  • npm install (如果有package-lock.json)
  • yarn install (如果有yarn.lock)
  • pnpm install (如果有pnpm-lock.yaml)

通常不需要修改

环境变量配置(可选)

如果你的项目需要环境变量(如API密钥、后端地址),在这里配置:

vercel-env-vars.png

添加环境变量

示例:

Name: VITE_API_URL
Value: https://api.example.com

Name: VITE_APP_TITLE
Value: My Awesome App

💡 提示:

  • Vite项目的环境变量需要VITE_前缀
  • Create React App项目需要REACT_APP_前缀
  • Next.js项目需要NEXT_PUBLIC_前缀(如果要在客户端访问)

2.4 开始部署

配置完成后,点击底部的 "Deploy" 按钮:

点击Deploy按钮开始部署


第三步:等待构建和部署

3.1 构建过程实时日志

点击部署后,Vercel会显示实时构建日志:

vercel-build-logs.png

实时构建日志,可以看到每一步的执行情况

构建流程:

1. Cloning repository       ← 从GitHub克隆代码
2. Analyzing dependencies    ← 分析依赖关系
3. Installing dependencies   ← 安装npm包 (最耗时)
4. Building application      ← 执行构建命令
5. Uploading build output    ← 上传到CDN
6. Deploying to production   ← 部署完成!

首次部署通常需要2-5分钟,取决于你的项目大小和依赖数量。

3.2 部署成功!

部署成功时会有个庆祝界面,恭喜,部署成功了! 🎉

部署成功页面,显示你的项目URL

你会得到一个默认域名,格式为:

https://your-project-name.vercel.app

立即访问测试!

点击 "Visit" 按钮,或者直接在浏览器中打开这个URL,看看你的项目是否正常运行。

3.3 部署失败?别慌,看日志!

如果部署失败,Vercel会显示详细的错误信息:

常见错误及解决方案:

错误1: Command "npm run build" exited with 1

原因: 构建命令执行失败
解决:
1. 检查package.json中的build脚本是否正确
2. 在本地运行`npm run build`看是否有错误
3. 检查是否缺少必要的环境变量

错误2: Error: Cannot find module 'xxx'

原因: 缺少依赖包
解决:
1. 确认依赖包在package.json的dependencies中(不是devDependencies)
2. 本地删除node_modules重新安装测试
3. 检查package-lock.json是否提交到GitHub

错误3: Build exceeded maximum duration of 45 minutes

原因: 构建超时(免费版限制45分钟)
解决:
1. 优化构建配置,减少不必要的依赖
2. 使用.vercelignore排除不需要的文件
3. 考虑升级到Pro版(300分钟限制)

错误4: FATAL ERROR: Reached heap limit

原因: Node.js内存不足
解决:
在项目根目录创建vercel.json:
{
  "builds": [
    {
      "src": "package.json",
      "use": "@vercel/node",
      "config": {
        "maxLambdaSize": "50mb"
      }
    }
  ]
}

第四步:配置自动部署

部署成功后,最酷的功能来了:自动部署!

4.1 Git自动部署原理

工作流程:
你在本地修改代码
    ↓
git commit & git push
    ↓
Vercel检测到GitHub仓库更新
    ↓
自动触发新的部署
    ↓
几分钟后新版本自动上线!

完全不需要手动操作!


第五步:配置自定义域名(进阶)

默认的vercel.app域名虽然能用,但不够专业。让我们配置一个自己的域名!

5.1 前置条件

✅ 你已经拥有一个域名(如从腾讯云、阿里云、GoDaddy购买)
✅ 能够访问域名的DNS管理后台

5.2 在Vercel中添加域名

进入项目的 SettingsDomains:

vercel-add-domain.png

在Domains设置页面添加自定义域名

输入你的域名,如 blog.example.com,然后点击 "Add"

Vercel会显示需要配置的DNS记录:

vercel-dns-instructions.png

Vercel提供的DNS配置说明

两种域名类型:

类型1: 根域名(Apex Domain)

示例: example.com

需要配置:
A记录: example.com → 76.76.21.21

类型2: 子域名(Subdomain)

示例: blog.example.com 或 www.example.com

需要配置:
CNAME记录: blog → cname.vercel-dns.com

5.3 在腾讯云DNS配置域名解析

我以腾讯云DNSPod为例,演示配置过程。(阿里云、Cloudflare等平台操作类似)

步骤1: 登录腾讯云DNSPod

访问 console.dnspod.cn,登录后选择你的域名:

步骤2: 添加CNAME记录

点击 "添加记录":

配置示例(子域名):

记录类型: CNAME
主机记录: blog    (如果是www则填www)
记录值: 复制Vercel中添加的域名中的CNAME对应的Value指
TTL: 600 (10分钟,可以默认)

配置示例(根域名):

记录类型: A
主机记录: @      (@ 表示根域名)
记录值: 76.76.21.21  (Vercel提供的IP地址)
TTL: 600

点击 "保存"

步骤3: 等待DNS生效

DNS记录通常需要几分钟到几小时才能全球生效。

如何检查DNS是否生效?

在命令行中执行:

# 检查CNAME记录
nslookup blog.example.com

# 或使用dig命令
dig blog.example.com

# 应该看到返回:
# blog.example.com.   IN  CNAME   cname.vercel-dns.com.

5.4 在Vercel中验证域名

回到Vercel的Domains设置页面,等待系统自动验证:

验证成功后,会显示:域名配置成功,自动启用HTTPS

Vercel自动提供:

  • ✅ 免费SSL证书(Let's Encrypt)
  • ✅ 自动续期
  • ✅ 强制HTTPS跳转

5.5 测试自定义域名

在浏览器中访问你的自定义域名:

https://blog.example.com

成功! 🎉

同时你还会发现:

  • 默认域名your-app.vercel.app仍然可用
  • HTTP自动跳转到HTTPS
  • 加载速度飞快(全球CDN加速)

第六步:项目管理和运维

6.1 查看部署历史

在项目的 Deployments 页面,可以看到所有的部署记录:

vercel-deployments.png

所有历史部署记录

每个部署都有:

  • 唯一的URL
  • 部署时间
  • Git commit信息
  • 构建日志
  • 访问统计

6.2 一键回滚

如果新版本有问题,可以瞬间回退到任意历史版本:

回滚流程:

1. 找到你想回退到的版本
2. 点击右侧的 ⋯ 按钮
3. 选择"Promote to Production"
4. 几秒钟后,旧版本重新上线!

6.3 访问统计

Analytics 页面查看访问数据:

vercel-analytics.png

Vercel Analytics提供实时访问数据

免费版数据:

  • 访问量(PV/UV)
  • 地理分布
  • 设备类型
  • 浏览器类型

常见问题排查

Q1: 部署成功但页面404

问题现象:

访问首页: https://my-app.vercel.app  ✅ 正常
访问子页面: https://my-app.vercel.app/about  ❌ 404

原因: 前端路由(React Router/Vue Router)需要服务器配置支持。

解决方案:

在项目根目录创建vercel.json:

{
  "rewrites": [
    {
      "source": "/(.*)",
      "destination": "/index.html"
    }
  ]
}

这会将所有请求重定向到index.html,让前端路由接管。

Q2: 静态资源加载失败(404)

问题现象:

Console错误:
GET https://my-app.vercel.app/assets/logo.png 404

原因: 静态资源路径配置错误。

解决方案:

Vite项目:

修改vite.config.js:

export default {
  base: '/',  // 确保base是 '/' 而不是相对路径
  build: {
    outDir: 'dist',
  }
}

Create React App:

修改package.json:

{
  "homepage": "https://my-app.vercel.app"
}

Q3: 环境变量未生效

问题现象:

console.log(import.meta.env.VITE_API_URL)
// 输出: undefined

排查清单:

 环境变量名称错误(缺少前缀)
    Vite: 必须以VITE_开头
    CRA: 必须以REACT_APP_开头
    Next.js: 必须以NEXT_PUBLIC_开头(客户端使用)

 环境变量未在Vercel中配置
    进入Settings  Environment Variables添加

 配置后未重新部署
    修改环境变量后需要触发新部署才能生效

Q4: 构建时间过长

优化策略:

1. 使用.vercelignore排除不必要文件

创建.vercelignore:

.git
*.md
.vscode
.idea
tests
docs

2. 启用依赖缓存

Vercel默认会缓存node_modules,但可以优化:

// vercel.json
{
  "github": {
    "silent": true
  },
  "build": {
    "env": {
      "NODE_OPTIONS": "--max_old_space_size=4096"
    }
  }
}

3. 并行构建

如果是monorepo,可以配置并行构建:

{
  "builds": [
    { "src": "package.json", "use": "@vercel/static-build", "config": { "parallel": 3 } }
  ]
}

Q5: 自定义域名HTTPS证书错误

问题现象:

浏览器显示"您的连接不是私密连接"。

解决步骤:

1. 检查DNS是否生效 (nslookup your-domain.com)
2. 在Vercel中删除域名,重新添加
3. 等待几分钟让Let's Encrypt重新签发证书
4. 清除浏览器缓存和SSL状态

进阶技巧

技巧1: 使用Vercel CLI本地开发

安装Vercel CLI:

npm i -g vercel

在本地模拟Vercel环境:

# 链接到Vercel项目
vercel link

# 下载环境变量
vercel env pull

# 本地运行(使用生产环境配置)
vercel dev

优势:

✅ 本地使用Vercel的环境变量
✅ 模拟Vercel的Serverless Functions
✅ 测试rewrite/redirect规则

技巧2: 配置多域名

一个项目可以绑定多个域名:

example.com          → 主域名
www.example.com      → 自动跳转到主域名
blog.example.com     → 独立访问

在Vercel Domains设置中添加多个域名即可。

技巧3: 自定义构建缓存

优化构建速度:

// vercel.json
{
  "build": {
    "env": {
      "ENABLE_EXPERIMENTAL_COREPACK": "1",
      "NEXT_PRIVATE_CACHE_HANDLER": "1"
    }
  },
  "crons": [
    {
      "path": "/api/clear-cache",
      "schedule": "0 0 * * *"
    }
  ]
}

技巧4: 配置Redirect规则

SEO优化和URL管理:

{
  "redirects": [
    {
      "source": "/old-blog/:slug",
      "destination": "/blog/:slug",
      "permanent": true
    },
    {
      "source": "/docs",
      "destination": "/documentation",
      "permanent": false
    }
  ]
}

技巧5: 配置HTTP Headers

安全和性能优化:

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "X-Frame-Options",
          "value": "DENY"
        },
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        },
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ]
}

成本和限制

Hobby(免费)计划

✅ 无限项目
✅ 无限部署
✅ 100GB带宽/月
✅ 1000Serverless Function调用/天
✅ 自动SSL证书
✅ 全球CDN

❌ 团队协作(仅限个人)
❌ 商业使用

Pro计划($20/月)

+ Hobby所有功能
+ 1TB带宽/月
+ 无限Serverless调用
+ 团队协作(无限成员)
+ 优先级支持
+ 密码保护
+ 分析和日志保留更长

什么时候需要升级Pro?

✅ 月访问量超过100GB带宽
✅ 需要团队协作开发
✅ 需要密码保护预览环境
✅ 需要更详细的访问分析

对于个人项目和小型网站,免费计划完全够用!


总结:Vercel部署检查清单

部署前检查

✅ 代码已推送到GitHub
✅ package.json中的dependencies正确
✅ 本地执行npm run build成功
✅ 构建产物在正确的输出目录
✅ 环境变量整理完毕
✅ .gitignore包含node_modules和构建产物

部署配置检查

✅ Framework Preset正确识别
✅ Build Command配置正确
✅ Output Directory配置正确
✅ Root Directory配置正确(如果不是根目录)
✅ Environment Variables添加完整

部署后检查

✅ 首页能正常访问
✅ 前端路由跳转正常(多页应用)
✅ 静态资源加载正常
✅ API请求正常(如果有后端)
✅ 环境变量生效
✅ 移动端响应式正常

自定义域名检查

✅ DNS记录配置正确
✅ DNS已生效(nslookup检查)
✅ Vercel中域名验证成功
✅ HTTPS证书自动签发
✅ HTTP自动跳转HTTPS
✅ www和非www都能访问(如果需要)

下一步行动

今天就开始:

  • 注册Vercel账号
  • 选择一个项目进行部署
  • 配置自动部署
  • (可选)绑定自定义域名

本周任务:

  • 将所有前端项目迁移到Vercel
  • 配置PR预览环境
  • 优化构建配置
  • 设置性能监控

长期优化:

  • 使用Vercel Analytics分析用户行为
  • 根据Web Vitals优化性能
  • 探索Serverless Functions(Vercel的后端能力)
  • 学习Vercel的Edge Functions(边缘计算)

相关资源

官方文档:

社区资源:

对比参考:


从今天开始,让你的前端项目走出本地,面向世界!记住:部署不是结束,而是你的项目真正开始被使用的起点

现在就行动,10分钟后,你的作品将拥有一个全球可访问的URL! 🚀


这篇文章对你有帮助吗?分享你的Vercel部署经验,或者在评论区提问!

0成本、0代码、全球CDN:Vercel + Notion快速搭建个人博客

2026年1月4日 17:31

大家好,我是凌览

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。

前言

搭个博客,五分钟就能跑起来,但长期维护困难。

第一年有新人补贴,阿里云轻量服务器只要百来块,再配个域名,全套两百搞定,便宜得像白捡。

可优惠券一到期,账单立刻变脸:续费价直接翻倍,低配机型也要六百起跳。

若搭建的网站无法带来收益,对于大多数人会选择不在继续续费。

本文推荐一个开源项目,利用 Vercel 与 Notion 快速搭建网站,仅需自行设置域名即可上线。

NotionNext

NotionNext是作者tangly1024在Github上开源的基于Next.js框架开发的博客生成器。目的是帮助写作爱好者们通过Notion笔记快速搭建一个独立站,从而专注于写作、而不需要操心网站的维护。

它将您的Notion笔记渲染成静态的博客页、并托管在Vercel云服务中。与Hexo静态博客相比较不同的是,您无需每次写好的文章都要推送到Github,编写与发布只在您的Notion笔记中完成。

依托于Notion强大的编辑功能,您可以随时随地撰写文章、记录你的创意与灵感,笔记会被自动同步至您的博客站点中。

它是一种几乎零成本的网站搭建方式,您只需要花费几十块钱购买域名的费用,就可以拥有一个独立的网站。

成功案例比如我的个人博客blog.code24.top/:

image.pngtangly1024 作者的网站blog.tangly1024.com/

image 1.png

NotionNext部署

作者已提供详细的NotionNext部署文档,按照文档指引即可完成部署。

项目采用MIT开源协议,用户可根据自身需求自由修改和定制UI界面。

对于国内用户而言,由于Notion网络访问存在不稳定因素,可能会出现连接超时的情况。虽然通过配置国内域名能够在一定程度上改善访问体验,但图片加载问题仍然存在,因为图片资源主要托管在Notion服务器上。

image 2.png

最后

我的个人网站基于 NotionNext 搭建,每年仅需支付域名费用,运维成本趋近于零。借助 Vercel 的免费托管与 Notion 的免费数据库,整套方案把服务器、带宽、证书、备份等开销全部省去,真正实现了“零服务器”部署。

VS Code 终端崩溃问题分析与解决方案

作者 凯哥1970
2026年1月3日 10:35

VS Code 终端崩溃问题分析与解决方案

错误代码:-2147023895 (0x800703E9)

显示如下

终端进程“C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe”已终止,退出代码: -2147023895。

问题描述

在 VS Code 中打开终端时,PowerShell 进程异常退出,返回错误代码 -2147023895。该错误会导致终端无法正常启动或使用,影响开发效率。


错误原因分析

错误代码 -2147023895 对应十六进制值 0x800703E9,是一个标准的 HRESULT 错误码,其结构解析如下:

  • 严重性位(Bit 31) :1,表示失败。
  • 设备代码(Bits 16-26) :7(FACILITY_WIN32),表示错误源自 Windows API 调用。
  • 低位代码(Bits 0-15)0x03E9(十进制 1001)。
可能的原因:
  1. 栈溢出(Stack Overflow)
    PowerShell 启动时脚本陷入无限递归,耗尽线程栈空间,触发系统异常。
  2. 文件完整性校验失败(Invalid Image Hash)
    Windows 代码完整性机制或安全软件(如 AppLocker)检测到脚本文件签名无效、文件损坏或哈希不匹配,导致加载被拒绝。
  3. 环境变量冲突
    脚本执行过程中展开的环境变量(如 PATH)过长,引发内存或栈溢出。

根本原因定位

多数情况下,该错误与 VS Code 终端 Shell 集成脚本 shellIntegration.ps1 有关。该脚本在终端启动时被自动加载,若文件损坏或与用户配置冲突,即会触发上述错误。


解决方案:手动替换脚本文件(治本)

无需禁用终端功能,直接替换损坏的脚本文件即可根治问题。

操作步骤:
  1. 定位脚本文件
    根据 VS Code 安装方式,找到目标目录:

    • 用户安装
      %LOCALAPPDATA%\Programs<EditorName>\resources\app\out\vs\workbench\contrib\terminal\common\scripts
    • 系统安装
      C:\Program Files\Microsoft VS Code\resources\app\out\vs\workbench\contrib\terminal\common\scripts
  2. 备份原文件
    将目录中的 shellIntegration.ps1 重命名为 shellIntegration.ps1.bak,作为备份。

  3. 下载官方脚本
    访问 VS Code 官方 GitHub 仓库,下载最新版本的脚本文件:

https://raw.githubusercontent.com/microsoft/vscode/refs/heads/main/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1
  1. 替换文件
    将下载的 shellIntegration.ps1 复制到步骤 1 的目录中,确保当前用户有读取权限。

  2. 重启验证
    完全关闭 VS Code(包括后台进程),重新启动并打开终端,检查是否恢复正常。


方案原理

通过替换为官方完好的脚本文件,确保:

  • PowerShell 解析器能正常解析语法,避免因文件损坏导致的崩溃。
  • 脚本与用户环境兼容,避免递归冲突或安全校验失败。
  • 保留完整的终端 Shell 集成功能(如命令装饰、状态提示等)。

注意事项

  • 若问题仍然存在,可检查用户 PowerShell 配置文件($PROFILE)中是否存在与 Shell 集成冲突的自定义代码。
  • 建议定期更新 VS Code,以获取官方修复的脚本版本。

通过以上步骤,可从根本上解决终端崩溃问题,无需临时禁用功能或修改启动命令,确保开发环境稳定可用。

HUNT0 上线了——尽早发布,尽早发现

作者 Justin3go
2026年1月2日 00:07

HUNT0 上线了——尽早发布,尽早发现

✨文章摘要(AI生成)

HUNT0 是一个面向 maker 和 indie hacker 的社区驱动发布目录,想把“发布”变成一件有仪式感、可被发现、可获得反馈的事:你可以预约发布日期,通过声望加权投票与评论拿到早期反馈,再通过榜单与 Explore(按筛选意图组织)快速发现值得关注的新产品。

v1.0.0 先把核心闭环跑通:launch → discover → vote → discuss → recap/reward。我们做了实时同步投票状态、结构化的提交流程(免费排队 + Premium Launch)、以及提醒/奖项等自动化机制,目标是减少噪音,让早期发现更“有章可循”。

一切都始于一张有点“丑”的草图:一把向前倾斜的梯子。

这张梯子草图最终成为了 HUNT0 的 logo。它提醒我们:进步是一格一格爬出来的——与其等完美,不如先把不完美的东西发出去。

那张后来变成 HUNT0 logo 的原始梯子草图

这也是口号 “Ship Early, Hunt Early” 的由来:创作者尽早发布,社区才能尽早发现——去浏览、投票、讨论,帮助好产品找到第一批用户。

如果你在公开构建(build in public),欢迎来这里发布;如果你喜欢探索新东西,就从“hunt”开始。

什么是 HUNT0?

HUNT0 是一个社区驱动的发布目录,让“发布”和“发现”同频发生:

  • 预约发布日期,把发布变成一件明确的事件
  • 通过投票与评论获得早期反馈
  • 用榜单与 Explore 按时间与兴趣组织“新产品”,而不是按噪音排序

上线 v1.0.0:我们做了什么

v1.0.0 先聚焦在核心闭环:launch → discover → vote → discuss → recap/reward

1)首页榜单:“今天该看什么?”

首页按时间窗口组织,方便你快速扫到值得关注的内容:

  • Top Products Launching Today:今天(UTC)发布的产品
  • Yesterday / This Week / This Month:按更长时间窗口回顾

投票状态会在页面内 实时同步:你在任意位置给某个产品投票,页面上的所有实例会立刻一起更新,无需刷新。

2)Explore:按意图筛选,而不是靠运气

Explore 支持 分类、标签(最多 10 个)、时间范围、全文搜索,并支持分页。

在相同筛选条件下,Premium Launch 会在排序上优先于免费发布——在“更需要曝光”的时刻更有效。

3)产品页:展示、访问、讨论一站式

每个产品都有独立详情页,便于更深入地了解与互动:

  • 核心信息与外链(Visit)
  • 截图画廊与更长的产品介绍(About)
  • 声望加权的投票与评论
  • 榜单/奖项徽章(例如日榜/周榜/月榜 Top 3)

4)提交:免费排队 + Premium Launch

我们把“发布”设计成一个可控流程,而不只是贴个链接:

  • Free Launch:每日容量有限(默认 10 个名额/天
  • Premium Launch:通过 Stripe Checkout 付费,获得更强曝光

如果 Premium 未完成支付,提交会以草稿形态保留在你的 dashboard,不会公开展示,直到支付成功。

提交也支持更丰富的展示信息:

  • 最多 3 个分类
  • 最多 10 个标签
  • 联系方式/社交链接(至少 1 个)
  • logo 与截图,让产品页更完整

声望系统:让贡献“有分量”

投票不是固定的一人一票。HUNT0 使用 Reputation → Level → Vote Weight

  • 通过参与获得声望(每日访问、投票、评论、发布)
  • 等级越高,投票权重越大
  • 榜单按加权投票聚合,更多反映可信贡献者的偏好

它既是激励机制,也是在早期社区里减少噪音的实用手段。

提醒与奖项:把发布当成“事件”

为了让发布更有“时刻感”,我们加了一些自动化:

  • 发布提醒邮件:在 UTC 发布日开始前 1 小时发送
  • 日榜/周榜/月榜奖项:自动计算 Top 3 并通知创作者(可选公开复盘)

开始使用

  • 如果你是创作者:去 Submit 预约发布日期
  • 如果你想发现新产品:去 Explore 按分类/标签筛选
  • 如果你想看 logo 的故事:去 About 看梯子的来源

我们会持续迭代发现、榜单和社区激励机制。Launch something, hunt something——也欢迎告诉我们哪里可以做得更好。

2025年度稀土掘金影响力榜单如约而至!

作者 掘金酱
2025年12月31日 15:57

稀土掘金社区2025年度影响力榜单正式公布

1303-734主视觉 (1) (3).jpg

时光向前,2025年即将落下句点。回首这一程,幸而有伴 —— 每一次深夜的打磨,每一段真诚的分享,每一回评论区里的倾力相助,都为技术探索的漫漫长路,点亮了一盏盏暖灯。

感谢这一年里,将实操经验凝注于代码、把深度思考落笔成文字的创作者。是每一个具体的 “你”,让稀土掘金始终活力满满。

今天,我们以这份榜单,记录那些值得被看见的光芒。它们来自团队扎实的沉淀,来自创作者持久的热情,也来自那些真正帮助过许多人的走心好文。

榜单地址aicoding.juejin.cn/pens/758993…

2025年度优秀创作者 | The Best 10 Creative Writers of 2025

他们是社区里的“解惑人”,把复杂讲得简单,把枯燥变得生动。他们的文字,曾陪伴无数掘友走出技术探索的迷茫时刻。

站内昵称 个人主页
Moment juejin.cn/user/378276…
ConardLi juejin.cn/user/394910…
何贤 juejin.cn/user/277499…
洛卡卡了 juejin.cn/user/888839…
why技术 juejin.cn/user/370281…
恋猫de小郭 juejin.cn/user/817692…
苏三说技术 juejin.cn/user/465848…
coder_pig juejin.cn/user/414261…
张风捷特烈 juejin.cn/user/149189…
德莱厄斯 juejin.cn/user/391911…

2025年度影响力团队 -The Most Influential Teams of 2025

他们以团队的力量,把一线实践沉淀成可复用的经验,如一张张清晰的技术地图,帮助不少同行找到了方向。

团队名称 团队主页
DevUI团队 juejin.cn/user/712139…
vivo互联网技术 juejin.cn/user/993614…
得物技术 juejin.cn/user/239295…
古茗前端团队 juejin.cn/user/323304…
货拉拉技术 juejin.cn/user/176848…
京东零售技术 juejin.cn/user/423357…
奇舞精选 juejin.cn/user/438890…
37手游后端团队 juejin.cn/user/154852…
哔哩哔哩技术 juejin.cn/user/303070…
转转技术团队 juejin.cn/user/606586…

2025年度爆款好文 | High hits articles in 2025

这20篇文章,是从海量分享中脱颖而出的“年度之选”。它们或视角新颖、或剖析深入、或实战性强,在各自领域内获得了认可,成了许多掘友收藏或推荐的那一篇。

文章标题 文章链接 所属作者
前端
《40岁老前端2025年上半年都学了什么?》 juejin.cn/post/752454… 张鑫旭
《前端仔如何在公司搭建 AI Review 系统》 juejin.cn/post/753259… 唐某人丶
《因为写了一个前端脚手架,这个月的KPI 打满了!!!》 juejin.cn/post/745793… 赵小川
《因网速太慢我把20M+的字体压缩到了几KB》 juejin.cn/post/749033… 古茗前端团队
《历经4个月,基于 Tiptap 和 NestJs 打造一款 AI 驱动的智能文档协作平台 🚀🚀🚀》 juejin.cn/post/755316… Moment
后端
《MCP 很火,来看看我们直接给后台管理系统上一个 MCP?》 juejin.cn/post/748149… Hamm
《还在用WebSocket实现即时通讯?试试MQTT吧,真香!》 juejin.cn/post/753935… MacroZheng
《我做了套小红书一键发布系统,运营小姐姐说她不想离开我了》 juejin.cn/post/755248… 洛卡卡了
《Java 实现责任链模式 + 策略模式:优雅处理多级请求的方式》 juejin.cn/post/745736… 后端出路在何方
《CompletableFuture还能这么玩》 juejin.cn/post/745556… 一只叫煤球的猫
移动端
《2025 跨平台框架更新和发布对比,这是你没看过的全新版本》 juejin.cn/post/750557… 恋猫de小郭
《月下载 40 万次的框架是怎么练成的?》 juejin.cn/post/754740… Android轮子哥
《[targetSDK升级为35] 恶心的EdgeToEdge适配 (v7)》 juejin.cn/post/749717… snwrking
《gson很好,但我劝你在Kotlin上使用kotlinx.serialization》 juejin.cn/post/745929… 沈剑心
《开箱即食Flutter通用脚手架》 juejin.cn/post/748278… SunshineBrother
人工智能
《一天 AI 搓出痛风伴侣 H5 程序,前后端+部署通吃,还接入了大模型接口(万字总结)》 juejin.cn/post/751749… 志辉AI编程
《AI 应用开发入门:前端也可以学习 AI》 juejin.cn/post/751719… 唐某人丶
《如何把你的 DeePseek-R1 微调为某个领域的专家?》 juejin.cn/post/747330… ConardLi
《3天,1人,从0到付费产品:AI时代个人开发者的生存指南》 juejin.cn/post/757765… HiStewie
《全网最细,一文带你弄懂 MCP 的核心原理!》 juejin.cn/post/749345… ConardLi

好的社区,是人与人相互照亮

这份榜单,与其说是评选,不如说是一次郑重的致谢。谢谢所有分享者,也谢谢每一位静静学习、默默点赞、热心评论的掘友。

技术之路,日常而长远。2026年,愿你继续在这里写下自己的章节,发出自己的光。我们相信,每一个人的微小光芒,终将汇聚成行业的星辰。

*注:以上排名不分先后,随机排序。本榜单依据2025年1月1日至12月21日期间的数据综合评定,涵盖文章质量、互动热度、创作者影响力、分类分布及评审团意见等多维度,最终解释权归稀土掘金所有。

lecen:一个更好的开源可视化系统搭建项目--页面设计器(表单设计器)--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一个懂你的人

作者 晴虹
2025年12月31日 17:12

页面设计

页面设计器的使用,主要界面栏目功能及操作说明。

我们在做系统页面的时候,可以不用像传统开发那样,必须在本地启动开发环境,设计你的页面或者组件等,这里提供了可视化构建页面的能力,让你能够在网页中通过鼠标点点点来创建一个新的系统页面。

它也是我们系统的核心内容,我们将通过页面设计器来设计出统一的代码格式组成的页面,然后再用统一的解析页面来渲染整个系统。

换而言之,系统的所有页面全都由设计器设计出来,并且系统的所有页面全部都由一个页面解析并渲染出来。

由页面设计器设计出来的页面,全部都是所见即所得,设计出来什么样子,实际就是什么样子,不会放大缩小或者变形,我们可以对页面进行任何的设置,并且都是在设计页面实时生效的,所有的效果都与实际页面一样,也就是说不用使用预览功能就可以做到预览与交互。

功能分区

拖拽式可视化编辑创建页面。设计器主要界面

页面设计器

整个设计器大体分为三个部分:

  1. 视图绘制区域:整个页面的结构,这里放页面的组件。
  2. 组件面板:相当于设计页面时所需的物料区,所有的组件都能在这里找到。
  3. 属性面板:组件的操作面板,可以设置它的属性、事件、插槽等等。

组件面板

组件面板给我们提供了经常用到的一些 UI 组件,默认使用element-plus来渲染,你也可以指定为naive-ui,甚至使用你自己创建的组件。

组件面板按钮

可直接使用鼠标点击相应图标,并拖拽到视图绘制区域。

主要分为三栏,从左到右分别为:

基本组件栏: 一般的表单元素组件,或者一些基本元素的组件。

复合组件栏: 包含嵌套结构的元素组件等,也就是由多个基本组件组成。

反馈组件栏: 系统提示弹窗组件等,与用户有较强交互效果的组件。

注意:

页面初始需要放置行列组件,也就是说所有组件都是在最外层的行列组件内。在创建一个新页面时,最开始需要拖入的是行列。

行列图标

就是区域2中的第一个组件,为了方便和显眼,我们把它放在了页面居中并偏左的地方,而且为它设置了一点颜色。

行列可以嵌套行列,以达到无限布局

组件面板提供了非常多的组件,还有一些隐藏组件没有显示出来,只有等到用到他们的时候才会自动展示出来。

将鼠标放到某个组件的右上角,会显示相应的提示信息,当通过外观分辨不出是什么组件的时候,可以通过该种方式查看组件的名称。

组件名称提示

快捷操作

为了进行快速的操作,节省页面设计时间,也是为了更友好的交互,提供了一些快捷键,在实际使用中会经常用到。

按键 说明
Alt 唤出右侧属性面板
空格 唤出底部组件面板
Shift + 空格 隐藏组件面板和属性面板
Shift + 上/下/左/右 切换组件面板位置
Shift + A 唤出/隐藏 组件面板(修改层级)
Shift + D 唤出/隐藏 属性面板(修改层级)

视图绘制区

视图绘制

通过拖拽组件区中的行列组件,我们将它放置到视图绘制区中,这样就可以往行列组件中拖拽任意的组件了。 比如这里我们首先放置了一个新增按钮,然后又放置了一个查询输入框,最后我们又放置了一个查询的按钮。 因为查询按钮是最后放置的,因此目前默认它就是可编辑的状态,所有的设置都会对这个按钮生效。

默认情况下,被拖入的空行会用虚线显示出来,以方便查看和拖入其他组件,实际做好的页面中空行不会显示出来。

空行显示虚线

只要行组件被放置了其他组件,那么该行的虚线轮廓示意将会自动消失,显示成它实际该有的样子。

除此之外,还提供了右键的功能,比如我们在行列组件上右键的话,就会出现下面这样:

行列右键

移除该行 的选项是高亮的,表示我们可以操作,点击后就会将该行以及它所包含的所有内容,全部从页面上删除掉。

填入该列 的选项是置灰的,表示我们目前不可以操作,这是因为还没有选中元素,如果有选中元素的话,那么使用该项就会把选中的元素填入到该列当中。

如果我们将右键放到其他组件上面使用的话,会出现不一样的效果:

组件右键

移除 表示将该组件从当前页面移除掉。

移动 表示我们将要移动该组件,点击之后,该组件将处于可移动状态,也就是被标记为将要移动的状态。

放在前面 如果我们目前有可移动组件,那么我们可以将它放置到该组件的前面

放在后面 如果我们目前有可移动组件,那么我们可以将它放置到该组件的后面

比如我们在新增按钮上面右键,选择移动,然后在查询按钮上面右键,选择放在后面,那么页面就会变成这样:

移动组件

而且我们会遇到一些特殊的情况,当你想要编辑一个下拉框的时候,如果是点击下拉框,那么并不会弹出对应的属性面板,而是展开下拉选项

点击下拉框

这时我们就需要一个特殊的手段来处理它。

因此我们给这种特殊的组件增加了右键选中的功能

右键下拉框

其他组件,比如按钮,是没有右键的选中菜单的,因为这类组件直接点击就可以进行编辑

按钮右键

但是也有特殊情况,比如我们给按钮绑定了一些事件,但是我只想编辑属性而不是触发事件,这时能够通过按住ctrl再点击右键来强制调出选中项:

右键强制选中

选择之后就能够编辑对应的组件了:

选中组件

由于我们拖拽的是一个按钮,那么默认也是编辑的这个按钮的配置,但是也可以点击里面的文字,进行文字的更改:

按钮中的文字

点击页面空白处,也就是没有组件的地方,是会隐藏组件面板和属性面板的。

快捷操作

选中某个组件之后,也可以通过快捷键来帮助做一些便捷的处理。

按键
说明
ctrl + ↑ 选择上一级组件,直到根元素的行组件
ctrl + ← 如果左边有兄弟组件,那么选中它
ctrl + ↓ 如果在行组件上操作,那么将会选中它的第一列,如果是在列组件上操作,那么将会选中它的第一个子组件,如果是在非行列组件上操作,并且有兄弟组件的话,那么选中它的最后一个兄弟组件
ctrl + → 如果右边有兄弟组件,那么选中它
ctrl + click 正常click会打开属性面板,但是如果想单纯的点击组件查看效果,那么可以按住ctrl再点击鼠标左键,这样就跟在真实业务场景下的页面中一样,不会弹出属性面板
Ctrl + 鼠标右键 定位当前元素弹出选中

属性面板

当前添加或选择的组件的属性和事件等的相关配置。如图

组件属性面板

当页面中包含一个组件的时候,我们希望能够对它进行任意的定制,组件属性面板给我们提供了入口,主要提供以下五个方面的配置

基础操作区域

所有组件都可以使用的功能,主要是围绕着组件做一些事情,而不是修改配置信息。

公共操作区域

  • 上一级 如果组件形成了嵌套关系,那么可以通过该按钮快速选择上一级,一直到当前的根元素,也就是行组件为止。

行组件操作区域

行组件这里有一些变化,能够进行上移和下移来调整行位置,并给出提示信息,这些都是可配置的,只要找到对应的配置文件进行修改即可。

  • 左移 如果左边有兄弟组件,那么它将移动到该组件的左边。

  • 右移 如果右边有兄弟组件,那么它将移动到该组件的右边。

  • 移除 移除该组件。

  • 固钉图标 属性面板默认是固定在页面右侧的,可以通过点击图标来解除固定,然后用鼠标按住图钉进行拖动就能够移动了。

  • 当前组件 显示了当前组件的名称,如:按钮、输入框、表格等,右边有一个刷新图标,如果更改组件某个配置页面没有自动更新的话,可以点击该图标手动刷新状态。

  • 滑动条 用来快速滚动页面,如果组件属性配置面板比较长,那么可以通过鼠标点击滑动条来快速切换到对应为止,滑动条中滑动块的位置与页面中的滚动条位置相对应,比如点击滑动条尾部,会直接自动跳转到属性面板的底部。

滑动条

并且属性面板向下滚动的时候,滑动条会固定在面板顶部保持始终可见。

  • 之前添加组件 点击该按钮之后,会自动弹出组件面板,选择对应组件之后,所选择的组件就会被插入到该组件的前面。

之前添加组件

并且该按钮会自动转换成取消按钮,可以点击它来取消此次操作。

之前添加组件选择

可以看到,组件面板中的所有组件全部变成高亮状态,并且显示出了很多隐藏的组件以供选择。

  • 替换当前组件 选择的组件会替换掉当前的组件。

  • 之后添加组件 选择的组件会被插入到该组件的后面。

属性区域

除了原生 div 或者 span 等标签元素,其他所有的组件全部都默认都由 element-plus 中的组件来渲染,因此属性区域可配置项默认包含了 element-plus 官网中对应组件的所有属性,但是也有一些公共的属性需要配置,这里对一些属性做一下陈述:

类名和样式属性

其中有所有组件都有的三个属性配置:

类名

可以给该组件添加类名,能够通过手动书写类名或者预置选择以及可视化设置的方式来添加。

比如我们给查询按钮添加一点样式:文字变成红色、宽度变为100px。

按钮添加类名

这里我们使用手输的方式,给按钮添加了两个类名,具体格式之后会详解,详情参见,现在按钮已经变了样子。

按钮添加类名效果

可以看到,按钮已经按照设置变成了我们想要的效果。

现在用预置选择的方式再来看一下,我这里预置了一个效果。

类名预置选择

有一个圆角凸起的预置选项,我们选中它之后,其他什么都不用改,按钮就会发生变化。

按钮圆角凸起效果

而且它也有了鼠标悬浮等交互效果,每个组件的类名预置选项都是可以配置的,不会冲突,彼此之间相互独立。

点击类名旁边的小手,可以唤醒配置面板,

设置类名

例如我们现在设置一个高度为60px的类名,然后再设置一个圆角为50%的类名

按钮设置类名

点击确定之后,按钮就自动添加上相应的类名了,样式实时生效

按钮效果

样式

如果有更定制化的样式设置,或者样式比较复杂,用单元类名写起来比较繁琐,那么可以使用该功能细致化的定制该组件的样式,所写的所有样式都只针对当前组件生效。

点击编写按钮之后,会弹出一个编辑框,里面就可以编写css代码了。

样式编辑弹窗

有三种编写的方式:

① 直接书写属性对。

② 将属性对包裹在一堆大括号中。

③ 以*开头的大括号内写属性对。

推荐使用第三种方式,因为这样会有代码提示:

编写样式代码

我们给按钮添加一个样式,让它的文字变成蓝色:

蓝色字体样式代码

点击对勾之后,我们就能看到变化啦:

蓝色字体样式效果

深层样式

有时候我们不但要改变当前组件的样式,还要更改它子孙组件的样式,这个时候我们就可以使用深层样式来控制内部组件。

有点相当于我们平时写的深层选择器,为了更好的控制样式生效范围,我们规定必须手动指定一个选择器,再在这个基础上去控制深层组件。

比如现在我们有一个行内卡片,它的默认padding是20px:

行内卡片

显然,通过单元类名或者样式都是无法更改头部区域和内容区域的padding值的,这时我们使用深层样式对它进行更改:

修改card的padding代码

根据上面我们的约定,mini-card就是我们手动指定的选择器,编写完深层样式之后,我们还需要对该组件指定这个选择器:

card增加类名

现在我们想要的效果就已经达成啦:

card的padding效果

这时,再新增的card组件或者其他既有card组件不会收到影响,还是展示默认的效果,如果也想同步这样的效果,只需要给那个组件也加上mini-card类名即可。

其实可以发现,我们不但可以修改当前组件内的深层样式,也可以通过父级来修改当前组件的样式,甚至可以通过根组件来统一管理当前行的所有样式。

除了上面这三个公共属性,还有一些非常重要的属性,只不过它们只存在于一些特殊的组件。

行组件的专有属性

行属性设置列

默认一行有一个列,并且最少有一个列,遵循24栏布局,可以继续添加或者删除列。

也可以设置列的间距,以及水平和垂直方向上的对齐方式等。

model绑定

像输入框、下拉框等表单元素组件,都会产生数据交互,包括一些自定义组件也是,它们都支持model的绑定。

数据绑定

model的绑定一共由两个属性的组合来支持,一个是设定它的key,也可以叫做它的name,对于表单数据对象来说就是key-value对中的key,对于提交到后端的数据来说就是字段的名称name。

另一个就是设置的默认值,也就是未赋值的情况下,该组件显示的值。

model的绑定方式有两种:

  1. 直接写名称,那么它们都会被收集到根对象中以供提取和使用:

注:根对象是针对当前页面的,不同的页面有不同的根对象

比如我们设计了三个输入框,分别是手机号、邮箱、住址,它们的model分别是phone、email、address:

model值查看

collectionData中已经有了,现在给这三个输入框分别输入点内容:

输入框有值

看下它们值的变化:

model值的变化

  1. 通过用点(.)来连接多个名称,那么它们会被自动收集到设定的对象里面,并可以通过跟对象进行访问。

现在我们假定上面的三个输入框属于同一个表单,把他们绑定在对象userInfo上面,类似于这样:

model深层绑定

再看下此时它们被收集到了哪里:

model深层绑定值变化

这样就可以通过userInfo来访问三个输入框的值了,同理,支持无限层级的嵌套。

model的默认值也有两种绑定方式:

  1. 普通数据类型,可以指定为数字、字符串、布尔值、数组等等。

比如输入框可以指定为字符串,多选按钮指定为数组:

输入框默认值

多选框的默认值

这个时候页面上这两个组件的默认值就会自动更新了:

输入框默认值回显

多选框默认值回显

  1. 函数,默认值将是该函数的返回值。

默认值为函数

在这里插入图片描述

填充数据

有一些组件的渲染需要一些动态数据,比如表格、下拉框、复选框组等等,这些数据可以是自定义的,也可以是从后端读取的。

这里又追加了一种list的类型,也是可以填充数据的,主要包含了三个功能点:

填充数据

  1. 填充数据,可以绑定接口返回值,这个需要先给当前页面配置接口。

填充数据有三种方式:通过绑定接口返回值、通过绑定作用域插槽数据、通过绑定 collectionData 中的数据。

填充数据如果是 requestData 中的请求链接里返回的数据,直接从下拉选项选择所返回的对象属性名称即可。如果是从 collectionData 中获取可以在输入框中输入 @属性名@ 符号表示从 collectionData 取值。还可以使用 # 符号,表示在当前作用域插槽下取值,比如 #childrens ,表示取当前组件作用域插槽下的childrens数组。

  • 通过接口绑定

比如现在有一个表格,数据需要从接口获取,已经给页面配置好了一个获取歌曲列表的请求链接:

歌曲请求链接

那么就可以从填充数据中绑定它:

绑定填充数据

这个接口返回一个数组,每一项包含四个字段:title、singer、time、hot。详情可参考 请求链接

现在已经将数据绑定到表格上面了,那么接下来就可以指定列绑定相应的字段来渲染了,首先给表格建四个列:

直接修改文字

直接点击表格头部,就可以编辑对应列的名称了。

绑定表格列内容

点击对应的列,编辑它所渲染的内容,# 表示一级作用域插槽数据,也就是当前作用域插槽的数据,如果是##就表示上级作用域插槽的数据,其中row表示el-table列作用域插槽数据中的行数据,title就表示插槽数据的title字段值。

其中#也可以放在最左边,也就是 row#title#row.title 是一样的,#row.title 可能比较好理解,表示当前作用域的行数据的title字段,上图中那样写是为了将对象写在#左边,#右边只写字段,因为它也可以写成 a.b.c#d 的形式。

然后就能看到表格已经自动渲染了我们所绑定的数据:

表格渲染效果

  • 通过作用域插槽

其实上面已经顺带着演示了作用域插槽的使用,而我们填充的数据同样也是可以从作用域插槽中来获取。

  • collectionData

如果我们在页面初始化或者其他地方给 collectionData 中赋值了一个数据对象,那么填充的数据也可以取的到。比如我们有这样是一个数据:

collectionData赋值数据

collectionData中赋值了一个tableData属性,它的值是一个数组。

[{
    title: '稻香',
    singer: '周杰伦',
    hot: 999,
    time: '03:43'
},{
    title: '关不上的窗',
    singer: '周传雄',
    hot: 999,
    time: '04:56'
},{
    title: '口是心非',
    singer: '张雨生',
    hot: 999,
    time: '04:56'
},{
    title: '水手',
    singer: '郑智化',
    hot: 999,
    time: '04:57'
}]

现在我们将表格的数据源绑定到这个数组上面,作用域插槽使用 # 关键字,要引用 collectionData 中的属性使用 @ 关键字。

绑定collectionData属性

现在就绑定完成啦,表格会自动读取该数组来渲染:

collectionData绑定回显

并且现在修改tableData的数据,页面也会实时修改更新。

  1. 测试数据,可以手动更改请求链接的参数,如果填充数据使用的是接口的返回值,那么有可能这个接口在请求的时候需要动态传一些必填的参数。

在设计阶段,如果接口能够直接发起请求并返回数据,那么页面绑定接口之后就会自动发起请求并将返回数据绑定到页面上。

如果需要动态获取或者根据业务实时改变参数的接口,就可以使用测试数据的功能,它能够让我们先临时指定参数的值,来模拟实际场景的效果。

有一个获取字典的接口,可以通过code来查询对应的字典项,默认code是空的:

获取字典接口

将填充数据绑定到这个接口,那么我们就可以通过测试数据来指定某个code值以让接口发起请求:

绑定接口测试数据

  1. 手动指定数据,有一些填充数据不需要通过上面这些方式绑定,比如性别男女,或者选项是否等,就可以通过手动编辑组件需要渲染的数据。

有一个单选按钮组,通过一个列表渲染出多个radio的选项,手动指定列表的数据如下:

手动指定数据

绑定选项的label属性,内容是通过文本组件渲染,从作用域中获取数据:

单选标签插槽

单选内容插槽

现在页面中就会呈现出效果:

单选手动指定数据效果

比如我们再加一个保密的选项,直接修改手动指定数据的内容即可:

[{
  label: '男',
  value: 1
},{
  label: '女',
  value: 0
},{
  label: '保密',
  value: 2
}]

页面也实时回显效果:

单选手动指定数据效果2

事件区域

可配置组件暴露的事件,它们都能通过编写代码的方式进行事件的添加和绑定。

比如输入框暴露出的事件有:

输入框暴露的事件

单选按钮组暴露出的事件有:

单选按钮组暴露的事件

也可以通过单击【添加】设置 on_事件名 来绑定事件。

组件绑定事件

插槽区域

包含了组件支持的所有插槽,能够对插槽进行各种操作。

例如输入框的插槽:

输入框插槽

所有插槽全部包含三个功能:添加、删除、查看元素

  1. 添加

可以对该插槽添加组件,点击之后自动弹出组件面板,选中组件之后,该组件就会被添加到该插槽当中。

给输入框组件添加一个 prepend 插槽和一个 suffix 插槽。

prepend是一个文本:

插槽文本

suffix是一个图标:

插槽图标

两个插槽添加成功:

输入框插槽效果

如果某些组件更适合被添加,那么会自动高亮,比如给 table 添加插槽,table-column 会高亮,给 select 添加插槽,option 会高亮等等。

添加插槽

  1. 删除

可以直接清空插槽,无论该插槽中有多少个组件都会被一次性删除掉,如果想一个一个删除,那么选择到对应组件,使用组件的移除功能。

删除掉刚才的prepend插槽中的组件:

suffix插槽图标

现在就只剩suffix插槽了。

  1. 元素

可以查看当前插槽中所包含的所有组件,用列表的方式展现,单击列表中的项,能够直接跳转到对应组件的配置面板。

我们查看suffix插槽中的组件:

查看插槽内容

单击icon,就能够编辑这个icon组件的配置了:

图标属性面板

其他定义区域

在这里能够增加权限控制、获取页面DOM、获取渲染的视图、添加事件监听等等。

其他自定义区域

上面是书写规范的说明,下面是一个添加的按钮。

比如给按钮添加一个dataset,点击[+添加]可以输入对应的key和value。

dataset自定义属性

其中key为_data-level,value为1。看下DOM元素:

dateset的dom效果

之后可以对它进行引用和操作了。

也可以添加事件,按钮组件默认没有暴露出任何事件,但是我们可以手动添加点击事件。

自定义click事件

它的key为on_click,value为一个函数:

function _() {
  console.log('点击了按钮')
}

然后我们点击按钮就能够看到函数的执行效果:

click事件效果

既可以直接编写逻辑,也可以调用执行寄连,还能够操作组件、修改变量、发起请求等等等等。

在一个方法中你可以做任何事情。

如果一个属性的的值是通过函数返回的,那么这个value也可以设置为函数,只需要把函数命名为 attribute 即可。

比如将刚才的dataset设置为一个函数返回值:

function attribute() {
  return 2
}

那么页面中也是会实时变更的:

通过函数返回属性

如果一个属性的值,本身就需要是一个函数,比如下拉框的过滤属性filter-method,它的值就是一个函数,那么这时你可以任意命名,建议将函数命名成 _

右边侧栏

除了上面的三个大板块,页面设计器还包含了一些其他的配置功能。

主要用来配置当前页面使用的执行寄连、请求链接,还有页面整体的样式设计,以及选择元组等。还列出了当前设计页面所包含的所有组件。

页面配置按钮

默认是虚化状态,鼠标悬浮会高亮

上面从上到下的按钮依次为:全局配置、元素面板、元组面板、保存。

全局配置

针对整个页面进行配置,点击会展开一个菜单:

页面配置操作面板

按逆时针呈现的三个图标,分别表示:

  1. 整页样式调整

包括class设置、style设置、深层样式设置。

全局样式配置

设置规则同组件一样。见上面

  1. 页面执行寄连配置

弹窗分左右两个列表,右侧列表表示当前已添加的执行寄连。

绑定寄连

支持检索,点击添加按钮即可将该执行寄连绑定到该页面上,也可以点击移除从该页面中移除绑定。

  1. 页面请求链接配置

弹窗分左右两个列表,右侧列表表示当前已添加的请求链接。

绑定请求链接

支持检索,点击添加按钮即可将该请求链接绑定到该页面上,也可以点击移除从该页面中移除绑定。

绑定完请求链接,就可以在组件的填充数据中选择绑定的数据项了。

元素面板

当设计页面时,有可能有些组件不太好找到或者被选取到,这样就不容易配置它的属性,这时就可以从元素面板中找到它,点击对应的名称就可以自动定位到该组件,并打开它的属性配置面板。

页面中元素列表

元组面板

如果设计了多个元组,那么就可以通过元组面板来选取,达到快速设计的目的。

比如我们之前设计的歌曲列表页面。

歌曲列表页面

现在使用这个页面生成一个元组,注意元组是用来做复用的,这里只是一个演示。

那么就可以在元组面板中看到了:

选择元组面板

会以缩略的形式展现出来,单击对应的元组就可以添加到当前页面了,这里目前只有一个元组供选择。

添加元组

该元组已经被添加到刚刚做演示的页面中。

默认情况下,被添加进来的元组是不允许编辑的,因为它是公用的复合组件,如果需要修改,可以去修改元组,这样用到该元组的地方就会全部自动更新。

元组默认不能编辑

点击该元组的任何地方,弹出的属性面板都是空白的,无法配置的。

如果确实需要特殊化处理,只是为了快速复用,有一些独立修改的地方,那么可以点击解除关联绑定,这样就可以对元组进行编辑了,并且不会影响其他用到的页面。同样,修改元组之后该页面也不会变化。

保存

也就是设计完成按钮,点击该按钮会对页面进行保存,如果是第一次设计,会弹出基本信息输入的窗口

设计完成

其他

就像元组一样,我们还有模板的概念,它不同于元组,如果我们设定一个页面为模板的话,那么在用页面设计器新建一个页面的时候,会先提供模板选择的功能。

比如我们没有模板的时候新建页面是这样的:

设计器新建页面

因为没有任何模板可供选择,因此只有一个新建的图标,点击新建图标,就可以全新的设计一个页面了。

再将刚才歌曲列表的页面生成一个模板,那么我们在新建页面的时候就会是下面这样:

设计页面选择模板

就跟word一样,使用模板会快速的生成一个页面,可以自建任意多的模板。

每个页面在生成元组或者模板的时候,都会自动生成缩略图,方便选择。

【项目体验】

系统管理端地址www.lecen.top/manage

系统用户端地址www.liudaxianer.com/user

系统文档地址www.lnsstyp.com/web

RocketMQ高性能揭秘:承载万亿级流量的架构奥秘|得物技术

作者 得物技术
2025年12月30日 16:42

一、前言

在分布式系统架构中,消息队列如同畅通的“信息神经网络”,承担着解耦、削峰与异步通信的核心使命。在众多成熟方案中,RocketMQ凭借其阿里巴巴与Apache双重基因,以卓越的金融级可靠性、万亿级消息堆积能力和灵活的分布式特性脱颖而出,成为构建高可用、高性能数据流转枢纽的关键技术选型。本文将深入解析RocketMQ的核心架构、设计哲学与实践要义。

二、RocketMQ架构总览

官网图片

RocketMQ架构上主要分为四部分,如上图所示: 

RocketMQ作为一款高性能、高可用的分布式消息中间件,其核心架构采用了经典的四组件协同设计,实现了消息生产、存储、路由与消费的全链路解耦与高效协同。四大组件——生产者(Producer)、消费者(Consumer)、路由中心(NameServer)和代理服务器(Broker)——各司其职,共同构建了其坚实的基石。

生产者(Producer) 作为消息的源头,负责将业务消息高效、可靠地发布到系统中。它支持分布式集群部署,并通过内置的智能负载均衡机制,自动选择最优的Broker节点与队列进行投递。

消费者(Consumer) 是消息的处理终端,同样以集群化方式工作,支持推送(Push)和拉取(Pull)两种消息获取模式。它提供了集群消费与广播消费两种模式,并能动态维护其订阅关系。

路由中心(NameServer) 是整个架构的“注册中心”,扮演着轻量级服务发现的角色。所有Broker节点都会向NameServer注册,并通过定期心跳汇报健康状态。生产者与消费者则从NameServer获取实时的主题路由与Broker信息,从而实现消息寻址的完全解耦。

代理服务器(Broker) 是消息存储与流转的核心,负责消息的持久化存储、投递与查询。为了保障高可用性,Broker通常采用主从(Master-Slave)部署架构,确保数据与服务在故障时能无缝切换。其内部集成了通信处理、存储引擎、索引服务和高可用复制等核心模块。

三、核心组件深度解析

NameServer:轻量级服务发现枢纽

NameServer是RocketMQ的轻量级服务发现与路由中心, 其核心目标是实现生产消费与Broker服务的解耦。 它不存储消息数据,仅管理路由元数据。

核心是一张的路由表 HashMap<String/* Topic */, List>,记录了每个Topic对应在哪些Broker的哪些队列上。

客户端内置了故障规避机制。如果从某个NameServer获取路由失败,或根据路由信息访问Broker失败,会自动重试其他NameServer或Broker。

1. 核心角色与设计哲学: NameServer的设计哲学是 “简单、无状态、最终一致” 。 每个NameServer节点独立运行,节点间互不通信, 这使其具备极强的水平扩展能力和极高的可用性。客户端会配置所有NameServer地址,并向其广播请求。

2. 核心工作机制: 其运作围绕路由信息的生命周期展开,可通过下图一览其核心流程:

3. 和kafka注册中心对比

  • NameServer 采用 “去中心化” 和 “最终一致” 思想,追求极致的简单、轻量和水平扩展, 牺牲了强一致性,以换取架构的简洁和高可用。这非常适合路由信息变动不频繁、客户端具备容错能力的消息场景。
  • Kafka (KRaft) 采用 “中心化” 和 “强一致” 思想,追求数据的精确和系统的自包含。 它将元数据管理深度内化,通过共识协议保证全局一致,但代价是架构复杂度和运维成本更高。

优劣分析: NameServer在运维简易性、集群扩展性、无外部依赖上占优;而Kafka KRaft在元数据强一致性、系统自包含、架构统一性上更胜一筹。选择取决于你对一致性、复杂度、运维成本的具体权衡。

Broker:消息存储与转发的核心引擎

解密存储文件设计

Broker目录下的文件结构

所有核心存储文件均位于Broker节点的 ${storePathRootDir}/store/ 目录下(默认路径为 ~/store/),其下各子目录职责分明:

目录/文件 核心职责 关键设计说明
commitlog/ 消息实体存储库 • 设计:所有Topic的消息顺序混合追加写入。• 文件:以起始物理偏移量命名(20位数字),默认每个1GB。lock文件确保同一时刻只有一个进程写入,保障严格顺序写。
consumequeue/ 逻辑消费队列索引 • 结构:按 {Topic}/{QueueId}/三级目录组织。 • 文件:存储定长记录(20字节/条),包含物理偏移量、长度和Tag哈希码。 • 作用:为消费者提供按Topic和队列分组的逻辑视图,实现高效拉取。
index/ 消息键哈希索引 • 文件:以创建时间戳命名(如20240515080000000)。 • 结构:采用 “哈希槽 + 链表” 结构。 • 用途:支持根据 Message Key 或时间范围进行消息查询,用于运维排查。
config/ 运行时元数据 • 存储Broker运行期间生成的动态数据,如所有Topic的配置消费者组的消费进度(offset) 等。
checkpoint 状态检查点文件 • 记录 commitlog、consumequeue、index等文件最后一次刷盘的时间戳,用于崩溃恢复时确定数据恢复的起点。
abort 异常关闭标志文件 • 该文件存在即表明Broker上一次是非正常关闭,重启时会触发恢复流程。
lock 锁文件 • lock文件确保同一时刻只有一个进程写入,保障严格顺序写。

commitLog

消息主体以及元数据的存储主体, 存储Producer端写入的消息主体内容,消息内容不是定长的。 单个文件大小默认1G, 文件名长度为20位,左边补零,剩余为起始偏移量, 比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件;

当我们消息发送到RocketMQ以后,消息在commitLog中,因为body大小是不固定的,所以每个消息的长度也是不固定的,其存储格式如下:

下面每个表格列举了每个字段的含义

字段 字段名 数据类型 字节数 说明与用途
1 MsgLen / TOTALSIZE int 4 消息总长度,即从本字段开始到结束的总字节数,是解析消息的起点。
2 MagicCode int 4 魔术字,固定值(如 0xdaa320a7),用于标识这是一个有效的消息存储起始点,也用于区分消息体文件末尾空白填充区
3 BodyCRC int 4 消息体内容的CRC校验码, 用于校验消息体在存储过程中是否损坏。
4 QueueId int 4 队列ID,标识此消息属于Topic下的哪个逻辑队列。
5 Flag int 4 消息标志位,供应用程序自定义使用,RocketMQ内部未使用。
6 QueueOffset long 8 消费队列偏移量,即此消息在其对应ConsumeQueue中的顺序索引,是连续的
7 PhysicalOffset long 8 物理偏移量,即此消息在所有CommitLog文件中的起始字节偏移量。由于消息长度不定,此偏移量不是连续的
8 SysFlag int 4 系统标志位,是一个二进制组合值,用于标识消息特性,如:是否压缩、是否为事务消息、是否等待事务提交等。
9 BornTimestamp long 8 消息生成时间戳,由Producer客户端在发送时生成。
10 BornHost 8字节 8 消息发送者地址。其编码并非简单字符串,而是将IP的4个段和端口号的2个字节,共6个字节,按大端序组合并填充到8字节中。
11 StoreTimestamp long 8 消息存储时间戳,即Broker收到消息并写入内存的时间。
12 StoreHost 8字节 8 Broker存储地址,编码方式同BornHost。
13 ReconsumeTimes int 4 消息重试消费次数,用于死信队列判断。
14 PreparedTransationOffset long 8 事务消息专用,存储与之关联的事务日志(Transaction Log)的偏移量
15 BodyLength int 4 消息体实际长度,后跟Body内容。
16 Body byte[] 不定 消息体内容,即Producer发送的原始业务数据。
17 TopicLength byte 1 Topic名称的长度(1字节,因此Topic名不能超过255字符)。
18 Topic byte[] 不定 Topic名称的字节数组。
19 PropertiesLength short 2 消息属性长度,后跟Properties内容。
20 Properties byte[] 不定 消息属性,用于存储用户自定义的Key-Value扩展信息。在编码时,Key和Value之间用特殊不可见字符(如\u0001)分隔,因此属性中不能包含这些字符。

ConsumeQueue

消息消费索引,引入的目的主要是提高消息消费的性能。 由于RocketMQ是基于主题topic的订阅模式,消息消费是针对主题进行的,如果要遍历commitlog文件,根据topic检索消息是非常低效的。

为了解决这个问题中,提高消费时候的速度,RocketMQ会启动后台的 dispatch 线程源源不断的将消息从 commitLog 取出消息在 CommitLog 中的物理偏移量,消息长度以及 Tag Hash 等信息作为单条消息的索引,分发到对应的消费队列,构成了对 CommitLog 的引用。

consumer可根据ConsumeQueue来查找待消费的消息。其中,ConsumeQueue作为消费消息的索引,保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量offset,消息大小size和消息Tag的HashCode值。

consumequeue文件可以看成是基于topic的commitlog索引文件, 故consumequeue文件夹的组织方式如下:

$HOME/store/consumequeue/{topic}/{queueId}/{fileName}

consumequeue文件采取定长设计, 每一个条目共20个字节,前8字节的commitlog物理偏移量、中间4字节的消息长度、8字节tag的hashcode。

indexFile

RocketMQ的IndexFile索引文件提供了通过消息Key或时间区间查询消息的能力,其存储路径为$HOME/store/index/{fileName},其中文件名以创建时间戳命名。单个IndexFile文件大小固定约为400M,可保存2000W个索引,其底层采用类HashMap的哈希索引结构实现。

IndexFile是一个固定大小的文件(约400MB),其物理结构由三部分组成

1.IndexHeader(索引头,40字节)

beginTimestamp: 第一条消息存储时间

endTimestamp: 最后一条消息存储时间

beginPhyoffset: 第一条消息在CommitLog中的物理偏移量

endPhyoffset: 最后一条消息在CommitLog中的物理偏移量

hashSlotCount: 已使用的哈希槽数量

indexCount: 索引单元总数

2.Slots(哈希槽)

每个IndexFile包含500万个哈希槽位,每个Slot槽位(4字节)存储的是链式索引的第一个索引序号,每个槽位可挂载多个索引单元,形成链式结构。

  • 如果Slot值为0:表示该槽位没有索引链
  • 如果Slot值为N:表示该槽位对应的索引链头节点索引序号为N

3.Indexes(索引单元,20字节/个)

每个索引单元包含以下字段:

  • keyHash: 消息Key的哈希值
  • phyOffset: 消息在CommitLog中的物理偏移量
  • timeDiff: 消息存储时间与IndexFile创建时间的差值
  • preIndexNo: 同一哈希槽中前一个索引单元的序号

这个结构和hashmap结构很像,但是支持每个key通过时间排序,就可以进行时间范围的检索。

通过定长索引结构和整体设计可以通过key快速定位索引数据,拿到真实数据的物理偏移量。

4.索引查询流程

消费者通过消息Key查询时,执行以下步骤:

  1. 计算槽位序号slot序号 = key哈希值 % 500万
  2. 定位槽位地址slot位置 = 40 + (slot序号 - 1) × 4
  3. 获取首个索引位置index位置 = 40 + 500万 × 4 + (索引序号 - 1) × 20
  4. 遍历索引链从槽位指向的索引开始,沿preIndexNo链式查找,匹配目标Key并校验时间范围
  5. 获取物理偏移量从匹配的索引单元中读取phyOffset,最终从CommitLog获取完整消息内容

通过此机制,IndexFile实现了基于Key的高效点查和基于时间范围的快速检索。

整体流程

RocketMQ 高性能存储的核心,在于其 “混合存储” 架构,这正是一种精妙的存储层读写分离设计。

其工作流程可以这样理解:

  1. 统一写入,保证极致性能: 所有消息顺序追加写入一个统一的 CommitLog 文件。这种单一的顺序写操作,是它能承受海量消息写入的根本。
  2. 异步构建,优化读取路径: 消息一旦持久化至 CommitLog,即视为安全。随后,后台服务线程会异步地构建出专供消费的 ConsumerQueue(逻辑队列索引)和用于查询的 IndexFile。这相当于为数据建立了高效的“目录”。
  3. 消费消息: 消费者实际拉取消息时,是先读取 ConsumerQueue 找到消息在 CommitLog 中的物理位置,再反查 CommitLog 获取完整消息内容。
  4. 可靠的消费机制: 基于上述持久化保障,配合消费者自身的偏移量管理及Broker的长轮询机制,共同实现了消息的可靠投递与高效获取。

这种 “读写分离” 设计的好处在于:将耗时的写操作(顺序写CommitLog)与复杂的读操作(构建索引、分散查询)解耦,让两者可以异步、独立地进行优化,从而在整体上获得更高的吞吐量和更低的延迟。这体现了“各司其职,异步协同”的经典架构思想。

下图是官方文档的流程图

写入流程

1.消息预处理

基础校验: 检查Topic名称、消息体长度等是否合法。

生成唯一ID: 结合Broker地址和CommitLog偏移量等,生成全局唯一的MsgID。

设置系统标志: 根据消息属性(如是否事务消息、是否压缩)设置SysFlag。

2.CommitLog核心写入

获取MappedFile: 根据当前写入位置,定位或创建对应的1GB内存映射文件。这里采用双重检查锁模式来保证性能和安全。

串行加锁写入: 获取全局或文件级锁(PutMessageLock),确保同一时刻只有一个线程写入文件,严格保证顺序性。

序列化与追加: 将消息按照之前分析的二进制协议, 序列化到MappedByteBuffer中,并更新写入指针。

3.刷盘(Flush)

同步刷盘: 消息写入内存映射区后,会创建一个GroupCommitRequest并放入请求组。写入线程会等待,直到刷盘线程完成该请求对应文件的物理刷盘后,才返回成功给Producer。数据最可靠,但延迟较高。

异步刷盘(默认): 消息写入内存映射区后,立即返回成功给Producer。同时唤醒异步刷盘线程, 该线程会定时或当PageCache中待刷盘数据积累到一定量时,执行一次批量刷盘。性能高,但有宕机丢数风险。

4.异步索引构建

由独立的ReputMessageService线程处理。它不断检查CommitLog中是否有新消息到达。

一旦有新消息被确认持久化(对于同步刷盘是已落盘,对于异步刷盘是已写入映射区),该线程就会读取消息内容。

随后,它会为这条消息在对应的consumequeue目录下构建消费队列索引(记录CommitLog物理偏移量和消息长度),更新index索引文件。

消费流程

1.启动与负载均衡

消费者启动后,会向NameServer获取Topic的路由信息(包含哪些队列、分布在哪些Broker上)。

如果消费者组内有多个实例,会触发队列负载均衡(默认策略是平均分配)。例如,一个Topic有8个队列,两个消费者实例,则通常每个消费者负责消费4个队列。这一步决定了每个消费者“认领”了哪些消息队列。

2.拉取消息循环

每个消费者实例内部都有一个PullMessageService线程,它循环从一个PullRequest队列中获取任务。

PullRequest包含了拉取目标(如Broker-A, 队列3)以及下一次要拉取的位点(offset)。

消费者向指定的Broker发送网络请求,请求体中就携带了这个offset。

3.Broker端处理与返回

Broker收到请求后,根据Topic、队列ID和offset,去查询对应的ConsumeQueue索引文件。

ConsumeQueue中存储的是定长(20字节)的记录,包含消息在CommitLog中的物理偏移量和长度。

Broker根据物理偏移量,从CommitLog文件中读取完整的消息内容,通过网络返回给消费者。

4.消息处理与位点提交

消费者将拉取到的消息提交到内部的消费线程池进行处理,你的业务逻辑就在这里执行。

消费位点的管理至关重要:

位点存储: 位点由OffsetStore管理。在集群模式(CLUSTER) 下,消费位点存储在Broker上;在广播模式(BROADCAST) 下,位点存储在本地。

位点提交: 消费成功后,消费者会异步(默认方式)向Broker提交已消费的位点。Broker将其持久化到store/config/consumerOffset.json文件中。

5.消息重试与死信

如果消息消费失败(抛出异常或超时未返回CONSUME_SUCCESS),RocketMQ会触发重试机制。

对于普通消息,消息会被发回Broker上一个特殊的重试主题(%RETRY%),延迟一段时间(延迟级别:1s、5s、10s…)后再被原消费者组拉取。

如果重试超过最大次数(默认16次),消息会被投递到死信主题(%DLQ%),等待人工干预。死信队列中的消息不会再被自动消费。

一体与分离:Kafka和RocketMQ的核心架构博弈

说起RocketMQ就不能不提起Kafka了,两者都是消息中间件这个领域的霸主,但它们的核心架构设计差异, 直接决定了各自不同的性能特性和适用场景,这也是技术选型时必须深入理解的重点。

核心架构设计差异

Kafka:读写一体的“分区日志”模型, Kafka的架构哲学是极简与统一。 它将每个主题分区抽象为一个仅追加(append-only)的物理日志文件。 生产者和消费者都直接与这个日志文件交互:生产者顺序写入尾部,消费者通过维护偏移量顺序读取。这种设计下,数据的读写路径完全一致, 逻辑与物理结构高度统一。

RocketMQ:读写分离的“二级制”模型 , RocketMQ的架构哲学是分工与优化。 它采用了物理CommitLog + 逻辑ConsumeQueue的二级结构。 所有消息都顺序写入一个统一的CommitLog物理文件,实现磁盘的最高效顺序写。同时,为每个消息队列异步构建一个轻量级的ConsumeQueue索引文件,消费者读取时先查询内存中的ConsumeQueue定位,再到CommitLog中获取消息体。这是一种逻辑与物理分离的设计。

优劣势对比

基于上述架构设计根本差异,两者在关键指标上各显优劣:

维度 Kafka(读写一体) RocketMQ(读写分离)
核心优势 极致吞吐与低延迟:读写同路径,数据写入后立即可读,端到端延迟极低。架构简单:无中间状态,副本同步、故障恢复逻辑清晰。 高并发读与丰富功能:索引与数据分离,支持海量消费者并发读。业务友好:原生支持事务消息、定时/延时消息、消息轨迹查询。
存储效率 磁盘顺序IO最大化:生产和消费都是严格顺序IO,尤其适合机械硬盘。 写性能极致化:所有消息顺序写CommitLog,但存在“写放大” ,一条消息需写多次(1次CommitLog + N次ConsumeQueue)。
读性能 消费者落后时可能触发随机读:若消费者要读取非尾部历史数据,可能需磁盘寻道。但现代SSD和预读机制已大大缓解此问题。 读路径优化:ConsumeQueue小而固定,可全量缓存至内存,读操作变为“内存寻址 + CommitLog顺序/随机读”。在PageCache命中率高时表现优异。
扩展性与成本 文件句柄(inode)开销大:每个分区都是独立目录和文件,海量分区时运维成本高。 存储成本与效率更优:多Topic共享CommitLog,文件数少,特别适合中小消息体、多Topic的场景
典型场景 日志流、指标监控、实时流处理:作为大数据管道,与Flink/Spark生态无缝集成。 电商交易、金融业务、异步解耦:需要严格顺序、事务保障、业务查询的在线业务场景。

总而言之,Kafka像一个设计精良的高速公路系统, 核心目标是让数据车辆(消息)能够高吞吐、低延迟地持续流动,并方便地引向各个处理工厂(流计算)。而RocketMQ则像一个高度可靠的快递网络, 不仅确保包裹(消息)准确送达,还提供预约配送(定时)、签收确认(事务)、异常重投(重试)等一系列服务于业务逻辑的增值功能。

RocketMQ对于随机读取的优化

RocketMQ在消费时候的流程

消费者请求 → ConsumeQueue(内存/顺序)获取commitlog上的物理偏移量 → 根据物理偏移量定位CommitLog(磁盘/随机) → 返回消息

从ConsumeQueue获取到消息在commitlog中的偏移量的时候,回查时候可能产生随机IO

  1. 第一次随机IO: 根据ConsumeQueue中的物理偏移量,在CommitLog中定位消息位置
  2. 可能的连续随机IO: 如果一次拉取多条消息,这些消息在CommitLog中可能物理不连续

为了保证RocketMQ的高性能,采用一些优化措施,尽量避免随机IO

1. ConsumeQueue的内存映射优化

实际上,RocketMQ将ConsumeQueue映射到内存,每个ConsumeQueue约5.72MB,可完全放入PageCache,读索引操作几乎是内存操作。

public class ConsumeQueue {
    private MappedFile mappedFile;  // 内存映射文件
    // 20字节每条:8(offset) + 4(size) + 8(tagHashCode)
}

2. PageCache的充分利用

Linux PageCache工作流程: 

  1. 消息写入CommitLog → 进入PageCache
  2. 消费者读取 → 优先从PageCache获取
  3. 如果PageCache命中:内存速度(≈100ns)
  4. 如果PageCache未命中:磁盘随机读取(≈10ms)

3. 批量读取优化

// DefaultMessageStore.java
public GetMessageResult getMessage(...) {
    // 一次读取多条消息(默认最多32条)
    // 即使这些消息物理不连续,通过批量读取减少IO次数
    for (int i = 0; i < maxMsgNums; i++) {
        // 使用同一个文件channel批量读取
        readMessage(ctx, msgId, consumerGroup);
    }
}

4. 读取顺序性的保持

虽然CommitLog中不同Topic的消息是随机存放的,但同一个Queue的消息在CommitLog中是基本连续的:

Queue1: | Msg1 | Msg3 | Msg5 | ... | 在ConsumeQueue中连续
        ↓      ↓      ↓
CommitLog: | Msg1 | Msg2(T2) | Msg3 | Msg4(T3) | Msg5 |
          ↑_________________________↑
          物理上相对连续,减少磁头寻道

高可用设计:双轨并行的可靠性架构

主从架构(Master-Slave)

经典主从模式: RocketMQ早期采用Master-Slave架构,Master处理所有读写请求,Slave仅作为热备份。这种模式下,故障切换依赖人工干预或半自动脚本, 恢复时间通常在分钟级别。

Dledger高可用集群: RocketMQ 4.5引入的Dledger基于Raft协议实现真正的主从自动切换。 当Master故障时,集群能在秒级(通常2-10秒)内自动选举新Leader,期间消息仍可写入(写入请求会阻塞至新Leader选出)。

多副本机制: 现代部署中,建议采用2主2从或3主3从架构。例如在阿里云上,每个Broker组包含1个Master和2个Slave,形成跨可用区的三副本, 单机房故障不影响服务可用性。

同步/异步复制

同步复制保证强一致(消息不丢失),异步复制追求更高性能。

// Broker配置示例
brokerRole = SYNC_MASTER
// 生产者发送消息后,必须等待至少一个Slave确认
// 确保即使Master宕机,消息也不会丢失
  • 强一致性保证:消息写入Master后,同步复制到Slave才返回成功
  • 性能代价:延迟增加约30-50%,TPS下降约20-40%
  • 适用场景:金融交易、资金变动等对数据一致性要求极高的业务

同步/异步刷盘

同步刷盘保证消息持久化不丢失,异步刷盘提升吞吐。

brokerRole = ASYNC_MASTER
// 消息写入Master即返回成功,Slave异步复制
// 存在极短时间的数据丢失风险
  • 高性能模式: 延迟降低,吞吐量接近单节点性能
  • 风险窗口: Master宕机且数据未同步时,最近几秒消息可能丢失
  • 适用场景: 日志收集、监控数据、可容忍微量丢失的业务消息

刷盘策略的工程优化

同步刷盘(SYNC_FLUSH)

生产者 → Broker内存 → 磁盘强制刷盘 → 返回成功
  • 零数据丢失: 即使机器掉电,消息也已持久化到磁盘
  • 性能瓶颈: 每次写入都触发磁盘IO,机械硬盘下TPS通常<1000
  • 优化手段: 使用SSD硬盘可大幅提升性能

异步刷盘(ASYNC_FLUSH)

生产者 → Broker内存 → 立即返回成功 → 异步批量刷盘
  • 高性能选择: 依赖PageCache,SSD下TPS可达数万至数十万
  • 可靠性依赖: 依赖操作系统的刷盘机制(通常5秒刷盘一次)
  • 配置调优:
# 调整刷盘参数
flushCommitLogLeastPages = 4    # 至少4页(16KB)才刷盘
flushCommitLogThoroughInterval = 10000  # 10秒强制刷盘一次

四、Producer与Consumer:高效的生产与消费模型

Producer

消息路由策略:

// 内置多种队列选择算法
DefaultMQProducer producer = new DefaultMQProducer("ProducerGroup");
// 1. 轮询(默认):均匀分布到所有队列
// 2. 哈希:相同Key的消息路由到同一队列,保证局部顺序
// 3. 机房就近:优先选择同机房的Broker
producer.send(msg, new MessageQueueSelector() {
    @Override
    public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
        // 自定义路由逻辑
        return mqs.get(arg.hashCode() % mqs.size());
    }
});

发送模式对比:

模式 特点 性能 适用场景
同步发送 阻塞等待Broker响应 TPS约5000-20000 重要业务消息,需立即知道发送结果
异步发送 回调通知结果 TPS可达50000+ 高并发场景,如日志、监控数据
单向发送 发送后不等待 TPS最高(100000+) 可容忍少量丢失的非关键数据

失败重试与熔断:

  • 智能重试: 发送失败时自动重试(默认2次),可配置退避策略
  • 故障规避: 自动检测Broker可用性,故障期间路由到健康节点
  • 慢请求熔断: 统计发送耗时,自动隔离响应慢的Broker

Consumer

负载均衡策略:

// 集群模式:同一ConsumerGroup内消费者均分队列
consumer.setMessageModel(MessageModel.CLUSTERING);
// 广播模式:每个消费者消费全量队列
consumer.setMessageModel(MessageModel.BROADCASTING);

消费进度管理:

Broker托管: 默认方式,消费进度存储在Broker

本地维护: 某些场景下可自主管理offset(如批量处理)

重置策略:

// 支持多种消费起点
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);  // 从最后
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET); // 从头
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_TIMESTAMP);    // 从时间点

并发控制优化:

// 关键并发参数
consumer.setConsumeThreadMin(20);     // 最小消费线程数
consumer.setConsumeThreadMax(64);     // 最大消费线程数
consumer.setPullBatchSize(32);        // 每次拉取消息数
consumer.setConsumeMessageBatchMaxSize(1); // 批量消费大小
// 流控机制
consumer.setPullThresholdForQueue(1000);  // 队列堆积阈值
consumer.setPullInterval(0);              // 拉取间隔(0为长轮询)

五、核心流程与特性背后的架构支撑

1 .顺序消息如何保证?

全局顺序: 单Topic单队列(牺牲并发)。

分区顺序: 通过MessageQueue选择器确保同一业务键(如订单ID)的消息发往同一队列,Consumer端按队列顺序消费。

2.事务消息的两阶段提交

流程详解: Half Message -> 执行本地事务 -> Commit/Rollback。

架构支撑: Op消息回查机制,解决分布式事务的最终一致性,是架构设计中“状态可回溯”思想的体现。

3.延时消息的实现奥秘

并非真正延迟投递: 为不同延迟级别预设独立的SCHEDULE_TOPIC, 定时任务扫描到期后投递至真实Topic。

设计权衡: 以存储和计算换取功能的灵活与可靠。

六、其他性能优化关键技术点

  1. 零拷贝(Zero-copy): 通过sendfile或mmap+write方式,减少内核态与用户态间数据拷贝,大幅提升网络发送与文件读写效率。
  2. 堆外内存与内存池: 避免JVM GC对大数据块处理的影响,实现高效的内存管理。
  3. 文件预热: 启动时将存储文件映射到内存并写入“假数据”,避免运行时缺页中断。

七、总结:RocketMQ架构设计的启示

RocketMQ的架构设计,尤其是其在简洁性、高性能和云原生演进方面的平衡,为构建现代分布式系统提供了许多宝贵启示。

  1. 在简单与完备间权衡: RocketMQ没有采用强一致性的ZooKeeper,而是自研了极其简单的NameServer。这说明在非核心路径上,牺牲一定的功能完备性来换取简单性和高可用性,可能也是个不错的选择。
  2. 以写定存储,以读优查询: 其存储架构是典型的写优化设计。所有消息顺序追加写入,保证了最高的写入性能。而针对消费和查询这两种主要的“读”场景,则分别通过异步构建索引数据结构(ConsumeQueue和IndexFile)来优化。

八、参考资料

往期回顾

1.PAG在得物社区S级活动的落地

2.Ant Design 6.0 尝鲜:上手现代化组件开发|得物技术 

3.Java 设计模式:原理、框架应用与实战全解析|得物技术

4.Go语言在高并发高可用系统中的实践与解决方案|得物技术

5.从0到1搭建一个智能分析OBS埋点数据的AI Agent|得物技术

文 /磊子

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

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

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

手把手实现 Gin + Socket.IO 实时聊天功能

2025年12月29日 10:10

手把手实现 Gin + Socket.IO 实时聊天功能

在 Web 开发中,实时通信场景(如在线聊天、实时通知、协同编辑等)十分常见,而 Socket.IO 作为一款成熟的实时通信库,支持 WebSocket 协议并提供轮询降级方案,能很好地兼容各类浏览器和场景。本文将手把手教你使用 Go 语言的 Gin 框架整合 Socket.IO,搭建一套完整的前后端实时聊天系统,包含房间广播、跨域处理、静态资源托管等核心功能。

一、项目准备

1. 技术栈说明

  • 后端:Go 1.18+、Gin 框架(轻量高性能 HTTP 框架)、googollee/go-socket.io(Socket.IO Go 服务端实现)
  • 前端:原生 JavaScript、Socket.IO 客户端(兼容服务端版本)
  • 运行环境:Windows/Linux/Mac(本文以 Windows 为例,跨平台无差异)

2. 项目目录结构

先搭建规范的项目目录,便于后续开发和维护:

plaintext

chat-demo/
├── go.mod       // Go 模块依赖配置
├── main.go      // 后端核心代码
└── static/      // 前端静态资源目录
    ├── index.html       // 前端聊天页面
    ├── jquery-3.6.0.min.js  // jQuery(可选,本文未实际依赖)
    ├── socket.io-1.2.0.js   // Socket.IO 客户端
    └── favicon.ico      // 网站图标(可选)

3. 初始化 Go 模块

打开终端,进入项目目录,执行以下命令初始化 Go 模块:

bash

运行

go mod init chat-demo

然后安装所需依赖:

bash

运行

# 安装 Gin 框架
go get github.com/gin-gonic/gin
# 安装 Socket.IO Go 服务端
go get github.com/googollee/go-socket.io

二、后端实现:Gin + Socket.IO 服务搭建

后端核心功能包括:Gin 引擎配置、跨域处理、静态资源托管、Socket.IO 服务初始化、房间管理与消息广播。

1. 完整后端代码(main.go)

go

运行

package main

import (
"github.com/gin-gonic/gin"
socketio "github.com/googollee/go-socket.io"
"github.com/googollee/go-socket.io/engineio"
"github.com/googollee/go-socket.io/engineio/transport"
"github.com/googollee/go-socket.io/engineio/transport/polling"
"github.com/googollee/go-socket.io/engineio/transport/websocket"
"log"
"net/http"
)

func main() {
// 1. Gin 引擎优化:生产环境启用 Release 模式,关闭调试日志
gin.SetMode(gin.ReleaseMode)
router := gin.Default()

// 2. 跨域中间件配置:解决前后端跨域通信问题
router.Use(func(c *gin.Context) {
// 允许所有来源跨域(生产环境可指定具体域名,更安全)
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
// 允许的 HTTP 请求方法
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
// 允许的请求头
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// 处理 OPTIONS 预检请求
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusOK)
return
}
c.Next()
})

// 3. 静态资源托管:映射 static 目录,提供前端页面和静态文件
router.Static("/static", "./static")

// 4. Socket.IO 服务器配置:支持 polling(轮询)和 websocket(优先推荐)
sio := socketio.NewServer(&engineio.Options{
Transports: []transport.Transport{
polling.Default,
websocket.Default,
},
})

// 5. Socket.IO 事件监听:处理连接、消息、加入房间、断开连接等事件
// 5.1 客户端连接事件
sio.OnConnect("/", func(s socketio.Conn) error {
log.Println("客户端已连接:", s.ID())
return nil
})

// 5.2 接收客户端发送的消息事件,并广播到 chat 房间
sio.OnEvent("/", "message", func(s socketio.Conn, msg string) {
log.Println("收到消息:", msg, "(来自:", s.ID(), ")")
// 广播消息到 / 命名空间下的 chat 房间
sio.BroadcastToRoom("/", "chat", "message", msg)
})

// 5.3 客户端加入房间事件
sio.OnEvent("/", "join", func(s socketio.Conn, room string) {
// 让当前客户端加入指定房间
s.Join(room)
log.Println("客户端", s.ID(), "已加入房间:", room)
})

// 5.4 客户端断开连接事件
sio.OnDisconnect("/", func(s socketio.Conn, reason string) {
log.Println("客户端", s.ID(), "已断开连接;原因:", reason)
})

// 5.5 错误处理事件
sio.OnError("/", func(s socketio.Conn, e error) {
log.Println("客户端", s.ID(), "发生错误:", e)
})

// 6. 注册 Socket.IO 路由:将 Socket.IO 请求委托给 Gin 处理
router.GET("/socket.io/*any", gin.WrapH(sio))
router.POST("/socket.io/*any", gin.WrapH(sio))

// 7. 根路径路由:访问 http://127.0.0.1:8080/ 直接返回前端聊天页面
router.GET("/", func(c *gin.Context) {
c.File("./static/index.html")
})

// 8. 启动 Socket.IO 服务器(异步启动,不阻塞 Gin 启动)
go sio.Serve()
defer sio.Close() // 程序退出时关闭 Socket.IO 服务

// 9. 启动 Gin 服务器,监听 8080 端口
if err := router.Run(":8080"); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}

2. 后端核心功能说明

  • Gin 优化:启用 gin.ReleaseMode 关闭调试日志,提升服务性能,适合生产环境部署。

  • 跨域处理:通过自定义中间件设置 CORS 响应头,处理 OPTIONS 预检请求,解决前后端跨域通信障碍。

  • 静态资源托管:通过 router.Static 将 ./static 目录映射到 /static 路由,前端可通过该路径访问 JS、图片等静态资源。

  • Socket.IO 配置:同时支持 polling 和 websocket 传输方式,websocket 为高性能全双工通信,polling 作为降级方案兼容低版本浏览器。

  • 事件处理

    • OnConnect:监听客户端连接,打印客户端唯一 ID;
    • OnEvent("message"):接收客户端消息,并通过 BroadcastToRoom 广播到 chat 房间;
    • OnEvent("join"):处理客户端加入房间请求,通过 s.Join(room) 让客户端加入指定房间;
    • OnDisconnect/OnError:监听客户端断开连接和错误事件,便于问题排查和日志监控。
  • 路由配置:根路径 / 直接返回前端 index.html,无需手动拼接静态资源路径,使用更便捷;Socket.IO 路由注册后,可处理前端的 Socket.IO 连接请求。

三、前端实现:Socket.IO 客户端与页面交互

前端核心功能包括:页面布局搭建、Socket.IO 客户端连接、加入房间、消息发送与接收、页面渲染。

1. 完整前端代码(static/index.html)

html

预览

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Socket.IO 实时聊天示例</title>
    <!-- 引入 jQuery(本文未实际使用,可按需移除) -->
    <script src="/static/jquery-3.6.0.min.js"></script>
    <!-- 引入 Socket.IO 客户端库(需与服务端协议兼容) -->
    <script src="/static/socket.io-1.2.0.js"></script>
    <!-- 网站图标(可选) -->
    <link rel="icon" href="/static/favicon.ico" type="image/x-icon">
</head>

<body>
    <!-- 聊天界面布局:输入框、发送按钮、消息展示区域 -->
    <input type="text" id="message-input" placeholder="输入消息">
    <button id="send-button">发送</button>
    <div id="messages"></div>

    <script>
        // 1. 连接 Socket.IO 服务端
        var socket = io('http://127.0.0.1:8080/', {
            transports: ['websocket', 'polling'], // 优先使用 websocket,降级为 polling
            timeout: 5000 // 连接超时时间:5 秒
        });

        // 2. 监听连接成功事件,连接后立即加入 chat 房间
        socket.on('connect', () => {
            // 发送 join 事件,加入 chat 房间
            socket.emit('join', 'chat');
            console.log('已连接到服务器');
        });

        // 3. 监听服务端广播的 message 事件,渲染消息到页面
        socket.on('message', function (msg) {
            const messagesDiv = document.getElementById('messages');
            const newMessage = document.createElement('p');
            newMessage.textContent = msg;
            messagesDiv.appendChild(newMessage);
        });

        // 4. 绑定发送按钮点击事件,发送消息到服务端
        const sendButton = document.getElementById('send-button');
        const messageInput = document.getElementById('message-input');
        sendButton.addEventListener('click', function () {
            const message = messageInput.value;
            if (message) {
                // 发送 message 事件,携带输入的消息内容
                socket.emit('message', message);
                // 清空输入框
                messageInput.value = '';
            }
        });
    </script>
</body>

</html>

2. 前端核心功能说明

  • Socket.IO 连接:通过 io() 方法连接服务端地址 http://127.0.0.1:8080/,配置传输方式优先级和连接超时时间。
  • 连接成功处理:监听 connect 事件,连接成功后立即发送 join 事件,加入服务端的 chat 房间,确保能接收房间内的广播消息。
  • 消息接收与渲染:监听服务端的 message 事件,收到消息后创建 <p> 标签,将消息内容插入到页面的消息展示区域。
  • 消息发送:绑定按钮点击事件,获取输入框内容,通过 socket.emit('message', message) 发送到服务端,发送后清空输入框,提升交互体验。

四、项目运行与测试

1. 启动服务

  1. 将前端文件(index.htmlsocket.io-1.2.0.js 等)放入 static 目录;

  2. 在项目目录终端执行以下命令启动后端服务:

    bash

    运行

    go run main.go
    
  3. 服务启动成功后,终端会打印日志,监听端口为 8080

2. 测试步骤

  1. 打开多个浏览器窗口(或不同浏览器),访问 http://127.0.0.1:8080/
  2. 在任意一个窗口的输入框中输入消息,点击「发送」按钮;
  3. 观察其他窗口,会实时收到该消息,实现多客户端实时聊天功能;
  4. 查看后端终端,可看到客户端连接、加入房间、接收消息、断开连接等日志信息。

五、常见问题与优化建议

1. 常见问题排查

  • 前后端无法通信:大概率是 Socket.IO 客户端与服务端版本不兼容,建议客户端使用 1.x 或 2.x 版本,与 googollee/go-socket.io 保持协议兼容;
  • 跨域报错:检查后端跨域中间件配置,确保 Access-Control-Allow-Origin 配置正确,生产环境建议指定具体域名而非 *
  • 无法接收广播消息:确认前端已发送 join 事件加入 chat 房间,服务端广播时指定了正确的命名空间和房间名。

2. 优化建议

  • 性能优化:后端可调整 Socket.IO 传输方式优先级,优先使用 websocket;Gin 框架可自定义 http.Server 配置,优化 TCP 连接复用和并发处理能力;
  • 体验优化:前端可添加回车键发送消息、消息区分发送者与接收者、自动滚动到最新消息等功能;
  • 安全优化:生产环境中,跨域配置指定具体域名,添加身份验证(如 Token 验证),防止非法客户端连接;
  • 部署优化:可将静态资源部署到 CDN,提升前端加载速度;后端可使用进程管理工具(如 supervisor)保障服务稳定运行。

六、总结

本文通过 Gin 框架与 Socket.IO 的整合,实现了一套完整的前后端实时聊天系统,核心亮点如下:

  1. 后端完成了跨域处理、静态资源托管、Socket.IO 事件监听与房间广播;
  2. 前端实现了 Socket.IO 连接、房间加入、消息发送与接收渲染;
  3. 项目结构清晰,代码可直接复用,支持多客户端实时通信,可扩展为在线客服、实时通知等场景。

通过本文的实战,你不仅能掌握 Gin 与 Socket.IO 的使用方法,还能理解实时通信的核心原理,为后续复杂实时系统的开发打下坚实基础。

百度一站式全业务智能结算中台

作者 百度Geek说
2025年12月23日 16:41

导读

本文深入介绍了百度一站式全业务智能结算中台,其作为公司财务体系核心,支撑多业务线精准分润与资金流转。中台采用通用化、标准化设计,支持广告、补贴、订单等多种结算模式,实现周结与月结灵活管理。通过业务流程标准化、分润模型通用化及账单测算自动化,大幅提升结算效率与准确性,确保数据合规与业务稳健发展。未来,中台将推进全业务线结算立项线上化、数据智能分析,进一步提升数据分析智能化水平,为公司业务发展提供坚实保障。

01 概述

结算中台作为公司财务体系的核心组成部分,承担着多业务线分润计算、结算及资金流转的关键职能。采用通用化、标准化的设计理念,结算中台能够高效支撑公司内数十个业务线的分润需求,确保广告收入、订单收入、内容分发的精准结算,为公司的财务健康与业务稳健发展提供坚实保障。结算中台建设的核心目标是: 构建高效、标准化、智能化的结算中台体系,支撑多业务线分润计算与资金流转,确保结算数据准确性、高时效披露及业务快速迭代能力,同时降低运维复杂度,推动全业务线结算线上化管理。

结算中台已对接了百家号业务、搜索业务、智能体业务、小说等多个业务线的结算需求, 支持广告分润、补贴分润、订单分润三种结算模式。不同业务线根据各自的业务场景使用不同的结算模式,确保每个业务的收益分配准确无误。结算中台功能分层如图:

图片

02 基本功能

1. 结算模式

结算中台支持三种结算模式,以适应不同业务场景的结算需求:

  • 订单结算:基于直接订单数据,按照订单实际金额与分成策略进行分润计算。

  • 补贴结算:针对特定业务或用户群体,提供额外的收益补贴,以增强业务的市场竞争力。

  • 广告结算:根据分发内容的广告变现与渠道分成比例,精确计算媒体与内容的实际收益。

2. 结算能力

结算中台支持周结与月结两种结算能力:

  • 周结:适用于需要快速资金回笼的业务场景,比如短剧快速回款以后能够再次用于投流, 确保资金流转的高效性。

  • 月结:作为默认的结算周期,便于公司进行统一的财务管理与账务处理。

3. 账单测算自动化

结算中台支持重点业务账单自动测算,通过预设的分润模型,自动计算每个渠道、每位作者的应得收益,生成测算报告。这一自动化过程显著提升工作效率,减少人为错误,确保结算数据的绝对准确。

03 需求分析

在推进公司结算业务时,我们致力于实现统一化、标准化,规范业务流程,并确保数据合规化治理,我们面临着诸多问题与挑战,具体表现如下:

1. 流程与规范缺失

  • 结算流程管理混乱:存在结算需求未备案即已上线的情况,或者备案内容与实际实现不一致,甚至缺乏备案流程。

  • 日志规范陈旧:广告分润场景中,内容日志打点冗余,同时缺少扩展性,导致对新的业务场景无法很好兼容。

2. 烟囱式开发成本高

  • 标准化与统一化需求迫切:之前,各个结算业务维护各自的结算系统,涉及不同的技术栈和结算模型,线下、线上结算方式并存,导致人工处理环节多,易出错,case多,管理难度大。为提高效率,需实现结算业务的标准化与统一化,并拓展支持多种业务结算模式。

  • 分润模型通用化设计:多数业务结算方式相同,同时账单计算逻辑也相似或者相同,没有必要每个业务设计一套逻辑,需要做通用化设计。

3. 业务迭代中的新诉求

  • 测算系统需求凸显:在业务快速迭代的过程中,许多业务希望尽快看到结算效果,以推进项目落地。因此,构建高效的测算系统成为迫切需求,以加速业务迭代和决策过程。

  • 提升作者体验:为提升作者等合作伙伴的满意度和忠诚度,结算数据需实现高时效披露,确保他们能及时、准确地获取收益信息。结算账单数据的产出依赖百余条数据源,要保证数据在每天12点前产出,困难重重

  • 数据校验与监控机制:结算数据的准确性和质量直接关系到公司的财务健康和业务发展。因此,需建立完善的数据校验和监控机制,确保结算数据的准确无误和高质量。

04 技术实现

根据结算中台建设的核心目标,结合业务痛点,在结算系统建设中,基于通用化、标准化的理念,从以下五个方面来搭建统一的、规范化的结算中台。

  • 业务流程标准化:建设一套标准来定义三类结算模式下每个数据处理环节的实现方式,包括业务处理流程、数据处理过程。

  • 分润模型通用化:实现不同的账单计算算法,支持各个业务的各类作者收入分配诉求,并且实现参数配置线上化。

  • 技术架构统一:统一整个结算业务的技术栈、部署环境、功能入口和数据出口。

  • 建设账单测算能力:模拟线上结算流程的账单测算能力,支持业务快速验证分润模型参数调整带来的作者收入影响效果。

  • 建设质量保证体系:建设全流程预警机制,通过日志质检、自动对账、数据异常检测来保障账单产出数据时效性、准确性。

1. 业务流程标准化

不同业务场景,采用了通用化、标准化的设计来满足业务的特异性需求,下面是三大结算模式业务流程简图:

图片

在广告模式、补贴模式、订单模式结算流程设计中, 从日志打点、线上化、计算逻辑等方向考虑了通用化、标准化设计, 具体如下:

(1) 日志打点统一化

统一日志标准, 针对业务日志规范陈旧问题,要求所有接入的业务方严格按照统一格式打点日志,删除冗余字段, 确保数据的规范性与一致性,同时保证设计能够覆盖所有业务场景,为后续处理奠定坚实基础。

针对某些业务定制化的需求, 在广告模式、补贴模式、订单模式三种结算方式中,在设计日志打点规范时, 会预留一些扩展字段, 使用时以 JSON 形式表示, 不使用时打默认值。

(2) 账单计算线上化

在补贴结算模式中,之前不同业务都有各自的账单格式设计,同时存在离线人工计算账单的非规范化场景,账单无法统一在线计算、存储、监管。新的结算中台的补贴结算模式,将所有离线结算模式,使用统一的账单格式,全部实现线上化结算,实现了业务结算流程规范化。

(3) 账单计算逻辑优化

比如在广告模式中,百家号业务的公域视频、图文、动态场景中,由于收入口径调整,迭代效率要求,不再需要进行广告拼接,所以专门对账单计算流程做了优化调整。不仅满足业务诉求,同时做了通用化设计考虑,保证后续其他业务也可以使用这套流程的同时, 也能兼容旧的业务流程。

广告模式结算流程优化前:

图片

广告模式结算流程优化后:

图片

2. 分润模型通用化

不同业务场景,不同结算对象,有不同的结算诉求,不仅要满足业务形态多样化要求,还要具有灵活性。因此抽取业务共性做通用性设计,同时通过可插拔式设计灵活满足个性化需求。

图片

(1) 基于流量变化模型

以合作站点的优质用户投流方为代表的用户,他们在为百度提供海量数据获得收益的同时,也有自己的诉求,那就是自己内容的收益不能受到其他用户内容的影响。自己优质内容不能被其他用户冲淡,当然自己的低质内容也不会去拉低别人的收益水平。

对于此部分用户我们提供“基于流量变现的分润”策略,简单来说就是,某一篇内容的收益仅仅由它自己内容页面挂载的广告消费折算而来,这样就保证了优质用户投流方收益的相对独立,也促使优质用户产出更加多的内容。

(2) 基于内容分发模型

  • 部分作者只关注收益回报: 对百家号的某些作者来说,他们的目的很单纯,他们只关注产出的内容是否获得具有竞争力的收益回报,至于收益怎么来他们并不关心。

  • “基于流量变现”策略不妥此时,我们再使用“基于流量变现”的策略的话,就有些不妥,举个极端里的例子,有一个作者比较倒霉,每次分发都没有广告的渲染,那他是不是颗粒无收?这对作者是很不友好的。

  • “基于内容分发的分润”模型: 基于收益平衡性考虑,我们推出了更加适合百家号用户的“基于内容分发的分润”模型。在这种模型下,只要内容有分发,就一定有收益,而不管本身是否有广告消费。

  • 策略平衡考虑: 当然,为了防止海量产出低质内容来刷取利润,在分润模型上,我们同时将内容质量分和运营因子作为分润计算的权重,也就是说作者最终的收益由内容的质量和内容的分发量共同决定,以达到通过调整分润来指导内容产出的目的。

(3) 基于作者标签模型

为了实现对百家号头部优质作者进行激励,促进内容生态良性发展, 会对不同的作者进行打标, 并且使用不同的分润模型, 比如对公域的百家号作者进行打标, 优质作者, 通过动态单价及内容质量权重策略来给到他们更加的分成, 其他的普通作者, 通过内容分发模型来分润。这样不仅保证了优质作者取得高收益,也保证了其他作者也有一定的收益

另外,出于对预算的精确控制,发挥每一笔预算的钱效,优质的作者会占用较大的预算资金池,而普通作者使用占用较少的预算资金池。同时也会对每类资金池进行上下限控制,保证预算不会花超。

(4) 基于运营场景模型

为了实现对百家号作者的精细化运营,比如对一些参与各类短期活动的作者给予一定的阶段性的奖励,可以通过补贴模型来实现。在一些运营活动中,需要控制部分作者的分成上限,分润模型会进行多轮分成计算,如果作者的收益未触顶并且资金池还有余额的情况下,会对余额进行二次分配,给作者再分配一些收益。此类模型主要是为了实现灵活多变的作者分润策略。

3. 技术架构统一

根据业务流程标准化、分润模型通用化的设计原则,建设统一的结算中台。以下是结算中台统一结算业务前后的对比:

图片

图片

4. 建设账单测算能力

为各个需要测算能力的业务,设计了一套通用的测算流程,如下图:

图片

针对每个测算业务,设计了独立的测算参数管理后台,用于管理业务相关的分润模型参数,如下图:

图片

测算流程设计

(1) 功能简述: 每个测算业务, 产品需要登录模型参数管理后台,此后台支持对分润模型参数进行创建、查看、编辑、测算、复制、上线、删除,以及查看测算结果等操作, 出于业务流程合规化的要求, 每次模型参数上线前, 需要对变更的参数完成线上备案流程才可以上线,实现分润流程合规线上化。

(2) 流程简述

  • 流程简述概览: 每次测算时, 产品需要先创建一个版本的账单模型测算参数,并发起参数测算,参数状态变成待测算 。

  • 离线任务与收益计算: 此后,离线任务会轮询所有的待测算参数,提交Spark任务,调用账单计算模型来计算作者收益,最后生成TDA报告。

  • 查看与评估测算报告: 产品在管理平台看到任务状态变成测算完成时, 可以点击 TDA 链接来查看测算报告, 评估是否符合预期。

  • 根据预期结果的操作:如果不符合预期,可以编辑参数再次发起测算;如果符合预期,则可以发起备案流程,流程走完后可以提交上线。

(3) 收益明显: 通过账单测算建设, 不仅解决结算需求未备案即已上线或者备案内容与实际实现不一致,甚至缺乏备案流程的业务痛点问题,  而且把业务线下账单计算的流程做到了线上, 做到留痕可追踪。同时也满足了业务高效迭代的诉求, 一次账单测算耗时从半天下降到分钟级, 大大降低了账单测算的人力成本与时间成本。

5. 建设质量保障体系

为了保证业务质量,从以下几方面来建设:

(1) 建设数据预警机制:为保证作者账单数据及时披露, 分润业务涉及的 百余条数据源都签订了 SLA, 每份数据都关联到具体的接口人, 通过如流机器人来监控每个环节的数据到位时间, 并及时发出报警信息, 并推送给具体的接口负责人。对于产出延迟频次高的数据流,会定期拉相关负责人相关复盘,不断优化数据产出时效,保证账单数据在每天如期产出

(2) 数据异常检测机制:对账单数据进行异常波动性检测, 确保数据准确性 ,及时发现并处理潜在异常问题

(3) 自动对账机制:每天自动进行上下游系统间账单明细核对,保证出账数据流转的准确无误。

(4) 日志质检机制:每日例行对日志进行全面质检分析, 及时发现日志打点日志。

05 中台收益

结算中台作为公司财务体系的核心,承担多业务线分润计算与资金流转重任。

(1) 通过通用化、标准化设计,高效支撑数十个业务线的精准结算,确保广告、订单、内容分发的业务结算稳定、健康。近一年,结算业务零事故、零损失。

(2) 中台支持多种结算模式与灵活周期管理,实现账单测算自动化,账单测算时间从天级降到小时级。提升效率并减少错误,提升业务需求迭代效率。

(3) 通过业务流程标准化、分润模型通用化、账单测算能力建设及质量保证体系,解决了结算业务规范缺失、业务形态多样等问题。累计解决历史结算case数十个,涉及结算金额达千万级。

未来,结算中台将推进全业务线结算立项线上化、周结与测算能力落地、项目全生命周期管理,并依托大模型能力实现数据智能分析,进一步提升数据分析智能化水平,为公司业务稳健发展提供坚实保障。

06 未来规划

1、推进全业务线结算实现立项线上化;

2、推进周结 、测算能力在各业务线上落地;

3、推进项目全生命周期管理,实现项目从上线到下线整体生命周期变化线上化存档,可随时回顾复盘。

4、数据智能分析,依托公司大模型能力,实现通过多轮对话问答来进行数据分析,针对业务问题进行答疑解惑,提升数据分析的智能化水平。

Java 设计模式:原理、框架应用与实战全解析|得物技术

作者 得物技术
2025年12月18日 14:03

一、概述

简介

设计模式(Design Pattern)是前辈们对代码开发经验的总结,它不是语法规定,是解决特定问题的一系列思想,是面向对象设计原则的具象化实现, 是解决 “需求变更” 与 “系统复杂度” 矛盾的标准化方案 —— 并非孤立的 “代码模板”,而是 “高内聚、低耦合” 思想的落地工具。其核心价值在于提升代码的可复用性、可维护性、可读性、稳健性及安全性。

1994 年,GoF(Gang of Four:Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides)合著的《Design Patterns - Elements of Reusable Object-Oriented Software》(中文译名《设计模式 - 可复用的面向对象软件元素》)出版,收录 23 种经典设计模式,奠定该领域的行业标准,即 “GoF 设计模式”。

核心思想

  • 对接口编程,而非对实现编程
  • 优先使用对象组合,而非继承
  • 灵活适配需求:简单程序无需过度设计,大型项目 / 框架必须借助模式优化架构

组件生命周期

模式类型 核心关注点 生命周期阶段 代表模式
创建型模式 对象创建机制 (解耦创建与使用) 组件的创建 单例、工厂方法、抽象工厂、原型、建造者
结构型模式 对象 / 类的组合方式 组件的使用 代理、适配器、装饰器、外观、享元、桥接、组合、过滤器
行为型模式 对象 / 类的运行时协作流程 组件的交互与销毁 策略、观察者、责任链、模板方法、命令、状态、中介者、迭代器、访问者、备忘录、解释器

七大设计原则

原则名称 核心定义 关联模式 实际开发决策逻辑
开闭原则(OCP) 对扩展开放,对修改关闭 (新增功能通过扩展类实现,不修改原有代码) 所有模式的终极目标 新增需求优先考虑 “加类”,而非 “改类”
依赖倒转原则(DIP) 依赖抽象而非具体实现 (面向接口编程,不依赖具体类) 工厂、策略、桥接 类的依赖通过接口注入,而非直接 new 具体类
合成复用原则(CRP) 优先使用组合 / 聚合,而非继承 (降低耦合,提升灵活性) 装饰器、组合、桥接 复用功能时,先考虑 “组合”,再考虑 “继承”
单一职责原则(SRP) 一个类仅负责一项核心职责 (避免 “万能类”) 策略、适配器、装饰器 当一个类有多个修改原因时,立即拆分
接口隔离原则(ISP) 使用多个专用接口替代单一万能接口 (降低类与接口的耦合) 适配器、代理 接口方法拆分到 “最小粒度”,避免实现类冗余
里氏代换原则(LSP) 子类可替换父类,且不破坏原有逻辑 (继承复用的核心前提) 模板方法、策略 子类重写父类方法时,不能改变父类契约
迪米特法则(LOD) 实体应尽量少与其他实体直接交互 (通过中间者解耦) 中介者、外观、责任链 两个无直接关联的类,通过第三方间接交互

二、原理与框架应用

创建型模式

为什么用创建型模式?

  • 创建型模式关注点“怎样创建出对象?”“将对象的创建与使用分离”
  • 降低系统的耦合度
  • 使用者无需关注对象的创建细节
  • 对象的创建由相关的工厂来完成;(各种工厂模式)
  • 对象的创建由一个建造者来完成;(建造者模式)
  • 对象的创建由原来对象克隆完成;(原型模式)
  • 对象始终在系统中只有一个实例;(单例模式)

创建型模式之单例模式

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一,提供了一种创建对象的最佳方式。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

意图: 保证一个类仅有一个实例,并提供一个访问它的全局访问点。

主要解决: 一个全局使用的类频繁地创建与销毁。

何时使用: 当您想控制实例数目,节省系统资源的时候。

如何解决: 判断系统是否已经有这个单例,如果有则返回,如果没有则创建。

优点:

1、在内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例(比如首页页面缓存)。

2、避免对资源的多重占用(比如写文件操作)。

缺点:

没有接口,不能继承,与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化。

使用场景:

1、要求生产唯一序列号。

2、多线程中的线程池。

3、创建的一个对象需要消耗的资源过多,比如 I/O 与数据库的连接等。

4、系统环境信息(System.getProperties())。

单例模式四种实现方案

饿汉式

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 饿汉式单例(线程安全)
 * 核心原理:依赖类加载机制(JVM保证类初始化时线程安全)
 * 适用场景:实例占用资源小、启动时初始化可接受的场景
 */
public class LibifuTestSingleton {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestSingleton.class);


    // 类加载时直接初始化实例(无延迟加载)
    private static final LibifuTestSingleton INSTANCE = new LibifuTestSingleton();
    // 私有构造器(禁止外部实例化)
    private LibifuTestSingleton() {
        log.info("LibifuTestSingleton 实例初始化完成");
    }
    // 全局访问点(无锁,高效)
    public static LibifuTestSingleton getInstance() {
        return INSTANCE;
    }
    // 业务方法示例
    public void doBusiness() {
        log.info("饿汉式单例(LibifuTestSingleton)执行业务逻辑");
    }
}

懒汉式

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 懒汉式单例(线程安全)
 * 核心原理:第一次调用时初始化,synchronized保证线程安全
 * 适用场景:实例使用频率极低、无性能要求的场景
 */
public class LibifuTestLazySingleton {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestLazySingleton.class);


    // 私有静态实例(初始为null,延迟加载)
    private static LibifuTestLazySingleton instance;
    // 私有构造器(禁止外部实例化)
    private LibifuTestLazySingleton() {
        log.info("LibifuTestLazySingleton 实例初始化完成");
    }
    // 同步方法(保证多线程下唯一实例)
    public static synchronized LibifuTestLazySingleton getInstance() {
        if (instance == null) {
            instance = new LibifuTestLazySingleton();
        }
        return instance;
    }
    // 业务方法示例
    public void doBusiness() {
        log.info("懒汉式单例(LibifuTestLazySingleton)执行业务逻辑");
    }
}

双检锁 (DCL,JDK1.5+)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 双检锁单例(线程安全,高效)
 * 核心原理:volatile禁止指令重排序,双重校验+类锁保证唯一性
 * 适用场景:大多数高并发场景
 */
public class LibifuTestDclSingleton {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestDclSingleton.class);


    // volatile关键字:禁止instance = new LibifuTestDclSingleton()指令重排序
    private volatile static LibifuTestDclSingleton instance;
    // 私有构造器(禁止外部实例化,含防反射攻击)
    private LibifuTestDclSingleton() {
        log.info("LibifuTestDclSingleton 实例初始化完成");
        // 防反射攻击:若实例已存在,直接抛出异常
        if (instance != null) {
            throw new IllegalStateException("单例实例已存在,禁止重复创建");
        }
    }
    // 全局访问点(双重校验+类锁,兼顾线程安全与效率)
    public static LibifuTestDclSingleton getInstance() {
        // 第一次校验:避免频繁加锁(提高效率)
        if (instance == null) {
            // 类锁:保证同一时刻只有一个线程进入实例创建逻辑
            synchronized (LibifuTestDclSingleton.class) {
                // 第二次校验:确保唯一实例(防止多线程并发绕过第一次校验)
                if (instance == null) {
                    instance = new LibifuTestDclSingleton();
                }
            }
        }
        return instance;
    }
    // 防序列化漏洞:反序列化时返回已有实例(而非创建新实例)
    private Object readResolve() {
        return getInstance();
    }
    // 业务方法示例
    public void doBusiness() {
        log.info("双检锁单例(LibifuTestDclSingleton)执行业务逻辑");
    }
}

枚举单例(JDK1.5+)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 枚举单例(天然线程安全、防反射、防序列化)
 * 核心原理:枚举类的实例由JVM管理,天然唯一
 * 适用场景:安全性要求极高的场景(如配置中心、加密工具类)
 */
public enum LibifuTestEnumSingleton {
    INSTANCE;
    private static final Logger log = LoggerFactory.getLogger(LibifuTestEnumSingleton.class);
    // 枚举构造器(默认私有,无需显式声明)
    LibifuTestEnumSingleton() {
        log.info("LibifuTestEnumSingleton 实例初始化完成");
    }
    // 业务方法示例
    public void doBusiness() {
        log.info("枚举单例(LibifuTestEnumSingleton)执行业务逻辑");
    }
}

框架应用

Spring 框架中 Bean 默认作用域为singleton(单例),核心通过AbstractBeanFactory类的缓存机制 + 单例创建逻辑实现 —— 确保每个 Bean 在 Spring 容器中仅存在一个实例,且由容器统一管理创建、缓存与销毁,降低对象频繁创建销毁的资源开销,契合单例模式 “唯一实例 + 全局访问” 的核心思想。

核心逻辑:Bean 创建后存入singletonObjects(单例缓存池),后续获取时优先从缓存读取,未命中则触发创建流程,同时通过同步机制保证多线程安全。

以下选取AbstractBeanFactory中实现单例 Bean 获取的核心代码片段:

// 1. 对外暴露的获取Bean的公共接口,接收Bean名称参数
@Override
public Object getBean(String name) throws BeansException {
    // 2. 委托doGetBean方法实现具体逻辑,参数分别为:Bean名称、所需类型(null表示不指定)、构造参数(null)、是否仅类型检查(false)
    return doGetBean(name, nullnullfalse);
}
// 3. 核心获取Bean的实现方法,泛型T保证类型安全
@SuppressWarnings("unchecked")
protected <T> T doGetBean(
        String name, Class<T> requiredType, Object[] args, boolean typeCheckOnly) throws BeansException {
    // 4. 处理Bean名称:转换别名、去除FactoryBean前缀(如&),得到原始Bean名称
    String beanName = transformedBeanName(name);
    // 5. 从单例缓存中获取Bean实例(核心:优先复用已有实例)
    Object sharedInstance = getSingleton(beanName);
    // 6. 缓存命中(存在单例实例)且无构造参数(无需重新创建)
    if (sharedInstance != null && args == null) {
        // 7. 处理特殊Bean(如FactoryBean):如果是FactoryBean,返回其getObject()创建的实例,而非FactoryBean本身
        T bean = (T) getObjectForBeanInstance(sharedInstance, name, beanName, null);
    } else {
        // 8. 缓存未命中或需创建新实例(非单例、原型等作用域)的逻辑(此处省略,聚焦单例)
    }
    // 9. 返回最终的Bean实例(类型转换后)
    return (T) bean;
}
// 10. 从单例缓存中获取实例的核心方法,allowEarlyReference表示是否允许早期引用(循环依赖场景)
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
    // 11. 从一级缓存(singletonObjects)获取已完全初始化的单例实例(key=Bean名称,value=Bean实例)
    Object singletonObject = this.singletonObjects.get(beanName);


    // 12. 缓存未命中,且当前Bean正在创建中(解决循环依赖)
    if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
        // 13. 对一级缓存加锁,保证多线程安全(避免并发创建多个实例)
        synchronized (this.singletonObjects) {
            // 14. 从二级缓存(earlySingletonObjects)获取早期暴露的实例(未完全初始化,仅解决循环依赖)
            singletonObject = this.earlySingletonObjects.get(beanName);


            // 15. 二级缓存未命中,且允许早期引用
            if (singletonObject == null && allowEarlyReference) {
                // 16. 从三级缓存(singletonFactories)获取Bean的工厂对象(用于创建早期实例)
                ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);


                // 17. 工厂对象存在,通过工厂创建早期实例
                if (singletonFactory != null) {
                    singletonObject = singletonFactory.getObject();
                    // 18. 将早期实例存入二级缓存,同时移除三级缓存(避免重复创建)
                    this.earlySingletonObjects.put(beanName, singletonObject);
                    this.singletonFactories.remove(beanName);
                }
            }
        }
    }
    // 19. 返回单例实例(可能是完全初始化的,也可能是早期实例)
    return singletonObject;
}

入口: getBean(String name)是获取 Bean 的入口,委托doGetBean实现细节;

名称处理: transformedBeanName统一 Bean 名称格式,避免别名、FactoryBean 前缀导致的识别问题;

缓存优先: 通过getSingleton从三级缓存(singletonObjects→earlySingletonObjects→singletonFactories)获取实例,优先复用已有实例,契合单例模式核心;

线程安全: 对单例缓存加锁,防止多线程并发创建多个实例;

特殊处理: getObjectForBeanInstance区分普通 Bean 和 FactoryBean,确保返回用户预期的实例。

整个流程围绕 “缓存复用 + 安全创建” 实现 Spring 单例 Bean 的管理,是单例模式在框架级的经典落地。

结构型模式

为什么用结构型模式?

  • 结构型模式关注点“怎样组合对象/类”
  • 类结构型模式关心类的组合,由多个类可以组合成一个更大的(继承)
  • 对象结构型模式关心类与对象的组合,通过关联关系在一个类中定义另一个类的实例对象(组合)根据“合成复用原则”,在系统中尽量使用关联关系来替代继承关系,因此大部分结构型模式都是对象结构型模式。
  • 适配器模式(Adapter Pattern):两个不兼容接口之间适配的桥梁
  • 桥接模式(Bridge Pattern):相同功能抽象化与实现化解耦,抽象与实现可以独立升级
  • 过滤器模式(Filter、Criteria Pattern):使用不同的标准来过滤一组对象
  • 组合模式(Composite Pattern):相似对象进行组合,形成树形结构
  • 装饰器模式(Decorator Pattern):向一个现有的对象添加新的功能,同时又不改变其结构
  • 外观模式(Facade Pattern):向现有的系统添加一个接口,客户端访问此接口来隐藏系统的复杂性
  • 享元模式(Flyweight Pattern):尝试重用现有的同类对象,如果未找到匹配的对象,则创建新对象
  • 代理模式(Proxy Pattern):一个类代表另一个类的功能

结构型模式之外观模式

外观模式(Facade Pattern)为复杂子系统提供统一高层接口,隐藏内部复杂性,简化客户端调用。这种模式涉及到一个单一的类,该类提供了客户端请求的简化方法和对现有系统类方法的委托调用。

意图: 为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。

主要解决: 降低访问复杂系统的内部子系统时的复杂度,简化客户端之间的接口。

何时使用:

1、客户端不需要知道系统内部的复杂联系,整个系统只需提供一个"接待员"即可。

2、定义系统的入口。

如何解决: 客户端不与系统耦合,外观类与系统耦合。

优点:

1、减少系统相互依赖。

2、提高灵活性。

3、提高了安全性。

缺点:

不符合开闭原则,如果要改东西很麻烦,继承重写都不合适。

使用场景:

1、JAVA 的三层开发模式

2、分布式系统的网关

外观模式简单应用

程序员这行,主打一个 “代码虐我千百遍,我待键盘如初恋”—— 白天 debug ,深夜改 Bug ,免疫力堪比未加 try-catch 的代码,说崩就崩。现在医院就诊(挂号、缴费、取药等子系统)都是通过 “微信自助程序”来统一入口,下面就使用外观模式简单实现:

子系统组件(就诊各窗口)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 子系统1:挂号窗口
 */
public class LibifuTestRegisterWindow {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestRegisterWindow.class);
    /**
     * 挂号业务逻辑
     * @param name 患者姓名
     * @param department 就诊科室
     */
    public void register(String name, String department) {
        log.info(" {} 已完成{}挂号,挂号成功", name, department);
    }
}
/**
 * 子系统2:医保缴费窗口
 */
public class LibifuTestPaymentWindow {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestPaymentWindow.class);
    /**
     * 医保结算业务逻辑
     * @param name 患者姓名
     * @param amount 缴费金额(元)
     */
    public void socialInsuranceSettlement(String name, double amount) {
        log.info("{} 医保结算完成,缴费金额:{}元", name, amount);
    }
}
/**
 * 子系统3:取药窗口
 */
public class LibifuTestDrugWindow {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestDrugWindow.class);
    /**
     * 取药业务逻辑
     * @param name 患者姓名
     * @param drugNames 药品名称列表
     */
    public void takeDrug(String name, String... drugNames) {
        String drugs = String.join("、", drugNames);
        log.info("{} 已领取药品:{},取药完成", name, drugs);
    }
}

外观类(微信自助程序)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 外观类:微信自助程序(统一就诊入口)
 */
public class LibifuTestWeixinHospitalFacade {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestWeixinHospitalFacade.class);
    // 依赖子系统组件(外观类与子系统耦合,客户端与子系统解耦)
    private final LibifuTestRegisterWindow registerWindow;
    private final LibifuTestPaymentWindow paymentWindow;
    private final LibifuTestDrugWindow drugWindow;
    // 构造器初始化子系统(也可通过依赖注入实现)
    public LibifuTestWeixinHospitalFacade() {
        this.registerWindow = new LibifuTestRegisterWindow();
        this.paymentWindow = new LibifuTestPaymentWindow();
        this.drugWindow = new LibifuTestDrugWindow();
    }
    /**
     * 统一就诊流程(封装子系统调用,对外暴露单一接口)
     * @param name 患者姓名
     * @param department 就诊科室
     * @param amount 缴费金额
     * @param drugNames 药品名称
     */
    public void processMedicalService(String name, String department, double amount, String... drugNames) {
        log.info("\n===== {} 发起微信自助就诊流程 =====", name);
        try {
            // 1. 调用挂号子系统
            registerWindow.register(name, department);
            // 2. 调用医保缴费子系统
            paymentWindow.socialInsuranceSettlement(name, amount);
            // 3. 调用取药子系统
            drugWindow.takeDrug(name, drugNames);
            log.info("===== {} 就诊流程全部完成 =====", name);
        } catch (Exception e) {
            log.error("===== {} 就诊流程失败 =====", name, e);
            throw new RuntimeException("就诊流程异常,请重试", e);
        }
    }
}

测试类

/**
 * 客户端:测试外观模式调用
 */
public class LibifuTestFacadeClient {
    public static void main(String[] args) {
        // 1. 获取外观类实例(仅需与外观类交互)
        LibifuTestWeixinHospitalFacade weixinFacade = new LibifuTestWeixinHospitalFacade();
        // 2. 调用统一接口,完成就诊全流程(无需关注子系统细节)
        weixinFacade.processMedicalService(
            "libifu", 
            "呼吸内科", 
            198.5, 
            "布洛芬缓释胶囊""感冒灵颗粒"
        );
    }
}

运行结果

框架应用

Spring 框架中外观模式(Facade Pattern) 最经典的落地是 ApplicationContext 接口及其实现类。

ApplicationContext 作为「外观类」,封装了底层多个复杂子系统:

  • BeanFactory(Bean 创建 / 管理核心);
  • ResourceLoader(配置文件 / 资源加载);
  • ApplicationEventPublisher(事件发布);
  • MessageSource(国际化消息处理);
  • EnvironmentCapable(环境变量 / 配置解析)。

开发者无需关注这些子系统的交互细节,仅通过 ApplicationContext 提供的统一接口(如 getBean()、publishEvent())即可完成 Spring 容器的所有核心操作 —— 就像程序员通过「微信自助程序」看病,不用关心医院内部挂号 / 缴费 / 取药的流程,只调用统一入口即可,这正是外观模式「简化复杂系统交互」的核心价值。

以下选取ApplicationContext 、AbstractApplicationContext核心代码片段,展示外观模式的落地逻辑:

package org.springframework.context;
import org.springframework.beans.factory.HierarchicalBeanFactory;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.core.env.EnvironmentCapable;
import org.springframework.core.io.support.ResourcePatternResolver;
/**
 * 外观接口:整合多个子系统接口,提供统一的容器操作入口
 */
public interface ApplicationContext extends EnvironmentCapable, ListableBeanFactory, 
        HierarchicalBeanFactory, MessageSource, ApplicationEventPublisher, ResourcePatternResolver {
    // 1. 获取应用上下文唯一ID(封装底层无,仅统一暴露)
    String getId();
    // 2. 获取应用名称(统一接口)
    String getApplicationName();
    // 3. 获取上下文显示名称(统一接口)
    String getDisplayName();
    // 4. 获取上下文首次加载的时间戳(统一接口)
    long getStartupDate();
    // 5. 获取父上下文(封装层级BeanFactory的父容器逻辑)
    ApplicationContext getParent();
    // 6. 获取自动装配BeanFactory(封装底层BeanFactory的自动装配能力,核心子系统入口)
    AutowireCapableBeanFactory getAutowireCapableBeanFactory() throws IllegalStateException;
}
package org.springframework.context.support;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.ConfigurableApplicationContext;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract class AbstractApplicationContext extends DefaultResourceLoader
        implements ConfigurableApplicationContext {
    // ========== 核心1:refresh() - 封装所有子系统的初始化逻辑 ==========
    @Override
    public void refresh() throws BeansException, IllegalStateException {
        synchronized (this.startupShutdownMonitor) {
            // 1. 封装子系统初始化前置检查
            prepareRefresh();
            // 2. 封装BeanFactory子系统的创建/刷新(子类实现具体BeanFactory,如DefaultListableBeanFactory)
            ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
            // 3. 封装BeanFactory子系统的基础配置
            prepareBeanFactory(beanFactory);
            try {
                // xxx 其他源码省略
                // 4. 封装BeanFactory后置处理器执行、事件系统初始化、单例Bean初始化等所有子系统逻辑
                finishBeanFactoryInitialization(beanFactory);
                // 5. 封装容器激活、刷新完成事件发布(子系统收尾)
                finishRefresh();
            } catch (BeansException ex) {
                // 6. 封装子系统初始化失败的回滚逻辑
            }
        }
    }
    // ========== 核心2:getBean() - 封装BeanFactory子系统的调用 + 状态检查 ==========
    @Override
    public <T> T getBean(Class<T> requiredType) throws BeansException {
        // 外观层封装:子系统状态检查(客户端无需关注BeanFactory是否活跃)
        assertBeanFactoryActive();
        // 外观层委托:调用底层BeanFactory子系统的getBean,客户端无需关注BeanFactory具体实现
        return getBeanFactory().getBean(requiredType);
    }
    // ========== 抽象方法:委托子类实现具体BeanFactory获取(屏蔽子系统实现) ==========
    public abstract ConfigurableListableBeanFactory getBeanFactory() throws IllegalStateException;
}

Spring 通过 ApplicationContext(外观接口)和 AbstractApplicationContext(外观实现)封装了其他子系统的复杂逻辑:

  • 客户端只需调用 ApplicationContext.getBean() 即可获取 Bean,无需关注底层 Bean 的缓存、实例化、状态检查等细节;
  • 外观类屏蔽了子系统的复杂度,降低了客户端与底层 BeanFactory 的耦合,符合外观模式的设计思想。

行为型模式

为什么用行为型模式?

  • 行为型模式关注点“怎样运行对象/类”关注类/对象的运行时流程控制。
  • 行为型模式用于描述程序在运行时复杂的流程控制,描述多个类或对象之间怎样相互协作共同完成单个对象都无法单独完成的任务,它涉及算法与对象间职责的分配。
  • 行为型模式分为类行为模式和对象行为模式,前者采用继承机制来在类间分派行为后者采用组合或聚合在对象间分配行为。由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象行为模式比类行为模式具有更大的灵活性。
  • 模板方法(Template Method)模式:父类定义算法骨架,某些实现放在子类
  • 策略(Strategy)模式:每种算法独立封装,根据不同情况使用不同算法策略
  • 状态(State)模式:每种状态独立封装,不同状态内部封装了不同行为
  • 命令(Command)模式:将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开
  • 责任链(Chain of Responsibility)模式:所有处理者封装为链式结构,依次调用
  • 备忘录(Memento)模式:把核心信息抽取出来,可以进行保存
  • 解释器(Interpreter)模式:定义语法解析规则
  • 观察者(Observer)模式:维护多个观察者依赖,状态变化通知所有观察者
  • 中介者(Mediator)模式:取消类/对象的直接调用关系,使用中介者维护
  • 迭代器(Iterator)模式:定义集合数据的遍历规则
  • 访问者(Visitor)模式:分离对象结构,与元素的执行算法

除了模板方法模式和解释器模式是类行为型模式,其他的全部属于对象行为型模式。

行为型模式之策略模式

策略模式(Strategy Pattern)指的是一个类的行为或其算法可以在运行时更改,在策略模式中,我们创建表示各种策略的对象和一个行为随着策略对象改变而改变的 context 对象,策略对象改变 context 对象的执行算法。

意图: 定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。

主要解决: 在有多种算法相似的情况下,使用 if...else 所带来的复杂和难以维护。

何时使用: 一个系统有许多许多类,而区分它们的只是它们之间的行为。

如何解决: 将这些算法封装成一个一个的类,任意地替换。

优点:

1、算法可以自由切换。

2、避免使用多重条件判断。

3、扩展性良好。

缺点:

1、策略类会增多。

2、所有策略类都需要对外暴露。

使用场景:

1、如果在一个系统里面有许多类,它们之间的区别仅在于它们的行为,那么使用策略模式可以

动态地让一个对象在许多行为中选择一种行为。

2、一个系统需要动态地在几种算法中选择一种。

3、线程池拒绝策略。

策略模式简单应用

在电商支付系统中,都会支持多种支付方式(微信、支付宝、银联),每种支付方式对应一种 “支付策略”,客户端可根据用户选择动态切换策略,无需修改支付核心逻辑,下面就使用策略模式简单实现:

策略接口(定义统一算法规范)

/**
 * 策略接口:支付策略(定义所有支付方式的统一规范)
 */
public interface LibifuTestPaymentStrategy {
    /**
     * 执行支付逻辑
     * @param amount 支付金额(元)
     * @param orderId 订单ID
     * @return 支付结果(成功/失败)
     */
    String pay(double amount, String orderId);
}

具体策略类 1:微信支付

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 具体策略:微信支付(实现支付策略接口)
 */
public class LibifuTestWechatPayStrategy implements LibifuTestPaymentStrategy {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestWechatPayStrategy.class);
    @Override
    public String pay(double amount, String orderId) {
        log.info("【微信支付】开始处理订单:{},金额:{}元", orderId, amount);
        // 模拟微信支付核心逻辑(签名、调用微信接口等)
        boolean isSuccess = true// 模拟支付成功
        if (isSuccess) {
            String result = String.format("【微信支付】订单%s支付成功,金额:%.2f元", orderId, amount);
            log.info(result);
            return result;
        } else {
            String result = String.format("【微信支付】订单%s支付失败", orderId);
            log.error(result);
            return result;
        }
    }
}

具体策略类 2:支付宝支付

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 具体策略:支付宝支付(实现支付策略接口)
 */
public class LibifuTestAlipayStrategy implements LibifuTestPaymentStrategy {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestAlipayStrategy.class);
    @Override
    public String pay(double amount, String orderId) {
        log.info("【支付宝支付】开始处理订单:{},金额:{}元", orderId, amount);
        // 模拟支付宝支付核心逻辑(验签、调用支付宝接口等)
        boolean isSuccess = true// 模拟支付成功
        if (isSuccess) {
            String result = String.format("【支付宝支付】订单%s支付成功,金额:%.2f元", orderId, amount);
            log.info(result);
            return result;
        } else {
            String result = String.format("【支付宝支付】订单%s支付失败", orderId);
            log.error(result);
            return result;
        }
    }
}

具体策略类 3:银联支付

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 具体策略:银联支付(实现支付策略接口)
 */
public class LibifuTestUnionPayStrategy implements LibifuTestPaymentStrategy {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestUnionPayStrategy.class);
    @Override
    public String pay(double amount, String orderId) {
        log.info("【银联支付】开始处理订单:{},金额:{}元", orderId, amount);
        // 模拟银联支付核心逻辑(加密、调用银联接口等)
        boolean isSuccess = true// 模拟支付成功
        if (isSuccess) {
            String result = String.format("【银联支付】订单%s支付成功,金额:%.2f元", orderId, amount);
            log.info(result);
            return result;
        } else {
            String result = String.format("【银联支付】订单%s支付失败", orderId);
            log.error(result);
            return result;
        }
    }
}

上下文类(封装策略调用,屏蔽算法细节)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
 * 上下文类:支付上下文(持有策略对象,提供统一调用入口)
 * 作用:客户端仅与上下文交互,无需直接操作具体策略
 */
public class LibifuTestPaymentContext {
    private static final Logger log = LoggerFactory.getLogger(LibifuTestPaymentContext.class);
    // 持有策略对象(可动态替换)
    private LibifuTestPaymentStrategy paymentStrategy;
    /**
     * 构造器:初始化支付策略
     * @param paymentStrategy 具体支付策略
     */
    public LibifuTestPaymentContext(LibifuTestPaymentStrategy paymentStrategy) {
        this.paymentStrategy = paymentStrategy;
    }
    /**
     * 动态切换支付策略
     * @param paymentStrategy 新的支付策略
     */
    public void setPaymentStrategy(LibifuTestPaymentStrategy paymentStrategy) {
        log.info("【支付上下文】切换支付策略:{}", paymentStrategy.getClass().getSimpleName());
        this.paymentStrategy = paymentStrategy;
    }
    /**
     * 统一支付入口(屏蔽策略细节,对外暴露简洁方法)
     * @param amount 支付金额
     * @param orderId 订单ID
     * @return 支付结果
     */
    public String executePay(double amount, String orderId) {
        log.info("【支付上下文】开始处理订单{}的支付请求", orderId);
        return paymentStrategy.pay(amount, orderId);
    }
}

测试类

/**
 * 客户端:测试策略模式(动态切换支付方式)
 */
public class LibifuTestStrategyClient {
    public static void main(String[] args) {
        // 1. 订单信息
        String orderId"ORDER_20251213_001";
        double amount199.99;
        // 2. 选择微信支付策略
        LibifuTestPaymentContext paymentContext = new LibifuTestPaymentContext(new LibifuTestWechatPayStrategy());
        String wechatResult = paymentContext.executePay(amount, orderId);
        System.out.println(wechatResult);
        // 3. 动态切换为支付宝支付策略
        paymentContext.setPaymentStrategy(new LibifuTestAlipayStrategy());
        String alipayResult = paymentContext.executePay(amount, orderId);
        System.out.println(alipayResult);
        // 4. 动态切换为银联支付策略
        paymentContext.setPaymentStrategy(new LibifuTestUnionPayStrategy());
        String unionPayResult = paymentContext.executePay(amount, orderId);
        System.out.println(unionPayResult);
    }
}

运行结果

框架应用

在Spring 中 ,ResourceLoader 接口及实现类是策略模式的典型落地:

  • 策略接口:ResourceLoader(定义 “加载资源” 的统一规范);
  • 具体策略:DefaultResourceLoader(默认资源加载)、FileSystemResourceLoader(文件系统加载)、ClassPathXmlApplicationContext(类路径加载)等;
  • 核心价值:不同资源(类路径、文件系统、URL)的加载逻辑封装为独立策略,可灵活切换且不影响调用方。
  • 以下选取ResourceLoader 、FileSystemResourceLoader核心代码片段,展示策略模式的落地逻辑:

package org.springframework.core.io;
import org.springframework.lang.Nullable;
/**
 * 策略接口:定义资源加载的统一规范(策略模式核心接口)
 */
public interface ResourceLoader {
    // 类路径资源前缀(常量,子系统细节)
    String CLASSPATH_URL_PREFIX = "classpath:";
    /**
     * 策略核心方法:根据资源路径加载Resource(所有具体策略需实现此方法)
     * @param location 资源路径(如classpath:application.xml、file:/data/config.xml)
     * @return 封装后的Resource对象
     */
    Resource getResource(String location);
    /**
     * 辅助方法:获取类加载器(策略实现时依赖)
     */
    @Nullable
    ClassLoader getClassLoader();
}
package org.springframework.core.io;
/**
 * 具体策略:文件系统资源加载器(覆盖扩展点实现文件系统加载)
 */
public class FileSystemResourceLoader extends DefaultResourceLoader {
    /**
     * 覆盖策略扩展点:实现文件系统路径加载
     */
    @Override
    protected Resource getResourceByPath(String path) {
        // 若路径为绝对路径,直接创建FileSystemResource
        if (path.startsWith("/")) {
            return new FileSystemResource(path);
        }
        // 否则创建文件系统上下文资源(支持相对路径)
        else {
            return new FileSystemContextResource(path);
        }
    }
    /**
     * 内部类:文件系统上下文资源(策略辅助实现)
     */
    private static class FileSystemContextResource extends FileSystemResource {
        public FileSystemContextResource(String path) {
            super(path);
        }
        // xxx
    }
}
角色 类 / 接口 作用
策略接口 ResourceLoader 定义getResource统一加载规范,屏蔽不同资源加载的细节
抽象策略 DefaultResourceLoader 实现通用加载逻辑(类路径、URL),提供扩展点getResourceByPath
具体策略 FileSystemResourceLoader 覆盖扩展点,实现文件系统资源加载的专属逻辑
调用方 ApplicationContext(如ClassPathXmlApplicationContext) 依赖ResourceLoader接口,无需关注具体加载策略,可灵活切换

三、实战

背景

除了大家熟悉的"出价还价"列表外,现在订单列表、"想要"收藏列表等场景也能看到心仪商品的还价信息——还价功能,在用户体验上逐步从单一场景向多场景持续演进。

1.0 版本:

在功能初期,我们采用轻量级的设计思路:

  • 聚焦核心场景:仅在还价列表页提供精简高效的还价服务
  • 极简技术实现:通过线性调用商品/库存/订单等等服务,确保功能稳定交付
  • 智能引导策略:内置还价优先级算法,帮助用户快速决策

2.0 版本:

但随着得物还价功能不断加强,系统面临了一些烦恼:

  • 场景维度:订单列表、想要<收藏>列表等新场景接入
  • 流量维度:部分页面的访问量呈指数级增长,峰值较初期上升明显

我们发现原有设计逐渐显现出一些局限性:

  • 用户体验优化:随着用户规模快速增长,如何在高并发场景下依然保持丝滑流畅的还价体验,成为重要关注点
  • 迭代效率:每次新增业务场景都需要重复开发相似逻辑
  • 协作效率:功能迭代的沟通和对接成本增加

改造点

针对上述问题,我们采用策略模式进行代码结构升级,核心改造点包括:

抽象策略接口

public interface xxxQueryStrategy {
    /**
     * 策略类型
     *
     * @return 策略类型
     */
    String matchType();
    /**
     * 前置校验
     *
     * @param ctx xxx上下文
     * @return true-校验通过;false-校验未通过
     */
    boolean beforeProcess(xxxCtx ctx);
    /**
     * 执行策略
     *
     * @param ctx xxx上下文
     * @return xxxdto
     */
    xxxQueryDTO handle(xxxtx ctx);
    /**
     * 后置处理
     *
     * @param ctx xxx上下文
     */
    void afterProcess(xxxCtx ctx);
}

抽象基类 :封装公共数据查询逻辑

@Slf4j
@Component
public abstract class AbstractxxxStrategy {
        /**
         * 执行策略
         *
         * @param ctx xxx上下文
         */
        public void doHandler(xxxCtx ctx) {
            // 初始化xxx数据
            initxxx(ctx);
            // 异步查询相关信息
            supplyAsync(ctx);
            // 初始化xxx上下文
            initxxxCtx(ctx);
            // 查询xxxx策略
            queryxxxGuide(ctx);
            // 查询xxx底部策略
            queryxxxBottomGuide(ctx);
        }
        /**
         * 初始化xxx数据
         *
         * @param ctx xxx上下文
         */
        protected abstract void initxxx(xxxCtx ctx);




        /**
         * 异步查询相关信息
         *
         * @param ctx xxx上下文
         */
        protected abstract void supplyAsync(xxxCtx ctx);


        /**
         * 初始化xxx上下文
         *
         * @param ctx xxx上下文
         */
        protected abstract void initxxxCtx(xxxCtx ctx);


        /**
         * 查询xxx策略
         *
         * @param ctx xxx上下文
         */
        protected abstract void queryxxxGuide(xxxCtx ctx);


        /**
         * 查询xxx底部策略
         *
         * @param ctx xxx上下文
         */
        protected abstract void queryxxxBottomGuide(xxxCtx ctx);


        /**
         * 构建出参
         *
         * @param ctx xxx上下文
         */
        protected abstract void buildXXX(xxxCtx ctx);
}

具体策略 :实现场景特有逻辑

public class xxxStrategy extends AbstractxxxxStrategy implements xxxStrategy {
    /**
     * 策略类型
     *
     * @return 策略类型
     */
    @Override
    public String matchType() {
        // XXX
    }


    /**
     * 前置校验
     *
     * @param ctx xxx上下文
     * @return true-校验通过;false-校验未通过
     */
    @Override
    public boolean beforeProcess(xxxCtx ctx) {
        // XXX
    }


    /**
     * 执行策略
     *
     * @param ctx  xxx上下文
     * @return 公共出参
     */
    @Override
    public BuyerBiddingQueryDTO handle(xxxCtx ctx) {
        super.doHandler(ctx);
        // XXX
    }


    /**
     * 后置处理
     *
     * @param ctx xxx上下文
     */
    @Override
    public void afterProcess(xxxCtx ctx) {
       // XXX
    }


    /**
     * 初始化xxx数据
     *
     * @param ctx xxx上下文
     */
    @Override
    protected void initxxx(xxxCtx ctx) {
        // XXX
    }


    /**
     * 异步查询相关信息
     *
     * @param ctx  XXX上下文
     */
    @Override
    protected void supplyAsync(xxxCtx ctx) {
        // 前置异步查询
        super.preBatchAsyncxxx(ctx);
        // 策略定制业务
        // XXX
    }


    /**
     * 初始化XXX上下文
     *
     * @param ctx XXX上下文
     */
    @Override
    protected void initGuideCtx(xxxCtx ctx) {
        // XXX
    }


    /**
     * 查询XXX策略
     *
     * @param ctx XXX上下文
     */
    @Override
    protected void queryXXXGuide(xxxCtx ctx) {
        // XXX
    }


    /**
     * 查询XXX策略
     *
     * @param ctx XXX上下文
     */
    @Override
    protected void queryXXXBottomGuide(XXXCtx ctx) {
        // XXX
    }


    /**
     * 构建出参
     *
     * @param ctx XXX上下文
     */
    @Override
    protected void buildXXX(XXXCtx ctx) {
        // XXX
    }
}

运行时策略路由

@Component
@RequiredArgsConstructor
public class xxxStrategyFactory {
    private final List<xxxStrategy> xxxStrategyList;


    private final Map<String, xxxStrategy> strategyMap = new HashMap<>();


    @PostConstruct
    public void init() {
        CollectionUtils.emptyIfNull(xxxStrategyList)
                .stream()
                .filter(Objects::nonNull)
                .forEach(strategy -> strategyMap.put(strategy.matchType(), strategy));
    }


    public xxxStrategy select(String scene) {
        return strategyMap.get(scene); 
    }
}

升级收益

1.性能提升 :

  • 同步调用改为CompletableFuture异步编排
  • 并行化独立IO操作,降低整体响应时间

2.扩展性增强 :

  • 新增场景只需实现新的Strategy类
  • 符合开闭原则(对扩展开放,对修改关闭)

3.可维护性改善 :

  • 业务逻辑按场景垂直拆分
  • 公共逻辑下沉到抽象基类
  • 消除复杂的条件分支判断

4.架构清晰度 :

  • 明确的策略接口定义
  • 各策略实现类职责单一

这种架构改造体现了组合优于继承 、面向接口编程等设计原则,通过策略模式将原本复杂的单体式结构拆分为可插拔的组件,为后续业务迭代提供了良好的扩展基础。

四、总结

在软件开发中,设计模式是一种解决特定场景问题的通用方法论,旨在提升代码的可读性、可维护性和可复用性。其核心优势在于清晰的职责分离理念、灵活的行为抽象能力以及对系统结构的优化设计。结合丰富的实践经验,设计模式已经成为开发者应对复杂业务需求、构建高质量软件系统的重要指导原则。

本文通过解析一些经典设计模式的原理、框架应用与实战案例,深入探讨了设计模式在实际开发中的价值与作用。作为代码优化的工具,更作为一种开发哲学,设计模式以简洁优雅的方式解决复杂问题,推动系统的高效与稳健。

当然了,在实际的软件开发中,我们应根据实际需求合理选择和应用设计模式,避免过度设计,同时深入理解其背后的理念,最终实现更加高效、健壮的代码与系统架构。

往期回顾

1.从0到1搭建一个智能分析OBS埋点数据的AI Agent|得物技术

2.数据库AI方向探索-MCP原理解析&DB方向实战|得物技术

3.项目性能优化实践:深入FMP算法原理探索|得物技术

4.Dragonboat统一存储LogDB实现分析|得物技术

5.从数字到版面:得物数据产品里数字格式化的那些事

文 /忘川

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

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

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

❌
❌