Compare commits
72 Commits
c3211a70d8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f47e512d02 | |||
| a5569f70ae | |||
| 911429d591 | |||
| eadbf47c5f | |||
| 9fb8c23518 | |||
| 2be39b4d81 | |||
| b839e361ac | |||
| b40d3f14a8 | |||
| 46366ef73d | |||
| 597159298f | |||
| 21414245d9 | |||
| 9c11e484f3 | |||
| b3c0452621 | |||
| a64f20e84f | |||
| a5976c9783 | |||
| 845a29f476 | |||
| a825ccdadb | |||
| 2777ef8676 | |||
| 6ab2f8a1f2 | |||
| 46545ca0c2 | |||
| efc1f5fbae | |||
| 8c9b3adc57 | |||
| ad9a4d6806 | |||
| e8081aa92a | |||
| 306f8df886 | |||
| 0ea90ccccf | |||
| 94c1157260 | |||
| 906e8ea5d5 | |||
| fa8f0da32a | |||
| 681aff8bd5 | |||
| a0c40be4b6 | |||
| 62d0b0515e | |||
| 24c3fded6c | |||
| e4717ac906 | |||
| 40b5f462fe | |||
| cbe8821376 | |||
| b39d345da9 | |||
| 2549b696e1 | |||
| 4f07faaa64 | |||
| b6669a1731 | |||
| 939cf05db2 | |||
| 66d565d65b | |||
| 3911a00a51 | |||
| c2516e04b5 | |||
| 4fc8d4eecc | |||
| 6e054f7cae | |||
| fb404f2bcf | |||
| 228109ee81 | |||
| 5b02b1c729 | |||
| 49ff8fd7d6 | |||
| ac278aae47 | |||
| b4db9aae81 | |||
| 2bb60d00e8 | |||
| 7dfd151119 | |||
| cbcc48d457 | |||
| 564799001a | |||
| 8b2c289833 | |||
| c68d66f01a | |||
| f7a52fc11a | |||
| 845f225ff7 | |||
| 23413ce227 | |||
| 33a328c852 | |||
| 2752cc0f9e | |||
| cf8fec51a0 | |||
| aa0603efff | |||
| bda2256ea7 | |||
| e1aae7080e | |||
| daab0c4495 | |||
| c064dfc417 | |||
| da19dd00ca | |||
| c5ac06f6eb | |||
| 8398b40176 |
10
.idea/UniappTool.xml
generated
Normal file
10
.idea/UniappTool.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="cn.fjdmy.uniapp.UniappProjectDataService">
|
||||||
|
<option name="generalBasePath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="manifestPath" value="$PROJECT_DIR$/manifest.json" />
|
||||||
|
<option name="pagesPath" value="$PROJECT_DIR$/pages.json" />
|
||||||
|
<option name="scanNum" value="1" />
|
||||||
|
<option name="type" value="store" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
39
.idea/dataSources.local.xml
generated
Normal file
39
.idea/dataSources.local.xml
generated
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="dataSourceStorageLocal" created-in="WS-253.29346.143">
|
||||||
|
<data-source name="@localhost" uuid="0a5f547c-9d7e-48c2-8022-49fb896f0b20">
|
||||||
|
<database-info product="MySQL" version="8.4.9" jdbc-version="4.2" driver-name="MySQL Connector/J" driver-version="mysql-connector-j-9.5.0 (Revision: a7b3c94f50efbddb9f0dd69b3e0d1aaa25305cd6)" dbms="MYSQL" exact-version="8.4.9" exact-driver-version="9.5">
|
||||||
|
<extra-name-characters>$</extra-name-characters>
|
||||||
|
<identifier-quote-string>`</identifier-quote-string>
|
||||||
|
</database-info>
|
||||||
|
<case-sensitivity plain-identifiers="lower" quoted-identifiers="lower" />
|
||||||
|
<secret-storage>master_key</secret-storage>
|
||||||
|
<user-name>root</user-name>
|
||||||
|
<schema-mapping>
|
||||||
|
<introspection-scope>
|
||||||
|
<node kind="schema" qname="@" />
|
||||||
|
</introspection-scope>
|
||||||
|
</schema-mapping>
|
||||||
|
</data-source>
|
||||||
|
<data-source name="192.168.1.7" uuid="d432891a-8082-4b1f-89b4-c55af2fe67de">
|
||||||
|
<database-info product="MySQL" version="8.0.31" jdbc-version="4.2" driver-name="MySQL Connector/J" driver-version="mysql-connector-j-9.5.0 (Revision: a7b3c94f50efbddb9f0dd69b3e0d1aaa25305cd6)" dbms="MYSQL" exact-version="8.0.31" exact-driver-version="9.5">
|
||||||
|
<extra-name-characters>$</extra-name-characters>
|
||||||
|
<identifier-quote-string>`</identifier-quote-string>
|
||||||
|
</database-info>
|
||||||
|
<case-sensitivity plain-identifiers="exact" quoted-identifiers="exact" />
|
||||||
|
<secret-storage>master_key</secret-storage>
|
||||||
|
<user-name>root</user-name>
|
||||||
|
<schema-mapping>
|
||||||
|
<introspection-scope>
|
||||||
|
<node kind="schema">
|
||||||
|
<name qname="@" />
|
||||||
|
<name qname="blog" />
|
||||||
|
<name qname="food_hub" />
|
||||||
|
<name qname="sweet_hut" />
|
||||||
|
<name qname="xxl_job" />
|
||||||
|
</node>
|
||||||
|
</introspection-scope>
|
||||||
|
</schema-mapping>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
19
.idea/dataSources.xml
generated
Normal file
19
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="@localhost" uuid="0a5f547c-9d7e-48c2-8022-49fb896f0b20">
|
||||||
|
<driver-ref>mysql.8</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:mysql://localhost:3306</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
<data-source source="LOCAL" name="192.168.1.7" uuid="d432891a-8082-4b1f-89b4-c55af2fe67de">
|
||||||
|
<driver-ref>mysql.8</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:mysql://192.168.1.7:3306</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#n:information_schema
|
||||||
|
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||||
2
.idea/dataSources/0a5f547c-9d7e-48c2-8022-49fb896f0b20/storage_v2/_src_/schema/mysql.osA4Bg.meta
generated
Normal file
2
.idea/dataSources/0a5f547c-9d7e-48c2-8022-49fb896f0b20/storage_v2/_src_/schema/mysql.osA4Bg.meta
generated
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#n:mysql
|
||||||
|
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#n:performance_schema
|
||||||
|
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||||
2
.idea/dataSources/0a5f547c-9d7e-48c2-8022-49fb896f0b20/storage_v2/_src_/schema/sys.zb4BAA.meta
generated
Normal file
2
.idea/dataSources/0a5f547c-9d7e-48c2-8022-49fb896f0b20/storage_v2/_src_/schema/sys.zb4BAA.meta
generated
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#n:sys
|
||||||
|
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#n:information_schema
|
||||||
|
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||||
2
.idea/dataSources/d432891a-8082-4b1f-89b4-c55af2fe67de/storage_v2/_src_/schema/mysql.osA4Bg.meta
generated
Normal file
2
.idea/dataSources/d432891a-8082-4b1f-89b4-c55af2fe67de/storage_v2/_src_/schema/mysql.osA4Bg.meta
generated
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#n:mysql
|
||||||
|
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#n:performance_schema
|
||||||
|
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||||
2
.idea/dataSources/d432891a-8082-4b1f-89b4-c55af2fe67de/storage_v2/_src_/schema/sys.zb4BAA.meta
generated
Normal file
2
.idea/dataSources/d432891a-8082-4b1f-89b4-c55af2fe67de/storage_v2/_src_/schema/sys.zb4BAA.meta
generated
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#n:sys
|
||||||
|
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||||
50
.idea/dataSources/data_sources_history.xml
generated
Normal file
50
.idea/dataSources/data_sources_history.xml
generated
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<DataSourcesHistory>
|
||||||
|
<DataSourceFromHistory isRemovedFromProject="false">
|
||||||
|
<data-source source="LOCAL" name="@localhost" uuid="0a5f547c-9d7e-48c2-8022-49fb896f0b20">
|
||||||
|
<database-info product="MySQL" version="8.4.9" jdbc-version="4.2" driver-name="MySQL Connector/J" driver-version="mysql-connector-j-9.5.0 (Revision: a7b3c94f50efbddb9f0dd69b3e0d1aaa25305cd6)" dbms="MYSQL" exact-version="8.4.9" exact-driver-version="9.5">
|
||||||
|
<extra-name-characters>$</extra-name-characters>
|
||||||
|
<identifier-quote-string>`</identifier-quote-string>
|
||||||
|
</database-info>
|
||||||
|
<case-sensitivity plain-identifiers="lower" quoted-identifiers="lower" />
|
||||||
|
<driver-ref>mysql.8</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:mysql://localhost:3306</jdbc-url>
|
||||||
|
<secret-storage>master_key</secret-storage>
|
||||||
|
<user-name>root</user-name>
|
||||||
|
<schema-mapping>
|
||||||
|
<introspection-scope>
|
||||||
|
<node kind="schema" qname="@" />
|
||||||
|
</introspection-scope>
|
||||||
|
</schema-mapping>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</DataSourceFromHistory>
|
||||||
|
<DataSourceFromHistory isRemovedFromProject="false">
|
||||||
|
<data-source source="LOCAL" name="192.168.1.7" uuid="d432891a-8082-4b1f-89b4-c55af2fe67de">
|
||||||
|
<database-info product="MySQL" version="8.0.31" jdbc-version="4.2" driver-name="MySQL Connector/J" driver-version="mysql-connector-j-9.5.0 (Revision: a7b3c94f50efbddb9f0dd69b3e0d1aaa25305cd6)" dbms="MYSQL" exact-version="8.0.31" exact-driver-version="9.5">
|
||||||
|
<extra-name-characters>$</extra-name-characters>
|
||||||
|
<identifier-quote-string>`</identifier-quote-string>
|
||||||
|
</database-info>
|
||||||
|
<case-sensitivity plain-identifiers="exact" quoted-identifiers="exact" />
|
||||||
|
<driver-ref>mysql.8</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:mysql://192.168.1.7:3306</jdbc-url>
|
||||||
|
<secret-storage>master_key</secret-storage>
|
||||||
|
<user-name>root</user-name>
|
||||||
|
<schema-mapping>
|
||||||
|
<introspection-scope>
|
||||||
|
<node kind="schema">
|
||||||
|
<name qname="@" />
|
||||||
|
<name qname="blog" />
|
||||||
|
<name qname="food_hub" />
|
||||||
|
<name qname="sweet_hut" />
|
||||||
|
<name qname="xxl_job" />
|
||||||
|
</node>
|
||||||
|
</introspection-scope>
|
||||||
|
</schema-mapping>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</DataSourceFromHistory>
|
||||||
|
</DataSourcesHistory>
|
||||||
10
.idea/db-forest-config.xml
generated
Normal file
10
.idea/db-forest-config.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="db-forest-configuration">
|
||||||
|
<data version="2">.
|
||||||
|
----------------------------------------
|
||||||
|
1:0:0a5f547c-9d7e-48c2-8022-49fb896f0b20
|
||||||
|
2:0:d432891a-8082-4b1f-89b4-c55af2fe67de
|
||||||
|
.</data>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
5
Dockerfile
Normal file
5
Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
FROM nginx:1.27.0
|
||||||
|
COPY docs/.vitepress/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||||
|
RUN echo 'Asia/Shanghai' > /etc/timezone
|
||||||
@@ -1,57 +1,50 @@
|
|||||||
import { defineConfig } from 'vitepress'
|
import { defineConfig } from 'vitepress'
|
||||||
|
import { withMermaid } from 'vitepress-plugin-mermaid'
|
||||||
|
import { routers } from './theme/router'
|
||||||
|
|
||||||
// https://vitepress.dev/reference/site-config
|
// https://vitepress.dev/reference/site-config
|
||||||
export default defineConfig({
|
export default withMermaid({
|
||||||
lang: 'zh-CN',
|
title: "拾光记",
|
||||||
title: "Cxx0822",
|
|
||||||
description: "Cxx0822 Blog",
|
description: "Cxx0822 Blog",
|
||||||
|
head: [
|
||||||
|
['link', { rel: 'icon', href: '/blog-press/favicon.ico' }]
|
||||||
|
],
|
||||||
|
base: '/blog-press/',
|
||||||
|
lang: 'zh-CN',
|
||||||
|
locales: {
|
||||||
|
"/": {
|
||||||
|
label: "简体中文",
|
||||||
|
lang: "zh-CN",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
ssr: {
|
||||||
|
noExternal: ['vue3-calendar-heatmap']
|
||||||
|
}
|
||||||
|
},
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
|
logo: "/logo.png",
|
||||||
// https://vitepress.dev/reference/default-theme-config
|
// https://vitepress.dev/reference/default-theme-config
|
||||||
nav: [
|
nav: [
|
||||||
{ text: 'Home', link: '/' },
|
{ text: '🏠 Home', link: '/' },
|
||||||
{ text: 'Web', link: '/Web/' },
|
...routers
|
||||||
{ text: 'DevOps', link: '/DevOps/' }
|
|
||||||
],
|
],
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
'/Web/': [
|
'/Web/': [routers[0]],
|
||||||
{
|
'/DevOps/': [routers[1]],
|
||||||
text: 'Web',
|
'/Practice/': [routers[2]],
|
||||||
collapsible: true,
|
'/Others/': [routers[3]]
|
||||||
items: [
|
|
||||||
{ text: 'MySQL知识点', link: '/Web/MySQL知识点' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'/DevOps/': [
|
|
||||||
{
|
|
||||||
text: 'DevOps',
|
|
||||||
collapsible: true,
|
|
||||||
items: [
|
|
||||||
{ text: 'OpenObserve部署和简单使用', link: '/DevOps/OpenObserve部署和简单使用' },
|
|
||||||
{ text: 'Jenkins部署和使用', link: '/DevOps/Jenkins部署和使用' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// socialLinks: [
|
socialLinks: [
|
||||||
// { icon: 'github', link: 'https://github.com/vuejs/vitepress' }
|
{ icon: 'gitea', link: 'https://cxxgit.iepose.cn/Cxx0822/blog-press' }
|
||||||
// ]
|
],
|
||||||
|
|
||||||
footer: {
|
footer: {
|
||||||
message: '基于 VitePress 构建',
|
message: '基于 VitePress 构建',
|
||||||
copyright: 'Copyright © 2025 Cxx0822'
|
copyright: 'Copyright © 2025 Cxx0822'
|
||||||
},
|
},
|
||||||
|
|
||||||
lastUpdated: {
|
|
||||||
text: '最后更新于',
|
|
||||||
formatOptions: {
|
|
||||||
dateStyle: 'full',
|
|
||||||
timeStyle: 'medium'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
search: {
|
search: {
|
||||||
provider: 'local',
|
provider: 'local',
|
||||||
options: {
|
options: {
|
||||||
@@ -73,18 +66,40 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 返回顶部按钮配置
|
outline: {
|
||||||
returnToTop: {
|
label: "目录",
|
||||||
// 显示返回顶部按钮的阈值(滚动距离)
|
level: [1, 3]
|
||||||
threshold: 300,
|
|
||||||
// 按钮文本
|
|
||||||
text: '返回顶部'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
returnToTopLabel: "返回顶部",
|
||||||
|
|
||||||
// 上下篇导航
|
// 上下篇导航
|
||||||
docFooter: {
|
docFooter: {
|
||||||
prev: '上一篇',
|
prev: '上一篇',
|
||||||
next: '下一篇'
|
next: '下一篇'
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
sidebarMenuLabel: '菜单',
|
||||||
|
darkModeSwitchLabel: '外观',
|
||||||
|
|
||||||
|
lastUpdated: {
|
||||||
|
text: '最后更新于'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
lastUpdated: true,
|
||||||
|
|
||||||
|
markdown: {
|
||||||
|
lineNumbers: true,
|
||||||
|
image: {
|
||||||
|
lazyLoading: true
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
tipLabel: '提示',
|
||||||
|
warningLabel: '注意',
|
||||||
|
dangerLabel: '警告',
|
||||||
|
infoLabel: '信息',
|
||||||
|
detailsLabel: '详细信息'
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
19
docs/.vitepress/data/posts.data.js
Normal file
19
docs/.vitepress/data/posts.data.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createContentLoader } from 'vitepress'
|
||||||
|
|
||||||
|
export default createContentLoader('./**/*.md', {
|
||||||
|
includeSrc: true, // 包含原始 markdown 源
|
||||||
|
render: true, // 包含渲染的整页 HTML
|
||||||
|
transform(rawData) {
|
||||||
|
return rawData.filter(page => {
|
||||||
|
return page.url.includes('.html') && page.frontmatter.date
|
||||||
|
}).map((page) => {
|
||||||
|
// 对每个页面进行处理
|
||||||
|
return {
|
||||||
|
// 返回你需要的页面数据
|
||||||
|
title: page.frontmatter.title,
|
||||||
|
date: page.frontmatter.date,
|
||||||
|
url: page.url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
109
docs/.vitepress/theme/components/ArticleMetadata.vue
Normal file
109
docs/.vitepress/theme/components/ArticleMetadata.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="title">
|
||||||
|
<span>{{ page.frontmatter.title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!page.filePath.includes('index.md')" class="blog-stats">
|
||||||
|
<div v-if="page.frontmatter.isGreat" class="item">
|
||||||
|
<svg t="1765851905856" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11930" width="16" height="16"><path d="M0 0h1024v1024H0V0z" fill="#202425" opacity=".01" p-id="11931"></path><path d="M136.533333 307.2a34.133333 34.133333 0 0 1 34.133334-34.133333h682.666666a34.133333 34.133333 0 0 1 34.133334 34.133333v437.3504a68.266667 68.266667 0 0 1-35.566934 59.938133l-274.5344 149.7088a136.533333 136.533333 0 0 1-130.730666 0L172.100267 804.522667A68.266667 68.266667 0 0 1 136.533333 744.5504V307.2z" fill="#bd34fe" p-id="11932"></path><path d="M68.266667 136.533333a68.266667 68.266667 0 0 1 68.266666-68.266666h750.933334a68.266667 68.266667 0 0 1 68.266666 68.266666v117.4528a34.133333 34.133333 0 0 1-16.247466 29.0816l-373.828267 230.058667a102.4 102.4 0 0 1-107.3152 0L84.514133 283.0336A34.133333 34.133333 0 0 1 68.266667 253.952V136.533333z" fill="#bd34fe" p-id="11933"></path><path d="M496.605867 339.490133a17.066667 17.066667 0 0 1 30.788266 0L576.853333 443.050667a17.066667 17.066667 0 0 0 13.175467 9.557333l113.800533 15.018667a17.066667 17.066667 0 0 1 9.5232 29.2864l-83.2512 79.018666a17.066667 17.066667 0 0 0-5.051733 15.496534l20.923733 112.8448a17.066667 17.066667 0 0 1-24.917333 18.090666l-100.864-54.715733a17.066667 17.066667 0 0 0-16.315733 0l-100.864 54.715733a17.066667 17.066667 0 0 1-24.917334-18.090666l20.8896-112.8448a17.066667 17.066667 0 0 0-5.0176-15.496534l-83.217066-79.018666a17.066667 17.066667 0 0 1 9.489066-29.2864l113.800534-15.018667a17.066667 17.066667 0 0 0 13.175466-9.557333l49.425067-103.560534z" fill="#FFFFFF" p-id="11934"></path></svg>
|
||||||
|
精品
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item">
|
||||||
|
<svg t="1765933533911" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15052" width="16" height="16"><path d="M718.6 227.3c13.2 0 24.5-8.2 24.5-18.6V83.3c0-10.4-10.9-18.6-24.5-18.6-13.2 0-24.1 8.2-24.1 18.6v125.3c0 10.5 10.5 18.7 24.1 18.7zM305.4 227.3c13.2 0 24.1-8.2 24.1-18.6V83.3c0-10.4-10.9-18.6-24.1-18.6-13.2 0-24.5 8.2-24.5 18.6v125.3c0 10.5 10.9 18.7 24.5 18.7z" fill="#5CAF82" p-id="15053"></path><path d="M949.3 230.9c0-47.7-44-86.3-98.5-86.3H770v64.5c0 24.5-22.7 44.5-50.9 44.5s-50.9-20-50.9-44.5v-64.5H354.9v64.5c0 24.5-22.7 44.5-50.9 44.5s-50.9-20-50.9-44.5v-64.5h-79.9c-54.5 0-98.5 38.6-98.5 86.3V319h875v-88.1h-0.4zM74.3 873c0 47.7 44 86.3 98.5 86.3h677.5c54.5 0 98.5-38.6 98.5-86.3V363.1H74.3V873z m238.4-255.2c14.5-12.7 38.6-8.2 51.3 6.4L469.3 745 680 503c12.7-14.5 35-16.8 50.9-5.4 16.3 11.8 11.8 40 1.4 50.4L492.5 824c-12.7 14.5-33.1 14.5-45.9 0l-134-153c-12.2-14.6-15.4-39.6 0.1-53.2z" fill="#5CAF82" p-id="15054"></path></svg>
|
||||||
|
日期: {{ page.frontmatter.date.split('T')[0] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item">
|
||||||
|
<svg t="1724571760788" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6125" width="16" height="16"><path d="M204.8 0h477.866667l273.066666 273.066667v614.4c0 75.093333-61.44 136.533333-136.533333 136.533333H204.8c-75.093333 0-136.533333-61.44-136.533333-136.533333V136.533333C68.266667 61.44 129.706667 0 204.8 0z m307.2 607.573333l68.266667 191.146667c13.653333 27.306667 54.613333 27.306667 61.44 0l102.4-273.066667c6.826667-20.48 0-34.133333-20.48-40.96s-34.133333 0-40.96 13.653334l-68.266667 191.146666-68.266667-191.146666c-13.653333-27.306667-54.613333-27.306667-68.266666 0l-68.266667 191.146666-68.266667-191.146666c-6.826667-13.653333-27.306667-27.306667-47.786666-20.48s-27.306667 27.306667-20.48 47.786666l102.4 273.066667c13.653333 27.306667 54.613333 27.306667 61.44 0l75.093333-191.146667z" fill="#1890FF" p-id="6126"></path><path d="M682.666667 0l273.066666 273.066667h-204.8c-40.96 0-68.266667-27.306667-68.266666-68.266667V0z" fill="#52C41A" p-id="6127"></path></svg>
|
||||||
|
字数: {{ formatNumberUnit(wordCount) }} 字
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<svg t="1724572797268" class="icon" viewBox="0 0 1060 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15031" width="16" height="16"><path d="M556.726857 0.256A493.933714 493.933714 0 0 0 121.929143 258.998857L0 135.021714v350.390857h344.649143L196.205714 334.482286a406.820571 406.820571 0 1 1-15.908571 312.649143H68.937143A505.819429 505.819429 0 1 0 556.726857 0.256z m-79.542857 269.531429v274.907428l249.197714 150.966857 42.422857-70.070857-212.114285-129.389714V269.787429h-79.542857z" fill="#FA8C16" p-id="15032"></path></svg>
|
||||||
|
时长: {{ readTime }} 分钟
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
|
import { countWord, formatNumberUnit } from '../utils'
|
||||||
|
import { useData } from 'vitepress'
|
||||||
|
|
||||||
|
const wordCount = ref(0)
|
||||||
|
const imageCount = ref(0)
|
||||||
|
|
||||||
|
// 获取页面数据
|
||||||
|
const { page } = useData()
|
||||||
|
|
||||||
|
// 文字阅读时间
|
||||||
|
const wordTime = computed(() => {
|
||||||
|
return ((wordCount.value / 275) * 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 图片阅读时间
|
||||||
|
const imageTime = computed(() => {
|
||||||
|
const n = imageCount.value
|
||||||
|
if (imageCount.value <= 10) {
|
||||||
|
// 等差数列求和
|
||||||
|
return n * 13 + (n * (n - 1)) / 2
|
||||||
|
}
|
||||||
|
return 175 + (n - 10) * 3
|
||||||
|
})
|
||||||
|
|
||||||
|
// 阅读时间
|
||||||
|
const readTime = computed(() => {
|
||||||
|
return Math.ceil((wordTime.value + imageTime.value) / 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
const analyze = () => {
|
||||||
|
// 选择文档内容区域
|
||||||
|
const docDomContainer = window.document.querySelector('#VPContent')
|
||||||
|
// 统计图片数量
|
||||||
|
const imgs = docDomContainer?.querySelectorAll<HTMLImageElement>('.content-container .main img')
|
||||||
|
imageCount.value = imgs?.length || 0
|
||||||
|
|
||||||
|
// 统计文字数量(textContent:提取纯文本)
|
||||||
|
const words = docDomContainer?.querySelector('.content-container .main')?.textContent || ''
|
||||||
|
wordCount.value = countWord(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => page.value.title, () => {
|
||||||
|
// 路径变化时执行
|
||||||
|
analyze()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初始化时执行一次
|
||||||
|
analyze()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.blog-stats {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
background: -webkit-linear-gradient(10deg, #3DC8F0 5%, #8036FA 15%);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
transform: translate(0px , 2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
151
docs/.vitepress/theme/components/Backtotop.vue
Normal file
151
docs/.vitepress/theme/components/Backtotop.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="fade">
|
||||||
|
<div class="back-top-container" v-show="showBackTop">
|
||||||
|
<svg class="progress-ring" viewBox="0 0 100 100">
|
||||||
|
<circle class="progress-ring-background" cx="50" cy="50" r="42" />
|
||||||
|
<circle
|
||||||
|
class="progress-ring-circle"
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="42"
|
||||||
|
:style="{'stroke-dashoffset': circumference - (scrollProgress * circumference)}"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="vitepress-backTop-main"
|
||||||
|
title="返回顶部"
|
||||||
|
@click="scrollToTop()"
|
||||||
|
>
|
||||||
|
<svg class="icon" viewBox="0 0 1024 1024">
|
||||||
|
<path d="M752.736 431.063C757.159 140.575 520.41 8.97 504.518 0.41V0l-0.45 0.205-0.41-0.205v0.41c-15.934 8.56-252.723 140.165-248.259 430.653-48.21 31.457-98.713 87.368-90.685 184.074 8.028 96.666 101.007 160.768 136.601 157.287 35.595-3.482 25.232-30.31 25.232-30.31l12.206-50.095s52.47 80.569 69.304 80.528c15.114-1.23 87-0.123 95.6 0h0.82c8.602-0.123 80.486-1.23 95.6 0 16.794 0 69.305-80.528 69.305-80.528l12.165 50.094s-10.322 26.83 25.272 30.31c35.595 3.482 128.574-60.62 136.602-157.286 8.028-96.665-42.475-152.617-90.685-184.074z m-248.669-4.26c-6.758-0.123-94.781-3.359-102.891-107.192 2.95-98.714 95.97-107.438 102.891-107.93 6.964 0.492 99.943 9.216 102.892 107.93-8.11 103.833-96.174 107.07-102.892 107.192z m-52.019 500.531c0 11.838-9.42 21.382-21.012 21.382a21.217 21.217 0 0 1-21.054-21.34V821.74c0-11.797 9.421-21.382 21.054-21.382 11.591 0 21.012 9.585 21.012 21.382v105.635z m77.333 57.222a21.504 21.504 0 0 1-21.34 21.626 21.504 21.504 0 0 1-21.34-21.626V827.474c0-11.96 9.543-21.668 21.299-21.668 11.796 0 21.38 9.708 21.38 21.668v157.082z m71.147-82.043c0 11.796-9.42 21.34-21.053 21.34a21.217 21.217 0 0 1-21.013-21.34v-75.367c0-11.755 9.421-21.299 21.013-21.299 11.632 0 21.053 9.544 21.053 21.3v75.366z" fill="#FFF"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, computed } from "vue";
|
||||||
|
|
||||||
|
const showBackTop = ref(false); // 初始状态设为false
|
||||||
|
const scrollProgress = ref(0);
|
||||||
|
|
||||||
|
// 圆形进度条计算
|
||||||
|
const radius = 42;
|
||||||
|
const circumference = computed(() => 2 * Math.PI * radius);
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用更高效的节流函数
|
||||||
|
const throttle = (fn, delay = 50) => {
|
||||||
|
let timer = null;
|
||||||
|
return function(...args) {
|
||||||
|
if (!timer) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
fn.apply(this, args);
|
||||||
|
timer = null;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateScrollProgress = () => {
|
||||||
|
const { scrollY, innerHeight } = window;
|
||||||
|
const { scrollHeight } = document.documentElement;
|
||||||
|
const totalScroll = scrollHeight - innerHeight;
|
||||||
|
scrollProgress.value = totalScroll > 0 ? Math.min(scrollY / totalScroll, 1) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = throttle(() => {
|
||||||
|
// 当滚动超过100px时显示,否则隐藏
|
||||||
|
const shouldShow = window.scrollY > 100;
|
||||||
|
showBackTop.value = shouldShow;
|
||||||
|
updateScrollProgress();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
updateScrollProgress();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("scroll", handleScroll);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back-top-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vitepress-backTop-main {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: pointer;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #3eaf7c;
|
||||||
|
padding: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vitepress-backTop-main:hover {
|
||||||
|
background-color: #71cda3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring-background {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(62, 175, 124, 0.15);
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring-circle {
|
||||||
|
fill: none;
|
||||||
|
stroke: #3eaf7c;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-dasharray: 264; /* 2 * π * 42 */
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
14
docs/.vitepress/theme/components/Confetti.vue
Normal file
14
docs/.vitepress/theme/components/Confetti.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import confetti from "canvas-confetti";
|
||||||
|
|
||||||
|
/* 纸屑 */
|
||||||
|
confetti({
|
||||||
|
particleCount: 100,
|
||||||
|
spread: 170,
|
||||||
|
origin: { y: 0.6 },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
164
docs/.vitepress/theme/components/Loading.vue
Normal file
164
docs/.vitepress/theme/components/Loading.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div class="loading-content">
|
||||||
|
<!-- 动态加载动画 -->
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<div class="spinner-circle"></div>
|
||||||
|
<div class="spinner-arc"></div>
|
||||||
|
<div class="brand-dot">
|
||||||
|
<div class="dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载文字 -->
|
||||||
|
<div class="loading-text">
|
||||||
|
<h3>正在加载</h3>
|
||||||
|
<p>请稍候...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-circle {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 3px solid rgba(var(--vp-c-brand-rgb, 0, 0, 0), 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-arc {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-top: 3px solid var(--vp-c-brand);
|
||||||
|
border-right: 3px solid var(--vp-c-brand);
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
animation: spin 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--vp-c-brand);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 20px rgba(var(--vp-c-brand-rgb, 0, 0, 0), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
text-align: center;
|
||||||
|
animation: fadeInOut 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vp-c-text-1);
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--vp-c-text-2);
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInOut {
|
||||||
|
0%, 100% { opacity: 0.9; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 淡入淡出过渡动画 */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容滑入动画 */
|
||||||
|
.slide-up-enter-active {
|
||||||
|
transition: all 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.loading-content {
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
37
docs/.vitepress/theme/components/MenuList.vue
Normal file
37
docs/.vitepress/theme/components/MenuList.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="isNested">
|
||||||
|
<div v-for="category in props.routers.items" :key="category.text">
|
||||||
|
<h3>{{ category.text }}</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in category.items" :key="item.text">
|
||||||
|
<a :href="withBase(item.link)">{{ item.text }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in props.routers.items" :key="item.text">
|
||||||
|
<a :href="withBase(item.link)">{{ item.text }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { withBase } from 'vitepress'
|
||||||
|
import { defineProps } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
routers: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
isNested: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
64
docs/.vitepress/theme/components/MyLayout.vue
Normal file
64
docs/.vitepress/theme/components/MyLayout.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="isLoading" class="elegant-loading">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition name="slide-up">
|
||||||
|
<div v-show="!isLoading">
|
||||||
|
<DefaultTheme.Layout v-bind="$attrs">
|
||||||
|
<template #doc-before>
|
||||||
|
<ArticleMetadat />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #doc-footer-before>
|
||||||
|
<Backtotop />
|
||||||
|
</template>
|
||||||
|
</DefaultTheme.Layout>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import DefaultTheme from 'vitepress/theme'
|
||||||
|
import ArticleMetadat from './ArticleMetadata.vue';
|
||||||
|
import Backtotop from './Backtotop.vue';
|
||||||
|
import Loading from './Loading.vue';
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const handleLoad = () => {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止错过load加载完成
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
isLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载完成事件
|
||||||
|
window.addEventListener('load', handleLoad, { once: true })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.elegant-loading {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--vp-c-bg);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
82
docs/.vitepress/theme/components/StatsChart.vue
Normal file
82
docs/.vitepress/theme/components/StatsChart.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="!isMobile()">
|
||||||
|
<calendar-heatmap
|
||||||
|
:values="heatmapData"
|
||||||
|
:end-date="new Date()"
|
||||||
|
no-data-text="暂无记录"
|
||||||
|
tooltip-unit="篇"
|
||||||
|
:round="2"
|
||||||
|
:locale="{
|
||||||
|
less: '少于',
|
||||||
|
more: '多于',
|
||||||
|
months: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
|
||||||
|
days: ['日', '一', '二', '三', '四', '五', '六'],
|
||||||
|
}"
|
||||||
|
class="blog-heatmap"
|
||||||
|
/>
|
||||||
|
<div class="heatmap-footer">
|
||||||
|
⏱️ 已坚持<span class="highlight">{{ getDaysDifference(new Date(), startBlogDate) }}</span> 天
|
||||||
|
| 📝 已创作<span class="highlight">{{ data.length }}</span> 篇
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CalendarHeatmap } from 'vue3-calendar-heatmap';
|
||||||
|
import 'vue3-calendar-heatmap/dist/style.css';
|
||||||
|
import { data } from '../../data/posts.data.js'
|
||||||
|
import { isMobile, getDaysDifference, startBlogDate } from "../utils";
|
||||||
|
|
||||||
|
const dateCountMap = new Map<string, number>();
|
||||||
|
|
||||||
|
// 按日期分组统计
|
||||||
|
data.forEach(item => {
|
||||||
|
const date = item.date
|
||||||
|
|
||||||
|
if (dateCountMap.has(date)) {
|
||||||
|
dateCountMap.set(date, dateCountMap.get(date)! + 1);
|
||||||
|
} else {
|
||||||
|
dateCountMap.set(date, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换为目标格式
|
||||||
|
const heatmapData = Array.from(dateCountMap.entries()).map(([date, count]) => ({
|
||||||
|
date,
|
||||||
|
count
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.blog-heatmap {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-heatmap :deep(.vch__wrapper) {
|
||||||
|
font-family: 'CustomFont', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-heatmap :deep(.vch__month__label) {
|
||||||
|
font-size: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-heatmap :deep(.vch__day__label) {
|
||||||
|
font-size: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-footer {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6366f1;
|
||||||
|
margin: 0 2px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
docs/.vitepress/theme/fonts/custom.woff2
Normal file
BIN
docs/.vitepress/theme/fonts/custom.woff2
Normal file
Binary file not shown.
32
docs/.vitepress/theme/index.ts
Normal file
32
docs/.vitepress/theme/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import DefaultTheme from 'vitepress/theme'
|
||||||
|
import Confetti from "./components/Confetti.vue";
|
||||||
|
import ArticleMetadata from "./components/ArticleMetadata.vue"
|
||||||
|
import StatsChart from './components/StatsChart.vue'
|
||||||
|
import MenuList from './components/MenuList.vue'
|
||||||
|
import type { EnhanceAppContext } from 'vitepress'
|
||||||
|
import { useRoute } from 'vitepress'
|
||||||
|
import imageViewer from "vitepress-plugin-image-viewer";
|
||||||
|
import "viewerjs/dist/viewer.min.css";
|
||||||
|
import '../theme/style.css'
|
||||||
|
import './style/var.css'
|
||||||
|
import './style/vp-code.css'
|
||||||
|
import './style/custom-block.css'
|
||||||
|
import MyLayout from './components/MyLayout.vue';
|
||||||
|
import VPSwiper from '@cssnr/vitepress-swiper'
|
||||||
|
import '@cssnr/vitepress-swiper/style.css'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: DefaultTheme,
|
||||||
|
Layout: MyLayout,
|
||||||
|
setup() {
|
||||||
|
const route = useRoute();
|
||||||
|
imageViewer(route);
|
||||||
|
},
|
||||||
|
enhanceApp({ app }: EnhanceAppContext) {
|
||||||
|
app.component("Confetti", Confetti);
|
||||||
|
app.component("StatsChart", StatsChart);
|
||||||
|
app.component("MenuList", MenuList);
|
||||||
|
app.component("ArticleMetadata", ArticleMetadata);
|
||||||
|
app.component("VPSwiper", VPSwiper);
|
||||||
|
},
|
||||||
|
};
|
||||||
142
docs/.vitepress/theme/router/index.ts
Normal file
142
docs/.vitepress/theme/router/index.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
export const routers = [
|
||||||
|
{
|
||||||
|
text: '🌐 Web平台',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: '🌿 Vue',
|
||||||
|
items: [
|
||||||
|
{ text: 'Vue3架构原理', link: '/Web/Vue/Vue3-Architecture' },
|
||||||
|
{ text: 'Vue3自定义指令', link: '/Web/Vue/Vue3-Directive' },
|
||||||
|
{ text: 'Vue3 defineModel', link: '/Web/Vue/Vue3-DefineModel' },
|
||||||
|
{ text: 'JavaScript知识点整理', link: '/Web/Vue/JavaScript-Guide' },
|
||||||
|
{ text: '前端包管理器', link: '/Web/Vue/Package' },
|
||||||
|
{ text: 'Vite核心原理', link: '/Web/Vue/Vite-Principle' },
|
||||||
|
{ text: 'VueUse简介和使用', link: '/Web/Vue/VueUse' },
|
||||||
|
{ text: '浏览器渲染全流程解析', link: '/Web/Vue/Browser-Process' },
|
||||||
|
{ text: 'Element Plus 上传器', link: '/Web/Vue/ElUpload' },
|
||||||
|
{ text: 'Element Plus 表格组件', link: '/Web/Vue/ElTable' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🍃 SpringBoot',
|
||||||
|
items: [
|
||||||
|
{ text: 'Spring IOC简介', link: '/Web/SpringBoot/Spring-IOC' },
|
||||||
|
{ text: 'Spring AOP简介', link: '/Web/SpringBoot/Spring-AOP' },
|
||||||
|
{ text: 'Spring MVC简介', link: '/Web/SpringBoot/Spring-MVC' },
|
||||||
|
{ text: '启动流程', link: '/Web/SpringBoot/SpringBoot-Start-Process' },
|
||||||
|
{ text: 'Starter原理', link: '/Web/SpringBoot/SpringBoot-Starter' },
|
||||||
|
{ text: 'Bean简介', link: '/Web/SpringBoot/SpringBoot-Bean' },
|
||||||
|
{ text: 'SpingBoot注解', link: '/Web/SpringBoot/SpringBoot-Annotation' },
|
||||||
|
{ text: 'SpringBoot3原生镜像', link: '/Web/SpringBoot/SpringBoot3-GraalVM' },
|
||||||
|
{ text: 'SpringBoot技巧', link: '/Web/SpringBoot/SpringBoot-Skills' },
|
||||||
|
{ text: 'RestClient简介', link: '/Web/SpringBoot/SpringBoot-RestClient' },
|
||||||
|
{ text: 'Redis简介和使用', link: '/Web/SpringBoot/SpringBoot-Redis' },
|
||||||
|
{ text: 'RocketMQ简介和使用', link: '/Web/SpringBoot/SpringBoot-RocketMQ' },
|
||||||
|
{ text: 'WebSocket简介和使用', link: '/Web/SpringBoot/SpringBoot-WebSocket' },
|
||||||
|
{ text: 'Quarzt简介和使用', link: '/Web/SpringBoot/SpringBoot-Quartz' },
|
||||||
|
{ text: '线程与事件', link: '/Web/SpringBoot/SpringBoot-Thread' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🐍 FastAPI',
|
||||||
|
items: [
|
||||||
|
{ text: 'FastAPI基础教程', link: '/Web/FastAPI/FastAPI-Guide' },
|
||||||
|
{ text: '基于OAuth2的安全验证', link: '/Web/FastAPI/OAuth2' },
|
||||||
|
{ text: 'FastAPI Docker部署', link: '/Web/FastAPI/FastAPI-Docker' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🐬 MySQL',
|
||||||
|
items: [
|
||||||
|
{ text: 'MyBatis简介和使用', link: '/Web/MySQL/MyBatis' },
|
||||||
|
{ text: 'Flyway简单使用', link: '/Web/MySQL/Flyway' },
|
||||||
|
{ text: 'MySQL知识点', link: '/Web/MySQL/Summary' },
|
||||||
|
{ text: 'SQL高阶用法', link: '/Web/MySQL/SQL-Advance' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🤖 AI',
|
||||||
|
items: [
|
||||||
|
{ text: 'Agent开发', link: '/Web/AI/Langchain' },
|
||||||
|
{ text: 'Text To SQL开发', link: '/Web/AI/TextToSQL' },
|
||||||
|
{ text: 'RAG开发', link: '/Web/AI/RAG' },
|
||||||
|
{ text: 'Tools开发', link: '/Web/AI/Tools' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '📶 Network',
|
||||||
|
items: [
|
||||||
|
{ text: '网络编程简介', link: '/Web/Network/Summary' },
|
||||||
|
{ text: '.Net Core实现', link: '/Web/Network/NetCore' },
|
||||||
|
{ text: 'Mqtt简介与使用', link: '/Web/Network/Mqtt' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '📱 Flutter',
|
||||||
|
items: [
|
||||||
|
{ text: 'Flutter简介与安装', link: '/Web/Flutter/FlutterInstall' },
|
||||||
|
{ text: 'Dart基础教程', link: '/Web/Flutter/DartGuide' },
|
||||||
|
{ text: 'Flutter基础教程', link: '/Web/Flutter/FlutterGuide' },
|
||||||
|
{ text: '实战技巧', link: '/Web/Flutter/Practice' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🔄 其他',
|
||||||
|
items: [
|
||||||
|
{ text: 'RustFS简介和使用', link: '/Web/Others/RustFS' },
|
||||||
|
{ text: 'AList简介和使用', link: '/Web/Others/AList' },
|
||||||
|
{ text: 'InfluxDB简介和使用', link: '/Web/Others/InfluxDB' },
|
||||||
|
{ text: 'Meilisearch简介和使用', link: '/Web/Others/Meilisearch' },
|
||||||
|
{ text: '视频压缩', link: '/Web/Others/VideoCompressor' },
|
||||||
|
{ text: '图片处理服务', link: '/Web/Others/ImageProcessor' },
|
||||||
|
{ text: '秒杀系统设计与实战', link: '/Web/Others/Seckill' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🚀 运维部署',
|
||||||
|
items: [
|
||||||
|
{ text: '⭐ OpenObserve部署和使用', link: '/DevOps/OpenObserve' },
|
||||||
|
{ text: 'Jenkins部署和使用', link: '/DevOps/Jenkins' },
|
||||||
|
{ text: 'ElasticSearch部署和使用', link: '/DevOps/ElasticSearch' },
|
||||||
|
{ text: 'Docker简介和安装', link: '/DevOps/Docker' },
|
||||||
|
{ text: 'Linux命令', link: '/DevOps/LinuxCommand' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🏗️ 项目实战',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: '🧩 通用模块',
|
||||||
|
items: [
|
||||||
|
{ text: 'Vue3 Common', link: '/Practice/Common/Vue3-Common' },
|
||||||
|
{ text: 'SpringBoot Common', link: '/Practice/Common/SpringBoot-Common' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🏢 公司项目',
|
||||||
|
items: [
|
||||||
|
{ text: '埃学习', link: '/Practice/Company/Learn' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '👤 个人项目',
|
||||||
|
items: [
|
||||||
|
{ text: '家庭管理系统', link: '/Practice/Personal/SweetHut' },
|
||||||
|
{ text: '个人博客系统', link: '/Practice/Personal/Blog' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🔄 其他',
|
||||||
|
items: [
|
||||||
|
{ text: 'VitePress使用和简介', link: '/Others/VitePress' },
|
||||||
|
{ text: '企业微信工作台开发', link: '/Others/WeCom' },
|
||||||
|
{ text: 'XDRP简介和安装', link: '/Others/XDRP' },
|
||||||
|
{ text: 'RustDesk简介和安装', link: '/Others/RustDesk' },
|
||||||
|
{ text: 'Systemback制作Ubuntu镜像', link: '/Others/Systemback' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
19
docs/.vitepress/theme/style.css
Normal file
19
docs/.vitepress/theme/style.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'CustomFont';
|
||||||
|
src: url('./fonts/custom.woff2') format('woff2');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--vp-font-family-base: 'CustomFont', -apple-system, BlinkMacSystemFont,
|
||||||
|
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
|
||||||
|
--vp-font-family-mono: 'CustomFont', Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong, b {
|
||||||
|
font-weight: bold;
|
||||||
|
font-synthesis: weight;
|
||||||
|
}
|
||||||
193
docs/.vitepress/theme/style/custom-block.css
Normal file
193
docs/.vitepress/theme/style/custom-block.css
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/* .vitepress/theme/style/custom-block.css */
|
||||||
|
/* 深浅色卡 */
|
||||||
|
:root {
|
||||||
|
--custom-block-info-left: #cccccc;
|
||||||
|
--custom-block-info-bg: #fafafa;
|
||||||
|
|
||||||
|
--custom-block-tip-left: #009400;
|
||||||
|
--custom-block-tip-bg: #e6f6e6;
|
||||||
|
|
||||||
|
--custom-block-warning-left: #e6a700;
|
||||||
|
--custom-block-warning-bg: #fff8e6;
|
||||||
|
|
||||||
|
--custom-block-danger-left: #e13238;
|
||||||
|
--custom-block-danger-bg: #ffebec;
|
||||||
|
|
||||||
|
--custom-block-note-left: #4cb3d4;
|
||||||
|
--custom-block-note-bg: #eef9fd;
|
||||||
|
|
||||||
|
--custom-block-important-left: #a371f7;
|
||||||
|
--custom-block-important-bg: #f4eefe;
|
||||||
|
|
||||||
|
--custom-block-caution-left: #e0575b;
|
||||||
|
--custom-block-caution-bg: #fde4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--custom-block-info-left: #cccccc;
|
||||||
|
--custom-block-info-bg: #474748;
|
||||||
|
|
||||||
|
--custom-block-tip-left: #009400;
|
||||||
|
--custom-block-tip-bg: #003100;
|
||||||
|
|
||||||
|
--custom-block-warning-left: #e6a700;
|
||||||
|
--custom-block-warning-bg: #4d3800;
|
||||||
|
|
||||||
|
--custom-block-danger-left: #e13238;
|
||||||
|
--custom-block-danger-bg: #4b1113;
|
||||||
|
|
||||||
|
--custom-block-note-left: #4cb3d4;
|
||||||
|
--custom-block-note-bg: #193c47;
|
||||||
|
|
||||||
|
--custom-block-important-left: #a371f7;
|
||||||
|
--custom-block-important-bg: #230555;
|
||||||
|
|
||||||
|
--custom-block-caution-left: #e0575b;
|
||||||
|
--custom-block-caution-bg: #391c22;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 标题字体大小 */
|
||||||
|
.custom-block-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* info容器:背景色、左侧 */
|
||||||
|
.custom-block.info {
|
||||||
|
border-left: 5px solid var(--custom-block-info-left);
|
||||||
|
background-color: var(--custom-block-info-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* info容器:svg图 */
|
||||||
|
.custom-block.info [class*="custom-block-title"]::before {
|
||||||
|
content: '';
|
||||||
|
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z' fill='%23ccc'/%3E%3C/svg%3E");
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 4px;
|
||||||
|
left: -5px;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提示容器:边框色、背景色、左侧 */
|
||||||
|
.custom-block.tip {
|
||||||
|
/* border-color: var(--custom-block-tip); */
|
||||||
|
border-left: 5px solid var(--custom-block-tip-left);
|
||||||
|
background-color: var(--custom-block-tip-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提示容器:svg图 */
|
||||||
|
.custom-block.tip [class*="custom-block-title"]::before {
|
||||||
|
content: '';
|
||||||
|
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23009400' d='M7.941 18c-.297-1.273-1.637-2.314-2.187-3a8 8 0 1 1 12.49.002c-.55.685-1.888 1.726-2.185 2.998H7.94zM16 20v1a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-1h8zm-3-9.995V6l-4.5 6.005H11v4l4.5-6H13z'/%3E%3C/svg%3E");
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 4px;
|
||||||
|
left: -5px;
|
||||||
|
top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 警告容器:背景色、左侧 */
|
||||||
|
.custom-block.warning {
|
||||||
|
border-left: 5px solid var(--custom-block-warning-left);
|
||||||
|
background-color: var(--custom-block-warning-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 警告容器:svg图 */
|
||||||
|
.custom-block.warning [class*="custom-block-title"]::before {
|
||||||
|
content: '';
|
||||||
|
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M576.286 752.57v-95.425q0-7.031-4.771-11.802t-11.3-4.772h-96.43q-6.528 0-11.3 4.772t-4.77 11.802v95.424q0 7.031 4.77 11.803t11.3 4.77h96.43q6.528 0 11.3-4.77t4.77-11.803zm-1.005-187.836 9.04-230.524q0-6.027-5.022-9.543-6.529-5.524-12.053-5.524H456.754q-5.524 0-12.053 5.524-5.022 3.516-5.022 10.547l8.538 229.52q0 5.023 5.022 8.287t12.053 3.265h92.913q7.032 0 11.803-3.265t5.273-8.287zM568.25 95.65l385.714 707.142q17.578 31.641-1.004 63.282-8.538 14.564-23.354 23.102t-31.892 8.538H126.286q-17.076 0-31.892-8.538T71.04 866.074q-18.582-31.641-1.004-63.282L455.75 95.65q8.538-15.57 23.605-24.61T512 62t32.645 9.04 23.605 24.61z' fill='%23e6a700'/%3E%3C/svg%3E");
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 4px;
|
||||||
|
left: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 危险容器:背景色、左侧 */
|
||||||
|
.custom-block.danger {
|
||||||
|
border-left: 5px solid var(--custom-block-danger-left);
|
||||||
|
background-color: var(--custom-block-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 危险容器:svg图 */
|
||||||
|
.custom-block.danger [class*="custom-block-title"]::before {
|
||||||
|
content: '';
|
||||||
|
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2c5.523 0 10 4.477 10 10v3.764a2 2 0 0 1-1.106 1.789L18 19v1a3 3 0 0 1-2.824 2.995L14.95 23a2.5 2.5 0 0 0 .044-.33L15 22.5V22a2 2 0 0 0-1.85-1.995L13 20h-2a2 2 0 0 0-1.995 1.85L9 22v.5c0 .171.017.339.05.5H9a3 3 0 0 1-3-3v-1l-2.894-1.447A2 2 0 0 1 2 15.763V12C2 6.477 6.477 2 12 2zm-4 9a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm8 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z' fill='%23e13238'/%3E%3C/svg%3E");
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 4px;
|
||||||
|
left: -5px;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提醒容器:背景色、左侧 */
|
||||||
|
.custom-block.note {
|
||||||
|
border-left: 5px solid var(--custom-block-note-left);
|
||||||
|
background-color: var(--custom-block-note-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提醒容器:svg图 */
|
||||||
|
.custom-block.note [class*="custom-block-title"]::before {
|
||||||
|
content: '';
|
||||||
|
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z' fill='%234cb3d4'/%3E%3C/svg%3E");
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 4px;
|
||||||
|
left: -5px;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 重要容器:背景色、左侧 */
|
||||||
|
.custom-block.important {
|
||||||
|
border-left: 5px solid var(--custom-block-important-left);
|
||||||
|
background-color: var(--custom-block-important-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 重要容器:svg图 */
|
||||||
|
.custom-block.important [class*="custom-block-title"]::before {
|
||||||
|
content: '';
|
||||||
|
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 981.333a84.992 84.992 0 0 1-84.907-84.906h169.814A84.992 84.992 0 0 1 512 981.333zm384-128H128v-42.666l85.333-85.334v-256A298.325 298.325 0 0 1 448 177.92V128a64 64 0 0 1 128 0v49.92a298.325 298.325 0 0 1 234.667 291.413v256L896 810.667v42.666zm-426.667-256v85.334h85.334v-85.334h-85.334zm0-256V512h85.334V341.333h-85.334z' fill='%23a371f7'/%3E%3C/svg%3E");
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 4px;
|
||||||
|
left: -5px;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 注意容器:背景色、左侧 */
|
||||||
|
.custom-block.caution {
|
||||||
|
border-left: 5px solid var(--custom-block-caution-left);
|
||||||
|
background-color: var(--custom-block-caution-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 注意容器:svg图 */
|
||||||
|
.custom-block.caution [class*="custom-block-title"]::before {
|
||||||
|
content: '';
|
||||||
|
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2c5.523 0 10 4.477 10 10v3.764a2 2 0 0 1-1.106 1.789L18 19v1a3 3 0 0 1-2.824 2.995L14.95 23a2.5 2.5 0 0 0 .044-.33L15 22.5V22a2 2 0 0 0-1.85-1.995L13 20h-2a2 2 0 0 0-1.995 1.85L9 22v.5c0 .171.017.339.05.5H9a3 3 0 0 1-3-3v-1l-2.894-1.447A2 2 0 0 1 2 15.763V12C2 6.477 6.477 2 12 2zm-4 9a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm8 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z' fill='%23e13238'/%3E%3C/svg%3E");
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 4px;
|
||||||
|
left: -5px;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
45
docs/.vitepress/theme/style/var.css
Normal file
45
docs/.vitepress/theme/style/var.css
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/* VPFeatures 页卡悬浮效果 */
|
||||||
|
.VPFeatures .items .item {
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.VPFeatures .items .item:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--vp-home-hero-name-color: transparent;
|
||||||
|
--vp-home-hero-name-background: -webkit-linear-gradient(120deg,#bd34fe 30%,#41d1ff);
|
||||||
|
--vp-home-hero-image-background-image: linear-gradient(-45deg,#bd34fe 50%,#47caff 50%);
|
||||||
|
--vp-home-hero-image-filter: blur(44px)
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
:root {
|
||||||
|
--vp-home-hero-image-filter:blur(56px)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
:root {
|
||||||
|
--vp-home-hero-image-filter:blur(68px)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.VPHero .VPImage {
|
||||||
|
filter: drop-shadow(-2px 4px 6px #0003);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-doc h2 {
|
||||||
|
margin: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-swiper-button {
|
||||||
|
color: #bd34fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #bd34fe;
|
||||||
|
}
|
||||||
40
docs/.vitepress/theme/style/vp-code.css
Normal file
40
docs/.vitepress/theme/style/vp-code.css
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/* 代码块:增加留空边距 增加阴影 */
|
||||||
|
.vp-doc div[class*=language-] {
|
||||||
|
box-shadow: 0 10px 15px 0 rgb(0 0 0 / 20%);
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块:添加macOS风格的小圆点 */
|
||||||
|
.vp-doc div[class*=language-]::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #ff5f56;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 20px 0 0 #ffbd2e, 40px 0 0 #27c93f;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块:下移行号 隐藏右侧竖线 */
|
||||||
|
.vp-doc .line-numbers-wrapper {
|
||||||
|
padding-top: 40px;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块:重建行号右侧竖线 */
|
||||||
|
.vp-doc .line-numbers-wrapper::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
right: 0;
|
||||||
|
border-right: 1px solid var(--vp-code-block-divider-color);
|
||||||
|
height: calc(100% - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-doc div[class*='language-'].line-numbers-mode {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
67
docs/.vitepress/theme/utils/index.ts
Normal file
67
docs/.vitepress/theme/utils/index.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
const pattern
|
||||||
|
= /[a-zA-Z0-9_\u0392-\u03C9\u00C0-\u00FF\u0600-\u06FF\u0400-\u04FF]+|[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF\u3040-\u309F\uAC00-\uD7AF]+/g
|
||||||
|
|
||||||
|
export const countWord = (data: string)=> {
|
||||||
|
const m = data.match(pattern)
|
||||||
|
let count = 0
|
||||||
|
if (!m) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for (let i = 0; i < m.length; i += 1) {
|
||||||
|
if (m[i].charCodeAt(0) >= 0x4E00) {
|
||||||
|
count += m[i].length
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatNumberUnit = (num: number, fixed = 1) => {
|
||||||
|
if (typeof num !== 'number') {
|
||||||
|
num = parseFloat(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(num)) return '0';
|
||||||
|
|
||||||
|
const absNum = Math.abs(num);
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (absNum >= 100000000) {
|
||||||
|
// 亿
|
||||||
|
result = (num / 100000000).toFixed(fixed) + '亿';
|
||||||
|
} else if (absNum >= 10000) {
|
||||||
|
// 万
|
||||||
|
result = (num / 10000).toFixed(fixed) + '万';
|
||||||
|
} else {
|
||||||
|
result = num.toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除多余的 .0
|
||||||
|
return result.replace(/\.0+($|亿|万)/, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为移动端
|
||||||
|
*/
|
||||||
|
export const isMobile = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
|
||||||
|
|
||||||
|
return mobileRegex.test(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const startBlogDate = new Date('2025-11-26');
|
||||||
|
|
||||||
|
export const getDaysDifference = (date1: Date, date2: Date): number => {
|
||||||
|
let d1 = new Date(date1);
|
||||||
|
let d2 = new Date(date2);
|
||||||
|
|
||||||
|
const diffMs = Math.abs(d2.getTime() - d1.getTime());
|
||||||
|
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
16
docs/.vitepress/tsconfig.json
Normal file
16
docs/.vitepress/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["ES2017", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.vue"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
368
docs/DevOps/Docker.md
Normal file
368
docs/DevOps/Docker.md
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
---
|
||||||
|
title: Docker简介和安装
|
||||||
|
date: 2025-12-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、Docker简介
|
||||||
|
## 1.1 基础概念
|
||||||
|
### 1.1.1 Docker概念
|
||||||
|
  Docker是一个开源的应用容器引擎,可以让开发者将应用和依赖打包到一个轻量级、可移植的容器中,然后部署到任何流行的Linux或Windows机器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口。
|
||||||
|
  虚拟机和Docker的主要区别:**服务器虚拟化解决的核心问题是资源调配,而容器解决的核心问题是应用开发、测试和部署。**
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
  传统虚拟机技术是虚拟出一套硬件后,在其上运行一个完整操作系统,在该系统上再运行所需应用进程;而容器内的应用进程直接运行于宿主的内核,容器内没有自己的内核,而且也没有进行硬件虚拟。因此容器要比传统虚拟机更为轻便。
|
||||||
|
|
||||||
|
| 特性| 容器| 虚拟机 |
|
||||||
|
| :---: | :---: | :---: |
|
||||||
|
| 启动 | 秒级 | 分钟级 |
|
||||||
|
| 硬盘使用 | 一般为 `MB` | 一般为 `GB` |
|
||||||
|
| 性能 | 接近原生 | 弱于 |
|
||||||
|
| 系统支持量 | 单机支持上千个容器 | 一般几十个 |
|
||||||
|
|
||||||
|
### 1.1.2 Docker镜像——Image
|
||||||
|
  Docker 镜像是一个特殊的文件系统,除了提供容器运行时所需的**程序、库、资源、配置**等文件外,还包含了一些为运行时准备的一些**配置参数**(如匿名卷、环境变量、用户等)。**镜像不包含任何动态数据**,其内容在构建之后也不会被改变。
|
||||||
|
|
||||||
|
### 1.2.3 Docker容器——Container
|
||||||
|
  镜像(`Image`)和容器(`Container`)的关系,就像是面向对象程序设计中的 `类` 和 `实例` 一样,**镜像是静态的定义,容器是镜像运行时的实体**。容器可以被创建、启动、停止、删除、暂停等。
|
||||||
|
  **容器的实质是进程**,但与直接在宿主执行的进程不同,**容器进程运行于属于自己的独立的**[命名空间(opens new window)](https://en.wikipedia.org/wiki/Linux_namespaces)。因此容器可以拥有自己的 `root` 文件系统、自己的网络配置、自己的进程空间,甚至自己的用户 ID 空间。
|
||||||
|
  容器内的进程是运行在一个隔离的环境里,使用起来,就好像是在一个独立于宿主的系统下操作一样。这种特性使得容器封装的应用比直接在宿主运行更加安全。
|
||||||
|
  按照 Docker 最佳实践的要求,**容器不应该向其存储层内写入任何数据**,容器存储层要保持无状态化。**所有的文件写入操作,都应该使用数据卷(Volume)、或者绑定宿主目录**,在这些位置的读写会跳过容器存储层,直接对宿主(或网络存储)发生读写,其性能和稳定性更高。
|
||||||
|
  数据卷的生存周期独立于容器,容器消亡,数据卷不会消亡。因此,使用数据卷后,容器删除或者重新运行之后,数据却不会丢失。
|
||||||
|
|
||||||
|
### 1.2.4 Docker仓库——Registry
|
||||||
|
  一个 Docker Registry 中可以包含多个仓库(Repository);每个仓库可以包含多个标签(Tag);每个标签对应一个镜像。
|
||||||
|
  一个仓库会包含同一个软件不同版本的镜像,而标签就常用于对应该软件的各个版本。我们可以通过 `<仓库名>:<标签>` 的格式来指定具体是这个软件哪个版本的镜像。
|
||||||
|
  最常使用的 Registry 公开服务是官方的 [Docker Hub](https://hub.docker.com/),这也是默认的 Registry。
|
||||||
|
|
||||||
|
# 二、Docker安装
|
||||||
|
1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install apt-transport-https ca-certificates curl software-properties-common
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 添加 GPG 密钥
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
|
||||||
|
```
|
||||||
|
|
||||||
|
  也可以使用阿里云Docker镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL http://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | sudo apt-key add -
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 添加Docker软件源
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
|
||||||
|
```
|
||||||
|
|
||||||
|
  阿里云Docker镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo add-apt-repository "deb [arch=amd64] http://mirrors.aliyun.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 安装Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install docker-ce
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
也可以通过sudo apt install docker.io安装Ubuntu指定版本的docker
|
||||||
|
docker-ce是官方仓库,docker.io是Ubuntu仓库
|
||||||
|
:::
|
||||||
|
|
||||||
|
5. 配置Docker镜像服务
|
||||||
|
|
||||||
|
  1️⃣登录阿里云账号,在**容器镜像服务->镜像工具->镜像加速器**中找到**加速器地址**。
|
||||||
|
  2️⃣在/etc/docker中新建文件 daemon.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry-mirrors": ["https://******.mirror.aliyuncs.com"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
或者使用1ms.docker作为Docker镜像源: "https://docker.1ms.run"
|
||||||
|
:::
|
||||||
|
|
||||||
|
  3️⃣执行命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
# 三、Portainer安装
|
||||||
|
## 3.1 简介
|
||||||
|
  [Portainer](https://www.portainer.io/) 是一个开源的轻量级Docker管理界面,提供直观的图形化用户界面,支持快速部署、操作和监控Docker容器、镜像、服务等。
|
||||||
|
  Portainer提供用户管理、权限控制、容器生命周期管理、镜像操作、服务堆栈管理等功能。支持多节点管理和面板化展示。
|
||||||
|
|
||||||
|
## 3.2 安装
|
||||||
|
  拉取镜像:`docker pull portainer/portainer-ce`
|
||||||
|
|
||||||
|
## 3.3 启动
|
||||||
|
  启动容器:`docker run -d -p 9000:9000 -v /var/run/docker.sock:/var/run/docker.sock -v /dockerData/portainer:/data --restart=always --name portainer portainer/portainer-ce:latest`
|
||||||
|
|
||||||
|
## 3.4 运行
|
||||||
|
  在浏览器中输入`127.0.0.1:9090`即可访问Portainer管理系统。
|
||||||
|
|
||||||
|
# 四、Docker基础命令
|
||||||
|
| 功能分类 | 命令功能 | 具体命令及示例 |
|
||||||
|
|------------------|--------------------------|-------------------------------------|
|
||||||
|
| **Docker环境** | 查看版本信息 | `docker version` |
|
||||||
|
| | 查看系统信息 | `docker info` |
|
||||||
|
| | 启动Docker服务(Linux) | `sudo systemctl start docker` |
|
||||||
|
| | 停止Docker服务(Linux) | `sudo systemctl stop docker` |
|
||||||
|
| | 重启Docker服务(Linux) | `sudo systemctl restart docker` |
|
||||||
|
| | 设置开机自启(Linux) | `sudo systemctl enable docker` |
|
||||||
|
| **镜像操作** | 搜索镜像(Docker Hub) | `docker search nginx` |
|
||||||
|
| | 拉取镜像(最新版) | `docker pull nginx` |
|
||||||
|
| | 拉取镜像(指定版本) | `docker pull nginx:1.24.0` |
|
||||||
|
| | 查看本地镜像 | `docker images` |
|
||||||
|
| | 删除镜像 | `docker rmi nginx:1.24.0` |
|
||||||
|
| | 删除悬空镜像 | `docker image prune` |
|
||||||
|
| | 删除所有未用 | `docker image prune -a` |
|
||||||
|
| | 构建镜像 | `docker build -t mynginx:v1 .`(当前目录Dockerfile) |
|
||||||
|
| | 保存镜像 | `docker save -o nginx.tar nginx:1.24.0` |
|
||||||
|
| | 加载镜像 | `docker load -i nginx.tar` |
|
||||||
|
| **容器操作** | 创建并启动容器 | `docker run -d --name mynginx -p 80:80 nginx` |
|
||||||
|
| | 查看运行中容器 | `docker ps` |
|
||||||
|
| | 查看所有容器(含停止) | `docker ps -a` |
|
||||||
|
| | 启动已停止容器 | `docker start mynginx` |
|
||||||
|
| | 停止运行中容器 | `docker stop mynginx`、`docker kill mynginx` |
|
||||||
|
| | 重启容器 | `docker restart mynginx` |
|
||||||
|
| | 进入运行中的容器 | `docker exec -it mynginx /bin/bash` |
|
||||||
|
| | 查看容器日志 | `docker logs mynginx` |
|
||||||
|
| | 删除容器 | `docker rm mynginx`、`docker rm -f mynginx`|
|
||||||
|
| | 清理所有停止的容器 | `docker container prune` |
|
||||||
|
| | 查看容器详情 | `docker inspect mynginx` |
|
||||||
|
|
||||||
|
# 五、Docker Compose
|
||||||
|
## 5.1 安装
|
||||||
|
1. 从Github官网下载[Docker Compose](https://github.com/docker/compose)相应版本
|
||||||
|
2. 将文件保存至/usr/local/bin/docker-compose可执行目录
|
||||||
|
3. 赋予执行权限:
|
||||||
|
```bash
|
||||||
|
sudo chmod +x /usr/local/bin/docker-compose
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 验证安装:
|
||||||
|
```bash
|
||||||
|
docker compose version
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
也可以通过sudo apt-get install docker-compose-plugin安装Ubuntu指定版本
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 5.2 YAML文件
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Spring Boot 后端服务
|
||||||
|
backend:
|
||||||
|
container_name: springboot-backend
|
||||||
|
image: springboot-app:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# 添加 host.docker.internal 映射
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
# 环境变量配置
|
||||||
|
environment:
|
||||||
|
- SERVER_PORT=8080
|
||||||
|
|
||||||
|
# 端口映射(前面为宿主机端口,后面为容器端口)
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
# 数据卷挂载(前面为宿主机目录,后面为容器目录)
|
||||||
|
volumes:
|
||||||
|
- ./backend/logs:/app/logs
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
# 网络配置
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
# 资源限制
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1.0'
|
||||||
|
reservations:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '0.5'
|
||||||
|
|
||||||
|
# Vue3 Vite 前端服务
|
||||||
|
frontend:
|
||||||
|
container_name: vue-frontend
|
||||||
|
image: vue-app:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# 添加 host.docker.internal 映射
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
# 端口映射
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
|
||||||
|
# 依赖后端
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# 网络
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
# 资源限制
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '0.5'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.25'
|
||||||
|
|
||||||
|
# 网络定义
|
||||||
|
networks:
|
||||||
|
app-network:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.3 命令
|
||||||
|
```bash
|
||||||
|
# 1. 启动服务
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 2. 查看服务状态
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# 3. 查看日志
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# 4. 进入容器
|
||||||
|
docker compose exec backend bash
|
||||||
|
|
||||||
|
# 5. 停止服务
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 6. 重启服务
|
||||||
|
docker compose restart
|
||||||
|
|
||||||
|
# 7. 查看配置
|
||||||
|
docker compose config
|
||||||
|
|
||||||
|
# 8. 构建镜像
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
# 9. 执行命令
|
||||||
|
docker compose exec backend ls -la
|
||||||
|
|
||||||
|
# 10. 清理资源
|
||||||
|
docker compose down -v
|
||||||
|
docker system prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
# 六、Dockerfile
|
||||||
|
  Dockerfile 是用来构建 Docker 镜像的文本文件,包含了一系列构建指令,可以自动化地创建包含应用及其运行环境的镜像。
|
||||||
|
  基本结构:
|
||||||
|
```dockerfile
|
||||||
|
# 基础镜像
|
||||||
|
FROM 镜像名:标签
|
||||||
|
|
||||||
|
# 维护者信息
|
||||||
|
LABEL maintainer="name@email.com"
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制文件
|
||||||
|
COPY 源路径 目标路径
|
||||||
|
|
||||||
|
# 执行命令
|
||||||
|
RUN 命令
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 端口
|
||||||
|
|
||||||
|
# 容器启动命令
|
||||||
|
CMD ["可执行文件", "参数1", "参数2"]
|
||||||
|
```
|
||||||
|
|
||||||
|
  典型示例
|
||||||
|
::: code-group
|
||||||
|
```dockerfile [nginx]
|
||||||
|
FROM nginx:1.27.0
|
||||||
|
COPY dist/ /usr/share/nginx/html/
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||||
|
RUN echo 'Asia/Shanghai' > /etc/timezone
|
||||||
|
```
|
||||||
|
|
||||||
|
```dockerfile [SpringBoot]
|
||||||
|
FROM openjdk:11-jdk
|
||||||
|
ARG JAR_FILE=target/*.jar
|
||||||
|
COPY $JAR_FILE app.jar
|
||||||
|
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||||
|
RUN echo 'Asia/Shanghai' > /etc/timezone
|
||||||
|
ENTRYPOINT java $JAVA_TOOL_OPTIONS -jar /app.jar $0 $@
|
||||||
|
```
|
||||||
|
|
||||||
|
```dockerfile [FastApi]
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||||
|
RUN echo 'Asia/Shanghai' > /etc/timezone
|
||||||
|
COPY ./packages /app/packages
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links=/app/packages -r requirements.txt
|
||||||
|
COPY . /app/
|
||||||
|
EXPOSE 8082
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8082"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```dockerfile [dotnet]
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ./publish .
|
||||||
|
EXPOSE 14040
|
||||||
|
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||||
|
RUN echo 'Asia/Shanghai' > /etc/timezone
|
||||||
|
ENTRYPOINT ["dotnet", "App.dll"]
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 七、Docker开发流程
|
||||||
|
1. 编写业务代码
|
||||||
|
2. 编译打包项目工程
|
||||||
|
3. 编写Dockerfile文件
|
||||||
|
4. 根据Dockerfile文件和项目打包代码创建Docker镜像(标注镜像名称和版本)
|
||||||
|
5. 编写Docker Compose文件
|
||||||
|
6. 执行Docker Compose启动命令
|
||||||
|
7. 查看Docker Container状态
|
||||||
|
|
||||||
|
# 八、参考资料
|
||||||
|
1. [Docker — 从入门到实践](https://github.com/yeasy/docker_practice?tab=readme-ov-file)
|
||||||
|
2. [Docker完整项目的部署](https://blog.csdn.net/qq_71654538/article/details/136520075)
|
||||||
|
|
||||||
87
docs/DevOps/ElasticSearch.md
Normal file
87
docs/DevOps/ElasticSearch.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
title: ElasticSearch部署和使用
|
||||||
|
date: 2025-11-26
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、基础概念
|
||||||
|
  Elasticsearch(简称 ES)是一款开源的分布式全文搜索引擎,基于 Lucene 核心构建,提供了 RESTful API 接口、高可用、高扩展的分布式数据存储与检索能力,广泛应用于日志分析、全文检索、监控告警、数据可视化等场景。
|
||||||
|
|
||||||
|
# 二、Docker部署
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
elasticsearch:
|
||||||
|
image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
|
||||||
|
container_name: elasticsearch
|
||||||
|
environment:
|
||||||
|
- discovery.type=single-node
|
||||||
|
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
|
||||||
|
- xpack.security.enabled=true
|
||||||
|
- ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "9200:9200"
|
||||||
|
volumes:
|
||||||
|
- ./elasticsearch-data:/usr/share/elasticsearch/data
|
||||||
|
networks:
|
||||||
|
- devops
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sS -u elastic:${ELASTIC_PASSWORD} http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
kibana:
|
||||||
|
image: docker.elastic.co/kibana/kibana:8.12.0
|
||||||
|
container_name: kibana
|
||||||
|
environment:
|
||||||
|
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
|
||||||
|
- ELASTICSEARCH_USERNAME=kibana_system
|
||||||
|
- ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "5601:5601"
|
||||||
|
networks:
|
||||||
|
- devops
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
elasticsearch:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
devops:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
elasticsearch-data:
|
||||||
|
driver: local
|
||||||
|
```
|
||||||
|
|
||||||
|
  elasticsearch和kibana版本要保持一致,该镜像较大,可以先使用docker pull拉取到本地。
|
||||||
|
  ELASTIC_PASSWORD和KIBANA_PASSWORD可以通过.env配置文件设置。
|
||||||
|
  启动时,elasticsearch可能会提示elasticsearch-data文件夹权限问题,目前解决方案为将其权限设置为777。
|
||||||
|
|
||||||
|
# 三、简单使用
|
||||||
|
## 3.1 安装插件
|
||||||
|
  启动成功后,需要安装elasticsearch的分词插件analysis-ik。
|
||||||
|
  进入Docker容器,输入指令:
|
||||||
|
```bash
|
||||||
|
bin/elasticsearch-plugin install https://get.infini.cloud/elasticsearch/analysis-ik/8.12.0
|
||||||
|
```
|
||||||
|
|
||||||
|
  注意版本要匹配。https://github.com/infinilabs/analysis-ik/releases/tag/Latest
|
||||||
|
  安装完成后,重启容器。
|
||||||
|
|
||||||
|
## 3.2 更改密码
|
||||||
|
  启动成功后,需要更改elasticsearch的账号密码(不知道为啥docker-compose文件设置的密码不行,可能是容器初始化后又重置了)。
|
||||||
|
  进入Docker容器,输入指令:
|
||||||
|
```bash
|
||||||
|
bin/elasticsearch-setup-passwords interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
  根据提示输入相应的账号密码。
|
||||||
|
|
||||||
|
## 3.3 新建索引
|
||||||
|
1. 打开Elastic控制台,选择Management->Dev Tools,输入语句新建索引。
|
||||||
|
2. 选择Management->Stack Management->Data Views,选择Create data view新建数据视图。
|
||||||
|
3. 输入name、index pattern(刚才新建的索引)和Timestamp信息,保存数据视图。
|
||||||
|
4. 选择Discover,选择刚才的数据视图即可查看该视图下所有数据。
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Jenkins部署和使用
|
||||||
|
date: 2025-11-26
|
||||||
|
---
|
||||||
|
|
||||||
# 一、Jenkins安装
|
# 一、Jenkins安装
|
||||||
## 1.1 简介
|
## 1.1 简介
|
||||||
  Jenkins 是一款开源的持续集成(Continuous Integration, CI) 和持续交付(Continuous Delivery, CD) 自动化工具,广泛应用于软件开发流程中,帮助团队实现代码构建、测试、部署的自动化,从而提升开发效率、减少人为错误,并确保软件质量。
|
  Jenkins 是一款开源的持续集成(Continuous Integration, CI) 和持续交付(Continuous Delivery, CD) 自动化工具,广泛应用于软件开发流程中,帮助团队实现代码构建、测试、部署的自动化,从而提升开发效率、减少人为错误,并确保软件质量。
|
||||||
@@ -85,7 +90,7 @@ Installation directory: /opt/node-v20.10.0-linux-x64
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 1.4.4 配置Gitee
|
### 1.4.4 配置Gitee
|
||||||
  选择Dashboard -> Manage Jenkins -> System,选择Gitee Configuration,输入name和URL,点击Add Credentials,选择Kind的Gitee API token,将Gitee中生成的token复制到指定位置。
|
  选择Dashboard -> Manage Jenkins -> System,选择Gitee Configuration,输入name和URL,点击Add Credentials,选择Kind的Gitee API token,将Gitee中生成的token复制到指定位置。
|
||||||
  注:该版本新增的Gitee Token并不能在后续的项目中找到,但是又无法新增username和password形式,只能在项目的gitee配置中新增username和password的形式,不知道是什么原因?
|
  注:该版本新增的Gitee Token并不能在后续的项目中找到,但是又无法新增username和password形式,只能在项目的gitee配置中新增username和password的形式,不知道是什么原因?
|
||||||
|
|
||||||
# 二、创建工程
|
# 二、创建工程
|
||||||
100
docs/DevOps/LinuxCommand.md
Normal file
100
docs/DevOps/LinuxCommand.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
title: Linux命令
|
||||||
|
date: 2026-05-09
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、系统信息查看
|
||||||
|
| 命令 | 说明 | 示例 | 参数/注释 |
|
||||||
|
| :---: | :---: | :---: | :---: |
|
||||||
|
| uname | 查看系统内核信息 | uname | -a:所有信息 |
|
||||||
|
| lsb_release -a | 查看发行版信息 | - | - |
|
||||||
|
| df | 查看磁盘空间使用 | df -h | -h:显示可读的文件大小 |
|
||||||
|
| du | 计算文件/目录大小 | du -sh * | - |
|
||||||
|
| free | 查看内存使用 | free -h | - |
|
||||||
|
| uptime | 查看系统运行时间 | uptime | - |
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
查看指定目录下每个文件/文件夹的大小,并按大小排序:
|
||||||
|
```bash
|
||||||
|
du -sh /root/* | sort -hr
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 二、文件和目录操作
|
||||||
|
| 命令 | 说明 | 示例 | 参数/注释 |
|
||||||
|
| :---: | :---: | :---: | :---: |
|
||||||
|
| ls | 列出目录内容 | ls /home -lah | -a:显示隐藏文件 <br> -l:显示详细信息 <br> -h:显示可读的文件大小 |
|
||||||
|
| pwd | 显示当前路径 | - | - |
|
||||||
|
| cp | 复制文件或目录 | cp -r src/ dst/ | -r:文件夹 |
|
||||||
|
| mv | 移动或重命名 | mv file.txt new.txt | 建议写完整路径 |
|
||||||
|
| find | 查找文件 | find . -name "*.txt" <br> find . -type f -size +10M | - |
|
||||||
|
|
||||||
|
# 三、文件内容查看
|
||||||
|
| 命令 | 说明 | 示例 | 参数/注释 |
|
||||||
|
| :---: | :---: | :---: | :---: |
|
||||||
|
| cat | 查看整个文件 | cat /etc/os-release | - |
|
||||||
|
| less | 分页浏览(支持上下翻) | less /var/log/syslog | q:退出 <br> ↑↓:上下一行 <br> b/空格:上下一屏|
|
||||||
|
| more | 分页浏览(仅向下) | more /var/log/syslog | q:退出 <br> 空格:下一屏|
|
||||||
|
| head | 查看文件开头 N 行 | head /var/log/syslog | -n 20:前20行 |
|
||||||
|
| tail | 查看文件末尾 N 行 | tail /var/log/syslog | -n 20:后20行 <br> -f:实时显示|
|
||||||
|
|
||||||
|
# 四、进程管理
|
||||||
|
| 命令 | 说明 | 示例 | 参数/注释 |
|
||||||
|
| :---: | :---: | :---: | :---: |
|
||||||
|
| ps | 查看当前进程快照 | ps aux | - |
|
||||||
|
| top | 实时进程监控 | top | 实时刷新,按q退出 <br> P(大写):CPU排序 <br> M:内存排序 |
|
||||||
|
| kill | 终止进程 | kill PID | -9:强制关闭 |
|
||||||
|
| pkill | 按名称终止进程 | pkill PID | -9:强制关闭 |
|
||||||
|
| nohup | 后台运行且忽略挂断 | nohup python app.py & | - |
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
1. 查看某个程序进程:
|
||||||
|
```bash
|
||||||
|
ps aux | grep nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 查看指定 PID 的进程:
|
||||||
|
```bash
|
||||||
|
ps -fp PID号
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 按 CPU 排序
|
||||||
|
```bash
|
||||||
|
ps aux --sort=-%cpu | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 按内存排序
|
||||||
|
```bash
|
||||||
|
ps aux --sort=-%mem | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
5. nohup输出日志
|
||||||
|
```bash
|
||||||
|
nohup command > app.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
6. nohup不输出日志
|
||||||
|
```bash
|
||||||
|
nohup command > /dev/null 2>&1 &
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 五、网络管理
|
||||||
|
| 命令 | 说明 | 示例 | 参数/注释 |
|
||||||
|
| :---: | :---: | :---: | :---: |
|
||||||
|
| ip | 查看/配置网络接口 | - | link:查看所有网卡 <br> addr:查看 IP 地址 <br> route:查看路由表 |
|
||||||
|
| ss | 查看网络连接、端口监听、套接字统计 | - | -l:监听 <br> -n:不解析服务名 <br> -t:TCP <br> -p:显示进程 PID / 程序名 |
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
1. 显示进程端口
|
||||||
|
```bash
|
||||||
|
ss -lntp
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 查看某个端口是否启动
|
||||||
|
```bash
|
||||||
|
ss -lntp | grep 8080
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 六、实战场景
|
||||||
703
docs/DevOps/OpenObserve.md
Normal file
703
docs/DevOps/OpenObserve.md
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
---
|
||||||
|
title: OpenObserve部署和使用
|
||||||
|
date: 2025-11-26
|
||||||
|
isGreat: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、基础概念
|
||||||
|
## 1.1 Fluent Bit
|
||||||
|
  [Fluent Bit](https://fluentbit.io/) 是一个开源的、轻量级、高性能的日志处理器和转发器。Fluent Bit 的核心任务是:**从各种来源收集日志、指标和追踪数据,进行处理和过滤,然后将其发送到一个或多个目的地。**
|
||||||
|
|
||||||
|
### 1.1.1 特点
|
||||||
|
1. 极致的轻量级
|
||||||
|
Fluent Bit 是用 C 语言编写的,运行时内存占用极低(通常只有几百 KB),CPU 使用率也非常低。这使得它成为在资源受限环境(如边缘计算、IoT 设备、容器)中运行的理想选择。
|
||||||
|
2. 高性能
|
||||||
|
其核心架构经过高度优化,处理数据的速度极快,能够轻松应对高吞吐量的日志流。
|
||||||
|
3. 可插拔架构
|
||||||
|
Fluent Bit 的功能通过“插件”来扩展。
|
||||||
|
|
||||||
|
### 1.1.2 插件类型
|
||||||
|
1. Input(输入)
|
||||||
|
定义数据来源,例如从文件、标准输入、TCP/UDP、系统指标(CPU/内存)、Docker 容器、Windows 事件日志等收集数据。
|
||||||
|
2. Filter(过滤器)
|
||||||
|
在数据传递过程中进行处理,例如解析(如将非结构化文本解析为 JSON)、丰富(如添加 Kubernetes 元数据)、修改(如重命名字段)、采样等。
|
||||||
|
3. Output(输出)
|
||||||
|
定义数据目的地,例如 Elasticsearch、Splunk、Kafka、Datadog、Loki、S3、另一个 Fluentd/Fluent Bit 实例等。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
常用Input插件:
|
||||||
|
1. Forward
|
||||||
|
  适用于Fluent Bit / Fluentd 节点,传输协议为私有二进制协议。支持压缩和重连,传输效率极高,但客户端必须使用特定的库。
|
||||||
|
2. TCP
|
||||||
|
  适用于能建立 Socket 连接的程序,传输协议为TCP协议,只负责接收字节流,通常需要配合解析器(Parser)来处理格式。
|
||||||
|
3. HTTP
|
||||||
|
  通过标准的 HTTP POST 请求接收 JSON 数据,传输协议为HTTP协议,兼容性最好,任何编程语言都能轻松发送 HTTP 请求。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 1.1.3 核心架构
|
||||||
|
1. Input(输入)
|
||||||
|
一个或多个输入插件持续地从数据源收集数据。每条数据被读入后,会带上时间戳和标签等信息,被包装成一个内部的事件记录。
|
||||||
|
2. Parser(解析器)
|
||||||
|
(可选)输入插件可以使用解析器,将非结构化的日志数据(如 Nginx 访问日志)转换为结构化的 JSON 格式。
|
||||||
|
3. Filter(过滤)
|
||||||
|
(可选)数据会经过一个或多个过滤器插件。这些插件可以修改、丰富或删除记录。过滤器是按顺序执行的。
|
||||||
|
4. Router(路由)
|
||||||
|
根据每条记录的标签(Tag),路由器将其匹配到相应的输出插件。一个记录可以被路由到多个输出(多路复用)。
|
||||||
|
5. Buffer(缓冲)
|
||||||
|
在发送到输出之前,数据可能会在内存或文件系统中进行缓冲。
|
||||||
|
6. Output(输出)
|
||||||
|
最终,输出插件将处理好的数据发送到配置的目的地。
|
||||||
|
|
||||||
|
### 1.1.4 安装
|
||||||
|
```bash
|
||||||
|
sudo sh -c 'curl https://packages.fluentbit.io/fluentbit.key | gpg --dearmor > /usr/share/keyrings/fluentbit-keyring.gpg'
|
||||||
|
CODENAME=$(lsb_release -cs) && echo "deb [signed-by=/usr/share/keyrings/fluentbit-keyring.gpg] https://packages.fluentbit.io/ubuntu/$CODENAME $CODENAME main" | sudo tee /etc/apt/sources.list.d/fluent-bit.list
|
||||||
|
apt-get update
|
||||||
|
apt-get install fluent-bit
|
||||||
|
systemctl start fluent-bit.service
|
||||||
|
systemctl enable fluent-bit.service
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
默认安装位置为:/opt/fluent-bit/bin/fluent-bit,默认配置文件位置为:/etc/fluent-bit。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 1.2 OpenObserve
|
||||||
|
  [OpenObserve](https://openobserve.ai/) 是一个开源的、云原生的可观测性数据平台,旨在处理日志、指标、追踪和性能数据。它的目标是成为 Elasticsearch/Logstash/Kibana (ELK)、Grafana Loki 和 Datadog 等流行解决方案的更简单、更经济高效、更高性能的替代品。
|
||||||
|
  OpenObserve 是一个“一体化”的平台,你可以将所有的可观测性数据(日志、指标、追踪)都发送到它这里,然后通过统一的界面进行搜索、分析和可视化。
|
||||||
|
|
||||||
|
# 二、Docker部署
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
fluent-bit:
|
||||||
|
image: fluent/fluent-bit:4.0.7
|
||||||
|
container_name: fluent-bit
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./fluent-bit/config:/fluent-bit/etc
|
||||||
|
ports:
|
||||||
|
- "24422:24422"
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
networks:
|
||||||
|
- devops
|
||||||
|
|
||||||
|
openobserve:
|
||||||
|
image: public.ecr.aws/zinclabs/openobserve:v0.14.0
|
||||||
|
container_name: openobserve
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./openobserve-data:/data
|
||||||
|
environment:
|
||||||
|
- ZO_ROOT_USER_EMAIL=1556464090@qq.com
|
||||||
|
- ZO_ROOT_USER_PASSWORD=yourpassword
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "5080:5080"
|
||||||
|
networks:
|
||||||
|
- devops
|
||||||
|
|
||||||
|
networks:
|
||||||
|
devops:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
openobserve-data:
|
||||||
|
driver: local
|
||||||
|
```
|
||||||
|
|
||||||
|
  fluent bit配置文件fluent-bit.conf:
|
||||||
|
```conf
|
||||||
|
[SERVICE]
|
||||||
|
Flush 5
|
||||||
|
Log_Level info
|
||||||
|
Daemon off
|
||||||
|
Parsers_File parsers.conf
|
||||||
|
|
||||||
|
[INPUT]
|
||||||
|
Name forward
|
||||||
|
Listen 0.0.0.0
|
||||||
|
Port 24422
|
||||||
|
Tag external
|
||||||
|
|
||||||
|
[OUTPUT]
|
||||||
|
Name http
|
||||||
|
Match *
|
||||||
|
Host openobserve
|
||||||
|
Port 5080
|
||||||
|
URI /api/default/default/_json
|
||||||
|
http_user 1556464090@qq.com
|
||||||
|
http_passwd yourpassword
|
||||||
|
Format json
|
||||||
|
json_date_key timestamp
|
||||||
|
json_date_format iso8601
|
||||||
|
```
|
||||||
|
|
||||||
|
  INPUT为日志输入源,使用Forward插件,监听24422端口。
|
||||||
|
  OUTPUT为日志输出源,将日志发送到openobserve中(这里使用的是容器名称),URI表示openobserve接收数据的api路径,default/default表示组织名称/流名称,可以更改为其他的名称。_json表示以json的格式。
|
||||||
|
  Match *表示接收所有的INPUT,也可以根据INPUT的tag来区分分发到哪个OUTPUT。
|
||||||
|
|
||||||
|
# 三、数据接入
|
||||||
|
## 3.1 SpringBoot
|
||||||
|
### 3.1.1 安装依赖
|
||||||
|
```xml
|
||||||
|
<properties>
|
||||||
|
<logback-more-appenders.version>1.8.8</logback-more-appenders.version>
|
||||||
|
<fluency-fluentd.version>2.7.0</fluency-fluentd.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- SpringBoot 日志启动 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-logging</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- 扩展 logback appender -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.sndyuk</groupId>
|
||||||
|
<artifactId>logback-more-appenders</artifactId>
|
||||||
|
<version>${logback-more-appenders.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- Fluentd 日志搜集和转发 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.komamitsu</groupId>
|
||||||
|
<artifactId>fluency-fluentd</artifactId>
|
||||||
|
<version>${fluency-fluentd.version}</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1.2 日志配置
|
||||||
|
```yml
|
||||||
|
logging:
|
||||||
|
config: classpath:logback-spring.xml
|
||||||
|
fluentd:
|
||||||
|
host: host.docker.internal
|
||||||
|
port: 24224
|
||||||
|
level:
|
||||||
|
root: info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1.3 配置文件
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration scan="true" scanPeriod="10 seconds">
|
||||||
|
|
||||||
|
<!-- 自定义属性 -->
|
||||||
|
<springProperty scope="context" name="fluentdHost" source="logging.fluentd.host" defaultValue="localhost"/>
|
||||||
|
<springProperty scope="context" name="fluentdPort" source="logging.fluentd.port" defaultValue="24224"/>
|
||||||
|
<springProperty scope="context" name="topic" source="spring.application.name" defaultValue="default"/>
|
||||||
|
|
||||||
|
<!-- log路径 -->
|
||||||
|
<property name="log.path" value="logs/${topic}"/>
|
||||||
|
|
||||||
|
<!--控制台日志格式:彩色日志-->
|
||||||
|
<!-- magenta:洋红 -->
|
||||||
|
<!-- boldMagenta:粗红-->
|
||||||
|
<!-- cyan:青色 -->
|
||||||
|
<!-- white:白色 -->
|
||||||
|
<!-- magenta:洋红 -->
|
||||||
|
<property name="CONSOLE_LOG_PATTERN"
|
||||||
|
value="%yellow(%date{yyyy-MM-dd HH:mm:ss}) |%highlight(%-5level) |%blue(%thread) |%blue(%file:%line) |%green(%logger) |%cyan(%msg%n)"/>
|
||||||
|
|
||||||
|
<!--文件日志格式-->
|
||||||
|
<property name="FILE_LOG_PATTERN"
|
||||||
|
value="%date{yyyy-MM-dd HH:mm:ss} |%-5level |%thread |%file:%line |%logger |%msg%n"/>
|
||||||
|
|
||||||
|
<!--编码-->
|
||||||
|
<property name="ENCODING"
|
||||||
|
value="UTF-8"/>
|
||||||
|
|
||||||
|
<!--输出到控制台-->
|
||||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||||
|
<!--日志级别-->
|
||||||
|
<level>DEBUG</level>
|
||||||
|
</filter>
|
||||||
|
<encoder>
|
||||||
|
<!--日志格式-->
|
||||||
|
<Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
|
||||||
|
<!--日志字符集-->
|
||||||
|
<charset>${ENCODING}</charset>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!--输出到文件-->
|
||||||
|
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<!--日志过滤器:此日志文件只记录INFO级别的-->
|
||||||
|
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||||
|
<level>INFO</level>
|
||||||
|
<onMatch>ACCEPT</onMatch>
|
||||||
|
<onMismatch>DENY</onMismatch>
|
||||||
|
</filter>
|
||||||
|
<!-- 正在记录的日志文件的路径及文件名 -->
|
||||||
|
<file>${log.path}/log_info.log</file>
|
||||||
|
<encoder>
|
||||||
|
<pattern>${FILE_LOG_PATTERN}</pattern>
|
||||||
|
<charset>${ENCODING}</charset>
|
||||||
|
</encoder>
|
||||||
|
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<!-- 每天日志归档路径以及格式 -->
|
||||||
|
<fileNamePattern>${log.path}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
|
<!-- 日志文件的最大大小 -->
|
||||||
|
<maxFileSize>100MB</maxFileSize>
|
||||||
|
<!--日志文件保留天数-->
|
||||||
|
<maxHistory>15</maxHistory>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<!-- 日志过滤器:此日志文件只记录WARN级别的 -->
|
||||||
|
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||||
|
<level>WARN</level>
|
||||||
|
<onMatch>ACCEPT</onMatch>
|
||||||
|
<onMismatch>DENY</onMismatch>
|
||||||
|
</filter>
|
||||||
|
<!-- 正在记录的日志文件的路径及文件名 -->
|
||||||
|
<file>${log.path}/log_warn.log</file>
|
||||||
|
<encoder>
|
||||||
|
<pattern>${FILE_LOG_PATTERN}</pattern>
|
||||||
|
<charset>${ENCODING}</charset> <!-- 此处设置字符集 -->
|
||||||
|
</encoder>
|
||||||
|
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${log.path}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
|
<!-- 日志文件的最大大小 -->
|
||||||
|
<maxFileSize>100MB</maxFileSize>
|
||||||
|
<!--日志文件保留天数-->
|
||||||
|
<maxHistory>15</maxHistory>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<!-- 日志过滤器:此日志文件只记录ERROR级别的 -->
|
||||||
|
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||||
|
<level>ERROR</level>
|
||||||
|
<onMatch>ACCEPT</onMatch>
|
||||||
|
<onMismatch>DENY</onMismatch>
|
||||||
|
</filter>
|
||||||
|
<!-- 正在记录的日志文件的路径及文件名 -->
|
||||||
|
<file>${log.path}/log_error.log</file>
|
||||||
|
<encoder>
|
||||||
|
<pattern>${FILE_LOG_PATTERN}</pattern>
|
||||||
|
<charset>${ENCODING}</charset> <!-- 此处设置字符集 -->
|
||||||
|
</encoder>
|
||||||
|
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${log.path}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
|
<!-- 日志文件的最大大小 -->
|
||||||
|
<maxFileSize>100MB</maxFileSize>
|
||||||
|
<!--日志文件保留天数-->
|
||||||
|
<maxHistory>15</maxHistory>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!--输出到FLUENCY-->
|
||||||
|
<appender name="FLUENCY" class="ch.qos.logback.more.appenders.FluencyLogbackAppender">
|
||||||
|
<remoteHost>${fluentdHost}</remoteHost>
|
||||||
|
<port>${fluentdPort}</port>
|
||||||
|
<additionalField>
|
||||||
|
<key>topic</key>
|
||||||
|
<value>${topic}</value>
|
||||||
|
</additionalField>
|
||||||
|
<ignoredField>caller</ignoredField>
|
||||||
|
<useEventTime>true</useEventTime>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 开发环境 -->
|
||||||
|
<springProfile name="dev">
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
</root>
|
||||||
|
</springProfile>
|
||||||
|
|
||||||
|
<!-- 生产环境 -->
|
||||||
|
<springProfile name="prod">
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="INFO_FILE"/>
|
||||||
|
<appender-ref ref="WARN_FILE"/>
|
||||||
|
<appender-ref ref="ERROR_FILE"/>
|
||||||
|
</root>
|
||||||
|
</springProfile>
|
||||||
|
|
||||||
|
<!-- 容器环境 -->
|
||||||
|
<springProfile name="docker">
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
<appender-ref ref="FLUENCY"/>
|
||||||
|
</root>
|
||||||
|
</springProfile>
|
||||||
|
</configuration>
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
这里是用docker部署的,如果是非docker部署,host为服务器ip地址。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 3.2 .NET Core
|
||||||
|
### 3.2.1 安装Nuget包
|
||||||
|
  安装NLog.Extensions.Hosting和NLog.Targets.Network包。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
NLog.Targets.Fluentd这个包也可以,但是无法定制FluentBit的消息格式。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 3.2.2 使用NLog
|
||||||
|
```cs
|
||||||
|
var host = Host.CreateDefaultBuilder(args)
|
||||||
|
.UseNLog()
|
||||||
|
.Build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2.3 配置NLog
|
||||||
|
  在项目目录创建`NLog.config`并设置复制到输出目录为始终复制。
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
autoReload="true">
|
||||||
|
|
||||||
|
<extensions>
|
||||||
|
<add assembly="NLog.Targets.Network" />
|
||||||
|
</extensions>
|
||||||
|
|
||||||
|
<targets async="true">
|
||||||
|
<target name="fluentbit" xsi:type="Network"
|
||||||
|
newLine="true" lineEnding="LF"
|
||||||
|
address="tcp://ip:5170">
|
||||||
|
<layout xsi:type="JsonLayout">
|
||||||
|
<attribute name="topic" layout="message-hub"/>
|
||||||
|
<attribute name="timestamp" layout="${longdate:universalTime=true}"/>
|
||||||
|
<attribute name="level" layout="${level:uppercase=true}"/>
|
||||||
|
<attribute name="logger" layout="${logger}"/>
|
||||||
|
<attribute name="message" layout="${message}"/>
|
||||||
|
<attribute name="throwable" layout="${exception:format=ToString}"/>
|
||||||
|
</layout>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
<target name="stdout"
|
||||||
|
xsi:type="Console"
|
||||||
|
layout="${longdate} ${level:uppercase=true}: ${logger} - ${message}${onexception:${newline}${exception:format=ToString}}" />
|
||||||
|
</targets>
|
||||||
|
|
||||||
|
<rules>
|
||||||
|
<logger name="*" minlevel="Info" writeTo="stdout, fluentbit"/>
|
||||||
|
</rules>
|
||||||
|
</nlog>
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
这里的topic为项目名称。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 3.2.3 配置插件
|
||||||
|
  在FluentBit中增加TCP插件:
|
||||||
|
```conf
|
||||||
|
[INPUT]
|
||||||
|
Name tcp
|
||||||
|
Listen 0.0.0.0
|
||||||
|
Port 5170
|
||||||
|
Tag tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
docker-compose文件中也要增加5170端口映射。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 3.3 Python
|
||||||
|
### 3.3.1 安装依赖
|
||||||
|
```bash
|
||||||
|
pip install fluent-logger
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3.2 配置文件
|
||||||
|
```.env
|
||||||
|
FLUENT_HOST=ip
|
||||||
|
FLUENT_PORT=24224
|
||||||
|
FLUENT_TAG=test-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3.3 日志配置
|
||||||
|
```py
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from fluent import sender, event
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Fluent-bit配置
|
||||||
|
FLUENT_HOST = os.getenv("FLUENT_HOST", "localhost")
|
||||||
|
FLUENT_PORT = int(os.getenv("FLUENT_PORT", 24224))
|
||||||
|
FLUENT_TAG = os.getenv("FLUENT_TAG", "default")
|
||||||
|
|
||||||
|
|
||||||
|
class FluentHandler(logging.Handler):
|
||||||
|
"""Fluent-bit日志处理器"""
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
try:
|
||||||
|
log_data = {
|
||||||
|
"level": record.levelname,
|
||||||
|
"message": record.message,
|
||||||
|
"module": record.module,
|
||||||
|
"line": record.lineno,
|
||||||
|
"topic": FLUENT_TAG,
|
||||||
|
}
|
||||||
|
event.Event(record.levelname.lower(), log_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Fluent日志发送失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logger():
|
||||||
|
logger = logging.getLogger("app_logger")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
logger.propagate = False # 防止日志重复输出
|
||||||
|
|
||||||
|
# 日志格式
|
||||||
|
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(module)s:%(lineno)d - %(message)s")
|
||||||
|
|
||||||
|
# 控制台处理器(所有环境启用)
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
console_handler.setLevel(logging.DEBUG)
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# 输出到fluent-bit
|
||||||
|
sender.setup(FLUENT_TAG, host=FLUENT_HOST, port=FLUENT_PORT)
|
||||||
|
fluent_handler = FluentHandler()
|
||||||
|
fluent_handler.setFormatter(formatter)
|
||||||
|
fluent_handler.setLevel(logging.INFO)
|
||||||
|
logger.addHandler(fluent_handler)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
# 全局日志实例
|
||||||
|
logger = setup_logger()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.4 Unity
|
||||||
|
### 3.4.1 日志配置
|
||||||
|
```cs
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.Networking;
|
||||||
|
using System.Collections;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
public class FluentBitLogger : MonoBehaviour
|
||||||
|
{
|
||||||
|
private string url = "http://ip:9880";
|
||||||
|
|
||||||
|
void OnEnable() => Application.logMessageReceived += HandleLog;
|
||||||
|
void OnDisable() => Application.logMessageReceived -= HandleLog;
|
||||||
|
|
||||||
|
void HandleLog(string logString, string stackTrace, LogType type)
|
||||||
|
{
|
||||||
|
// 1. 准备数据
|
||||||
|
var logData = new LogData
|
||||||
|
{
|
||||||
|
topic = "default",
|
||||||
|
message = logString,
|
||||||
|
logType = type.ToString(),
|
||||||
|
timestamp = DateTime.Now.ToString("o")
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 转 JSON 并发送
|
||||||
|
string json = JsonUtility.ToJson(logData);
|
||||||
|
StartCoroutine(Send(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerator Send(string json)
|
||||||
|
{
|
||||||
|
// 快速判断设备是否连接到互联网
|
||||||
|
if (Application.internetReachability == NetworkReachability.NotReachable)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (UnityWebRequest req = UnityWebRequest.Put(url, json))
|
||||||
|
{
|
||||||
|
req.method = "POST";
|
||||||
|
req.timeout = 1;
|
||||||
|
req.SetRequestHeader("Content-Type", "application/json");
|
||||||
|
yield return req.SendWebRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public class LogData
|
||||||
|
{
|
||||||
|
public string topic;
|
||||||
|
public string message;
|
||||||
|
public string logType;
|
||||||
|
public string timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4.2 配置插件
|
||||||
|
  在FluentBit中增加http插件:
|
||||||
|
```conf
|
||||||
|
[INPUT]
|
||||||
|
Name http
|
||||||
|
Listen 0.0.0.0
|
||||||
|
Port 9880
|
||||||
|
Tag unity.logs
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
docker-compose文件中也要增加9880端口映射。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 3.5 Vue
|
||||||
|
  发送HTTP数据到FluentBit中:
|
||||||
|
```ts
|
||||||
|
export const sendError = (err, instance, info) => {
|
||||||
|
try {
|
||||||
|
const log = {
|
||||||
|
top: 'vue-ui',
|
||||||
|
level: 'ERROR',
|
||||||
|
info,
|
||||||
|
component: instance?.$options?.name || 'Anonymous',
|
||||||
|
message: err.message,
|
||||||
|
url: location.href
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('http://ip:9880', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(log)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  利用app.config.errorHandler监听异常:
|
||||||
|
```ts
|
||||||
|
app.config.errorHandler = (err, instance, info) => {
|
||||||
|
sendError(err, instance, info)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、简单使用
|
||||||
|
## 3.1 配置数据流
|
||||||
|
  打开控制台,选择数据流->添加数据流,输入fluent bit配置文件中的流名称,选择数据流类型为Logs。
|
||||||
|
  在新添加的数据流中,点击查看,跳转到日志页面即可看到上次的日志信息。
|
||||||
|
  如果没有日志请检查FluentBit容器日志是否有错误,正确的应该会有上传成功的提示。
|
||||||
|

|
||||||
|
|
||||||
|
## 3.2 配置邮件告警
|
||||||
|
  首先需要配置模板和邮箱地址。
|
||||||
|
  修改docker-compose.yml文件,添加邮件服务器:
|
||||||
|
```yml
|
||||||
|
openobserve:
|
||||||
|
image: public.ecr.aws/zinclabs/openobserve:v0.14.0
|
||||||
|
container_name: openobserve
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./openobserve-data:/data
|
||||||
|
environment:
|
||||||
|
- ZO_ROOT_USER_EMAIL=njcxx0822@163.com
|
||||||
|
- ZO_ROOT_USER_PASSWORD=yourpassword
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
- ZO_SMTP_ENABLED=true
|
||||||
|
- ZO_SMTP_HOST=smtp.163.com
|
||||||
|
- ZO_SMTP_PORT=25
|
||||||
|
- ZO_SMTP_USER_NAME=njcxx0822@163.com
|
||||||
|
- ZO_SMTP_PASSWORD=NBZzx6jXtZ2G6wrB
|
||||||
|
- ZO_SMTP_FROM_EMAIL=njcxx0822@163.com
|
||||||
|
#- ZO_SMTP_ENCRYPTION=starttls
|
||||||
|
ports:
|
||||||
|
- "5080:5080"
|
||||||
|
networks:
|
||||||
|
- devops
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里配置587端口会有问题,使用了25端口,并且去掉了安全认证。
|
||||||
|
  注意配置的邮件地址需要和用户名一致。
|
||||||
|
  打开控制台,选择右上角的设置按钮,选择模板->添加模板->Email,填写标题和Body:
|
||||||
|
```json
|
||||||
|
[Alert: {alert_name}] - Severity: {alert_type}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"alertname": "{alert_name}",
|
||||||
|
"stream": "{stream_name}",
|
||||||
|
"organization": "{org_name}",
|
||||||
|
"alerttype": "{alert_type}",
|
||||||
|
"severity": "critical"
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"timestamp": "{timestamp}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|

|
||||||
|
  **注:这里的`{}`里面的内容需要和日志里面的字段保持一致。**
|
||||||
|
|
||||||
|
  选择地址->添加地址->Email,选择刚才的模板,并填写收件人。
|
||||||
|

|
||||||
|
|
||||||
|
  配置完模板和地址后,选择告警->添加告警,例如可以选择当有level='critical',并且出现1次时,触发报警,并通知到指定邮箱。
|
||||||
|

|
||||||
|
  周期:监控数据的时间窗口范围,即每次检查告警条件时,回溯多长时间的监控数据。如果周期 = 5分钟:每次检查时,分析最近 5 分钟的日志/指标数据。
|
||||||
|
  频率:告警规则检查的时间间隔,即每隔多久执行一次告警条件判断。
|
||||||
|
  通知后静默:触发告警并发送通知后,系统暂时停止重复通知的时间。
|
||||||
|
  也可以设置为实时告警,即当告警发生时就立即触发,不会有周期和频率参数设置。
|
||||||
|
|
||||||
|
## 3.3 配置WebHook
|
||||||
|
  首先配置模板和WebHook地址。
|
||||||
|
  打开控制台,选择右上角的设置按钮,选择模板->添加模板->Web Hook,填写Body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msgtype": "markdown",
|
||||||
|
"markdown": {
|
||||||
|
"content": "alertname: {alert_name} timestamp:{timestamp}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|

|
||||||
|
|
||||||
|
  选择地址->添加地址->WebHook,选择刚才的模板,并填写URL。
|
||||||
|
  这里可以使用企业微信的群消息推送功能,添加成功后,会提供一个WebHook地址。也可以自定义一个WebHook接收地址。
|
||||||
|
  配置完模板和地址后,选择告警->添加告警,选择通知到刚才的WebHook地址即可。
|
||||||
|
|
||||||
|
## 3.4 通过API获取日志信息
|
||||||
|
  通过http://ip:5080/swagger/index.html#/ 即可访问OpenobserveAPI接口。
|
||||||
|
  例如要获取日志,可以使用/api/{origin_id}/_search的接口:
|
||||||
|
```cmd
|
||||||
|
curl -X 'POST' \
|
||||||
|
'http://127.0.0.1:5080/api/default/_search' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"query": {
|
||||||
|
"end_time": 1759115401962000,
|
||||||
|
"from": 0,
|
||||||
|
"size": 10,
|
||||||
|
"sql": "select * from default",
|
||||||
|
"start_time": 1759029001962000
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
  注意这里要加上Basic auth认证,并提供username和password。
|
||||||
|

|
||||||
|
|
||||||
|
# 五、自定义日志告警服务
|
||||||
|

|
||||||
|
  基本原理为通过提供一个HTTP的API接口,当Openobserver触发告警时,调用该接口,并传入当前的alert_name和timestamp,然后根据timestamp和Openobserver提供的API接口去查询最近一段时间内的所有日志信息,并存储到SqLite数据库中。同时也可以选择是否同步推送到企业微信和邮箱。
|
||||||
|
  最后也需要提供一个查询历史日志告警信息的接口,并可视化显示,方便运维和开发查看。
|
||||||
|
  自定义服务参考代码:[log-alert-service](https://cxxgit.iepose.cn/Cxx0822/log-alert-service)
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
## 一、基础概念
|
|
||||||
### 1.1 Fluent Bit
|
|
||||||
  [Fluent Bit](https://fluentbit.io/) 是一个开源的、轻量级、高性能的日志处理器和转发器。Fluent Bit 的核心任务是:**从各种来源收集日志、指标和追踪数据,进行处理和过滤,然后将其发送到一个或多个目的地。**
|
|
||||||
|
|
||||||
#### 1.1.1 特点
|
|
||||||
1. 极致的轻量级
|
|
||||||
  Fluent Bit 是用 C 语言编写的,运行时内存占用极低(通常只有几百 KB),CPU 使用率也非常低。这使得它成为在资源受限环境(如边缘计算、IoT 设备、容器侧车)中运行的理想选择。
|
|
||||||
2. 高性能
|
|
||||||
  其核心架构经过高度优化,处理数据的速度极快,能够轻松应对高吞吐量的日志流。
|
|
||||||
3. 可插拔架构
|
|
||||||
  Fluent Bit 的功能通过“插件”来扩展。
|
|
||||||
|
|
||||||
#### 1.1.2 插件类型
|
|
||||||
1. Input(输入):定义数据来源,例如从文件、标准输入、TCP/UDP、系统指标(CPU/内存)、Docker 容器、Windows 事件日志等收集数据。
|
|
||||||
2. Filter(过滤器):在数据传递过程中进行处理,例如解析(如将非结构化文本解析为 JSON)、丰富(如添加 Kubernetes 元数据)、修改(如重命名字段)、采样等。
|
|
||||||
3. Output(输出):定义数据目的地,例如 Elasticsearch、Splunk、Kafka、Datadog、Loki、S3、另一个 Fluentd/Fluent Bit 实例等。
|
|
||||||
|
|
||||||
#### 1.1.3 核心架构与数据流
|
|
||||||
1. Input(输入):一个或多个输入插件持续地从数据源收集数据。每条数据被读入后,会带上时间戳和标签等信息,被包装成一个内部的事件记录。
|
|
||||||
2. Parser(解析器):(可选)输入插件可以使用解析器,将非结构化的日志数据(如 Nginx 访问日志)转换为结构化的 JSON 格式。
|
|
||||||
3. Filter(过滤):(可选)数据会经过一个或多个过滤器插件。这些插件可以修改、丰富或删除记录。过滤器是按顺序执行的。
|
|
||||||
4. Router(路由):根据每条记录的标签(Tag),路由器将其匹配到相应的输出插件。一个记录可以被路由到多个输出(多路复用)。
|
|
||||||
5. Buffer(缓冲):在发送到输出之前,数据可能会在内存或文件系统中进行缓冲。
|
|
||||||
6. Output(输出):最终,输出插件将处理好的数据发送到配置的目的地。
|
|
||||||
|
|
||||||
### 1.2 OpenObserve
|
|
||||||
  [OpenObserve](https://openobserve.ai/) 是一个开源的、云原生的可观测性数据平台,旨在处理日志、指标、追踪和性能数据。它的目标是成为 Elasticsearch/Logstash/Kibana (ELK)、Grafana Loki 和 Datadog 等流行解决方案的更简单、更经济高效、更高性能的替代品。
|
|
||||||
  OpenObserve 是一个“一体化”的平台,你可以将所有的可观测性数据(日志、指标、追踪)都发送到它这里,然后通过统一的界面进行搜索、分析和可视化。
|
|
||||||
|
|
||||||
## 二、Docker部署
|
|
||||||
```yml
|
|
||||||
services:
|
|
||||||
fluent-bit:
|
|
||||||
image: fluent/fluent-bit:4.0.7
|
|
||||||
container_name: fluent-bit
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./fluent-bit/config:/fluent-bit/etc
|
|
||||||
ports:
|
|
||||||
- "24422:24422"
|
|
||||||
environment:
|
|
||||||
- TZ=Asia/Shanghai
|
|
||||||
networks:
|
|
||||||
- devops
|
|
||||||
|
|
||||||
openobserve:
|
|
||||||
image: public.ecr.aws/zinclabs/openobserve:v0.14.0
|
|
||||||
container_name: openobserve
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./openobserve-data:/data
|
|
||||||
environment:
|
|
||||||
- ZO_ROOT_USER_EMAIL=1556464090@qq.com
|
|
||||||
- ZO_ROOT_USER_PASSWORD=19940822Cxx
|
|
||||||
- TZ=Asia/Shanghai
|
|
||||||
ports:
|
|
||||||
- "5080:5080"
|
|
||||||
networks:
|
|
||||||
- devops
|
|
||||||
|
|
||||||
networks:
|
|
||||||
devops:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
openobserve-data:
|
|
||||||
driver: local
|
|
||||||
```
|
|
||||||
|
|
||||||
  fluent bit配置文件:
|
|
||||||
```conf
|
|
||||||
[SERVICE]
|
|
||||||
Flush 5
|
|
||||||
Log_Level info
|
|
||||||
Daemon off
|
|
||||||
Parsers_File parsers.conf
|
|
||||||
|
|
||||||
[INPUT]
|
|
||||||
Name forward
|
|
||||||
Listen 0.0.0.0
|
|
||||||
Port 24422
|
|
||||||
Tag external
|
|
||||||
|
|
||||||
[OUTPUT]
|
|
||||||
Name http
|
|
||||||
Match *
|
|
||||||
Host openobserve
|
|
||||||
Port 5080
|
|
||||||
URI /api/default/default/_json
|
|
||||||
http_user 1556464090@qq.com
|
|
||||||
http_passwd 19940822Cxx
|
|
||||||
Format json
|
|
||||||
json_date_key timestamp
|
|
||||||
json_date_format iso8601
|
|
||||||
```
|
|
||||||
|
|
||||||
  INPUT为日志输入源,监听24422端口。
|
|
||||||
  OUTPUT为日志输出源,将日志发送到openobserve中(这里使用的是容器名称),URI表示openobserve接收数据的api路径,default/default表示组织名称/流名称,可以更改为其他的名称。_json表示以json的格式。
|
|
||||||
|
|
||||||
## 三、简单使用
|
|
||||||
### 3.1 配置数据流
|
|
||||||
  打开控制台,选择数据流->添加数据流,输入fluent bit配置文件中的流名称,选择数据流类型为Logs。
|
|
||||||
  在新添加的数据流中,点击查看,跳转到日志页面即可看到上次的日志信息。
|
|
||||||
  如果没有日志请检查FluentBit容器日志是否有错误,正确的应该会有上传成功的提示。
|
|
||||||

|
|
||||||
|
|
||||||
### 3.2 配置邮件告警
|
|
||||||
  首先需要配置模板和邮箱地址。
|
|
||||||
  修改docker-compose.yml文件,添加邮件服务器:
|
|
||||||
```yml
|
|
||||||
openobserve:
|
|
||||||
image: public.ecr.aws/zinclabs/openobserve:v0.14.0
|
|
||||||
container_name: openobserve
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./openobserve-data:/data
|
|
||||||
environment:
|
|
||||||
- ZO_ROOT_USER_EMAIL=njcxx0822@163.com
|
|
||||||
- ZO_ROOT_USER_PASSWORD=19940822Cxx
|
|
||||||
- TZ=Asia/Shanghai
|
|
||||||
|
|
||||||
- ZO_SMTP_ENABLED=true
|
|
||||||
- ZO_SMTP_HOST=smtp.163.com
|
|
||||||
- ZO_SMTP_PORT=25
|
|
||||||
- ZO_SMTP_USER_NAME=njcxx0822@163.com
|
|
||||||
- ZO_SMTP_PASSWORD=NBZzx6jXtZ2G6wrB
|
|
||||||
- ZO_SMTP_FROM_EMAIL=njcxx0822@163.com
|
|
||||||
#- ZO_SMTP_ENCRYPTION=starttls
|
|
||||||
ports:
|
|
||||||
- "5080:5080"
|
|
||||||
networks:
|
|
||||||
- devops
|
|
||||||
```
|
|
||||||
|
|
||||||
  这里配置587端口会有问题,使用了25端口,并且去掉了安全认证。
|
|
||||||
  注意配置的邮件地址需要和用户名一致。
|
|
||||||
  打开控制台,选择右上角的设置按钮,选择模板->添加模板->Email,填写标题和Body:
|
|
||||||
```json
|
|
||||||
[Alert: {alert_name}] - Severity: {alert_type}
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"labels": {
|
|
||||||
"alertname": "{alert_name}",
|
|
||||||
"stream": "{stream_name}",
|
|
||||||
"organization": "{org_name}",
|
|
||||||
"alerttype": "{alert_type}",
|
|
||||||
"severity": "critical"
|
|
||||||
},
|
|
||||||
"annotations": {
|
|
||||||
"timestamp": "{timestamp}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||

|
|
||||||
  **注:这里的`{}`里面的内容需要和日志里面的字段保持一致。**
|
|
||||||
|
|
||||||
  选择地址->添加地址->Email,选择刚才的模板,并填写收件人。
|
|
||||||

|
|
||||||
|
|
||||||
  配置完模板和地址后,选择告警->添加告警,例如可以选择当有level='critical',并且出现1次时,触发报警,并通知到指定邮箱。
|
|
||||||

|
|
||||||
  周期:监控数据的时间窗口范围,即每次检查告警条件时,回溯多长时间的监控数据。如果周期 = 5分钟:每次检查时,分析最近 5 分钟的日志/指标数据。
|
|
||||||
  频率:告警规则检查的时间间隔,即每隔多久执行一次告警条件判断。
|
|
||||||
  通知后静默:触发告警并发送通知后,系统暂时停止重复通知的时间。
|
|
||||||
  也可以设置为实时告警,即当告警发生时就立即触发,不会有周期和频率参数设置。
|
|
||||||
|
|
||||||
### 3.3 配置WebHook
|
|
||||||
  首先配置模板和WebHook地址。
|
|
||||||
  打开控制台,选择右上角的设置按钮,选择模板->添加模板->Web Hook,填写Body:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"msgtype": "markdown",
|
|
||||||
"markdown": {
|
|
||||||
"content": "alertname: {alert_name} timestamp:{timestamp}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||

|
|
||||||
|
|
||||||
  选择地址->添加地址->WebHook,选择刚才的模板,并填写URL。
|
|
||||||
  这里可以使用企业微信的群消息推送功能,添加成功后,会提供一个WebHook地址。也可以自定义一个WebHook接收地址。
|
|
||||||
  配置完模板和地址后,选择告警->添加告警,选择通知到刚才的WebHook地址即可。
|
|
||||||
|
|
||||||
### 3.4 通过API获取日志信息
|
|
||||||
  通过http://ip:5080/swagger/index.html#/ 即可访问OpenobserveAPI接口。
|
|
||||||
  例如要获取日志,可以使用/api/{origin_id}/_search的接口:
|
|
||||||
```cmd
|
|
||||||
curl -X 'POST' \
|
|
||||||
'http://127.0.0.1:5080/api/default/_search' \
|
|
||||||
-H 'accept: application/json' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"query": {
|
|
||||||
"end_time": 1759115401962000,
|
|
||||||
"from": 0,
|
|
||||||
"size": 10,
|
|
||||||
"sql": "select * from default",
|
|
||||||
"start_time": 1759029001962000
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
  注意这里要加上Basic auth认证,并提供username和password。
|
|
||||||

|
|
||||||
|
|
||||||
## 四、自定义日志告警服务
|
|
||||||

|
|
||||||
  基本原理为通过提供一个HTTP的API接口,当Openobserver触发告警时,调用该接口,并传入当前的alert_name和timestamp,然后根据timestamp和Openobserver提供的API接口去查询最近一段时间内的所有日志信息,并存储到SqLite数据库中。同时也可以选择是否同步推送到企业微信和邮箱。
|
|
||||||
  最后也需要提供一个查询历史日志告警信息的接口,并可视化显示,方便运维和开发查看。
|
|
||||||
  自定义服务参考代码:[log-alert-service](https://cxxgit.iepose.cn/Cxx0822/log-alert-service)
|
|
||||||
BIN
docs/DevOps/images/docker.png
Normal file
BIN
docs/DevOps/images/docker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/DevOps/images/虚拟机.png
Normal file
BIN
docs/DevOps/images/虚拟机.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
layout: doc
|
layout: doc
|
||||||
title: DevOps
|
title: 运维部署
|
||||||
---
|
---
|
||||||
|
|
||||||
# 📚 内容导航
|
<script setup>
|
||||||
|
import { routers } from '../.vitepress/theme/router'
|
||||||
|
</script>
|
||||||
|
|
||||||
- [OpenObserve部署和简单使用](/DevOps/OpenObserve部署和简单使用)
|
<MenuList :routers=routers[1] />
|
||||||
- [Jenkins部署和使用](/DevOps/Jenkins部署和使用)
|
|
||||||
108
docs/Others/RustDesk.md
Normal file
108
docs/Others/RustDesk.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
title: RustDesk简介和安装
|
||||||
|
date: 2026-03-24
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  RustDesk是一款开源的远程桌面控制软件,它允许用户通过网络从一台计算机远程控制另一台计算机。
|
||||||
|
  特点:
|
||||||
|
1. 跨平台:支持 Windows、macOS、Linux、Android、iOS 等主流操作系统。
|
||||||
|
2. 自托管:你可以搭建自己的中继服务器,将数据完全掌握在自己手中,保障安全和隐私。
|
||||||
|
3. 高性能:使用 Rust 语言编写,并采用了先进的视频编解码技术(如 H.264),力求在低带宽下也能实现流畅的远程控制体验。
|
||||||
|
4. 功能全面:支持文件传输、远程终端、语音通话、多显示器切换、远程重启等常用功能。
|
||||||
|
|
||||||
|
# 二、安装
|
||||||
|
## 2.1 自建服务器安装
|
||||||
|
  [RustDesk文档-Docker](https://rustdesk.com/docs/zh-cn/self-host/rustdesk-server-oss/docker/)
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
hbbs:
|
||||||
|
container_name: hbbs
|
||||||
|
image: rustdesk/rustdesk-server:latest
|
||||||
|
command: hbbs
|
||||||
|
volumes:
|
||||||
|
- ./data:/root
|
||||||
|
network_mode: "host"
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- hbbr
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
hbbr:
|
||||||
|
container_name: hbbr
|
||||||
|
image: rustdesk/rustdesk-server:latest
|
||||||
|
command: hbbr
|
||||||
|
volumes:
|
||||||
|
- ./data:/root
|
||||||
|
network_mode: "host"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
需要开放以下端口:
|
||||||
|
hbbs:
|
||||||
|
  21114 (TCP): 用于网页控制台,仅在 Pro 版本中可用。
|
||||||
|
  21115 (TCP): 用于 NAT 类型测试。
|
||||||
|
  21116 (TCP/UDP): 请注意 21116 应该同时为 TCP 和 UDP 启用。 21116/UDP 用于 ID 注册和心跳服务。21116/TCP 用于 TCP 打洞和连接服务。
|
||||||
|
  21118 (TCP): 用于支持网页客户端。
|
||||||
|
hbbr:
|
||||||
|
  21117 (TCP): 用于中继服务。
|
||||||
|
  21119 (TCP): 用于支持网页客户端。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 2.2 客户端安装
|
||||||
|
  进入[RustDesk官网](https://rustdesk.com/zh-cn/),根据系统类型选择相应的安装包下载并安装。
|
||||||
|
|
||||||
|
## 2.3 客户端配置
|
||||||
|
  例如,在Windows系统中,打开exe文件(可以直接免安装打开,也可以安装到本地),点击右上角设置,选择网络->ID/中继服务器,配置ID服务器、中继服务器和Key值。
|
||||||
|
  其中ID服务器和中继服务器为自建服务器的IP地址,Key值可以通过docker日志查看。
|
||||||
|
|
||||||
|
# 三、RustDeskApi
|
||||||
|
  官方RustDesk自建服务的Web Api和管理后台是需要收费的,可以使用免费的[RustDeskApi](https://github.com/lejianwen/rustdesk-api)项目。
|
||||||
|
|
||||||
|
## 3.1 自建服务器安装
|
||||||
|
```yml
|
||||||
|
networks:
|
||||||
|
rustdesk-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rustdesk_server_data:
|
||||||
|
rustdesk_api_data:
|
||||||
|
|
||||||
|
services:
|
||||||
|
rustdesk-full:
|
||||||
|
container_name: rustdesk-full
|
||||||
|
image: lejianwen/rustdesk-api:full-s6
|
||||||
|
ports:
|
||||||
|
- "21114:21114" # API 服务端口
|
||||||
|
- "21115:21115" # hbbs 端口
|
||||||
|
- "21116:21116" # hbbs 端口
|
||||||
|
- "21116:21116/udp" # hbbs UDP 端口
|
||||||
|
- "21117:21117" # hbbr 中继端口
|
||||||
|
- "21118:21118" # WebSocket 端口(Web客户端)
|
||||||
|
- "21119:21119" # 额外中继端口
|
||||||
|
environment:
|
||||||
|
- RELAY=<ip>:21117
|
||||||
|
- ENCRYPTED_ONLY=1
|
||||||
|
- MUST_LOGIN=N
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- RUSTDESK_API_RUSTDESK_ID_SERVER=<ip>:21116
|
||||||
|
- RUSTDESK_API_RUSTDESK_RELAY_SERVER=<ip>:21117
|
||||||
|
- RUSTDESK_API_RUSTDESK_API_SERVER=http://<ip>:21114
|
||||||
|
- RUSTDESK_API_KEY_FILE=/app/conf/data/id_ed25519.pub
|
||||||
|
volumes:
|
||||||
|
- ./data/server:/data
|
||||||
|
- ./data/server:/app/conf/data
|
||||||
|
- ./data/api:/app/data
|
||||||
|
networks:
|
||||||
|
- rustdesk-net
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
这里采用官方S6镜像,[Wiki](https://github.com/lejianwen/rustdesk-api/wiki),其中ip为自建服务器ip。
|
||||||
|
:::
|
||||||
|
|
||||||
|
  输入`http://ip:21114/_admin/`进入管理员后台。
|
||||||
|
  或者输入`http://ip:21114/webclient/`直接进入RustDesk Web客户端。
|
||||||
37
docs/Others/Systemback.md
Normal file
37
docs/Others/Systemback.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Systemback制作Ubuntu镜像
|
||||||
|
date: 2026-03-12
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、Systemback简介
|
||||||
|
  Systemback 是一款用于 Ubuntu 等基于 Ubuntu 的 Linux 发行版的系统管理工具。它主要功能是创建系统备份和还原系统,类似于 Windows 系统中的 “系统还原” 功能。
|
||||||
|
|
||||||
|
# 二、安装
|
||||||
|
```bash
|
||||||
|
sudo sh -c 'echo "deb [arch=amd64] http://mirrors.bwbot.org/ stable main" > /etc/apt/sources.list.d/systemback.list'
|
||||||
|
sudo apt-key adv --keyserver 'hkp://keyserver.ubuntu.com:80' --recv-key 50B2C005A67B264F
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install systemback
|
||||||
|
```
|
||||||
|
|
||||||
|
# 三、制作镜像
|
||||||
|
1. 打开Systemback工具
|
||||||
|
```bash
|
||||||
|
sudo systemback
|
||||||
|
```
|
||||||
|
2. 选择 **Live system create**
|
||||||
|
3. 勾选include the user data files
|
||||||
|
4. 点击 **Create new**
|
||||||
|
5. 等待制作
|
||||||
|
6. 插入U盘,在Write target中选择U盘,点击 **Write to target**
|
||||||
|
7. 等待制作完成
|
||||||
|
|
||||||
|
# 四、还原镜像
|
||||||
|
1. 插入U盘,进入安装界面,选择 **Boot System installer**
|
||||||
|
2. 设置用户名和密码
|
||||||
|
3. 设置分区,**注意一定要设置/boot/efi分区**
|
||||||
|
4. 等待还原
|
||||||
|
|
||||||
|
# 五、参考资料
|
||||||
|
1. [使用systemback备份和还原Ubuntu](https://blog.csdn.net/weixin_51526597/article/details/135712523)
|
||||||
|
2. [Systemback Ubuntu18.04 制作系统ISO镜像并物理机还原](https://www.zhihu.com/tardis/zm/art/576420693?source_id=1005)
|
||||||
556
docs/Others/VitePress.md
Normal file
556
docs/Others/VitePress.md
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
---
|
||||||
|
title: VitePress简介和使用
|
||||||
|
date: 2025-12-18
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  [VitePress](https://vitepress.dev/zh/)是由 Vue 团队开发的静态网站生成器 (SSG),基于 Vite(前端构建工具)和 Vue 3 构建,专门用于快速搭建文档网站、博客或个人主页。它继承了 Vite 的极速开发体验,同时具备 Vue 的组件化能力,语法上兼容 Markdown 并支持扩展 Vue 组件,是替代 VuePress 的新一代工具。
|
||||||
|
|
||||||
|
# 二、快速开始
|
||||||
|
## 2.1 安装
|
||||||
|
```sh
|
||||||
|
pnpm add -D vitepress@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2 初始化
|
||||||
|
```sh
|
||||||
|
pnpm vitepress init
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
建议按照官网配置,文档目录设置为`./docs`。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 2.3 运行
|
||||||
|
```sh
|
||||||
|
pnpm run docs:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.4 使用
|
||||||
|
  在`docs`文件夹新建`md`文档,并在`.vitepress/config.mts`中配置`nav`和`sidebar`路径。
|
||||||
|
|
||||||
|
# 三、优化
|
||||||
|
## 3.1 路由组件
|
||||||
|
  每次新建文档时,都需要在`.vitepress/config.mts`重复`nav`和`sidebar`路径,有时还需要在文档目录的`index.md`配置导航链接,每次都要重复写3次,很麻烦,因此可以构造一个路由配置清单和路由组件,每次只需更新一次路由配置清单即可。
|
||||||
|
  在`.vitepress/theme/router`文件夹下新建路由清单文件:
|
||||||
|
```ts
|
||||||
|
export const routers = [
|
||||||
|
{
|
||||||
|
text: '🌐 Web前端',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: '🌿 Vue',
|
||||||
|
items: [
|
||||||
|
{ text: 'Test', link: '/Web-Front/Vue/Test' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🔄 其他',
|
||||||
|
items: [
|
||||||
|
{ text: 'Test', link: '/Web-Front/Others/Test' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🖥️ Web后端',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: '🍃 SpringBoot',
|
||||||
|
items: [
|
||||||
|
{ text: 'Test', link: '/Web-Backend/SpringBoot/Test' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🐍 FastAPI',
|
||||||
|
items: [
|
||||||
|
{ text: 'Test', link: '/Web-Backend/FastAPI/Test' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🔄 其他',
|
||||||
|
items: [
|
||||||
|
{ text: 'Test', link: '/Web-Backend/Others/Test' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '🚀 DevOps',
|
||||||
|
items: [
|
||||||
|
{ text: 'Test', link: '/DevOps/Test' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里列举了有二级菜单和只有一级菜单的两种情况。
|
||||||
|
  在`.vitepress/theme/components`文件夹下新建路由组件`MenuList.vue`:
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div v-if="isNested">
|
||||||
|
<div v-for="category in props.routers.items" :key="category.text">
|
||||||
|
<h3>{{ category.text }}</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in category.items" :key="item.text">
|
||||||
|
<a :href="withBase(item.link)">{{ item.text }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in props.routers.items" :key="item.text">
|
||||||
|
<a :href="withBase(item.link)">{{ item.text }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { withBase } from 'vitepress'
|
||||||
|
import { defineProps } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
routers: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
isNested: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里需要使用`withBase`拼接路由,防止存在设置了`base`根路径的情况。通过`isNested`来控制是否存在二级菜单。
|
||||||
|
::: warning
|
||||||
|
这里只考虑了最多存在二级菜单的情况。
|
||||||
|
:::
|
||||||
|
|
||||||
|
  在`.vitepress/theme/index.ts`注册组件:
|
||||||
|
```ts
|
||||||
|
import MenuList from './components/MenuList.vue'
|
||||||
|
import type { EnhanceAppContext } from 'vitepress'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
enhanceApp({ app }: EnhanceAppContext) {
|
||||||
|
app.component("MenuList", MenuList);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
  在`.vitepress/config.mts`中配置`nav`和`sidebar`:
|
||||||
|
```ts
|
||||||
|
export default defineConfig({
|
||||||
|
themeConfig: {
|
||||||
|
nav: [
|
||||||
|
{ text: '🏠 Home', link: '/' },
|
||||||
|
...routers
|
||||||
|
],
|
||||||
|
sidebar: {
|
||||||
|
'/Web-Front/': [routers[0]],
|
||||||
|
'/Web-Backend/': [routers[1]],
|
||||||
|
'/DevOps/': [routers[2]]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lastUpdated: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
  在文档所在目录的`index.md`中配置:
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
layout: doc
|
||||||
|
title: Web前端
|
||||||
|
description: Web前端开发技术文档
|
||||||
|
---
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { routers } from '../.vitepress/theme/router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<MenuList :routers=routers[0] :isNested=true />
|
||||||
|
```
|
||||||
|
|
||||||
|
  这样后续只需要维护路由配置清单文件即可自动生成路由菜单。
|
||||||
|
|
||||||
|
## 3.2 博客元数据
|
||||||
|
  可以通过在每个博客中添加`frontmatter`元数据,在最顶部添加博客属性,例如创建日期、字数、时长和是否精品等信息。
|
||||||
|
### 3.2.1 定义元数据
|
||||||
|
  在每个博客最上面添加元数据:
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
title: Docker简介和安装
|
||||||
|
date: 2025-12-15
|
||||||
|
isGreat: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里可以自定义添加任何`key: value`形式的字段信息。
|
||||||
|
::: tip
|
||||||
|
建议手动加上每个博客的创建日期
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 3.2.2 博客信息组件
|
||||||
|
  在`.vitepress/theme/components`文件夹下新建博客信息组件`ArticleMetadata.vue`
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="title">
|
||||||
|
<span>{{ page.frontmatter.title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!page.filePath.includes('index.md')" class="blog-stats">
|
||||||
|
<div v-if="page.frontmatter.isGreat" class="item">
|
||||||
|
</svg>
|
||||||
|
精品
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item">
|
||||||
|
</svg>
|
||||||
|
日期: {{ page.frontmatter.date.split('T')[0] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item">
|
||||||
|
</svg>
|
||||||
|
字数: {{ formatNumberUnit(wordCount) }} 字
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
</svg>
|
||||||
|
时长: {{ readTime }} 分钟
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
|
import { countWord, formatNumberUnit } from '../utils'
|
||||||
|
import { useData } from 'vitepress'
|
||||||
|
|
||||||
|
const wordCount = ref(0)
|
||||||
|
const imageCount = ref(0)
|
||||||
|
|
||||||
|
// 获取页面数据
|
||||||
|
const { page } = useData()
|
||||||
|
|
||||||
|
// 文字阅读时间
|
||||||
|
const wordTime = computed(() => {
|
||||||
|
return ((wordCount.value / 275) * 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 图片阅读时间
|
||||||
|
const imageTime = computed(() => {
|
||||||
|
const n = imageCount.value
|
||||||
|
if (imageCount.value <= 10) {
|
||||||
|
// 等差数列求和
|
||||||
|
return n * 13 + (n * (n - 1)) / 2
|
||||||
|
}
|
||||||
|
return 175 + (n - 10) * 3
|
||||||
|
})
|
||||||
|
|
||||||
|
// 阅读时间
|
||||||
|
const readTime = computed(() => {
|
||||||
|
return Math.ceil((wordTime.value + imageTime.value) / 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
const analyze = () => {
|
||||||
|
// 选择文档内容区域
|
||||||
|
const docDomContainer = window.document.querySelector('#VPContent')
|
||||||
|
// 统计图片数量
|
||||||
|
const imgs = docDomContainer?.querySelectorAll<HTMLImageElement>('.content-container .main img')
|
||||||
|
imageCount.value = imgs?.length || 0
|
||||||
|
|
||||||
|
// 统计文字数量(textContent:提取纯文本)
|
||||||
|
const words = docDomContainer?.querySelector('.content-container .main')?.textContent || ''
|
||||||
|
wordCount.value = countWord(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => page.value.title, () => {
|
||||||
|
// 路径变化时执行
|
||||||
|
analyze()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初始化时执行一次
|
||||||
|
analyze()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.blog-stats {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
background: -webkit-linear-gradient(10deg, #3DC8F0 5%, #8036FA 15%);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
transform: translate(0px , 2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里统计了正文部分的文字和图片数量,并通过相应规则计算阅读时间。
|
||||||
|
  通过`page.frontmatter`获取自定义的元数据信息并在博客最上面显示。
|
||||||
|
|
||||||
|
### 3.2.3 注册组件
|
||||||
|
  在`.vitepress/theme/index.ts`注册组件(参考3.1节内容)。
|
||||||
|
|
||||||
|
### 3.2.4 自定义布局组件
|
||||||
|
  需要将博客信息组件放在自定义布局组件中。
|
||||||
|
  在`.vitepress/theme/components`文件夹下新建自定义布局组件`MyLayout.vue`。
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<DefaultTheme.Layout v-bind="$attrs">
|
||||||
|
<template #doc-before>
|
||||||
|
<ArticleMetadat />
|
||||||
|
</template>
|
||||||
|
</DefaultTheme.Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import DefaultTheme from 'vitepress/theme'
|
||||||
|
import ArticleMetadat from './ArticleMetadata.vue';
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
  更多插槽可以参考[Github源码](https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/Layout.vue)。
|
||||||
|
  同样需要注册组件。
|
||||||
|
|
||||||
|
### 3.2.5 配置布局组件
|
||||||
|
  在`.vitepress/config.mts`中配置布局:
|
||||||
|
```ts
|
||||||
|
import DefaultTheme from 'vitepress/theme'
|
||||||
|
import MyLayout from './components/MyLayout.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: DefaultTheme,
|
||||||
|
Layout: MyLayout,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.3 热力图
|
||||||
|
  使用`vue3-calendar-heatmap`组件在首页绘制博客热力图。
|
||||||
|
### 3.3.1 安装
|
||||||
|
```sh
|
||||||
|
pnpm install vue3-calendar-heatmap
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3.2 获取数据
|
||||||
|
  使用`VitePress`提供的[createContentLoader](https://vitepress.dev/zh/guide/data-loading)来获取所有的`md`博客数据。
|
||||||
|
  在`.vitepress/data`新建`posts.data.js`:
|
||||||
|
```js
|
||||||
|
import { createContentLoader } from 'vitepress'
|
||||||
|
|
||||||
|
export default createContentLoader('./**/*.md', {
|
||||||
|
includeSrc: true, // 包含原始 markdown 源
|
||||||
|
render: true, // 包含渲染的整页 HTML
|
||||||
|
transform(rawData) {
|
||||||
|
return rawData.filter(page => {
|
||||||
|
return page.url.includes('.html') && page.frontmatter.date
|
||||||
|
}).map((page) => {
|
||||||
|
// 对每个页面进行处理
|
||||||
|
return {
|
||||||
|
// 返回你需要的页面数据
|
||||||
|
title: page.frontmatter.title,
|
||||||
|
date: page.frontmatter.date,
|
||||||
|
url: page.url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
  通过判断路径是否包含`html`来确认是博客文档,并且筛选出元数据中包含日期的博客,然后返回相应内容。
|
||||||
|
|
||||||
|
### 3.3.3 博客统计组件
|
||||||
|
  在`.vitepress/theme/components`文件夹下新建博客信息组件`StatsChart.vue`:
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div v-if="!isMobile()">
|
||||||
|
<calendar-heatmap
|
||||||
|
:values="heatmapData"
|
||||||
|
:end-date="new Date()"
|
||||||
|
no-data-text="暂无记录"
|
||||||
|
tooltip-unit="篇"
|
||||||
|
:round="2"
|
||||||
|
:locale="{
|
||||||
|
less: '少于',
|
||||||
|
more: '多于',
|
||||||
|
months: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
|
||||||
|
days: ['日', '一', '二', '三', '四', '五', '六'],
|
||||||
|
}"
|
||||||
|
class="blog-heatmap"
|
||||||
|
/>
|
||||||
|
<div class="heatmap-footer">
|
||||||
|
⏱️ 已坚持<span class="highlight">{{ getDaysDifference(new Date(), startBlogDate) }}</span> 天
|
||||||
|
| 📝 已创作<span class="highlight">{{ data.length }}</span> 篇
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CalendarHeatmap } from 'vue3-calendar-heatmap';
|
||||||
|
import 'vue3-calendar-heatmap/dist/style.css';
|
||||||
|
import { data } from '../../data/posts.data.js'
|
||||||
|
import { isMobile, getDaysDifference, startBlogDate } from "../utils";
|
||||||
|
|
||||||
|
const dateCountMap = new Map<string, number>();
|
||||||
|
|
||||||
|
// 按日期分组统计
|
||||||
|
data.forEach(item => {
|
||||||
|
const date = item.date
|
||||||
|
|
||||||
|
if (dateCountMap.has(date)) {
|
||||||
|
dateCountMap.set(date, dateCountMap.get(date)! + 1);
|
||||||
|
} else {
|
||||||
|
dateCountMap.set(date, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换为目标格式
|
||||||
|
const heatmapData = Array.from(dateCountMap.entries()).map(([date, count]) => ({
|
||||||
|
date,
|
||||||
|
count
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.blog-heatmap {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-heatmap :deep(.vch__wrapper) {
|
||||||
|
font-family: 'CustomFont', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-heatmap :deep(.vch__month__label) {
|
||||||
|
font-size: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-heatmap :deep(.vch__day__label) {
|
||||||
|
font-size: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-footer {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6366f1;
|
||||||
|
margin: 0 2px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
  根据`posts.data.js`提供的博客数据进行日期分组,并统计已坚持天数和已创作博客数。
|
||||||
|
|
||||||
|
### 3.3.4 注册组件
|
||||||
|
  在`.vitepress/theme/index.ts`注册组件(参考3.1节内容)。
|
||||||
|
|
||||||
|
### 3.3.5 使用组件
|
||||||
|
  在`docs/index.md`最后添加组件。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
如果在打包时遇到`Named export 'CalendarHeatmap' not found. The requested module 'vue3-calendar-heatmap' is a CommonJS module, which may not support all module.exports as named exports.`错误提示,说明`vue3-calendar-heatmap`是`CommonJS`模块,而你在代码中使用了`ES`模块的命名导入方式,需要在`config.mts`中添加以下配置:
|
||||||
|
```ts
|
||||||
|
export default defineConfig({
|
||||||
|
vite: {
|
||||||
|
ssr: {
|
||||||
|
noExternal: ['vue3-calendar-heatmap']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 3.4 Mermaid 图表
|
||||||
|
### 3.4.1 安装
|
||||||
|
```sh
|
||||||
|
pnpm i vitepress-plugin-mermaid mermaid -D
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4.2 配置
|
||||||
|
  在`.vitepress/config.mts`中配置:
|
||||||
|
```ts
|
||||||
|
import { withMermaid } from 'vitepress-plugin-mermaid'
|
||||||
|
|
||||||
|
// https://vitepress.dev/reference/site-config
|
||||||
|
export default withMermaid({
|
||||||
|
title: "拾光记"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4.3 使用
|
||||||
|
  在`Markdown`中使用:
|
||||||
|
```md
|
||||||
|
'''mermaid
|
||||||
|
graph TD
|
||||||
|
A[Vue 3] --> B[Compiler]
|
||||||
|
A --> C[Runtime]
|
||||||
|
A --> D[Reactivity]
|
||||||
|
|
||||||
|
B --> B1[Parser]
|
||||||
|
B --> B2[Transformer]
|
||||||
|
B --> B3[Codegen]
|
||||||
|
|
||||||
|
C --> C1[Virtual DOM]
|
||||||
|
C --> C2[Renderer]
|
||||||
|
C --> C3[Component]
|
||||||
|
|
||||||
|
D --> D1[Proxy]
|
||||||
|
D --> D2[Effect]
|
||||||
|
D --> D3[Dependency Graph]
|
||||||
|
|
||||||
|
B3 -->|生成| C1
|
||||||
|
C1 -->|Diff/Patch| C2
|
||||||
|
D3 -->|驱动更新| C1
|
||||||
|
'''
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.5 图片预览
|
||||||
|
### 3.5.1 安装
|
||||||
|
```sh
|
||||||
|
pnpm i vitepress-plugin-image-viewer viewerjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5.2 配置
|
||||||
|
  在`.vitepress/theme/index.ts`中配置:
|
||||||
|
```ts
|
||||||
|
import imageViewer from "vitepress-plugin-image-viewer";
|
||||||
|
import "viewerjs/dist/viewer.min.css";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: DefaultTheme,
|
||||||
|
setup() {
|
||||||
|
const route = useRoute();
|
||||||
|
imageViewer(route);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
509
docs/Others/WeCom.md
Normal file
509
docs/Others/WeCom.md
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
---
|
||||||
|
title: 企业微信工作台开发
|
||||||
|
date: 2026-01-23
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、建立工作台
|
||||||
|
1. 进入企业微信后台管理系统,选择应用管理->应用管理。
|
||||||
|
2. 选择自建->创建应用。
|
||||||
|
3. 输入名称、图标等信息,分配可见范围,完成应用创建.
|
||||||
|
4. 点击应用,选择开发者接口->网页授权,配置可信域名和企业可信IP。
|
||||||
|
5. 查看并保存AgentId和Secret。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
可信域名不能是IP形式,必须是和企业微信备案主体一致的服务器。
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 二、JS-SDK开发
|
||||||
|
## 2.1 接口鉴权
|
||||||
|
  [官方文档](https://developer.work.weixin.qq.com/document/path/90514)
|
||||||
|
  参考第三节企业微信鉴权接口开发
|
||||||
|
|
||||||
|
## 2.2 打开默认浏览器
|
||||||
|
  使用系统浏览器打开指定 URL,支持传入 oauth2 链接,从而实现在系统浏览器内免登录的效果。
|
||||||
|
|
||||||
|
# 三、鉴权接口开发
|
||||||
|
  本教程以node.js为例。
|
||||||
|
## 3.1 通用企业微信服务端API
|
||||||
|
```js
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const TOKEN_URL = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken'
|
||||||
|
const JSAPI_TICKET_URL = 'https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket'
|
||||||
|
const MESSAGE_URL = 'https://qyapi.weixin.qq.com/cgi-bin/message/send'
|
||||||
|
|
||||||
|
// 获取access_token
|
||||||
|
async function getAccessTokenApi(corpId, corpSecret) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${TOKEN_URL}?corpid=${corpId}&corpsecret=${corpSecret}`
|
||||||
|
const response = await axios.get(url);
|
||||||
|
|
||||||
|
if (response.data.errcode === 0) {
|
||||||
|
return {
|
||||||
|
accessToken: response.data.access_token,
|
||||||
|
expires: now + (response.data.expires_in - 300) * 1000 // 提前5分钟过期
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(`获取access_token失败: ${response.data.errmsg}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取access_token错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取jsapi_ticket
|
||||||
|
async function getJsapiTicketApi(accessToken) {
|
||||||
|
try {
|
||||||
|
const url = `${JSAPI_TICKET_URL}?access_token=${accessToken}`
|
||||||
|
const response = await axios.get(url);
|
||||||
|
|
||||||
|
if (response.data.errcode === 0) {
|
||||||
|
return response.data.ticket;
|
||||||
|
} else {
|
||||||
|
throw new Error(`获取jsapi_ticket失败: ${response.data.errmsg}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取jsapi_ticket错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送应用消息
|
||||||
|
async function sendMessageApi(agentid, accessToken, touser, content) {
|
||||||
|
try {
|
||||||
|
const url = `${MESSAGE_URL}?access_token=${accessToken}`
|
||||||
|
const response = await axios.post(url, {
|
||||||
|
touser: touser,
|
||||||
|
msgtype: 'text',
|
||||||
|
agentid: agentid,
|
||||||
|
text: {
|
||||||
|
content: content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.errcode === 0) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`发送应用消息失败: ${response.data.errmsg}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送应用消息错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAccessTokenApi,
|
||||||
|
getJsapiTicketApi,
|
||||||
|
sendMessageApi
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 自建应用鉴权接口
|
||||||
|
```js
|
||||||
|
const {getAccessTokenApi, getJsapiTicketApi, sendMessageApi} = require("./wecom");
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
corpId: '', // 企业id
|
||||||
|
corpSecret: '', // 应用密码
|
||||||
|
agentId: '', // 应用id
|
||||||
|
domain: '', // 跳转域名
|
||||||
|
password: '' // 登录默认密码
|
||||||
|
};
|
||||||
|
|
||||||
|
// 存储access_token和jsapi_ticket
|
||||||
|
let tokenCache = {
|
||||||
|
accessToken: null,
|
||||||
|
jsapiTicket: null,
|
||||||
|
expires: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取access_token
|
||||||
|
async function getRepairAccessToken() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (tokenCache.accessToken && tokenCache.expires > now) {
|
||||||
|
return tokenCache.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getAccessTokenApi(config.corpId, config.corpSecret)
|
||||||
|
tokenCache.accessToken = result.accessToken;
|
||||||
|
tokenCache.expires = result.expires
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取jsapi_ticket
|
||||||
|
async function getRepairJsapiTicket() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (tokenCache.jsapiTicket && tokenCache.expires > now) {
|
||||||
|
return tokenCache.jsapiTicket;
|
||||||
|
}
|
||||||
|
|
||||||
|
await getRepairAccessToken();
|
||||||
|
tokenCache.jsapiTicket = await getJsapiTicketApi(tokenCache.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送应用消息
|
||||||
|
async function sendRepairMessage(touser, content) {
|
||||||
|
await getRepairAccessToken();
|
||||||
|
await sendMessageApi(config.agentId, tokenCache.accessToken, touser, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRepairDomain() {
|
||||||
|
return config.domain
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRepairPassword() {
|
||||||
|
return config.password
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getRepairAccessToken,
|
||||||
|
getRepairJsapiTicket,
|
||||||
|
sendRepairMessage,
|
||||||
|
getRepairDomain,
|
||||||
|
getRepairPassword
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.3 NodeJS API
|
||||||
|
```js
|
||||||
|
const express = require('express');
|
||||||
|
const axios = require('axios');
|
||||||
|
const cors = require('cors');
|
||||||
|
const {generateNonceStr, generateSignature} = require("./utils");
|
||||||
|
const {sendRepairMessage, getRepairJsapiTicket, getRepairAccessToken, getRepairDomain, getRepairPassword} = require("./repair");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = 3000;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const corpId = 'ww48932728388c9e3a'
|
||||||
|
const USER_INFO_URL = 'https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo'
|
||||||
|
|
||||||
|
// API接口:生成JS-SDK配置
|
||||||
|
app.post('/auth/config/repair', async (req, res) => {
|
||||||
|
await configService(req, res, 'repair')
|
||||||
|
});
|
||||||
|
|
||||||
|
async function configService(req, res, appName) {
|
||||||
|
try {
|
||||||
|
const {url} = req.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({errcode: -1, errmsg: '缺少url参数'});
|
||||||
|
}
|
||||||
|
|
||||||
|
let jsapiTicket = ''
|
||||||
|
|
||||||
|
if (appName === 'repair') {
|
||||||
|
jsapiTicket = await getRepairJsapiTicket();
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const noncestr = generateNonceStr();
|
||||||
|
const signature = generateSignature(jsapiTicket, noncestr, timestamp, url);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: 'ok',
|
||||||
|
appId: corpId,
|
||||||
|
timestamp,
|
||||||
|
nonceStr: noncestr,
|
||||||
|
signature
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('生成JS-SDK配置错误:', error);
|
||||||
|
res.status(500).json({errcode: -1, errmsg: '服务器内部错误'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API接口:OAuth回调处理
|
||||||
|
app.get('/auth/callback/repair', async (req, res) => {
|
||||||
|
await callback(req, res, 'repair')
|
||||||
|
});
|
||||||
|
|
||||||
|
async function callback(req, res, appName) {
|
||||||
|
try {
|
||||||
|
const {code, state} = req.query;
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).send('缺少code参数');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过code获取用户信息
|
||||||
|
let accessToken = ''
|
||||||
|
if (appName === 'repair') {
|
||||||
|
accessToken = await getRepairAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${USER_INFO_URL}?access_token=${accessToken}&code=${code}`
|
||||||
|
const userResponse = await axios.get(url);
|
||||||
|
|
||||||
|
if (userResponse.data.errcode !== 0) {
|
||||||
|
throw new Error(`获取用户信息失败: ${userResponse.data.errmsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = userResponse.data;
|
||||||
|
|
||||||
|
// 重定向到前端页面
|
||||||
|
let redirectUrl = ''
|
||||||
|
if (appName === 'repair') {
|
||||||
|
redirectUrl = `${getRepairDomain()}?username=${userInfo.UserId}&password=${getRepairPassword()}&type=repair`;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(redirectUrl);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OAuth回调处理错误:', error);
|
||||||
|
res.status(500).send(`
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>登录失败</h1>
|
||||||
|
<p>${error.message}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API接口:发现应用消息
|
||||||
|
app.post('/message/repair', async (req, res) => {
|
||||||
|
const {touser, content} = req.body;
|
||||||
|
await sendRepairMessage(touser, content)
|
||||||
|
res.json({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: 'ok'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动服务
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`企业微信登录服务运行在端口 ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.4 工具类
|
||||||
|
```js
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// 工具函数:生成随机字符串
|
||||||
|
function generateNonceStr(length = 16) {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具函数:生成签名
|
||||||
|
function generateSignature(jsapiTicket, noncestr, timestamp, url) {
|
||||||
|
const string = `jsapi_ticket=${jsapiTicket}&noncestr=${noncestr}×tamp=${timestamp}&url=${url}`;
|
||||||
|
return crypto.createHash('sha1').update(string).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateNonceStr,
|
||||||
|
generateSignature
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、应用主页
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>企业微信登录跳转</title>
|
||||||
|
<!-- 引入新版企业微信JS-SDK -->
|
||||||
|
<script src="https://wwcdn.weixin.qq.com/node/open/js/wecom-jssdk-2.3.3.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loading">正在跳转,请稍候...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 配置参数
|
||||||
|
const serverUrl = ''
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
corpId: '', // 企业ID
|
||||||
|
agentId: '', // 应用ID
|
||||||
|
redirectUrl: encodeURIComponent(`${serverUrl}auth/callback/repair`), // OAuth回调地址
|
||||||
|
authUrl: `${serverUrl}auth/config/repair` // 后端授权入口
|
||||||
|
};
|
||||||
|
|
||||||
|
// 主函数
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
// 获取当前页面URL(用于签名)
|
||||||
|
const currentUrl = window.location.href.split('#')[0];
|
||||||
|
|
||||||
|
// 从后端获取签名信息
|
||||||
|
const signatureData = await getSignatureData(currentUrl);
|
||||||
|
|
||||||
|
const getConfigSignature = function(url) {
|
||||||
|
return {
|
||||||
|
timestamp: signatureData.timestamp,
|
||||||
|
nonceStr: signatureData.nonceStr,
|
||||||
|
signature: signatureData.signature
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用ww.register 进行身份注册
|
||||||
|
await ww.register({
|
||||||
|
corpId: config.corpId,
|
||||||
|
agentId: config.agentId,
|
||||||
|
jsApiList: ['openDefaultBrowser', 'closeWindow'],
|
||||||
|
getConfigSignature: getConfigSignature
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成授权URL
|
||||||
|
const authUrl = await generateAuthUrl();
|
||||||
|
|
||||||
|
const isMobile = /(phone|pad|pod|iphone|ipod|ios|ipad|android|mobile|blackberry|iemobile|mqqbrowser|fennec|wosbrowser|browserng|webos|symbian|windows phone)/i.test(navigator.userAgent);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// 在移动端企业微信中直接打开链接
|
||||||
|
window.location.href = authUrl;
|
||||||
|
} else {
|
||||||
|
// 使用新版API调用系统浏览器
|
||||||
|
ww.openDefaultBrowser({
|
||||||
|
url: authUrl,
|
||||||
|
success: (res) => {
|
||||||
|
// console.log('成功调用系统浏览器:', res);
|
||||||
|
ww.closeWindow({})
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
// console.error('打开浏览器失败:', err);
|
||||||
|
document.getElementById('loading').innerHTML = `打开失败: ${err.errMsg || '未知错误'},请点击右上角菜单选择"在浏览器中打开"`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化失败:', error);
|
||||||
|
document.getElementById('loading').innerHTML = '跳转失败,请手动在浏览器中打开: ' + error.errMsg;
|
||||||
|
// window.alert(error.errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取签名数据
|
||||||
|
async function getSignatureData(url) {
|
||||||
|
const response = await fetch(`${config.authUrl}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: url
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取签名配置失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// 确保返回的数据包含必要的字段
|
||||||
|
if (!result.timestamp || !result.nonceStr || !result.signature) {
|
||||||
|
throw new Error('签名数据不完整');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成授权URL
|
||||||
|
async function generateAuthUrl() {
|
||||||
|
// 使用URLSearchParams构建参数
|
||||||
|
const authParams = new URLSearchParams({
|
||||||
|
appid: config.corpId,
|
||||||
|
redirect_uri: config.redirectUrl,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'snsapi_base', // 静默授权
|
||||||
|
state: 'wecom_redirect',
|
||||||
|
agentid: config.agentId
|
||||||
|
});
|
||||||
|
|
||||||
|
return `https://open.weixin.qq.com/connect/oauth2/authorize?${authParams.toString()}#wechat_redirect`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载完成后初始化
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
  将该应用主页的URL放在工作台的应用主页中。
|
||||||
|
::: tip
|
||||||
|
需要在前端工程中配置路由参数访问,即通过ip:port/#/login?username=''&password=''的形式访问。
|
||||||
|
:::
|
||||||
|
::: tip
|
||||||
|
如果需要在发送应用消息时,可以点击消息访问应用,绑定的URL链接也是该应用主页的链接。
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 五、使用
|
||||||
|
  本教程以SpringBoot为例。
|
||||||
|
## 5.1 配置RestClient
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
public class RestClientConfig {
|
||||||
|
final String weiComBaseUrl = "";
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestClient weiComClient() {
|
||||||
|
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||||
|
factory.setConnectTimeout(Duration.ofSeconds(5));
|
||||||
|
factory.setReadTimeout(Duration.ofSeconds(10));
|
||||||
|
|
||||||
|
return RestClient.builder()
|
||||||
|
.requestFactory(factory)
|
||||||
|
.baseUrl(weiComBaseUrl)
|
||||||
|
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.2 调用发送消息接口
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class NoticeService {
|
||||||
|
@Resource
|
||||||
|
private RestClient weiComClient;
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void sendRepairMessage(String touser, String content) {
|
||||||
|
String link = "";
|
||||||
|
|
||||||
|
WeComNoticeRequest request = new WeComNoticeRequest();
|
||||||
|
request.setTouser(touser);
|
||||||
|
request.setContent(content + "\n点击打开工作台应用:" + "<a href=\"" + link + "\">打开应用</a>");
|
||||||
|
|
||||||
|
WeComResponse response = weiComClient.post()
|
||||||
|
.uri("/message/repair")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(WeComResponse.class);
|
||||||
|
|
||||||
|
if (response == null || response.getErrcode() != 0) {
|
||||||
|
throw new CustomException("企业微信发送消息失败 请联系管理员");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 附录
|
||||||
|
企业微信官方文档:[接口](https://developer.work.weixin.qq.com/document/path/90664)
|
||||||
33
docs/Others/XDRP.md
Normal file
33
docs/Others/XDRP.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
title: XDRP简介和安装
|
||||||
|
date: 2026-02-25
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  XDRP 是一个基于 RDP(Remote Desktop Protocol 远程桌面协议)的开源服务器软件,允许用户通过远程桌面客户端连接到 Linux 服务器,提供图形化远程访问功能。
|
||||||
|
  特点:
|
||||||
|
1. 支持 Windows 远程桌面客户端
|
||||||
|
2. 支持多种桌面环境:GNOME、KDE、XFCE 等
|
||||||
|
3. 使用标准的 3389 端口
|
||||||
|
4. 轻量级,资源占用较少
|
||||||
|
5. 不需要连接显示器
|
||||||
|
|
||||||
|
# 二、安装
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install xrdp -y
|
||||||
|
sudo systemctl enable xrdp
|
||||||
|
```
|
||||||
|
|
||||||
|
# 三、连接
|
||||||
|
  打开Windows自带的远程桌面连接工具,输入ip、用户名和密码登录。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
如果遇到黑屏的情况,打开/etc/xrdp/startwm.sh脚本,在`if test -r /etc/default/locale; then fi`这段结束后,插入以下内容:
|
||||||
|
```bash
|
||||||
|
unset DBUS_SESSION_BUS_ADDRESS
|
||||||
|
unset XDG_RUNTIME_DIR
|
||||||
|
. $HOME/.profile
|
||||||
|
```
|
||||||
|
然后sudo systemctl restart xrdp重启服务。
|
||||||
|
:::
|
||||||
10
docs/Others/index.md
Normal file
10
docs/Others/index.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
layout: doc
|
||||||
|
title: 其他
|
||||||
|
---
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { routers } from '../.vitepress/theme/router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<MenuList :routers=routers[3] />
|
||||||
1033
docs/Practice/Common/SpringBoot-Common.md
Normal file
1033
docs/Practice/Common/SpringBoot-Common.md
Normal file
File diff suppressed because it is too large
Load Diff
580
docs/Practice/Common/Vue3-Common.md
Normal file
580
docs/Practice/Common/Vue3-Common.md
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
---
|
||||||
|
title: Vue3 Common
|
||||||
|
date: 2025-12-15 23:25
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、依赖管理
|
||||||
|
  本项目会传递安装的依赖有:
|
||||||
|
|
||||||
|
| 包名称 | 版本 | 含义和用途说明 |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `@fortawesome/fontawesome-free` | ^6.7.2 | FontAwesome 图标库的免费版本 |
|
||||||
|
| `@popperjs/core` | ^2.11.8 | 工具提示和弹出框定位引擎 |
|
||||||
|
| `axios` | ^1.4.0 | 基于 Promise 的 HTTP 客户端 |
|
||||||
|
| `compressorjs` | ^1.2.1 | 纯 JavaScript 图片压缩库 |
|
||||||
|
| `crypto-js` | ^4.2.0 | JavaScript 加密算法库 |
|
||||||
|
| `dayjs` | ^1.11.13 | 轻量级的日期处理库 |
|
||||||
|
| `echarts` | ^5.5.1 | 百度开源的数据可视化图表库 |
|
||||||
|
| `element-plus` | ^2.6.0 | 基于 Vue 3 的桌面端 UI 组件库 |
|
||||||
|
| `js-cookie` | ^3.0.5 | JavaScript Cookie 操作库 |
|
||||||
|
| `lunar-calendar` | ^0.1.4 | 农历日历转换库 |
|
||||||
|
| `lunar-javascript` | ^1.6.13 | 农历日期处理的 JavaScript 库 |
|
||||||
|
| `path-browserify` | ^1.0.1 | Node.js path 模块的浏览器版本兼容实现 |
|
||||||
|
| `qs` | ^6.13.0 | URL 查询字符串解析和序列化库 |
|
||||||
|
| `v-calendar` | ^3.1.2 | Vue.js 的日历和日期选择器组件 |
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
如果使用pnpm安装,会传递peerDependencies部分。
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
建议将Vite、Typescript、@types等构建工具依赖放在devDependencies中。
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 二、vite.config.ts配置
|
||||||
|
## 2.1 生成ts类型文件
|
||||||
|
  安装vite-plugin-dts插件
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
pnpm add vite-plugin-dts -D
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import dts from 'vite-plugin-dts'
|
||||||
|
|
||||||
|
// 打包输出文件夹
|
||||||
|
const outDirPath = 'dist'
|
||||||
|
// 需要打包的类型文件
|
||||||
|
const TARGET_TYPE_FOLDERS = ['src/components', 'src/utils', 'src/types', 'src/vue3-common.ts']
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
// 生成类型文件
|
||||||
|
dts({
|
||||||
|
// 需要处理的文件
|
||||||
|
include: TARGET_TYPE_FOLDERS,
|
||||||
|
// 使用特定的 tsconfig 配置
|
||||||
|
tsconfigPath: path.resolve(__dirname, 'tsconfig.app.json'),
|
||||||
|
// 类型文件输出目录
|
||||||
|
outDir: path.resolve(__dirname, outDirPath)
|
||||||
|
})
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
  打包后会在dist目录生成.d.ts文件,提供完整的Typescript类型支持。
|
||||||
|
|
||||||
|
## 2.2 build打包配置
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import dts from 'vite-plugin-dts'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
import { ICommonObj } from './src/types'
|
||||||
|
|
||||||
|
// 打包输出文件夹
|
||||||
|
const outDirPath = 'dist'
|
||||||
|
// 需要单个打包的文件夹(组件和工具类)
|
||||||
|
const TARGET_LIB_FOLDERS = ['src/components', 'src/utils']
|
||||||
|
// 需要排除的第三方依赖
|
||||||
|
const EXTERNAL = ['vue', 'element-plus', 'echarts', 'axios', 'moment', 'crypto-js', 'spark-md5', 'path-browserify']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归读取文件夹下的所有文件
|
||||||
|
* @param folderPath 文件夹
|
||||||
|
*/
|
||||||
|
const getFilesFromFolder = (folderPath: string) => {
|
||||||
|
const files: string[] = []
|
||||||
|
|
||||||
|
// 读取文件夹中的内容
|
||||||
|
fs.readdirSync(folderPath).forEach((item: string) => {
|
||||||
|
const fullPath = path.join(folderPath, item)
|
||||||
|
// 读取文件状态 如果是文件夹,递归读取
|
||||||
|
if (fs.statSync(fullPath).isDirectory()) {
|
||||||
|
files.push(...getFilesFromFolder(fullPath))
|
||||||
|
} else {
|
||||||
|
// 否则添加至文件列表
|
||||||
|
files.push(fullPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态生成入口文件
|
||||||
|
const inputEntries = TARGET_LIB_FOLDERS.reduce((entries: ICommonObj, folder) => {
|
||||||
|
// 获取文件夹内的所有文件
|
||||||
|
const files = getFilesFromFolder(path.resolve(__dirname, folder))
|
||||||
|
|
||||||
|
files.forEach((filePath) => {
|
||||||
|
// 判断文件是否是 .vue 或 .ts 文件
|
||||||
|
if (filePath.endsWith('.vue') || filePath.endsWith('.ts')) {
|
||||||
|
// 获取相对路径并保持目录结构
|
||||||
|
const relativePath = path.relative('src', filePath)
|
||||||
|
const entryName = path.join('lib', relativePath.replace(/\.(vue|ts)$/, ''))
|
||||||
|
|
||||||
|
entries[entryName] = filePath // 为每个文件创建一个入口
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
// lib文件配置
|
||||||
|
lib: {
|
||||||
|
// 入口文件
|
||||||
|
entry: path.resolve(__dirname, 'src/vue3-common.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
name: 'vue3-common',
|
||||||
|
// 文件名
|
||||||
|
fileName: (format) => `vue3-common.${format}.js`
|
||||||
|
},
|
||||||
|
// 输出文件路径
|
||||||
|
outDir: outDirPath,
|
||||||
|
// 是否将css文件分割
|
||||||
|
cssCodeSplit: true,
|
||||||
|
// Rollup打包配置
|
||||||
|
rollupOptions: {
|
||||||
|
// 需要排除的依赖 通常为第三方库
|
||||||
|
external: EXTERNAL,
|
||||||
|
// 输入配置
|
||||||
|
input: {
|
||||||
|
// 入口文件
|
||||||
|
'vue3-common': path.resolve(__dirname, 'src/vue3-common.ts'),
|
||||||
|
// 其余需要单独打包的文件
|
||||||
|
...inputEntries
|
||||||
|
},
|
||||||
|
// 输出配置
|
||||||
|
output: {
|
||||||
|
dir: path.resolve(__dirname, outDirPath),
|
||||||
|
// 入口文件
|
||||||
|
entryFileNames: '[name].js',
|
||||||
|
// chunk文件
|
||||||
|
chunkFileNames: 'lib/[name].js',
|
||||||
|
// 资源文件
|
||||||
|
assetFileNames: 'styles/[name].[ext]'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 是否压缩代码
|
||||||
|
minify: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
  打包后会在dist目录生成源码文件。
|
||||||
|
|
||||||
|
# 三、package.json配置
|
||||||
|
## 3.1 导出路径
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/vue3-common.js",
|
||||||
|
"require": "./dist/vue3-common.js",
|
||||||
|
"types": "./dist/vue3-common.d.ts"
|
||||||
|
},
|
||||||
|
"./components/SvgIcon.vue": {
|
||||||
|
"import": "./dist/lib/components/SvgIcon.js",
|
||||||
|
"types": "./dist/components/SvgIcon.vue.d.ts"
|
||||||
|
},
|
||||||
|
"./styles/SvgIcon.css": "./dist/styles/SvgIcon.css",
|
||||||
|
"./components/LoginForm.vue": {
|
||||||
|
"import": "./dist/lib/components/LoginForm.js",
|
||||||
|
"types": "./dist/components/LoginForm.vue.d.ts"
|
||||||
|
},
|
||||||
|
"./styles/LoginForm.css": "./dist/styles/LoginForm.css",
|
||||||
|
"./components/MenuItem.vue": {
|
||||||
|
"import": "./dist/lib/components/MenuItem.js",
|
||||||
|
"types": "./dist/components/MenuItem.vue.d.ts"
|
||||||
|
},
|
||||||
|
"./components/MenuList.vue": {
|
||||||
|
"import": "./dist/lib/components/MenuList.js",
|
||||||
|
"types": "./dist/components/MenuList.vue.d.ts"
|
||||||
|
},
|
||||||
|
"./components/Hamburger.vue": {
|
||||||
|
"import": "./dist/lib/components/Hamburger.js",
|
||||||
|
"types": "./dist/components/Hamburger.vue.d.ts"
|
||||||
|
},
|
||||||
|
"./styles/Hamburger.css": "./dist/styles/Hamburger.css",
|
||||||
|
"./components/MultiInput.vue": {
|
||||||
|
"import": "./dist/lib/components/MultiInput.js",
|
||||||
|
"types": "./dist/components/MultiInput.vue.d.ts"
|
||||||
|
},
|
||||||
|
"./styles/MultiInput.css": "./dist/styles/MultiInput.css",
|
||||||
|
"./types": {
|
||||||
|
"types": "./dist/types/index.d.ts"
|
||||||
|
},
|
||||||
|
"./utils/axiosUtil": {
|
||||||
|
"import": "./dist/lib/utils/axiosUtil.js",
|
||||||
|
"types": "./dist/types/utils/axiosUtil.d.ts"
|
||||||
|
},
|
||||||
|
"./utils/cryptoUtil": {
|
||||||
|
"import": "./dist/lib/utils/cryptoUtil.js",
|
||||||
|
"types": "./dist/types/utils/cryptoUtil.d.ts"
|
||||||
|
},
|
||||||
|
"./utils/dataUtil": {
|
||||||
|
"import": "./dist/lib/utils/dataUtil.js",
|
||||||
|
"types": "./dist/types/utils/dataUtil.d.ts"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  在项目中引入common包后,通过配置tsconfig.json即可实现按需引入功能`import { fun1 } from 'vue3-common/utils/index'`。
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"vue3-common/*": ["node_modules/vue3-common/dist/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、发布框架
|
||||||
|
1. 发布到本地
|
||||||
|
执行`npm pack`命令,会在项目文件夹下生成`.tgz`文件,其他项目通过文件路径形式引入:
|
||||||
|
```json
|
||||||
|
"vue3-common": "file:../vue3-common/vue3-common-1.0.0.tgz"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 发布到git
|
||||||
|
将dist文件夹发布到git仓库,其他项目通过git形式引入:
|
||||||
|
```json
|
||||||
|
"vue3-common": "git+https://gitee.com/Cxx0822/vue3-common#master"
|
||||||
|
```
|
||||||
|
|
||||||
|
# 五、组件使用说明
|
||||||
|
## 5.1 菜单组件
|
||||||
|
1. 安装Vue Router
|
||||||
|
```cmd
|
||||||
|
pnpm install vue-router
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 在src/views文件夹中新建vue文件,例如:
|
||||||
|
```cmd
|
||||||
|
views
|
||||||
|
viewA
|
||||||
|
index.vue
|
||||||
|
viewB
|
||||||
|
index.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 在src/views文件夹下新建meta.ts配置文件
|
||||||
|
```typescript
|
||||||
|
import type { IRouteMetaConfig } from 'vue3-common/types'
|
||||||
|
|
||||||
|
export const metaList: IRouteMetaConfig = {
|
||||||
|
'/viewA': {
|
||||||
|
title: 'viewA',
|
||||||
|
icon: 'viewA',
|
||||||
|
order: 1,
|
||||||
|
redirect: '/viewA/index'
|
||||||
|
},
|
||||||
|
'/viewA/index': {
|
||||||
|
title: 'viewA-Index',
|
||||||
|
icon: ''
|
||||||
|
},
|
||||||
|
'/viewB': {
|
||||||
|
title: 'viewB',
|
||||||
|
icon: 'viewB',
|
||||||
|
order: 2,
|
||||||
|
redirect: '/viewB/index'
|
||||||
|
},
|
||||||
|
'/viewB/index': {
|
||||||
|
title: 'viewB-index',
|
||||||
|
icon: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  order对应的显示顺序关系,icon对应的为src/icons/svg中的svg图标文件(参考下文图标组件)。
|
||||||
|
|
||||||
|
4. 在src目录新建router文件夹,新建menu.ts文件
|
||||||
|
```typescript
|
||||||
|
import { getRoutersByModules, sortRoutesByOrder } from 'vue3-common/utils/routerUtil'
|
||||||
|
import Layout from '@/layout/index.vue'
|
||||||
|
import { metaList } from '@/views/meta'
|
||||||
|
|
||||||
|
const modules = {
|
||||||
|
...import.meta.glob('@/views/viewA/**/*.vue'),
|
||||||
|
...import.meta.glob('@/views/viewB/**/*.vue'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuRoutes = getRoutersByModules(modules, Layout, metaList)
|
||||||
|
|
||||||
|
export default sortRoutesByOrder(menuRoutes)
|
||||||
|
```
|
||||||
|
|
||||||
|
  导入刚才的views文件夹并生成路由菜单。
|
||||||
|
|
||||||
|
5. 如果还有其他的常量路由,可以在src/router中新建constant.ts文件:
|
||||||
|
```typescript
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
const constantRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/login'
|
||||||
|
},
|
||||||
|
// 主页
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
component: () => import('@/views/login/index.vue'),
|
||||||
|
meta: { hidden: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default constantRoutes
|
||||||
|
```
|
||||||
|
|
||||||
|
  该部分即Vue Router中的路由定义。
|
||||||
|
|
||||||
|
6. 在src/router中新建index.ts:
|
||||||
|
```typescript
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
import { setupRouteGuard } from 'vue3-common/utils/permissionUtil'
|
||||||
|
|
||||||
|
// 使用 import.meta.glob 自动导入所有 src/router 目录下的 .ts 文件
|
||||||
|
const routeModules = import.meta.glob('./*.ts', { eager: true })
|
||||||
|
|
||||||
|
// 将所有模块的默认导出(即路由配置)合并成一个路由数组
|
||||||
|
const routes: RouteRecordRaw[] = Object.values(routeModules)
|
||||||
|
.map((module: any) => module.default) // 获取每个模块的默认导出
|
||||||
|
.flat() // 扁平化数组,确保所有路由项都在一个数组中
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
scrollBehavior: () => ({ top: 0 }),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置路由守卫
|
||||||
|
setupRouteGuard(router)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
```
|
||||||
|
|
||||||
|
  遍历src/router文件夹下所有的路由文件,并添加路由守卫。
|
||||||
|
|
||||||
|
7. 在main.ts中配置路由:
|
||||||
|
```ts
|
||||||
|
// 引入路由
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
// 创建Vue3实例
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 使用路由
|
||||||
|
app.use(router)
|
||||||
|
```
|
||||||
|
|
||||||
|
8. 在layout中使用:
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<aside class="layout-sidebar">
|
||||||
|
<menu-list
|
||||||
|
:route="useRoute()"
|
||||||
|
:routers="menuRoutes"
|
||||||
|
:collapse="isCollapse"
|
||||||
|
class="layout-menu"/>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { MenuList } from 'vue3-common'
|
||||||
|
import menuRoutes from '@/router/menu'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
  如果需要对路由菜单进行权限处理,可以使用计算属性过滤一遍:
|
||||||
|
```ts
|
||||||
|
const routers = computed(() => {
|
||||||
|
const menuPathList = menuRoutes.map((item) => item.path)
|
||||||
|
const removePathList = menuPathList.filter((item) => !authStore.currentUser.menu.includes(item))
|
||||||
|
|
||||||
|
removePathList.forEach((item) => {
|
||||||
|
router.removeRoute(capitalizeStr(item.replace('/', '')))
|
||||||
|
})
|
||||||
|
|
||||||
|
return menuRoutes.filter((route) => !removePathList.includes(route.path))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
实现原理:
|
||||||
|
```ts
|
||||||
|
import type { RouteComponent, RouteRecordRaw, RouteMeta } from 'vue-router'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任意对象类型
|
||||||
|
*/
|
||||||
|
interface ICommonObj {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由meta配置
|
||||||
|
*/
|
||||||
|
interface IRouteMetaConfig {
|
||||||
|
[key: string]: RouteMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由路径转为名称 /admin/dashboard -> AdminDashboard
|
||||||
|
* @param path
|
||||||
|
*/
|
||||||
|
export const convertPath2Name = (path: string): string => {
|
||||||
|
// 移除路径开头和结尾的斜杠(如果存在)
|
||||||
|
const newPath = path.replace(/^\/|\/$/g, '')
|
||||||
|
|
||||||
|
// 将路径按斜杠分割成多个部分
|
||||||
|
const parts = newPath.split('/')
|
||||||
|
|
||||||
|
// 处理每个部分:将连字符后的字母大写,并移除连字符;处理动态参数(如 :id)
|
||||||
|
return parts.map((part) =>
|
||||||
|
part.split('-').map((subPart) => {
|
||||||
|
// 处理动态参数(如 :id)
|
||||||
|
if (subPart.startsWith(':')) {
|
||||||
|
return subPart.slice(1).charAt(0).toUpperCase() + subPart.slice(2)
|
||||||
|
}
|
||||||
|
// 普通部分
|
||||||
|
return subPart.charAt(0).toUpperCase() + subPart.slice(1)
|
||||||
|
}).join('')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据meta的order排序
|
||||||
|
* @param routes 路由列表
|
||||||
|
*/
|
||||||
|
export const sortRoutesByOrder = (routes: RouteRecordRaw[]): RouteRecordRaw[] => {
|
||||||
|
// 排序当前路由的子路由
|
||||||
|
routes.forEach((route) => {
|
||||||
|
if (route.children && route.children.length > 0) {
|
||||||
|
// 如果有子路由,递归排序子路由
|
||||||
|
route.children = sortRoutesByOrder(route.children)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 对当前路由按 meta.order 排序
|
||||||
|
return routes.sort((a, b) => (a.meta?.order as number || 0) - (b.meta?.order as number || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取路由
|
||||||
|
* @param modules 通过import.meta.glob导入的模块
|
||||||
|
* @param layout 布局组件
|
||||||
|
* @param metaConfig 路由meta配置
|
||||||
|
*/
|
||||||
|
export const getRoutersByModules = (modules: ICommonObj,
|
||||||
|
layout: RouteComponent, metaConfig?: IRouteMetaConfig): RouteRecordRaw[] => {
|
||||||
|
// const modules = import.meta.glob('@/views/admin/**/*.vue')
|
||||||
|
const routes: RouteRecordRaw[] = []
|
||||||
|
|
||||||
|
Object.keys(modules).forEach((filePath) => {
|
||||||
|
// 获取文件的模块路径和文件名
|
||||||
|
const parentPath = filePath.substring(0, filePath.lastIndexOf('/')).replace('/src/views', '')
|
||||||
|
const path = filePath.replace('.vue', '')
|
||||||
|
.replace('/src/views', '')
|
||||||
|
.replace(/\[([^\]]+)]/g, '/:$1')
|
||||||
|
|
||||||
|
// 为每个文件生成对应的路由
|
||||||
|
const route: RouteRecordRaw = {
|
||||||
|
path,
|
||||||
|
name: convertPath2Name(path),
|
||||||
|
component: modules[filePath]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果存在meta配置
|
||||||
|
if (metaConfig) {
|
||||||
|
route.meta = metaConfig[path]
|
||||||
|
|
||||||
|
// 设置重定向属性
|
||||||
|
if (route.meta?.redirect) {
|
||||||
|
route.redirect = route.meta?.redirect as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果该路径已经存在(嵌套路由),则将其作为子路由
|
||||||
|
let parentRoute = routes.find((item) => item.path === parentPath)
|
||||||
|
if (!parentRoute) {
|
||||||
|
parentRoute = {
|
||||||
|
path: parentPath,
|
||||||
|
name: convertPath2Name(parentPath),
|
||||||
|
component: layout,
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果存在meta配置
|
||||||
|
if (metaConfig) {
|
||||||
|
parentRoute.meta = metaConfig[parentPath]
|
||||||
|
|
||||||
|
// 设置重定向属性
|
||||||
|
if (parentRoute.meta?.redirect) {
|
||||||
|
parentRoute.redirect = parentRoute.meta?.redirect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
routes.push(parentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是子路由,加入到父路由的 `children` 数组
|
||||||
|
parentRoute.children?.push(route)
|
||||||
|
})
|
||||||
|
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  核心原理就是利用`import.meta.glob`创建路径->组件的映射,然后结合路由配置表,转为路由对象并添加到`vue router`中。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 5.2 图标组件
|
||||||
|
1. 安装vite-plugin-svg-icons依赖
|
||||||
|
```cmd
|
||||||
|
pnpm install vite-plugin-svg-icons -D
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 配置vite.config.ts
|
||||||
|
```typescript
|
||||||
|
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
createSvgIconsPlugin({
|
||||||
|
// 指定 SVG图标 保存的文件夹路径
|
||||||
|
iconDirs: [path.resolve(process.cwd(), 'src/icons/svg')],
|
||||||
|
// 指定 使用svg图标的格式
|
||||||
|
symbolId: 'icon-[dir]-[name]'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 在main.ts中注册
|
||||||
|
```typescript
|
||||||
|
// 注册svg-icon
|
||||||
|
import 'virtual:svg-icons-register'
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 在src/icons/svg目录中添加svg图标
|
||||||
|
5. 在组件中使用
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<svg-icon name="user"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { SvgIcon } from 'vue3-common'
|
||||||
|
</script>
|
||||||
|
```
|
||||||
62
docs/Practice/Company/Learn.md
Normal file
62
docs/Practice/Company/Learn.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
title: 埃学习
|
||||||
|
date: 2026-05-27
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、项目背景
|
||||||
|
  为解决企业内部线下培训成本高、进度难追踪的问题,开发了一套企业在线学习平台,用于员工课程学习、在线考试、培训计划管理与数据统计分析,已全面接入企业微信工作台。
|
||||||
|
|
||||||
|
# 二、系统架构
|
||||||
|

|
||||||
|
|
||||||
|
# 三、核心模块
|
||||||
|
## 3.1 员工端
|
||||||
|
1. 课程学习(视频播放、进度记录、课程评论)
|
||||||
|
2. 在线考试(随机试卷、自动阅卷)
|
||||||
|
3. 学习记录和统计
|
||||||
|
|
||||||
|
## 3.2 管理员端
|
||||||
|
1. 课程管理(视频上传、章节管理)
|
||||||
|
2. 培训计划管理(按部门/岗位下发)
|
||||||
|
3. 考试与题库管理
|
||||||
|
4. 人员与组织管理
|
||||||
|
5. 员工学习数据统计
|
||||||
|
|
||||||
|
# 四、技术栈
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|---|---|
|
||||||
|
| **前端** | Vue 3、Element Plus、Vue Router、Pinia、DPlayer (视频播放)、@vue-office (文档预览)、ECharts (数据可视化)|
|
||||||
|
| **后端** | Spring Boot 3、Spring MVC、Sa-Token (鉴权)、MyBatis-Plus |
|
||||||
|
| **中间件** | Redis、企业微信 SDK |
|
||||||
|
| **视频处理** | Java JAVE2 (FFmpeg 封装,视频压缩转码) |
|
||||||
|
| **存储** | MySQL 8.0 (业务数据)、AList (视频/文件资源存储) |
|
||||||
|
| **运维** | Docker、Nginx、Fluent Bit (日志采集)、OpenObserver (日志可视化) |
|
||||||
|
|
||||||
|
# 五、核心设计
|
||||||
|
- 基于 **Spring Boot 3 + MyBatis‑Plus** 构建稳定的后端架构,通过 **Sa‑Token** 实现接口级鉴权,保障系统安全与权限可控。
|
||||||
|
- 集成 **企业微信 SDK**,实现企业微信工作台免登录认证;通过**企业微信消息通知机制**,自动推送学习任务与提醒。
|
||||||
|
- 设计 **课程资源、学习计划与在线考试** 等核心业务模型,支持批量下发培训任务;实现随机组卷、自动阅卷与成绩统计,并自动提醒员工完成学习任务。
|
||||||
|
- 基于 **JAVE2(FFmpeg)** 实现视频自动压缩与转码,适配多终端播放;采用 AList 作为统一文件存储平台,集中管理音视频及课件资源。
|
||||||
|
- 基于 **Vue 3 + Element Plus + Pinia** 构建管理员端与员工端页面,集成 **vue‑office** 实现多格式文档在线预览,集合**ECharts**实现多维度数据分析。
|
||||||
|
- 使用 **Docker + Nginx** 部署,结合 **Fluent Bit + OpenObserver** 构建统一日志监控体系,提升系统可观测性与运维效率。
|
||||||
|
|
||||||
|
# 六、难点/亮点
|
||||||
|
## 5.1 公网视频播放卡顿
|
||||||
|
- **问题**:服务器公网带宽小,高清视频并发播放严重卡顿。
|
||||||
|
- **方案**:上传视频后,使用JAVE2调用FFmpeg将视频统一压缩为720p的清晰度视频。
|
||||||
|
- **效果**:视频体积减少60%+,公网环境播放流畅。
|
||||||
|
|
||||||
|
## 5.2 学习进度防作弊
|
||||||
|
- **问题**:员工学习视频课程时,可通过拖动进度条快速完成课程,无法真实反映学习行为,存在“刷课”风险。
|
||||||
|
- **方案**:根据管理员设定的课程学习时长,在员工学习时,启动定时器;每分钟会保存学习时长至数据库中,下次学习时,直接跳转至上次学习进度;课程是否完成已定时器结束为准。
|
||||||
|
- **效果**:有效防止通过拖拽进度条刷课,保证学习时长真实可信;同时支持断点续学,避免重复学习,满足企业培训考核与合规审计要求。
|
||||||
|
|
||||||
|
## 5.3 学习计划通知
|
||||||
|
- **问题**:培训计划、考试通知无法及时触达员工,导致培训参与率低、管理成本高。
|
||||||
|
- **方案**:在发布培训计划时,调用企业微信消息推送接口,自动向指定员工发送应用消息通知;结合定时任务,对未完成培训的人员进行周期性提醒。
|
||||||
|
- **效果**:培训通知实时触达,未读提醒显著提高培训完成率;管理员无需手动维护人员信息,降低了系统运维成本。
|
||||||
|
|
||||||
|
# 七、成果
|
||||||
|
1. 平台全面替代线下培训,年度培训计划完成率由 60% 提升至 95% 以上,满足了企业合规培训要求。
|
||||||
|
2. 过视频压缩方案解决了公网带宽瓶颈,结合企业微信自动化通知,大幅降低人工催办成本,整体培训运营成本下降约 40%。
|
||||||
|
3. 实现了学习进度防作弊与学习记录全跟踪,为公司提供精准的数据统计报表,支撑人才盘点与绩效评估。
|
||||||
88
docs/Practice/Personal/Blog.md
Normal file
88
docs/Practice/Personal/Blog.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
title: 个人博客系统
|
||||||
|
date: 2026-06-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、项目背景
|
||||||
|
  为满足个人技术积累与内容分享需求,独立开发了一套多端博客系统,支持博客管理、发布与多端阅读。
|
||||||
|
  系统包含博客网站、博客管理后台与移动端 App,覆盖 Web 端与移动端用户场景,实现了从内容创作、存储、检索到多端展示的完整闭环。
|
||||||
|
|
||||||
|
# 二、系统架构
|
||||||
|
|
||||||
|
# 三、核心模块
|
||||||
|
## 3.1 博客网站
|
||||||
|
1. 首页博客列表展示(分页、置顶)
|
||||||
|
2. 博客分类与标签筛选
|
||||||
|
3. 博客归档(按时间维度)
|
||||||
|
4. 博客全文搜索(Elasticsearch)
|
||||||
|
5. Markdown 文章渲染与代码高亮
|
||||||
|
6. 博客阅读时长和阅读人数统计
|
||||||
|
7. 评论发布与互动(匿名)
|
||||||
|
|
||||||
|
<VPSwiper
|
||||||
|
:slides="[
|
||||||
|
'/blog-press/images/blog/home.png',
|
||||||
|
'/blog-press/images/blog/category.png',
|
||||||
|
'/blog-press/images/blog/archive.png',
|
||||||
|
'/blog-press/images/blog/search.png',
|
||||||
|
'/blog-press/images/blog/content.png',
|
||||||
|
'/blog-press/images/blog/commit.png'
|
||||||
|
]"
|
||||||
|
button-text="点击全屏"
|
||||||
|
:navigation="false"
|
||||||
|
effect="coverflow"
|
||||||
|
/>
|
||||||
|
|
||||||
|
## 3.2 博客管理后台
|
||||||
|
1. 博客新增、编辑、删除(权限控制)
|
||||||
|
2. Markdown 编辑器(md-editor-v3)
|
||||||
|
3. 博客图片上传(RustFS)
|
||||||
|
4. 分类 / 标签管理
|
||||||
|
5. 博客状态管理(草稿 / 发布)
|
||||||
|
6. 博客访问数据查看
|
||||||
|
7. 统计数据仪表盘(ECharts)
|
||||||
|
|
||||||
|
<VPSwiper
|
||||||
|
:slides="[
|
||||||
|
'/blog-press/images/blog/login.png',
|
||||||
|
'/blog-press/images/blog/stats.png',
|
||||||
|
'/blog-press/images/blog/article.png',
|
||||||
|
'/blog-press/images/blog/edit.png'
|
||||||
|
]"
|
||||||
|
button-text="点击全屏"
|
||||||
|
:navigation="false"
|
||||||
|
effect="coverflow"
|
||||||
|
/>
|
||||||
|
|
||||||
|
## 3.3 移动端APP
|
||||||
|
1. 博客列表与详情浏览
|
||||||
|
2. Markdown 内容原生渲染(markdown_widget)
|
||||||
|
3. 分类、归档、搜索功能
|
||||||
|
4. 移动端自适应布局
|
||||||
|
|
||||||
|
# 四、技术栈
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|---|---|
|
||||||
|
| **前端** | Vue 3、Element Plus、Vue Router、Pinia、md-editor-v3、Axios |
|
||||||
|
| **后端** | FastAPI、Pydantic、SQLAlchemy、Uvicorn |
|
||||||
|
| **移动端** | Flutter、markdown_widget、provider、dio |
|
||||||
|
| **中间件** | Elasticsearch(全文检索、分词索引) |
|
||||||
|
| **存储** | MySQL 8.0(业务数据)、RustFS(图片存储) |
|
||||||
|
| **运维** | Docker、Nginx、Fluent Bit (日志采集)、OpenObserver (日志可视化) |
|
||||||
|
|
||||||
|
# 五、核心设计
|
||||||
|
- 基于 FastAPI 构建高性能后端 API,采用分层结构设计,保证业务逻辑清晰、接口响应高效。
|
||||||
|
- 博客内容统一采用 Markdown 格式,Web 端与移动端分别基于 md-editor-v3与 markdown_widget实现一致的内容渲染效果。
|
||||||
|
- 使用 Elasticsearch 构建博客全文检索引擎,支持标题、内容、标签多维搜索,显著提升内容查找效率。
|
||||||
|
- 图片资源统一上传至 RustFS 对象存储,通过 URL 访问,降低应用服务器存储压力,提升静态资源加载速度。
|
||||||
|
- 管理后台与博客前端共享同一后端服务,通过接口级权限控制,实现“管理操作需鉴权、公开访问无需登录”的安全设计。
|
||||||
|
- Web 端采用 Vue 3 + Pinia 实现状态管理与组件化开发;移动端基于 Flutter 实现跨平台一致体验。
|
||||||
|
|
||||||
|
# 六、难点 / 亮点
|
||||||
|
## 6.1 博客搜索性能优化
|
||||||
|
- **问题**:随着博客数量增长,MySQL LIKE 查询性能下降明显。
|
||||||
|
- **方案**:引入 Elasticsearch,构建博客索引,支持中文分词与多字段检索。
|
||||||
|
|
||||||
|
## 6.2 图片存储与访问优化
|
||||||
|
- **问题**:本地存储图片不利于扩展,且增加服务器带宽压力。
|
||||||
|
- **方案**:集成 RustFS 对象存储,所有博客图片统一上传并返回访问 URL。
|
||||||
65
docs/Practice/Personal/SweetHut.md
Normal file
65
docs/Practice/Personal/SweetHut.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
title: 家庭管理系统
|
||||||
|
date: 2026-06-04
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、项目背景
|
||||||
|
  针对家庭信息分散、难以统一管理的问题,设计并实现了一套移动端家庭管理平台,将账单、旅行、健康、纪念日等数据进行集中管理,并通过私有化部署与数据加密保障安全,实现家庭生活的数字化沉淀。
|
||||||
|
|
||||||
|
# 二、系统架构
|
||||||
|
|
||||||
|
# 三、核心模块
|
||||||
|
- **账单记录**
|
||||||
|

|
||||||
|
|
||||||
|
- **美食记录**
|
||||||
|
|
||||||
|
- **旅行记录**
|
||||||
|
|
||||||
|
- **物品管理**
|
||||||
|
|
||||||
|
- **运动健康**
|
||||||
|
|
||||||
|
- **纪念日管理**
|
||||||
|
|
||||||
|
- **家庭资产**
|
||||||
|
|
||||||
|
- **密码管家**
|
||||||
|
|
||||||
|
- **奖状证书**
|
||||||
|
|
||||||
|
- **时光手账**
|
||||||
|
|
||||||
|
# 四、技术栈
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|---|---|
|
||||||
|
| **前端** | Vue 3、Vant、Vue Router、Pinia、Axios |
|
||||||
|
| **后端** | Spring Boot 3、Spring MVC、Satoken、MyBatis‑Plus |
|
||||||
|
| **中间件** | Express +Sharp(图片缩略图)、Quartz(定时任务) |
|
||||||
|
| **存储** | MySQL 8.0(业务数据)、AList(图片/视频对象存储) |
|
||||||
|
| **视频处理** | Java JAVE2 (FFmpeg 封装,视频压缩转码) |
|
||||||
|
| **安全** | AES 数据加密 |
|
||||||
|
| **移动端** | Flutter(WebView 封装为 Android APK) |
|
||||||
|
| **运维** | Docker、Nginx、Fluent Bit (日志采集)、OpenObserver (日志可视化) |
|
||||||
|
|
||||||
|
# 五、核心设计
|
||||||
|
- 基于 **Spring Boot 3 + MyBatis‑Plus** 构建稳定的后端架构,通过 **Sa‑Token** 实现接口级鉴权,保障系统安全与权限可控。
|
||||||
|
- 所有敏感信息(密码、资产等)均使用 **AES 对称加密** 存储,防止数据库泄露导致隐私暴露。
|
||||||
|
- 使用 **Node.js + Sharp** 搭建图片服务,移动端优先加载缩略图,点击后按需加载原图,显著优化首屏性能。
|
||||||
|
- 视频资源上传后通过 **JAVE2 + FFmpeg** 压缩并转码,在保证清晰度的前提下减小体积。
|
||||||
|
- 基于 **Quartz** 实现定时任务,用于纪念日提醒等场景。
|
||||||
|
- 前端基于 **Vue 3 + Element Plus + Vant + Pinia** 开发移动端页面,并使用 **Flutter WebView** 将 Web 应用封装为 Android APK,实现一套代码多端使用。
|
||||||
|
- 使用 **Docker + Nginx** 部署,结合 **Fluent Bit + OpenObserver** 构建统一日志监控体系,提升系统可观测性与运维效率。
|
||||||
|
|
||||||
|
# 六、难点 / 亮点
|
||||||
|
## 6.1 图片资源加载性能优化
|
||||||
|
- **问题**:手机拍摄的照片体积较大,移动端直接加载原图导致页面卡顿、流量消耗大。
|
||||||
|
- **方案**:使用 **Node.js + Sharp** 构建图片实时处理服务,通过路径参数实时获取压缩图片,默认加载缩略图,点击后加载原图,并配合lazy懒加载优化首屏加载时间。
|
||||||
|
|
||||||
|
## 6.2 视频资源压缩与存储
|
||||||
|
- **问题**:手机视频多为高清录制,体积大、加载慢,直接存储与播放影响用户体验。
|
||||||
|
- **方案**:使用 **JAVE2 + FFmpeg** 在上传时将视频统一压缩并转码,减少视频空间。
|
||||||
|
|
||||||
|
## 6.3 敏感数据加密存储
|
||||||
|
- **问题**:密码、资产等数据高度敏感,若数据库被攻破将造成严重隐私泄露。
|
||||||
|
- **方案**:采用 AES 对敏感字段进行加密存储,密钥与应用配置隔离管理,服务端统一加解密。
|
||||||
10
docs/Practice/index.md
Normal file
10
docs/Practice/index.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
layout: doc
|
||||||
|
title: 项目实战
|
||||||
|
---
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { routers } from '../.vitepress/theme/router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<MenuList :routers=routers[2] :isNested=true />
|
||||||
242
docs/Web/AI/Langchain.md
Normal file
242
docs/Web/AI/Langchain.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
---
|
||||||
|
title: 基于Langchain的Agent开发
|
||||||
|
date: 2026-05-28
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  LangChain是一整个智能体开发平台,包含一系列开源的智能体(Agent)开发框架,而且兼容Python和TypeScript两种语言。
|
||||||
|
  Agent(通常翻译为智能体或代理)是指一种能够感知环境、进行推理、自主决策并采取行动以实现特定目标的智能系统。
|
||||||
|
|
||||||
|
# 二、模型
|
||||||
|
## 2.1 模型定义
|
||||||
|
  通用自定义模型`init_chat_model`:
|
||||||
|
```python
|
||||||
|
# 初始化模型
|
||||||
|
model = init_chat_model(
|
||||||
|
model="qwen-max", # 模型名称,这里可以自定义,我们用的是阿里的qwen-max
|
||||||
|
model_provider="openai",
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
temperature=1.5,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
  如果是Langchain不支持的模型,需要指定模型提供者(虽然我们用的是阿里,但是阿里兼容openai,所以这里用openai,就是默认采用openai的API规范)
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
init_chat_model还可以调整模型参数:
|
||||||
|
- temperature: 控制生成文本的随机性,值越小越确定,值越大越随机
|
||||||
|
- max_tokens: 控制生成文本的最大长度
|
||||||
|
- top_p: 控制生成文本的多样性,值越小越多样,值越大越确定
|
||||||
|
- timeout: 控制生成文本的超时时间
|
||||||
|
- max_retries: 控制生成文本的最大重试次数
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 2.2 访问模型
|
||||||
|
### 2.2.1 阻塞调用
|
||||||
|
```python
|
||||||
|
# 调用invoke方法
|
||||||
|
response = model.invoke("中国的首都是哪里?")
|
||||||
|
|
||||||
|
# 查看响应结果
|
||||||
|
print(response)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2.2 流式调用
|
||||||
|
```python
|
||||||
|
# 通过.stream方法实现流式访问
|
||||||
|
stream = model.stream("中国的首都是哪里?")
|
||||||
|
|
||||||
|
# 遍历stream结果,实时打印AI的回复
|
||||||
|
for chunk in stream:
|
||||||
|
print(chunk.content, end="", flush=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 三、智能体
|
||||||
|
## 3.1 创建智能体
|
||||||
|
```python
|
||||||
|
# 1.使用init_chat_model初始化模型
|
||||||
|
model = init_chat_model(
|
||||||
|
model="qwen-max",
|
||||||
|
model_provider="openai",
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
temperature=1.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2.使用初始化好的model创建智能体
|
||||||
|
agent = create_agent(model=model)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 消息类型
|
||||||
|
- SystemMessage:role是system,代表系统消息,用于设定模型角色和交互背景
|
||||||
|
- HumanMessage:role是user,代表用户输入的消息
|
||||||
|
- AIMessage:role是assistant,代表LLM生成的响应,包含:文本、工具调用、元数据
|
||||||
|
- ToolMessage:role是tool,代表工具调用时产生的结果
|
||||||
|
|
||||||
|
## 3.3 调用模型
|
||||||
|
```python
|
||||||
|
# 调用Agent,发送消息
|
||||||
|
response = agent.invoke({
|
||||||
|
"messages": [
|
||||||
|
HumanMessage(content="你好,我是虎哥"),
|
||||||
|
AIMessage(content="你好,虎哥,很高兴认识你。"),
|
||||||
|
HumanMessage(content="我的名字是什么?")
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
for message in response['messages']:
|
||||||
|
message.pretty_print()
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、提示词
|
||||||
|
  发送给大模型的所有消息都可以称为提示词(Prompt),它直接影响模型的输出结果。
|
||||||
|
  其中,SystemMessage尤为重要,我们把SystemMessage称为系统提示词(System Prompt),它可以给模型设定角色和本次聊天的背景,对模型生成的内容有很大的影响。
|
||||||
|
  从内容来说,提示词通常包含以下几个部分,通常按此顺序排列:
|
||||||
|
- 身份(Identity):描述AI的职责、沟通风格和总体目标。
|
||||||
|
- 说明(Instructions):请指导模型如何生成所需的响应。它应该遵循哪些规则?模型应该做什么,以及模型绝对不能做什么?
|
||||||
|
- 示例(Examples):提供可能的输入示例,以及模型期望的输出。
|
||||||
|
- 背景信息(Context):向模型提供生成响应所需的任何额外信息,例如RAG的额外知识库数据,或您认为特别相关的任何其他数据。
|
||||||
|
|
||||||
|
  例如写一个智能生成菜谱的提示词:
|
||||||
|
```python
|
||||||
|
system_prompt = """
|
||||||
|
你是一名私人厨师。收到用户提供的食材照片或清单后,请按以下流程操作:
|
||||||
|
1.识别和评估食材:若用户提供照片,首先辨识所有可见食材。基于食材的外观状态,评估其新鲜度与可用量,整理出一份“当前可用食材清单”。
|
||||||
|
2.智能食谱检索:优先调用 web_search 工具,以“可用食材清单”为核心关键词,查找可行菜谱。
|
||||||
|
3.多维度评估与排序:从营养价值和制作难度两个维度对检索到的候选食谱进行量化打分,并根据得分排序,制作简单且营养丰富的排名靠前。
|
||||||
|
4.结构化方案输出:把排序后的食谱整理为一份结构清晰的建议报告,要包含食谱信息、得分、推荐理由、食谱的参考图片,帮助用户快速做出决策。
|
||||||
|
|
||||||
|
请严格按照流程,优先调用 web_search 工具搜索食谱,搜索不到的情况下才能自己发挥。
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
  提示词作为agent的参数传递:
|
||||||
|
```python
|
||||||
|
agent = create_agent(model=model, system_prompt=system_prompt)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 五、工具
|
||||||
|
  一个完整的Agent至少要包含两个关键的部分:
|
||||||
|
- 模型:是Agent的大脑,负责推理、分析,规划任务步骤
|
||||||
|
- 工具:是Agent的手脚,负责执行任务,与外界交互
|
||||||
|
|
||||||
|
  例如使用专门用于给Agent提供Web搜索的工具:Tavily
|
||||||
|
```python
|
||||||
|
# 初始化工具,并设置参数,具体参数设置参考官网
|
||||||
|
tool = TavilySearch(
|
||||||
|
tavily_api_key=key,
|
||||||
|
max_results=5,
|
||||||
|
topic="general"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建智能体,使用预定义工具tavily
|
||||||
|
agent = create_agent(
|
||||||
|
model=model,
|
||||||
|
tools=[tool],
|
||||||
|
system_prompt=system_prompt
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
tavily_api_key可以通过访问官网免费获取。
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 六、记忆
|
||||||
|
  LangChain也提供了很多持久化存储的checkpointer,例如:
|
||||||
|
- SqlLiteSaver :基于sqlite存储
|
||||||
|
- PostgresSaver :基于Postgres存储
|
||||||
|
- CosmosDBSaver :使用Azure Cosmos DB的实现
|
||||||
|
|
||||||
|
## 6.1 SqlLiteSaver
|
||||||
|
### 6.1.1 初始化
|
||||||
|
```python
|
||||||
|
# 初始化checkpointer
|
||||||
|
checkpointer = SqliteSaver(sqlite3.connect("checkpoint.db", check_same_thread=False))
|
||||||
|
# 自动建表
|
||||||
|
checkpointer.setup()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1.2 配置
|
||||||
|
```python
|
||||||
|
# 创建agent
|
||||||
|
agent = create_agent(
|
||||||
|
model=model,
|
||||||
|
checkpointer=checkpointer,
|
||||||
|
)
|
||||||
|
|
||||||
|
agent.stream({"messages": [message]},
|
||||||
|
{"configurable": {"thread_id": thread_id}},
|
||||||
|
stream_mode="messages"
|
||||||
|
):
|
||||||
|
```
|
||||||
|
|
||||||
|
  调用模型的时候需要传递`thread_id`。
|
||||||
|
::: tip
|
||||||
|
通常还需要构建一个`用户-问题标题`的数据库,方便查询用户的所有历史回答。
|
||||||
|
问题标题可以再调一次模型总结。
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 七、实战
|
||||||
|
## 7.1 后端
|
||||||
|
```python
|
||||||
|
async def query_agent(request: ChatRequest):
|
||||||
|
try:
|
||||||
|
message = HumanMessage(content=request.message)
|
||||||
|
|
||||||
|
# 流式调用Agent
|
||||||
|
for chunk, metadata in agent.stream(
|
||||||
|
{"messages": [message]},
|
||||||
|
{"configurable": {"thread_id": request.thread_id}},
|
||||||
|
stream_mode="messages"
|
||||||
|
):
|
||||||
|
if isinstance(chunk, AIMessageChunk) and chunk.content:
|
||||||
|
yield chunk.content
|
||||||
|
|
||||||
|
# 总结对话标题并保存
|
||||||
|
if not exists_session(request.thread_id, request.username):
|
||||||
|
title = generate_title(get_messages(request.thread_id))
|
||||||
|
save_session(request.thread_id, request.username, title)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[错误]: {str(e)}")
|
||||||
|
yield "信息检索失败,请重新输入问题提问"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/stream")
|
||||||
|
async def chat_endpoint(request: ChatRequest):
|
||||||
|
"""流式对话"""
|
||||||
|
return StreamingResponse(
|
||||||
|
query_agent(request),
|
||||||
|
media_type="text/event-stream"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7.2 前端
|
||||||
|
```typescript
|
||||||
|
const sendMessage = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
"/chief-agent-api/chat/stream",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(message)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let aiText = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
aiText += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
211
docs/Web/AI/RAG.md
Normal file
211
docs/Web/AI/RAG.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
---
|
||||||
|
title: RAG开发
|
||||||
|
date: 2026-06-24
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、工作流程
|
||||||
|
```text
|
||||||
|
原始文档 / 数据
|
||||||
|
↓
|
||||||
|
【文档收集与清洗】
|
||||||
|
↓
|
||||||
|
【文档切分(Chunking)】
|
||||||
|
↓
|
||||||
|
【向量化(Embedding)】
|
||||||
|
↓
|
||||||
|
【向量数据库存储】
|
||||||
|
↓
|
||||||
|
用户自然语言问题
|
||||||
|
↓
|
||||||
|
【检索系统】召回相关知识片段
|
||||||
|
↓
|
||||||
|
【上下文组装】构建增强 Prompt
|
||||||
|
↓
|
||||||
|
【大模型】基于知识生成回答
|
||||||
|
↓
|
||||||
|
【引用 / 校验】
|
||||||
|
↓
|
||||||
|
最终返回给用户(答案 + 来源)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 二、实战
|
||||||
|
## 2.1 资料处理
|
||||||
|
### 2.1.1 pdf处理
|
||||||
|
  [PaddleOCR](https://aistudio.baidu.com/paddleocr)是百度飞桨(PaddlePaddle)团队开源的产业级 OCR(光学字符识别)与文档智能开发套件。
|
||||||
|
```python
|
||||||
|
JOB_URL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
|
||||||
|
TOKEN = ""
|
||||||
|
MODEL = "PP-OCRv5"
|
||||||
|
|
||||||
|
FILE_URL = ""
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"Authorization": f"bearer {TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========== 提交 OCR 任务 ==========
|
||||||
|
payload = {
|
||||||
|
"fileUrl": FILE_URL,
|
||||||
|
"model": MODEL,
|
||||||
|
"optionalPayload": {
|
||||||
|
"useDocOrientationClassify": False,
|
||||||
|
"useDocUnwarping": False,
|
||||||
|
"useChartRecognition": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
print("🚀 Submitting OCR job...")
|
||||||
|
resp = requests.post(JOB_URL, json=payload, headers=HEADERS)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
job_id = resp.json()["data"]["jobId"]
|
||||||
|
print(f"✅ Job submitted, jobId={job_id}")
|
||||||
|
|
||||||
|
# ========== 轮询任务状态 ==========
|
||||||
|
while True:
|
||||||
|
r = requests.get(f"{JOB_URL}/{job_id}", headers=HEADERS)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
data = r.json()["data"]
|
||||||
|
state = data["state"]
|
||||||
|
|
||||||
|
if state == "pending":
|
||||||
|
print("⏳ Job status: pending")
|
||||||
|
elif state == "running":
|
||||||
|
prog = data.get("extractProgress", {})
|
||||||
|
total = prog.get("totalPages")
|
||||||
|
done = prog.get("extractedPages")
|
||||||
|
if total and done:
|
||||||
|
print(f"📄 Processing: {done}/{total} pages")
|
||||||
|
else:
|
||||||
|
print("📄 Processing...")
|
||||||
|
elif state == "done":
|
||||||
|
prog = data["extractProgress"]
|
||||||
|
print(
|
||||||
|
f"✅ Job finished | Pages: {prog['extractedPages']} | "
|
||||||
|
f"Start: {prog['startTime']} | End: {prog['endTime']}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
elif state == "failed":
|
||||||
|
print("❌ Job failed:", data.get("errorMsg", "Unknown error"))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# ========== 获取 OCR 结果 ==========
|
||||||
|
jsonl_url = data["resultUrl"]["jsonUrl"]
|
||||||
|
print(f"📥 Fetching result: {jsonl_url}")
|
||||||
|
|
||||||
|
resp = requests.get(jsonl_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里只处理了url形式的文档,实际开发中,可以搭建一个管理页面集中处理文档资料。
|
||||||
|
  获取到的结果是按照区域划分的,还需要进一步的处理:
|
||||||
|
```python
|
||||||
|
pages = []
|
||||||
|
|
||||||
|
for line in resp.text.strip().splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
obj = json.loads(line)
|
||||||
|
|
||||||
|
for page in obj.get("result", {}).get("ocrResults", []):
|
||||||
|
pruned = page.get("prunedResult", {})
|
||||||
|
texts = pruned.get("rec_texts", [])
|
||||||
|
|
||||||
|
# 一页一段
|
||||||
|
page_text = "\n".join(texts)
|
||||||
|
pages.append(page_text)
|
||||||
|
|
||||||
|
# ✅ 最终文档
|
||||||
|
full_doc = "\n\n".join(pages)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2 向量化
|
||||||
|
  使用在线或者离线向量化模型处理:
|
||||||
|
```python
|
||||||
|
text_splitter = RecursiveCharacterTextSplitter(
|
||||||
|
chunk_size=500, # 每个 chunk 最大长度
|
||||||
|
chunk_overlap=50, # 重叠,防止切坏语义
|
||||||
|
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = text_splitter.split_text(full_doc)
|
||||||
|
print(f"✅ 切分成 {len(chunks)} 个 chunk")
|
||||||
|
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
print(f"\n===== Chunk {i} =====")
|
||||||
|
print(chunk)
|
||||||
|
|
||||||
|
model = SentenceTransformer("BAAI/bge-base-zh", cache_folder="./models")
|
||||||
|
|
||||||
|
embeddings = model.encode(
|
||||||
|
chunks,
|
||||||
|
normalize_embeddings=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"✅ 向量维度: {embeddings.shape}")
|
||||||
|
|
||||||
|
client = chromadb.PersistentClient(path="./vector_db/train_docs")
|
||||||
|
collection = client.get_or_create_collection(name="train_docs")
|
||||||
|
|
||||||
|
for i, (chunk, emb) in enumerate(zip(chunks, embeddings)):
|
||||||
|
collection.add(
|
||||||
|
ids=[str(i)],
|
||||||
|
documents=[chunk],
|
||||||
|
embeddings=[emb.tolist()]
|
||||||
|
)
|
||||||
|
|
||||||
|
print("✅ 向量库写入完成")
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里使用的是`BAAI/bge-base-zh`模型,第一次会将模型下载到本地。
|
||||||
|
  将处理结果存储到chroma向量数据库中。
|
||||||
|
|
||||||
|
## 2.3 检索系统
|
||||||
|
```python
|
||||||
|
model_path = "./models/models--BAAI--bge-base-zh/snapshots/0e5f83d4895db7955e4cb9ed37ab73f7ded339b6"
|
||||||
|
model = SentenceTransformer(model_path, local_files_only=True)
|
||||||
|
|
||||||
|
client = chromadb.PersistentClient(path="./vector_db/train_docs")
|
||||||
|
collection = client.get_or_create_collection(name="train_docs")
|
||||||
|
|
||||||
|
q = "培训计划怎么制定?"
|
||||||
|
q_emb = model.encode([q], normalize_embeddings=True)
|
||||||
|
|
||||||
|
res = collection.query(
|
||||||
|
query_embeddings=q_emb.tolist(),
|
||||||
|
n_results=3
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n\n".join(res["documents"][0]))
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
注意这里使用离线模型时的路径
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 2.4 结合大模型
|
||||||
|
  常用提示词:
|
||||||
|
```python
|
||||||
|
你是专业的知识问答助手。
|
||||||
|
请严格基于下方提供的参考资料回答问题。
|
||||||
|
如果参考资料中不包含答案,请明确说明“当前资料无法回答该问题”。
|
||||||
|
不要编造、推测或引入外部知识。
|
||||||
|
|
||||||
|
参考资料:
|
||||||
|
"""
|
||||||
|
{{context}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
用户问题:
|
||||||
|
{{question}}
|
||||||
|
|
||||||
|
请按以下要求回答:
|
||||||
|
1. 回答简洁、准确、有条理
|
||||||
|
2. 必要时使用列表或分点说明
|
||||||
|
3. 避免冗余描述
|
||||||
|
```
|
||||||
103
docs/Web/AI/TextToSql.md
Normal file
103
docs/Web/AI/TextToSql.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
title: Text To SQL开发
|
||||||
|
date: 2026-06-09
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、工作流程
|
||||||
|
```text
|
||||||
|
用户自然语言问题
|
||||||
|
↓
|
||||||
|
【大模型】生成 SQL
|
||||||
|
↓
|
||||||
|
【数据库】执行 SQL → 得到结构化结果
|
||||||
|
↓
|
||||||
|
【大模型】分析 / 解读 / 总结 / 可视化建议
|
||||||
|
↓
|
||||||
|
最终返回给用户(文字、结论、图表说明)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 二、实战
|
||||||
|
## 2.1 获取数据库结构
|
||||||
|
```python
|
||||||
|
db = SQLDatabase.from_uri(os.getenv("DB_URL"), include_tables=['table_1', 'table_2'])
|
||||||
|
table_info = db.get_table_info()
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
这里的数据库账号只能为只读权限。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 2.2 SQL提示词
|
||||||
|
```python
|
||||||
|
from langchain_core.prompts import PromptTemplate
|
||||||
|
|
||||||
|
SQL_PROMPT = PromptTemplate.from_template(
|
||||||
|
"""
|
||||||
|
你是一个 MySQL 专家。
|
||||||
|
请根据用户问题生成一条可执行的 MySQL 查询语句。
|
||||||
|
只返回 SQL,不要解释,不要加 ```。
|
||||||
|
|
||||||
|
数据库结构:
|
||||||
|
{schema}
|
||||||
|
|
||||||
|
数据库补充:
|
||||||
|
{option}
|
||||||
|
|
||||||
|
用户问题:
|
||||||
|
{question}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
  数据库结构就是刚才生成的`table_info`,数据库补充就是对一些关系表的说明和字段含义的解释等。
|
||||||
|
|
||||||
|
## 2.3 生成SQL
|
||||||
|
```python
|
||||||
|
sql = chat_model.invoke(
|
||||||
|
SQL_PROMPT.format(schema=table_info, option=option, question=request.message)
|
||||||
|
).content.strip()
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里的`chat_model`为`init_chat_model`构建的大模型。
|
||||||
|
|
||||||
|
## 2.4 执行SQL
|
||||||
|
```python
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
os.getenv("DB_URL"),
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = engine.connect()
|
||||||
|
|
||||||
|
|
||||||
|
def is_select(sql: str) -> bool:
|
||||||
|
return sql.lower().lstrip().startswith("select")
|
||||||
|
|
||||||
|
def execute_sql(sql: str):
|
||||||
|
if not is_select(sql):
|
||||||
|
raise ValueError("只允许 SELECT 查询")
|
||||||
|
|
||||||
|
result = conn.execute(text(sql))
|
||||||
|
rows = result.fetchall()
|
||||||
|
columns = result.keys()
|
||||||
|
return {
|
||||||
|
"columns": list(columns),
|
||||||
|
"rows": rows
|
||||||
|
}
|
||||||
|
```
|
||||||
|
  使用sqlalchemy执行SQL,并加入了安全校验。
|
||||||
|
|
||||||
|
## 2.5 分析数据
|
||||||
|
```python
|
||||||
|
agent = create_agent(
|
||||||
|
model=chat_model,
|
||||||
|
system_prompt=SYSTEM_PROMPT.format(question=request.message, result=result),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = agent.invoke({"message": HumanMessage(content=request.message)})
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里的系统提示词根据情况编写。
|
||||||
77
docs/Web/AI/Tools.md
Normal file
77
docs/Web/AI/Tools.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
title: Tools开发
|
||||||
|
date: 2026-06-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、工作流程
|
||||||
|
```text
|
||||||
|
【定义工具能力】
|
||||||
|
↓
|
||||||
|
【约定输入输出】
|
||||||
|
↓
|
||||||
|
【实现工具逻辑】
|
||||||
|
↓
|
||||||
|
【注册到模型】
|
||||||
|
↓
|
||||||
|
用户自然语言请求
|
||||||
|
↓
|
||||||
|
【模型选择工具】
|
||||||
|
↓
|
||||||
|
【执行工具】
|
||||||
|
↓
|
||||||
|
【返回结果】
|
||||||
|
```
|
||||||
|
|
||||||
|
# 二、实战
|
||||||
|
## 2.1 工具定义
|
||||||
|
  实现一个根据接口查询数据的工具:
|
||||||
|
```python
|
||||||
|
@tool
|
||||||
|
def repair_search(date: list[str]) -> str:
|
||||||
|
"""
|
||||||
|
查询维修工单列表。
|
||||||
|
|
||||||
|
参数说明:
|
||||||
|
- date (list[str], 必填):
|
||||||
|
- 日期范围,格式:["YYYY-MM-DD", "YYYY-MM-DD"]
|
||||||
|
- 示例:["2026-07-01", "2026-08-01"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
if date is None:
|
||||||
|
date = []
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"currentPage": 0,
|
||||||
|
"pageSize": 0,
|
||||||
|
"customer": "",
|
||||||
|
"deviceName": "",
|
||||||
|
"deviceModel": "",
|
||||||
|
"date": date
|
||||||
|
}
|
||||||
|
|
||||||
|
token = get_token()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"satoken": token
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{BASE_URL}repair", params=params, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.text
|
||||||
|
except Exception as e:
|
||||||
|
return f"查询维修工单失败: {e}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2 注册到模型
|
||||||
|
```python
|
||||||
|
integrate_agent = create_agent(
|
||||||
|
model=model,
|
||||||
|
tools=[repair_search],
|
||||||
|
system_prompt=integrate_prompt
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
工具里面的注释大模型是无法查看的,需要把工具的使用说明,例如接口返回值的含义,写在提示词里面。
|
||||||
|
:::
|
||||||
33
docs/Web/FastAPI/FastAPI-Docker.md
Normal file
33
docs/Web/FastAPI/FastAPI-Docker.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
title: FastAPI Docker离线部署
|
||||||
|
date: 2026-01-06
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、安装依赖
|
||||||
|
  通过pip将requirements.txt中的依赖包下载到本地:
|
||||||
|
```sh
|
||||||
|
pip download -r requirements.txt -d ./packages --only-binary=:all: --platform manylinux2014_x86_64 -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
`--only-binary=:all:`表示只下载预编译的二进制包
|
||||||
|
`--platform manylinux2014_x86_64`指定目标平台为 Linux
|
||||||
|
`-i`指定pip镜像源源
|
||||||
|
还可以加上`--python-version 310`指定python版本
|
||||||
|
:::
|
||||||
|
|
||||||
|
  执行命令后,会在./packages中下载相应的二进制包。
|
||||||
|
|
||||||
|
# 二、Dockfile
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.10-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||||
|
RUN echo 'Asia/Shanghai' > /etc/timezone
|
||||||
|
COPY ./packages /app/packages
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links=/app/packages -r requirements.txt
|
||||||
|
COPY . /app/
|
||||||
|
EXPOSE 8095
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8095"]
|
||||||
|
```
|
||||||
828
docs/Web/FastAPI/FastAPI-Guide.md
Normal file
828
docs/Web/FastAPI/FastAPI-Guide.md
Normal file
@@ -0,0 +1,828 @@
|
|||||||
|
---
|
||||||
|
title: FastAPI基础教程
|
||||||
|
date: 2025-12-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  [FastAPI](https://fastapi.tiangolo.com/zh/) 是一个用于构建 API 的现代、快速(高性能)的 web 框架,使用 Python 并基于标准的 Python 类型提示。
|
||||||
|
  安装:
|
||||||
|
```cmd
|
||||||
|
pip install fastapi
|
||||||
|
pip install uvicorn[standard] # ASGI服务器
|
||||||
|
```
|
||||||
|
|
||||||
|
# 二、数据层
|
||||||
|
## 2.1 数据库ORM SQLAlchemy
|
||||||
|
### 2.1.1 配置与连接
|
||||||
|
```python
|
||||||
|
# database.py - 数据库配置
|
||||||
|
from sqlalchemy import create_engine, MetaData
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
from typing import Generator
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
DATABASE_URL = f"mysql+pymysql://{os.getenv("DATABASE_URL", "root:123456@127.0.0.1:3306/test")}"
|
||||||
|
|
||||||
|
# 创建引擎
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
echo=True, # 显示 SQL 语句(开发环境)
|
||||||
|
pool_size=20, # 连接池大小
|
||||||
|
max_overflow=40, # 最大溢出连接数
|
||||||
|
pool_pre_ping=True, # 连接前 ping
|
||||||
|
pool_recycle=3600, # 连接回收时间(秒)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建会话工厂
|
||||||
|
SessionLocal = sessionmaker(
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
bind=engine,
|
||||||
|
expire_on_commit=False, # 提交后不使实例过期
|
||||||
|
)
|
||||||
|
|
||||||
|
# 声明基类
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# 依赖注入:获取数据库会话
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
"""
|
||||||
|
获取数据库会话
|
||||||
|
使用 yield 确保会话正确关闭
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
需要把pool_pre_ping=True打开,否则会出现超过pool_recycle时间后,数据库断线。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 2.1.2 数据库基类
|
||||||
|
```python
|
||||||
|
from sqlalchemy import Column, BigInteger, String, DateTime, event
|
||||||
|
from sqlalchemy.ext.declarative import declared_attr
|
||||||
|
|
||||||
|
from config.auth import context_sub
|
||||||
|
from config.database import Base
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from utils.common import camel_to_snake
|
||||||
|
from id_generator import options, generator
|
||||||
|
|
||||||
|
# https://github.com/yitter/IdGenerator/tree/master/Python
|
||||||
|
options = options.IdGeneratorOptions(worker_id=23)
|
||||||
|
idgen = generator.DefaultIdGenerator()
|
||||||
|
idgen.set_id_generator(options)
|
||||||
|
|
||||||
|
|
||||||
|
# 第二层基类:包含ID
|
||||||
|
class IdBase(Base):
|
||||||
|
__abstract__ = True
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
|
||||||
|
@declared_attr
|
||||||
|
def __tablename__(cls):
|
||||||
|
# 自动把数据库实体类名驼峰转为数据库表名下划线
|
||||||
|
return camel_to_snake(cls.__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# 自动填充id
|
||||||
|
@event.listens_for(IdBase, 'before_insert', propagate=True)
|
||||||
|
def before_insert_listener(mapper, connection, target):
|
||||||
|
if target.id is None:
|
||||||
|
target.id = idgen.next_id()
|
||||||
|
|
||||||
|
|
||||||
|
# 第二层基类:包含ID和审计字段
|
||||||
|
class AuditBase(IdBase):
|
||||||
|
__abstract__ = True
|
||||||
|
|
||||||
|
create_time = Column(DateTime, nullable=True, default=datetime.now)
|
||||||
|
create_by = Column(String(255), nullable=True)
|
||||||
|
update_time = Column(DateTime, nullable=True, default=datetime.now, onupdate=datetime.now)
|
||||||
|
update_by = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(AuditBase, 'before_insert', propagate=True)
|
||||||
|
def before_insert(mapper, connection, target):
|
||||||
|
value = context_sub.get(None)
|
||||||
|
if value is not None:
|
||||||
|
target.create_by = value
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(AuditBase, 'before_update', propagate=True)
|
||||||
|
def before_update(mapper, connection, target):
|
||||||
|
value = context_sub.get(None)
|
||||||
|
if value is not None:
|
||||||
|
target.update_by = value
|
||||||
|
```
|
||||||
|
|
||||||
|
  id采用yitter雪花id。
|
||||||
|
  审计字段中的create_time和update_time通过SQLAlchemy参数配置。
|
||||||
|
  create_by和update_by字段为登录认证中存储的账号信息。
|
||||||
|
|
||||||
|
### 2.1.3 数据库模型
|
||||||
|
```python
|
||||||
|
from sqlalchemy import (BigInteger, Boolean, Column, Integer, DECIMAL, String, LargeBinary)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from models.base import AuditBase, IdBase
|
||||||
|
|
||||||
|
|
||||||
|
class Blog(AuditBase):
|
||||||
|
title = Column(String(255), nullable=False, comment="博客标题")
|
||||||
|
top_value = Column(Integer, nullable=False, default=0, comment="置顶值 越大越靠前")
|
||||||
|
is_great = Column(Boolean, nullable=False, default=False, comment="是否是精品")
|
||||||
|
category_id = Column(BigInteger, nullable=False, comment="博客类别")
|
||||||
|
summary = Column(String(255), nullable=False, comment="博客内容概要")
|
||||||
|
content_id = Column(BigInteger, nullable=False, comment="博客内容")
|
||||||
|
word_count = Column(Integer, nullable=False, default=0, comment="字数统计")
|
||||||
|
read_duration = Column(DECIMAL(10, 2), nullable=False, default=0.00, comment="阅读时长")
|
||||||
|
is_approved = Column(Boolean, nullable=False, default=False, comment="是否发布")
|
||||||
|
|
||||||
|
# 分类关系(多对一)
|
||||||
|
category = relationship(
|
||||||
|
"BlogCategory",
|
||||||
|
# 与BlogCategory的blogs属性建立双向关系
|
||||||
|
back_populates="blogs",
|
||||||
|
# 只级联保存和合并操作,不级联删除(删除博客不应删除分类
|
||||||
|
cascade="save-update, merge",
|
||||||
|
# 明确指定连接条件
|
||||||
|
# 如果数据库设置了外键可以省略
|
||||||
|
primaryjoin="foreign(Blog.category_id) == BlogCategory.id"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 内容关系(一对一)
|
||||||
|
content = relationship(
|
||||||
|
"BlogContent",
|
||||||
|
# 与BlogContent的blog属性建立双向关系
|
||||||
|
back_populates="blog",
|
||||||
|
# 完全级联操作:保存、合并、刷新、删除等所有操作都会级联
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
# 设置为False表示一对一关系,返回单个对象而不是列表
|
||||||
|
uselist=False,
|
||||||
|
# 确保内容只有一个父博客,与delete-orphan配合使用
|
||||||
|
single_parent=True,
|
||||||
|
# 明确指定连接条件
|
||||||
|
primaryjoin="foreign(Blog.content_id) == BlogContent.id"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 评论(一对多)
|
||||||
|
comments = relationship(
|
||||||
|
"BlogComment",
|
||||||
|
# 与BlogComment的blog属性建立双向关系
|
||||||
|
back_populates="blog",
|
||||||
|
# 完全级联操作:博客删除时自动删除所有评论
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
# 明确指定连接条件
|
||||||
|
primaryjoin="Blog.id == foreign(BlogComment.blog_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogCategory(AuditBase):
|
||||||
|
name = Column(String(45), nullable=False, comment="类别名称")
|
||||||
|
|
||||||
|
blogs = relationship(
|
||||||
|
"Blog",
|
||||||
|
# 与Blog的category属性建立双向关系
|
||||||
|
back_populates="category",
|
||||||
|
# 完全级联操作:分类删除时自动删除所有关联的博客
|
||||||
|
# 警告:这会级联删除分类下的所有博客,包括博客的内容、访问记录和评论
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
# 明确指定连接条件
|
||||||
|
primaryjoin="BlogCategory.id == foreign(Blog.category_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogContent(IdBase):
|
||||||
|
content = Column(LargeBinary, nullable=False, comment="博客内容")
|
||||||
|
|
||||||
|
blog = relationship(
|
||||||
|
"Blog",
|
||||||
|
# 与Blog的content属性建立双向关系
|
||||||
|
back_populates="content",
|
||||||
|
# 设置为False表示一对一关系
|
||||||
|
uselist=False,
|
||||||
|
# 明确指定连接条件
|
||||||
|
primaryjoin="BlogContent.id == foreign(Blog.content_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogComment(AuditBase):
|
||||||
|
blog_id = Column(BigInteger, nullable=False, comment="博客ID")
|
||||||
|
parent_id = Column(BigInteger, nullable=False, comment="父评论ID")
|
||||||
|
name = Column(String(255), nullable=False, comment="评论人昵称")
|
||||||
|
website = Column(String(255), nullable=True, comment="评论人网站")
|
||||||
|
ip_address = Column(String(45), nullable=False, comment="评论人IP")
|
||||||
|
user_agent = Column(String(255), nullable=False, comment="评论人浏览器信息")
|
||||||
|
content = Column(String(255), nullable=False, comment="评论内容")
|
||||||
|
is_approved = Column(Boolean, nullable=False, default=False, comment="是否通过")
|
||||||
|
|
||||||
|
blog = relationship(
|
||||||
|
"Blog",
|
||||||
|
# 与Blog的comments属性建立双向关系
|
||||||
|
back_populates="comments",
|
||||||
|
# 明确指定连接条件
|
||||||
|
primaryjoin="foreign(BlogComment.blog_id) == Blog.id"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里没有通过在数据库建立外键,而是通过SQLAlchemy中的relationship来建立。
|
||||||
|
  其中primaryjoin明确指定了连接条件。如果为一对一,需要设置uselist=False和single_parent=True,返回单个对象而不是列表。
|
||||||
|
  cascade表示级联操作:
|
||||||
|
```
|
||||||
|
# cascade 的完整可选值列表:
|
||||||
|
|
||||||
|
# 基本级联操作
|
||||||
|
"save-update" # 保存/更新时级联
|
||||||
|
"merge" # 合并会话时级联
|
||||||
|
"refresh-expire" # 刷新过期对象时级联
|
||||||
|
"expunge" # 从会话中移除时级联
|
||||||
|
"delete" # 删除时级联
|
||||||
|
"delete-orphan" # 成为孤儿时删除
|
||||||
|
|
||||||
|
# 快捷组合
|
||||||
|
"all" # 包含除 delete-orphan 外的所有操作
|
||||||
|
"all, delete-orphan" # 包含所有操作
|
||||||
|
"none" # 禁用所有级联(默认)
|
||||||
|
|
||||||
|
# 其他组合
|
||||||
|
"save-update, merge" # 常用组合
|
||||||
|
"save-update, merge, delete" # 包含删除
|
||||||
|
"save-update, merge, refresh-expire"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2 数据验证pydantic
|
||||||
|
```python
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BlogQuery(BaseModel):
|
||||||
|
"""博客查询参数"""
|
||||||
|
category: Optional[str] = Field(None, description="分类名称")
|
||||||
|
title: Optional[str] = Field(None, description="标题关键词")
|
||||||
|
year: Optional[int] = Field(None, description="发布年份", ge=2000, le=datetime.now().year)
|
||||||
|
|
||||||
|
@field_validator('year')
|
||||||
|
def validate_year(cls, v):
|
||||||
|
if v is not None and v > datetime.now().year:
|
||||||
|
raise ValueError('年份不能超过当前年份')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class BlogBase(BaseModel):
|
||||||
|
"""博客基础模型"""
|
||||||
|
title: str = Field(..., min_length=1, max_length=255, description="博客标题")
|
||||||
|
top_value: int = Field(default=0, ge=0, description="置顶值,越大越靠前", alias="topValue")
|
||||||
|
is_great: bool = Field(default=False, description="是否是精品", alias="isGreat")
|
||||||
|
category: str = Field(..., min_length=1, max_length=45, description="分类名称")
|
||||||
|
content: Optional[str] = Field(None, description="博客内容")
|
||||||
|
is_approved: bool = Field(default=False, description="是否已发布", alias="isApproved")
|
||||||
|
|
||||||
|
@field_validator('title')
|
||||||
|
def title_not_empty(cls, v):
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError('标题不能为空')
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
@field_validator('category')
|
||||||
|
def category_not_empty(cls, v):
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError('分类不能为空')
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class BlogCreate(BlogBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BlogUpdate(BlogBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BlogResponse(BlogBase):
|
||||||
|
id: int = Field(..., description="博客ID")
|
||||||
|
summary: Optional[str] = Field(None, description="内容摘要")
|
||||||
|
word_count: Optional[int] = Field(None, description="字数统计", alias="wordCount")
|
||||||
|
read_duration: Optional[float] = Field(None, description="阅读时长", alias="readDuration")
|
||||||
|
visit_count: Optional[int] = Field(0, description="访问次数", alias="visitCount")
|
||||||
|
create_time: datetime = Field(..., description="创建时间", alias="createTime")
|
||||||
|
update_time: datetime = Field(..., description="更新时间", alias="updateTime")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
populate_by_name=True,
|
||||||
|
json_encoders={
|
||||||
|
datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogCategoryResponse(BaseModel):
|
||||||
|
name: str = Field(..., description="分类名称")
|
||||||
|
count: int = Field(..., description="博客数量")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
populate_by_name=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogStatsResponse(BaseModel):
|
||||||
|
blog_count: int = Field(..., description="博客总数", alias="blogCount")
|
||||||
|
category_count: int = Field(..., description="分类总数", alias="categoryCount")
|
||||||
|
word_count: int = Field(..., description="总字数", alias="wordCount")
|
||||||
|
|
||||||
|
|
||||||
|
class BlogVisitResponse(BaseModel):
|
||||||
|
ip: str = Field(..., description="IP地址")
|
||||||
|
os: str = Field(..., description="操作系统")
|
||||||
|
browser: str = Field(..., description="浏览器")
|
||||||
|
uri: str = Field(..., description="访问路径")
|
||||||
|
title: str = Field(None, description="博客标题")
|
||||||
|
visit_time: datetime = Field(..., description="访问时间", alias="visitTime")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
populate_by_name=True,
|
||||||
|
json_encoders={
|
||||||
|
datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogLatestResponse(BaseModel):
|
||||||
|
id: int = Field(..., description="博客ID")
|
||||||
|
title: str = Field(..., description="博客标题")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
populate_by_name=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogAdjacentResponse(BaseModel):
|
||||||
|
id: int = Field(..., description="博客ID")
|
||||||
|
title: str = Field(..., description="博客标题")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
populate_by_name=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogCommentCreate(BaseModel):
|
||||||
|
parent_id: int = Field(default=0, ge=0, description="父评论ID,0表示顶级评论", alias="parentId")
|
||||||
|
name: str = Field(..., min_length=1, max_length=50, description="评论人昵称")
|
||||||
|
website: Optional[str] = Field(None, description="评论人网站")
|
||||||
|
content: str = Field(..., min_length=1, max_length=1000, description="评论内容")
|
||||||
|
|
||||||
|
@field_validator('name')
|
||||||
|
def name_not_empty(cls, v):
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError('昵称不能为空')
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
@field_validator('content')
|
||||||
|
def content_not_empty(cls, v):
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError('评论内容不能为空')
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class BlogCommentResponse(BlogCommentCreate):
|
||||||
|
id: int = Field(..., description="评论ID")
|
||||||
|
ip_address: str = Field(..., description="IP地址", alias="ipAddress")
|
||||||
|
user_agent: str = Field(..., description="浏览器信息", alias="userAgent")
|
||||||
|
create_time: datetime = Field(..., description="创建时间", alias="createTime")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
populate_by_name=True,
|
||||||
|
json_encoders={
|
||||||
|
datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
  `@field_validator`为Pydantic V2的验证器装饰器。
|
||||||
|
  `ConfigDict`为全局配置类,`from_attributes=True`表示允许从SQLAlchemy等ORM对象创建。`populate_by_name=True`表示允许通过字段的别名(alias)来赋值 `json_encoders`表示自定义特定类型的 JSON 序列化方式。
|
||||||
|
|
||||||
|
# 三、服务层
|
||||||
|
## 3.1 查询操作
|
||||||
|
```python
|
||||||
|
def query_blog_by_id(db: Session, blog_id: int) -> BlogResponse:
|
||||||
|
check_blog_exist(db, blog_id)
|
||||||
|
|
||||||
|
stmt = (select(
|
||||||
|
Blog.id,
|
||||||
|
Blog.title,
|
||||||
|
Blog.top_value,
|
||||||
|
Blog.is_great,
|
||||||
|
BlogCategory.name.label("category"),
|
||||||
|
Blog.summary,
|
||||||
|
BlogContent.content,
|
||||||
|
Blog.word_count,
|
||||||
|
Blog.read_duration,
|
||||||
|
func.count(BlogVisit.id).label("visitCount"),
|
||||||
|
Blog.is_approved,
|
||||||
|
Blog.create_time,
|
||||||
|
Blog.update_time
|
||||||
|
).where(Blog.id == blog_id)
|
||||||
|
.outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
|
||||||
|
.outerjoin(BlogContent, Blog.content_id == BlogContent.id)
|
||||||
|
.outerjoin(BlogVisit, Blog.id == BlogVisit.blog_id))
|
||||||
|
|
||||||
|
blog = db.execute(stmt).first()
|
||||||
|
|
||||||
|
return BlogResponse.model_validate(blog)
|
||||||
|
```
|
||||||
|
|
||||||
|
  条件和分页查询
|
||||||
|
```python
|
||||||
|
def query_blog_by_condition(db: Session, blog_query: BlogQuery) -> List[BlogResponse]:
|
||||||
|
stmt = get_query_blog_by_condition_stmt(blog_query)
|
||||||
|
results = db.execute(stmt).fetchall()
|
||||||
|
return [BlogResponse.model_validate(result) for result in results]
|
||||||
|
|
||||||
|
|
||||||
|
def get_query_blog_by_condition_stmt(blog_query: BlogQuery):
|
||||||
|
stmt = (select(
|
||||||
|
Blog.id,
|
||||||
|
Blog.title,
|
||||||
|
Blog.top_value,
|
||||||
|
Blog.is_great,
|
||||||
|
BlogCategory.name.label("category"),
|
||||||
|
Blog.summary,
|
||||||
|
BlogContent.content,
|
||||||
|
Blog.word_count,
|
||||||
|
Blog.read_duration,
|
||||||
|
func.count(BlogVisit.id).label("visitCount"),
|
||||||
|
Blog.is_approved,
|
||||||
|
Blog.create_time,
|
||||||
|
Blog.update_time
|
||||||
|
).where(Blog.is_approved == 1)
|
||||||
|
.outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
|
||||||
|
.outerjoin(BlogContent, Blog.content_id == BlogContent.id)
|
||||||
|
.outerjoin(BlogVisit, Blog.id == BlogVisit.blog_id)
|
||||||
|
.group_by(Blog.id))
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if blog_query.category:
|
||||||
|
conditions.append(BlogCategory.name == blog_query.category)
|
||||||
|
|
||||||
|
if blog_query.title:
|
||||||
|
conditions.append(Blog.title.like(f"%{blog_query.title}%"))
|
||||||
|
|
||||||
|
if blog_query.year:
|
||||||
|
conditions.append(func.extract('year', Blog.create_time) == blog_query.year)
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
stmt = stmt.where(and_(*conditions))
|
||||||
|
|
||||||
|
stmt = stmt.order_by(desc(Blog.is_great), desc(Blog.update_time))
|
||||||
|
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def query_blog_by_condition_page(db: Session, blog_query: BlogQuery,
|
||||||
|
current_page: int = 1, page_size: int = 10) -> PageResult[BlogResponse]:
|
||||||
|
stmt = get_query_blog_by_condition_stmt(blog_query)
|
||||||
|
return paginate_query(db, stmt, current_page, page_size)
|
||||||
|
|
||||||
|
def paginate_query(db: Session, query, current_page: int = 1, page_size: int = 10) -> PageResult:
|
||||||
|
"""通用分页查询函数"""
|
||||||
|
# 计算总记录数
|
||||||
|
total = db.execute(select(func.count()).select_from(query.subquery())).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
# 计算总页数
|
||||||
|
total_pages = (total + page_size - 1) // page_size if page_size != 0 else 0
|
||||||
|
|
||||||
|
# 执行分页查询
|
||||||
|
results = db.execute(query.offset((current_page - 1) * page_size).limit(page_size)).all()
|
||||||
|
|
||||||
|
# 转换为字典列表
|
||||||
|
records = [row._asdict() if hasattr(row, "_asdict") else dict(row) for row in results]
|
||||||
|
|
||||||
|
return PageResult(
|
||||||
|
current=current_page,
|
||||||
|
size=page_size,
|
||||||
|
total=total,
|
||||||
|
pages=total_pages,
|
||||||
|
records=records
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 新增操作
|
||||||
|
```python
|
||||||
|
def add_blog(db: Session, blog: BlogCreate) -> bool:
|
||||||
|
word_count = get_word_count(blog.content)
|
||||||
|
|
||||||
|
db_blog = Blog(
|
||||||
|
title=blog.title,
|
||||||
|
top_value=blog.top_value,
|
||||||
|
is_great=blog.is_great,
|
||||||
|
category_id=add_blog_category(db, blog.category),
|
||||||
|
summary=get_blog_summary(blog.content),
|
||||||
|
word_count=word_count,
|
||||||
|
read_duration=get_read_duration(word_count),
|
||||||
|
is_approved=blog.is_approved
|
||||||
|
)
|
||||||
|
|
||||||
|
# 级联新增
|
||||||
|
db_blog.content = BlogContent(content=blog.content.encode('utf-8'))
|
||||||
|
|
||||||
|
db.add(db_blog)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_blog)
|
||||||
|
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里的`db_blog.content`可以直接赋值`BlogContent`对象实现级联新增,不用传`content_id`。
|
||||||
|
::: warning
|
||||||
|
这里的category和blog是多对一的关系,且需要一定的逻辑处理,不能直接赋值`BlogCategory`对象,否则每新增一条博客都会新增一个`category`。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 3.3 更新操作
|
||||||
|
```python
|
||||||
|
def update_blog(db: Session, blog_id: int, blog: BlogUpdate) -> bool:
|
||||||
|
db_blog = check_blog_exist(db, blog_id)
|
||||||
|
|
||||||
|
db_blog.title = blog.title
|
||||||
|
db_blog.top_value = blog.topValue
|
||||||
|
db_blog.is_great = blog.isGreat
|
||||||
|
db_blog.category_id = update_blog_category(db, blog.category)
|
||||||
|
|
||||||
|
# 级联更新
|
||||||
|
db_blog.content = BlogContent(content=blog.content.encode('utf-8'))
|
||||||
|
|
||||||
|
word_count = get_word_count(blog.content)
|
||||||
|
db_blog.summary = get_blog_summary(blog.content),
|
||||||
|
db_blog.word_count = word_count
|
||||||
|
db_blog.read_duration = get_read_duration(word_count)
|
||||||
|
db_blog.is_approved = blog.isApproved
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_blog)
|
||||||
|
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.4 删除操作
|
||||||
|
```python
|
||||||
|
def delete_blog(db: Session, blog_id: int) -> bool:
|
||||||
|
db_blog = check_blog_exist(db, blog_id)
|
||||||
|
|
||||||
|
# 级联删除
|
||||||
|
db.delete(db_blog)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、路由层
|
||||||
|
```python
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/blog",
|
||||||
|
tags=["博客管理"],
|
||||||
|
responses={404: {"description": "Not found"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/page", summary="分页查询博客", response_model=PageResult[BlogResponse])
|
||||||
|
def query_blog_by_page(
|
||||||
|
current_page: int = Query(1, ge=1, alias="currentPage", description="当前页码,从1开始"),
|
||||||
|
page_size: int = Query(10, ge=1, le=100, alias="pageSize", description="每页显示数量,1-100之间"),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return blog_service.query_blog_by_page(db, current_page, page_size)
|
||||||
|
|
||||||
|
@router.get("/condition", summary="条件查询博客", response_model=List[BlogResponse])
|
||||||
|
def query_blog_by_condition(query: BlogQuery = Depends(), db: Session = Depends(get_db)):
|
||||||
|
return blog_service.query_blog_by_condition(db, query)
|
||||||
|
|
||||||
|
@router.post("", summary="新增博客内容", response_model=bool)
|
||||||
|
def add_blog(
|
||||||
|
blog: BlogCreate = Body(..., description="博客创建数据"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_=Depends(verify_token)
|
||||||
|
):
|
||||||
|
return blog_service.add_blog(db, blog)
|
||||||
|
|
||||||
|
@router.put("/{blog_id}", summary="更新博客内容", response_model=bool)
|
||||||
|
def update_blog(
|
||||||
|
blog_id: int = Path(..., ge=1, description="博客ID"),
|
||||||
|
blog: BlogUpdate = Body(..., description="博客更新数据"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_=Depends(verify_token)
|
||||||
|
):
|
||||||
|
return blog_service.update_blog(db, blog_id, blog)
|
||||||
|
|
||||||
|
@router.delete("/{blog_id}", summary="删除博客内容", response_model=bool)
|
||||||
|
def delete_blog(
|
||||||
|
blog_id: int = Path(..., ge=1, description="博客ID"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_=Depends(verify_token)
|
||||||
|
):
|
||||||
|
return blog_service.delete_blog(db, blog_id)
|
||||||
|
|
||||||
|
@router.put("/{blog_id}/comment", summary="新增博客评论", response_model=bool)
|
||||||
|
def add_blog_comment(
|
||||||
|
request: Request,
|
||||||
|
blog_id: int = Path(..., ge=1, description="博客ID"),
|
||||||
|
blog_comment: BlogCommentCreate = Body(..., description="博客评论数据"),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return blog_service.add_blog_comment(db, request, blog_id, blog_comment)
|
||||||
|
```
|
||||||
|
|
||||||
|
  路由装饰器参数中的`response_model`表示定义接口返回的数据模型。
|
||||||
|
  参数注解中的`Query()`表示参数来自URL查询字符串,`Path/Query/Body`分别对应路径参数、查询参数、请求体参数。
|
||||||
|
  `Depends()`表示依赖注入,自动解析参数或执行依赖函数。
|
||||||
|
  `db: Session = Depends(get_db)`表示获取数据库连接,`_=Depends(verify_token)`表示验证用户身份。
|
||||||
|
  `request: Request`可以获取到HTTP请求的完整上下文信息。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
这里的`query: BlogQuery = Depends()`会从查询参数中自动实例化`BlogQuery`对象,并进行数据验证和类型转换。
|
||||||
|
:::
|
||||||
|
|
||||||
|
  注册路由:
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from .blog import router as blog_router
|
||||||
|
|
||||||
|
def register_routers(app: FastAPI):
|
||||||
|
app.include_router(blog_router, prefix="")
|
||||||
|
|
||||||
|
|
||||||
|
# main.py
|
||||||
|
app = FastAPI(title="Blog Service")
|
||||||
|
register_routers(app)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 五、中间件
|
||||||
|
## 5.1 全局异常处理器
|
||||||
|
```python
|
||||||
|
from fastapi import Request, HTTPException, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from config.logging import logger
|
||||||
|
|
||||||
|
|
||||||
|
# 自定义异常类
|
||||||
|
class AppException(Exception):
|
||||||
|
def __init__(self, message: str, details=None):
|
||||||
|
self.message = message
|
||||||
|
self.details = details
|
||||||
|
|
||||||
|
|
||||||
|
# 全局异常处理中间件
|
||||||
|
async def global_exception_handler(request: Request, call_next):
|
||||||
|
try:
|
||||||
|
# 记录请求信息(可选)
|
||||||
|
logger.info(f"请求: {request.method} {request.url}")
|
||||||
|
if request.query_params:
|
||||||
|
logger.info(f"查询参数: {dict(request.query_params)}")
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# 记录响应信息(可选)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
logger.warning(f"响应: {response.status_code}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except AppException as e:
|
||||||
|
# 记录业务异常
|
||||||
|
logger.error(f"业务异常: {e.message} - 详情: {e.details}")
|
||||||
|
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
content={"message": e.message, "details": e.details})
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
# 记录数据库异常
|
||||||
|
logger.critical(f"数据库异常: {str(e)}")
|
||||||
|
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={"message": "数据库操作失败", "details": str(e)})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 记录未知异常(带堆栈信息)
|
||||||
|
logger.critical(f"未知异常: {str(e)}")
|
||||||
|
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={"code": 500, "message": "服务器内部错误", "details": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
def get_credentials_exception() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.2 日志处理器
|
||||||
|
```python
|
||||||
|
import atexit
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fluent import sender
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from config.setting import settings
|
||||||
|
|
||||||
|
FLUENTD_HOST = settings.FLUENTD_HOST
|
||||||
|
FLUENTD_PORT = 24224
|
||||||
|
TOPIC_TAG = 'blog-service'
|
||||||
|
|
||||||
|
# 日志级别
|
||||||
|
LOG_LEVEL = settings.LOG_LEVEL.upper()
|
||||||
|
|
||||||
|
# 日志格式
|
||||||
|
STDOUT_FORMAT = (
|
||||||
|
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
||||||
|
"<level>{level: <8}</level> | "
|
||||||
|
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
|
||||||
|
"<level>{message}</level>"
|
||||||
|
)
|
||||||
|
|
||||||
|
FILE_FORMAT = (
|
||||||
|
"{time:YYYY-MM-DD HH:mm:ss.SSS} | "
|
||||||
|
"{level: <8} | "
|
||||||
|
"{name}:{function}:{line} - {message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
fluent_sender = sender.FluentSender(
|
||||||
|
tag=TOPIC_TAG,
|
||||||
|
host=FLUENTD_HOST,
|
||||||
|
port=FLUENTD_PORT,
|
||||||
|
buffer_max_size=8 * 1024 * 1024,
|
||||||
|
timeout=3.0,
|
||||||
|
retry_timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_to_fluent(message):
|
||||||
|
try:
|
||||||
|
record = message.record
|
||||||
|
|
||||||
|
# 构建结构化日志数据
|
||||||
|
log_data = {
|
||||||
|
'topic': TOPIC_TAG,
|
||||||
|
'timestamp': record['time'].timestamp(),
|
||||||
|
'level': record['level'].name.lower(),
|
||||||
|
'message': record['message'],
|
||||||
|
'source': f"{record['file'].path}:{record['line']}",
|
||||||
|
'module': record['module'],
|
||||||
|
'function': record['function'],
|
||||||
|
'process_id': record['process'].id,
|
||||||
|
'thread_id': record['thread'].id,
|
||||||
|
**record['extra']
|
||||||
|
}
|
||||||
|
|
||||||
|
if not fluent_sender.emit(TOPIC_TAG, log_data):
|
||||||
|
print(f"Fluentd 发送失败: {fluent_sender.last_error}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"日志处理异常: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# 移除默认处理器
|
||||||
|
logger.remove()
|
||||||
|
|
||||||
|
# 添加控制台处理器
|
||||||
|
logger.add(
|
||||||
|
sink=sys.stdout,
|
||||||
|
level=LOG_LEVEL,
|
||||||
|
format=STDOUT_FORMAT,
|
||||||
|
colorize=True,
|
||||||
|
backtrace=True, # 显示完整异常堆栈
|
||||||
|
diagnose=True, # 显示详细异常信息
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.ENVIRONMENT == 'docker':
|
||||||
|
logger.add(
|
||||||
|
log_to_fluent,
|
||||||
|
level=LOG_LEVEL, # 处理 INFO 及以上级别
|
||||||
|
format="{message}", # 原始消息(实际使用结构化数据)
|
||||||
|
backtrace=True, # 启用堆栈回溯
|
||||||
|
diagnose=True # 显示诊断信息
|
||||||
|
)
|
||||||
|
|
||||||
|
atexit.register(fluent_sender.close)
|
||||||
|
|
||||||
|
# 导出配置好的logger
|
||||||
|
__all__ = ["logger"]
|
||||||
|
```
|
||||||
170
docs/Web/FastAPI/OAuth2.md
Normal file
170
docs/Web/FastAPI/OAuth2.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
title: 基于OAuth2的FastApi安全验证
|
||||||
|
date: 2025-11-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、基础概念
|
||||||
|
## 1.1 OAuth2 Password Bearer 模式
|
||||||
|
  用于**用户名+密码**登录,获取**access_token**。
|
||||||
|
|
||||||
|
## 1.2 FastAPI 的 OAuth2PasswordBearer
|
||||||
|
  从请求头**Authorization**中提取Token,Token格式为 **Bearer Token**,必须是这个格式,如果不是则会提示**401 Unauthorized**错误。
|
||||||
|
|
||||||
|
# 二、核心流程
|
||||||
|
## 2.1 获取Token
|
||||||
|
```python
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="session")
|
||||||
|
|
||||||
|
@app.post("/session")
|
||||||
|
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||||
|
# 1. 验证用户名和密码
|
||||||
|
# 2. 返回 Token
|
||||||
|
return Token
|
||||||
|
```
|
||||||
|
|
||||||
|
   这里的**tokenUrl="session"** 对应的是fastapi中的url路径(@app.post("/session")),用于Swagger文档中的Token认证。
|
||||||
|
   前端必须要以FormData表单的形式传递username和password,且必须是username和password字段。
|
||||||
|
```ts
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('username', username)
|
||||||
|
data.append('password', password)
|
||||||
|
```
|
||||||
|
   在获取Token前,一般还需要进行验证用户名和密码是否和数据库中的信息一致。
|
||||||
|
   可以采用JWT格式封装TokenValue:
|
||||||
|
```python
|
||||||
|
def create_token(payload: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
# 复制一份
|
||||||
|
payload_copy = payload.copy()
|
||||||
|
|
||||||
|
# 加上有效时间
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.now() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
payload_copy.update({"exp": expire})
|
||||||
|
|
||||||
|
# 生成jwt Token
|
||||||
|
return jwt.encode(payload_copy, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2 访问接口
|
||||||
|
```python
|
||||||
|
@router.delete("/{blog_id}", summary="删除博客内容", response_model=bool)
|
||||||
|
def delete_blog(blog_id: int, db: Session = Depends(get_db), _ = Depends(verify_token)):
|
||||||
|
return blog_service.delete_blog(db, blog_id)
|
||||||
|
```
|
||||||
|
   例如访问这个删除接口,在参数中添加验证token的依赖:
|
||||||
|
```python
|
||||||
|
async def verify_token(token: str = Depends(oauth2_scheme)):
|
||||||
|
try:
|
||||||
|
# 1. 检验token信息
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
# 2. 校验payload信息
|
||||||
|
if not verify_payload(payload):
|
||||||
|
raise get_credentials_exception()
|
||||||
|
# 3. 校验数据库中是否存在 payload中的账户信息
|
||||||
|
sub = get_payload_sub(payload)
|
||||||
|
|
||||||
|
# 4. 存储账户信息
|
||||||
|
context_sub.set(sub)
|
||||||
|
except jwt.exceptions.InvalidTokenError:
|
||||||
|
raise get_credentials_exception()
|
||||||
|
```
|
||||||
|
   该依赖又依赖于子依赖oauth2_scheme,通过调用OAuth2PasswordBearer方法从请求头Authorization中获取token值。
|
||||||
|
   校验Token通常包含校验格式是否正确和Token包含的账户信息是否正确。
|
||||||
|
|
||||||
|
# 三、参考代码
|
||||||
|
```python
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from middleware.exceptions import get_credentials_exception
|
||||||
|
|
||||||
|
# 密钥和算法配置
|
||||||
|
SECRET_KEY = "sjdi@!#3ksj2780se1283"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
|
|
||||||
|
# 密码哈希上下文
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# OAuth2 方案
|
||||||
|
# 设置默认登录接口
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="session")
|
||||||
|
|
||||||
|
# 请求上下文
|
||||||
|
context_sub: ContextVar[str] = ContextVar('sub')
|
||||||
|
|
||||||
|
|
||||||
|
# 获取数据库 加密密码
|
||||||
|
def get_password_hash(password: str):
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
# 验证数据库密码
|
||||||
|
def verify_password(plain_password: str, hashed_password: str):
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
# 生成payload
|
||||||
|
def create_payload(sub: str) -> dict:
|
||||||
|
return {
|
||||||
|
"sub": sub
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 验证payload
|
||||||
|
def verify_payload(payload: dict) -> bool:
|
||||||
|
return "sub" in payload
|
||||||
|
|
||||||
|
|
||||||
|
# 从payload中获取用户
|
||||||
|
def get_payload_sub(payload: dict) -> str:
|
||||||
|
return payload["sub"]
|
||||||
|
|
||||||
|
|
||||||
|
# 创建token
|
||||||
|
def create_token(payload: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
# 复制一份
|
||||||
|
payload_copy = payload.copy()
|
||||||
|
|
||||||
|
# 加上有效时间
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.now() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
payload_copy.update({"exp": expire})
|
||||||
|
|
||||||
|
# 生成jwt Token
|
||||||
|
return jwt.encode(payload_copy, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
# 验证token
|
||||||
|
async def verify_token(token: str = Depends(oauth2_scheme)):
|
||||||
|
try:
|
||||||
|
# 1. 检验token信息
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
# 2. 校验payload信息
|
||||||
|
if not verify_payload(payload):
|
||||||
|
raise get_credentials_exception()
|
||||||
|
# 3. 校验数据库中是否存在 payload中的账户信息
|
||||||
|
sub = get_payload_sub(payload)
|
||||||
|
|
||||||
|
# 4. 存储账户信息
|
||||||
|
context_sub.set(sub)
|
||||||
|
except jwt.exceptions.InvalidTokenError:
|
||||||
|
raise get_credentials_exception()
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、注意事项
|
||||||
|
1. 如果整个路由模块都需要Token验证,可以在APIRouter中添加依赖:
|
||||||
|
|
||||||
|
```python
|
||||||
|
protected_router = APIRouter(dependencies=[Depends(verify_token)])
|
||||||
|
```
|
||||||
377
docs/Web/Flutter/DartGuide.md
Normal file
377
docs/Web/Flutter/DartGuide.md
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
---
|
||||||
|
title: Dart基础教程
|
||||||
|
date: 2025-12-05
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、基础语法
|
||||||
|
## 1.1 变量与常量
|
||||||
|
```dart
|
||||||
|
// 显式声明类型
|
||||||
|
String name = 'Dart';
|
||||||
|
int age = 10;
|
||||||
|
|
||||||
|
// 类型推断(使用 var)
|
||||||
|
var city = 'Beijing'; // 自动推断为 String
|
||||||
|
var count = 100; // 自动推断为 int
|
||||||
|
|
||||||
|
// 延迟初始化
|
||||||
|
String? email; // 可空类型
|
||||||
|
late String description; // 延迟初始化
|
||||||
|
|
||||||
|
const PI = 3.14159;
|
||||||
|
const List<int> nums = [1, 2, 3]; // 不可修改的列表
|
||||||
|
|
||||||
|
final currentTime = DateTime.now(); // 运行时才知道值
|
||||||
|
final List<int> numbers = [1, 2, 3];
|
||||||
|
numbers.add(4); // 可以!列表内容可修改
|
||||||
|
|
||||||
|
dynamic value = 'Hello';
|
||||||
|
value = 123; // 可以
|
||||||
|
value = true; // 可以
|
||||||
|
|
||||||
|
// 非空类型(默认)
|
||||||
|
String name = 'Dart';
|
||||||
|
// name = null; // 错误!
|
||||||
|
|
||||||
|
// 可空类型(使用 ?)
|
||||||
|
String? nickname;
|
||||||
|
nickname = null; // 正确
|
||||||
|
|
||||||
|
// 空值断言(!)
|
||||||
|
String? value = 'Hello';
|
||||||
|
print(value!.length); // 确信不为null时使用
|
||||||
|
|
||||||
|
// 空值合并运算符(??)
|
||||||
|
String? name;
|
||||||
|
String displayName = name ?? 'Guest'; // name为null时使用'Guest'
|
||||||
|
|
||||||
|
// 条件访问(?.)
|
||||||
|
String? text;
|
||||||
|
print(text?.length); // text为null时返回null,不报错
|
||||||
|
```
|
||||||
|
|
||||||
|
  const常量:值在编译期就确定,对象及其内容都不可变。
|
||||||
|
  final常量:值在运行时确定,但只能赋值一次,引用不可变,但对象内容可能可变。
|
||||||
|
|
||||||
|
## 1.2 数据类型
|
||||||
|
```dart
|
||||||
|
// 单引号或双引号
|
||||||
|
String str1 = 'Hello';
|
||||||
|
String str2 = "World";
|
||||||
|
|
||||||
|
// 字符串插值
|
||||||
|
String name = 'Dart';
|
||||||
|
int version = 3;
|
||||||
|
print('$name $version');
|
||||||
|
print('${name version}');
|
||||||
|
|
||||||
|
// int - 整数
|
||||||
|
int count = 10;
|
||||||
|
int hex = 0xDEADBEEF;
|
||||||
|
|
||||||
|
// double - 浮点数
|
||||||
|
double price = 19.99;
|
||||||
|
double exponent = 1.42e5; // 科学计数法
|
||||||
|
|
||||||
|
// num - int 和 double 的父类
|
||||||
|
num value1 = 10;
|
||||||
|
num value2 = 10.5;
|
||||||
|
|
||||||
|
// 常用方法
|
||||||
|
int a = 5;
|
||||||
|
double b = 3.14159;
|
||||||
|
print(b.toStringAsFixed(2)); // 3.14
|
||||||
|
print(int.parse('42')); // 字符串转int
|
||||||
|
print(double.parse('3.14')); // 字符串转double
|
||||||
|
|
||||||
|
bool isActive = true;
|
||||||
|
bool isCompleted = false;
|
||||||
|
|
||||||
|
// 创建列表
|
||||||
|
List<int> numbers = [1, 2, 3, 4, 5];
|
||||||
|
var fruits = ['apple', 'banana', 'orange'];
|
||||||
|
|
||||||
|
// 访问元素
|
||||||
|
print(numbers[0]); // 1
|
||||||
|
print(numbers.length); // 5
|
||||||
|
|
||||||
|
// 添加元素
|
||||||
|
numbers.add(6);
|
||||||
|
numbers.addAll([7, 8, 9]);
|
||||||
|
|
||||||
|
// 删除元素
|
||||||
|
numbers.remove(3); // 删除值为3的元素
|
||||||
|
numbers.removeAt(0); // 删除索引0的元素
|
||||||
|
|
||||||
|
// 常用方法
|
||||||
|
print(numbers.first); // 第一个元素
|
||||||
|
print(numbers.last); // 最后一个元素
|
||||||
|
print(numbers.isEmpty); // 是否为空
|
||||||
|
numbers.forEach((num) => print(num)); // 遍历
|
||||||
|
|
||||||
|
// 不可变列表
|
||||||
|
const fixedList = [1, 2, 3];
|
||||||
|
|
||||||
|
// 扩展运算符
|
||||||
|
var list1 = [1, 2, 3];
|
||||||
|
var list2 = [0, ...list1]; // [0, 1, 2, 3]
|
||||||
|
|
||||||
|
// 创建 Map
|
||||||
|
Map<String, int> ages = {
|
||||||
|
'Alice': 25,
|
||||||
|
'Bob': 30,
|
||||||
|
'Charlie': 35
|
||||||
|
};
|
||||||
|
|
||||||
|
// 访问元素
|
||||||
|
print(ages['Alice']); // 25
|
||||||
|
|
||||||
|
// 添加/修改元素
|
||||||
|
ages['David'] = 28;
|
||||||
|
ages['Alice'] = 26;
|
||||||
|
|
||||||
|
// 删除元素
|
||||||
|
ages.remove('Bob');
|
||||||
|
|
||||||
|
// 常用方法
|
||||||
|
print(ages.keys); // 所有键
|
||||||
|
print(ages.values); // 所有值
|
||||||
|
print(ages.length); // 元素个数
|
||||||
|
print(ages.isEmpty); // 是否为空
|
||||||
|
print(ages.containsKey('Alice')); // 是否包含键
|
||||||
|
|
||||||
|
// 遍历
|
||||||
|
ages.forEach((key, value) {
|
||||||
|
print('$key: $value');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
# 二、函数
|
||||||
|
```dart
|
||||||
|
// 简写(箭头函数,适用于单行表达式)
|
||||||
|
String greet2(String name) => 'Hello, $name!';
|
||||||
|
|
||||||
|
// 必传参数
|
||||||
|
int add(int a, int b) {
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选位置参数(用 [] 包裹,可提供默认值)
|
||||||
|
String introduce(String name, [int? age, String city = 'Beijing']) {
|
||||||
|
if (age != null) {
|
||||||
|
return '$name, $age years old, from $city';
|
||||||
|
}
|
||||||
|
return '$name from $city';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用示例
|
||||||
|
print(introduce('Alice')); // Alice from Beijing
|
||||||
|
print(introduce('Bob', 25)); // Bob, 25 years old, from Beijing
|
||||||
|
print(introduce('Charlie', 30, 'Shanghai')); // Charlie, 30 years old, from Shanghai
|
||||||
|
|
||||||
|
// 命名参数(用 {} 包裹)
|
||||||
|
void createUser({
|
||||||
|
required String name, // required 表示必传
|
||||||
|
int age = 18, // 有默认值
|
||||||
|
String? email // 可选,可为null
|
||||||
|
}) {
|
||||||
|
print('Name: $name, Age: $age, Email: $email');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用时使用参数名
|
||||||
|
createUser(name: 'Alice');
|
||||||
|
createUser(name: 'Bob', age: 25, email: 'bob@example.com');
|
||||||
|
|
||||||
|
// 命名参数的优点:顺序无关,更清晰
|
||||||
|
void setStyle({String? color, double? size, bool? bold}) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
setStyle(bold: true, color: 'red'); // 顺序可以任意
|
||||||
|
```
|
||||||
|
|
||||||
|
# 三、类
|
||||||
|
```dart
|
||||||
|
class BankAccount {
|
||||||
|
String accountNumber; // 公有属性
|
||||||
|
double _balance; // 私有属性(以_开头)
|
||||||
|
|
||||||
|
BankAccount(this.accountNumber, this._balance);
|
||||||
|
|
||||||
|
// 公有方法
|
||||||
|
double getBalance() {
|
||||||
|
return _balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 私有方法
|
||||||
|
void _updateBalance(double amount) {
|
||||||
|
_balance += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
void deposit(double amount) {
|
||||||
|
if (amount > 0) {
|
||||||
|
_updateBalance(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getter
|
||||||
|
double get balance => _balance;
|
||||||
|
|
||||||
|
// Setter
|
||||||
|
set balance(double value) {
|
||||||
|
if (value >= 0) {
|
||||||
|
_balance = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用
|
||||||
|
var account = BankAccount('123456', 1000);
|
||||||
|
print(account.balance); // 使用getter:1000
|
||||||
|
account.deposit(500);
|
||||||
|
print(account.balance); // 1500
|
||||||
|
account.balance = 2000; // 使用setter
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、任务
|
||||||
|
```dart
|
||||||
|
void eventLoopExample() {
|
||||||
|
print("主线程开始");
|
||||||
|
|
||||||
|
// 微任务
|
||||||
|
scheduleMicrotask(() {
|
||||||
|
print("微任务1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 异步任务
|
||||||
|
Future.delayed(Duration(seconds: 0), () {
|
||||||
|
print("异步任务1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 再次添加微任务
|
||||||
|
scheduleMicrotask(() {
|
||||||
|
print("微任务2");
|
||||||
|
});
|
||||||
|
|
||||||
|
print("主线程结束");
|
||||||
|
}
|
||||||
|
|
||||||
|
//输出结果:
|
||||||
|
主线程开始
|
||||||
|
主线程结束
|
||||||
|
微任务1
|
||||||
|
微任务2
|
||||||
|
异步任务1
|
||||||
|
```
|
||||||
|
|
||||||
|
  在 Dart 的事件循环中,执行顺序如下:
|
||||||
|
1. 同步代码:首先执行所有同步代码。
|
||||||
|
2. 微任务队列:然后依次执行微任务队列中的所有微任务。
|
||||||
|
3. 事件队列:最后执行事件队列中的异步任务。
|
||||||
|
|
||||||
|
# 五、异步编程
|
||||||
|
```dart
|
||||||
|
// 1. Future() 构造函数
|
||||||
|
Future<int> calculateSum(int a, int b) {
|
||||||
|
return Future(() {
|
||||||
|
return a + b;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Future.value() - 立即完成
|
||||||
|
Future<String> immediateFuture = Future.value("立即结果");
|
||||||
|
|
||||||
|
// 3. Future.error() - 立即失败
|
||||||
|
Future<void> errorFuture = Future.error("错误信息");
|
||||||
|
|
||||||
|
// 4. Future.delayed() - 延迟执行
|
||||||
|
Future<String> delayedFuture = Future.delayed(
|
||||||
|
Duration(seconds: 3),
|
||||||
|
() => "延迟结果"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Future.sync() - 同步执行
|
||||||
|
Future<int> syncFuture = Future.sync(() => 42);
|
||||||
|
|
||||||
|
// 从网络请求
|
||||||
|
Future<http.Response> fetchUserData() async {
|
||||||
|
return await http.get(Uri.parse('https://api.example.com/user'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件操作
|
||||||
|
Future<File> writeToFile(String content) async {
|
||||||
|
final file = File('data.txt');
|
||||||
|
return await file.writeAsString(content);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  其他用法:
|
||||||
|
```dart
|
||||||
|
Future<void> processData() async {
|
||||||
|
try {
|
||||||
|
print("开始获取数据...");
|
||||||
|
|
||||||
|
// 等待第一个 Future
|
||||||
|
final data1 = await fetchDataFromSource1();
|
||||||
|
print("数据1: $data1");
|
||||||
|
|
||||||
|
// 等待第二个 Future
|
||||||
|
final data2 = await fetchDataFromSource2();
|
||||||
|
print("数据2: $data2");
|
||||||
|
|
||||||
|
// 处理结果
|
||||||
|
final result = await processCombinedData(data1, data2);
|
||||||
|
print("最终结果: $result");
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
print("处理过程中出错: $e");
|
||||||
|
} finally {
|
||||||
|
print("清理资源");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Future.wait() - 等待所有完成
|
||||||
|
Future<void> fetchAllData() async {
|
||||||
|
final futures = [
|
||||||
|
fetchUserData(),
|
||||||
|
fetchProductData(),
|
||||||
|
fetchOrderData(),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final results = await Future.wait(futures);
|
||||||
|
print("所有数据获取完成: $results");
|
||||||
|
} catch (e) {
|
||||||
|
print("部分请求失败: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Future.any() - 第一个完成
|
||||||
|
Future<String> getFastestResponse() {
|
||||||
|
return Future.any([
|
||||||
|
fetchFromServer1(),
|
||||||
|
fetchFromServer2(),
|
||||||
|
fetchFromServer3(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Future.forEach() - 顺序执行
|
||||||
|
Future<void> processItems(List<String> items) async {
|
||||||
|
await Future.forEach(items, (item) async {
|
||||||
|
await processItem(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 链式操作
|
||||||
|
Future<String> complexOperation() {
|
||||||
|
return authenticateUser()
|
||||||
|
.then((token) => fetchUserProfile(token))
|
||||||
|
.then((profile) => updateProfile(profile))
|
||||||
|
.then((updated) => saveToDatabase(updated))
|
||||||
|
.then((savedId) => "操作完成,ID: $savedId")
|
||||||
|
.catchError((error) {
|
||||||
|
// 统一错误处理
|
||||||
|
print("链式操作失败: $error");
|
||||||
|
return "默认值";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
248
docs/Web/Flutter/FlutterGuide.md
Normal file
248
docs/Web/Flutter/FlutterGuide.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
---
|
||||||
|
title: Flutter基础教程
|
||||||
|
date: 2025-12-05
|
||||||
|
---
|
||||||
|
|
||||||
|
# 应用核心Widget
|
||||||
|
```dart
|
||||||
|
// Flutter 应用的入口,配置主题、路由、国际化等。
|
||||||
|
MaterialApp(
|
||||||
|
home: HomePage(), // 必需:首页
|
||||||
|
theme: ThemeData.light(), // 主题
|
||||||
|
routes: {'/details': (c) => DetailsPage()}, // 路由
|
||||||
|
debugShowCheckedModeBanner: false, // 隐藏调试条
|
||||||
|
)
|
||||||
|
|
||||||
|
// 页面骨架,包含顶部栏、内容区、悬浮按钮等标准组件。
|
||||||
|
Scaffold(
|
||||||
|
appBar: AppBar(title: Text('标题')), // 顶部栏
|
||||||
|
body: Center(child: Text('内容')), // 主体
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () {}, // 悬浮按钮
|
||||||
|
),
|
||||||
|
drawer: Drawer(child: Text('侧边栏')), // 抽屉
|
||||||
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
|
items: [], // 底部导航
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 布局Widget
|
||||||
|
```dart
|
||||||
|
Container(
|
||||||
|
width: 100,
|
||||||
|
height: 50,
|
||||||
|
margin: EdgeInsets.all(10), // 外边距
|
||||||
|
padding: EdgeInsets.all(20), // 内边距
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue, // 背景色
|
||||||
|
borderRadius: BorderRadius.circular(10), // 圆角
|
||||||
|
border: Border.all(color: Colors.black), // 边框
|
||||||
|
),
|
||||||
|
child: Text('内容'),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, // 主轴对齐
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center, // 交叉轴对齐
|
||||||
|
children: [Text('左'),Text('中'),Text('右')],
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Container(color: Colors.red)), // 扩展
|
||||||
|
Flexible(child: Container(color: Colors.blue)), // 灵活
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 层叠布局,用于重叠显示 Widget,配合 Positioned 定位。
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Container(color: Colors.red, width: 200, height: 200), // 底层
|
||||||
|
Positioned( // 绝对定位
|
||||||
|
top: 20,
|
||||||
|
left: 20,
|
||||||
|
child: Text('重叠内容'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('左'),
|
||||||
|
Spacer(), // 自动占据剩余空间
|
||||||
|
Text('右'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2, // 权重
|
||||||
|
child: Container(color: Colors.red),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: Container(color: Colors.blue),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 列表和网格
|
||||||
|
```dart
|
||||||
|
ListView(
|
||||||
|
children: List.generate(20, (i) => ListTile(
|
||||||
|
title: Text('项目 $i'), // 列表项
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 懒加载版本(推荐)
|
||||||
|
ListView.builder(
|
||||||
|
itemCount: 1000,
|
||||||
|
itemBuilder: (context, index) => ListTile(
|
||||||
|
title: Text('项目 $index'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
GridView.count(
|
||||||
|
crossAxisCount: 2, // 每行数量
|
||||||
|
children: List.generate(20, (i) => Container(
|
||||||
|
color: Colors.blue,
|
||||||
|
child: Center(child: Text('$i')),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 展示 Widget
|
||||||
|
```dart
|
||||||
|
Text(
|
||||||
|
'Hello Flutter',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
color: Colors.blue,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Image.network(
|
||||||
|
'https://example.com/image.jpg',
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
fit: BoxFit.cover, // 填充方式
|
||||||
|
loadingBuilder: (c, child, progress) {
|
||||||
|
if (progress == null) return child;
|
||||||
|
return CircularProgressIndicator();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
Icons.favorite,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 30,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 对话框Widget
|
||||||
|
```dart
|
||||||
|
// 提示对话框,showDialog 显示,需要 context。
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text('提示'),
|
||||||
|
content: Text('确定删除吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 底部弹出表单
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Container(
|
||||||
|
height: 200,
|
||||||
|
child: ListView(
|
||||||
|
children: [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 底部轻提示,不打断用户操作
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('操作成功'),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: '撤销',
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 导航和路由
|
||||||
|
```dart
|
||||||
|
Navigator.push( // 跳转
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (c) => DetailsPage()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.pop(context); // 返回
|
||||||
|
Navigator.pushNamed(context, '/details'); // 命名路由
|
||||||
|
|
||||||
|
// 页面切换器
|
||||||
|
PageView(
|
||||||
|
children: [
|
||||||
|
Page1(),
|
||||||
|
Page2(),
|
||||||
|
Page3(),
|
||||||
|
],
|
||||||
|
controller: PageController(),
|
||||||
|
onPageChanged: (index) => print('切换到 $index'),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 手势Widget
|
||||||
|
```dart
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => print('点击'),
|
||||||
|
onDoubleTap: () => print('双击'),
|
||||||
|
onLongPress: () => print('长按'),
|
||||||
|
onPanUpdate: (details) => print('拖拽: ${details.delta}'),
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
InkWell(
|
||||||
|
onTap: () => print('点击'),
|
||||||
|
splashColor: Colors.blue.withOpacity(0.3), // 水波纹颜色
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.all(20),
|
||||||
|
child: Text('可点击区域'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
# 其他Widget
|
||||||
|
```dart
|
||||||
|
// 控制 Widget 显示/隐藏,比条件渲染性能更好。
|
||||||
|
Visibility(
|
||||||
|
visible: isVisible, // 是否显示
|
||||||
|
child: Text('内容'),
|
||||||
|
maintainSize: true, // 保持占位
|
||||||
|
maintainAnimation: true,
|
||||||
|
maintainState: true,
|
||||||
|
)
|
||||||
|
```
|
||||||
29
docs/Web/Flutter/FlutterInstall.md
Normal file
29
docs/Web/Flutter/FlutterInstall.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: Flutter简介与安装
|
||||||
|
date: 2026-05-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  Flutter 是 Google 推出的跨平台 UI 开发框架,使用 Dart 语言,通过自带的渲染引擎直接绘制界面,可一套代码同时构建 iOS、Android、Web 及 Windows/macOS/Linux 桌面应用,具备原生级性能、热重载和高 UI 一致性,适合追求多端统一与体验效率的项目开发。
|
||||||
|
|
||||||
|
# 二、安装
|
||||||
|
## 2.1 下载SDK
|
||||||
|
  打开[官网](https://docs.flutter.dev/install/archive),选择SDK版本下载,然后解压到相应目录。
|
||||||
|
|
||||||
|
## 2.2 环境变量配置
|
||||||
|
1. 在系统变量->path中添加:'/path/to/flutter/bin'
|
||||||
|
2. 在系统变量中添加: 'PUB_HOSTED_URL'->'https://pub.flutter-io.cn'
|
||||||
|
3. 在系统变量中添加: 'FLUTTER_STORAGE_BASE_URL'->'https://storage.flutter-io.cn'
|
||||||
|
4. 在系统变量中添加: 'FLUTTER_GIT_URL'->'https://gitee.com/mirrors/Flutter.git'
|
||||||
|
|
||||||
|
## 2.3 校验安装
|
||||||
|
  输入`flutter --version`和`flutter doctor`查看是否有错误输出。
|
||||||
|
::: tip
|
||||||
|
Windows开发需要安装Visual Studio的'C++的桌面开发'。
|
||||||
|
Android开发需要安装Android SDK。
|
||||||
|
Web开发需要安装Chrome浏览器。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 三、Android Studio
|
||||||
|
1. 安装Flutter和Dart插件
|
||||||
|
2. 在Settings->Languages->Dart中选择Dart路径:`/path/to/flutter/bin/cache/dart-sdk`
|
||||||
77
docs/Web/Flutter/Practice.md
Normal file
77
docs/Web/Flutter/Practice.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
title: 实战技巧
|
||||||
|
date: 2025-12-03
|
||||||
|
---
|
||||||
|
|
||||||
|
# 安卓签名
|
||||||
|
  每次安装/升级软件必须使用同一个签名,否则会将本地数据全部清空。
|
||||||
|
## 1. 生成密钥库文件
|
||||||
|
  使用keytool命令生成
|
||||||
|
```cmd
|
||||||
|
keytool -genkey -v -keystore android/app/my-release-key.keystore -alias my-key -keyalg RSA -keysize 2048 -validity 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
  根据提示输入相应信息。
|
||||||
|
  my.keystore为自定义名称。生成后的文件位于android/app/文件夹内
|
||||||
|
|
||||||
|
## 2. 配置key.properties
|
||||||
|
  在android文件夹内新建key.properties文件,并配置信息
|
||||||
|
```properties
|
||||||
|
# 密钥库文件的密码
|
||||||
|
storePassword=12345678
|
||||||
|
# 密钥本身的密码
|
||||||
|
keyPassword=12345678
|
||||||
|
# 密钥的别名,在密钥库中标识具体的密钥
|
||||||
|
keyAlias=my-key
|
||||||
|
# 密钥库文件相对于本配置文件的路径
|
||||||
|
storeFile=my-release-key.keystore
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 配置build.gradle.kts
|
||||||
|
```kts
|
||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
|
// 从根路径加载密钥属性
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  在android块内新增签名配置
|
||||||
|
```kts
|
||||||
|
android {
|
||||||
|
// 签名配置
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
|
||||||
|
release {
|
||||||
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
|
// 使用发布签名
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
// 使用默认签名
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  打包为release包时即可生效签名。
|
||||||
|
|
||||||
|
::: danger
|
||||||
|
禁止将签名文件上传到Git仓库中。
|
||||||
|
:::
|
||||||
62
docs/Web/MySQL/Flyway.md
Normal file
62
docs/Web/MySQL/Flyway.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
title: Flyway简单使用
|
||||||
|
date: 2025-11-27
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  Flyway 是一个开源的数据库版本控制工具,它极大地简化了数据库的迁移和版本管理。它的核心思想是像**用 Git 管理代码一样来管理数据库的结构**。
|
||||||
|
|
||||||
|
# 二、原理
|
||||||
|
  Flyway 通过在数据库中创建一个名为 flyway_schema_history的特殊表来工作
|
||||||
|
| 列名 | 含义 |
|
||||||
|
| - | - |
|
||||||
|
| installed_rank | 执行顺序 |
|
||||||
|
| version | 脚本的版本号 |
|
||||||
|
| description | 脚本的描述 |
|
||||||
|
| type | 脚本类型(通常是 SQL) |
|
||||||
|
| script | 脚本文件名 |
|
||||||
|
| checksum | 脚本文件的校验和(用于检测篡改) |
|
||||||
|
| installed_by | 执行人 |
|
||||||
|
| installed_on | 执行时间 |
|
||||||
|
| execution_time | 执行耗时(毫秒) |
|
||||||
|
| success | 是否成功 |
|
||||||
|
|
||||||
|
  工作流程:
|
||||||
|
1. 应用启动时,Flyway 会检查配置的数据库路径。
|
||||||
|
2. 检查目标数据库中的 flyway_schema_history表。
|
||||||
|
3. 将数据库路径下的迁移脚本与 flyway_schema_history表中的记录进行对比。
|
||||||
|
4. 按照版本号顺序执行那些尚未执行的迁移脚本。
|
||||||
|
5. 执行成功后,将记录插入 flyway_schema_history表。
|
||||||
|
|
||||||
|
## 2.1 校验和计算
|
||||||
|
  Flyway 使用 CRC32 算法 计算 SQL 脚本文件的校验和(Checksum)。Javs使用32位有符号整数存储,因此有时会得到负数。可以通过`mvn flyway:info`查看每个脚本的校验和。
|
||||||
|
|
||||||
|
# 三、与SpringBoot集成
|
||||||
|
## 3.1 添加依赖
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 配置数据源
|
||||||
|
```yml
|
||||||
|
spring:
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
locations: classpath:db/migration
|
||||||
|
baseline-on-migrate: true # 如果数据库非空,且无 flyway_schema_history 表,则先创建基线版本
|
||||||
|
```
|
||||||
|
  `baseline-on-migrate: true`,当数据库已经存在数据,但是没有flyway_schema_history表时,Flyway会插入一条基线数据,并标记为1.0版本,则后续的迁移脚本只会执行比1.0版本更高的数据库文件。
|
||||||
|
  **如果设置为false,如果数据库已经存在数据时,Flyway会报错。**
|
||||||
|
  因此需要避免这种情况的存在,在**初始发布应用时,要保证数据库为空**。
|
||||||
|
|
||||||
|
## 3.3 创建数据库脚本
|
||||||
|
  在项目的资源目录 src/main/resources下创建文件夹 db/migration。
|
||||||
|
  Flyway 的 SQL 脚本文件名有严格的命名规则:`V<Version>__<Description>.sql`
|
||||||
|
  例如V1.0.0_001__20251027.sql,表示v1.0.0版本的第一个sql,日期为2025年10月27日。
|
||||||
|
  **创建了迁移脚本,一旦应用,就不可修改,否则会导致校验错误。如果确实要修改,请再创建一个脚本。**
|
||||||
|
|
||||||
|
## 3.4 启动程序
|
||||||
|
  启动程序后。Flyway会自动在数据库中创建flyway_schema_history表,然后扫描db/migration目录下的所有脚本,按顺序执行sql文件,并记录到flyway_schema_history表中。
|
||||||
562
docs/Web/MySQL/MyBatis.md
Normal file
562
docs/Web/MySQL/MyBatis.md
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
---
|
||||||
|
title: MyBatis简介和使用
|
||||||
|
date: 2025-11-27
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  [MyBatis](https://mybatis.org/mybatis-3/)是一款优秀的持久层框架,它支持自定义 SQL、存储过程以及高级映射。MyBatis 免除了几乎所有的 JDBC 代码以及设置参数和获取结果集的工作。MyBatis 可以通过简单的 XML 或注解来配置和映射原始类型、接口和 Java POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。
|
||||||
|
|
||||||
|
# 二、安装
|
||||||
|
### 2.1 引入依赖
|
||||||
|
  在`pom.xml`文件中,引入依赖:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mybatis</groupId>
|
||||||
|
<artifactId>mybatis</artifactId>
|
||||||
|
<version>x.x.x</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
  可以在Github中查看[MyBatis](https://github.com/mybatis/mybatis-3)最新版本号。
|
||||||
|
|
||||||
|
### 2.2 配置文件
|
||||||
|
  在`resource`文件夹中新建mybatis-config.xml文件和mapper->BlogMapper.xml映射文件:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "https://mybatis.org/dtd/mybatis-3-config.dtd">
|
||||||
|
<configuration>
|
||||||
|
<!-- 环境配置 -->
|
||||||
|
<environments default="development">
|
||||||
|
<!-- 环境名称 -->
|
||||||
|
<environment id="development">
|
||||||
|
<!-- 事务管理器配置 -->
|
||||||
|
<transactionManager type="JDBC"/>
|
||||||
|
<!-- 数据源配置 -->
|
||||||
|
<dataSource type="POOLED">
|
||||||
|
<!-- JDBC驱动名称 -->
|
||||||
|
<property name="driver" value="com.mysql.cj.jdbc.Driver"/>
|
||||||
|
<!-- 数据库地址 -->
|
||||||
|
<property name="url" value="jdbc:mysql://localhost:3306/mybatis_learn?useSSL=false&useUnicode=true&characterEncoding=utf8&serverTimezone=GMT"/>
|
||||||
|
<!-- 数据库用户名 -->
|
||||||
|
<property name="username" value="root"/>
|
||||||
|
<!-- 数据库密码-->
|
||||||
|
<property name="password" value="123456"/>
|
||||||
|
</dataSource>
|
||||||
|
</environment>
|
||||||
|
</environments>
|
||||||
|
|
||||||
|
<!-- 映射器 -->
|
||||||
|
<mappers>
|
||||||
|
<!-- mapper文件 -->
|
||||||
|
<mapper resource="mapper/BlogMapper.xml"/>
|
||||||
|
</mappers>
|
||||||
|
</configuration>
|
||||||
|
```
|
||||||
|
|
||||||
|
  注:如果使用MySql数据库,需要增加MySql驱动依赖:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-java</artifactId>
|
||||||
|
<version>8.0.12</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 定义映射语句
|
||||||
|
  新建dao->BlogDao.java:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface BlogDao {
|
||||||
|
Blog selectBlog(@Param("id") Integer id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  BlogMapper.xml:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.example.mybatislearn.dao.BlogDao">
|
||||||
|
<select id="selectBlog" resultType="com.example.mybatislearn.entity.Blog">
|
||||||
|
select * from Blog where author_id = #{id}
|
||||||
|
</select>
|
||||||
|
</mapper>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 执行SqlSession
|
||||||
|
```java
|
||||||
|
// 配置文件路径
|
||||||
|
String resource = "mybatis-config.xml";
|
||||||
|
try {
|
||||||
|
// 读取配置文件
|
||||||
|
InputStream inputStream = Resources.getResourceAsStream(resource);
|
||||||
|
// 构建SqlSession工厂
|
||||||
|
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
|
||||||
|
// 获取SqlSession
|
||||||
|
SqlSession session = sqlSessionFactory.openSession();
|
||||||
|
// 获取映射文件
|
||||||
|
BlogDao blogDao = session.getMapper(BlogDao.class);
|
||||||
|
// 执行已映射的SQL语句
|
||||||
|
Blog blog = blogDao.selectBlog(101);
|
||||||
|
System.out.println(blog);
|
||||||
|
// 关闭SqlSession
|
||||||
|
session.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  每个基于 MyBatis 的应用都是以一个 SqlSessionFactory 的实例为核心的。
|
||||||
|
  SqlSessionFactory 的实例可以通过 SqlSessionFactoryBuilder 获得。
|
||||||
|
  而 SqlSessionFactoryBuilder 则可以从 XML 配置文件或一个预先配置的 Configuration 实例来构建出 SqlSessionFactory 实例。
|
||||||
|
|
||||||
|
### 2.5 作用域和生命周期
|
||||||
|
1. SqlSessionFactoryBuilder
|
||||||
|
|
||||||
|
  这个类可以被实例化、使用和丢弃,一旦创建了 SqlSessionFactory,就不再需要它了。 因此 SqlSessionFactoryBuilder 实例的最佳作用域是方法作用域(也就是局部方法变量)。 你可以重用 SqlSessionFactoryBuilder 来创建多个 SqlSessionFactory 实例,但最好还是不要一直保留着它,以保证所有的 XML 解析资源可以被释放给更重要的事情。
|
||||||
|
|
||||||
|
2. SqlSessionFactory
|
||||||
|
|
||||||
|
  SqlSessionFactory 一旦被创建就应该在应用的运行期间一直存在,没有任何理由丢弃它或重新创建另一个实例。 使用 SqlSessionFactory 的最佳实践是在应用运行期间不要重复创建多次,多次重建 SqlSessionFactory 被视为一种代码“坏习惯”。因此 SqlSessionFactory 的最佳作用域是应用作用域。 有很多方法可以做到,最简单的就是使用单例模式或者静态单例模式。
|
||||||
|
|
||||||
|
3. SqlSession
|
||||||
|
|
||||||
|
  每个线程都应该有它自己的 SqlSession 实例。SqlSession 的实例不是线程安全的,因此是不能被共享的,所以它的最佳的作用域是请求或方法作用域。 绝对不能将 SqlSession 实例的引用放在一个类的静态域,甚至一个类的实例变量也不行。 也绝不能将 SqlSession 实例的引用放在任何类型的托管作用域中,比如 Servlet 框架中的 HttpSession。 如果你现在正在使用一种 Web 框架,考虑将 SqlSession 放在一个和 HTTP 请求相似的作用域中。 换句话说,每次收到 HTTP 请求,就可以打开一个 SqlSession,返回一个响应后,就关闭它。 这个关闭操作很重要,为了确保每次都能执行关闭操作,你应该把这个关闭操作放到 finally 块中。
|
||||||
|
|
||||||
|
# 三、注入SpringBoot框架
|
||||||
|
## 3.1 引入依赖
|
||||||
|
  将之前MyBatis的依赖替换成MyBatis的SpringBoot Starter:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mybatis.spring.boot</groupId>
|
||||||
|
<artifactId>mybatis-spring-boot-starter</artifactId>
|
||||||
|
<version>2.3.0</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 配置文件
|
||||||
|
  在Resource文件夹下新建application.yml:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
url: jdbc:mysql://localhost:3306/mybatis_learn?useSSL=false&useUnicode=true&characterEncoding=utf8&serverTimezone=GMT
|
||||||
|
username: root
|
||||||
|
password: 123456
|
||||||
|
|
||||||
|
mybatis:
|
||||||
|
# mapper文件路径
|
||||||
|
mapper-locations: classpath*:mapper/*Mapper.xml
|
||||||
|
configuration:
|
||||||
|
# 开启日志
|
||||||
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
|
```
|
||||||
|
|
||||||
|
  注:这里的url的写法和XML文件中的写法不一致。
|
||||||
|
|
||||||
|
# 3.3 定义映射语句
|
||||||
|
  在原映射接口文件BlogDao.java中添加@Mapper注解
|
||||||
|
|
||||||
|
## 3.4 实现原理
|
||||||
|
  引入mybatis-spring-boot-starter模块之后,其可以:
|
||||||
|
|
||||||
|
1. **自动检测DataSource**
|
||||||
|
2. **使用SqlSessionFactoryBean注册SqlSessionFactory 实例,并设置DataSource数据源**
|
||||||
|
3. **基于SqlSessionFactory自动注册SqlSessionTemplate实例**
|
||||||
|
4. **自动扫描@Mapper注解类,并通过SqlSessionTemplate注册到Spring Context中**
|
||||||
|
|
||||||
|
  每次执行@Mapper映射文件中的接口时,都会自动开启一个SqlSession并在执行结束时关闭。
|
||||||
|
|
||||||
|
## 3.5 执行映射语句
|
||||||
|
```java
|
||||||
|
Blog blog = blogDao.selectBlog(101);
|
||||||
|
System.out.println(blog);
|
||||||
|
```
|
||||||
|
|
||||||
|
  相较之前的写法,节省了大量的配置工作。
|
||||||
|
|
||||||
|
# 四、高级特性
|
||||||
|
## 4.1 动态参数
|
||||||
|
```xml
|
||||||
|
#{}是参数占位符的标记,它可以防止SQL注入,当使用#{}时,MyBatis会自动处理参数的数据类型,
|
||||||
|
如果参数是字符串,它会给传入的值加上引号,这样可以有效地防止SQL注入攻击。
|
||||||
|
|
||||||
|
${}则是直接将参数值嵌入SQL语句中。当使用${}时,传入的参数会直接显示在SQL中,
|
||||||
|
MyBatis不会对参数进行任何类型转换或加引号处理。一般用在动态表名、列名或数据库名称中。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.2 SQL片段
|
||||||
|
  可以用来定义可重复的SQL代码片段:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<sql id="userColumns">
|
||||||
|
id, username, email, phone
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="findAllUsers" resultType="User">
|
||||||
|
SELECT <include refid="userColumns" /> FROM users
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.3 批量操作
|
||||||
|
  推荐使用集合方式批量操作:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Mapper
|
||||||
|
public interface UserMapper {
|
||||||
|
Integer insertUsers(@Param("list") List<User> userList);
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<insert id="insertUsers">
|
||||||
|
INSERT INTO user (username, password)
|
||||||
|
VALUES
|
||||||
|
<foreach collection ="list" item="item" separator =",">
|
||||||
|
(#{item.username}, #{item.password})
|
||||||
|
</foreach>
|
||||||
|
</insert>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.4 结果映射
|
||||||
|
  复杂结果缓存
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- 非常复杂的结果映射 -->
|
||||||
|
<resultMap id="detailedBlogResultMap" type="Blog">
|
||||||
|
<!-- 一般不需要 -->
|
||||||
|
<constructor>
|
||||||
|
<idArg column="blog_id" javaType="int"/>
|
||||||
|
</constructor>
|
||||||
|
<result property="title" column="blog_title"/>
|
||||||
|
<!-- 复杂对象 1对1 -->
|
||||||
|
<association property="author" javaType="Author">
|
||||||
|
<id property="id" column="author_id"/>
|
||||||
|
<result property="username" column="author_username"/>
|
||||||
|
<result property="password" column="author_password"/>
|
||||||
|
<result property="email" column="author_email"/>
|
||||||
|
<result property="bio" column="author_bio"/>
|
||||||
|
<result property="favouriteSection" column="author_favourite_section"/>
|
||||||
|
</association>
|
||||||
|
<!-- 列表 1对多-->
|
||||||
|
<collection property="posts" ofType="Post">
|
||||||
|
<id property="id" column="post_id"/>
|
||||||
|
<result property="subject" column="post_subject"/>
|
||||||
|
<association property="author" javaType="Author"/>
|
||||||
|
<collection property="comments" ofType="Comment">
|
||||||
|
<id property="id" column="comment_id"/>
|
||||||
|
</collection>
|
||||||
|
<collection property="tags" ofType="Tag" >
|
||||||
|
<id property="id" column="tag_id"/>
|
||||||
|
</collection>
|
||||||
|
<discriminator javaType="int" column="draft">
|
||||||
|
<case value="1" resultType="DraftPost"/>
|
||||||
|
</discriminator>
|
||||||
|
</collection>
|
||||||
|
</resultMap>
|
||||||
|
```
|
||||||
|
|
||||||
|
  对应的数据结构为:
|
||||||
|
```java
|
||||||
|
public class Blog {
|
||||||
|
private Integer id;
|
||||||
|
private String title;
|
||||||
|
private Author author; // 一对一
|
||||||
|
private List<Post> posts; // 一对多
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Author {
|
||||||
|
private Integer id;
|
||||||
|
private String username;
|
||||||
|
private String password;
|
||||||
|
private String email;
|
||||||
|
private String bio;
|
||||||
|
private String favouriteSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Post {
|
||||||
|
private Integer id;
|
||||||
|
private String subject;
|
||||||
|
private Author author; // 一对一
|
||||||
|
private List<Comment> comments; // 一对多
|
||||||
|
private List<Tag> tags; // 一对多
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Comment {
|
||||||
|
private Integer id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Tag {
|
||||||
|
private Integer id;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  其中`<collection>`也可以使用嵌套查询,例如`<collection property="posts" ofType="Post" select="queryPost"/>`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<select id="queryPost" resultMap="postResultMap">
|
||||||
|
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<resultMap id="postResultMap" type="Post">
|
||||||
|
<id property="id" column="post_id"/>
|
||||||
|
<result property="subject" column="post_subject"/>
|
||||||
|
<association property="author" javaType="Author"/>
|
||||||
|
<collection property="comments" ofType="Comment">
|
||||||
|
<id property="id" column="comment_id"/>
|
||||||
|
</collection>
|
||||||
|
<collection property="tags" ofType="Tag" >
|
||||||
|
<id property="id" column="tag_id"/>
|
||||||
|
</collection>
|
||||||
|
<discriminator javaType="int" column="draft">
|
||||||
|
<case value="1" resultType="DraftPost"/>
|
||||||
|
</discriminator>
|
||||||
|
</resultMap>
|
||||||
|
```
|
||||||
|
|
||||||
|
  如果需要传递参数,可以在`<collection>`添加`column`属性:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- 单个参数 -->
|
||||||
|
<collection property="posts" column="name" ofType="Post" select="queryPost"/>
|
||||||
|
<!-- 多个参数 -->
|
||||||
|
<collection property="posts" column="{param1=param_1, param2=param_2}" ofType="Post" select="queryPost"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
  注:建立在非列表数据时使用嵌套查询,否则每查到一个数据都会进行一次子查询操作。
|
||||||
|
|
||||||
|
## 4.5 一二级缓存
|
||||||
|
  默认情况下,只启用了本地的会话缓存,它仅仅对一个会话中的数据进行缓存。 要启用全局的二级缓存,只需要在你的 SQL 映射文件中添加一行:<cache/>
|
||||||
|
|
||||||
|
+ 映射语句文件中的所有 select 语句的结果将会被缓存。
|
||||||
|
+ 映射语句文件中的所有 insert、update 和 delete 语句会刷新缓存。
|
||||||
|
+ 一级缓存和二级缓存区别在于一级缓存只针对一次SqlSession,二级缓存针对全局范围。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
一级缓存作用于service里面的同一个方法(一个方法就是一个SqlSession),多次查询时,直接返回缓存数据,如果是同一个service里面的不同方法,就是多个SqlSession,一级缓存会失效。
|
||||||
|
二级缓存作用于同一个mapper,即使是不同的service的不同方法,只要是同一个mapper,都可以使用二级缓存。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 4.6 动态SQL
|
||||||
|
+ if :是/否
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<select id="findActiveBlogWithTitleLike" resultType="Blog">
|
||||||
|
SELECT * FROM BLOG
|
||||||
|
WHERE state = 'ACTIVE'
|
||||||
|
<if test="title != null">
|
||||||
|
AND title like #{title}
|
||||||
|
</if>
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
+ choose、when、otherwise:选择其中一个
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<select id="findActiveBlogLike" resultType="Blog">
|
||||||
|
SELECT * FROM BLOG WHERE state = 'ACTIVE'
|
||||||
|
<choose>
|
||||||
|
<when test="title != null">
|
||||||
|
AND title like #{title}
|
||||||
|
</when>
|
||||||
|
<when test="author != null and author.name != null">
|
||||||
|
AND author_name like #{author.name}
|
||||||
|
</when>
|
||||||
|
<otherwise>
|
||||||
|
AND featured = 1
|
||||||
|
</otherwise>
|
||||||
|
</choose>
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
+ where、set:解决SQL语法问题
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<select id="findActiveBlogLike" resultType="Blog">
|
||||||
|
SELECT * FROM BLOG
|
||||||
|
<where>
|
||||||
|
<if test="state != null">
|
||||||
|
state = #{state}
|
||||||
|
</if>
|
||||||
|
<if test="title != null">
|
||||||
|
AND title like #{title}
|
||||||
|
</if>
|
||||||
|
</where>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="updateAuthorIfNecessary">
|
||||||
|
update Author
|
||||||
|
<set>
|
||||||
|
<if test="username != null">username=#{username},</if>
|
||||||
|
<if test="password != null">password=#{password},</if>
|
||||||
|
</set>
|
||||||
|
where id=#{id}
|
||||||
|
</update>
|
||||||
|
```
|
||||||
|
|
||||||
|
  where 元素只会在子元素返回任何内容的情况下才插入 “WHERE” 子句。而且,若子句的开头为 “AND” 或 “OR”,where 元素也会将它们去除。
|
||||||
|
|
||||||
|
# 五、自定义类型处理器
|
||||||
|
  MyBatis 在预处理语句(PreparedStatement)中设置参数时,会从 Java 类型(javaType)转换为 JDBC 类型(jdbcType);而从结果集中取出值时,会将 JDBC 类型转换为 Java 类型。这个转换工作就是由 TypeHandler来完成的。
|
||||||
|
  需要创建一个类来实现 org.apache.ibatis.type.TypeHandler接口,或者继承 org.apache.ibatis.type.BaseTypeHandler类实现自定义类型处理器。
|
||||||
|
  需要实现的方法:
|
||||||
|
```java
|
||||||
|
/**
|
||||||
|
* 将Java对象设置到PreparedStatement中(Java类型 → JDBC类型)
|
||||||
|
* @param ps PreparedStatement对象
|
||||||
|
* @param i 参数位置(从1开始)
|
||||||
|
* @param parameter 要设置的Java对象(非空)
|
||||||
|
* @param jdbcType JDBC类型
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {
|
||||||
|
// 实现转换逻辑:T → JDBC类型
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据列名从ResultSet中获取值(JDBC类型 → Java类型)
|
||||||
|
* @param rs ResultSet对象
|
||||||
|
* @param columnName 列名
|
||||||
|
* @return 转换后的Java对象
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public T getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||||
|
// 实现转换逻辑:JDBC类型 → T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据列索引从ResultSet中获取值
|
||||||
|
* @param rs ResultSet对象
|
||||||
|
* @param columnIndex 列索引(从1开始)
|
||||||
|
* @return 转换后的Java对象
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||||
|
// 实现转换逻辑:JDBC类型 → T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从CallableStatement中获取值(用于存储过程)
|
||||||
|
* @param cs CallableStatement对象
|
||||||
|
* @param columnIndex 列索引
|
||||||
|
* @return 转换后的Java对象
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||||
|
// 实现转换逻辑:JDBC类型 → T
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  例如自定义一个处理 MEDIUMBLOB 字段与 Base64 字符串的转换:
|
||||||
|
```java
|
||||||
|
/**
|
||||||
|
* 处理 MEDIUMBLOB 字段与 Base64 字符串的转换
|
||||||
|
*/
|
||||||
|
@MappedJdbcTypes(JdbcType.BLOB)
|
||||||
|
@MappedTypes(String.class)
|
||||||
|
public class BlobToBase64TypeHandler extends BaseTypeHandler<String> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
|
||||||
|
// Base64字符串 -> 数据库的byte[]
|
||||||
|
if (parameter != null && !parameter.trim().isEmpty()) {
|
||||||
|
byte[] bytes = Base64.getDecoder().decode(parameter);
|
||||||
|
ps.setBytes(i, bytes);
|
||||||
|
} else {
|
||||||
|
ps.setBytes(i, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||||
|
// 数据库的byte[] -> Base64字符串
|
||||||
|
byte[] bytes = rs.getBytes(columnName);
|
||||||
|
return bytes != null ? Base64.getEncoder().encodeToString(bytes) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||||
|
byte[] bytes = rs.getBytes(columnIndex);
|
||||||
|
return bytes != null ? Base64.getEncoder().encodeToString(bytes) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||||
|
byte[] bytes = cs.getBytes(columnIndex);
|
||||||
|
return bytes != null ? Base64.getEncoder().encodeToString(bytes) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  例如自定义一个处理敏感字段(如密码、手机号)的自动加解密:
|
||||||
|
```java
|
||||||
|
public class EncryptTypeHandler extends BaseTypeHandler<String> {
|
||||||
|
@Override
|
||||||
|
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
|
||||||
|
ps.setString(i, AESUtil.encrypt(parameter)); // 自定义加密方法
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||||
|
String encrypted = rs.getString(columnName);
|
||||||
|
return AESUtil.decrypt(encrypted); // 自定义解密方法
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  然后在实体类字段上加上 @TableField(typeHandler = EncryptTypeHandler.class):
|
||||||
|
```java
|
||||||
|
@TableName("user")
|
||||||
|
public class User {
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
// 这个字段在数据库里存的是加密后的字符串
|
||||||
|
@TableField(typeHandler = EncryptTypeHandler.class)
|
||||||
|
private String phone; // 例如 "13800138000" → 存为 "U2FsdGVkX1+oO1W..."
|
||||||
|
|
||||||
|
@TableField(typeHandler = EncryptTypeHandler.class)
|
||||||
|
private String idCard; // 身份证号
|
||||||
|
|
||||||
|
// 普通字段,不加密
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  如果是在XML文件中查询,需要指定TypeHandler:
|
||||||
|
```xml
|
||||||
|
<resultMap id="UserResultMap" type="com.example.entity.User">
|
||||||
|
<id column="id" property="id" />
|
||||||
|
<result column="name" property="name" />
|
||||||
|
<!-- 关键:phone 字段使用 EncryptTypeHandler -->
|
||||||
|
<result column="phone" property="phone" typeHandler="com.example.handler.EncryptTypeHandler"/>
|
||||||
|
<!-- idCard 字段也用同一个处理器 -->
|
||||||
|
<result column="id_card" property="idCard" typeHandler="com.example.handler.EncryptTypeHandler"/>
|
||||||
|
<result column="email" property="email" />
|
||||||
|
</resultMap>
|
||||||
|
```
|
||||||
|
|
||||||
|
  MyBatisPlus中定义了一些常用的类型处理器,例如:JacksonTypeHandler:
|
||||||
|
```java
|
||||||
|
@TableField(value = "fault_type", typeHandler = JacksonTypeHandler.class)
|
||||||
|
```
|
||||||
|
|
||||||
|
  在XML文件中,将typeHandler设置为`com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler`。
|
||||||
|
|
||||||
|
# 六、MyBatis/MyBatis Plus常见问题
|
||||||
|
1. 更新值为null的字段时会失效
|
||||||
|
|
||||||
|
  需要更改字段的更新策略:
|
||||||
|
```java
|
||||||
|
@TableField(value = "file_name", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 自动填充出现失效
|
||||||
|
|
||||||
|
  strictInsert/UpdateFill的默认填充策略是:如果实体类中需要自动填充的字段已经有值了,那么当前值就不会进行填充,或者你想给一个字段填充null值也是不可以的。可以替换为setFieldValByName,将其直接覆盖。(一般是更新的时候需要替换,新增默认就是没有值的)
|
||||||
41
docs/Web/MySQL/SQL-Advance.md
Normal file
41
docs/Web/MySQL/SQL-Advance.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
title: SQL高阶用法
|
||||||
|
date: 2025-12-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、WITH
|
||||||
|
## 1.1 定义
|
||||||
|
  SQL 中的WITH子句也被称为**公用表表达式**(CTE,Common Table Expression),它的作用是**在执行主查询之前,先定义一个临时的结果集,这个结果集可以在后续的查询中被多次引用**,就像一个临时表一样。它能让复杂的 SQL 查询变得更清晰、更易读,还能简化嵌套查询的逻辑。
|
||||||
|
|
||||||
|
## 1.2 使用
|
||||||
|
```sql
|
||||||
|
-- 定义第一个CTE:数学高分学生
|
||||||
|
WITH math_high_score AS (
|
||||||
|
SELECT name, score FROM student_score WHERE subject = '数学' AND score > 85
|
||||||
|
),
|
||||||
|
-- 定义第二个CTE:语文高分学生
|
||||||
|
chinese_high_score AS (
|
||||||
|
SELECT name, score FROM student_score WHERE subject = '语文' AND score > 85
|
||||||
|
)
|
||||||
|
-- 主查询:查询既在数学高分又在语文高分的学生
|
||||||
|
SELECT m.name
|
||||||
|
FROM math_high_score m
|
||||||
|
JOIN chinese_high_score c ON m.name = c.name;
|
||||||
|
```
|
||||||
|
|
||||||
|
  递归 CTE:
|
||||||
|
```sql
|
||||||
|
WITH recursive dept_hierarchy AS (
|
||||||
|
-- 锚点成员:查询顶级部门(parent_id为NULL)
|
||||||
|
SELECT dept_id, dept_name, parent_id, 1 AS level
|
||||||
|
FROM department
|
||||||
|
WHERE parent_id IS NULL
|
||||||
|
UNION ALL
|
||||||
|
-- 递归成员:查询子部门,关联自身的dept_id和parent_id
|
||||||
|
SELECT d.dept_id, d.dept_name, d.parent_id, dh.level + 1 AS level
|
||||||
|
FROM department d
|
||||||
|
JOIN dept_hierarchy dh ON d.parent_id = dh.dept_id
|
||||||
|
)
|
||||||
|
-- 主查询:获取所有部门的层级
|
||||||
|
SELECT * FROM dept_hierarchy;
|
||||||
|
```
|
||||||
@@ -1,43 +1,47 @@
|
|||||||
# MySQL知识点
|
---
|
||||||
## 一、基础知识
|
title: MySQL知识点
|
||||||
### 1.1 数据类型
|
date: 2025-11-26
|
||||||
#### 1.1.1 汇总
|
---
|
||||||
|类型|存储空间|范围|适用场景|备注|
|
|
||||||
| :-------------------------------------------------------------------------------------------------: | :-----------------: | :--------------------------: | :-----------------------: | :------------------------------------: |
|
# 一、基础知识
|
||||||
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">TINYINT</span>|1字节|-128 \~ 127|状态码、年龄、布尔值||
|
## 1.1 数据类型
|
||||||
|
### 1.1.1 汇总
|
||||||
|
|类型|存储空间|范围|适用场景|
|
||||||
|
| :-------------------------------------------------------------------------------------------------: | :-----------------: | :--------------------------: | :-----------------------: |
|
||||||
|
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">TINYINT</span>|1字节|-128 \~ 127|状态码、年龄、布尔值|
|
||||||
|SMALLINT|2字节|-32,768 \~ 32,767|小范围计数、年份||
|
|SMALLINT|2字节|-32,768 \~ 32,767|小范围计数、年份||
|
||||||
|MEDIUMINT|3字节|-8,388,608 \~ 8,388,607|中型ID、访问量统计||
|
|MEDIUMINT|3字节|-8,388,608 \~ 8,388,607|中型ID、访问量统计|
|
||||||
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">INT/INTEGER</span>|4字节|-2\^31 \~ 2\^31-1|用户ID、订单号(常用)||
|
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">INT/INTEGER</span>|4字节|-2\^31 \~ 2\^31-1|用户ID、订单号(常用)|
|
||||||
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">BIGINT</span>|8字节|-2\^63 \~ 2\^63-1|分布式ID、大数据量计数||
|
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">BIGINT</span>|8字节|-2\^63 \~ 2\^63-1|分布式ID、大数据量计数|
|
||||||
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">DECIMAL(M,D)</span>|变长(M+2字节)||金融金额、精确计算|精确小数,M\=总位数,D\=小数位|
|
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">DECIMAL(M,D)</span>|变长(M+2字节)||金融金额、精确计算|精确小数,M\=总位数,D\=小数位|
|
||||||
|FLOAT|4字节||科学测量、非精确计算|单精度浮点,约6-7位有效数字|
|
|FLOAT|4字节||科学测量、非精确计算|
|
||||||
|DOUBLE|8字节||地理坐标、高精度计算|双精度浮点,约15位有效数字|
|
|DOUBLE|8字节||地理坐标、高精度计算|
|
||||||
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">CHAR(M)</span>|255字符||固定长度编码、MD5哈希||
|
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">CHAR(M)</span>|255字符||固定长度编码、MD5哈希|
|
||||||
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">VARCHAR(M)</span>|65,535字节||用户名、地址等变长数据||
|
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">VARCHAR(M)</span>|65,535字节||用户名、地址等变长数据|
|
||||||
|TINYTEXT|255字节||短标题、简介||
|
|TINYTEXT|255字节||短标题、简介|
|
||||||
|TEXT|65,535字节||文章内容、评论||
|
|TEXT|65,535字节||文章内容、评论|
|
||||||
|MEDIUMTEXT|16MB (2\^24-1)||博客文章、产品描述||
|
|MEDIUMTEXT|16MB (2\^24-1)||博客文章、产品描述|
|
||||||
|LONGTEXT|4GB (2\^32-1)||电子书、大型文档||
|
|LONGTEXT|4GB (2\^32-1)||电子书、大型文档|
|
||||||
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">DATE</span>|||纯日期||
|
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">DATE</span>|||纯日期|
|
||||||
|TIME(fsp)|||可指定微秒精度(0-6)||
|
|TIME(fsp)|||可指定微秒精度(0-6)|
|
||||||
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">DATETIME(fsp)</span>|||高精度时间记录||
|
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">DATETIME(fsp)</span>|||高精度时间记录|
|
||||||
|TIMESTAMP(fsp)|||自动时区转换,4字节存储||
|
|TIMESTAMP(fsp)|||自动时区转换,4字节存储|
|
||||||
|ENUM|||性别、状态等有限选项|`gender ENUM('M','F','O')`|
|
|ENUM|||性别、状态等有限选项|`gender ENUM('M','F','O')`|
|
||||||
|SET|||用户兴趣、文章标签|`tags SET('red','green','blue')`|
|
|SET|||用户兴趣、文章标签|`tags SET('red','green','blue')`|
|
||||||
|TINYBLOB|255字节||微小二进制对象||
|
|TINYBLOB|255字节||微小二进制对象|
|
||||||
|BLOB|65KB||标准二进制对象||
|
|BLOB|65KB||标准二进制对象|
|
||||||
|MEDIUMBLOB|16MB||中等二进制对象||
|
|MEDIUMBLOB|16MB||中等二进制对象|
|
||||||
|LONGBLOB|4GB||超大二进制对象||
|
|LONGBLOB|4GB||超大二进制对象|
|
||||||
|
|
||||||
#### 1.1.2 法则
|
### 1.1.2 法则
|
||||||
  整数选择:优先INT,大数量用BIGINT,布尔值用TINYINT(1)或BIT(1)
|
  **整数选择**:优先INT,大数量用BIGINT,布尔值用TINYINT(1)或BIT(1)
|
||||||
  小数选择:金融金额必须用DECIMAL,非精确测量可用FLOAT/DOUBLE
|
  **小数选择**:金融金额必须用DECIMAL,非精确测量可用FLOAT/DOUBLE
|
||||||
  字符串选择:定长编码用CHAR,变长文本用VARCHAR,大文本用TEXT系列
|
  **字符串选择**:定长编码用CHAR,变长文本用VARCHAR,大文本用TEXT系列
|
||||||
  时间选择:日期用DATE,精确时间用DATETIME,自动更新用TIMESTAMP
|
  **时间选择**:日期用DATE,精确时间用DATETIME,自动更新用TIMESTAMP
|
||||||
  特殊场景:多选项用SET,结构化数据用JSON
|
  **特殊场景**:多选项用SET,结构化数据用JSON
|
||||||
|
|
||||||
### 1.2 常用函数
|
## 1.2 常用函数
|
||||||
#### 1.2.1 字符串函数
|
### 1.2.1 字符串函数
|
||||||
1. CONCAT(str1, str2, ...):连接字符串
|
1. CONCAT(str1, str2, ...):连接字符串
|
||||||
```sql
|
```sql
|
||||||
SELECT CONCAT('Hello', ' ', 'World');
|
SELECT CONCAT('Hello', ' ', 'World');
|
||||||
@@ -51,7 +55,7 @@ SELECT LENGTH('Hello');
|
|||||||
SELECT SUBSTRING('Hello World', 7, 5); -- 返回 'World'
|
SELECT SUBSTRING('Hello World', 7, 5); -- 返回 'World'
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 1.2.2 日期/时间函数
|
### 1.2.2 日期/时间函数
|
||||||
1. NOW():返回当前日期和时间(yyyy-MM-DD HH:mm:ss)。
|
1. NOW():返回当前日期和时间(yyyy-MM-DD HH:mm:ss)。
|
||||||
2. CURDATE():返回当前日期,不带时间部分(yyyy-MM-DD)。
|
2. CURDATE():返回当前日期,不带时间部分(yyyy-MM-DD)。
|
||||||
3. DATE_FORMAT(date, format):按照指定的格式返回日期值。
|
3. DATE_FORMAT(date, format):按照指定的格式返回日期值。
|
||||||
@@ -71,7 +75,7 @@ SELECT DATEDIFF('2024-12-31', '2024-01-01'); -- 返回364
|
|||||||
SELECT STR_TO_DATE('01-09-2024', '%d-%m-%Y'); -- 返回 '2024-09-01'
|
SELECT STR_TO_DATE('01-09-2024', '%d-%m-%Y'); -- 返回 '2024-09-01'
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 1.2.3 数值函数
|
### 1.2.3 数值函数
|
||||||
1. ROUND(x, d):将数值 x 四舍五入到 d 位小数。
|
1. ROUND(x, d):将数值 x 四舍五入到 d 位小数。
|
||||||
```sql
|
```sql
|
||||||
SELECT ROUND(123.4567, 2); -- 返回 123.46
|
SELECT ROUND(123.4567, 2); -- 返回 123.46
|
||||||
@@ -82,13 +86,13 @@ SELECT FLOOR(2.9); -- 返回 2
|
|||||||
SELECT CEIL(2.1); -- 返回 3
|
SELECT CEIL(2.1); -- 返回 3
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 1.2.4 聚合函数
|
### 1.2.4 聚合函数
|
||||||
1. COUNT(expression):返回某列中的记录数。
|
1. COUNT(expression):返回某列中的记录数。
|
||||||
2. SUM(expression):返回某列中数值的总和。
|
2. SUM(expression):返回某列中数值的总和。
|
||||||
3. AVG(expression):返回某列中数值的平均值。
|
3. AVG(expression):返回某列中数值的平均值。
|
||||||
4. MAX(expression) / MIN(expression):返回某列的最大值或最小值。
|
4. MAX(expression) / MIN(expression):返回某列的最大值或最小值。
|
||||||
|
|
||||||
#### 1.2.5 控制流函数
|
### 1.2.5 控制流函数
|
||||||
1. IF(condition, true_value, false_value):如果 condition 为真,返回 true_value,否则返回 false_value。
|
1. IF(condition, true_value, false_value):如果 condition 为真,返回 true_value,否则返回 false_value。
|
||||||
```sql
|
```sql
|
||||||
SELECT IF(1 > 0, 'Yes', 'No'); -- 返回 'Yes'
|
SELECT IF(1 > 0, 'Yes', 'No'); -- 返回 'Yes'
|
||||||
@@ -104,11 +108,11 @@ SELECT
|
|||||||
FROM employees;
|
FROM employees;
|
||||||
```
|
```
|
||||||
|
|
||||||
## 二、索引
|
# 二、索引
|
||||||
  索引是对数据库表中的一列或多列值进行**排序**的一种结构,使用索引可以快速访问数据库表中的特定信息。
|
  索引是对数据库表中的一列或多列值进行**排序**的一种结构,使用索引可以快速访问数据库表中的特定信息。
|
||||||
  **索引相当于图书上的目录,可以根据目录上的页码快速找到所需的内容,提高性能(查询速度)**
|
  **索引相当于图书上的目录,可以根据目录上的页码快速找到所需的内容,提高性能(查询速度),相当于用空间换时间。**
|
||||||
|
|
||||||
### 2.1 优缺点
|
## 2.1 优缺点
|
||||||
优点:
|
优点:
|
||||||
* **查询速度起飞 (主要目的)** :通过索引,数据库可以**大幅减少需要扫描的数据量**,直接定位到符合条件的记录,从而显著加快数据检索速度,减少磁盘 I/O 次数。
|
* **查询速度起飞 (主要目的)** :通过索引,数据库可以**大幅减少需要扫描的数据量**,直接定位到符合条件的记录,从而显著加快数据检索速度,减少磁盘 I/O 次数。
|
||||||
* **保证数据唯一性**:通过创建**唯一索引 (Unique Index)** ,可以确保表中的某一列(或几列组合)的值是独一无二的,比如用户ID、邮箱等。**主键本身就是一种唯一索引**。
|
* **保证数据唯一性**:通过创建**唯一索引 (Unique Index)** ,可以确保表中的某一列(或几列组合)的值是独一无二的,比如用户ID、邮箱等。**主键本身就是一种唯一索引**。
|
||||||
@@ -119,7 +123,7 @@ FROM employees;
|
|||||||
* **占用存储空间**:索引本质上也是一种数据结构,需要以物理文件(或内存结构)的形式存储,因此会**额外占用一定的磁盘空间**。索引越多、越大,占用的空间也就越多。
|
* **占用存储空间**:索引本质上也是一种数据结构,需要以物理文件(或内存结构)的形式存储,因此会**额外占用一定的磁盘空间**。索引越多、越大,占用的空间也就越多。
|
||||||
* **可能被误用或失效**:如果索引设计不当,或者查询语句写得不好,数据库优化器可能不会选择使用索引(或者选错索引),反而导致性能下降。
|
* **可能被误用或失效**:如果索引设计不当,或者查询语句写得不好,数据库优化器可能不会选择使用索引(或者选错索引),反而导致性能下降。
|
||||||
|
|
||||||
### 2.2 适用场景
|
## 2.2 适用场景
|
||||||
适用场景
|
适用场景
|
||||||
* **频繁作为查询条件的字段**应该创建索引
|
* **频繁作为查询条件的字段**应该创建索引
|
||||||
* **查询中排序的字段**创建索引将大大提高排序的速度(索引就是排序加快速查找)
|
* **查询中排序的字段**创建索引将大大提高排序的速度(索引就是排序加快速查找)
|
||||||
@@ -130,10 +134,10 @@ FROM employees;
|
|||||||
* **表记录太少,不需要创建索引**;
|
* **表记录太少,不需要创建索引**;
|
||||||
* **数据重复且分布平均的字段**,因此为经常查询的和经常排序的字段建立索引。注意某些数据包含大量重复数据,因此他建立索引就没有太大的效果,例如性别字段,只有男女,不适合建立索引。
|
* **数据重复且分布平均的字段**,因此为经常查询的和经常排序的字段建立索引。注意某些数据包含大量重复数据,因此他建立索引就没有太大的效果,例如性别字段,只有男女,不适合建立索引。
|
||||||
|
|
||||||
### 2.3 数据结构
|
## 2.3 数据结构
|
||||||
  在 MySQL 中,MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构。
|
  在 MySQL 中,MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构。
|
||||||

|

|
||||||
  例如要查找id=75的用户:`SELECT * FROM users WHERE id = 75`
|
  例如要查找id=75的用户:`SELECT * FROM users WHERE id = 75`
|
||||||
  查找步骤:
|
  查找步骤:
|
||||||
1. 从根节点开始,75在50~100之间,走中间分支
|
1. 从根节点开始,75在50~100之间,走中间分支
|
||||||
2. 到达叶子节点,找到id=75的数据位置
|
2. 到达叶子节点,找到id=75的数据位置
|
||||||
@@ -147,9 +151,9 @@ FROM employees;
|
|||||||
|叶子节点连接|支持范围查询|ORDER BY、分页查询快|
|
|叶子节点连接|支持范围查询|ORDER BY、分页查询快|
|
||||||
|只在叶子存数据|内部节点小|更多索引数据放入内存|
|
|只在叶子存数据|内部节点小|更多索引数据放入内存|
|
||||||
|
|
||||||
### 2.4 索引类型
|
## 2.4 索引类型
|
||||||
1. 主键索引
|
1. 主键索引
|
||||||
  主键索引是最特殊的索引。
|
主键索引是最特殊的索引。
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
@@ -173,21 +177,33 @@ CREATE INDEX idx_name ON users(name);
|
|||||||
SELECT * FROM users WHERE name = "张三";
|
SELECT * FROM users WHERE name = "张三";
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 复合索引
|
4. 联合索引
|
||||||
  多个字段组合的索引
|
多个字段组合的索引
|
||||||
```sql
|
```sql
|
||||||
CREATE INDEX idx_name_age_city ON users(name, age, city);
|
CREATE INDEX idx_name_age_city ON users(name, age, city);
|
||||||
|
|
||||||
SELECT * FROM users WHERE name = '张三';
|
SELECT * FROM users WHERE name = '张三';
|
||||||
SELECT * FROM users WHERE name = '张三' AND age = 25;
|
SELECT * FROM users WHERE name = '张三' AND age = 25;
|
||||||
SELECT * FROM users WHERE name = '张三' AND age = 25 AND city = '北京';
|
SELECT * FROM users WHERE name = '张三' AND age = 25 AND city = '北京';
|
||||||
SELECT * FROM users WHERE age = 25; -- 无法使用索引
|
SELECT * FROM users WHERE age = 25; -- 无法使用索引
|
||||||
SELECT * FROM users WHERE city = '北京'; -- 无法使用索引
|
SELECT * FROM users WHERE city = '北京'; -- 无法使用索引
|
||||||
```
|
```
|
||||||
  复合索引的使用最左前缀原则。
|
  联合索引的使用最左前缀原则。最左前缀原则指的是查询从索引的最左列开始,不能跳过索引中的列。比如有A、B、C这3列作为索引,则查询语句中必须是A、AB、AC、ABC,不能是BC这样的跳过了A,SQL语句中字段的位置可以任意,但一定要包含,比如CBA顺序也可以。
|
||||||
|
  联合索引中,出现范围查询(<, >),范围查询右侧的列索引失效。可以用>=或者<=来规避索引失效问题。
|
||||||
  `CREATE INDEX idx_name_age_city ON users(name, age, city);`相当于创建了3个索引:`users(name),users(name, age),users(name, age, city)`。
|
  `CREATE INDEX idx_name_age_city ON users(name, age, city);`相当于创建了3个索引:`users(name),users(name, age),users(name, age, city)`。
|
||||||
|
|
||||||
### 2.5 索引设计
|
::: tip
|
||||||
|
在 InnoDB 存储引擎中,根据索引的存储形式,又可以分为以下两种:
|
||||||
|
| 分类 | 含义 | 特点 |
|
||||||
|
|---|---|---|
|
||||||
|
| 聚集索引 | 将数据存储与索引放在一起,索引结构的叶子节点保存了行数据 | 必须有,而且只有一个 |
|
||||||
|
| 二级索引 | 将数据与索引分开存储,索引结构的叶子节点关联的是对应的主键值 | 可以存在多个 |
|
||||||
|
|
||||||
|
  聚集索引就是主键索引,其他的都是二级索引,不能存放所有的数据。
|
||||||
|
  比如查询`select * from user where name = 'Arm';`时,虽然使用了索引,但是只能查到`Arm`对应的`user_id`主键值,还需要根据主键值去查找完整的行记录。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 2.5 索引设计
|
||||||
* 为WHERE条件添加索引
|
* 为WHERE条件添加索引
|
||||||
```sql
|
```sql
|
||||||
SELECT * FROM orders WHERE user_id = 123;
|
SELECT * FROM orders WHERE user_id = 123;
|
||||||
@@ -218,7 +234,7 @@ CREATE INDEX idx_city_age_create_time ON users(city, age, create_time);
|
|||||||
* 被频繁更新的字段应该慎重建立索引,虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。
|
* 被频繁更新的字段应该慎重建立索引,虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。
|
||||||
* 尽可能的考虑建立联合索引而不是单列索引。因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+ 树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。
|
* 尽可能的考虑建立联合索引而不是单列索引。因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+ 树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。
|
||||||
|
|
||||||
### 2.6 索引优化
|
## 2.6 索引优化
|
||||||
1. 监控慢查询
|
1. 监控慢查询
|
||||||
```sql
|
```sql
|
||||||
-- 开启慢查询日志
|
-- 开启慢查询日志
|
||||||
@@ -238,7 +254,7 @@ CREATE INDEX idx_title_prefix ON articles(title(20)); -- 只索引前20个字
|
|||||||
## 三、事务
|
## 三、事务
|
||||||
  事务是数据库操作的基本单位,它是一组原子性的 SQL 语句,或者说是一个独立的工作单元。事务内的所有操作**要么全部成功,要么全部失败**。
|
  事务是数据库操作的基本单位,它是一组原子性的 SQL 语句,或者说是一个独立的工作单元。事务内的所有操作**要么全部成功,要么全部失败**。
|
||||||
|
|
||||||
### 3.1 特性
|
## 3.1 特性
|
||||||
* **原子性**(`Atomicity`):原子性确保事务中的所有操作要么全部完成,要么全部不完成。如果事务执行过程中发生错误,所有已执行的操作都会回滚。
|
* **原子性**(`Atomicity`):原子性确保事务中的所有操作要么全部完成,要么全部不完成。如果事务执行过程中发生错误,所有已执行的操作都会回滚。
|
||||||
```sql
|
```sql
|
||||||
START TRANSACTION;
|
START TRANSACTION;
|
||||||
@@ -281,13 +297,13 @@ COMMIT;
|
|||||||
-- 提交后,数据已经持久化到磁盘
|
-- 提交后,数据已经持久化到磁盘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 隔离级别
|
## 3.2 隔离级别
|
||||||
#### 3.2.1 概念
|
### 3.2.1 概念
|
||||||
* **脏读 (Dirty Read):** 事务A读取了事务B**尚未提交**的修改数据。如果事务B最终回滚,事务A读到的就是无效的"脏"数据。
|
* **脏读 (Dirty Read):** 事务A读取了事务B**尚未提交**的修改数据。如果事务B最终回滚,事务A读到的就是无效的"脏"数据。
|
||||||
* **不可重复读 (Non-Repeatable Read):** 在同一个事务A中,**两次读取同一行数据**,得到的结果不同。这是因为在两次读取之间,该行数据被另一个提交了的事务B**修改**了。
|
* **不可重复读 (Non-Repeatable Read):** 在同一个事务A中,**两次读取同一行数据**,得到的结果不同。这是因为在两次读取之间,该行数据被另一个提交了的事务B**修改**了。
|
||||||
* **幻读 (Phantom Read):** 在同一个事务A中,**两次执行相同的查询**(通常是范围查询 `SELECT ... WHERE ...`),得到的结果集行数不同(出现了新的"幻影"行或原有行消失了)。这是因为在两次查询之间,另一个提交了的事务B**插入**了满足查询条件的新行或**删除**了原有的行。
|
* **幻读 (Phantom Read):** 在同一个事务A中,**两次执行相同的查询**(通常是范围查询 `SELECT ... WHERE ...`),得到的结果集行数不同(出现了新的"幻影"行或原有行消失了)。这是因为在两次查询之间,另一个提交了的事务B**插入**了满足查询条件的新行或**删除**了原有的行。
|
||||||
|
|
||||||
#### 3.2.2 类型
|
### 3.2.2 类型
|
||||||
1. 读未提交 (READ UNCOMMITTED)
|
1. 读未提交 (READ UNCOMMITTED)
|
||||||
* 特点: 这是最低的隔离级别,允许读取尚未提交的数据变更。
|
* 特点: 这是最低的隔离级别,允许读取尚未提交的数据变更。
|
||||||
* 允许的问题:
|
* 允许的问题:
|
||||||
@@ -317,7 +333,6 @@ COMMIT;
|
|||||||
* 并发性: **中等**。比 `READ COMMITTED` 稍低,因为锁(特别是 Next-Key Locks)可能持有更长时间,覆盖更大的范围(索引区间)。
|
* 并发性: **中等**。比 `READ COMMITTED` 稍低,因为锁(特别是 Next-Key Locks)可能持有更长时间,覆盖更大的范围(索引区间)。
|
||||||
* 数据一致性: **好**。保证了事务内读取数据的稳定性(同一行可重复读),并通过机制有效防止了幻读,满足大多数应用的需求。
|
* 数据一致性: **好**。保证了事务内读取数据的稳定性(同一行可重复读),并通过机制有效防止了幻读,满足大多数应用的需求。
|
||||||
* **使用场景:** MySQL 的默认选择,适用于绝大多数需要保证事务内数据读取一致性的场景,如订单处理、账户管理等。是兼顾一致性和并发性的良好平衡点。
|
* **使用场景:** MySQL 的默认选择,适用于绝大多数需要保证事务内数据读取一致性的场景,如订单处理、账户管理等。是兼顾一致性和并发性的良好平衡点。
|
||||||
*
|
|
||||||
4. 串行化 (SERIALIZABLE)
|
4. 串行化 (SERIALIZABLE)
|
||||||
* 特点: 最高的隔离级别。它通过强制事务**串行执行**来实现,所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰。
|
* 特点: 最高的隔离级别。它通过强制事务**串行执行**来实现,所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰。
|
||||||
* 允许的问题:
|
* 允许的问题:
|
||||||
@@ -329,7 +344,7 @@ COMMIT;
|
|||||||
* 数据一致性: **最好**。完全保证事务的隔离性,不会出现任何并发问题。
|
* 数据一致性: **最好**。完全保证事务的隔离性,不会出现任何并发问题。
|
||||||
* 使用场景: 仅在对数据一致性要求极高,且完全不能接受任何并发副作用(如金融核心系统的某些极端操作),并且能承受极低并发性能的情况下使用。**实践中很少使用。**
|
* 使用场景: 仅在对数据一致性要求极高,且完全不能接受任何并发副作用(如金融核心系统的某些极端操作),并且能承受极低并发性能的情况下使用。**实践中很少使用。**
|
||||||
|
|
||||||
### 3.3 MySQL锁
|
## 3.3 MySQL锁
|
||||||
* **表级锁:** MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。不过,触发锁冲突的概率最高,高并发下效率极低。表级锁和存储引擎无关,MyISAM 和 InnoDB 引擎都支持表级锁。
|
* **表级锁:** MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。不过,触发锁冲突的概率最高,高并发下效率极低。表级锁和存储引擎无关,MyISAM 和 InnoDB 引擎都支持表级锁。
|
||||||
* **行级锁:** MySQL 中锁定粒度最小的一种锁,是 **针对索引字段加的锁** ,只针对当前操作的行记录进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。行级锁和存储引擎有关,是在存储引擎层面实现的。
|
* **行级锁:** MySQL 中锁定粒度最小的一种锁,是 **针对索引字段加的锁** ,只针对当前操作的行记录进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。行级锁和存储引擎有关,是在存储引擎层面实现的。
|
||||||
* **共享锁(S 锁)** :又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。
|
* **共享锁(S 锁)** :又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。
|
||||||
@@ -344,48 +359,49 @@ COMMIT;
|
|||||||
|UPDATE|排他锁(X)|自动获取|
|
|UPDATE|排他锁(X)|自动获取|
|
||||||
|DELETE|排他锁(X)|自动获取|
|
|DELETE|排他锁(X)|自动获取|
|
||||||
|
|
||||||
## 四、日志
|
# 四、日志
|
||||||
  MySQL 的三大核心日志系统是保证数据一致性、实现故障恢复和提供复制功能的关键组件。这三大日志分别是:二进制日志(binlog)、错误日志(error log) 和 重做日志(redo log)。
|
  MySQL 的三大核心日志系统是保证数据一致性、实现故障恢复和提供复制功能的关键组件。这三大日志分别是:二进制日志(binlog)、错误日志(error log) 和 重做日志(redo log)。
|
||||||
### 4.1 二进制日志(Binary Log,简称 binlog)
|
## 4.1 二进制日志(Binary Log,简称 binlog)
|
||||||
#### 4.1.1 概念
|
### 4.1.1 概念
|
||||||
  二进制日志是 MySQL 记录所有修改数据或可能修改数据的语句(或数据变更)的日志文件。它记录了数据库执行的更改操作(如 INSERT、UPDATE、DELETE 等 DML 操作,以及 CREATE、ALTER、DROP 等 DDL 操作),但**不记录 SELECT 这类不修改数据的查询操作**。
|
  二进制日志是 MySQL 记录所有修改数据或可能修改数据的语句(或数据变更)的日志文件。它记录了数据库执行的更改操作(如 INSERT、UPDATE、DELETE 等 DML 操作,以及 CREATE、ALTER、DROP 等 DDL 操作),但**不记录 SELECT 这类不修改数据的查询操作**。
|
||||||
|
|
||||||
#### 4.1.2 作用
|
### 4.1.2 作用
|
||||||
1. 主从复制(Replication):在主从架构中,主库将 binlog 发送给从库,从库通过读取并重放 binlog 来保持与主库的数据同步。
|
1. 主从复制(Replication):在主从架构中,主库将 binlog 发送给从库,从库通过读取并重放 binlog 来保持与主库的数据同步。
|
||||||
2. 数据恢复(Point-in-Time Recovery):通过备份 + binlog 可以恢复到某个具体时间点。
|
2. 数据恢复(Point-in-Time Recovery):通过备份 + binlog 可以恢复到某个具体时间点。
|
||||||
3. 审计:可以追踪数据库的所有变更操作。
|
3. 审计:可以追踪数据库的所有变更操作。
|
||||||
|
|
||||||
### 4.2 重做日志(Redo Log)
|
## 4.2 重做日志(Redo Log)
|
||||||
#### 4.2.1 概念
|
### 4.2.1 概念
|
||||||
  重做日志是 InnoDB 存储引擎特有的日志,它记录的是 “物理级别” 上的页修改信息,主要用于 崩溃恢复(Crash Recovery)。
|
  重做日志是 InnoDB 存储引擎特有的日志,它记录的是 “物理级别” 上的页修改信息,主要用于 崩溃恢复(Crash Recovery)。
|
||||||
  InnoDB 使用 WAL(Write-Ahead Logging,预写日志)技术,即 事务提交时,先写 redo log,再写磁盘数据页,从而保证即使系统崩溃,也能通过 redo log 恢复数据。
|
  InnoDB 使用 WAL(Write-Ahead Logging,预写日志)技术,即 事务提交时,先写 redo log,再写磁盘数据页,从而保证即使系统崩溃,也能通过 redo log 恢复数据。
|
||||||
|
|
||||||
#### 4.2.2 作用
|
### 4.2.2 作用
|
||||||
1. 实现事务的持久性(Durability):确保事务提交后,即使发生宕机,数据也不会丢失。
|
1. 实现事务的持久性(Durability):确保事务提交后,即使发生宕机,数据也不会丢失。
|
||||||
2. 提高写入性能:数据不是每次修改都直接写磁盘,而是先写 redo log(顺序写,速度快),后续再异步刷盘。
|
2. 提高写入性能:数据不是每次修改都直接写磁盘,而是先写 redo log(顺序写,速度快),后续再异步刷盘。
|
||||||
3. 支持 crash-safe:MySQL 宕机重启后,可通过 redo log 恢复未刷盘的数据。
|
3. 支持 crash-safe:MySQL 宕机重启后,可通过 redo log 恢复未刷盘的数据。
|
||||||
|
|
||||||
### 4.3 回滚日志(Undo Log)
|
## 4.3 回滚日志(Undo Log)
|
||||||
#### 4.3.1 概念
|
### 4.3.1 概念
|
||||||
  回滚日志也是 InnoDB 引擎特有 的日志,它记录的是 数据被修改前的原始值
|
  回滚日志也是 InnoDB 引擎特有 的日志,它记录的是 数据被修改前的原始值
|
||||||
|
|
||||||
#### 4.3.2 作用
|
### 4.3.2 作用
|
||||||
1. 支持事务回滚:如果事务执行失败或调用了 ROLLBACK,可以通过 undo log 将数据恢复到修改之前的状态。
|
1. 支持事务回滚:如果事务执行失败或调用了 ROLLBACK,可以通过 undo log 将数据恢复到修改之前的状态。
|
||||||
2. 实现 MVCC(多版本并发控制):在读已提交(RC)、可重复读(RR)隔离级别下,undo log 用于提供历史版本数据,使得不同事务能看到不同的数据快照,而不需要加锁。
|
2. 实现 MVCC(多版本并发控制):在读已提交(RC)、可重复读(RR)隔离级别下,undo log 用于提供历史版本数据,使得不同事务能看到不同的数据快照,而不需要加锁。
|
||||||
### 4.4 和事务、锁之间的关系
|
|
||||||
|
## 4.4 和事务、锁之间的关系
|
||||||
  例如,当执行一条SQL语句时,事务、锁、日志等之间的关系:
|
  例如,当执行一条SQL语句时,事务、锁、日志等之间的关系:
|
||||||
1. 发起一个事务
|
1. 发起一个事务
|
||||||
2. 执行一系列的DML操作(如 INSERT/UPDATE/DELETE)
|
2. 执行一系列的DML操作(如 INSERT/UPDATE/DELETE)
|
||||||
  **涉及 锁:对操作的数据行或表加锁,防止其他事务干扰。**
|
  **涉及 锁:对操作的数据行或表加锁,防止其他事务干扰。**
|
||||||
  **涉及 隔离级别:决定其他事务是否能“看到”你未提交的数据。**
|
  **涉及 隔离级别:决定其他事务是否能“看到”你未提交的数据。**
|
||||||
  **涉及 undo log:如果事务回滚,可以根据 undo log 恢复旧值。**
|
  **涉及 undo log:如果事务回滚,可以根据 undo log 恢复旧值。**
|
||||||
3. 事务提交(commit) 或 回滚(rollback)
|
3. 事务提交(commit) 或 回滚(rollback)
|
||||||
  **redo log:保证即使宕机,已提交事务的修改也不丢失(持久性)。**
|
  **redo log:保证即使宕机,已提交事务的修改也不丢失(持久性)。**
|
||||||
  **binlog:记录数据变更,用于主从复制与时间点恢复。**
|
  **binlog:记录数据变更,用于主从复制与时间点恢复。**
|
||||||
  **undo log:用于实现事务回滚、MVCC。**
|
  **undo log:用于实现事务回滚、MVCC。**
|
||||||
4. **背后有日志系统默默记录一切,锁系统保障并发安全,隔离级别定义了“你能看到啥”。**
|
4. **背后有日志系统默默记录一切,锁系统保障并发安全,隔离级别定义了“你能看到啥”。**
|
||||||
|
|
||||||
#### 4.4.1 相互关系
|
### 4.4.1 相互关系
|
||||||
1. 事务是锁的使用者,锁是事务实现隔离性的手段。当一个事务对某行数据执行 UPDATE/DELETE 操作时,为了防止其他事务同时修改相同数据,InnoDB 会自动对该行或索引加 排他锁(X锁)。如果事务只是读取数据,根据隔离级别,可能会加 共享锁(S锁) 或使用 MVCC(不加锁)。
|
1. 事务是锁的使用者,锁是事务实现隔离性的手段。当一个事务对某行数据执行 UPDATE/DELETE 操作时,为了防止其他事务同时修改相同数据,InnoDB 会自动对该行或索引加 排他锁(X锁)。如果事务只是读取数据,根据隔离级别,可能会加 共享锁(S锁) 或使用 MVCC(不加锁)。
|
||||||
2. 隔离级别定义了事务之间的可见性规则,是事务“隔离性”的具体体现。事务的隔离性是通过锁 + MVCC(多版本并发控制,依赖 undo log)+ 隔离级别共同实现的。
|
2. 隔离级别定义了事务之间的可见性规则,是事务“隔离性”的具体体现。事务的隔离性是通过锁 + MVCC(多版本并发控制,依赖 undo log)+ 隔离级别共同实现的。
|
||||||
3. 日志是事务实现 持久性、崩溃恢复、回滚 等能力的基石。redo log(重做日志)保障事务的持久性(Durability),事务提交时,先将数据页的变更记录到 redo log(顺序写,高性能),随后再异步刷盘到磁盘数据页。即使系统崩溃,重启后也能通过 redo log 恢复已提交但尚未刷盘的数据。undo log(回滚日志)支持事务回滚 和 MVCC。事务修改数据前,会先把原始数据保存到 undo log,如果事务回滚,可以用它恢复旧值。同时,undo log 也是 MVCC(多版本控制)实现的基础,用于提供历史版本数据。binlog(二进制日志)用于主从复制、时间点恢复。虽然 binlog 是 Server 层的日志,不属于 InnoDB 事务引擎的一部分,但在事务提交时,binlog 与 redo log 通过两阶段提交(2PC)保证一致性。
|
3. 日志是事务实现 持久性、崩溃恢复、回滚 等能力的基石。redo log(重做日志)保障事务的持久性(Durability),事务提交时,先将数据页的变更记录到 redo log(顺序写,高性能),随后再异步刷盘到磁盘数据页。即使系统崩溃,重启后也能通过 redo log 恢复已提交但尚未刷盘的数据。undo log(回滚日志)支持事务回滚 和 MVCC。事务修改数据前,会先把原始数据保存到 undo log,如果事务回滚,可以用它恢复旧值。同时,undo log 也是 MVCC(多版本控制)实现的基础,用于提供历史版本数据。binlog(二进制日志)用于主从复制、时间点恢复。虽然 binlog 是 Server 层的日志,不属于 InnoDB 事务引擎的一部分,但在事务提交时,binlog 与 redo log 通过两阶段提交(2PC)保证一致性。
|
||||||
@@ -393,15 +409,42 @@ COMMIT;
|
|||||||
5. 锁保障并发安全,日志保障操作可恢复。两者从不同维度确保数据库的正确性。锁:是在运行时控制谁可以访问哪些数据,是 并发控制 的手段。日志:是在磁盘上记录操作过程,是 故障恢复 & 事务一致性 的手段。
|
5. 锁保障并发安全,日志保障操作可恢复。两者从不同维度确保数据库的正确性。锁:是在运行时控制谁可以访问哪些数据,是 并发控制 的手段。日志:是在磁盘上记录操作过程,是 故障恢复 & 事务一致性 的手段。
|
||||||
6. 隔离级别定义了事务间数据的可见性,而日志(尤其是 undo log 和 binlog)为这种“可见性”提供了实现基础。undo log 是实现 MVCC(多版本并发控制) 的基础,而 MVCC 是 REPEATABLE READ 等隔离级别的关键。binlog 虽不直接影响隔离性,但它记录了事务的变更历史,是构建主从环境、实现数据恢复的基础。
|
6. 隔离级别定义了事务间数据的可见性,而日志(尤其是 undo log 和 binlog)为这种“可见性”提供了实现基础。undo log 是实现 MVCC(多版本并发控制) 的基础,而 MVCC 是 REPEATABLE READ 等隔离级别的关键。binlog 虽不直接影响隔离性,但它记录了事务的变更历史,是构建主从环境、实现数据恢复的基础。
|
||||||
|
|
||||||
|
# 五、优化
|
||||||
|
## 5.1 开启慢查询
|
||||||
|
  查看慢查询是否开启:
|
||||||
|
```bash
|
||||||
|
SHOW VARIABLES LIKE 'slow_query_log';
|
||||||
|
SHOW VARIABLES LIKE 'slow_query_log_file';
|
||||||
|
```
|
||||||
|
|
||||||
## 五、优化
|
  临时开启慢查询,重启服务后无效:
|
||||||
### 5.1 Explain 执行计划
|
```bash
|
||||||
|
SET GLOBAL slow_query_log = ON;
|
||||||
|
SET GLOBAL long_query_time = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
  永久开启慢查询:
|
||||||
|
```bash
|
||||||
|
[mysqld]
|
||||||
|
slow_query_log = ON
|
||||||
|
long_query_time = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
  开启后可以通过查看`SHOW VARIABLES LIKE 'slow_query_log_file'`查看记录的文件路径。
|
||||||
|
|
||||||
|
## 5.2 查看SQL运行时间
|
||||||
|
```bash
|
||||||
|
SET profiling = 1;
|
||||||
|
SELECT name, age FROM users WHERE name = "user235909051";
|
||||||
|
SHOW PROFILES;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.3 Explain 执行计划
|
||||||
  `EXPLAIN`是 MySQL 自带的一个诊断工具,它可以模拟 MySQL 查询优化器的执行过程,对`SELECT`语句(在 MySQL 8.0 及以上版本,也支持对`UPDATE`、`DELETE`等语句使用)进行分析,并输出该语句的执行计划。
|
  `EXPLAIN`是 MySQL 自带的一个诊断工具,它可以模拟 MySQL 查询优化器的执行过程,对`SELECT`语句(在 MySQL 8.0 及以上版本,也支持对`UPDATE`、`DELETE`等语句使用)进行分析,并输出该语句的执行计划。
|
||||||
#### 5.1.1 基本用法
|
### 5.3.1 基本用法
|
||||||
  在select语句前面加上EXPLAIN关键字即可,例如:`EXPLAIN SELECT * FROM users WHERE age > 25;
|
  在select语句前面加上EXPLAIN关键字即可,例如:`EXPLAIN SELECT * FROM users WHERE age > 25;`
|
||||||
`
|
|
||||||
|
|
||||||
#### 5.1.2 输出列说明
|
### 5.3.2 输出列说明
|
||||||
| 列名 | 说明 | 示例 |
|
| 列名 | 说明 | 示例 |
|
||||||
| :-: | :-: | :-: |
|
| :-: | :-: | :-: |
|
||||||
| id | 查询标识符 | 相同 id 表示同组查询,执行顺序从上到下;不同 id 值越大优先级越高 |
|
| id | 查询标识符 | 相同 id 表示同组查询,执行顺序从上到下;不同 id 值越大优先级越高 |
|
||||||
@@ -417,7 +460,7 @@ COMMIT;
|
|||||||
| filtered | 存储引擎返回数据后在 server 层过滤的比例 | |
|
| filtered | 存储引擎返回数据后在 server 层过滤的比例 | |
|
||||||
| **Extra** | 额外执行信息 | |
|
| **Extra** | 额外执行信息 | |
|
||||||
|
|
||||||
#### 5.1.3 重要指标:Type
|
### 5.3.3 重要指标:Type
|
||||||
| 类型 | 描述 | 性能 | 示例 |
|
| 类型 | 描述 | 性能 | 示例 |
|
||||||
| - | - | - | - |
|
| - | - | - | - |
|
||||||
| system | 系统表,仅一行 | 最优 | MyISAM 引擎的空表 |
|
| system | 系统表,仅一行 | 最优 | MyISAM 引擎的空表 |
|
||||||
@@ -433,7 +476,7 @@ COMMIT;
|
|||||||
| index | 全索引扫描 | 差 | SELECT indexed_col FROM table |
|
| index | 全索引扫描 | 差 | SELECT indexed_col FROM table |
|
||||||
| ALL | 全表扫描 | 最差 | 无索引查询|
|
| ALL | 全表扫描 | 最差 | 无索引查询|
|
||||||
|
|
||||||
#### 5.1.4 重要指标:Extra
|
### 5.3.4 重要指标:Extra
|
||||||
| 值 | 含义 | 优化建议 |
|
| 值 | 含义 | 优化建议 |
|
||||||
| - | - | - |
|
| - | - | - |
|
||||||
| Using index | 覆盖索引(无需回表) | 优,保持 |
|
| Using index | 覆盖索引(无需回表) | 优,保持 |
|
||||||
@@ -446,13 +489,72 @@ COMMIT;
|
|||||||
| Impossible WHERE | WHERE 条件永不成立 | 查询逻辑错误 |
|
| Impossible WHERE | WHERE 条件永不成立 | 查询逻辑错误 |
|
||||||
| Distinct | 优化 DISTINCT 操作 | |
|
| Distinct | 优化 DISTINCT 操作 | |
|
||||||
|
|
||||||
## 六、执行过程
|
# 六、执行过程
|
||||||

|

|
||||||
  MySQL架构分为两层,Service层和存储引擎层。
|
  MySQL架构分为两层,Service层和存储引擎层。
|
||||||
  Server 层负责建立连接、分析和执行 SQL。存储引擎层负责数据的存储和提取。
|
  Server 层负责建立连接、分析和执行 SQL。存储引擎层负责数据的存储和提取。
|
||||||
1. 连接器:建立连接、管理连接、校验用户身份。
|
1. 连接器:建立连接、管理连接、校验用户身份。
|
||||||
2. 查询缓存(MySQL8.0已删除)
|
2. 查询缓存(MySQL8.0已删除)
|
||||||
3. 解析SQL,通过解析器对SQL查询语句进行词法分析、语法分析,然后构建语法树,方便后续模块读取表名、字段和语句类型等。
|
3. 解析SQL,通过解析器对SQL查询语句进行词法分析、语法分析,然后构建语法树,方便后续模块读取表名、字段和语句类型等。
|
||||||
4. 执行SQL:分为预处理阶段、优化阶段和执行阶段。
|
4. 执行SQL:分为预处理阶段、优化阶段和执行阶段。
|
||||||
|
|
||||||
|
# 七、视图
|
||||||
|
## 7.1 定义
|
||||||
|
  视图(View)是 MySQL 中的一种虚拟表,它的数据来源于一个或多个实际的表(基表),**其结构和数据是通过 SQL 查询语句定义的**。简单来说,视图就像是一个 “查询窗口”,你看到的是基表的数据,但**视图本身并不存储实际数据,每次访问视图时都会执行对应的查询语句**。
|
||||||
|
|
||||||
|
## 7.2 作用
|
||||||
|
1. 简化复杂查询:将多表关联、聚合等复杂的 SQL 逻辑封装到视图中,后续使用时只需查询视图即可。
|
||||||
|
2. 数据安全:可以只暴露基表中的部分列或部分行给用户,隐藏敏感数据。
|
||||||
|
3. 数据一致性:如果业务逻辑发生变化,只需修改视图的定义,而无需修改所有使用该逻辑的查询。
|
||||||
|
|
||||||
|
## 7.3 使用
|
||||||
|
  创建视图
|
||||||
|
```sql
|
||||||
|
-- 创建视图v_student_basic
|
||||||
|
CREATE VIEW v_student_basic
|
||||||
|
AS
|
||||||
|
SELECT name, age, gender FROM student;
|
||||||
|
```
|
||||||
|
|
||||||
|
  使用视图和正常使用数据表一致:
|
||||||
|
```sql
|
||||||
|
-- 查询视图(和查询普通表的方式完全一样)
|
||||||
|
SELECT * FROM v_student_basic;
|
||||||
|
```
|
||||||
|
|
||||||
|
# 八、事件
|
||||||
|
## 8.1 定义
|
||||||
|
  MySQL 事件(Event)也被称为事件调度器(Event Scheduler),是 `MySQL 中一种定时执行的数据库对象`,可以理解为数据库层面的 “定时任务” 或 “计划任务”。它能根据你设定的时间规则(一次性执行、周期性执行),自动触发并执行指定的 SQL 逻辑(如数据清理、统计报表生成、数据同步等)。
|
||||||
|
|
||||||
|
## 8.2 特点
|
||||||
|
1. 定时执行:支持一次性执行(如某个具体时间点)和周期性执行(如每天凌晨 3 点、每小时执行一次)。
|
||||||
|
2. 自动运行:依赖 MySQL 的事件调度器线程,只要调度器开启,事件就会按规则自动触发。
|
||||||
|
3. 与存储过程结合:事件的执行逻辑可以是单条 SQL,也可以是复杂的存储过程(推荐用存储过程封装复杂逻辑)。
|
||||||
|
|
||||||
|
## 8.3 作用
|
||||||
|
- 数据清理:定期删除过期数据(如删除 30 天前的日志表数据)。
|
||||||
|
- 数据统计:定时生成业务统计报表(如每天凌晨统计前一天的订单数据)。
|
||||||
|
- 数据同步:定期将 A 表的数据同步到 B 表。
|
||||||
|
- 定时备份:定期执行数据库备份脚本(配合存储过程)。
|
||||||
|
|
||||||
|
## 8.4 使用
|
||||||
|
  开启事件调度器:
|
||||||
|
```sql
|
||||||
|
-- 查看事件调度器状态(ON表示开启,OFF表示关闭)
|
||||||
|
SHOW VARIABLES LIKE 'event_scheduler';
|
||||||
|
|
||||||
|
-- 临时开启(MySQL重启后会恢复为默认状态)
|
||||||
|
SET GLOBAL event_scheduler = ON;
|
||||||
|
-- 永久开启(需要修改my.cnf/my.ini配置文件,添加以下内容,然后重启MySQL)
|
||||||
|
event_scheduler = ON # 放在[mysqld]节点下
|
||||||
|
```
|
||||||
|
|
||||||
|
  每天凌晨 1 点执行的事件,删除日志表中 7 天前的过期数据:
|
||||||
|
```sql
|
||||||
|
-- 创建周期性事件,每天凌晨1点执行,无结束时间
|
||||||
|
CREATE EVENT IF NOT EXISTS event_clear_log
|
||||||
|
ON SCHEDULE EVERY 1 DAY STARTS '2025-12-22 01:00:00'
|
||||||
|
COMMENT '每天凌晨1点删除7天前的日志'
|
||||||
|
DO
|
||||||
|
DELETE FROM log WHERE create_time < DATE_SUB(NOW(), INTERVAL 7 DAY);
|
||||||
|
```
|
||||||
56
docs/Web/Network/Mqtt.md
Normal file
56
docs/Web/Network/Mqtt.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: Mqtt简介与使用
|
||||||
|
date: 2026-05-27
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  MQTT(Message Queuing Telemetry Transport) 是一种 轻量级物联网通信协议,基于 发布 / 订阅(Pub/Sub) 模式。
|
||||||
|
|
||||||
|
## 1.1 核心概念
|
||||||
|
1. Broker:消息服务器
|
||||||
|
2. Client:发布或订阅消息的设备 / 程序
|
||||||
|
3. Topic: 消息主题(路径形式)
|
||||||
|
4. Publish:发布消息
|
||||||
|
5. Subscribe:订阅消息
|
||||||
|
6. QoS:服务质量等级(0 / 1 / 2)
|
||||||
|
|
||||||
|
## 1.2 工作方式
|
||||||
|
```text
|
||||||
|
[设备A] --发布--> TopicA --> [Broker] --> TopicA --> [设备B]
|
||||||
|
```
|
||||||
|
|
||||||
|
# 二、安装
|
||||||
|
  [EMQX](https://www.emqx.com/zh) Enterprise 是基于开源 EMQX 扩展的企业级 MQTT 物联网消息接入平台,完全兼容 MQTT v3.1.1 / v5.0。
|
||||||
|
|
||||||
|
## 2.1 docker安装
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
emqx:
|
||||||
|
image: emqx/emqx-enterprise:5.10.0
|
||||||
|
container_name: emqx
|
||||||
|
ports:
|
||||||
|
- "1883:1883"
|
||||||
|
- "8083:8083"
|
||||||
|
- "8084:8084"
|
||||||
|
- "8883:8883"
|
||||||
|
- "18083:18083"
|
||||||
|
restart: always
|
||||||
|
```
|
||||||
|
|
||||||
|
  启动容器后,输入ip:18083,默认账号密码为admin/public。
|
||||||
|
|
||||||
|
# 三、消息规范
|
||||||
|
## 3.1 Topic
|
||||||
|
```text
|
||||||
|
<domain>/<product>/<device_id>/<msg_type>/<resource>
|
||||||
|
```
|
||||||
|
|
||||||
|
  例如`iot/thermostat/dev001/status/online`、`iot/thermostat/dev001/cmd/set_mode`。
|
||||||
|
|
||||||
|
## 通配符
|
||||||
|
1. +:单层通配符,例如:iot/+/dev001/telemetry/+
|
||||||
|
2. #:多层通配符,只能放在topic末尾,例如:iot/thermostat/dev001/#
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
使用+通配符时,必须严格满足层级要求,例如,如果topic是5层,那么iot/+将无法匹配到消息
|
||||||
|
:::
|
||||||
392
docs/Web/Network/NetCore.md
Normal file
392
docs/Web/Network/NetCore.md
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
---
|
||||||
|
title: .Net Core实现
|
||||||
|
date: 2026-05-27
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、.Net Core实现
|
||||||
|
## 1.1 SuperSocket
|
||||||
|
  [SuperSocket](https://www.supersocket.net/)是一个轻量级, 跨平台而且可扩展的 .Net/Mono Socket 服务器程序框架。可以轻松构建TCP、UDP、WebSocket服务器。
|
||||||
|
|
||||||
|
## 1.2 安装依赖
|
||||||
|
  NuGut安装SuperSocket、SuperSocket.WebSocket和SuperSocket.WebSocket.Server 2.0及以上版本。
|
||||||
|
|
||||||
|
## 1.3 配置文件
|
||||||
|
  appsettings.json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"serverOptions": {
|
||||||
|
"TcpServer": {
|
||||||
|
"name": "TcpServer",
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"ip": "Any",
|
||||||
|
"port": 4040
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"WebSocketServer": {
|
||||||
|
"name": "WebSocket",
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"ip": "Any",
|
||||||
|
"port": 5050
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Mqtt": {
|
||||||
|
"Host": "127.0.0.1",
|
||||||
|
"Port": 1883,
|
||||||
|
"ClientId": "MqttClient",
|
||||||
|
"Topics": [
|
||||||
|
"test/topic1",
|
||||||
|
"test/topic2"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1.4 主程序
|
||||||
|
  program.cs
|
||||||
|
```cs
|
||||||
|
var host = Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureServices((context, services) =>
|
||||||
|
{
|
||||||
|
services.AddSingleton<IPackageHandler<ProtocolFrame>, TcpPackageHandler>();
|
||||||
|
services.AddSingleton<WebSocketMessageHandler>();
|
||||||
|
services.AddSingleton<WebSocketConnectionManager>();
|
||||||
|
|
||||||
|
// 1. 注入 MQTT 配置
|
||||||
|
services.Configure<MqttSettings>(context.Configuration.GetSection("Mqtt"));
|
||||||
|
// 2. 注册 MQTT 客户端
|
||||||
|
services.AddSingleton<IMqttClient>(serviceProvider => new MqttClientFactory().CreateMqttClient());
|
||||||
|
// 3. 注册 MQTT 后台服务
|
||||||
|
services.AddHostedService<MqttService>();
|
||||||
|
})
|
||||||
|
.AsMultipleServerHostBuilder()
|
||||||
|
.AddServer<TcpService, ProtocolFrame, BinaryPipelineFilter>(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServerOptions((ctx, config) => config.GetSection("TcpServer"));
|
||||||
|
})
|
||||||
|
.AddWebSocketServer(builder =>
|
||||||
|
{
|
||||||
|
builder
|
||||||
|
.UseSessionHandler(
|
||||||
|
session =>
|
||||||
|
{
|
||||||
|
if (session is WebSocketSession wsSession)
|
||||||
|
{
|
||||||
|
var connectionManager = session.Server.ServiceProvider.GetRequiredService<WebSocketConnectionManager>();
|
||||||
|
connectionManager.Add(wsSession);
|
||||||
|
}
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
},
|
||||||
|
(session, reason) =>
|
||||||
|
{
|
||||||
|
if (session is WebSocketSession wsSession)
|
||||||
|
{
|
||||||
|
var connectionManager = session.Server.ServiceProvider.GetRequiredService<WebSocketConnectionManager>();
|
||||||
|
connectionManager.Remove(wsSession);
|
||||||
|
}
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.UseWebSocketMessageHandler(async (session, package) =>
|
||||||
|
{
|
||||||
|
using var scope = session.Server.ServiceProvider.CreateScope();
|
||||||
|
var handler = scope.ServiceProvider.GetRequiredService<WebSocketMessageHandler>();
|
||||||
|
|
||||||
|
await handler.HandleAsync(session, package);
|
||||||
|
})
|
||||||
|
.ConfigureServerOptions((ctx, config) => config.GetSection("WebSocketServer"));
|
||||||
|
})
|
||||||
|
.ConfigureLogging(logging => logging.ClearProviders())
|
||||||
|
.UseNLog()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await host.RunAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
  通过.AsMultipleServerHostBuilder()可以构造多服务器实例。
|
||||||
|
|
||||||
|
# 二、 TCP服务器
|
||||||
|
## 2.1 协议数据
|
||||||
|
```cs
|
||||||
|
/// <summary>
|
||||||
|
/// 协议数据结构 示例:65 6D 00 05 00 01 68 65 6C 6C 6F
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Magic">帧头标识</param>
|
||||||
|
/// <param name="Length">数据长度</param>
|
||||||
|
/// <param name="Type">数据类型</param>
|
||||||
|
/// <param name="Payload">数据内容</param>
|
||||||
|
///
|
||||||
|
record ProtocolFrame(
|
||||||
|
ushort Magic,
|
||||||
|
ushort Length,
|
||||||
|
ushort Type,
|
||||||
|
byte[] Payload
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
  可以根据实际情况自定义消息格式。
|
||||||
|
|
||||||
|
## 2.2 协议解析
|
||||||
|
```cs
|
||||||
|
class BinaryPipelineFilter : FixedHeaderPipelineFilter<ProtocolFrame>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 固定头长度
|
||||||
|
/// </summary>
|
||||||
|
public BinaryPipelineFilter() : base(6)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从包头中解析出 Body 长度
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="buffer">字节流</param>
|
||||||
|
/// <returns>Body 长度</returns>
|
||||||
|
protected override int GetBodyLengthFromHeader(ref ReadOnlySequence<byte> buffer)
|
||||||
|
{
|
||||||
|
var reader = new SequenceReader<byte>(buffer);
|
||||||
|
|
||||||
|
// 读取前2字节 → magic
|
||||||
|
reader.TryReadBigEndian(out ushort magic);
|
||||||
|
|
||||||
|
// 再读2字节 → Length
|
||||||
|
reader.TryReadBigEndian(out ushort length);
|
||||||
|
|
||||||
|
// 再读2字节 → Type
|
||||||
|
reader.TryReadBigEndian(out ushort type);
|
||||||
|
|
||||||
|
// 校验帧头
|
||||||
|
if (magic != 0x656D)
|
||||||
|
{
|
||||||
|
throw new Exception("非法帧头");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制长度
|
||||||
|
if (length == 0 || length > 8192)
|
||||||
|
{
|
||||||
|
throw new Exception("非法长度");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回 Payload 长度
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把完整字节包 → 转换成 ProtocolFrame
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="buffer">字节流</param>
|
||||||
|
/// <returns>ProtocolFrame</returns>
|
||||||
|
protected override ProtocolFrame DecodePackage(ref ReadOnlySequence<byte> buffer)
|
||||||
|
{
|
||||||
|
var reader = new SequenceReader<byte>(buffer);
|
||||||
|
|
||||||
|
// 读取前2字节 → magic
|
||||||
|
reader.TryReadBigEndian(out ushort magic);
|
||||||
|
|
||||||
|
// 再读2字节 → Length
|
||||||
|
reader.TryReadBigEndian(out ushort length);
|
||||||
|
|
||||||
|
// 再读2字节 → Type
|
||||||
|
reader.TryReadBigEndian(out ushort type);
|
||||||
|
|
||||||
|
var payload = buffer.Slice(6, length).ToArray();
|
||||||
|
|
||||||
|
// 构造ProtocolFrame
|
||||||
|
return new ProtocolFrame(magic, length, type, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  根据协议数据ProtocolFrame来解包。
|
||||||
|
|
||||||
|
## 2.3 消息处理
|
||||||
|
```cs
|
||||||
|
class TcpPackageHandler(ILogger<TcpPackageHandler> logger) : IPackageHandler<ProtocolFrame>
|
||||||
|
{
|
||||||
|
private readonly ILogger<TcpPackageHandler> _logger = logger;
|
||||||
|
|
||||||
|
public async ValueTask Handle(IAppSession session, ProtocolFrame package, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"Magic={package.Magic:X4}, Type={package.Type}, Len={package.Length}");
|
||||||
|
await session.SendAsync(package.Payload, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.4 TCPService
|
||||||
|
```cs
|
||||||
|
class TcpService(IServiceProvider serviceProvider, IOptions<ServerOptions> serverOptions, ILogger<TcpService> logger) : SuperSocketService<ProtocolFrame>(serviceProvider, serverOptions)
|
||||||
|
{
|
||||||
|
private readonly ILogger<TcpService> _logger = logger;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, string> _sessionDevices = new();
|
||||||
|
|
||||||
|
public void BindDevice(string sessionId, string deviceNum)
|
||||||
|
{
|
||||||
|
_sessionDevices.AddOrUpdate(sessionId, deviceNum, (k, v) => deviceNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async ValueTask OnSessionConnectedAsync(IAppSession session)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("TCP Session 连上: {SessionID}", session.SessionID);
|
||||||
|
await base.OnSessionConnectedAsync(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async ValueTask OnSessionClosedAsync(IAppSession session, CloseEventArgs e)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("TCP Session 断开: {SessionID}, Reason={Reason}", session.SessionID, e.Reason);
|
||||||
|
|
||||||
|
if (_sessionDevices.TryRemove(session.SessionID, out var deviceNum))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("设备 {DeviceNum} 标记离线", deviceNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.OnSessionClosedAsync(session, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 三、Websocket服务器
|
||||||
|
## 3.1 消息处理
|
||||||
|
```cs
|
||||||
|
class WebSocketMessageHandler(ILogger<WebSocketMessageHandler> logger)
|
||||||
|
{
|
||||||
|
private readonly ILogger<WebSocketMessageHandler> _logger = logger;
|
||||||
|
|
||||||
|
public async ValueTask HandleAsync(WebSocketSession session, WebSocketPackage package)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"[WebSocket] {package.Message}");
|
||||||
|
await session.SendAsync("ok");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 连接管理
|
||||||
|
```cs
|
||||||
|
public class WebSocketConnectionManager(ILogger<WebSocketConnectionManager> logger)
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, WebSocketSession> _sessions = new();
|
||||||
|
private readonly ILogger<WebSocketConnectionManager> _logger = logger;
|
||||||
|
|
||||||
|
public void Add(WebSocketSession session) => _sessions.TryAdd(session.SessionID, session);
|
||||||
|
|
||||||
|
public void Remove(WebSocketSession session) => _sessions.TryRemove(session.SessionID, out _);
|
||||||
|
|
||||||
|
public async Task BroadcastAsync(string json)
|
||||||
|
{
|
||||||
|
var payload = new ReadOnlyMemory<byte>(System.Text.Encoding.UTF8.GetBytes(json));
|
||||||
|
|
||||||
|
var tasks = _sessions.Values
|
||||||
|
.Where(s => s.State == SessionState.Connected)
|
||||||
|
.Select(async s =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await s.SendAsync(json);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Remove(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、 MQTT服务器
|
||||||
|
## 4.1 MqttService
|
||||||
|
```cs
|
||||||
|
class MqttService(IMqttClient mqttClient, IOptions<MqttSettings> settings, ILogger<TcpPackageHandler> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IMqttClient _mqttClient = mqttClient;
|
||||||
|
private readonly MqttSettings _settings = settings.Value;
|
||||||
|
private readonly ILogger<TcpPackageHandler> _logger = logger;
|
||||||
|
private MqttClientOptions? _mqttOptions;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// 构建连接配置
|
||||||
|
_mqttOptions = new MqttClientOptionsBuilder()
|
||||||
|
.WithClientId($"{_settings.ClientId}_{Guid.NewGuid():N}")
|
||||||
|
.WithTcpServer(_settings.Host, _settings.Port)
|
||||||
|
.WithCleanSession()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_mqttClient.ConnectedAsync += OnConnectedAsync;
|
||||||
|
_mqttClient.DisconnectedAsync += OnDisconnectedAsync;
|
||||||
|
_mqttClient.ApplicationMessageReceivedAsync += HandleMessage;
|
||||||
|
|
||||||
|
_logger.LogInformation("[MQTT] 正在连接到服务器 {Host}:{Port}...", _settings.Host, _settings.Port);
|
||||||
|
|
||||||
|
await _mqttClient.ConnectAsync(_mqttOptions, stoppingToken);
|
||||||
|
|
||||||
|
// 等待程序停止
|
||||||
|
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnConnectedAsync(MqttClientConnectedEventArgs arg)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[MQTT] 已成功连接到服务器");
|
||||||
|
|
||||||
|
// 连接成功后订阅主题
|
||||||
|
foreach (var topic in _settings.Topics)
|
||||||
|
{
|
||||||
|
await _mqttClient.SubscribeAsync(topic, MqttQualityOfServiceLevel.AtLeastOnce);
|
||||||
|
_logger.LogInformation($"[MQTT] 已订阅主题:{topic}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnDisconnectedAsync(MqttClientDisconnectedEventArgs arg)
|
||||||
|
{
|
||||||
|
_logger.LogError("[MQTT] 连接失败,原因:{Reason}", arg.Reason);
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleMessage(MqttApplicationMessageReceivedEventArgs arg)
|
||||||
|
{
|
||||||
|
var topic = arg.ApplicationMessage.Topic;
|
||||||
|
var payload = Encoding.UTF8.GetString(arg.ApplicationMessage.Payload);
|
||||||
|
|
||||||
|
_logger.LogInformation($"\n[MQTT] 收到消息\n主题:{topic}\n内容:{payload}\n");
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> PublishAsync(string topic, string payload, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var message = new MqttApplicationMessageBuilder()
|
||||||
|
.WithTopic(topic)
|
||||||
|
.WithPayload(payload)
|
||||||
|
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await _mqttClient.PublishAsync(message, cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[MQTT] 发布消息到主题 {Topic} 失败", topic);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task StopAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await _mqttClient.DisconnectAsync();
|
||||||
|
await base.StopAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  需要实现后台服务接口一直运行。
|
||||||
636
docs/Web/Network/Summary.md
Normal file
636
docs/Web/Network/Summary.md
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
---
|
||||||
|
title: 网络编程简介
|
||||||
|
date: 2025-12-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、TCP
|
||||||
|
## 1.1 定义
|
||||||
|
  TCP(Transmission Control Protocol,传输控制协议)是互联网核心的**面向连接、可靠、字节流**的传输层协议,工作在 OSI 模型的传输层(TCP/IP 模型的传输层),基于 IP 协议提供端到端的可靠数据传输服务。它是 HTTP、HTTPS、WebSocket、MQTT 等应用层协议的底层依赖,核心目标是解决 IP 协议 “无连接、不可靠、无顺序” 的缺陷,确保数据在不可靠的网络中准确、完整、有序地传输。
|
||||||
|
|
||||||
|
## 1.2 特性
|
||||||
|
### 1.2.1 面向连接
|
||||||
|
  通信前必须完成「三次握手」建立连接,通信后通过「四次挥手」释放连接:
|
||||||
|
- 三次握手:客户端发 SYN → 服务器回 SYN+ACK → 客户端发 ACK(确保双方收发能力正常);
|
||||||
|
- 四次挥手:客户端发 FIN → 服务器回 ACK → 服务器发 FIN → 客户端回 ACK(确保数据传输完毕)。
|
||||||
|
|
||||||
|
### 1.2.2 可靠传输
|
||||||
|
- 序号与确认号:每个字节都有序号,接收方收到后回复确认号,未收到则发送方重传;
|
||||||
|
- 超时重传:发送方未在规定时间收到确认,自动重传数据;
|
||||||
|
- 流量控制:通过滑动窗口机制,防止发送方发送过快导致接收方缓冲区溢出;
|
||||||
|
- 拥塞控制:通过慢启动、拥塞避免等算法,适应网络带宽变化。
|
||||||
|
|
||||||
|
### 1.2.3 面向字节流
|
||||||
|
  TCP 将应用层数据视为连续的字节流,不保留应用层数据的边界(与 UDP 的 “数据报” 模式不同):
|
||||||
|
- 发送方:应用层数据被拆分为 TCP 报文段(Segment)发送,拆分规则由 TCP 协议决定(如 MSS 限制)。
|
||||||
|
- 接收方:将收到的报文段按顺序重组为完整的字节流,再交给应用层,确保数据顺序与发送时一致。
|
||||||
|
|
||||||
|
### 1.2.4 有序传输
|
||||||
|
  TCP 报文段头部包含 “序号(Sequence Number)” 和 “确认号(Acknowledgment Number)”:
|
||||||
|
- 序号(SN):标识发送方当前发送的字节流位置(如序号为 100 表示当前报文段的第一个字节是整个字节流的第 100 字节)。
|
||||||
|
- 确认号(ACK):标识接收方期望下次接收的字节流位置(如确认号为 200 表示已正确接收前 199 字节,下次需从 200 字节开始接收)。
|
||||||
|
- 接收方通过序号排序报文段,丢弃重复报文,确保按发送顺序交付数据。
|
||||||
|
|
||||||
|
### 1.2.5 全双工通信
|
||||||
|
  TCP 连接是双向的,双方可同时发送和接收数据,无需等待对方结束发送:
|
||||||
|
- 每个方向都有独立的发送缓冲区和接收缓冲区,以及独立的滑动窗口用于流量控制。
|
||||||
|
- 示例:客户端发送数据的同时,服务器可同步向客户端返回响应,无需等待客户端发送完毕。
|
||||||
|
|
||||||
|
## 1.3 优缺点
|
||||||
|
  优点:
|
||||||
|
1. 可靠、有序、无丢包
|
||||||
|
2. 支持流量 / 拥塞控制
|
||||||
|
3. 适用于大数据传输
|
||||||
|
|
||||||
|
  缺点:
|
||||||
|
1. 连接建立 / 释放开销大
|
||||||
|
2. 头部开销大(20-60 字节)
|
||||||
|
3. 不适合实时性要求极高的场景(如直播低延迟)
|
||||||
|
|
||||||
|
## 1.4 Python实现
|
||||||
|
  TCP服务器:
|
||||||
|
```python
|
||||||
|
import socket
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
|
||||||
|
from model.mqtt import UNKNOWN_MESSAGE
|
||||||
|
from processor.tcp_processor import TcpMessageProcessor
|
||||||
|
from config.logger_config import logger
|
||||||
|
|
||||||
|
|
||||||
|
class TCPServer:
|
||||||
|
def __init__(self):
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
self.host = os.getenv("TCP_HOST", 'localhost')
|
||||||
|
self.port = int(os.getenv("TCP_PORT", 9100))
|
||||||
|
|
||||||
|
self.max_workers = 10
|
||||||
|
self.timeout = 300
|
||||||
|
|
||||||
|
self.server_socket = None
|
||||||
|
self.running = False
|
||||||
|
self.thread_pool = ThreadPoolExecutor(max_workers=self.max_workers)
|
||||||
|
self.message_processor = TcpMessageProcessor()
|
||||||
|
|
||||||
|
# 已经连接的客户端
|
||||||
|
self.clients = {}
|
||||||
|
self.client_lock = threading.Lock()
|
||||||
|
|
||||||
|
self.server_thread = None
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, self._handle_signal)
|
||||||
|
signal.signal(signal.SIGINT, self._handle_signal)
|
||||||
|
|
||||||
|
def _handle_signal(self, signum, frame):
|
||||||
|
"""处理终止信号,触发优雅关闭"""
|
||||||
|
logger.info(f"收到信号 {signum},准备关闭服务器...")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def _start_loop(self):
|
||||||
|
"""启动服务器"""
|
||||||
|
try:
|
||||||
|
# 创建 TCP/IP socket AF_INET: IPv4地址族 SOCK_STREAM: TCP协议(面向连接)
|
||||||
|
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
# 设置SO_REUSEADDR选项,允许重用地址和端口,避免服务端重启时出现 “地址已被占用” 的错误
|
||||||
|
self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
# 绑定到指定主机和端口 0.0.0.0 表示所有主机
|
||||||
|
self.server_socket.bind((self.host, self.port))
|
||||||
|
# 设置最大等待连接数
|
||||||
|
self.server_socket.listen(5)
|
||||||
|
# 设置socket超时时间(1.0秒)
|
||||||
|
self.server_socket.settimeout(1.0)
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
logger.info(f"TCP服务器启动,监听 {self.host}:{self.port} "
|
||||||
|
f"(最大线程: {self.max_workers}, 超时: {self.timeout}s)")
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
# accept() 会阻塞直到有客户端连接
|
||||||
|
# client_socket: 与客户端通信的新socket client_address: 客户端地址(ip, port)元组
|
||||||
|
client_socket, client_address = self.server_socket.accept()
|
||||||
|
# 获取客户端IP
|
||||||
|
client_ip = client_address[0]
|
||||||
|
# 设置客户端socket超时
|
||||||
|
# 防止客户端长时间不发送数据
|
||||||
|
client_socket.settimeout(self.timeout)
|
||||||
|
logger.info(f"新连接: {client_address}")
|
||||||
|
|
||||||
|
# 存储客户端
|
||||||
|
with self.client_lock:
|
||||||
|
self.clients[client_ip] = client_socket
|
||||||
|
|
||||||
|
# 提交到线程池处理
|
||||||
|
# 提交到线程池是为了让服务器能同时服务多个客户端,而不让一个慢客户端阻塞所有其他客户端
|
||||||
|
self.thread_pool.submit(self.handle_client, client_socket, client_ip)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
if self.running:
|
||||||
|
logger.error(f"接受连接失败: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TCP服务器启动失败: {str(e)}")
|
||||||
|
finally:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.server_thread = threading.Thread(target=self._start_loop, daemon=True)
|
||||||
|
self.server_thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if not self.running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.running = False
|
||||||
|
logger.info("开始关闭服务器...")
|
||||||
|
|
||||||
|
# 移除客户端
|
||||||
|
with self.client_lock:
|
||||||
|
for client_socket in self.clients.values():
|
||||||
|
try:
|
||||||
|
client_socket.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"关闭客户端连接失败: {str(e)}")
|
||||||
|
self.clients.clear()
|
||||||
|
|
||||||
|
# 关闭线程池
|
||||||
|
self.thread_pool.shutdown(wait=True)
|
||||||
|
logger.info("所有客户端处理线程已结束")
|
||||||
|
|
||||||
|
# 关闭连接
|
||||||
|
if self.server_socket:
|
||||||
|
self.server_socket.close()
|
||||||
|
logger.info(f"服务器已关闭({self.host}:{self.port})")
|
||||||
|
|
||||||
|
def handle_client(self, client_socket, client_ip):
|
||||||
|
"""处理客户端连接"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = client_socket.recv(1024)
|
||||||
|
if not data:
|
||||||
|
logger.info(f"客户端 {client_ip} 主动断开连接")
|
||||||
|
break
|
||||||
|
|
||||||
|
message = data.decode('utf-8').strip()
|
||||||
|
logger.info(f"收到 {client_ip} 的消息: {message}")
|
||||||
|
|
||||||
|
# 放到消息处理器里面处理
|
||||||
|
response = self.message_processor.process(message)
|
||||||
|
|
||||||
|
# 回复消息
|
||||||
|
if response != UNKNOWN_MESSAGE:
|
||||||
|
client_socket.sendall(response.encode('utf-8'))
|
||||||
|
logger.info(f"回复 {client_ip}: {response}")
|
||||||
|
except socket.timeout:
|
||||||
|
logger.warning(f"客户端 {client_ip} 超时未活动")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"处理 {client_ip} 出错: {str(e)}")
|
||||||
|
finally:
|
||||||
|
# 异常情况下关闭连接
|
||||||
|
with self.client_lock:
|
||||||
|
if client_ip in self.clients:
|
||||||
|
del self.clients[client_ip]
|
||||||
|
|
||||||
|
try:
|
||||||
|
client_socket.close()
|
||||||
|
logger.info(f"客户端 {client_ip} 连接已关闭")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"关闭 {client_ip} 连接失败: {str(e)}")
|
||||||
|
|
||||||
|
def send_to_client(self, client_ip, message):
|
||||||
|
# 先获取客户端连接(加锁保护)
|
||||||
|
with self.client_lock:
|
||||||
|
client_socket = self.clients.get(client_ip)
|
||||||
|
if not client_socket:
|
||||||
|
logger.warning(f"客户端 {client_ip} 不存在或已断开连接")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 发送消息
|
||||||
|
try:
|
||||||
|
client_socket.sendall(message.encode('utf-8'))
|
||||||
|
logger.info(f"主动发送消息给 {client_ip}: {message}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"向 {client_ip} 发送消息失败: {str(e)}")
|
||||||
|
# 发送失败时移除无效连接
|
||||||
|
with self.client_lock:
|
||||||
|
if client_ip in self.clients:
|
||||||
|
del self.clients[client_ip]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
tcp_server = TCPServer()
|
||||||
|
```
|
||||||
|
|
||||||
|
  main.py:
|
||||||
|
```python
|
||||||
|
from endpoint.tcp_server import tcp_server
|
||||||
|
from config.logger_config import logger
|
||||||
|
import time
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
tcp_server.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("收到终止信号,开始关闭程序...")
|
||||||
|
finally:
|
||||||
|
tcp_server.stop()
|
||||||
|
logger.info("程序已退出")
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical(f"程序启动失败: {str(e)}", exc_info=True)
|
||||||
|
exit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
  启动tcp_server后,通过while循环防止主线程退出,从而让后台的TCP服务器线程能继续运行。
|
||||||
|
  在TCPServer中,通过while循环持续接收tcp客户端的连接,每当有一个客户端连接时,会提交到线程池中去处理,通过自定义消息处理器,将处理完的结果返回给客户端。
|
||||||
|
  TCP消息处理器:
|
||||||
|
::: code-group
|
||||||
|
```python [抽象消息处理器]
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
class AbstractDeviceMessageParser(ABC):
|
||||||
|
"""设备消息解析器基类"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def check(self, message):
|
||||||
|
"""判断当前解析器是否能处理该消息"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse(self, message):
|
||||||
|
"""解析消息内容"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def response(self, parsed_data):
|
||||||
|
"""根据解析后的数据生成响应"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
```python [示例消息处理器]
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from model.band import BandData
|
||||||
|
from model.mqtt import MqttTopic, MqttData, UNKNOWN_MESSAGE
|
||||||
|
from parser.abstract_parsers import AbstractDeviceMessageParser
|
||||||
|
import re
|
||||||
|
|
||||||
|
from utils.index import str_length_to_4hex
|
||||||
|
from config.logger_config import logger
|
||||||
|
from endpoint.mqtt_client import mqtt_client
|
||||||
|
|
||||||
|
|
||||||
|
class JuweiBandParser(AbstractDeviceMessageParser):
|
||||||
|
"""聚伟手环消息解析器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 聚伟手环消息格式: [MNYD*设备ID*内容长度*内容]
|
||||||
|
self.pattern = r'MNYD'
|
||||||
|
self.vendor = "聚伟手环"
|
||||||
|
self.tag = "MNYD"
|
||||||
|
self.device_id = ""
|
||||||
|
|
||||||
|
def check(self, message):
|
||||||
|
"""检查是否为聚伟手环的消息格式"""
|
||||||
|
return re.search(self.pattern, message) is not None
|
||||||
|
|
||||||
|
def parse(self, message):
|
||||||
|
"""解析聚伟手环消息"""
|
||||||
|
try:
|
||||||
|
parts = message.strip("[]").split("*")
|
||||||
|
|
||||||
|
self.device_id = parts[1]
|
||||||
|
content = parts[3]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'vendor': self.vendor,
|
||||||
|
'device_id': self.device_id,
|
||||||
|
'content': content,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析{self.vendor}消息出错: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def publish_vital_data(self, item, value):
|
||||||
|
mqtt_client.publish(
|
||||||
|
topic=MqttTopic.JuWei_Band_Post.value,
|
||||||
|
payload=MqttData(
|
||||||
|
deviceIp="",
|
||||||
|
deviceId=self.device_id,
|
||||||
|
payload=BandData(item=item, value=value).model_dump_json(),
|
||||||
|
).model_dump_json())
|
||||||
|
|
||||||
|
def response(self, parsed_data):
|
||||||
|
"""生成聚伟手环的响应消息"""
|
||||||
|
if not parsed_data:
|
||||||
|
return UNKNOWN_MESSAGE
|
||||||
|
|
||||||
|
# 处理不同命令
|
||||||
|
content = parsed_data['content']
|
||||||
|
|
||||||
|
parts = content.split(",", 1)
|
||||||
|
content_tag = parts[0] if len(parts) > 0 else ""
|
||||||
|
content_value = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
replay = UNKNOWN_MESSAGE
|
||||||
|
|
||||||
|
logger.info(f"解析{content_tag}消息")
|
||||||
|
|
||||||
|
match content_tag:
|
||||||
|
# PING消息 [MNYD*334588000000156*0004*PING]
|
||||||
|
case "PING":
|
||||||
|
replay = "PING,1"
|
||||||
|
# 日期,步数,翻滚次数,电量百分数,里程数(km)
|
||||||
|
# [MNYD*334588000000156*0014*KA,120414,50,100,100,100.12]
|
||||||
|
case "KA":
|
||||||
|
replay = content_tag
|
||||||
|
# 位置数据上报
|
||||||
|
# case "UD":
|
||||||
|
# replay = ""
|
||||||
|
# 报警数据上报
|
||||||
|
# [MNYD*334588000000156*00CD*AL,180916,064153,A,22.570512,N,113.8623267,
|
||||||
|
# E,0.00,154.8,0.0,11,100,100,0,0,00100018,7,0,460,1,9529,
|
||||||
|
# 21809,155,9529,21242,132,9529,21405,131,9529,63554,131,9529,
|
||||||
|
# 63555,130,9529,63556,118,9529,21869,116,0,12.4]
|
||||||
|
case "AL":
|
||||||
|
replay = content_tag
|
||||||
|
# 获取服务器端时间
|
||||||
|
# [MNYD*YYYYYYYYYYYYYYY*LEN*LGZONE]
|
||||||
|
case "LGZONE":
|
||||||
|
now = datetime.now()
|
||||||
|
current_date = now.date().strftime("%Y-%m-%d")
|
||||||
|
current_time = now.time().strftime("%H:%M:%S")
|
||||||
|
replay = f"{content_tag},+8,{current_time},{current_date}"
|
||||||
|
# 请求位置数据 TODO
|
||||||
|
case "WG":
|
||||||
|
replay = content_tag
|
||||||
|
# 请求电话本设置信息 TODO
|
||||||
|
case "PHLQ":
|
||||||
|
replay = "PHL"
|
||||||
|
# 请求SOS设置信息 TODO
|
||||||
|
case "SOS":
|
||||||
|
replay = content_tag
|
||||||
|
# 终端心率上传
|
||||||
|
case "heart":
|
||||||
|
self.publish_vital_data(content_tag, content_value)
|
||||||
|
replay = content_tag
|
||||||
|
# 上传体温数据 [MNYD*334588000000156*0009*temp,36.2]
|
||||||
|
case "temp":
|
||||||
|
self.publish_vital_data(content_tag, content_value)
|
||||||
|
replay = content_tag
|
||||||
|
# 上传血压数据 [MNYD*334588000000156*000C*blood,150,70]
|
||||||
|
case "blood":
|
||||||
|
self.publish_vital_data(content_tag, content_value)
|
||||||
|
replay = content_tag
|
||||||
|
# 上传血氧数据 [MNYD*334588000000156*0009*oxygen,97]
|
||||||
|
case "oxygen":
|
||||||
|
self.publish_vital_data(content_tag, content_value)
|
||||||
|
replay = content_tag
|
||||||
|
# 上传睡眠数据报告
|
||||||
|
case "SLEEPRPT":
|
||||||
|
replay = "SLEEP"
|
||||||
|
case _:
|
||||||
|
logger.info("不需要回复")
|
||||||
|
return UNKNOWN_MESSAGE
|
||||||
|
|
||||||
|
return f"[{self.tag}*{parsed_data['device_id']}*{str_length_to_4hex(replay)}*{replay}]"
|
||||||
|
```
|
||||||
|
|
||||||
|
```python [消息处理器]
|
||||||
|
from model.mqtt import UNKNOWN_MESSAGE
|
||||||
|
from parser.juwei_band_parser import JuweiBandParser
|
||||||
|
from config.logger_config import logger
|
||||||
|
|
||||||
|
class TcpMessageProcessor:
|
||||||
|
"""消息处理器,负责将消息路由到正确的设备解析器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 注册所有支持的设备解析器
|
||||||
|
self.parsers = [JuweiBandParser()]
|
||||||
|
|
||||||
|
def process(self, message):
|
||||||
|
"""处理消息,返回响应"""
|
||||||
|
# 尝试找到能处理该消息的解析器
|
||||||
|
for parser in self.parsers:
|
||||||
|
if parser.check(message):
|
||||||
|
parsed_data = parser.parse(message)
|
||||||
|
if parsed_data:
|
||||||
|
logger.info(f"处理{parsed_data['vendor']}消息: {message}")
|
||||||
|
return parser.response(parsed_data)
|
||||||
|
|
||||||
|
# 没有找到合适的解析器
|
||||||
|
logger.warning(f"未识别的消息格式: {message}")
|
||||||
|
return UNKNOWN_MESSAGE
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
|
||||||
|
# 二、HTTP
|
||||||
|
## 2.1 定义
|
||||||
|
  HTTP(HyperText Transfer Protocol,超文本传输协议)是互联网的核心协议之一,用于**客户端(如浏览器、App)与服务器之间的通信**,是万维网(WWW)数据交换的基础。它定义了请求 / 响应的格式、传输规则和状态码等核心机制,支持从简单文本到复杂多媒体(图片、视频、文件)的传输,也是现代 Web 应用的底层通信标准。
|
||||||
|
|
||||||
|
## 2.2 特性
|
||||||
|
### 2.2.1 请求 - 响应模式
|
||||||
|
  通信由客户端主动发起请求,服务器接收后处理并返回响应,不存在服务器主动向客户端推送数据的情况(HTTP/2 引入 Server Push 扩展,可主动推送关联资源)。
|
||||||
|
  一次完整通信流程:**客户端建立连接 → 发送请求 → 服务器处理 → 返回响应 → 连接关闭**(HTTP/1.1 默认开启长连接 Keep-Alive)。
|
||||||
|
|
||||||
|
### 2.2.2 无状态
|
||||||
|
  **服务器不会保存客户端的会话状态**(如登录状态、浏览记录),每次请求都是独立的,服务器无法通过协议本身识别连续请求是否来自同一客户端。通过 Cookie、Session、Token(如 JWT)等机制补充状态管理。
|
||||||
|
|
||||||
|
## 2.3 版本
|
||||||
|
### 2.3.1 HTTP/1.0(1996 年)
|
||||||
|
- 基础版本,支持 GET、POST、HEAD 三种请求方法。
|
||||||
|
- 每次请求都需要建立新的 TCP 连接(短连接),连接建立和关闭的开销大,性能较低。
|
||||||
|
- 不支持长连接、管线化请求,仅支持简单的文本传输。
|
||||||
|
|
||||||
|
### 2.3.2 HTTP/1.1(1999 年)
|
||||||
|
- 默认开启 长连接(Keep-Alive):同一 TCP 连接可处理多个请求,减少连接开销。
|
||||||
|
- 支持 管线化请求:客户端可连续发送多个请求,无需等待前一个响应返回(部分浏览器未完全支持)。
|
||||||
|
- 新增请求方法:PUT、DELETE、OPTIONS、TRACE、CONNECT。
|
||||||
|
- 支持 chunked 编码(分块传输)、缓存控制(Cache-Control)、内容协商等核心功能。
|
||||||
|
|
||||||
|
### 2.3.3 HTTP/2(2015 年)
|
||||||
|
  基于 SPDY 协议优化,核心目标是提升性能:
|
||||||
|
- 二进制帧传输:将请求 / 响应数据拆分为二进制帧,而非 HTTP/1.x 的文本格式,解析效率更高。
|
||||||
|
- 多路复用:同一 TCP 连接中可并发处理多个请求(通过帧的 Stream ID 区分),解决 HTTP/1.1 的 “队头阻塞” 问题。
|
||||||
|
- 服务器推送(Server Push):服务器可主动向客户端推送关联资源(如 HTML 引用的 CSS/JS),减少客户端请求次数。
|
||||||
|
- 头部压缩(HPACK):对请求头和响应头进行压缩,减少传输体积(HTTP/1.x 头部重复传输开销大)。
|
||||||
|
|
||||||
|
### 2.3.4 HTTP/3(2022 年)
|
||||||
|
  基于 SPDY 协议优化,核心目标是提升性能:
|
||||||
|
- 解决 TCP 队头阻塞:UDP 无连接特性,单个流的阻塞不影响其他流。
|
||||||
|
- 更快的连接建立:QUIC 集成 TLS 1.3,减少握手次数(1-RTT 甚至 0-RTT 建立连接)。
|
||||||
|
- 更好的移动网络支持:支持连接迁移(如手机切换 WiFi/4G 时,连接不中断)。
|
||||||
|
|
||||||
|
## 2.4 组成
|
||||||
|
### 2.4.1 请求消息(Request)
|
||||||
|
  客户端向服务器发送的请求格式,由 请求行、请求头、空行、请求体 四部分组成:
|
||||||
|
```http
|
||||||
|
GET /api/courses/1 HTTP/1.1 # 请求行
|
||||||
|
Host: lms.example.com # 请求头(键值对形式)
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Accept: application/json
|
||||||
|
User-Agent: Mozilla/5.0 (Chrome/120.0.0.0)
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"username": "admin", "password": "123456"} # 请求体(可选,POST/PUT 等方法常用)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 请求行
|
||||||
|
- 请求方法:表示请求的操作类型(常用方法如下表)。
|
||||||
|
- 请求 URI:指定服务器上的资源路径。
|
||||||
|
- 协议版本:如 HTTP/1.1、HTTP/2。
|
||||||
|
|
||||||
|
#### 请求头
|
||||||
|
  描述请求的附加信息,常用字段:
|
||||||
|
- Host:目标服务器域名(如 lms.example.com),HTTP/1.1 必选字段。
|
||||||
|
- User-Agent:客户端身份标识(如浏览器版本、App 名称)。
|
||||||
|
- Accept:客户端可接收的响应数据格式(如 application/json、text/html)。
|
||||||
|
- Content-Type:请求体的数据格式(如 application/json、multipart/form-data(文件上传))。
|
||||||
|
- Authorization:身份认证信息(如 Token、Basic Auth)。
|
||||||
|
- Cookie:客户端存储的会话信息(如登录态 Cookie)。
|
||||||
|
- Cache-Control:缓存控制策略(如 no-cache 表示不使用缓存)。
|
||||||
|
|
||||||
|
#### 请求体
|
||||||
|
  可选部分,仅在需要向服务器提交数据时使用(如 POST 提交表单、PUT 更新资源),数据格式由 Content-Type 指定:
|
||||||
|
- 表单数据:application/x-www-form-urlencoded(如 username=admin&password=123)。
|
||||||
|
- JSON 数据:application/json(如 {"key": "value"})。
|
||||||
|
- 文件上传:multipart/form-data(如 LMS 系统的作业文件上传)。
|
||||||
|
- 纯文本:text/plain。
|
||||||
|
|
||||||
|
### 2.4.2 响应消息(Response)
|
||||||
|
  服务器向客户端返回的响应格式,由 状态行、响应头、空行、响应体 四部分组成:
|
||||||
|
```http
|
||||||
|
HTTP/1.1 200 OK # 状态行
|
||||||
|
Server: Nginx
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 128
|
||||||
|
Set-Cookie: sessionId=abc123; Path=/
|
||||||
|
|
||||||
|
{"code": 200, "message": "success", "data": {"id": 1, "name": "Vue3 实战课程"}} # 响应体
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 状态行
|
||||||
|
- 协议版本:如 HTTP/1.1。
|
||||||
|
- 状态码:表示请求处理结果。
|
||||||
|
- 状态短语:状态码的文字描述(如 OK、Not Found)。
|
||||||
|
|
||||||
|
#### HTTP状态码
|
||||||
|
| 分类 | 状态码范围 | 含义 | 常用码 |
|
||||||
|
|------|------------|-----------------------|-------------------------|
|
||||||
|
| 1xx | 100-199 | 信息性响应(临时响应)| 100 Continue(预检通过)|
|
||||||
|
| 2xx | 200-299 | 成功响应 | 200 OK(成功)、201 Created(资源创建成功)、204 No Content(成功无响应体)|
|
||||||
|
| 3xx | 300-399 | 重定向 | 301 永久重定向、302 临时重定向、304 Not Modified(缓存有效)|
|
||||||
|
| 4xx | 400-499 | 客户端错误 | 400 Bad Request(请求参数错误)、401 Unauthorized(未认证)、403 Forbidden(权限不足)、404 Not Found(资源不存在)、405 Method Not Allowed(请求方法不支持)|
|
||||||
|
| 5xx | 500-599 | 服务器错误 | 500 Internal Server Error(服务器内部错误)、502 Bad Gateway(网关错误)、503 Service Unavailable(服务不可用)、504 Gateway Timeout(网关超时)|
|
||||||
|
|
||||||
|
#### 响应头
|
||||||
|
  描述响应的附加信息,常用字段:
|
||||||
|
- Server:服务器软件标识(如 Nginx、Tomcat)。
|
||||||
|
- Content-Type:响应体的数据格式(如 application/json、text/html)。
|
||||||
|
- Content-Length:响应体的字节大小。
|
||||||
|
- Set-Cookie:服务器向客户端设置 Cookie(如登录态、会话 ID)。
|
||||||
|
- Cache-Control:缓存控制策略(如 max-age=3600 表示缓存 1 小时)。
|
||||||
|
- Access-Control-Allow-Origin:跨域资源共享(CORS)配置(如 * 表示允许所有域名跨域)。
|
||||||
|
- Location:重定向目标地址(3xx 状态码必选)。
|
||||||
|
|
||||||
|
#### 响应体
|
||||||
|
  服务器返回的核心数据,数据格式由 Content-Type 指定,常见格式:
|
||||||
|
- JSON:前后端分离项目首选(如 {"code": 200, "data": [...]})。
|
||||||
|
- HTML:传统 Web 页面(如静态网页、JSP 页面)。
|
||||||
|
- 图片 / 视频:二进制流(如 image/jpeg、video/mp4)。
|
||||||
|
- 纯文本:text/plain。
|
||||||
|
|
||||||
|
# 三、WebSocket
|
||||||
|
## 3.1 定义
|
||||||
|
  WebSocket 是一种**全双工、双向、持久化的网络通信协议**(属于应用层协议),由 HTML5 规范定义,专门解决 HTTP 协议无法实现服务器主动向客户端推送数据的问题。
|
||||||
|
  它的核心特点是:**一旦客户端与服务器建立连接,双方就可以在这个连接上实时、双向地发送数据**,无需像 HTTP 那样每次通信都由客户端发起请求,非常适合实时通信场景(如聊天、直播弹幕、实时数据监控、在线协作等)。
|
||||||
|
|
||||||
|
| 特性 | HTTP | WebSocket |
|
||||||
|
|---------------------|-------------------------------|-------------------------------|
|
||||||
|
| 通信方向 | 单向(客户端请求→服务器响应)| 全双工(双方可同时发数据)|
|
||||||
|
| 连接类型 | 短连接 / 长连接(需重复请求) | 持久连接(一次建立,持续通信) |
|
||||||
|
| 数据传输效率 | 每次请求带大量头部信息,效率低 | 连接建立后仅传输数据,开销小 |
|
||||||
|
| 服务器主动推送 | 不支持(HTTP/2 的 Server Push 仅能推送资源,非实时数据) | 原生支持,可主动向客户端发数据 |
|
||||||
|
| 协议标识 | http:// / https:// | ws:// / wss://(加密版)|
|
||||||
|
|
||||||
|
## 3.2 特性
|
||||||
|
### 3.2.1 握手过程
|
||||||
|
1. 客户端发送 HTTP 请求,请求头包含 Upgrade: websocket 和 Connection: Upgrade(表示要升级为 WebSocket 协议);
|
||||||
|
2. 服务器响应 101 Switching Protocols,握手成功,连接转为 WebSocket 持久连接;
|
||||||
|
3. 后续通信不再使用 HTTP 格式,而是 WebSocket 帧格式(二进制 / 文本)。
|
||||||
|
|
||||||
|
  请求头:
|
||||||
|
```http
|
||||||
|
GET /chat HTTP/1.1
|
||||||
|
Host: example.com
|
||||||
|
Upgrade: websocket # 核心:请求升级为WebSocket协议
|
||||||
|
Connection: Upgrade # 核心:表示连接要升级
|
||||||
|
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== # 客户端生成的随机密钥,用于验证
|
||||||
|
Sec-WebSocket-Version: 13 # 指定WebSocket版本(主流为13)
|
||||||
|
Origin: https://example.com # 跨域验证
|
||||||
|
```
|
||||||
|
|
||||||
|
  响应头:
|
||||||
|
```http
|
||||||
|
HTTP/1.1 101 Switching Protocols
|
||||||
|
Upgrade: websocket
|
||||||
|
Connection: Upgrade
|
||||||
|
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= # 服务器加密后的密钥,客户端验证
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2.2 全双工通信
|
||||||
|
  此时 HTTP 连接已升级为 WebSocket 连接,双方可以通过这个连接实时、双向地发送数据。数据传输采用帧(Frame) 格式,支持文本数据和二进制数据(如图片、视频流)。
|
||||||
|
|
||||||
|
### 3.2.3 帧格式
|
||||||
|
  WebSocket 数据以帧为单位传输,帧头包含操作码(文本帧 0x01、二进制帧 0x02、关闭帧 0x08 等)、掩码(客户端发送数据需掩码,服务器无需);
|
||||||
|
|
||||||
|
### 3.2.4 无同源限制
|
||||||
|
  WebSocket 不遵循同源策略(但服务器可通过 Origin 头限制跨域);
|
||||||
|
|
||||||
|
### 3.2.5 心跳机制
|
||||||
|
  通过 Ping/Pong 帧维持连接(避免网络设备断开空闲连接,如 LMS 系统需定期发送 Ping 帧,服务器回复 Pong 帧)。
|
||||||
|
|
||||||
|
# 四、MQTT
|
||||||
|
## 4.1 定义
|
||||||
|
  MQTT(Message Queuing Telemetry Transport,消息队列遥测传输)是一种**轻量级、低带宽、低功耗的发布 / 订阅(Publish/Subscribe)模式物联网(IoT)通信协议**,由 IBM 于 1999 年设计,核心目标是解决受限设备(如传感器、嵌入式设备)和低带宽、不稳定网络环境下的高效数据传输问题。
|
||||||
|
|
||||||
|
## 4.2 架构
|
||||||
|
- 发布者(Publisher):发送消息的设备 / 服务;
|
||||||
|
- 订阅者(Subscriber):接收消息的设备 / 服务;
|
||||||
|
- broker(代理服务器):核心中间件,接收发布者的消息,根据「主题(Topic)」转发给订阅者(如 EMQ X、Mosquitto、RabbitMQ 支持 MQTT 插件);
|
||||||
|
- 主题(Topic):消息的分类标识。
|
||||||
|
|
||||||
|
## 4.3 特性
|
||||||
|
### 4.3.1 QoS(服务质量)等级
|
||||||
|
- QoS 0(最多一次):消息发送一次,不保证送达;
|
||||||
|
- QoS 1(至少一次):消息至少送达一次,可能重复;
|
||||||
|
- QoS 2(恰好一次):消息仅送达一次,最可靠。
|
||||||
|
|
||||||
|
### 4.3.2 轻量级
|
||||||
|
  头部开销极小(固定头部仅 2 字节),消息体支持二进制 / 文本,适合低带宽场景
|
||||||
|
|
||||||
|
### 4.3.3 保留消息(Retained Message)
|
||||||
|
  broker 保存某个主题的最后一条消息,新订阅者订阅后立即收到该消息
|
||||||
|
|
||||||
|
### 4.3.4 遗嘱消息(Will Message)
|
||||||
|
  客户端异常断开时,broker 自动发送预设消息
|
||||||
|
|
||||||
|
### 4.3.5 清洁会话(Clean Session)
|
||||||
|
  客户端断开连接后,broker 是否保留订阅信息和未送达消息
|
||||||
|
|
||||||
|
## 4.4 组成
|
||||||
|
  MQTT 消息由 固定头部(Fixed Header)、可变头部(Variable Header)、负载(Payload) 三部分组成:
|
||||||
|
| 部分 | 作用 |
|
||||||
|
|--------------|----------------------------------------------------------------------|
|
||||||
|
| 固定头部 | 必选,2 字节起,包含消息类型(如发布、订阅)、QoS 等级、是否保留消息等标识。 |
|
||||||
|
| 可变头部 | 可选,仅部分消息类型(如发布、订阅)需要,包含主题名、消息 ID 等信息。 |
|
||||||
|
| 负载 | 可选,消息的实际内容(如 JSON 字符串、二进制数据),例如 {"temperature": 25}。 |
|
||||||
79
docs/Web/Others/AList.md
Normal file
79
docs/Web/Others/AList.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
title: AList简介和使用
|
||||||
|
date: 2025-12-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  [AList](https://alistgo.com/zh/)是一个支持多种存储,支持网页浏览和 WebDAV 的文件列表程序,由 gin 和 Solidjs 驱动。
|
||||||
|
|
||||||
|
# 二、安装
|
||||||
|
## 2.1 Docker安装
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
alist:
|
||||||
|
image: xhofe/alist:v3.41.0
|
||||||
|
container_name: alist
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./data:/opt/alist/data
|
||||||
|
- /srv:/srv
|
||||||
|
ports:
|
||||||
|
- "5244:5244"
|
||||||
|
environment:
|
||||||
|
- PUID=0
|
||||||
|
- PGID=0
|
||||||
|
```
|
||||||
|
|
||||||
|
  这里的srv为宿主机的系统服务数据目录,添加该映射,可以让Alist能够访问宿主机/srv目录下的本地文件。
|
||||||
|
|
||||||
|
# 三、挂载
|
||||||
|
## 3.1 挂载本地存储
|
||||||
|
  打开控制台,选择存储->添加->本地存储,设置挂载路径(自定义,如`/local`),选择根文件夹路径为`/srv`,点击添加。
|
||||||
|
  这里的`/srv`文件夹为`docker`里面的文件夹路径,一般只需要更改`docker compose`文件里面的`/srv:/srv`前面的`srv`路径为宿主机路径即可,后面的`/srv`路径不要更改。
|
||||||
|
  可以在宿主机的`/srv`目录中添加文件,查看AList的主页是否可以正确显示。
|
||||||
|
|
||||||
|
# 四、API
|
||||||
|
  在线调试地址:[Alist-Public](https://alist-public.apifox.cn/)
|
||||||
|
|
||||||
|
## 4.1 登录
|
||||||
|
```JavaScript
|
||||||
|
const username = ''
|
||||||
|
const password = ''
|
||||||
|
|
||||||
|
// 1. 登录获取token
|
||||||
|
const login = await axios.post(`${AListUrl}/api/auth/login`, {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
|
||||||
|
const token = login.data.data.token
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.2 上传文件
|
||||||
|
```JavaScript
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('file', options.file)
|
||||||
|
|
||||||
|
const filePath = `${AListPath}/${options.file.name}`
|
||||||
|
|
||||||
|
await axios({
|
||||||
|
method: 'put',
|
||||||
|
url: `${AListUrl}/api/fs/form`,
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
'File-Path': filePath
|
||||||
|
},
|
||||||
|
data
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.3 下载文件
|
||||||
|
  在AList的文件路径中加上前缀`/p/`。例如文件路径为`/local/Test/test.mp4`,则下载路径为`/p/local/Test/test.mp4`。
|
||||||
|
::: tip
|
||||||
|
以上为相对路径,使用时加上相应的ip和端口号。
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
如果出现无法访问的情况,需要在设置->全局里面,将`签名所有`关闭。
|
||||||
|
签名所有:Alist是否将为这个存储下的所有文件请求生成带签名的临时链接,如果开启则需要带上校验码签名,否则不需要。
|
||||||
|
:::
|
||||||
154
docs/Web/Others/ImageProcessor.md
Normal file
154
docs/Web/Others/ImageProcessor.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
---
|
||||||
|
title: 图片处理服务
|
||||||
|
date: 2026-06-04
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  基于 Node.js + Sharp 实现的轻量级图片处理服务。
|
||||||
|
  [sharp](https://sharp.pixelplumbing.com/)是 Node.js 下非常常用的高性能图像处理库,主要用来缩放、裁剪、旋转、格式转换、压缩图片等,速度快、内存占用低。
|
||||||
|
  支持路径+参数的形式实时压缩图片,例如`ip:port/a.png?q=80&w=1024&h=768`
|
||||||
|
|
||||||
|
# 二、核心方法
|
||||||
|
## 2.1 根据参数调整图片
|
||||||
|
```JavaScript
|
||||||
|
const sharp = require('sharp');
|
||||||
|
|
||||||
|
// 压缩配置
|
||||||
|
const compressOptions = {
|
||||||
|
quality: 0.7,
|
||||||
|
maxHeight: 800,
|
||||||
|
maxWidth: 600
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理图片压缩和调整大小
|
||||||
|
* @param {string} imagePath 图片路径
|
||||||
|
* @param {Object} query 查询参数
|
||||||
|
* @returns {Promise<Buffer>} 处理后的图片Buffer
|
||||||
|
*/
|
||||||
|
async function processImage(imagePath, query) {
|
||||||
|
let sharpInstance = sharp(imagePath).rotate();
|
||||||
|
|
||||||
|
// 处理宽高参数
|
||||||
|
let targetWidth = parseInt(query.w) || parseInt(query.width);
|
||||||
|
let targetHeight = parseInt(query.h) || parseInt(query.height);
|
||||||
|
|
||||||
|
// 如果没有指定宽高,使用配置的默认最大宽高
|
||||||
|
if (!targetWidth && !targetHeight) {
|
||||||
|
targetWidth = compressOptions.maxWidth;
|
||||||
|
targetHeight = compressOptions.maxHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了宽高,进行调整大小处理
|
||||||
|
if (targetWidth || targetHeight) {
|
||||||
|
const resizeOptions = {
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
fit: sharp.fit.inside, // 保持宽高比,图片会完整显示在指定区域内
|
||||||
|
withoutEnlargement: true // 不放大比原始尺寸小的图片
|
||||||
|
};
|
||||||
|
sharpInstance = sharpInstance.resize(resizeOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理质量参数(优先使用查询参数,否则使用配置的默认值)
|
||||||
|
const quality = query.q ? Math.max(1, Math.min(100, parseInt(query.q))) :
|
||||||
|
Math.round(compressOptions.quality * 100);
|
||||||
|
const finalQuality = Math.max(1, Math.min(100, quality));
|
||||||
|
|
||||||
|
// 转换为JPEG格式并压缩
|
||||||
|
return sharpInstance.jpeg({quality: finalQuality, mozjpeg: true}).toBuffer();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  核心方法:[resize](https://sharp.nodejs.cn/api-resize/)、[jpeg](https://sharp.nodejs.cn/api-output/#jpeg)、[toBuffer](https://sharp.nodejs.cn/api-output/#tobuffer)。
|
||||||
|
|
||||||
|
## 2.2 返回图片数据
|
||||||
|
```JavaScript
|
||||||
|
/**
|
||||||
|
* 发送响应
|
||||||
|
* @param {ServerResponse} res 响应对象
|
||||||
|
* @param {number} statusCode 状态码
|
||||||
|
* @param {string} message 信息
|
||||||
|
*/
|
||||||
|
function sendResponse(res, statusCode, message) {
|
||||||
|
res.writeHead(statusCode, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return sendResponse(res, 405, '仅支持GET请求');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = url.parse(req.url, true);
|
||||||
|
const requestPath = parsedUrl.pathname;
|
||||||
|
const query = parsedUrl.query;
|
||||||
|
|
||||||
|
// 过滤根路径请求(避免直接访问服务根目录)
|
||||||
|
if (requestPath === '/') {
|
||||||
|
return sendResponse(res, 200, '图片处理服务已启动!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedFilePath = requestPath.slice(1);
|
||||||
|
const fullImagePath = path.join(IMAGE_BASE_DIR, requestedFilePath);
|
||||||
|
|
||||||
|
// 安全检查:防止路径遍历
|
||||||
|
if (!isSafePath(fullImagePath, IMAGE_BASE_DIR)) {
|
||||||
|
return sendResponse(res, 403, '访问拒绝:非法路径(路径遍历攻击)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullImagePath)) {
|
||||||
|
return sendResponse(res, 404, `图片不存在:${requestedFilePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有压缩参数,直接返回原图
|
||||||
|
if (!query.q && !query.w && !query.width && !query.h && !query.height) {
|
||||||
|
const fileStream = fs.createReadStream(fullImagePath);
|
||||||
|
const ext = path.extname(fullImagePath).slice(1);
|
||||||
|
res.writeHead(200, {'Content-Type': `image/${ext}`});
|
||||||
|
// 流式传输文件(适合大文件)
|
||||||
|
fileStream.pipe(res);
|
||||||
|
fileStream.on('error', (err) => {
|
||||||
|
sendResponse(res, 500, `读取文件失败:${err.message}`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理图片压缩和调整大小
|
||||||
|
const imageBuffer = await processImage(fullImagePath, query);
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
'Content-Length': imageBuffer.length,
|
||||||
|
'Cache-Control': 'public, max-age=86400' // 缓存1天
|
||||||
|
});
|
||||||
|
res.end(imageBuffer);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('服务器异常:', error);
|
||||||
|
sendResponse(res, 500, `服务器内部错误:${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
  原生Node.js中res用法:
|
||||||
|
```js
|
||||||
|
res.writeHead(statusCode, [statusMessage], [headers]);
|
||||||
|
res.end(message)
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
Express中req(请求对象)常用用法:
|
||||||
|
1. 获取请求路径参数:`/user/:id`->`req.params.id`
|
||||||
|
2. 获取查询参数(?key=value):`/search?name=tom&age=18`->`req.query.name`
|
||||||
|
3. 获取请求体:`req.body.username`
|
||||||
|
4. 获取请求头:`req.headers['user-agent']`
|
||||||
|
|
||||||
|
Express中res(响应对象)常用用法:
|
||||||
|
1. 返回普通文本:`res.send('Hello Express');`
|
||||||
|
2. 返回JSON:`res.json({ success: true, data: {} });`
|
||||||
|
3. 设置状态码:`res.status(404).json({ msg: 'Not Found' });`
|
||||||
|
4. 设置响应头:`res.set('Content-Type', 'text/plain');`
|
||||||
|
5. 结束响应:`res.end()`
|
||||||
|
:::
|
||||||
82
docs/Web/Others/InfluxDB.md
Normal file
82
docs/Web/Others/InfluxDB.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: InfluxDB简介和使用
|
||||||
|
date: 2026-05-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  InfluxDB 是一个开源的时序数据库(Time Series Database, TSDB),由 InfluxData 公司开发,专门用于存储、查询和处理时间序列数据(按时间顺序记录的数据点)。它在监控、物联网(IoT)、实时分析等场景中广泛应用,是时序数据领域的流行解决方案之一。
|
||||||
|
  InfluxDB 3.0 是一次底层的彻底重构(内核代号 IOx),核心代码从 Go 改写为 Rust,并从自研的 TSM 引擎转向基于 Apache Arrow 生态的开放架构。
|
||||||
|
|
||||||
|
# 二、特点
|
||||||
|
1. Rust 全新重写:更快、更省内存、更稳
|
||||||
|
2. 支持无限标签基数:不再怕设备 ID、用户 ID 这类高基数场景
|
||||||
|
3. 原生支持标准 SQL:不用只学 InfluxQL,普通 SQL 直接查
|
||||||
|
4. 底层基于 Arrow + Parquet:列式存储、压缩率高、查询快
|
||||||
|
5. 原生对接对象存储:支持 S3/OSS,冷热数据自动分层,可存海量历史数据
|
||||||
|
|
||||||
|
# 三、概念
|
||||||
|
1. 库 Database:相当于 MySQL 的库,隔离业务数据
|
||||||
|
2. 测量 Measurement:相当于 MySQL 的表(比如 cpu、温度传感器)
|
||||||
|
3. 标签 Tag:维度、索引字段(字符串,如设备 id、地区,用来筛选分组)
|
||||||
|
4. 字段 Field:实际指标值(数值,如温度、CPU 使用率,不做索引)
|
||||||
|
5. 时间 Time:自带时间戳,时序数据唯一主键、按时间排序
|
||||||
|
|
||||||
|
# 四、安装
|
||||||
|
## 4.1 Docker安装
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
influxdb3-core:
|
||||||
|
image: influxdb:3.9.1-core
|
||||||
|
container_name: influxdb3-core
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- influx-network
|
||||||
|
ports:
|
||||||
|
- "8181:8181"
|
||||||
|
volumes:
|
||||||
|
- ./influxdb3/data:/var/lib/influxdb3/data
|
||||||
|
- ./influxdb3/plugins:/var/lib/influxdb3/plugins
|
||||||
|
user: root
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
command: >
|
||||||
|
influxdb3 serve
|
||||||
|
--node-id=node0
|
||||||
|
--object-store=file
|
||||||
|
--data-dir=/var/lib/influxdb3/data
|
||||||
|
--plugin-dir=/var/lib/influxdb3/plugins
|
||||||
|
|
||||||
|
influxdb3-explorer:
|
||||||
|
image: influxdata/influxdb3-ui:1.6.3
|
||||||
|
container_name: influxdb3-explorer
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- influx-network
|
||||||
|
ports:
|
||||||
|
- "8600:80"
|
||||||
|
- "8888:8888"
|
||||||
|
volumes:
|
||||||
|
- ./config:/app-root/config:ro
|
||||||
|
- ./db:/db:rw
|
||||||
|
- ./ssl:/etc/nginx/ssl:ro
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
command: --mode=admin
|
||||||
|
|
||||||
|
networks:
|
||||||
|
influx-network:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.2 token生成
|
||||||
|
  进入influxdb3-core容器,输入命令获取token:
|
||||||
|
```bash
|
||||||
|
influxdb3 create token --admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.3 可视化页面
|
||||||
|
  打开ip:8600(具体端口看docker的配置),选择`Configure->Servers`,点击`Connect Your First Server`,输入InfluxDB服务器信息进行配置。
|
||||||
|
  在`Query Data->Data Explorer`中查看数据。
|
||||||
|
|
||||||
|
# 五、使用
|
||||||
|
## 5.1 SpringBoot使用
|
||||||
181
docs/Web/Others/Meilisearch.md
Normal file
181
docs/Web/Others/Meilisearch.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
---
|
||||||
|
title: Meilisearch简介和使用
|
||||||
|
date: 2026-08-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  [Meilisearch](https://meilisearch.com.cn/docs/home) 是一个基于 Rust 构建的开源全文搜索引擎,面向“用户侧搜索体验”设计。通过 REST API 接收 JSON 文档并建立倒排索引,提供 sub‑50ms 的搜索响应、默认拼写容错、前缀搜索、相关度排序与过滤分面能力,适用于站内搜索、商品检索、文档/知识库搜索、Cmd+K、RAG 检索层等场景。
|
||||||
|
|
||||||
|
# 二、核心概念
|
||||||
|
| 概念 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| **Index(索引)** | 一组共享设置的文档集合,等价于“表”,如 `products`、`articles` |
|
||||||
|
| **Document(文档)** | JSON 对象,是搜索基本单位,必须包含主键 |
|
||||||
|
| **Primary Key(主键)** | 文档唯一标识,默认推断 `id`,相同主键写入即覆盖(upsert) |
|
||||||
|
| **Task(任务)** | 所有写操作异步执行,返回 `taskUid`,状态:enqueued → processing → succeeded / failed |
|
||||||
|
| **Settings** | Index 级配置:searchable / filterable / sortable / displayed attributes、ranking rules、synonyms、stop words、tokenizer |
|
||||||
|
| **Ranking Rules** | 默认相关度规则:`words → typo → proximity → attribute → sort → exactness`,可定制 |
|
||||||
|
| **Master Key** | 实例级最高密钥(≥16 bytes),用于生成其他 Key,**禁止下发前端** |
|
||||||
|
| **API Key** | Search / Admin / Read-Only Admin / Chat,按 action + index 粒度授权 |
|
||||||
|
| **Tenant Token** | 由 API Key 派生,搜索时注入 filter,实现多租户行级隔离 |
|
||||||
|
| **Embedder** | 内建向量化配置,支持 OpenAI / HuggingFace / Ollama / Cohere / 自定义 REST,用于 Hybrid Search |
|
||||||
|
|
||||||
|
# 三、与Elasticsearch比较
|
||||||
|
| 维度 | Meilisearch | Elasticsearch |
|
||||||
|
|---|---|---|
|
||||||
|
| 核心引擎 | Rust + LMDB(mmap,单机 ACID) | Java + Apache Lucene(distributed) |
|
||||||
|
| 部署形态 | 单进程 / 单容器 | 多节点集群(shard + replica + 选主) |
|
||||||
|
| 查询延迟 | **sub-50ms 默认**(前缀/typo 开箱) | sub-100ms~200ms,需调优达到极低 P99 |
|
||||||
|
| 索引吞吐 | 中(异步 task 队列,单机受限) | 高(bulk + 多 shard 并行) |
|
||||||
|
| 近实时性 | 亚秒级 task 完成即可见 | refresh_interval 默认 1s |
|
||||||
|
| 内存占用 | ~200MB 空闲,无 GC | 1–2GB+ 起,JVM heap 调优关键 |
|
||||||
|
| 横向扩展 | 社区版单节点;Cloud/企业版支持 shard+replica | 原生分片、副本、跨集群复制 |
|
||||||
|
| 规模上限 | 单机百万~千万文档(索引通常 < 100GB 级) | PB 级、百亿~千亿文档 |
|
||||||
|
| 聚合分析 | facet 计数级 | 完整 aggregation / pipeline / SIEM |
|
||||||
|
| 分词/相关度 | 内置 Charabia + 默认 ranking | 显式 analyzer / DSL / fuzziness 调优 |
|
||||||
|
| 运维复杂度 | 低(启容器+key+dump) | 高(heap/shard/ILM/TLS/RBAC)|
|
||||||
|
| 成本模型 | 单机 $10–20/月级 | 托管最小集群 $150+/月级 |
|
||||||
|
| 许可证 | MIT CE / BUSL-1.1 | AGPL / SSPL / ELv2 |
|
||||||
|
|
||||||
|
# 四、Docker安装
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.16
|
||||||
|
container_name: meilisearch
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "7700:7700"
|
||||||
|
environment:
|
||||||
|
MEILI_MASTER_KEY: key
|
||||||
|
volumes:
|
||||||
|
- ./meili_data:/meili_data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- meili-net
|
||||||
|
|
||||||
|
meilisearch-ui:
|
||||||
|
image: eyeix/meilisearch-ui:v0.15.1-lite
|
||||||
|
container_name: meilisearch-ui
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "24900:24900"
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
depends_on:
|
||||||
|
meilisearch:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- meili-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
meili-net:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
  启动后在浏览器中打开ip:24900,新增实例和索引。
|
||||||
|
|
||||||
|
# 五、SpringBoot集成
|
||||||
|
## 5.1 安装依赖
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.meilisearch.sdk</groupId>
|
||||||
|
<artifactId>meilisearch-java</artifactId>
|
||||||
|
<version>0.20.0</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
该版本需要okhttp 4.12版本,如有冲突,需要指定okhttp版本
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.squareup.okhttp3</groupId>
|
||||||
|
<artifactId>okhttp</artifactId>
|
||||||
|
<version>4.12.0</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 5.2 配置属性
|
||||||
|
```yml
|
||||||
|
meilisearch:
|
||||||
|
host: http:/ip:7700
|
||||||
|
api-key: key
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.3 配置类
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
@ConfigurationProperties(prefix = "meilisearch")
|
||||||
|
@Data
|
||||||
|
public class MeiliSearchConfig {
|
||||||
|
private String host;
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Client meiliClient() {
|
||||||
|
return new Client(new Config(host, apiKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.4 操作实例
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class MeiliSearchServiceImpl implements MeiliSearchService {
|
||||||
|
@Resource
|
||||||
|
private Client meiliClient;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> int addDocuments(String indexUid, List<T> documents) {
|
||||||
|
String jsonArray = JSONUtil.toJsonStr(documents);
|
||||||
|
TaskInfo taskInfo = meiliClient.index(indexUid).addDocuments(jsonArray);
|
||||||
|
meiliClient.waitForTask(taskInfo.getTaskUid());
|
||||||
|
return taskInfo.getTaskUid();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateDocument(String indexUid, Object document) {
|
||||||
|
TaskInfo task = meiliClient.index(indexUid).updateDocuments(JSONUtil.toJsonStr(document));
|
||||||
|
meiliClient.waitForTask(task.getTaskUid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteDocument(String indexUid, String documentId) {
|
||||||
|
TaskInfo task = meiliClient.index(indexUid).deleteDocument(documentId);
|
||||||
|
meiliClient.waitForTask(task.getTaskUid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SearchResult search(String indexUid, String query, List<String> highlightFields) {
|
||||||
|
SearchRequest request = new SearchRequest(query)
|
||||||
|
.setShowMatchesPosition(true)
|
||||||
|
.setAttributesToHighlight(highlightFields.toArray(new String[0]));
|
||||||
|
return (SearchResult) meiliClient.index(indexUid).search(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
这里如果开启高亮搜索,会有个bug:`Meilisearch ApiException: {Error=APIError: {message='Invalid value type at .attributesToHighlight: expected an array, but found a string: "**[Ljava.lang.String;@10fc5e2f**"', code='invalid_search_attributes_to_highlight', type='invalid_request',`
|
||||||
|
解决方案:
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>com.vaadin.external.google</groupId>
|
||||||
|
<artifactId>android-json</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
参考:[issue](https://github.com/meilisearch/meilisearch-java/issues/687)
|
||||||
|
:::
|
||||||
220
docs/Web/Others/RustFS.md
Normal file
220
docs/Web/Others/RustFS.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
---
|
||||||
|
title: RustFS简介和使用
|
||||||
|
date: 2025-12-09
|
||||||
|
---
|
||||||
|
|
||||||
|
# 简介
|
||||||
|
  [RustFS](https://rustfs.com.cn/) 是一个基于 Rust 构建的高性能分布式对象存储系统。
|
||||||
|
  具体以下特点:
|
||||||
|
|
||||||
|
- 高性能:基于 Rust 构建,确保极致的速度和资源效率。
|
||||||
|
- 分布式架构:可扩展且容错的设计,适用于大规模部署。
|
||||||
|
- S3 兼容性:与现有的 S3 兼容应用和工具无缝集成。
|
||||||
|
- 数据湖支持:专为高吞吐量的大数据和 AI 工作负载优化。
|
||||||
|
- 完全开源:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
|
||||||
|
- 简单易用:设计简洁,易于部署和管理。
|
||||||
|
|
||||||
|
  S3 标准通常指 Amazon S3 的 API 接口规范,现已成为对象存储领域事实上的行业标准接口协议。它定义了对象存储的核心操作方式和数据模型。
|
||||||
|
- 对象:基本存储单元,包含数据、元数据和唯一标识符
|
||||||
|
- 桶:对象的逻辑容器,类似文件夹
|
||||||
|
- 扁平结构:无传统目录层级,通过键名(Key)定位
|
||||||
|
|
||||||
|
# 安装部署
|
||||||
|
  通过docker部署:
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
rustfs:
|
||||||
|
image: rustfs/rustfs:1.0.0-alpha.65
|
||||||
|
container_name: rustfs
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- ./logs:/logs
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- RUSTFS_ACCESS_KEY=admin
|
||||||
|
- RUSTFS_SECRET_KEY=19940822Cxx
|
||||||
|
- RUSTFS_CONSOLE_ENABLE=true
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
  其中9000是API端口,9001是控制台端口。
|
||||||
|
  默认的登录账号密码为:rustfsadmin / rustfsadmin。
|
||||||
|
|
||||||
|
# API调用
|
||||||
|
  登录控制台,获取access_key和secret_access。
|
||||||
|
|
||||||
|
## Python
|
||||||
|
1. 安装AWS官方SDK依赖库
|
||||||
|
```cmd
|
||||||
|
pip install boto3
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 配置与连接
|
||||||
|
```python
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
|
||||||
|
from config.setting import settings
|
||||||
|
|
||||||
|
url = 'ip:port'
|
||||||
|
access_key = ''
|
||||||
|
secret_access = ''
|
||||||
|
|
||||||
|
s3 = boto3.client('s3',
|
||||||
|
endpoint_url=url,
|
||||||
|
aws_access_key_id=access_key,
|
||||||
|
aws_secret_access_key=secret_access,
|
||||||
|
config=Config(signature_version='s3v4'),
|
||||||
|
region_name='cn-east-1'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 上传文件
|
||||||
|
```python
|
||||||
|
async def upload_file(md5: str, file: UploadFile = File(...)) -> str:
|
||||||
|
file_ext = file.filename.split('.')[-1]
|
||||||
|
unique_filename = f"{md5}.{file_ext}"
|
||||||
|
|
||||||
|
file_content = await file.read()
|
||||||
|
|
||||||
|
# 上传到S3
|
||||||
|
s3.put_object(
|
||||||
|
Bucket=BUCKET,
|
||||||
|
Key=unique_filename,
|
||||||
|
Body=file_content,
|
||||||
|
ContentType=file.content_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# 返回文件url
|
||||||
|
return f"{BUCKET}/{unique_filename}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flutter
|
||||||
|
1. 安装依赖包
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
minio: ^3.5.8
|
||||||
|
file_picker: ^10.3.3
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 工具类
|
||||||
|
```dart
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:minio/io.dart';
|
||||||
|
import 'package:minio/minio.dart';
|
||||||
|
|
||||||
|
import 'file_utils.dart';
|
||||||
|
|
||||||
|
class MinIOHelper {
|
||||||
|
static final MinIOHelper _instance = MinIOHelper._internal();
|
||||||
|
|
||||||
|
factory MinIOHelper() => _instance;
|
||||||
|
|
||||||
|
final String ip = 'ip';
|
||||||
|
final int port = 9100;
|
||||||
|
final String fileUrl = 'http://ip:port';
|
||||||
|
final String accessKey = '';
|
||||||
|
final String secretKey = '';
|
||||||
|
|
||||||
|
MinIOHelper._internal() {
|
||||||
|
_minio = Minio(
|
||||||
|
endPoint: ip,
|
||||||
|
port: port,
|
||||||
|
accessKey: accessKey,
|
||||||
|
secretKey: secretKey,
|
||||||
|
useSSL: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late Minio _minio;
|
||||||
|
|
||||||
|
Future<String> uploadFile({
|
||||||
|
required PlatformFile file,
|
||||||
|
required String bucketName,
|
||||||
|
Function(double)? onProgress,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
if (isImageFile(file.name)) {
|
||||||
|
// 压缩图片
|
||||||
|
final compressedFile = await compressImage(File(file.path!));
|
||||||
|
|
||||||
|
String hashName = await generateMD5HashName(compressedFile.path);
|
||||||
|
String fileName = '$hashName${getFileExtension(file.name)}';
|
||||||
|
|
||||||
|
await _minio.fPutObject(bucketName, fileName, compressedFile.path);
|
||||||
|
await compressedFile.delete();
|
||||||
|
|
||||||
|
return fileName;
|
||||||
|
} else {
|
||||||
|
String hashName = await generateMD5HashName(file.path!);
|
||||||
|
String fileName = '$hashName${getFileExtension(file.name)}';
|
||||||
|
|
||||||
|
await _minio.fPutObject(bucketName, fileName, file.path!);
|
||||||
|
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('文件上传失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  文件工具类:
|
||||||
|
```dart
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
|
||||||
|
import 'log_utils.dart';
|
||||||
|
|
||||||
|
String getFileExtension(String fileName) {
|
||||||
|
if (fileName.contains('.')) {
|
||||||
|
return '.${fileName.split('.').last.toLowerCase()}';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> generateMD5HashName(String filePath) async {
|
||||||
|
final file = File(filePath);
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
final hash = md5.convert(bytes);
|
||||||
|
return hash.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片压缩方法
|
||||||
|
Future<File> compressImage(File file) async {
|
||||||
|
try {
|
||||||
|
// 获取压缩后的文件路径
|
||||||
|
final result = await FlutterImageCompress.compressAndGetFile(
|
||||||
|
file.absolute.path,
|
||||||
|
'${file.parent.path}/compressed_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
quality: 70,
|
||||||
|
format: CompressFormat.jpeg,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
throw Exception('图片压缩失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(result.path);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e('图片压缩失败,使用原文件: $e');
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为图片文件
|
||||||
|
bool isImageFile(String fileName) {
|
||||||
|
final imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];
|
||||||
|
final extension = fileName.toLowerCase().substring(fileName.lastIndexOf('.'));
|
||||||
|
return imageExtensions.contains(extension);
|
||||||
|
}
|
||||||
|
```
|
||||||
130
docs/Web/Others/Seckill.md
Normal file
130
docs/Web/Others/Seckill.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
title: 秒杀系统设计与实战
|
||||||
|
date: 2026-06-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  一个典型的秒杀系统通常采用 **限流 + 缓存 + 异步** 的架构:
|
||||||
|
```plain
|
||||||
|
客户端
|
||||||
|
↓
|
||||||
|
Nginx / 网关(限流)
|
||||||
|
↓
|
||||||
|
秒杀接口(校验、Redis 预扣库存)
|
||||||
|
↓
|
||||||
|
RocketMQ(下单消息)
|
||||||
|
↓
|
||||||
|
订单服务(创建订单、真正扣库存)
|
||||||
|
↓
|
||||||
|
MySQL
|
||||||
|
```
|
||||||
|
  **限流挡人、缓存决策、异步解耦**。
|
||||||
|
|
||||||
|
# 二、实战设计
|
||||||
|
## 2.1 限流
|
||||||
|
### 2.1.1 Nginx限流
|
||||||
|
```conf
|
||||||
|
# 定义规则
|
||||||
|
limit_req_zone $binary_remote_addr zone=seckill:10m rate=5r/s;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
|
||||||
|
location /seckill-api/ {
|
||||||
|
# 启用限流
|
||||||
|
limit_req zone=seckill burst=10 nodelay;
|
||||||
|
|
||||||
|
# 返回 429
|
||||||
|
limit_req_status 429;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:8080/;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  `limit_req_zone`用来制定规则,`limit_req`表示启用规则,如果某个`location`没写,则不会启动规则。
|
||||||
|
  `zone=seckill:10m`表示开辟一块`10MB`的内存,取名叫`seckill`,专门存`IP`访问记录,`rate=5r/s`表示每个`IP`每秒最多`5`个请求。
|
||||||
|
  `limit_req zone=seckill`表示启动`seckill`规则,`burst=10`表示突发请求缓冲,`nodelay`表示超出后直接失败。
|
||||||
|
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
`limit_req_zone`一定要写在`http`模块中,如果是`Linux`部署的,直接写在最外面。
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
Nginx限流只能防止单ip高刷,无法挡住大量不同IP的分布式攻击。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 2.1.2 Semaphore限流
|
||||||
|
  `Semaphore`是针对应用层限流,保护`JVM`,防止`Tomcat`线程池被打爆。
|
||||||
|
```java
|
||||||
|
private final Semaphore semaphore = new Semaphore(100);
|
||||||
|
|
||||||
|
if (!semaphore.tryAcquire()) {
|
||||||
|
return ResponseEntity.status(429).body("系统繁忙");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
semaphore.release();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  `new Semaphore(100)`表示最多`100`个线程同时进来,超过则直接返回失败,不会进入排队,也不会卡住线程。
|
||||||
|
::: tip
|
||||||
|
Semaphore不是全局限流,不具备分布式能力。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 2.1.3 Redis限流
|
||||||
|
```java
|
||||||
|
String userKey = "seckill:user:" + userId;
|
||||||
|
Boolean first = redis.opsForValue().setIfAbsent(userKey, "1", 60, TimeUnit.SECONDS);
|
||||||
|
if (Boolean.FALSE.equals(first)) {
|
||||||
|
return ResponseEntity.status(429).body("请勿重复尝试");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
  如果是限制每个用户只能限购一次,则可以将过期时间设置为永久。
|
||||||
|
|
||||||
|
## 2.2 缓存
|
||||||
|
  先在`Redis`里面判断逻辑,然后再执行数据库。
|
||||||
|
```java
|
||||||
|
String stockKey = "seckill:stock:1";
|
||||||
|
Long remain = redis.opsForValue().decrement(stockKey);
|
||||||
|
if (remain == null || remain < 0) {
|
||||||
|
redis.opsForValue().increment(stockKey);
|
||||||
|
return ResponseEntity.status(200).body("已售罄");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
  要先减再判断,如果小于0,则表示已售罄,再回滚数据。
|
||||||
|
|
||||||
|
## 2.3 异步
|
||||||
|
```java
|
||||||
|
rocketMQTemplate.convertAndSend("seckill-order", Map.of("userId", userId, "goodsId", 1));
|
||||||
|
return ResponseEntity.status(200).body("秒杀成功");
|
||||||
|
```
|
||||||
|
  Redis成功后则通过发消息通知消费者操作数据库。
|
||||||
|
  为了防止重复创建订单,还需要进行一次幂等校验,即根据订单号的唯一性判断是否有重复的。
|
||||||
|
|
||||||
|
# 三、压测
|
||||||
|
  采用JMeter工具进行压测。
|
||||||
|
## 3.1 创建测试数据
|
||||||
|
  新建users.csv,输入以下内容:
|
||||||
|
```csv
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
  然后在`线程组->添加->配置元件->CSV Data Set Config`,设置变量名称为userId。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
不需要输入表头。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 3.2 HTTP请求
|
||||||
|
  设置请求路径为`/seckill/${userId}`,设置线程组参数,例如线程数1000,Ramp-Up时间5秒,循环次数为1000,创建聚合报告,执行线程,查看结果。
|
||||||
|
|
||||||
|

|
||||||
243
docs/Web/Others/VideoCompressor.md
Normal file
243
docs/Web/Others/VideoCompressor.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
---
|
||||||
|
title: 视频压缩
|
||||||
|
date: 2026-01-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  视频码率(Bit Rate),指的是视频文件在单位时间内(通常是每秒)所包含的数据量。单位通常是 Mbps(兆比特每秒)或 Kbps(千比特每秒)。
|
||||||
|
  码率越高:视频中包含的细节信息越多,画面越清晰、色彩越丰富、动态画面(如快速运动、爆炸等)越流畅,不容易出现马赛克或模糊。但代价是文件体积越大,传输所需的网络带宽也越高。码率越低:视频文件更小,传输更快,但在复杂画面中容易产生压缩瑕疵(如马赛克、模糊、色带等)。
|
||||||
|
  如果不压缩,原始视频数据量极其庞大,一部2小时的未压缩1080p电影可能占用数TB的存储空间,普通硬盘无法承受,家庭宽带或移动网络无法实时传输如此巨大的数据量,视频播放会不断卡顿。
|
||||||
|
  如果服务器的带宽为10M,适合 720p,勉强支持低码率 1080p。
|
||||||
|
|
||||||
|
# 二、引入依赖
|
||||||
|
```xml
|
||||||
|
<!-- Jave 2 视频处理库 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>ws.schild</groupId>
|
||||||
|
<artifactId>jave-all-deps</artifactId>
|
||||||
|
<version>3.5.0</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
# 三、工具类
|
||||||
|
```java
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import ws.schild.jave.Encoder;
|
||||||
|
import ws.schild.jave.MultimediaObject;
|
||||||
|
import ws.schild.jave.encode.AudioAttributes;
|
||||||
|
import ws.schild.jave.encode.EncodingAttributes;
|
||||||
|
import ws.schild.jave.encode.VideoAttributes;
|
||||||
|
import ws.schild.jave.info.VideoSize;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频压缩工具类
|
||||||
|
* 提供将视频压缩到720p的静态方法
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class VideoCompressor {
|
||||||
|
|
||||||
|
// 支持的视频格式
|
||||||
|
private static final String[] SUPPORTED_FORMATS = {
|
||||||
|
".mp4", ".avi", ".mov", ".mkv", ".flv",
|
||||||
|
".wmv", ".webm", ".mpeg", ".mpg", ".3gp"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 压缩参数常量
|
||||||
|
private static final int TARGET_WIDTH = 1280;
|
||||||
|
private static final int TARGET_HEIGHT = 720;
|
||||||
|
private static final int VIDEO_BITRATE = 1500000; // 1.5 Mbps
|
||||||
|
private static final int AUDIO_BITRATE = 128000; // 128 kbps
|
||||||
|
private static final String VIDEO_CODEC = "libx264";
|
||||||
|
private static final String AUDIO_CODEC = "aac";
|
||||||
|
private static final String OUTPUT_FORMAT = "mp4";
|
||||||
|
private static final String OUTPUT_SUFFIX = "_720p.mp4";
|
||||||
|
|
||||||
|
private static final int MAX_BITRATE = 2500; // 2.5 Mbps
|
||||||
|
private static final int MIN_BITRATE = 1000; // 1 Mbps
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩视频到720p,统一输出为MP4格式
|
||||||
|
*
|
||||||
|
* @param inputFile 输入视频文件
|
||||||
|
* @return 压缩后的视频文件,如果不需要压缩则返回原文件
|
||||||
|
* @throws Exception 压缩过程中可能出现的异常
|
||||||
|
*/
|
||||||
|
public static File compressTo720p(File inputFile) throws Exception {
|
||||||
|
if (!isSupportedVideoFile(inputFile)) {
|
||||||
|
log.info("文件格式不支持或文件不存在,直接返回源文件");
|
||||||
|
return inputFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否需要压缩
|
||||||
|
if (!needCompression(inputFile)) {
|
||||||
|
return inputFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
File outputFile = createOutputFile(inputFile);
|
||||||
|
Encoder encoder = new Encoder();
|
||||||
|
|
||||||
|
// 配置编码参数
|
||||||
|
EncodingAttributes encodingAttributes = createEncodingAttributes();
|
||||||
|
|
||||||
|
log.info("开始压缩视频: {} -> {}", inputFile.getName(), outputFile.getName());
|
||||||
|
encoder.encode(new MultimediaObject(inputFile), outputFile, encodingAttributes);
|
||||||
|
log.info("视频压缩完成: {}", outputFile.getName());
|
||||||
|
|
||||||
|
return outputFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能判断视频是否需要压缩
|
||||||
|
* 基于分辨率、码率、数据密度等多维度综合判断
|
||||||
|
*/
|
||||||
|
public static boolean needCompression(File videoFile) throws Exception {
|
||||||
|
MultimediaObject media = new MultimediaObject(videoFile);
|
||||||
|
|
||||||
|
// 获取视频基本信息
|
||||||
|
double fileSizeMB = videoFile.length() / 1048576.0; // 转换为MB
|
||||||
|
double duration = media.getInfo().getDuration() / 1000.0;
|
||||||
|
int bitRateKbps = media.getInfo().getVideo().getBitRate() / 1000; // 转换为kbps
|
||||||
|
|
||||||
|
// 获取视频分辨率
|
||||||
|
int width = media.getInfo().getVideo().getSize().getWidth();
|
||||||
|
int height = media.getInfo().getVideo().getSize().getHeight();
|
||||||
|
|
||||||
|
log.info("视频分析: 分辨率 {}x{}, 大小 {}MB, 时长 {}秒, 码率约 {}kbps", width, height, fileSizeMB, duration, bitRateKbps);
|
||||||
|
|
||||||
|
// 判断逻辑:基于分辨率和码率的智能判断
|
||||||
|
boolean needsCompression = false;
|
||||||
|
|
||||||
|
// 1. 如果分辨率已经<=720p,检查码率是否过高
|
||||||
|
if (height <= TARGET_HEIGHT && width <= TARGET_WIDTH) {
|
||||||
|
if (bitRateKbps > MAX_BITRATE) {
|
||||||
|
log.info("视频分辨率达标但码率({}kbps)过高,需要压缩", bitRateKbps);
|
||||||
|
needsCompression = true;
|
||||||
|
} else {
|
||||||
|
log.info("视频分辨率已达720p或以下,且码率合理,无需压缩");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. 如果分辨率>720p,但码率很低,说明已是优化过的视频,无需压缩
|
||||||
|
else if (height > TARGET_HEIGHT && bitRateKbps < MIN_BITRATE) {
|
||||||
|
log.info("视频虽为{}p高分辨率,但码率({}kbps)已优化,无需压缩", height, bitRateKbps);
|
||||||
|
}
|
||||||
|
// 3. 高分辨率+高码率,需要压缩
|
||||||
|
else if (height > TARGET_HEIGHT && bitRateKbps >= MAX_BITRATE) {
|
||||||
|
log.info("视频为{}p高分辨率且码率较高({}kbps),需要压缩至720p", height, bitRateKbps);
|
||||||
|
needsCompression = true;
|
||||||
|
}
|
||||||
|
// 4. 其他情况(如异常数据)默认不压缩
|
||||||
|
else {
|
||||||
|
log.info("视频参数异常,保守处理:不进行压缩");
|
||||||
|
}
|
||||||
|
|
||||||
|
return needsCompression;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否为支持的视频格式
|
||||||
|
*/
|
||||||
|
private static boolean isSupportedVideoFile(File file) {
|
||||||
|
if (file == null || !file.exists()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String filename = file.getName().toLowerCase();
|
||||||
|
for (String format : SUPPORTED_FORMATS) {
|
||||||
|
if (filename.endsWith(format)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建输出文件对象
|
||||||
|
*/
|
||||||
|
private static File createOutputFile(File inputFile) {
|
||||||
|
String baseName = getFileBaseName(inputFile.getName());
|
||||||
|
String outputFilename = baseName + OUTPUT_SUFFIX;
|
||||||
|
return new File(inputFile.getParent(), outputFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件名(不含扩展名)
|
||||||
|
*/
|
||||||
|
private static String getFileBaseName(String filename) {
|
||||||
|
int dotIndex = filename.lastIndexOf('.');
|
||||||
|
return (dotIndex == -1) ? filename : filename.substring(0, dotIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建编码配置参数
|
||||||
|
*/
|
||||||
|
private static EncodingAttributes createEncodingAttributes() {
|
||||||
|
// 音频配置
|
||||||
|
AudioAttributes audio = new AudioAttributes();
|
||||||
|
audio.setCodec(AUDIO_CODEC);
|
||||||
|
audio.setBitRate(AUDIO_BITRATE);
|
||||||
|
|
||||||
|
// 视频配置
|
||||||
|
VideoAttributes video = new VideoAttributes();
|
||||||
|
video.setCodec(VIDEO_CODEC);
|
||||||
|
video.setBitRate(VIDEO_BITRATE);
|
||||||
|
video.setSize(new VideoSize(TARGET_WIDTH, TARGET_HEIGHT));
|
||||||
|
|
||||||
|
// 编码设置
|
||||||
|
EncodingAttributes attributes = new EncodingAttributes();
|
||||||
|
attributes.setOutputFormat(OUTPUT_FORMAT);
|
||||||
|
attributes.setAudioAttributes(audio);
|
||||||
|
attributes.setVideoAttributes(video);
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  通过分辨率和码率的双重判断,确定是否需要压缩。
|
||||||
|
|
||||||
|
# 四、使用
|
||||||
|
```java
|
||||||
|
public void uploadFile(MultipartFile file, String filePath) {
|
||||||
|
try {
|
||||||
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
|
||||||
|
File tempFile = convertMultipartFileToFile(file);
|
||||||
|
log.info("临时文件地址:{}", tempFile.toPath());
|
||||||
|
|
||||||
|
log.info("开始压缩文件:{}", tempFile.getName());
|
||||||
|
File compressFile = VideoCompressor.compressTo720p(tempFile);
|
||||||
|
log.info("完成压缩文件:{}", tempFile.getName());
|
||||||
|
|
||||||
|
log.info("压缩文件地址:{}", tempFile.toPath());
|
||||||
|
|
||||||
|
body.add("file", new FileSystemResource(compressFile));
|
||||||
|
|
||||||
|
aListClient.put()
|
||||||
|
.uri("api/fs/form")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, token)
|
||||||
|
.header("File-Path", filePath)
|
||||||
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
|
.body(body)
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
|
||||||
|
Files.deleteIfExists(compressFile.toPath());
|
||||||
|
Files.deleteIfExists(tempFile.toPath());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("文件上传失败{}", e.getMessage());
|
||||||
|
throw new RuntimeException("文件上传失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private File convertMultipartFileToFile(MultipartFile file) throws IOException {
|
||||||
|
String suffix = (file.getOriginalFilename() != null && file.getOriginalFilename().contains("."))
|
||||||
|
? file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."))
|
||||||
|
: ".tmp";
|
||||||
|
|
||||||
|
Path tempFile = Files.createTempFile(System.currentTimeMillis() + "", suffix);
|
||||||
|
file.transferTo(tempFile);
|
||||||
|
return tempFile.toFile();
|
||||||
|
}
|
||||||
|
```
|
||||||
222
docs/Web/SpringBoot/Spring-AOP.md
Normal file
222
docs/Web/SpringBoot/Spring-AOP.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
---
|
||||||
|
title: Spring AOP简介
|
||||||
|
date: 2025-12-25
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、基本概念
|
||||||
|
  AOP(Aspect-Oriented Programming,面向切面编程)是一种编程范式,核心思想是**将与业务核心逻辑无关但又被多个模块共享的功能(如日志、事务、权限校验、性能监控)抽取出来,形成独立的 “切面”**,在不修改原有业务代码的前提下,通过 “织入” 的方式动态添加到目标方法的执行流程中。
|
||||||
|
  Spring AOP 是 Spring 框架对 AOP 思想的实现,基于**动态代理(JDK 动态代理 + CGLIB 动态代理)**,专注于解决企业级开发中的横切关注点问题。
|
||||||
|
| 术语 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 切面(Aspect) | 封装横切逻辑的类(如日志切面、事务切面),包含通知和切点 |
|
||||||
|
| 连接点(Joinpoint) | 程序执行过程中的某个特定点(如方法调用、异常抛出),Spring AOP 中仅支持方法级连接点 |
|
||||||
|
| 切点(Pointcut) | 匹配连接点的规则,用于指定切面要织入到哪些方法上 |
|
||||||
|
| 通知(Advice) | 切面的具体逻辑(如日志打印、权限校验),按执行时机分为 5 种 |
|
||||||
|
| 目标对象(Target) | 被切面织入的目标类(业务类) |
|
||||||
|
| 代理对象(Proxy) | Spring AOP 为目标对象创建的代理类,横切逻辑通过代理执行 |
|
||||||
|
| 织入(Weaving) | 将切面逻辑添加到目标对象的过程,Spring AOP 是运行时织入(通过动态代理) |
|
||||||
|
|
||||||
|
| 通知类型 | 执行时机 |
|
||||||
|
|----------|----------|
|
||||||
|
| 前置通知(Before) | 目标方法执行前执行 |
|
||||||
|
| 后置通知(After) | 目标方法执行后执行(无论是否抛出异常) |
|
||||||
|
| 返回通知(AfterReturning) | 目标方法正常返回后执行 |
|
||||||
|
| 异常通知(AfterThrowing) | 目标方法抛出异常后执行 |
|
||||||
|
| 环绕通知(Around) | 包裹目标方法执行,可自定义目标方法执行前后的逻辑(功能最强) |
|
||||||
|
  [AOP API](https://docs.springjava.cn/spring-framework/reference/core/aop-api.html)
|
||||||
|
|
||||||
|
# 二、原理
|
||||||
|
  Spring AOP 基于动态代理实现,分为两种方式:
|
||||||
|
1. JDK 动态代理
|
||||||
|
通过 java.lang.reflect.Proxy 生成代理类,代理类实现目标接口,调用 InvocationHandler 处理横切逻辑;基于接口,无侵入。
|
||||||
|
|
||||||
|
2. CGLIB 动态代理
|
||||||
|
通过继承目标类生成子类作为代理类,重写目标方法,织入横切逻辑;基于继承,目标类不能是 final,方法不能是 final。
|
||||||
|
|
||||||
|
  Spring AOP 自动选择规则:如果目标类实现了接口,使用 JDK 动态代理;否则使用 CGLIB。
|
||||||
|
|
||||||
|
# 三、实战
|
||||||
|
  例如存在以下业务模块:
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
// 模拟创建订单
|
||||||
|
public String createOrder(String orderNo) {
|
||||||
|
System.out.println("创建订单:" + orderNo);
|
||||||
|
return "订单创建成功:" + orderNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟删除订单(故意抛出异常)
|
||||||
|
public void deleteOrder(String orderNo) {
|
||||||
|
System.out.println("删除订单:" + orderNo);
|
||||||
|
throw new RuntimeException("删除订单失败:订单不存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  AOP实现:
|
||||||
|
```java
|
||||||
|
import org.aspectj.lang.JoinPoint;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.annotation.*;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
// 1. 标记为切面类
|
||||||
|
@Aspect
|
||||||
|
// 2. 交给Spring容器管理
|
||||||
|
@Component
|
||||||
|
public class LogAspect {
|
||||||
|
|
||||||
|
// 3. 定义切点:匹配OrderService类的所有方法
|
||||||
|
@Pointcut("execution(* com.example.aopdemo.service.OrderService.*(..))")
|
||||||
|
public void orderServicePointcut() {}
|
||||||
|
|
||||||
|
// 4. 前置通知:目标方法执行前执行
|
||||||
|
@Before("orderServicePointcut()")
|
||||||
|
public void beforeAdvice(JoinPoint joinPoint) {
|
||||||
|
// 获取方法名和参数
|
||||||
|
String methodName = joinPoint.getSignature().getName();
|
||||||
|
Object[] args = joinPoint.getArgs();
|
||||||
|
System.out.println("[前置通知] 方法:" + methodName + ",参数:" + Arrays.toString(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 后置通知:目标方法执行后执行(无论是否异常)
|
||||||
|
@After("orderServicePointcut()")
|
||||||
|
public void afterAdvice(JoinPoint joinPoint) {
|
||||||
|
String methodName = joinPoint.getSignature().getName();
|
||||||
|
System.out.println("[后置通知] 方法:" + methodName + " 执行完毕");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 返回通知:目标方法正常返回后执行
|
||||||
|
@AfterReturning(value = "orderServicePointcut()", returning = "result")
|
||||||
|
public void afterReturningAdvice(JoinPoint joinPoint, Object result) {
|
||||||
|
String methodName = joinPoint.getSignature().getName();
|
||||||
|
System.out.println("[返回通知] 方法:" + methodName + ",返回值:" + result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 异常通知:目标方法抛出异常后执行
|
||||||
|
@AfterThrowing(value = "orderServicePointcut()", throwing = "ex")
|
||||||
|
public void afterThrowingAdvice(JoinPoint joinPoint, Exception ex) {
|
||||||
|
String methodName = joinPoint.getSignature().getName();
|
||||||
|
System.out.println("[异常通知] 方法:" + methodName + ",异常:" + ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 环绕通知:包裹目标方法执行(功能最强)
|
||||||
|
@Around("orderServicePointcut()")
|
||||||
|
public Object aroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
|
||||||
|
String methodName = proceedingJoinPoint.getSignature().getName();
|
||||||
|
Object[] args = proceedingJoinPoint.getArgs();
|
||||||
|
|
||||||
|
// 前置逻辑
|
||||||
|
System.out.println("[环绕通知-前置] 方法:" + methodName + ",参数:" + Arrays.toString(args));
|
||||||
|
|
||||||
|
Object result = null;
|
||||||
|
try {
|
||||||
|
// 执行目标方法
|
||||||
|
result = proceedingJoinPoint.proceed();
|
||||||
|
// 返回后逻辑
|
||||||
|
System.out.println("[环绕通知-返回] 方法:" + methodName + ",返回值:" + result);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
// 异常逻辑
|
||||||
|
System.out.println("[环绕通知-异常] 方法:" + methodName + ",异常:" + e.getMessage());
|
||||||
|
throw e; // 必须抛出异常,否则上层无法捕获
|
||||||
|
} finally {
|
||||||
|
// 后置逻辑
|
||||||
|
System.out.println("[环绕通知-后置] 方法:" + methodName + " 执行完毕");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  测试:
|
||||||
|
```java
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
public class OrderServiceTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private OrderService orderService;
|
||||||
|
|
||||||
|
// 测试正常方法(createOrder)
|
||||||
|
@Test
|
||||||
|
public void testCreateOrder() {
|
||||||
|
orderService.createOrder("ORDER_20251225");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试异常方法(deleteOrder)
|
||||||
|
@Test
|
||||||
|
public void testDeleteOrder() {
|
||||||
|
try {
|
||||||
|
orderService.deleteOrder("ORDER_9999");
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 捕获异常,避免测试中断
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  测试结果:
|
||||||
|
```plaintext
|
||||||
|
[环绕通知-前置] 方法:createOrder,参数:[ORDER_20251225]
|
||||||
|
[前置通知] 方法:createOrder,参数:[ORDER_20251225]
|
||||||
|
创建订单:ORDER_20251225
|
||||||
|
[环绕通知-返回] 方法:createOrder,返回值:订单创建成功:ORDER_20251225
|
||||||
|
[返回通知] 方法:createOrder,返回值:订单创建成功:ORDER_20251225
|
||||||
|
[后置通知] 方法:createOrder 执行完毕
|
||||||
|
[环绕通知-后置] 方法:createOrder 执行完毕
|
||||||
|
|
||||||
|
|
||||||
|
[环绕通知-前置] 方法:deleteOrder,参数:[ORDER_9999]
|
||||||
|
[前置通知] 方法:deleteOrder,参数:[ORDER_9999]
|
||||||
|
删除订单:ORDER_9999
|
||||||
|
[环绕通知-异常] 方法:deleteOrder,异常:删除订单失败:订单不存在
|
||||||
|
[异常通知] 方法:deleteOrder,异常:删除订单失败:订单不存在
|
||||||
|
[后置通知] 方法:deleteOrder 执行完毕
|
||||||
|
[环绕通知-后置] 方法:deleteOrder 执行完毕
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、切点表达式
|
||||||
|
| 切点表达式 | 说明 |
|
||||||
|
|------------|------|
|
||||||
|
| `execution(* com.example.service.*.*(..))` | 匹配 com.example.service 包下所有类的所有方法 |
|
||||||
|
| `execution(public String com.example.service.OrderService.create*(String))` | 匹配 OrderService 类中公共、返回值为 String、方法名以 create 开头、参数为 String 的方法 |
|
||||||
|
| `execution(* com.example..*Service.*(..))` | 匹配 com.example 包及其子包下所有以 Service 结尾的类的所有方法 |
|
||||||
|
| `@annotation(com.example.annotation.Log)` | 匹配标注了 @Log 自定义注解的方法 |
|
||||||
|
|
||||||
|
  组合切点:
|
||||||
|
```java
|
||||||
|
// 匹配OrderService的方法 且 方法名包含"Order"
|
||||||
|
@Pointcut("execution(* com.example.service.OrderService.*(..)) && execution(* *Order*(..))")
|
||||||
|
public void combinePointcut() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 五、总结
|
||||||
|
  通俗的解释,AOP本质是:找目标(通过切点规则,精准找到要 “加戏” 的方法 / 类)→ 造代理(Spring 自动给这些目标生成一个 “中间商”(代理对象))→ 加戏(在目标方法真正执行前 / 后 / 出问题时,让这个 “中间商” 帮你干额外的活)。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
基于 Spring AOP 的知名框架/组件汇总
|
||||||
|
| 框架/组件 | 核心作用 | Spring AOP 应用说明 |
|
||||||
|
|-----------|----------|---------------------|
|
||||||
|
| AspectJ | 提供切点表达式语法、注解解析(@Aspect/@Pointcut 等) | Spring AOP 仅复用其语法规则,底层仍为自身动态代理(未使用 AspectJ 编译期织入) |
|
||||||
|
| Spring Core | 容器管理、动态代理创建(JDK/CGLIB) | 负责切面/目标类的生命周期管理,生成代理对象并完成织入逻辑 |
|
||||||
|
| Spring Transaction | 声明式事务管理 | 切点:匹配 @Transactional 注解方法;<br>通知:方法执行前开启事务、执行后提交、异常时回滚 |
|
||||||
|
| Spring Cache | 声明式缓存管理 | 切点:匹配 @Cacheable/@CacheEvict 等注解方法;<br>通知:方法执行前查缓存、执行后更新缓存 |
|
||||||
|
| spring-boot-starter-aop | AOP 基础依赖封装 | 自动引入 AspectJ + Spring AOP 核心,开箱即用 |
|
||||||
|
| Spring Security | 安全权限管理 | 切点:匹配 @PreAuthorize 等权限注解方法;<br>通知:方法执行前校验用户权限,无权限抛异常 |
|
||||||
|
| MyBatis-Plus | 增强 MyBatis CRUD 操作 | 切点:匹配 Mapper 接口方法/标注注解的实体类;<br>通知:织入分页逻辑、注解(@TableName)解析等 |
|
||||||
|
| Shiro | 权限管理框架 | 切点:匹配 @RequiresPermissions 注解方法;<br>通知:方法执行前校验用户权限 |
|
||||||
|
| Seata | 分布式事务框架 | 切点:匹配 @GlobalTransactional 注解方法;<br>通知:织入全局事务的开启、提交、回滚逻辑 |
|
||||||
|
| Sentinel | 流量控制/熔断降级框架 | 切点:匹配 @SentinelResource 注解方法;<br>通知:方法执行前校验流量阈值、熔断规则 |
|
||||||
|
| Dynamic-TP | 动态线程池框架 | 切点:匹配线程池执行方法;<br>通知:织入线程池耗时、队列长度等监控逻辑 |
|
||||||
|
| p6spy | 数据库 SQL 监控 | 切点:拦截 JDBC 操作方法;<br>通知:织入 SQL 打印、执行耗时统计逻辑 |
|
||||||
|
| dynamic-datasource-spring-boot-starter | 动态数据源切换 | 切点:匹配 @DS 注解方法;<br>通知:方法执行前切换数据源、执行后恢复默认数据源 |
|
||||||
|
| xxl-job | 分布式任务调度 | 切点:匹配任务执行方法;<br>通知:织入任务执行日志、监控指标采集逻辑 |
|
||||||
|
| Spring Cloud Sleuth | 微服务链路追踪 | 切点:拦截 HTTP/RPC 调用方法;<br>通知:织入 traceId/spanId 传递、链路信息采集逻辑 |
|
||||||
|
:::
|
||||||
562
docs/Web/SpringBoot/Spring-IOC.md
Normal file
562
docs/Web/SpringBoot/Spring-IOC.md
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
---
|
||||||
|
title: Spring IOC简介
|
||||||
|
date: 2025-11-26
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、IOC
|
||||||
|
  Spring框架的核心是IOC(控制反转)容器,它负责管理应用程序中的对象(称为Bean)的创建、配置和组装。
|
||||||
|
  [IoC 容器](https://docs.springjava.cn/spring-framework/reference/core/beans.html)
|
||||||
|
|
||||||
|
# 二、Bean
|
||||||
|
## 2.1 定义
|
||||||
|
  在Spring中,Bean是指由Spring IOC容器管理的对象实例。这些对象不再由应用程序直接创建,而是由容器负责实例化、配置和组装。
|
||||||
|
  例如@Service、@Controller就是特殊的Bean。
|
||||||
|
|
||||||
|
## 2.2 特点
|
||||||
|
- 由容器管理:生命周期由Spring容器控制
|
||||||
|
- 可配置性:通过配置元数据定义Bean的行为
|
||||||
|
- 依赖注入:容器自动处理Bean之间的依赖关系
|
||||||
|
- 作用域支持:支持singleton、prototype等不同作用域
|
||||||
|
|
||||||
|
## 2.3 创建方式
|
||||||
|
```java
|
||||||
|
// 1. 通过@Component注解
|
||||||
|
@Component
|
||||||
|
public class UserService {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 通过@Bean注解(配置类中)
|
||||||
|
@Configuration
|
||||||
|
public class AppConfig {
|
||||||
|
@Bean
|
||||||
|
public DataSource dataSource() {
|
||||||
|
return new DataSource();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 通过XML配置
|
||||||
|
<bean id="userService" class="com.example.UserService"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
@Service、@Controller这些注解是@Component的衍生注解。
|
||||||
|
@Component注解可以通过@Autowired自动注入,一般用于自己编写的业务模块,需要配合@ComponentScan启用组件扫描(实际开发中@SpringBootApplication注解已经包含了@ComponentScan注解)。
|
||||||
|
@Bean注解一般用于注册第三方库的类。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 2.4 作用域
|
||||||
|
  控制 Bean 在容器中的实例数量和生命周期。Spring 提供多种作用域,默认是 singleton。通过 @Scope 注解指定,如 @Scope("prototype")。
|
||||||
|
- singleton(单例):容器中只有一个实例,所有请求共享该实例(默认值)。
|
||||||
|
- prototype(多例):每次请求(如 getBean())都会创建新实例,容器不管理其销毁。
|
||||||
|
- request(Web):每个 HTTP 请求创建一个实例,仅在当前请求有效。
|
||||||
|
- session(Web):每个会话创建一个实例,在会话有效期内有效。
|
||||||
|
|
||||||
|
## 2.5 生命周期
|
||||||
|
1. 实例化(Instantiation)
|
||||||
|
容器通过构造器创建 Bean 实例(分配内存)。
|
||||||
|
|
||||||
|
2. 属性注入(Population)
|
||||||
|
容器将依赖的 Bean 注入到当前 Bean 的字段或方法(如 @Autowired 标注的依赖)。
|
||||||
|
|
||||||
|
3. 初始化前(Post-processing before initialization)
|
||||||
|
执行 BeanPostProcessor 的 postProcessBeforeInitialization 方法(AOP 代理生成在此阶段)。
|
||||||
|
|
||||||
|
4. 初始化(Initialization)
|
||||||
|
调用 @PostConstruct 注解的方法(JSR-250 标准,推荐)。
|
||||||
|
若实现 InitializingBean 接口,调用 afterPropertiesSet() 方法。
|
||||||
|
执行自定义初始化方法(如 @Bean(initMethod = "init") 中指定的 init 方法)。
|
||||||
|
|
||||||
|
5. 初始化后(Post-processing after initialization)
|
||||||
|
执行 BeanPostProcessor 的 postProcessAfterInitialization 方法。
|
||||||
|
|
||||||
|
6. 使用(In Use)
|
||||||
|
Bean 被应用程序调用。
|
||||||
|
|
||||||
|
7. 销毁(Destruction)
|
||||||
|
调用 @PreDestroy 注解的方法(JSR-250 标准,推荐)。
|
||||||
|
若实现 DisposableBean 接口,调用 destroy() 方法。
|
||||||
|
执行自定义销毁方法(如 @Bean(destroyMethod = "destroy") 中指定的 destroy 方法)。
|
||||||
|

|
||||||
|
|
||||||
|
# 三、BeanDefinition
|
||||||
|
## 3.1 定义
|
||||||
|
  BeanDefinition是Spring框架中描述Bean的元数据对象,它包含了创建Bean实例所需的所有信息。
|
||||||
|
|
||||||
|
## 3.2 作用
|
||||||
|
- 定义Bean的类名
|
||||||
|
- 设置Bean的作用域
|
||||||
|
- 配置Bean的属性值
|
||||||
|
- 定义依赖关系
|
||||||
|
- 配置初始化/销毁方法
|
||||||
|
|
||||||
|
## 3.3 结构
|
||||||
|
```xml
|
||||||
|
BeanDefinition (接口)
|
||||||
|
↑
|
||||||
|
AbstractBeanDefinition (抽象类)
|
||||||
|
↑
|
||||||
|
RootBeanDefinition, GenericBeanDefinition, ChildBeanDefinition
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.4 关键属性
|
||||||
|
```java
|
||||||
|
public interface BeanDefinition {
|
||||||
|
// Bean的类名
|
||||||
|
void setBeanClassName(String beanClassName);
|
||||||
|
String getBeanClassName();
|
||||||
|
|
||||||
|
// 作用域(singleton、prototype等)
|
||||||
|
void setScope(String scope);
|
||||||
|
String getScope();
|
||||||
|
|
||||||
|
// 是否延迟初始化
|
||||||
|
void setLazyInit(boolean lazyInit);
|
||||||
|
boolean isLazyInit();
|
||||||
|
|
||||||
|
// 依赖的Bean名称
|
||||||
|
void setDependsOn(String... dependsOn);
|
||||||
|
String[] getDependsOn();
|
||||||
|
|
||||||
|
// 初始化方法
|
||||||
|
void setInitMethodName(String initMethodName);
|
||||||
|
String getInitMethodName();
|
||||||
|
|
||||||
|
// 销毁方法
|
||||||
|
void setDestroyMethodName(String destroyMethodName);
|
||||||
|
String getDestroyMethodName();
|
||||||
|
|
||||||
|
// 工厂方法名
|
||||||
|
void setFactoryMethodName(String factoryMethodName);
|
||||||
|
String getFactoryMethodName();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.5 创建过程
|
||||||
|
```java
|
||||||
|
// 编程方式创建BeanDefinition
|
||||||
|
GenericBeanDefinition beanDefinition = new GenericBeanDefinition();
|
||||||
|
beanDefinition.setBeanClassName("com.example.UserService");
|
||||||
|
beanDefinition.setScope(BeanDefinition.SCOPE_SINGLETON);
|
||||||
|
beanDefinition.setLazyInit(false);
|
||||||
|
|
||||||
|
// 注册到BeanFactory
|
||||||
|
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
|
||||||
|
beanFactory.registerBeanDefinition("userService", beanDefinition);
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、BeanFactory
|
||||||
|
## 4.1 定义
|
||||||
|
  BeanFactory是Spring IOC容器的根接口,提供了最基本的IOC功能。它是访问Spring容器的基本客户端视图。
|
||||||
|
|
||||||
|
## 4.2 结构
|
||||||
|
```xml
|
||||||
|
BeanFactory (根接口)
|
||||||
|
↑
|
||||||
|
ListableBeanFactory, HierarchicalBeanFactory, AutowireCapableBeanFactory
|
||||||
|
↑
|
||||||
|
ConfigurableBeanFactory, ConfigurableListableBeanFactory
|
||||||
|
↑
|
||||||
|
DefaultListableBeanFactory (主要实现类)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.3 核心方法
|
||||||
|
```java
|
||||||
|
public interface BeanFactory {
|
||||||
|
// 根据名称获取Bean实例
|
||||||
|
Object getBean(String name) throws BeansException;
|
||||||
|
|
||||||
|
// 根据名称和类型获取Bean实例
|
||||||
|
<T> T getBean(String name, Class<T> requiredType) throws BeansException;
|
||||||
|
|
||||||
|
// 根据类型获取Bean实例
|
||||||
|
<T> T getBean(Class<T> requiredType) throws BeansException;
|
||||||
|
|
||||||
|
// 判断是否包含指定名称的Bean
|
||||||
|
boolean containsBean(String name);
|
||||||
|
|
||||||
|
// 判断Bean是否是单例
|
||||||
|
boolean isSingleton(String name) throws NoSuchBeanDefinitionException;
|
||||||
|
|
||||||
|
// 判断Bean是否是原型
|
||||||
|
boolean isPrototype(String name) throws NoSuchBeanDefinitionException;
|
||||||
|
|
||||||
|
// 获取Bean的类型
|
||||||
|
Class<?> getType(String name) throws NoSuchBeanDefinitionException;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.4 使用示例
|
||||||
|
```java
|
||||||
|
// 创建BeanFactory实例
|
||||||
|
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
|
||||||
|
|
||||||
|
// 创建BeanDefinition
|
||||||
|
GenericBeanDefinition beanDefinition = new GenericBeanDefinition();
|
||||||
|
beanDefinition.setBeanClassName("com.example.UserService");
|
||||||
|
|
||||||
|
// 注册BeanDefinition
|
||||||
|
beanFactory.registerBeanDefinition("userService", beanDefinition);
|
||||||
|
|
||||||
|
// 获取Bean实例
|
||||||
|
UserService userService = beanFactory.getBean("userService", UserService.class);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.5 特点
|
||||||
|
- 轻量级:功能相对简单,资源消耗少
|
||||||
|
- 延迟加载:默认在第一次请求时才创建Bean实例
|
||||||
|
- 基础功能:提供最基本的IOC功能
|
||||||
|
|
||||||
|
# 五、ApplicationContext ★★★
|
||||||
|
## 5.1 定义
|
||||||
|
  ApplicationContext是BeanFactory的子接口,在BeanFactory的基础上提供了更多企业级功能,是Spring容器的完整实现。
|
||||||
|
|
||||||
|
## 5.2 结构
|
||||||
|
```xml
|
||||||
|
BeanFactory
|
||||||
|
↑
|
||||||
|
ApplicationContext (扩展接口)
|
||||||
|
↑
|
||||||
|
ConfigurableApplicationContext
|
||||||
|
↑
|
||||||
|
AbstractApplicationContext
|
||||||
|
↑
|
||||||
|
ClassPathXmlApplicationContext, FileSystemXmlApplicationContext,
|
||||||
|
AnnotationConfigApplicationContext, WebApplicationContext
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.3 特点
|
||||||
|
- 完整的IOC功能:包含BeanFactory的所有功能
|
||||||
|
- 预实例化单例Bean:默认在启动时创建单例Bean
|
||||||
|
- 企业级功能:支持AOP、事务管理、国际化等
|
||||||
|
- 资源访问:统一的资源访问接口
|
||||||
|
- 事件机制:支持应用内事件发布/订阅
|
||||||
|
|
||||||
|
## 5.4 refresh方法
|
||||||
|
```java
|
||||||
|
// AbstractApplicationContext.java
|
||||||
|
/**
|
||||||
|
* Spring ApplicationContext 的核心启动方法
|
||||||
|
* 定义了容器初始化的完整生命周期
|
||||||
|
*/
|
||||||
|
public void refresh() throws BeansException, IllegalStateException {
|
||||||
|
// 使用同步锁确保容器启动的线程安全性,防止并发刷新
|
||||||
|
synchronized (this.startupShutdownMonitor) {
|
||||||
|
|
||||||
|
// [1] 准备刷新容器 - 设置启动状态,验证环境配置
|
||||||
|
prepareRefresh();
|
||||||
|
|
||||||
|
// [2] 获取或创建BeanFactory - 创建DefaultListableBeanFactory实例
|
||||||
|
// 如果是XML配置,会在这里加载和解析XML文件
|
||||||
|
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
|
||||||
|
|
||||||
|
// [3] 配置BeanFactory - 设置类加载器、注册核心BeanPostProcessor等
|
||||||
|
prepareBeanFactory(beanFactory);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// [4] 后处理BeanFactory - 模板方法,允许子类进行特殊配置
|
||||||
|
// Web应用会在这里注册Servlet相关的Scope
|
||||||
|
postProcessBeanFactory(beanFactory);
|
||||||
|
|
||||||
|
// [5] 🚀 关键步骤:调用BeanFactory后置处理器
|
||||||
|
// 执行BeanFactoryPostProcessor和BeanDefinitionRegistryPostProcessor
|
||||||
|
// ConfigurationClassPostProcessor在这里扫描@Component、@Configuration等注解
|
||||||
|
invokeBeanFactoryPostProcessors(beanFactory);
|
||||||
|
|
||||||
|
// [6] 注册Bean后置处理器 - 注册所有BeanPostProcessor
|
||||||
|
// 包括AOP、事务、自定义的BeanPostProcessor,此时只是注册,还未执行
|
||||||
|
registerBeanPostProcessors(beanFactory);
|
||||||
|
|
||||||
|
// [7] 初始化消息源 - 设置国际化消息资源
|
||||||
|
// 用于支持多语言消息解析
|
||||||
|
initMessageSource();
|
||||||
|
|
||||||
|
// [8] 初始化应用事件广播器 - 创建事件发布机制的核心组件
|
||||||
|
// 用于发布ApplicationEvent事件
|
||||||
|
initApplicationEventMulticaster();
|
||||||
|
|
||||||
|
// [9] 模板方法:子类可以在这里执行特殊的刷新逻辑
|
||||||
|
// SpringBoot在这里启动内嵌的Tomcat服务器
|
||||||
|
onRefresh();
|
||||||
|
|
||||||
|
// [10] 注册监听器 - 将所有ApplicationListener注册到事件广播器
|
||||||
|
// 包括静态指定的监听器和从BeanFactory中获取的监听器
|
||||||
|
registerListeners();
|
||||||
|
|
||||||
|
// [11] 🏭 核心步骤:完成BeanFactory的初始化
|
||||||
|
// 实例化所有非懒加载的单例Bean,执行依赖注入、AOP代理等
|
||||||
|
finishBeanFactoryInitialization(beanFactory);
|
||||||
|
|
||||||
|
// [12] 完成刷新过程 - 发布ContextRefreshedEvent事件
|
||||||
|
// 标记容器启动完成,可以正常工作了
|
||||||
|
finishRefresh();
|
||||||
|
|
||||||
|
} catch (BeansException ex) {
|
||||||
|
// 如果刷新过程中出现异常,需要销毁已经创建的单例Bean,防止资源泄漏
|
||||||
|
if (logger.isWarnEnabled()) {
|
||||||
|
logger.warn("Exception encountered during context initialization - " +
|
||||||
|
"cancelling refresh attempt: " + ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销毁已经创建的单例Bean
|
||||||
|
destroyBeans();
|
||||||
|
|
||||||
|
// 重置容器状态为未激活
|
||||||
|
cancelRefresh(ex);
|
||||||
|
|
||||||
|
// 重新抛出异常
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
// 最后重置一些公共的缓存,比如反射缓存
|
||||||
|
resetCommonCaches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4.1 prepareRefresh()
|
||||||
|
```java
|
||||||
|
protected void prepareRefresh() {
|
||||||
|
this.startupDate = System.currentTimeMillis();
|
||||||
|
this.closed.set(false);
|
||||||
|
this.active.set(true);
|
||||||
|
|
||||||
|
if (logger.isInfoEnabled()) {
|
||||||
|
logger.info("Refreshing " + this);
|
||||||
|
}
|
||||||
|
|
||||||
|
initPropertySources();
|
||||||
|
getEnvironment().validateRequiredProperties();
|
||||||
|
this.earlyApplicationEvents = new LinkedHashSet<>();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 这是容器启动的第一步,主要做初始化准备工作
|
||||||
|
- 设置容器的启动时间戳和活跃状态
|
||||||
|
- 验证必要的环境属性是否配置正确(比如通过@Value注解标记为必须的属性)
|
||||||
|
- 初始化早期事件集合,用于存储在事件多播器创建前发布的事件
|
||||||
|
- 为整个刷新过程奠定基础状态
|
||||||
|
|
||||||
|
### 5.4.2 obtainFreshBeanFactory()
|
||||||
|
```java
|
||||||
|
protected ConfigurableListableBeanFactory obtainFreshBeanFactory() {
|
||||||
|
refreshBeanFactory();
|
||||||
|
return getBeanFactory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子类实现示例
|
||||||
|
protected final void refreshBeanFactory() throws IllegalStateException {
|
||||||
|
if (hasBeanFactory()) {
|
||||||
|
destroyBeans();
|
||||||
|
closeBeanFactory();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
DefaultListableBeanFactory beanFactory = createBeanFactory();
|
||||||
|
customizeBeanFactory(beanFactory);
|
||||||
|
loadBeanDefinitions(beanFactory);
|
||||||
|
this.beanFactory = beanFactory;
|
||||||
|
}
|
||||||
|
catch (IOException ex) {
|
||||||
|
throw new ApplicationContextException("I/O error", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 创建或刷新底层的BeanFactory(Spring容器的核心工厂)
|
||||||
|
- 如果是刷新操作,会先销毁旧的BeanFactory和所有Bean实例
|
||||||
|
- 创建新的DefaultListableBeanFactory实例
|
||||||
|
- 加载Bean定义(从XML文件或注解配置中解析Bean的元数据)
|
||||||
|
- 这个步骤完成后,容器就知道了有哪些Bean需要管理,但还没有创建Bean实例
|
||||||
|
|
||||||
|
### 5.4.3 prepareBeanFactory()
|
||||||
|
```java
|
||||||
|
protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) {
|
||||||
|
beanFactory.setBeanClassLoader(getClassLoader());
|
||||||
|
beanFactory.setBeanExpressionResolver(new StandardBeanExpressionResolver());
|
||||||
|
beanFactory.addPropertyEditorRegistrar(new ResourceEditorRegistrar(this, getEnvironment()));
|
||||||
|
|
||||||
|
beanFactory.addBeanPostProcessor(new ApplicationContextAwareProcessor(this));
|
||||||
|
beanFactory.ignoreDependencyInterface(EnvironmentAware.class);
|
||||||
|
beanFactory.ignoreDependencyInterface(ApplicationContextAware.class);
|
||||||
|
|
||||||
|
beanFactory.registerResolvableDependency(BeanFactory.class, beanFactory);
|
||||||
|
beanFactory.registerResolvableDependency(ApplicationContext.class, this);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 配置BeanFactory的基本设置和功能组件
|
||||||
|
- 设置类加载器、SPEL表达式解析器、属性编辑器
|
||||||
|
- 注册ApplicationContextAwareProcessor,这是实现各种Aware接口的关键
|
||||||
|
- 忽略某些接口的依赖注入(这些接口由容器特殊处理)
|
||||||
|
- 注册可解析的依赖关系,当Bean需要这些类型时直接返回容器实例
|
||||||
|
- 为BeanFactory添加基本的功能支持
|
||||||
|
|
||||||
|
### 5.4.4 postProcessBeanFactory()
|
||||||
|
```java
|
||||||
|
protected void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
|
||||||
|
// 空方法 - 模板方法模式
|
||||||
|
// 子类可以覆盖此方法添加特定逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 模板方法,允许子类在标准初始化后对BeanFactory进行自定义配置
|
||||||
|
- 在Web环境中,会在这里注册Servlet相关的Scope(request、session等)
|
||||||
|
- 可以添加特定环境的BeanPostProcessor或进行其他特殊配置
|
||||||
|
- 这是框架扩展的重要切入点
|
||||||
|
|
||||||
|
### 5.4.5 invokeBeanFactoryPostProcessors()
|
||||||
|
```java
|
||||||
|
protected void invokeBeanFactoryPostProcessors(ConfigurableListableBeanFactory beanFactory) {
|
||||||
|
PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(beanFactory, getBeanFactoryPostProcessors());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 🚀 这是最关键的一步,负责扫描和注册所有的Bean定义
|
||||||
|
- 执行所有BeanFactoryPostProcessor,它们可以修改Bean的定义信息
|
||||||
|
- 特别是ConfigurationClassPostProcessor,它负责:
|
||||||
|
扫描@Component、@Service、@Repository等注解
|
||||||
|
处理@Configuration配置类
|
||||||
|
解析@Bean方法定义
|
||||||
|
处理@Import、@ComponentScan等注解
|
||||||
|
- 这一步完成后,所有的Bean定义都已经被解析和注册
|
||||||
|
|
||||||
|
### 5.4.6 registerBeanPostProcessors()
|
||||||
|
```java
|
||||||
|
protected void registerBeanPostProcessors(ConfigurableListableBeanFactory beanFactory) {
|
||||||
|
PostProcessorRegistrationDelegate.registerBeanPostProcessors(beanFactory, this);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 注册所有的BeanPostProcessor到BeanFactory中
|
||||||
|
- 包括AOP、事务管理、自定义的Bean后置处理器等
|
||||||
|
- 注意:此时只是注册,真正的调用要等到Bean实例化时
|
||||||
|
- 这些处理器按照优先级顺序注册,确保执行顺序正确
|
||||||
|
- 这是Spring AOP和事务管理等高级功能的基础
|
||||||
|
|
||||||
|
### 5.4.7 initMessageSource()
|
||||||
|
```java
|
||||||
|
protected void initMessageSource() {
|
||||||
|
ConfigurableListableBeanFactory beanFactory = getBeanFactory();
|
||||||
|
if (beanFactory.containsLocalBean(MESSAGE_SOURCE_BEAN_NAME)) {
|
||||||
|
this.messageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSource.class);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
DelegatingMessageSource dms = new DelegatingMessageSource();
|
||||||
|
this.messageSource = dms;
|
||||||
|
beanFactory.registerSingleton(MESSAGE_SOURCE_BEAN_NAME, this.messageSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 初始化国际化消息支持
|
||||||
|
- 如果用户配置了自定义的MessageSource,就使用用户的配置
|
||||||
|
- 否则创建默认的DelegatingMessageSource
|
||||||
|
- 支持从属性文件读取多语言消息,实现国际化功能
|
||||||
|
- 用于解析${}占位符和国际化消息
|
||||||
|
|
||||||
|
### 5.4.8 initApplicationEventMulticaster()
|
||||||
|
```java
|
||||||
|
protected void initApplicationEventMulticaster() {
|
||||||
|
ConfigurableListableBeanFactory beanFactory = getBeanFactory();
|
||||||
|
if (beanFactory.containsLocalBean(APPLICATION_EVENT_MULTICASTER_BEAN_NAME)) {
|
||||||
|
this.applicationEventMulticaster = beanFactory.getBean(APPLICATION_EVENT_MULTICASTER_BEAN_NAME, ApplicationEventMulticaster.class);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.applicationEventMulticaster = new SimpleApplicationEventMulticaster(beanFactory);
|
||||||
|
beanFactory.registerSingleton(APPLICATION_EVENT_MULTICASTER_BEAN_NAME, this.applicationEventMulticaster);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 初始化应用事件发布机制的核心组件
|
||||||
|
- 如果用户配置了自定义的事件广播器,就使用用户的配置
|
||||||
|
- 否则创建默认的SimpleApplicationEventMulticaster
|
||||||
|
- 这个组件负责将应用事件广播给所有监听器
|
||||||
|
- 支持观察者模式,实现应用内组件间的解耦通信
|
||||||
|
|
||||||
|
### 5.4.9 onRefresh() - 刷新时扩展点
|
||||||
|
```java
|
||||||
|
protected void onRefresh() throws BeansException {
|
||||||
|
// 空方法 - 模板方法
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 模板方法,允许子类在特定时机执行自定义逻辑
|
||||||
|
- 在Spring Boot中,这里会启动内嵌的Web服务器(Tomcat、Jetty等)
|
||||||
|
- 在Spring MVC中,这里会初始化HandlerMapping、HandlerAdapter等组件
|
||||||
|
- 这是框架扩展的重要切入点,不同的ApplicationContext实现有不同的行为
|
||||||
|
|
||||||
|
### 5.4.10 registerListeners()
|
||||||
|
```java
|
||||||
|
protected void registerListeners() {
|
||||||
|
for (ApplicationListener<?> listener : getApplicationListeners()) {
|
||||||
|
getApplicationEventMulticaster().addApplicationListener(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] listenerBeanNames = getBeanNamesForType(ApplicationListener.class, true, false);
|
||||||
|
for (String listenerBeanName : listenerBeanNames) {
|
||||||
|
getApplicationEventMulticaster().addApplicationListenerBean(listenerBeanName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布早期事件
|
||||||
|
if (this.earlyApplicationEvents != null) {
|
||||||
|
for (ApplicationEvent earlyEvent : this.earlyApplicationEvents) {
|
||||||
|
getApplicationEventMulticaster().multicastEvent(earlyEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 注册所有的事件监听器到事件广播器
|
||||||
|
- 包括静态注册的监听器和从BeanFactory中获取的监听器Bean
|
||||||
|
- 发布在监听器注册前收集的早期事件
|
||||||
|
- 确保事件发布机制完整可用,应用事件能够正确分发
|
||||||
|
|
||||||
|
### 5.4.11 finishBeanFactoryInitialization()
|
||||||
|
```java
|
||||||
|
protected void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory) {
|
||||||
|
if (beanFactory.containsBean(CONVERSION_SERVICE_BEAN_NAME)) {
|
||||||
|
beanFactory.setConversionService(beanFactory.getBean(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!beanFactory.hasEmbeddedValueResolver()) {
|
||||||
|
beanFactory.addEmbeddedValueResolver(strVal -> getEnvironment().resolvePlaceholders(strVal));
|
||||||
|
}
|
||||||
|
|
||||||
|
beanFactory.freezeConfiguration();
|
||||||
|
beanFactory.preInstantiateSingletons();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 🏭 这是最核心的步骤,真正创建Bean实例
|
||||||
|
- 初始化类型转换服务(ConversionService)
|
||||||
|
- 注册嵌入式值解析器(用于解析${}占位符)
|
||||||
|
- 冻结Bean定义,防止后续修改
|
||||||
|
- 实例化所有非懒加载的单例Bean,这个过程中会:
|
||||||
|
创建Bean实例
|
||||||
|
执行依赖注入(@Autowired、@Resource等)
|
||||||
|
执行BeanPostProcessor(AOP代理在这里发生)
|
||||||
|
调用初始化方法(@PostConstruct、InitializingBean)
|
||||||
|
- 这是容器启动过程中最耗时的步骤
|
||||||
|
|
||||||
|
### 5.4.12 finishRefresh()
|
||||||
|
```java
|
||||||
|
protected void finishRefresh() {
|
||||||
|
clearResourceCaches();
|
||||||
|
initLifecycleProcessor();
|
||||||
|
getLifecycleProcessor().onRefresh();
|
||||||
|
publishEvent(new ContextRefreshedEvent(this));
|
||||||
|
LiveBeansView.registerApplicationContext(this);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 完成容器的启动过程,进行最后的清理和通知工作
|
||||||
|
- 清除资源缓存,释放临时资源
|
||||||
|
- 初始化生命周期处理器,管理Bean的生命周期
|
||||||
|
- 发布ContextRefreshedEvent事件,通知所有监听器容器已启动完成
|
||||||
|
- 注册LiveBeansView(用于监控和调试)
|
||||||
|
- 标记整个容器启动流程结束,容器进入就绪状态
|
||||||
|
|
||||||
|
### 5.4.13 总结
|
||||||
|
  refresh()方法按固定顺序执行12个步骤,核心可归纳为:**准备容器环境 → 解析并注册Bean定义→ 初始化事件、消息等基础设施 → 实例化所有非懒加载的单例Bean→ 完成启动并发布事件**。整个过程通过模板方法模式定义,确保了扩展性和一致性。
|
||||||
|
  核心在于两个关键扩展机制:**BeanFactoryPostProcessor在Bean定义阶段介入,负责修改和注册Bean的定义信息;BeanPostProcessor在Bean实例化阶段介入,负责对创建好的Bean实例进行增强和包装。**
|
||||||
95
docs/Web/SpringBoot/Spring-MVC.md
Normal file
95
docs/Web/SpringBoot/Spring-MVC.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
title: Spring MVC简介
|
||||||
|
date: 2025-11-27
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、基础概念
|
||||||
|
  Spring MVC(Spring Model-View-Controller)是 Spring Framework 中的一部分,它是一个基于 **请求驱动** 的 Web 框架,主要用于构建 Web 应用程序,并且遵循 MVC(模型-视图-控制器) 设计模式。它将 Web 应用的业务逻辑、用户界面和请求处理分离,使得代码更加模块化和可维护。
|
||||||
|
  [Spring Web MVC](https://docs.springjava.cn/spring-framework/reference/web/webmvc.html)
|
||||||
|
|
||||||
|
# 二、具体流程
|
||||||
|

|
||||||
|
|
||||||
|
1. 客户端发起HTTP请求
|
||||||
|
|
||||||
|
  客户端(浏览器、API 客户端等)向服务器发送 HTTP 请求。请求包含了目标 URL、HTTP 方法(GET、POST 等)以及可能的请求参数。
|
||||||
|
前端通过发送 HTTP 请求(通常是 AJAX 请求)到后端的 API 端点,并期望获得响应(通常是 JSON 数据)。
|
||||||
|
|
||||||
|
2. 请求到达 DispatcherServlet
|
||||||
|
|
||||||
|
  所有 HTTP 请求都会先到达 Spring MVC 的核心组件:**DispatcherServlet**。它是前端控制器,负责将请求分发给正确的处理器。DispatcherServlet 是所有请求的入口,Spring Boot 会自动配置它。
|
||||||
|
  DispatcherServlet 接受所有的 HTTP 请求,根据请求 URL 和 HTTP 方法,定位匹配的控制器方法,将请求交给相应的 Handler Mapping 来匹配控制器。在请求处理前后,执行 拦截器(可选)。最终通过 View Resolver 返回一个合适的视图(对于 RESTful API,通常返回 JSON 格式的数据)。
|
||||||
|
|
||||||
|
3. 查找处理器(Handler Mapping)
|
||||||
|
|
||||||
|
  DispatcherServlet 根据请求的 URL 和 HTTP 方法,查找与之匹配的处理器方法(通常是一个控制器中的方法)。这是由 HandlerMapping 完成的。Spring MVC 提供了多种方式来映射 URL 到控制器方法,例如 @RequestMapping、@GetMapping 等注解。
|
||||||
|
  @RestController:该注解标记的类会返回 JSON 数据。
|
||||||
|
  @RequestMapping:用于映射 URL 路径和 HTTP 方法。
|
||||||
|
  @GetMapping、@PostMapping 等:简化版本的 @RequestMapping。
|
||||||
|
|
||||||
|
4. 执行拦截器(Interceptor)
|
||||||
|
|
||||||
|
  在请求到达控制器之前,DispatcherServlet 会依次执行配置的拦截器。拦截器主要用于日志记录、安全检查、请求参数的预处理等。
|
||||||
|
  preHandle:请求进入控制器方法之前。
|
||||||
|
  postHandle:请求完成后,视图渲染之前。
|
||||||
|
  afterCompletion:视图渲染完成后。
|
||||||
|
  **如果拦截器的 preHandle 方法返回 false,请求会被拦截,后续的处理不会继续进行。**
|
||||||
|
|
||||||
|
5. 控制器方法处理请求(Handler Execution)
|
||||||
|
|
||||||
|
  DispatcherServlet 调用合适的控制器方法来处理请求。控制器方法负责处理请求的业务逻辑,并返回一个 Java 对象,这个对象会被自动转换成 JSON 格式返回给客户端。
|
||||||
|
  Spring 通过 @ResponseBody 注解(或 @RestController)实现将 Java 对象自动转换为 JSON 格式。默认情况下,Spring 使用 Jackson 来将 Java 对象序列化成 JSON 数据。
|
||||||
|
  @RestController:将控制器标记为返回 JSON 数据的 RESTful 控制器。
|
||||||
|
  @ResponseBody:标记方法返回的数据需要直接作为响应体返回,而不是解析为视图。
|
||||||
|
  @RequestParam:用于获取单个请求参数。
|
||||||
|
  @PathVariable:用于从 URL 中提取变量(如 /users/{id} 中的 {id})。
|
||||||
|
|
||||||
|
6. 响应返回客户端
|
||||||
|
|
||||||
|
  DispatcherServlet 将处理完的结果(JSON 数据)返回给客户端。响应数据通常会包含:
|
||||||
|
  **状态码**:如 200 OK(成功)、201 Created(资源创建成功)、404 Not Found(资源未找到)、500 Internal Server Error(服务器错误)等。
|
||||||
|
  **响应头**:通常包括 Content-Type: application/json,表示响应体是 JSON 格式。
|
||||||
|
  **响应体**:包含返回的数据,通常是一个 JSON 格式的对象或数组。
|
||||||
|
|
||||||
|
注:以上只适用于基于RESTful API 前后端分离的架构。
|
||||||
|
|
||||||
|
# 三、完整流程
|
||||||
|

|
||||||
|
  在SpringMVC前面还有Tomcat容器层。客户端发起请求后,先经过Tomcat容器,根据请求的 URL 确定该请求是否是静态资源请求(如 .html、.css、.js)。如果是静态资源请求,Tomcat 会直接返回文件,而不经过 Spring MVC 的请求处理链。对于动态请求(如请求 /user 等),Tomcat 会将这些请求交给 DispatcherServlet 进行处理。
|
||||||
|
  在进入DispatcherServlet前可以增加过滤器进行处理。过滤器不属于Spring部分。
|
||||||
|
  Tomcat 是一个轻量级的 Web 服务器和 Servlet 容器,支持 Java Servlet 和 JSP 规范,能够处理 HTTP 请求并生成动态 Web 内容。
|
||||||
|
  Servlet 是 Java 编写的服务器端程序,能够接收 HTTP 请求并生成响应,它是动态 Web 内容生成的核心技术。
|
||||||
|
  Tomcat 管理 Servlet 的生命周期,并负责处理客户端的请求和返回响应。
|
||||||
|
|
||||||
|
# 四、重要知识点
|
||||||
|
## 4.1 拦截器和过滤器区别
|
||||||
|
1. 拦截器不依赖与servlet容器,过滤器依赖与servlet容器。
|
||||||
|
2. 拦截器只能对action请求(DispatcherServlet 映射的请求)起作用,而过滤器则可以对几乎所有的请求起作用。
|
||||||
|
3. 拦截器可以访问容器中的Bean(DI),而过滤器不能访问(基于spring注册的过滤器也可以访问容器中的bean)。
|
||||||
|
4. 执行顺序:过滤器->拦截器
|
||||||
|
|
||||||
|
## 4.2 拦截器执行顺序
|
||||||
|
  HandlerInterceptor 拦截器执行顺序:
|
||||||
|
1. preHandle():请求处理前 按注册顺序依次执行。
|
||||||
|
2. Controller:请求处理 请求到达Controller并被处理。
|
||||||
|
3. postHandle():请求处理后,视图渲染前 按注册顺序逆序执行。
|
||||||
|
4. afterCompletion():视图渲染后 按注册顺序逆序执行。
|
||||||
|
|
||||||
|
## 4.3 请求和响应体拦截器
|
||||||
|
  RequestBodyAdvice, ResponseBodyAdvice主要发生在Controller执行前后:
|
||||||
|
1. preHandle():请求处理前
|
||||||
|
2. beforeBodyRead:请求体反序列化前
|
||||||
|
3. @RequestBody:Controller方法参数绑定
|
||||||
|
4. Controller:请求处理
|
||||||
|
5. beforeBodyWrite():响应体序列化之前
|
||||||
|
6. postHandle():请求处理后
|
||||||
|
7. 视图渲染
|
||||||
|
8. afterCompletion():请求结束
|
||||||
|
|
||||||
|
## 4.4 @Controller 和 @RestController区别
|
||||||
|
  @Controller:用于定义一个 Spring MVC 控制器类。它通常用于返回视图(如 JSP 或 Thymeleaf)。
|
||||||
|
  @RestController:是 @Controller 和 @ResponseBody 的组合,用于开发 RESTful API。它将方法的返回值直接写入 HTTP 响应体中,通常返回 JSON 或 XML 格式的数据。
|
||||||
|
|
||||||
|
## 4.5 异常处理
|
||||||
|
  @ExceptionHandler:用于控制器类或方法中处理特定异常。它会捕获指定的异常并返回相应的响应(如错误页面、错误消息等)。
|
||||||
|
  @ControllerAdvice:是一个全局异常处理器,用于捕获应用中所有控制器抛出的异常。
|
||||||
72
docs/Web/SpringBoot/SpringBoot-Annotation.md
Normal file
72
docs/Web/SpringBoot/SpringBoot-Annotation.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
title: SpingBoot注解
|
||||||
|
date: 2025-12-30
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、注解
|
||||||
|
## 1.1 定义
|
||||||
|
  注解(Annotation)是 JDK5.0 引入的特性,可以理解为:给代码添加的 “元数据”(描述数据的数据),就像给代码贴标签,**本身不直接影响代码执行,但可以被编译器、框架(如 SpringBoot)读取并做相应处理**。
|
||||||
|
|
||||||
|
## 1.2 分类
|
||||||
|
| 类型 | 核心作用 | 典型示例 |
|
||||||
|
|----------------|--------------------|------------------|
|
||||||
|
| 源码注解 | 仅在源码编译阶段生效,编译后注解消失 | `@Override` |
|
||||||
|
| 编译时注解 | 编译期生效,注解信息保留到class文件,但JVM运行时不加载 | Lombok的`@Data` |
|
||||||
|
| 运行时注解 | 整个生命周期都存在(源码→编译→运行),可通过反射动态获取注解信息 | SpringBoot的`@RestController`、`@Service`、`@Transactional` |
|
||||||
|
|
||||||
|
## 1.3 示例
|
||||||
|
```java
|
||||||
|
import java.lang.annotation.*;
|
||||||
|
|
||||||
|
// 1. 注解的元注解(描述注解的注解)
|
||||||
|
@Target(ElementType.METHOD) // 注解作用在方法上
|
||||||
|
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,可通过反射获取
|
||||||
|
@Documented // 生成Javadoc时包含该注解
|
||||||
|
public @interface MyAnnotation {
|
||||||
|
// 注解的属性(类似方法,可设置默认值)
|
||||||
|
String value() default "默认描述";
|
||||||
|
int num() default 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 使用自定义注解
|
||||||
|
public class AnnotationTest {
|
||||||
|
@MyAnnotation(value = "测试方法", num = 10)
|
||||||
|
public void test() {
|
||||||
|
System.out.println("执行测试方法");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 通过反射读取注解
|
||||||
|
public static void main(String[] args) throws NoSuchMethodException {
|
||||||
|
// 获取方法对象
|
||||||
|
java.lang.reflect.Method method = AnnotationTest.class.getMethod("test");
|
||||||
|
// 判断方法是否有该注解
|
||||||
|
if (method.isAnnotationPresent(MyAnnotation.class)) {
|
||||||
|
// 获取注解实例
|
||||||
|
MyAnnotation annotation = method.getAnnotation(MyAnnotation.class);
|
||||||
|
// 读取注解属性
|
||||||
|
System.out.println("注解value:" + annotation.value()); // 输出:测试方法
|
||||||
|
System.out.println("注解num:" + annotation.num()); // 输出:10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  @Target:指定注解能作用的位置(如ElementType.METHOD= 方法、ElementType.TYPE= 类 / 接口、ElementType.FIELD= 字段)。
|
||||||
|
  @Retention:指定注解的保留阶段(RUNTIME是 SpringBoot 注解最常用的)
|
||||||
|
  首先定义注解的@Target和@Retention信息,然后设置注解的属性,类似于方法的参数,比如这里的value和num,需要在使用的时候通过命名参数的形式传递过来。如果是RUNTIME类型的注解,可以通过反射来获取方法和注解的参数,实现自定义逻辑功能。
|
||||||
|
|
||||||
|
# 二、SpringBoot核心注解
|
||||||
|
## 2.1 启动类注解
|
||||||
|
  @SpringBootApplication为SpringBoot启动类注解,由以下三个注解组成:
|
||||||
|
1. @Configuration:标记类为配置类(替代 XML 配置)
|
||||||
|
2. @EnableAutoConfiguration:开启自动配置(SpringBoot 核心,自动配置 Tomcat、数据库连接等)
|
||||||
|
3. @ComponentScan:扫描当前包及子包下的 @Component、@Service 等注解的类,纳入 Spring 容器管理
|
||||||
|
|
||||||
|
## 2.2 组件注册注解
|
||||||
|
| 注解 | 作用 | 使用场景 |
|
||||||
|
|----------------|--------------------------|------------------------------------------------------------------|
|
||||||
|
| @Component | 通用组件注解 | 通用工具类、非业务层类 |
|
||||||
|
| @Controller | 控制器注解 | MVC 的控制层(返回页面) |
|
||||||
|
| @RestController| REST 控制器 | API 接口层(返回 JSON/XML,= @Controller + @ResponseBody) |
|
||||||
|
| @Service | 服务层注解 | 业务逻辑层 |
|
||||||
|
| @Repository | 数据访问层注解 | DAO 层 / 持久层(如 MyBatis 的 Mapper 接口) |
|
||||||
65
docs/Web/SpringBoot/SpringBoot-Bean.md
Normal file
65
docs/Web/SpringBoot/SpringBoot-Bean.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
title: SpringBoot Bean简介
|
||||||
|
date: 2025-11-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、Bean概念
|
||||||
|
## 1.1 定义
|
||||||
|
  Spring bean是Spring框架在运行时管理的对象,Bean是一个由Spring IoC容器实例化、组装和管理的对象。
|
||||||
|
  一个 Bean 可以是任何普通的 Java 对象,例如 POJO、Service、Respository、Controller 等等。将一个类声明为 Bean 的方式可以是在类级别上使用 `@Component` 注解或其派生注解(`@Service`、`@Repository`、`@Controller`等),也可以是通过配置文件进行显式的声明。
|
||||||
|
|
||||||
|
## 1.2 实例化
|
||||||
|
  Spring 容器负责实例化 Bean。当应用程序启动时,Spring 容器会根据配置信息或注解扫描的结果,找到并实例化所有被标记为 Bean 的类,并将它们加入容器中。实例化的过程由 Spring 的 IoC 容器负责。
|
||||||
|
|
||||||
|
## 1.3 管理
|
||||||
|
  一旦 Bean 被实例化,Spring 容器将负责管理 Bean 的生命周期和依赖关系。它会根据配置文件或注解的信息,自动解决 Bean 之间的依赖关系,确保在需要的时候正确的注入依赖。Spring 容器还会负责销毁不再需要的 Bean。
|
||||||
|
|
||||||
|
## 1.4 依赖注入
|
||||||
|
  依赖注入是 Spring 框架的一个重要特性,它允许通过自动或显式配置的方式将 Bean 的依赖项注入到其它 Bean 中。依赖注入可以通过构造函数注入、Setter 方法注入或字段注入的方式实现,其中最常见的是使用 `@Autowired`注解进行注入。
|
||||||
|
|
||||||
|
## 1.5 作用域
|
||||||
|
  Spring 框架提供了多种作用域(scope)来管理 Bean 的生命周期。常见的作用域包括单例(Singleton)、原型(Prototype)、会话(Session)、请求(Request)等。默认情况下,Bean 是单例的,即每个容器中只存在一个实例。但可以根据需要配置其它作用域。
|
||||||
|
|
||||||
|
## 1.6 自动装配
|
||||||
|
  Spring Boot 支持自动装配(Auto - wiring),它能够根据类型或名称自动解析和注入依赖关系。通过在需要注入的字段、构造函数或 Setter 方法上使用 `@Autowired` 注解,Spring 容器会自动查找并注入对应的 Bean。
|
||||||
|
|
||||||
|
# 二、Bean生命周期
|
||||||
|

|
||||||
|
|
||||||
|
## 2.1 实例化
|
||||||
|
  指创建对象的过程,也就是说,当 Spring 容器启动时,它会扫描所有的配置文件,读取 Bean 定义,然后根据定义创建 Bean 对象的实例。这个过程中,Spring 容器会使用反射机制来调用 Bean 的构造函数来实例化对象。
|
||||||
|
|
||||||
|
## 2.2 属性赋值
|
||||||
|
  对 Bean 的实例化对象进行属性填充;
|
||||||
|
|
||||||
|
## 2.3 初始化
|
||||||
|
  指 Bean 对象被创建后,Spring 容器会根据配置文件中的定义来对其进行一系列的属性设置、依赖注入等操作,使其可以正常运行。这个过程中,Spring 容器会调用 Bean 的一些特定方法,如 set 方法、init-method 方法等来完成对象的初始化。
|
||||||
|
|
||||||
|
## 2.4 销毁
|
||||||
|
  执行一些预定义的销毁方法;
|
||||||
|
|
||||||
|
# 三、Bean注解——@Component
|
||||||
|
## 基本概念
|
||||||
|
  @Component 是 Spring 框架中的一个注解,用于将类标记为 Spring 管理的组件。当一个类被 @Component 注解修饰时,Spring 会在启动时自动扫描并将该类实例化为 Bean,放入应用程序的上下文(ApplicationContext)中,以便在其他地方可以通过依赖注入使用。
|
||||||
|
|
||||||
|
## 主要作用
|
||||||
|
1. 组件扫描:Spring 通过组件扫描机制自动发现并注册被 @Component 注解的类。
|
||||||
|
2. Bean 定义:被注解的类会被视为 Spring Bean,由 Spring 容器管理其生命周期。
|
||||||
|
3. 依赖注入:其他组件可以通过注解(如 @Autowired)注入这些 Bean。
|
||||||
|
|
||||||
|
## 其他衍生注解
|
||||||
|
| 注解 | 所属层 | 继承关系 | 适用场景 |
|
||||||
|
|------------|------------|-------------|----------|
|
||||||
|
| @Component | 通用组件 | 基础注解 | 不适合特定层的组件(如工具类、配置类) |
|
||||||
|
| @Repository | 数据访问层 (DAO) | 继承 @Component | 数据库操作类、ORM 映射类(如 JPA Repository) |
|
||||||
|
| @Service | 服务层 (Service) | 继承 @Component | 业务逻辑处理(如订单处理、用户认证) |
|
||||||
|
| @Controller | 控制层 (MVC/Web) | 继承 @Component | 处理 HTTP 请求的控制器(如 REST API、Web 页面控制器) |
|
||||||
|
| @RestController | 控制层 (REST API) | 继承 @Controller | RESTful API 接口开发 |
|
||||||
|
|
||||||
|
| 注解 | 功能描述 | Spring 自动处理特性 |
|
||||||
|
|------------------|----------------------|-------------------|
|
||||||
|
| @Component | 标记类为 Spring Bean,通过组件扫描自动注册。<br>无特定语义,是其他组件注解的基础。 | - Bean 生命周期管理<br>- 依赖注入支持 |
|
||||||
|
| @Repository | 标记数据访问组件(如数据库操作类),<br>自动处理数据访问异常。 | - 异常转换<br>- DAO 组件扫描 |
|
||||||
|
| @Service | 标记业务逻辑组件,负责处理业务流程、事务管理等。<br>提供语义化,明确该类在架构中的角色。 | - 与 @Transactional 注解默认集成<br>- IDE 识别为服务层组件 |
|
||||||
|
| @Controller | 标记 Web 控制器,处理 HTTP 请求。<br>与 Spring MVC 框架紧密集成。 | - 请求映射自动注册<br>- 支持 @RequestMapping、@GetMapping 等注解<br>- 视图解析(返回视图名称或 JSON) |
|
||||||
|
| @RestController | @Controller + @ResponseBody 的组合,专门用于 RESTful API,直接返回 JSON/XML 等数据。 | - 所有方法默认返回 JSON/XML(无需每个方法添加 @ResponseBody) |
|
||||||
120
docs/Web/SpringBoot/SpringBoot-Quartz.md
Normal file
120
docs/Web/SpringBoot/SpringBoot-Quartz.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
title: SpringBoot Quarzt简介和使用
|
||||||
|
date: 2026-06-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、基本概念
|
||||||
|
  Quartz 是 Java 生态最主流的企业级作业调度框架,支持简单间隔触发、Cron 表达式、持久化到数据库、集群部署。
|
||||||
|
- Job(任务) — 实现 execute()方法,写你要执行的业务逻辑
|
||||||
|
- Trigger(触发器) — 定义"什么时候执行",常用 SimpleTrigger(固定间隔)和 CronTrigger(Cron 表达式)
|
||||||
|
- Scheduler(调度器) — 把 Job + Trigger 绑定,负责启动和停止调度
|
||||||
|
|
||||||
|
# 二、原理
|
||||||
|
  Job相当于员工,只知道干活;Trigger 相当于排班表,决定什么时候干活;Scheduler 相当于老板,统一管理 Job 和 Trigger。
|
||||||
|
  Quartz内部有一个核心线程 QuartzSchedulerThread,会不停的检查哪个员工需要在啥时候干活,但并不是按照固定间隔轮询的方式,而是计算出最近一次 Trigger 的触发时间,通过 wait()精确等待,当等待结束后,调度线程会唤醒线程池中的工作线程,执行到点的 Job。如果当前没有任何 Trigger了,默认最多等待 30 秒,然后再重新扫描。如果此时有新增/修改/删除任务了,调度线程会被 notifyAll()立即唤醒,重新计算最近的触发时间。
|
||||||
|
|
||||||
|
# 三、安装
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-quartz</artifactId>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
  持久化:
|
||||||
|
```yml
|
||||||
|
spring:
|
||||||
|
quartz:
|
||||||
|
job-store-type: jdbc
|
||||||
|
```
|
||||||
|
|
||||||
|
  启用后会在数据库自动建表。
|
||||||
|
|
||||||
|
# 四、使用
|
||||||
|
## 4.1 定义Job
|
||||||
|
```java
|
||||||
|
@Component
|
||||||
|
@DisallowConcurrentExecution // 防止同一 Job 并发执行
|
||||||
|
public class QuartzJob extends QuartzJobBean {
|
||||||
|
@Override
|
||||||
|
protected void executeInternal(JobExecutionContext context) {
|
||||||
|
// 获取任务内容
|
||||||
|
JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
|
||||||
|
|
||||||
|
// TODO 根据任务内容执行任务
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.2 定义Trigger
|
||||||
|
```java
|
||||||
|
@Component
|
||||||
|
public class QuartzUtils {
|
||||||
|
public static JobDetail buildJobDetail(Task task, JobKey jobKey) {
|
||||||
|
JobDataMap jobDataMap = new JobDataMap();
|
||||||
|
jobDataMap.put("id", task.getId());
|
||||||
|
jobDataMap.put("type", task.getType());
|
||||||
|
jobDataMap.put("content", task.getContent());
|
||||||
|
|
||||||
|
return JobBuilder.newJob(QuartzJob.class)
|
||||||
|
.withIdentity(jobKey) // 使用 id 作为唯一标识
|
||||||
|
.usingJobData(jobDataMap)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Trigger buildTrigger(Task task, JobKey jobKey) {
|
||||||
|
Trigger trigger;
|
||||||
|
// 判断任务类型:Cron 任务或者一次性任务
|
||||||
|
if (task.getCronExpression() != null && !task.getCronExpression().isEmpty()) {
|
||||||
|
// 定时任务:使用 Cron 表达式
|
||||||
|
trigger = TriggerBuilder.newTrigger()
|
||||||
|
.withIdentity(jobKey.getName() + "Trigger")
|
||||||
|
.withSchedule(CronScheduleBuilder.cronSchedule(task.getCronExpression()))
|
||||||
|
.forJob(jobKey)
|
||||||
|
.build();
|
||||||
|
} else if (task.getExecuteTime() != null) {
|
||||||
|
// 一次性任务:使用 executeTime
|
||||||
|
trigger = TriggerBuilder.newTrigger()
|
||||||
|
.withIdentity(jobKey.getName() + "Trigger")
|
||||||
|
.startAt(task.getExecuteTime())
|
||||||
|
.forJob(jobKey)
|
||||||
|
.build();
|
||||||
|
} else {
|
||||||
|
// 如果没有 Cron 表达式和 executeTime,任务不能调度,抛出异常
|
||||||
|
throw new CustomException("任务没有有效的 Cron 表达式或执行时间");
|
||||||
|
}
|
||||||
|
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
这里的Task可以根据实际情况定义:
|
||||||
|
```java
|
||||||
|
@Data
|
||||||
|
public class Task {
|
||||||
|
private Long id;
|
||||||
|
private String type;
|
||||||
|
private String content;
|
||||||
|
private Date executeTime;
|
||||||
|
private String cronExpression;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 4.3 执行Scheduler
|
||||||
|
```java
|
||||||
|
@Resource
|
||||||
|
private Scheduler scheduler;
|
||||||
|
|
||||||
|
// 新增/更新
|
||||||
|
JobKey jobKey = new JobKey(task.getId().toString());
|
||||||
|
scheduler.scheduleJob(QuartzUtils.buildJobDetail(task, jobKey), QuartzUtils.buildTrigger(task, jobKey));
|
||||||
|
|
||||||
|
// 更新
|
||||||
|
scheduler.rescheduleJob(QuartzUtils.buildJobDetail(task, jobKey), QuartzUtils.buildTrigger(task, jobKey));
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
scheduler.deleteJob(jobKey);
|
||||||
|
```
|
||||||
170
docs/Web/SpringBoot/SpringBoot-Redis.md
Normal file
170
docs/Web/SpringBoot/SpringBoot-Redis.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
title: SpringBoot Redis简介和使用
|
||||||
|
date: 2026-03-22
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  Redis是一个高性能的开源内存数据库,用作缓存、数据库和消息中间件。它以极快的读写速度(基于内存存储)支持字符串、列表、哈希等多种数据结构。Redis提供数据持久化、主从复制、哨兵模式等高可用特性,广泛应用于缓存、会话存储、排行榜、消息队列等场景。
|
||||||
|
|
||||||
|
# 二、安装
|
||||||
|
## 2.1 apt安装
|
||||||
|
1. 通过apt包管理器安装:
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install redis-server
|
||||||
|
sudo systemctl enable redis-server
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 配置远程和密码访问(可选)
|
||||||
|
|
||||||
|
  打开`/etc/redis/redis.conf`文件,更改配置:
|
||||||
|
```conf
|
||||||
|
bind 0.0.0.0 ::1
|
||||||
|
protected-mode yes
|
||||||
|
requirepass password
|
||||||
|
```
|
||||||
|
|
||||||
|
  `systemctl restart redis-server`重启服务。
|
||||||
|
|
||||||
|
## 2.2 docker安装
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:8.6.4
|
||||||
|
container_name: redis
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- ./redis.conf:/usr/local/etc/redis/redis.conf
|
||||||
|
- ./data:/data
|
||||||
|
command: redis-server /usr/local/etc/redis/redis.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
  redis.conf:
|
||||||
|
```conf
|
||||||
|
bind 0.0.0.0
|
||||||
|
protected-mode yes
|
||||||
|
port 6379
|
||||||
|
|
||||||
|
requirepass password
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.3 客户端
|
||||||
|
[Tiny RDM](https://redis.tinycraft.cc/zh/)
|
||||||
|
|
||||||
|
# 三、SpringBoot使用
|
||||||
|
## 3.1 引入依赖
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 配置文件
|
||||||
|
```yml
|
||||||
|
spring:
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 6379
|
||||||
|
password: 123456
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
SpringBoot2中没有data层级
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 3.3 简单使用
|
||||||
|
```java
|
||||||
|
@Component
|
||||||
|
public class RedisUtils {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private StringRedisTemplate redis;
|
||||||
|
|
||||||
|
public void set(String key, String value, long seconds) {
|
||||||
|
redis.opsForValue().set(key, value, seconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String get(String key) {
|
||||||
|
return redis.opsForValue().get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String key) {
|
||||||
|
redis.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean hasKey(String key) {
|
||||||
|
return redis.hasKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配查询
|
||||||
|
public Set<String> scanKeys(String pattern) {
|
||||||
|
return redis.execute((RedisCallback<Set<String>>) connection -> {
|
||||||
|
Set<String> keys = new HashSet<>();
|
||||||
|
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build();
|
||||||
|
|
||||||
|
try (Cursor<byte[]> cursor = connection.keyCommands().scan(options)) {
|
||||||
|
while (cursor.hasNext()) {
|
||||||
|
keys.add(new String(cursor.next(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Redis SCAN 执行失败,pattern=" + pattern, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
生产环境中使用`scan`命令批量查询`key`值,`keys`命令会阻塞`Redis`线程。
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 四、实战
|
||||||
|
## 4.1 设备在线功能
|
||||||
|
  实现一个统计设备是否实时在线的功能。
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/device")
|
||||||
|
public class DeviceController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private RedisUtils redisUtils;
|
||||||
|
|
||||||
|
private static final String HEARTBEAT_KEY = "device:heartbeat:";
|
||||||
|
private static final long EXPIRE_SECONDS = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备心跳接口
|
||||||
|
* 设备每20-30秒调用一次
|
||||||
|
* 心跳key 40秒后过期,过期即认为设备离线
|
||||||
|
*/
|
||||||
|
@PostMapping("/heartbeat")
|
||||||
|
public Boolean heartbeat(@RequestParam String deviceId) {
|
||||||
|
redisUtils.set(HEARTBEAT_KEY + deviceId, String.valueOf(System.currentTimeMillis()), EXPIRE_SECONDS);
|
||||||
|
return Boolean.TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前在线设备列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/online")
|
||||||
|
public Set<String> getOnlineDevices() {
|
||||||
|
Set<String> keys = redisUtils.scanKeys(HEARTBEAT_KEY + "*");
|
||||||
|
Set<String> deviceIds = new HashSet<>();
|
||||||
|
|
||||||
|
for (String key : keys) {
|
||||||
|
String deviceId = key.substring(HEARTBEAT_KEY.length());
|
||||||
|
deviceIds.add(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  客户端每30秒调用一次`/device/heartbeat`接口。
|
||||||
134
docs/Web/SpringBoot/SpringBoot-RestClient.md
Normal file
134
docs/Web/SpringBoot/SpringBoot-RestClient.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
---
|
||||||
|
title: SpringBoot RestClient简介
|
||||||
|
date: 2026-01-06
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  RestClient 是 Spring Framework 6.1(及对应的 Spring Boot 3.2+)推出的新一代同步 HTTP 客户端,设计目标是替代传统的 RestTemplate(Spring 已标记 RestTemplate 为维护模式,不再新增功能),同时结合了 WebClient 的流畅 API 设计,又保持了 RestTemplate 的同步、简单易用的特点。
|
||||||
|
|
||||||
|
# 二、使用
|
||||||
|
## 2.1 创建实例
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
public class RestClientConfig {
|
||||||
|
final String baseUrl = "https://demo.com";
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestClient restClient() {
|
||||||
|
|
||||||
|
return RestClient.builder()
|
||||||
|
// 基础RURL
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
// 默认请求头 请求体为JSON格式
|
||||||
|
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
// 默认请求头 响应体为JSON格式
|
||||||
|
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2 HTTP请求
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class RestClientService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private RestClient restClient;
|
||||||
|
|
||||||
|
// 1. GET 请求:获取单个资源
|
||||||
|
public Post getPostById(Long id) {
|
||||||
|
return restClient.get() // 指定 GET 方法
|
||||||
|
.uri("/posts/{id}", id) // 请求路径(支持路径参数)
|
||||||
|
.retrieve() // 执行请求并获取响应
|
||||||
|
.body(Post.class); // 解析响应体为Post实体
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. GET 请求:获取列表并解析为 Map
|
||||||
|
public Map<String, Object>[] getAllPosts() {
|
||||||
|
return restClient.get()
|
||||||
|
.uri("/posts")
|
||||||
|
.retrieve()
|
||||||
|
.body(Map[].class); // 解析为 Map 数组(适合未知结构的 JSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. POST 请求:提交数据并获取响应
|
||||||
|
public Boolean createPost(Post post) {
|
||||||
|
return restClient.post() // 指定 POST 方法
|
||||||
|
.uri("/posts")
|
||||||
|
.body(post) // 设置请求体
|
||||||
|
.retrieve()
|
||||||
|
.body(Boolean.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. PUT 请求:更新资源
|
||||||
|
public Boolean updatePost(Long id, Post post) {
|
||||||
|
return restClient.put() // 指定 PUT 方法
|
||||||
|
.uri("/posts/{id}", id)
|
||||||
|
.body(post)
|
||||||
|
.retrieve()
|
||||||
|
.body(Boolean.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. DELETE 请求:删除资源
|
||||||
|
public void deletePost(Long id) {
|
||||||
|
restClient.delete() // 指定 DELETE 方法
|
||||||
|
.uri("/posts/{id}", id)
|
||||||
|
.retrieve(); // DELETE 请求通常无响应体
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
在.retrieve()之前的是请求阶段,在此之后是响应阶段。
|
||||||
|
因此在此之前的.body()是请求体,在此之后的.body()是响应体。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 2.3 进阶用法
|
||||||
|
### 2.3.1 设置超时时间
|
||||||
|
```java
|
||||||
|
@Bean
|
||||||
|
public RestClient restClient() {
|
||||||
|
// 创建请求工厂,设置超时
|
||||||
|
ClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory() {{
|
||||||
|
setConnectTimeout(3000); // 连接超时 3 秒
|
||||||
|
setReadTimeout(5000); // 读取超时 5 秒
|
||||||
|
}};
|
||||||
|
|
||||||
|
return RestClient.builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.requestFactory(factory) // 设置请求工厂
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3.2 添加请求拦截器
|
||||||
|
```java
|
||||||
|
@Bean
|
||||||
|
public RestClient restClient() {
|
||||||
|
return RestClient.builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
// 添加拦截器
|
||||||
|
.requestInterceptor(request -> {
|
||||||
|
request.getHeaders().add("Authorization", "Bearer your-token-here");
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3.3 自定义响应处理
|
||||||
|
```java
|
||||||
|
public String getPostWithErrorHandling(Long id) {
|
||||||
|
return restClient.get()
|
||||||
|
.uri("/posts/{id}", id)
|
||||||
|
.retrieve()
|
||||||
|
// 自定义状态码处理
|
||||||
|
.onStatus(status -> status.is4xxClientError(), (request, response) -> {
|
||||||
|
throw new RuntimeException("客户端错误:" + response.getStatusCode() + ",路径:" + request.getURI());
|
||||||
|
})
|
||||||
|
.onStatus(status -> status.is5xxServerError(), (request, response) -> {
|
||||||
|
throw new RuntimeException("服务端错误:" + response.getStatusCode());
|
||||||
|
})
|
||||||
|
.body(String.class);
|
||||||
|
}
|
||||||
|
```
|
||||||
100
docs/Web/SpringBoot/SpringBoot-RocketMQ.md
Normal file
100
docs/Web/SpringBoot/SpringBoot-RocketMQ.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
title: SpringBoot RocketMQ简介和使用
|
||||||
|
date: 2026-06-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  [RocketMQ](https://rocketmq.apache.org/zh/)Apache RocketMQ 是一款面向万亿级消息规模的 AI 原生异步通信引擎。诞生于阿里巴巴高并发电商场景,经过数千家企业的生产验证,RocketMQ 已从高性能消息队列演进为统一消息平台,横跨传统业务消息、事件流处理和新兴的 AI 原生通信三大范式。
|
||||||
|
|
||||||
|
# 二、安装
|
||||||
|
## 2.1 docker安装
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
namesrv:
|
||||||
|
image: apache/rocketmq:5.3.2
|
||||||
|
container_name: rmqnamesrv
|
||||||
|
ports:
|
||||||
|
- 9876:9876
|
||||||
|
networks:
|
||||||
|
- rocketmq
|
||||||
|
command: sh mqnamesrv
|
||||||
|
broker:
|
||||||
|
image: apache/rocketmq:5.3.2
|
||||||
|
container_name: rmqbroker
|
||||||
|
ports:
|
||||||
|
- 10909:10909
|
||||||
|
- 10911:10911
|
||||||
|
- 10912:10912
|
||||||
|
volumes:
|
||||||
|
- ./broker.conf:/home/rocketmq/rocketmq-5.3.2/conf/broker.conf
|
||||||
|
environment:
|
||||||
|
- NAMESRV_ADDR=rmqnamesrv:9876
|
||||||
|
depends_on:
|
||||||
|
- namesrv
|
||||||
|
networks:
|
||||||
|
- rocketmq
|
||||||
|
command: sh mqbroker -c /home/rocketmq/rocketmq-5.3.2/conf/broker.conf
|
||||||
|
proxy:
|
||||||
|
image: apache/rocketmq:5.3.2
|
||||||
|
container_name: rmqproxy
|
||||||
|
networks:
|
||||||
|
- rocketmq
|
||||||
|
depends_on:
|
||||||
|
- broker
|
||||||
|
- namesrv
|
||||||
|
ports:
|
||||||
|
- 8380:8080
|
||||||
|
- 8381:8081
|
||||||
|
restart: on-failure
|
||||||
|
environment:
|
||||||
|
- NAMESRV_ADDR=rmqnamesrv:9876
|
||||||
|
command: sh mqproxy
|
||||||
|
rocketmq-dashboard:
|
||||||
|
image: apacherocketmq/rocketmq-dashboard:2.1.0
|
||||||
|
container_name: rocketmq-dashboard
|
||||||
|
environment:
|
||||||
|
- JAVA_OPTS=-Drocketmq.namesrv.addr=rmqnamesrv:9876
|
||||||
|
ports:
|
||||||
|
- 8382:8082
|
||||||
|
networks:
|
||||||
|
- rocketmq
|
||||||
|
depends_on:
|
||||||
|
- namesrv
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
rocketmq:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
  broker.conf:
|
||||||
|
```conf
|
||||||
|
brokerIP1=192.168.1.100
|
||||||
|
autoCreateTopicEnable=true
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
这里一定要配置brokerIP1,否则Broker的地址是docker的内网地址,具体可以在控制台中查看Broker的地址。
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 三、SpringBoot使用
|
||||||
|
## 3.1 引入依赖
|
||||||
|
```xml
|
||||||
|
<!-- RocketMQ -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.rocketmq</groupId>
|
||||||
|
<artifactId>rocketmq-spring-boot-starter</artifactId>
|
||||||
|
<version>2.3.6</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
需要加上版本号,SpringBoot3使用2.3以上的版本。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 3.2 配置文件
|
||||||
|
```yml
|
||||||
|
rocketmq:
|
||||||
|
name-server: 192.168.1.100:9876
|
||||||
|
producer:
|
||||||
|
group: seckill-producer-group
|
||||||
|
```
|
||||||
359
docs/Web/SpringBoot/SpringBoot-Skills.md
Normal file
359
docs/Web/SpringBoot/SpringBoot-Skills.md
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
---
|
||||||
|
title: SpingBoot技巧
|
||||||
|
date: 2025-12-12
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、JsonView
|
||||||
|
  JsonView 是 Jackson 提供的注解,用于控制对象序列化/反序列化时包含哪些字段。可以实现:
|
||||||
|
- 不同接口返回不同字段
|
||||||
|
- 敏感字段过滤
|
||||||
|
- 前后端数据分离
|
||||||
|
|
||||||
|
## 1.1 添加依赖
|
||||||
|
```xml
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1.2 使用
|
||||||
|
1. 定义视图接口
|
||||||
|
```java
|
||||||
|
// JsonView 视图定义
|
||||||
|
public class Views {
|
||||||
|
// 基础视图 - 所有接口都包含的字段
|
||||||
|
public interface Basic {}
|
||||||
|
|
||||||
|
// 返回给前端时使用
|
||||||
|
public interface Response extends Basic {}
|
||||||
|
|
||||||
|
// 接收前端数据时使用
|
||||||
|
public interface Request extends Basic {}
|
||||||
|
|
||||||
|
// 管理视图 - 包含所有字段
|
||||||
|
public interface AdminView extends ReadView {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Dto对象使用JsonView
|
||||||
|
```java
|
||||||
|
@Data
|
||||||
|
public class UserDTO {
|
||||||
|
@JsonView(Views.Response.class)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@JsonView(Views.Request.class)
|
||||||
|
@NotBlank(message = "密码不能为空")
|
||||||
|
@Size(min = 6, message = "密码至少6位")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@JsonView(Views.Basic.class)
|
||||||
|
@NotBlank(message = "用户名不能为空")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@JsonView(Views.Basic.class)
|
||||||
|
@Email(message = "邮箱格式不正确")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@JsonView(Views.Basic.class)
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@JsonView(AdminView.class)
|
||||||
|
private Boolean isActive;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Controller中使用
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users")
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@JsonView(UserDTO.Response.class)
|
||||||
|
public UserDTO createUser(@RequestBody @JsonView(UserDTO.Request.class) UserDTO userDTO) {
|
||||||
|
return userService.createUser(userDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@JsonView(UserDTO.Response.class)
|
||||||
|
public UserDTO updateUser(@PathVariable Long id,
|
||||||
|
@RequestBody @JsonView(UserDTO.Request.class) UserDTO userDTO) {
|
||||||
|
return userService.updateUser(id, userDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@JsonView(UserDTO.Response.class)
|
||||||
|
public UserDTO getUser(@PathVariable Long id) {
|
||||||
|
return userService.getUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/admin")
|
||||||
|
@JsonView(UserDTO.AdminView.class)
|
||||||
|
public UserDTO getAdminUser(@PathVariable Long id) {
|
||||||
|
return userService.getAdminUserById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 二、MapStruct
|
||||||
|
  MapStruct 是一个 Java 注解处理器,用于生成类型安全的 Bean 映射代码:
|
||||||
|
- 编译时生成映射代码,无运行时性能损失
|
||||||
|
- 类型安全
|
||||||
|
- 支持复杂映射
|
||||||
|
|
||||||
|
## 2.1 添加依赖
|
||||||
|
```xml
|
||||||
|
<properties>
|
||||||
|
<org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct</artifactId>
|
||||||
|
<version>${org.mapstruct.version}</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>${org.mapstruct.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2 使用
|
||||||
|
```java
|
||||||
|
import org.mapstruct.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserMapper {
|
||||||
|
|
||||||
|
@Mapping(target = "name", source = "userName") // 字段名不同
|
||||||
|
@Mapping(target = "phoneNumber", source = "phone") // 字段名不同
|
||||||
|
@Mapping(target = "description", source = "remark") // 字段名不同
|
||||||
|
@Mapping(target = "statusDesc", ignore = true) // 需要特殊处理
|
||||||
|
@Mapping(target = "createTime", ignore = true) // 需要格式化
|
||||||
|
UserDTO toDTO(User user);
|
||||||
|
|
||||||
|
@Mapping(target = "userName", source = "name")
|
||||||
|
@Mapping(target = "phone", source = "phoneNumber")
|
||||||
|
@Mapping(target = "remark", source = "description")
|
||||||
|
@Mapping(target = "password", ignore = true) // 密码不映射
|
||||||
|
User toEntity(UserDTO dto);
|
||||||
|
|
||||||
|
// 列表映射
|
||||||
|
List<UserDTO> toDTOList(List<User> users);
|
||||||
|
|
||||||
|
// 带默认值的映射
|
||||||
|
@Mapping(target = "name", source = "userName")
|
||||||
|
@Mapping(target = "phoneNumber", source = "phone")
|
||||||
|
@Mapping(target = "description", source = "remark", defaultValue = "暂无描述")
|
||||||
|
@Mapping(target = "statusDesc", expression = "java(convertStatus(user.getStatus()))")
|
||||||
|
@Mapping(target = "createTime", expression = "java(formatTime(user.getCreateTime()))")
|
||||||
|
UserDTO toDTOWithDefault(User user);
|
||||||
|
|
||||||
|
// 自定义转换方法
|
||||||
|
default String convertStatus(Integer status) {
|
||||||
|
if (status == null) return "未知";
|
||||||
|
switch (status) {
|
||||||
|
case 0: return "禁用";
|
||||||
|
case 1: return "正常";
|
||||||
|
case 2: return "锁定";
|
||||||
|
default: return "未知";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间格式化方法
|
||||||
|
default String formatTime(LocalDateTime time) {
|
||||||
|
if (time == null) return "";
|
||||||
|
return time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  需要执行`mvn compile`后检查`target/generated-sources`文件夹下是否存在生成的转换代码。
|
||||||
|
|
||||||
|
# 三、Bean Validation
|
||||||
|
  Spring Boot的数据校验基于JSR-303/JSR-380规范(Bean Validation),通常使用Hibernate Validator作为实现。
|
||||||
|
|
||||||
|
## 3.1 添加依赖
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 常用检验注解
|
||||||
|
  Java Bean Validation 常用注解
|
||||||
|
|
||||||
|
| 注解 | 适用类型 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `@NotNull` | 任意类型 | 值不能为 null |
|
||||||
|
| `@NotBlank` | CharSequence | 字符串不能为 null,且必须包含至少一个非空白字符 |
|
||||||
|
| `@NotEmpty` | CharSequence, Collection, Map, Array | 字符串/集合/数组不能为 null 且不能为空(长度/大小大于 0) |
|
||||||
|
| `@Size(min=, max=)` | 字符串、集合、数组 | 限制长度或大小在 min 和 max 之间 |
|
||||||
|
| `@Min(value)` | 数值类型 | 数值必须大于或等于指定值 |
|
||||||
|
| `@Max(value)` | 数值类型 | 数值必须小于或等于指定值 |
|
||||||
|
| `@Email` | 字符串 | 字符串必须是合法的电子邮件地址格式 |
|
||||||
|
| `@Pattern(regexp=)` | 字符串 | 字符串必须匹配指定的正则表达式 |
|
||||||
|
| `@Future` / `@Past` | 日期时间类型 | 日期必须在当前时间的未来 / 过去 |
|
||||||
|
|
||||||
|
## 3.3 使用方法
|
||||||
|
```java
|
||||||
|
public class UserDTO {
|
||||||
|
@NotBlank(message = "用户名不能为空")
|
||||||
|
@Size(min = 3, max = 20, message = "用户名长度必须在3到20个字符之间")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@NotBlank(message = "密码不能为空")
|
||||||
|
@Size(min = 8, message = "密码长度至少为8个字符")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Email(message = "邮箱格式不正确")
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  在Controller的方法参数前使用@Valid或@Validated注解来触发校验:
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
public class UserController {
|
||||||
|
@PostMapping("/users")
|
||||||
|
public String createUser(@Valid @RequestBody UserDTO userDTO) {
|
||||||
|
return "用户创建成功";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.4 处理校验错误
|
||||||
|
```java
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
|
||||||
|
Map<String, String> errors = new HashMap<>();
|
||||||
|
ex.getBindingResult().getAllErrors().forEach((error) -> {
|
||||||
|
String fieldName = ((FieldError) error).getField();
|
||||||
|
String errorMessage = error.getDefaultMessage();
|
||||||
|
errors.put(fieldName, errorMessage);
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.5 自定义注解
|
||||||
|
```java
|
||||||
|
@Documented
|
||||||
|
@Constraint(validatedBy = PhoneValidator.class)
|
||||||
|
@Target({ElementType.FIELD})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface PhoneNumber {
|
||||||
|
String message() default "手机号码格式不正确";
|
||||||
|
Class<?>[] groups() default {};
|
||||||
|
Class<? extends Payload>[] payload() default {};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  校验规则:
|
||||||
|
```java
|
||||||
|
public class PhoneValidator implements ConstraintValidator<PhoneNumber, String> {
|
||||||
|
private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isValid(String value, ConstraintValidatorContext context) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return true; // 使用@NotBlank等注解处理空值
|
||||||
|
}
|
||||||
|
return PHONE_PATTERN.matcher(value).matches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  使用自定义校验:
|
||||||
|
```java
|
||||||
|
public class UserDTO {
|
||||||
|
@PhoneNumber
|
||||||
|
private String mobile;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、ConfigurationProperties注解
|
||||||
|
  @ConfigurationProperties 是 Spring Boot 提供的核心注解,用于将配置文件(如 application.yml/application.properties)中的属性批量绑定到 Java 类的字段上,相比 @Value 注解,它更适合管理一组有层级、有前缀的配置,代码更整洁、可维护性更高。
|
||||||
|
|
||||||
|
## 4.1 配置文件
|
||||||
|
```yml
|
||||||
|
app:
|
||||||
|
name: SpringBootDemo
|
||||||
|
version: 1.0.0
|
||||||
|
author:
|
||||||
|
name: 张三
|
||||||
|
age: 25
|
||||||
|
servers:
|
||||||
|
- 192.168.1.100
|
||||||
|
- 192.168.1.101
|
||||||
|
- 192.168.1.102
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.2 配置属性类
|
||||||
|
```java
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "app")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class AppProperties {
|
||||||
|
// 对应 app.name
|
||||||
|
private String name;
|
||||||
|
// 对应 app.version
|
||||||
|
private String version;
|
||||||
|
// 嵌套属性 - 对应 app.author
|
||||||
|
private Author author;
|
||||||
|
// 集合属性 - 对应 app.servers
|
||||||
|
private List<String> servers;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class Author {
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private Integer age;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  属性类支持嵌套和集合属性。
|
||||||
|
|
||||||
|
## 4.3 使用
|
||||||
|
  在Service或相应地方注入即可:
|
||||||
|
```java
|
||||||
|
@Autowired
|
||||||
|
private AppProperties appProperties;
|
||||||
|
```
|
||||||
287
docs/Web/SpringBoot/SpringBoot-Start-Process.md
Normal file
287
docs/Web/SpringBoot/SpringBoot-Start-Process.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
---
|
||||||
|
title: SpringBoot启动流程
|
||||||
|
date: 2025-11-26
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、启动流程
|
||||||
|

|
||||||
|
|
||||||
|
# 二、 阶段详解
|
||||||
|
## 2.1 SpringApplication初始化
|
||||||
|
**🔹 步骤1.1 - main方法入口执行**
|
||||||
|
- 执行位置:标注@SpringBootApplication的类中的main方法
|
||||||
|
- 核心动作:
|
||||||
|
1. 创建SpringApplication实例对象
|
||||||
|
2. 设置应用的基本配置信息
|
||||||
|
3. 准备启动所需的基础环境
|
||||||
|
|
||||||
|
  main方法是整个Spring Boot应用启动的唯一起点。当Java虚拟机开始执行程序时,首先会调用标注有@SpringBootApplication注解的主类中的main方法。在这个方法内部,会创建SpringApplication实例并调用其run方法,正式开启启动流程。
|
||||||
|
  SpringApplication的构造方法会进行一些重要的初始化工作,包括推断主配置类、设置初始引导类等。推断主配置类的过程是通过分析当前线程的堆栈信息来完成的,确保能够准确找到包含main方法的那个类。
|
||||||
|
|
||||||
|
**🔹 步骤1.2 - 加载SpringFactories配置**
|
||||||
|
- 配置源:META-INF/spring.factories文件
|
||||||
|
- 加载内容:
|
||||||
|
1. ApplicationContextInitializer(上下文初始化器)
|
||||||
|
2. ApplicationListener(应用监听器)
|
||||||
|
3. BeanFactoryPostProcessor(Bean工厂后处理器)
|
||||||
|
4. AutoConfigurationImportSelector(自动配置选择器)
|
||||||
|
|
||||||
|
  Spring Boot使用SpringFactoriesLoader机制从类路径下的**META-INF/spring.factories**文件中加载各种扩展组件。**这种机制是Spring Boot自动配置的核心基础,它允许框架和开发者通过标准的配置文件来注册和发现各种扩展实现。**
|
||||||
|
  配置文件中定义了多种类型的组件,包括**应用上下文初始化器、应用事件监听器、Bean工厂后处理器**等。这些组件将在后续的启动过程中按照特定的顺序被执行,共同完成应用的初始化工作。
|
||||||
|
|
||||||
|
**🔹 步骤1.3 - 推断Web应用类型**
|
||||||
|
- 检测逻辑:
|
||||||
|
1. 检查类路径是否存在Servlet相关类
|
||||||
|
2. 检查Spring MVC相关组件
|
||||||
|
3. 检查WebFlux相关组件
|
||||||
|
- 推断结果
|
||||||
|
🅰️ SERVLET:传统Web应用
|
||||||
|
🅱️ REACTIVE:响应式Web应用
|
||||||
|
©️ NONE:非Web应用
|
||||||
|
|
||||||
|
  应用类型推断是Spring Boot自动配置的重要环节。系统会根据项目的依赖情况自动判断应用类型,这直接影响后续创建的应用上下文类型和内嵌服务器的选择。
|
||||||
|
  **推断过程主要通过检查类路径中是否存在特定的类来完成。例如,如果存在Servlet相关的类,就推断为Web应用;如果存在WebFlux相关的类,就推断为响应式Web应用。** 这种基于类路径的自动推断机制使得开发者无需手动配置应用类型,大大简化了配置工作。
|
||||||
|
|
||||||
|
## 2.2 环境准备与配置加载
|
||||||
|
**🔹 步骤2.1 - 创建环境对象**
|
||||||
|
```
|
||||||
|
环境对象层次结构:
|
||||||
|
Environment(环境接口)
|
||||||
|
↓
|
||||||
|
ConfigurableEnvironment(可配置环境)
|
||||||
|
↓
|
||||||
|
具体环境实现(StandardEnvironment/StandardServletEnvironment)
|
||||||
|
├── PropertySources(属性源列表)
|
||||||
|
├── Profiles(激活的配置文件)
|
||||||
|
└── ConversionService(类型转换服务)
|
||||||
|
```
|
||||||
|
|
||||||
|
  环境对象是Spring Boot应用运行时的配置中心,它负责管理所有的配置属性和运行环境信息。根据应用类型的不同,Spring Boot会创建相应的环境实例。
|
||||||
|
  环境对象采用分层设计,提供了统一的属性访问接口,同时支持多种属性源的动态管理。环境对象还负责管理激活的配置文件(Profile),支持基于不同环境的配置隔离和切换。
|
||||||
|
|
||||||
|
**🔹 步骤2.2 - 配置属性源加载**
|
||||||
|
📊 属性源加载优先级(从高到低):
|
||||||
|
| 优先级 | 属性源类型 | 说明 |
|
||||||
|
| - | - | - |
|
||||||
|
| 1 | 命令行参数 | --spring.profiles.active=dev |
|
||||||
|
| 2 | Java系统属性 | System.getProperties() |
|
||||||
|
| 3 | 操作系统环境变量 | 系统级环境配置 |
|
||||||
|
| 4 | 应用配置文件 | application-{profile}.yml/properties |
|
||||||
|
| 5 | 默认属性 | SpringApplication.setDefaultProperties() |
|
||||||
|
|
||||||
|
  属性源加载遵循严格的优先级顺序,确保重要的配置能够覆盖默认配置。这种优先级设计使得配置管理更加灵活,开发者可以通过不同级别的配置来调整应用行为。
|
||||||
|
|
||||||
|
**🔹 步骤2.3 - Profile处理机制**
|
||||||
|
- Profile激活方式:
|
||||||
|
1. 通过spring.profiles.active显式指定
|
||||||
|
2. 通过spring.profiles.include包含其他profile
|
||||||
|
3. 默认使用default profile
|
||||||
|
- Profile解析流程:
|
||||||
|
1. 读取所有可用的profile配置
|
||||||
|
2. 解析条件化配置注解
|
||||||
|
3. 合并不同profile的配置项
|
||||||
|
4. 处理配置覆盖和冲突解决
|
||||||
|
|
||||||
|
  Profile机制是Spring框架提供的环境隔离解决方案,它允许开发者为不同的运行环境定义不同的配置。Spring Boot在启动时会根据激活的Profile来加载相应的配置,并处理配置之间的覆盖关系。
|
||||||
|
  Profile的激活可以通过多种方式实现,包括命令行参数、系统属性、配置文件等。系统支持同时激活多个Profile,并提供了灵活的配置合并策略来处理可能出现的配置冲突。
|
||||||
|
|
||||||
|
**🔹 步骤2.4 - 配置属性绑定**
|
||||||
|
1. 属性值解析:从各种属性源中获取属性值
|
||||||
|
2. 类型转换:将字符串值转换为目标类型
|
||||||
|
3. 数据验证:基于JSR-303进行数据校验
|
||||||
|
4. 对象绑定:将属性值设置到@ConfigurationProperties类
|
||||||
|
|
||||||
|
  配置属性绑定是将外部配置值注入到Java对象中的过程。Spring Boot提供了强大的属性绑定功能,支持宽松的绑定规则、类型转换、数据验证等特性。
|
||||||
|
  绑定过程首先会从环境对象中获取属性值,然后进行类型转换,将字符串类型的配置值转换为目标属性类型。如果配置了验证规则,还会执行数据验证,确保配置值的正确性。最后,将验证通过的属性值设置到目标对象中。
|
||||||
|
|
||||||
|
## 2.3 应用上下文创建与初始化
|
||||||
|
**🔹 步骤3.1 - 创建应用上下文实例**
|
||||||
|
📋 上下文类型映射表:
|
||||||
|
| 应用类型 | 上下文实现类 | 特点描述 |
|
||||||
|
| - | - | - |
|
||||||
|
| Web应用 | AnnotationConfigServletWebServerApplicationContext | 支持Servlet容器 |
|
||||||
|
| 响应式应用 | AnnotationConfigReactiveWebServerApplicationContext | 支持响应式编程 |
|
||||||
|
| 普通应用 | AnnotationConfigApplicationContext | 基础应用上下文 |
|
||||||
|
|
||||||
|
  应用上下文是Spring框架的核心容器,负责管理Bean的生命周期和依赖关系。Spring Boot会根据应用类型创建相应类型的应用上下文实例
|
||||||
|
|
||||||
|
**🔹 步骤3.2 - 应用上下文层次结构**
|
||||||
|
```
|
||||||
|
BeanFactory(Bean工厂基础接口)
|
||||||
|
↓
|
||||||
|
ApplicationContext(应用上下文接口)
|
||||||
|
↓
|
||||||
|
ConfigurableApplicationContext(可配置应用上下文)
|
||||||
|
↓
|
||||||
|
AbstractApplicationContext(抽象实现)
|
||||||
|
↓
|
||||||
|
GenericApplicationContext/AnnotationConfigApplicationContext
|
||||||
|
```
|
||||||
|
|
||||||
|
  应用上下文采用层次化设计,不同层次的接口和类承担不同的职责。这种设计使得上下文的功能可以逐步扩展,同时保持代码的清晰性和可维护性。
|
||||||
|
|
||||||
|
**🔹 步骤3.3 - 执行应用上下文初始化器**
|
||||||
|
- 执行时机:上下文创建后,Bean加载前
|
||||||
|
- 主要功能:
|
||||||
|
1. 🔧 注册自定义Bean定义
|
||||||
|
2. 🔧 设置上下文特定属性
|
||||||
|
3. 🔧 添加特殊的后处理器
|
||||||
|
4. 🔧 配置环境变量覆盖
|
||||||
|
|
||||||
|
  应用上下文初始化器是Spring Boot提供的重要扩展点,允许开发者**在上下文正式刷新之前执行自定义的初始化逻辑。** **初始化器可以通过spring.factories文件注册,也可以通过SpringApplication的addInitializers方法添加。**
|
||||||
|
  **初始化器的主要作用包括注册自定义的Bean定义、配置上下文特定的属性、添加特殊的后处理器等。** 通过初始化器,开发者可以深度定制应用上下文的行为,满足特殊的业务需求。
|
||||||
|
|
||||||
|
**🔹 步骤3.4 - 发布应用上下文事件**
|
||||||
|
事件发布序列:
|
||||||
|
1. ApplicationStartingEvent- 应用启动事件
|
||||||
|
2. ApplicationEnvironmentPreparedEvent- 环境准备完成事件
|
||||||
|
3. ApplicationContextInitializedEvent- 上下文初始化事件
|
||||||
|
4. ApplicationPreparedEvent- 应用准备事件
|
||||||
|
|
||||||
|
  Spring Boot的启动过程采用事件驱动模型,**每个关键步骤都会发布相应的事件**。这种设计使得各个模块之间解耦,同时也便于开发者通过监听事件来扩展启动逻辑。
|
||||||
|
|
||||||
|
## 2.4 Bean定义加载与处理
|
||||||
|
**🔹 步骤4.1 - Bean定义加载方式**
|
||||||
|
多种Bean定义加载途径:
|
||||||
|
1. 🔍 组件扫描:自动扫描@Component、@Service等注解
|
||||||
|
2. 📝 @Bean方法:处理@Configuration类中的@Bean方法
|
||||||
|
3. 📂 @Import导入:导入其他配置类
|
||||||
|
4. 🔗 ImportSelector:动态选择导入的配置类
|
||||||
|
|
||||||
|
  Bean定义加载是Spring容器初始化的核心环节。Spring Boot支持多种Bean定义加载方式,每种方式都有其适用的场景和特点。
|
||||||
|
|
||||||
|
**🔹 步骤4.2 - 组件扫描详细过程**
|
||||||
|
```
|
||||||
|
组件扫描流程:
|
||||||
|
开始扫描指定包路径
|
||||||
|
↓
|
||||||
|
读取包下的所有class文件
|
||||||
|
↓
|
||||||
|
解析类上的注解信息
|
||||||
|
↓
|
||||||
|
识别Spring组件注解
|
||||||
|
↓
|
||||||
|
注册Bean定义到容器
|
||||||
|
↓
|
||||||
|
完成组件扫描
|
||||||
|
```
|
||||||
|
|
||||||
|
  组件扫描是Spring Boot自动配置的基础机制,它能够自动发现和注册项目中的Spring组件。扫描过程基于注解元数据,通过反射机制分析类的结构信息。
|
||||||
|
|
||||||
|
**🔹 步骤4.3 - Bean工厂后处理**
|
||||||
|
| 后处理器 | 功能描述 | 执行时机 |
|
||||||
|
| - | - | - |
|
||||||
|
| ConfigurationClassPostProcessor | 处理@Configuration类 | Bean定义加载后 |
|
||||||
|
| PropertySourcesPlaceholderConfigurer | 处理属性占位符 | 属性解析阶段 |
|
||||||
|
| CustomScopeConfigurer | 注册自定义作用域 | 作用域配置阶段 |
|
||||||
|
|
||||||
|
  BeanFactoryPostProcessor是Spring框架的重要扩展点,允许在Bean实例化之前修改Bean定义信息。Spring Boot在启动过程中会执行多个内置的BeanFactoryPostProcessor。
|
||||||
|
|
||||||
|
## 2.5 Bean实例化与生命周期
|
||||||
|
**🔹 步骤5.1 - Bean实例化策略**
|
||||||
|
实例化顺序规则:
|
||||||
|
1. 🥇 BeanFactoryPostProcessor- 工厂后处理器最先实例化
|
||||||
|
2. 🥈 BeanPostProcessor- Bean后处理器其次实例化
|
||||||
|
3. 🥉 单例Bean- 按依赖顺序实例化普通Bean
|
||||||
|
4. 🏅 其他作用域Bean- 按需实例化
|
||||||
|
|
||||||
|
  Bean实例化遵循特定的顺序规则,确保依赖关系正确的Bean能够按正确的顺序创建。实例化过程采用懒加载和急切实例化相结合的策略。
|
||||||
|
|
||||||
|
**🔹 步骤5.2 - 依赖注入机制**
|
||||||
|
依赖注入的三种方式:
|
||||||
|
1. 🏗️ 构造器注入- 通过构造函数注入依赖
|
||||||
|
2. 🛠️ Setter注入- 通过setter方法注入依赖
|
||||||
|
3. 🎯 字段注入- 直接在字段上使用@Autowired注入
|
||||||
|
|
||||||
|
  依赖注入是Spring框架的核心特性,它通过自动装配机制将Bean之间的依赖关系解耦。Spring支持多种依赖注入方式,每种方式都有其适用的场景。
|
||||||
|
|
||||||
|
**🔹 步骤5.3 - Bean后处理流程**
|
||||||
|
```
|
||||||
|
Bean后处理序列:
|
||||||
|
Bean实例化
|
||||||
|
↓
|
||||||
|
执行BeanPostProcessor.postProcessBeforeInitialization
|
||||||
|
↓
|
||||||
|
执行@PostConstruct方法
|
||||||
|
↓
|
||||||
|
执行InitializingBean.afterPropertiesSet
|
||||||
|
↓
|
||||||
|
执行自定义init方法
|
||||||
|
↓
|
||||||
|
执行BeanPostProcessor.postProcessAfterInitialization
|
||||||
|
↓
|
||||||
|
Bean完全就绪
|
||||||
|
```
|
||||||
|
|
||||||
|
  BeanPostProcessor是Bean生命周期管理的重要扩展点,它允许在Bean初始化前后执行自定义逻辑。Spring Boot内置了多个BeanPostProcessor,用于处理各种注解和AOP代理。
|
||||||
|
|
||||||
|
**🔹 步骤5.4 - 循环依赖解决机制**
|
||||||
|
三级缓存解决方案:
|
||||||
|
1. 一级缓存:存放完全初始化完成的Bean
|
||||||
|
2. 二级缓存:存放早期暴露的Bean(已实例化但未初始化)
|
||||||
|
3. 三级缓存:存放Bean工厂,用于创建Bean的早期引用
|
||||||
|
|
||||||
|
  循环依赖是Spring容器需要解决的重要问题。Spring通过三级缓存机制来解决单例Bean的循环依赖问题,确保即使存在循环引用也能正确完成依赖注入。
|
||||||
|
|
||||||
|
## 2.6 Web服务器启动与配置
|
||||||
|
**🔹 步骤6.1 - 内嵌服务器选择策略**
|
||||||
|
服务器自动配置逻辑:
|
||||||
|
1. 检查类路径中的服务器依赖
|
||||||
|
2. 按优先级选择:Tomcat > Jetty > Undertow
|
||||||
|
3. 根据应用类型创建对应的Web服务器工厂
|
||||||
|
4. 配置服务器参数(端口、上下文路径等)
|
||||||
|
|
||||||
|
  Spring Boot支持多种内嵌服务器,包括Tomcat、Jetty和Undertow。服务器选择基于类路径中的依赖,采用特定的优先级规则。
|
||||||
|
|
||||||
|
**🔹 步骤6.2 - Servlet容器初始化**
|
||||||
|
1. 🎯 创建ServletContext(Servlet上下文)
|
||||||
|
2. 🎯 注册DispatcherServlet(前端控制器)
|
||||||
|
3. 🎯 配置字符编码过滤器
|
||||||
|
4. 🎯 设置会话管理配置
|
||||||
|
5. 🎯 启用静态资源服务
|
||||||
|
|
||||||
|
  Servlet容器初始化是Web应用启动的关键环节。Spring Boot会自动配置Servlet容器,并注册必要的Servlet、Filter和Listener。
|
||||||
|
|
||||||
|
**🔹 步骤6.3 - MVC组件自动配置**
|
||||||
|
自动配置的MVC组件:
|
||||||
|
1. HandlerMapping- 请求映射处理器
|
||||||
|
2. HandlerAdapter- 处理器适配器
|
||||||
|
3. ViewResolver- 视图解析器
|
||||||
|
4. MessageConverter- 消息转换器
|
||||||
|
5. Interceptor- 拦截器配置
|
||||||
|
|
||||||
|
  Spring Boot为Spring MVC提供了完整的自动配置,包括处理器映射、视图解析、消息转换等组件。这些组件基于约定大于配置的原则,提供了合理的默认值。
|
||||||
|
|
||||||
|
## 2.7 启动完成与后处理
|
||||||
|
**🔹 步骤7.1 - 启动事件发布序列**
|
||||||
|
```
|
||||||
|
ApplicationStartingEvent
|
||||||
|
↓
|
||||||
|
ApplicationEnvironmentPreparedEvent
|
||||||
|
↓
|
||||||
|
ApplicationContextInitializedEvent
|
||||||
|
↓
|
||||||
|
ApplicationPreparedEvent
|
||||||
|
↓
|
||||||
|
ContextRefreshedEvent
|
||||||
|
↓
|
||||||
|
ApplicationReadyEvent
|
||||||
|
```
|
||||||
|
|
||||||
|
  当所有的Bean实例化、依赖注入和初始化回调都执行完成后,Spring容器会发布ContextRefreshedEvent事件。这个事件标志着Spring IoC容器已经完全刷新并准备就绪。
|
||||||
|
|
||||||
|
**🔹 步骤7.2 - 命令行运行器执行**
|
||||||
|
运行器类型与特点:
|
||||||
|
| 运行器接口 | 执行方法 | 参数类型 | 使用场景 |
|
||||||
|
| - | - | - | - |
|
||||||
|
| ApplicationRunner | run(ApplicationArguments) | 封装的应用参数 | 需要丰富参数信息时 |
|
||||||
|
| CommandLineRunner | run(String... args) | 原始字符串参数 | 简单参数处理时 |
|
||||||
|
|
||||||
|
  Spring Boot会执行所有实现了ApplicationRunner或CommandLineRunner接口的Bean。
|
||||||
|
|
||||||
|
**🔹 步骤7.3 - 健康检查与指标收集**
|
||||||
|
启动后的监控机制:
|
||||||
|
1. ❤️ 健康检查:通过HealthIndicator监控应用健康状态
|
||||||
|
2. 📊 应用指标:通过Micrometer收集运行时指标
|
||||||
|
3. 🔔 事件监听:监控应用生命周期事件
|
||||||
|
4. 📝 日志记录:记录启动完成状态和耗时
|
||||||
110
docs/Web/SpringBoot/SpringBoot-Starter.md
Normal file
110
docs/Web/SpringBoot/SpringBoot-Starter.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
title: SpringBoot Starter原理
|
||||||
|
date: 2025-11-27
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  Spring Boot Starter是一组预定义的依赖项集合,旨在简化Maven或Gradle等构建工具中的依赖管理。每个Starter都包含了实现特定功能所需的库和组件,以及相应的配置文件。开发者只需在项目中引入相应的Starter依赖,即可快速搭建起具备该功能的项目骨架。
|
||||||
|
  Starter=依赖+自动配置+配置文件
|
||||||
|
|
||||||
|
# 二、实现原理
|
||||||
|
## 2.1 传统实现
|
||||||
|
  例如引入Spring中的jpa,则需要以下步骤:
|
||||||
|
1. 在Maven中引入数据库依赖
|
||||||
|
2. 在Maven中引入jpa依赖
|
||||||
|
3. 在配置文件中配置属性
|
||||||
|
4. 调试程序
|
||||||
|
|
||||||
|
  每次新建项目都需要重复此流程,操作繁琐。
|
||||||
|
|
||||||
|
## 2.2 自定义Starter实现
|
||||||
|
1. 新建Maven项目,在pom.xml文件中定义需要的依赖项。
|
||||||
|
2. 创建自动配置类 AutoConfigurationTest,添加@configuration注解,使其能够被SpringBoot自动扫描到。
|
||||||
|
3. 添加自动装配机制,在src/main/resources/META-INF文件夹下创建spring.factories文件,添加以下配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
||||||
|
com.example.springbootstartercustom.AutoConfigurationTest
|
||||||
|
```
|
||||||
|
|
||||||
|
  注:这里文件夹和文件名一定要正确,因此SpringFactoriesLoader中就是这么定义的。
|
||||||
|
|
||||||
|
4. 在配置文件中自定义属性(可选)。
|
||||||
|
5. 安装打包到maven仓库中。
|
||||||
|
6. 其他项目通过pom.xml文件引入该starter。
|
||||||
|
|
||||||
|
## 2.3 Starter实现原理
|
||||||
|
  加载依赖->扫描自动配置类->加载配置文件
|
||||||
|
|
||||||
|
# 三、高级特性
|
||||||
|
## 3.1 可插拔Starter
|
||||||
|
  所谓可插拔就是可以自行决定是否需要加载该starter的功能。例如可以通过注解的方式决定是否加载。
|
||||||
|
|
||||||
|
1. 定义注解
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface EnableAutoConfigTest {
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 在自动配置类 AutoConfiguration中增加条件注解
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnBean(annotation = EnableAutoConfigTest.class)
|
||||||
|
public class AutoConfigurationTest {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 在相应位置添加@EnableAutoConfigTest注解该stater才会生效。
|
||||||
|
|
||||||
|
## 3.2 自定义配置文件
|
||||||
|
  所谓自定义配置文件,就是可以在引入stater后,可以通过修改配置文件覆盖原来的配置属性,从而灵活配置stater功能。
|
||||||
|
|
||||||
|
1. 引用spring-boot-configuration-processor
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 定义Properties配置类
|
||||||
|
|
||||||
|
```java
|
||||||
|
@ConfigurationProperties(prefix = "test")
|
||||||
|
public class TestProperties {
|
||||||
|
private String name = "test";
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 在自动配置类 AutoConfigurationTest中引用
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@Import({TestProperties.class})
|
||||||
|
public class AutoConfigurationTest {
|
||||||
|
@Resource
|
||||||
|
private TestProperties testProperties;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 在引入的stater工程中,修改配置文件:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
test:
|
||||||
|
name: test1
|
||||||
|
```
|
||||||
333
docs/Web/SpringBoot/SpringBoot-Thread.md
Normal file
333
docs/Web/SpringBoot/SpringBoot-Thread.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
---
|
||||||
|
title: SpringBoot 线程与事件
|
||||||
|
date: 2026-06-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、基本概念
|
||||||
|
  **进程**:进程是操作系统资源分配的最小单位,每个进程都有独立的内存空间(代码段、数据段、堆、栈),比如打开一个应用程序,就是一个进程。
|
||||||
|
  **线程**:线程是 CPU 调度的最小单位,线程共享所属进程的内存空间。 比如一个HTTP请求就是一个线程。
|
||||||
|
  **协程**:协程是 用户态的轻量级“线程”,由程序自己调度,不依赖操作系统,切换成本极低。
|
||||||
|
  创建线程方法:
|
||||||
|
```java
|
||||||
|
// 继承 Thread 不推荐
|
||||||
|
class MyThread extends Thread {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
System.out.println("线程运行中:" + Thread.currentThread().getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Test {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
MyThread t = new MyThread();
|
||||||
|
t.start(); // ✅ 启动线程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实现 Runnable
|
||||||
|
class MyRunnable implements Runnable {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
System.out.println("线程运行中:" + Thread.currentThread().getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Test2 {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
Thread t = new Thread(new MyRunnable());
|
||||||
|
t.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lambda 写法
|
||||||
|
Thread t = new Thread(() -> {
|
||||||
|
System.out.println("Hello Thread");
|
||||||
|
});
|
||||||
|
t.start();
|
||||||
|
|
||||||
|
// 线程池
|
||||||
|
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||||
|
2, // 核心线程数
|
||||||
|
4, // 最大线程数
|
||||||
|
60,
|
||||||
|
TimeUnit.SECONDS,
|
||||||
|
new LinkedBlockingQueue<>(100),
|
||||||
|
Executors.defaultThreadFactory(),
|
||||||
|
new ThreadPoolExecutor.AbortPolicy()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
  **SpringBoot线程池**:
|
||||||
|
```java
|
||||||
|
// 1. 启动类加上 @EnableAsync 注解
|
||||||
|
@EnableAsync
|
||||||
|
@SpringBootApplication
|
||||||
|
public class Application {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 线程池配置
|
||||||
|
@Bean
|
||||||
|
public Executor taskExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(5);
|
||||||
|
executor.setMaxPoolSize(10);
|
||||||
|
executor.setQueueCapacity(20);
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 调用方法上加上 @Async 注解
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void createOrder() {
|
||||||
|
System.out.println(Thread.currentThread().getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 二、案例
|
||||||
|
  案例:电商秒杀系统。
|
||||||
|
- 资源竞争:多个用户抢同一件商品(多线程抢锁)。
|
||||||
|
- 异步解耦:扣减库存后,需要发短信、发优惠券(多线程并行)。
|
||||||
|
- 系统解耦:订单系统不应依赖短信系统(事件驱动)。
|
||||||
|
|
||||||
|
# 二、JDK原生线程通信
|
||||||
|
- synchronized:锁住库存,防止超卖。
|
||||||
|
- wait/notify:通知消息线程“库存已扣,可以发通知了”。
|
||||||
|
- Thread+ ExecutorService:实现多线程并行发通知。
|
||||||
|
|
||||||
|
## 2.1 共享资源
|
||||||
|
```java
|
||||||
|
/**
|
||||||
|
* 商品库存(共享资源)
|
||||||
|
*/
|
||||||
|
public class Stock {
|
||||||
|
// 假设只有 1 件库存
|
||||||
|
public int count = 1;
|
||||||
|
// 信号:是否有人下单成功
|
||||||
|
public volatile boolean ordered = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2 下单线程
|
||||||
|
```java
|
||||||
|
/**
|
||||||
|
* 用户下单线程
|
||||||
|
*/
|
||||||
|
public class OrderWorker extends Thread {
|
||||||
|
|
||||||
|
private final Stock stock;
|
||||||
|
|
||||||
|
public OrderWorker(Stock stock) {
|
||||||
|
this.stock = stock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// 1. 锁住库存,防止超卖
|
||||||
|
synchronized (stock) {
|
||||||
|
if (stock.count > 0) {
|
||||||
|
System.out.println(Thread.currentThread().getName() + " 抢到锁,准备扣库存");
|
||||||
|
stock.count--;
|
||||||
|
stock.ordered = true;
|
||||||
|
System.out.println("库存扣减成功,剩余:" + stock.count);
|
||||||
|
stock.notifyAll(); // 通知消息线程
|
||||||
|
} else {
|
||||||
|
System.out.println(Thread.currentThread().getName() + " 没抢到,库存不足");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.3 消息分发线程
|
||||||
|
```java
|
||||||
|
/**
|
||||||
|
* 消息分发线程
|
||||||
|
* 负责监听下单成功信号,并启动线程池发通知
|
||||||
|
*/
|
||||||
|
public class MessageDispatcher extends Thread {
|
||||||
|
|
||||||
|
private final Stock stock;
|
||||||
|
private final ExecutorService pool;
|
||||||
|
|
||||||
|
public MessageDispatcher(Stock stock, ExecutorService pool) {
|
||||||
|
this.stock = stock;
|
||||||
|
this.pool = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
synchronized (stock) {
|
||||||
|
try {
|
||||||
|
while (!stock.ordered) {
|
||||||
|
System.out.println("消息线程:等待下单信号...");
|
||||||
|
stock.wait(); // 没下单就死等
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("消息线程:收到下单成功信号,开始并行发通知!");
|
||||||
|
|
||||||
|
// 2. 并行执行通知任务
|
||||||
|
pool.execute(() -> System.out.println("线程池任务:发送短信"));
|
||||||
|
pool.execute(() -> System.out.println("线程池任务:发放优惠券"));
|
||||||
|
pool.execute(() -> System.out.println("线程池任务:更新用户积分"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.4 主程序
|
||||||
|
```java
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
public class TraditionalDemo {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
Stock stock = new Stock();
|
||||||
|
ExecutorService pool = Executors.newFixedThreadPool(3);
|
||||||
|
|
||||||
|
// 消息线程先启动(等待)
|
||||||
|
new MessageDispatcher(stock, pool).start();
|
||||||
|
|
||||||
|
// 模拟 3 个用户同时下单(竞争)
|
||||||
|
new OrderWorker(stock).start();
|
||||||
|
new OrderWorker(stock).start();
|
||||||
|
new OrderWorker(stock).start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 三、SpringBoot 异步方法
|
||||||
|
- @Transactional:保证库存扣减的事务性。
|
||||||
|
- @Async:将发短信、发券等耗时操作丢给线程池,立即释放 Tomcat 线程。
|
||||||
|
|
||||||
|
## 3.1 配置线程池
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
@Bean("orderExecutor")
|
||||||
|
public Executor orderExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(5);
|
||||||
|
executor.setThreadNamePrefix("Order-Async-");
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 订单服务
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StockService stockService;
|
||||||
|
@Autowired
|
||||||
|
private NotifyService notifyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下单主流程
|
||||||
|
*/
|
||||||
|
public void createOrder() {
|
||||||
|
// 1. 扣库存(核心,同步)
|
||||||
|
boolean success = stockService.decreaseStock();
|
||||||
|
if (!success) {
|
||||||
|
throw new RuntimeException("库存不足");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 异步通知(非核心,并行)
|
||||||
|
notifyService.sendSms();
|
||||||
|
notifyService.sendCoupon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.3 通知服务
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class NotifyService {
|
||||||
|
|
||||||
|
// 指定使用哪个线程池
|
||||||
|
@Async("orderExecutor")
|
||||||
|
public void sendSms() {
|
||||||
|
System.out.println(Thread.currentThread().getName() + " 发送短信");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async("orderExecutor")
|
||||||
|
public void sendCoupon() {
|
||||||
|
System.out.println(Thread.currentThread().getName() + " 发放优惠券");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、SpringBoot 事件机制
|
||||||
|
- 事件(Event):下单成功这个“事实”。
|
||||||
|
- 发布者(Publisher):只负责发布事件,不关心后续。
|
||||||
|
- 监听者(Listener):谁关心下单成功,谁就干活(且可以异步)。
|
||||||
|
|
||||||
|
## 4.1 定义事件
|
||||||
|
```java
|
||||||
|
public class OrderCreatedEvent {
|
||||||
|
private final String orderId;
|
||||||
|
|
||||||
|
public OrderCreatedEvent(String orderId) {
|
||||||
|
this.orderId = orderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOrderId() {
|
||||||
|
return orderId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.2 发布事件
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StockService stockService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApplicationEventPublisher publisher;
|
||||||
|
|
||||||
|
public void createOrder() {
|
||||||
|
// 1. 扣库存
|
||||||
|
boolean success = stockService.decreaseStock();
|
||||||
|
if (success) {
|
||||||
|
// 2. 发布事件(我不关心谁处理)
|
||||||
|
publisher.publishEvent(new OrderCreatedEvent("ORDER_123"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.3 监听事件
|
||||||
|
```java
|
||||||
|
@Component
|
||||||
|
public class OrderListener {
|
||||||
|
|
||||||
|
// 监听下单事件,并异步执行
|
||||||
|
@EventListener
|
||||||
|
@Async("orderExecutor")
|
||||||
|
public void handleSendSms(OrderCreatedEvent event) {
|
||||||
|
System.out.println(Thread.currentThread().getName() + " 监听到事件,发送短信");
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener
|
||||||
|
@Async("orderExecutor")
|
||||||
|
public void handleSendCoupon(OrderCreatedEvent event) {
|
||||||
|
System.out.println(Thread.currentThread().getName() + " 监听到事件,发放优惠券");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
154
docs/Web/SpringBoot/SpringBoot-WebSocket.md
Normal file
154
docs/Web/SpringBoot/SpringBoot-WebSocket.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
---
|
||||||
|
title: SpringBoot WebSocket简介和使用
|
||||||
|
date: 2026-06-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、简介
|
||||||
|
  [WebSocket API](https://docs.springjava.cn/spring-framework/reference/web/websocket/server.html)
|
||||||
|
|
||||||
|
# 二、安装
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
# 三、使用
|
||||||
|
## 3.1 定义WebSocketHandler
|
||||||
|
```java
|
||||||
|
@Component
|
||||||
|
public class GenericWebSocketHandler extends TextWebSocketHandler {
|
||||||
|
private static final Map<String, WebSocketSession> SESSION_MAP =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionEstablished(WebSocketSession session) {
|
||||||
|
String userId = getUserId(session);
|
||||||
|
SESSION_MAP.put(userId, session);
|
||||||
|
log("用户连接成功:" + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
||||||
|
String userId = getUserId(session);
|
||||||
|
SESSION_MAP.remove(userId);
|
||||||
|
log("用户断开连接:" + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
|
||||||
|
String payload = message.getPayload();
|
||||||
|
|
||||||
|
// 心跳处理
|
||||||
|
if ("PING".equalsIgnoreCase(payload)) {
|
||||||
|
session.sendMessage(new TextMessage("PONG"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通消息(可按 JSON 协议扩展)
|
||||||
|
log("收到消息:" + payload);
|
||||||
|
session.sendMessage(new TextMessage("服务端已收到:" + payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleTransportError(WebSocketSession session, Throwable exception) {
|
||||||
|
log("传输异常:" + exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 session 中获取用户标识(握手阶段放入) */
|
||||||
|
private String getUserId(WebSocketSession session) {
|
||||||
|
return (String) session.getAttributes().getOrDefault("userId", session.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单发 */
|
||||||
|
public void sendToUser(String userId, String msg) throws IOException {
|
||||||
|
WebSocketSession session = SESSION_MAP.get(userId);
|
||||||
|
if (session != null && session.isOpen()) {
|
||||||
|
session.sendMessage(new TextMessage(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 广播 */
|
||||||
|
public void broadcast(String msg) throws IOException {
|
||||||
|
for (WebSocketSession session : SESSION_MAP.values()) {
|
||||||
|
if (session.isOpen()) {
|
||||||
|
session.sendMessage(new TextMessage(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在线人数 */
|
||||||
|
public int onlineCount() {
|
||||||
|
return SESSION_MAP.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 定义HandshakeInterceptor
|
||||||
|
```java
|
||||||
|
@Component
|
||||||
|
public class GenericHandshakeInterceptor implements HandshakeInterceptor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 握手前执行
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean beforeHandshake(
|
||||||
|
ServerHttpRequest request,
|
||||||
|
ServerHttpResponse response,
|
||||||
|
WebSocketHandler wsHandler,
|
||||||
|
Map<String, Object> attributes) {
|
||||||
|
// 从请求中URL中获取id
|
||||||
|
// 例如:ws://localhost:8080/ws?userId=123,多个参数用&连接
|
||||||
|
String userId = servletRequest.getServletRequest().getParameter("userId");
|
||||||
|
|
||||||
|
if (userId == null) {
|
||||||
|
// 返回 false → 握手失败 → 连接被拒绝
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 放入 attributes 中,方便下次获取
|
||||||
|
attributes.put("userId", userId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 握手完成后执行(一般不用写)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void afterHandshake(
|
||||||
|
ServerHttpRequest request,
|
||||||
|
ServerHttpResponse response,
|
||||||
|
WebSocketHandler wsHandler,
|
||||||
|
Exception exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.3 配置WebSocketConfigurer
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocket
|
||||||
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private GenericWebSocketHandler genericWebSocketHandler;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private GenericHandshakeInterceptor genericHandshakeInterceptor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
|
registry.addHandler(genericWebSocketHandler, "/ws")
|
||||||
|
.addInterceptors(genericHandshakeInterceptor)
|
||||||
|
.setAllowedOrigins("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
  WebSocket连接地址:ip:port/ws?userId=123。
|
||||||
|
|
||||||
|
# 四、前端使用
|
||||||
|
  参考:[useWebSocket](../Vue/VueUse.md)
|
||||||
77
docs/Web/SpringBoot/SpringBoot3-GraalVM.md
Normal file
77
docs/Web/SpringBoot/SpringBoot3-GraalVM.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
title: SpringBoot3原生镜像
|
||||||
|
date: 2025-11-27
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、原生镜像
|
||||||
|
  传统 Java 应用基于 JVM 运行,需要加载完整的类库和 JVM 运行时环境,导致启动时间长、内存占用高。而**原生镜像技术通过提前编译(AOT)将 Java 应用直接编译为本地机器码,无需 JVM 即可运行**,具有以下核心优势:
|
||||||
|
|
||||||
|
1. 启动速度极快:毫秒级启动,相比传统 JVM 应用提升 10-100 倍、
|
||||||
|
2. 内存占用低:仅包含应用运行所需的最小资源集合
|
||||||
|
3. 部署体积小:通常为传统 JAR 包的 1/10 左右
|
||||||
|
4. 适合云原生场景:尤其适合 Kubernetes 等容器化环境
|
||||||
|
|
||||||
|
# 二、安装使用
|
||||||
|
## 2.1 基础软件
|
||||||
|
1. GraalVM(类似于JAVA JDK)下载:[官网](https://www.graalvm.org/downloads/),下载完成后需要配置环境变量
|
||||||
|
2. native-image安装:cmd运行:gu install native-image
|
||||||
|
3. Visual Studio Community2022 选择:使用C++的桌面开发
|
||||||
|
|
||||||
|
## 2.2 结合Springboot3 项目
|
||||||
|
1. IDEA创建Springboot3项目
|
||||||
|
2. Project Structure选择GraalVM JDK
|
||||||
|
3. 配置pom打包
|
||||||
|
```xml
|
||||||
|
<plugins>
|
||||||
|
<!-- 将项目打包成可执行的 JAR/WAR 文件 -->
|
||||||
|
<!-- repackage目标会替换 Maven 默认的打包行为 -->
|
||||||
|
<!-- 使 JAR/WAR 文件包含所有依赖项,成为一个独立运行的应用 -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
<!-- 执行 AOT 编译 -->
|
||||||
|
<goal>process-aot</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<!-- GraalVM Native Maven 插件 -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.graalvm.buildtools</groupId>
|
||||||
|
<artifactId>native-maven-plugin</artifactId>
|
||||||
|
<version>0.10.2</version>
|
||||||
|
<configuration>
|
||||||
|
<buildArgs combine.children="append">
|
||||||
|
<!-- 支持 http 协议 -->
|
||||||
|
<buildArg>--enable-url-protocols=http</buildArg>
|
||||||
|
<!-- 用于解决数据库中文乱码问题 -->
|
||||||
|
<buildArg>-H:+AddAllCharsets</buildArg>
|
||||||
|
</buildArgs>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>build-native</id>
|
||||||
|
<goals>
|
||||||
|
<goal>compile-no-fork</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
```
|
||||||
|
4. 选择Maven->Lifecycle->clean和package,或者输入命令:mvn -Pnative native:compile
|
||||||
|
5. 在target目录下可以看到生成的.exe文件,打开运行即可。
|
||||||
|
|
||||||
|
## 三、注意事项
|
||||||
|
  实际体验下来发现主要存在以下几个问题
|
||||||
|
1. 编译打包的速度非常慢
|
||||||
|
2. 目前有些第三方库并不支持(Mybatis已支持,Mybatis Plus暂不支持)
|
||||||
|
|
||||||
|
# 四、参考资料
|
||||||
|
1. [使用 Spring Boot 和 GraalVM 构建原生镜像](https://springdoc.cn/spring-native-intro/)
|
||||||
|
2. [GraalVM 原生镜像支持](https://docs.springframework.org.cn/spring-boot/reference/native-image/introducing-graalvm-native-images.html)
|
||||||
100
docs/Web/Vue/Browser-Process.md
Normal file
100
docs/Web/Vue/Browser-Process.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
title: 浏览器渲染全流程解析
|
||||||
|
date: 2025-12-13
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、网络请求阶段
|
||||||
|
1. DNS 解析
|
||||||
|
|
||||||
|
  浏览器先解析输入的网址对应的 IP 地址,先检查本地 DNS 缓存,若没有则向本地 DNS 服务器发起查询。最终通过 DNS 系统获取服务器的 IP 地址。
|
||||||
|
|
||||||
|
2. 建立网络连接(TCP/IP 握手)
|
||||||
|
|
||||||
|
  通过三次握手建立客户端(浏览器)与服务器的 TCP 连接。若网址使用 HTTPS,还需完成 TLS/SSL 握手,确保数据加密传输。
|
||||||
|
|
||||||
|
3. 发起 HTTP/HTTPS 请求
|
||||||
|
|
||||||
|
  浏览器向服务器发送请求报文,包含:
|
||||||
|
- **请求行**:方法(GET)、URL、协议版本(如 HTTP/1.1)。
|
||||||
|
- **请求头**:包含浏览器信息(User-Agent)、缓存策略(Cache-Control)等。
|
||||||
|
- **若为 POST 请求**:还包含请求体数据。
|
||||||
|
|
||||||
|
4. 服务器处理请求
|
||||||
|
|
||||||
|
  服务器接收到请求后,根据 URL 路由到对应的后端服务(如 Node.js、Java 等)。
|
||||||
|
- **若为 Vue3 项目**:
|
||||||
|
* 若为单页应用(SPA),服务器通常返回一个通用的 `index.html` 页面(前端路由由 Vue Router 处理)。
|
||||||
|
* 若涉及 API 请求(如获取数据),服务器返回 JSON 格式的数据(如通过 Express、Spring Boot 等接口)。
|
||||||
|
|
||||||
|
5. 返回响应结果
|
||||||
|
|
||||||
|
  服务器返回响应报文,包含:
|
||||||
|
- **状态行**:状态码(如 200 OK、404 Not Found)。
|
||||||
|
- **响应头**:包含内容类型(Content-Type: text/html)、缓存控制(Cache-Control: max-age=3600)等。
|
||||||
|
- **响应体**:
|
||||||
|
* 若为 `index.html`:返回 HTML 文本(包含 Vue3 项目的入口脚本引用,如 `<script src="/static/js/app.js"></script>`)。
|
||||||
|
* 若为 API 数据:返回 JSON 格式的数据(如用户信息、列表数据等)。
|
||||||
|
|
||||||
|
|
||||||
|
# 二、解析与执行阶段
|
||||||
|
1. 解析 HTML 并构建 DOM 树
|
||||||
|
|
||||||
|
  浏览器逐行解析 HTML 内容,构建 **DOM 树**(Document Object Model)。
|
||||||
|
- 遇到 `<link rel="stylesheet">` 标签时,异步加载 CSS 文件并构建 **CSSOM 树**(CSS Object Model)。
|
||||||
|
- 遇到 `<script>` 标签时:
|
||||||
|
* 若为 **同步脚本**(`<script src="app.js">`):暂停 HTML 解析,下载并执行脚本。
|
||||||
|
* 若为 **异步脚本**(`<script async src="app.js">`):异步下载脚本,不阻塞 HTML 解析。
|
||||||
|
|
||||||
|
2. 加载 Vue3 相关资源
|
||||||
|
|
||||||
|
  浏览器下载并执行 Vue3 的核心库(`vue.runtime.global.js`)、项目打包后的 JS 文件(如 `app.js`)和 CSS 文件(如 `app.css`)。
|
||||||
|
::: tip
|
||||||
|
- `app.js`:包含 Vue3 应用的入口逻辑(如 `createApp(App).mount('#app')`)、组件定义、路由配置等。
|
||||||
|
- `app.css`:包含全局样式或组件样式(通过 `<style>` 标签在组件中定义的样式会被编译为内联样式)。
|
||||||
|
:::
|
||||||
|
|
||||||
|
3. 执行 Vue3 应用初始化
|
||||||
|
|
||||||
|
  当浏览器执行到 Vue3 入口脚本时,会触发以下流程:
|
||||||
|
- **创建应用实例**:通过 `createApp(App)` 创建 Vue 应用实例。
|
||||||
|
- **配置插件和路由**:
|
||||||
|
* 安装路由插件 `vue-router`:`app.use(router)`。
|
||||||
|
* 安装状态管理插件(如 `pinia`):`app.use(pinia)`。
|
||||||
|
- **挂载应用到 DOM**:通过 `app.mount('#app')` 将 Vue 组件渲染到 HTML 中的指定容器(如 `<div id="app"></div>`)。
|
||||||
|
|
||||||
|
4. Vue3 组件渲染流程
|
||||||
|
|
||||||
|
  **初始化组件**:从根组件(`App.vue`)开始,递归解析组件树,初始化响应式数据。
|
||||||
|
- **模板编译**:
|
||||||
|
* 若使用单文件组件(`.vue`),模板(`<template>`)会被编译为渲染函数(`render`)。
|
||||||
|
- **响应式依赖收集**:Vue3 通过 `Proxy` 和 `Reflect` 实现响应式,当组件访问数据(如 `message`)时,自动建立依赖关系。
|
||||||
|
- **首次渲染(挂载阶段)**:
|
||||||
|
* 根据渲染函数生成 **虚拟 DOM(VNode)**。
|
||||||
|
* 通过 `patch` 函数将虚拟 DOM 渲染为真实 DOM,并插入到页面中。
|
||||||
|
* 例如,根组件渲染后,`#app` 容器内会生成对应的 HTML 结构。
|
||||||
|
|
||||||
|
5. 处理异步操作(如数据请求)
|
||||||
|
|
||||||
|
  在组件的 `setup` 函数或生命周期钩子(如 `onMounted`)中,通过 `axios` 或 Vue3 内置的 `useFetch` 发起 API 请求:
|
||||||
|
- **数据更新与重新渲染**:当响应数据返回后,更新响应式数据(如 `data.value`),Vue3 检测到变化后,重新生成虚拟 DOM 并与真实 DOM 对比,执行 **差异化更新(Diff 算法)**,只更新变化的部分。
|
||||||
|
|
||||||
|
# 三、页面渲染与交互阶段
|
||||||
|
1. 合成图层并绘制页面
|
||||||
|
|
||||||
|
  浏览器将 DOM 树和 CSSOM 树合并为 **渲染树(Render Tree)**,计算每个元素的布局(Layout)和样式(Style)。通过 **图层合成(Composite)** 将不同层级的元素绘制到页面上,最终显示完整的 UI。
|
||||||
|
|
||||||
|
2. 用户交互与组件更新
|
||||||
|
|
||||||
|
- 当用户触发事件(如点击按钮、输入表单)时:
|
||||||
|
* Vue3 通过 `v-on` 指令绑定事件处理函数(如 `@click="handleClick"`)。
|
||||||
|
* 事件处理函数中修改响应式数据,触发组件重新渲染。
|
||||||
|
- 点击按钮时,`count` 的值更新,Vue3 自动重新渲染按钮文本。
|
||||||
|
|
||||||
|
# 四、总结
|
||||||
|
::: tip
|
||||||
|
输入网址 → DNS解析 → TCP连接 → 服务器返回index.html → 解析HTML加载Vue3脚本 →
|
||||||
|
执行createApp初始化 → 挂载组件到DOM → 首次渲染(虚拟DOM转真实DOM) →
|
||||||
|
异步请求数据 → 数据更新触发重新渲染 → 页面最终呈现 → 用户交互驱动组件更新
|
||||||
|
:::
|
||||||
|
|
||||||
|
  通过以上流程,浏览器从输入网址到完成 Vue3 应用的渲染,充分利用了前端框架的响应式机制和虚拟 DOM 技术,实现高效的页面更新与交互。
|
||||||
202
docs/Web/Vue/ElTable.md
Normal file
202
docs/Web/Vue/ElTable.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
title: Element Plus 表格组件
|
||||||
|
date: 2026-05-27
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、加载中
|
||||||
|
```vue
|
||||||
|
<el-table
|
||||||
|
v-loading="dispatchStore.isLoading"
|
||||||
|
element-loading-text="加载中..."
|
||||||
|
:data="dispatchStore.contractList"
|
||||||
|
border
|
||||||
|
stripeempty-text="暂无记录"
|
||||||
|
class="common-table">
|
||||||
|
|
||||||
|
</el-table>
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
要配合pinia存储使用,获取数据前将loading置为true,完成后置为false。
|
||||||
|
:::
|
||||||
|
|
||||||
|
# 二、展开行
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<el-table :data="dispatchStore.contractList"
|
||||||
|
:row-key="getRowKey"
|
||||||
|
:expand-row-keys="expandedRows"
|
||||||
|
@expand-change="handleExpandChange">
|
||||||
|
<el-table-column type="expand">
|
||||||
|
<template #default="props">
|
||||||
|
<el-table :data="props.row.dispatchList"
|
||||||
|
border stripeempty-text="暂无记录"
|
||||||
|
class="dispatch-content"
|
||||||
|
style="width: 100%">
|
||||||
|
<el-table-column fixed type="index" align="center" width="60" />
|
||||||
|
<el-table-column prop="customer" label="终端客户" align="center" width="180"/>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const expandedRows = ref<number[]>([])
|
||||||
|
|
||||||
|
// 获取行的唯一标识
|
||||||
|
const getRowKey = (row: IDispatch) => {
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理展开行变化
|
||||||
|
const handleExpandChange = (row: IDispatch, expandedRowsList: IDispatch[]) => {
|
||||||
|
if (expandedRowsList.length > 0) {
|
||||||
|
// 只保留最新展开的行
|
||||||
|
expandedRows.value = [getRowKey(row)]
|
||||||
|
} else {
|
||||||
|
expandedRows.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
  通过类型expand和插槽props实现,可以展示任意格式数据。
|
||||||
|
  增加表格的`row-key`、`expand-row-key`属性可以实现只展开某一行功能。
|
||||||
|
|
||||||
|
# 三、排序和筛选
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<el-table :data="dispatchStore.contractList">
|
||||||
|
<el-table-column prop="contractNum" label="合同编号" align="center" min-width="10%"
|
||||||
|
:filters=contractNumFilters :filter-method="filterColumnMethod" sortable>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useDispatchStore } from '@/stores/dispatch.ts'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { IContract } from '@/types/dispatch.ts'
|
||||||
|
|
||||||
|
const dispatchStore = useDispatchStore()
|
||||||
|
|
||||||
|
type ContractField = keyof IContract
|
||||||
|
|
||||||
|
const filterColumnMethod = (value, row, column) => {
|
||||||
|
return row[column.property] === value
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFiltersByKey = (key: ContractField) => {
|
||||||
|
const result = [...new Set(dispatchStore.contractList.map((item) => item[key]).filter((value) => value != null && value !==''))]
|
||||||
|
|
||||||
|
return result.map((item) => ({
|
||||||
|
text: item,
|
||||||
|
value: item
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractNumFilters = computed(() => getFiltersByKey('contractNum'))
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
  在`el-table-column`中添加`sortable`即可实现排序功能。
|
||||||
|
  在`el-table-column`中添加`filters`和`filter-method`实现筛选功能。
|
||||||
|
  `filters`为原始数据中该字段下的所有值,并转为指定格式,也可以自定义筛选字段。
|
||||||
|
|
||||||
|
# 四、表头搜索
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<el-table :data="dispatchStore.contractList" >
|
||||||
|
<el-table-column prop="contractNum" label="合同编号" align="center" min-width="10%">
|
||||||
|
<template #header>
|
||||||
|
<div class="custom-header">
|
||||||
|
<span :class="{ 'active': contractNumSearch }">合同编号</span>
|
||||||
|
<el-popover ref="contractNumPopoverRef" placement="bottom" width="230" trigger="click">
|
||||||
|
<template #default>
|
||||||
|
<el-select v-model="contractNumSearch" filterable clearable
|
||||||
|
placeholder="请选择或输入合同编号" @change="applySearch('contractNum')">
|
||||||
|
<el-option v-for="(item, index) in contractNumValue"
|
||||||
|
:key="index" :label="item" :value="item"/>
|
||||||
|
</el-select>
|
||||||
|
<div style="margin-top: 8px;display: flex;justify-content: flex-end;">
|
||||||
|
<el-button type="primary" size="small" @click="applySearch('contractNum')">搜索</el-button>
|
||||||
|
<el-button size="small" @click="resetSearch('contractNum')">重置</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #reference>
|
||||||
|
<el-icon class="search-icon"><Search/></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useDispatchStore } from '@/stores/dispatch.ts'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import type { IContract } from '@/types/dispatch.ts'
|
||||||
|
import type { PopoverInstance } from 'element-plus'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
const dispatchStore = useDispatchStore()
|
||||||
|
|
||||||
|
type ContractField = keyof IContract
|
||||||
|
|
||||||
|
const getFiltersByKey = (key: ContractField) => {
|
||||||
|
const result = [...new Set(dispatchStore.contractList.map((item) => item[key]).filter((value) => value != null && value !== ''))]
|
||||||
|
|
||||||
|
return result.map((item) => ({
|
||||||
|
text: item,
|
||||||
|
value: item
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractNumFilters = computed(() => getFiltersByKey('contractNum'))
|
||||||
|
|
||||||
|
const contractNumValue = computed(() => contractNumFilters.value.map((item) => item.value))
|
||||||
|
|
||||||
|
const contractNumPopoverRef = ref<PopoverInstance>()
|
||||||
|
const contractNumSearch = ref('')
|
||||||
|
|
||||||
|
const popoverMap = new Map<string, Ref<PopoverInstance | undefined>>([
|
||||||
|
['contractNum', contractNumPopoverRef]
|
||||||
|
])
|
||||||
|
const searchMap = new Map<string, Ref<string>>([
|
||||||
|
['contractNum', contractNumSearch]
|
||||||
|
])
|
||||||
|
|
||||||
|
const applySearch = async (field: ContractField) => {
|
||||||
|
const searchRef = searchMap.get(field)
|
||||||
|
|
||||||
|
if (!searchRef) return
|
||||||
|
|
||||||
|
if (!searchRef.value) {
|
||||||
|
await dispatchStore.queryContractList()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!searchRef.value.trim()) return
|
||||||
|
|
||||||
|
dispatchStore.contractList = dispatchStore.contractList.filter((item) => {
|
||||||
|
const value = item[field]
|
||||||
|
return typeof value === 'string' && value.includes(searchRef.value)
|
||||||
|
})
|
||||||
|
popoverMap.get(field)?.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = async (field: string) => {
|
||||||
|
const searchRef = searchMap.get(field)
|
||||||
|
|
||||||
|
if (searchRef) {
|
||||||
|
searchRef.value = ''
|
||||||
|
}
|
||||||
|
popoverMap.get(field)?.value?.hide()
|
||||||
|
await dispatchStore.queryContractList()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
  表头搜索主要利用`header`插槽和`el-popover`组件实现。
|
||||||
313
docs/Web/Vue/ElUpload.md
Normal file
313
docs/Web/Vue/ElUpload.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
---
|
||||||
|
title: Element Plus 上传器
|
||||||
|
date: 2026-01-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、上传图片
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<el-upload
|
||||||
|
v-model:file-list="imageFileList"
|
||||||
|
list-type="picture-card"
|
||||||
|
:on-preview="handlePreview"
|
||||||
|
:limit="3"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:http-request="httpRequest"
|
||||||
|
:on-exceed="handleExceed"
|
||||||
|
:before-remove="beforeRemove"
|
||||||
|
:on-remove="handleRemove"
|
||||||
|
>
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip">
|
||||||
|
请上传小于10M的图片
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible">
|
||||||
|
<el-image :src="dialogImageUrl" alt="Preview Image" />
|
||||||
|
</el-dialog>
|
||||||
|
</el-upload>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ElLoading, ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import type { UploadRequestOptions } from 'element-plus'
|
||||||
|
import type { UploadProps, UploadUserFile, UploadRawFile } from 'element-plus'
|
||||||
|
import { getFileExtension, getFileHashName, validateImage } from '@/utils/file.ts'
|
||||||
|
import { uploadFileApi } from '@/apis/file.ts'
|
||||||
|
|
||||||
|
const imageFileList = ref<UploadUserFile[]>([])
|
||||||
|
const dialogImageUrl = ref('')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
|
||||||
|
const baseUrl = ''
|
||||||
|
const apiImageList = [] as string[]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
imageFileList.value = []
|
||||||
|
|
||||||
|
// 根据后端传来的List数据将信息添加文件列表中
|
||||||
|
apiImageList.forEach((item, index) => {
|
||||||
|
imageFileList.value.push({
|
||||||
|
name: index.toString(),
|
||||||
|
url: `${baseUrl}${item}`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const beforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => {
|
||||||
|
const maxFileMax = 1024 * 1024 * 10
|
||||||
|
const fileType = rawFile.type
|
||||||
|
const fileName = rawFile.name
|
||||||
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
|
||||||
|
|
||||||
|
if (!validateImage(fileType, fileExtension)) {
|
||||||
|
ElMessage.error('请上传图片')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawFile.size > maxFileMax) {
|
||||||
|
ElMessage.error('请上传10M以内的文件!')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpRequest: UploadProps['httpRequest'] = async (options: UploadRequestOptions): Promise<any> => {
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: '文件上传中 请等待',
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)'
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 重命名文件
|
||||||
|
const filePath = `${baseUrl}/${getFileHashName(options.file.name)}.${getFileExtension(options.file.name)}`
|
||||||
|
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('file', options.file)
|
||||||
|
data.append('filePath', filePath)
|
||||||
|
|
||||||
|
// 调用后端接口上传文件
|
||||||
|
await uploadFileApi(data)
|
||||||
|
apiImageList.push(filePath)
|
||||||
|
ElMessage.success('上传成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
ElMessage.error('上传失败 请联系管理员')
|
||||||
|
} finally {
|
||||||
|
loading.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExceed: UploadProps['onExceed'] = () => {
|
||||||
|
ElMessage.warning('最多上传3张图片')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove: UploadProps['onRemove'] = (uploadFile, uploadFiles) => {
|
||||||
|
const index = imageFileList.value.findIndex((item) => item.uid === uploadFile.uid)
|
||||||
|
apiImageList.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreview: UploadProps['onPreview'] = (uploadFile) => {
|
||||||
|
dialogImageUrl.value = uploadFile.url!
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeRemove: UploadProps['beforeRemove'] = () => {
|
||||||
|
return ElMessageBox.confirm('确认删除该文件?').then(() => true, () => false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
# 二、上传视频
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<el-upload
|
||||||
|
v-model:file-list="videoFileList"
|
||||||
|
:limit="1"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:http-request="httpRequest"
|
||||||
|
:on-exceed="handleExceed"
|
||||||
|
:before-remove="beforeRemove"
|
||||||
|
:on-remove="handleRemove"
|
||||||
|
>
|
||||||
|
<el-button type="primary">点击上传</el-button>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip">
|
||||||
|
请上传小于200M的视频
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ElLoading, ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import type { UploadRequestOptions } from 'element-plus'
|
||||||
|
import type { UploadProps, UploadUserFile, UploadRawFile } from 'element-plus'
|
||||||
|
import { getFileExtension, getFileHashName, validateVideo } from '@/utils/file.ts'
|
||||||
|
import { uploadFileApi } from '@/apis/file.ts'
|
||||||
|
|
||||||
|
const videoFileList = ref<UploadUserFile[]>([])
|
||||||
|
|
||||||
|
let baseUrl = ''
|
||||||
|
const apiVideoUrl = ''
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
videoFileList.value = []
|
||||||
|
if (apiVideoUrl) {
|
||||||
|
videoFileList.value.push({
|
||||||
|
name: 'video',
|
||||||
|
url: `${baseUrl}p/${apiVideoUrl}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const beforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => {
|
||||||
|
const maxFileMax = 1024 * 1024 * 200
|
||||||
|
const fileType = rawFile.type
|
||||||
|
const fileName = rawFile.name
|
||||||
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
|
||||||
|
|
||||||
|
if (!validateVideo(fileType, fileExtension)) {
|
||||||
|
ElMessage.error('请上传视频')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawFile.size > maxFileMax) {
|
||||||
|
ElMessage.error('请上传200M以内的文件!')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpRequest: UploadProps['httpRequest'] = async (options: UploadRequestOptions): Promise<any> => {
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: '文件上传中 请等待',
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)'
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = `${baseUrl}/${getFileHashName(options.file.name)}.${getFileExtension(options.file.name)}`
|
||||||
|
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('file', options.file)
|
||||||
|
data.append('filePath', filePath)
|
||||||
|
|
||||||
|
await uploadFileApi(data)
|
||||||
|
baseUrl = filePath
|
||||||
|
ElMessage.success('上传成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
ElMessage.error('上传失败 请联系管理员')
|
||||||
|
} finally {
|
||||||
|
loading.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExceed: UploadProps['onExceed'] = () => {
|
||||||
|
ElMessage.warning('最多上传1个视频')
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeRemove: UploadProps['beforeRemove'] = () => {
|
||||||
|
return ElMessageBox.confirm('确认删除该文件?').then(() => true, () => false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove: UploadProps['onRemove'] = () => {
|
||||||
|
baseUrl = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
# 三、工具类
|
||||||
|
```ts
|
||||||
|
import { hashSHA256 } from 'vue3-common/utils/cryptoUtil'
|
||||||
|
|
||||||
|
export const getFileHashName = (name: string) => {
|
||||||
|
return hashSHA256(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFileExtension = (name: string) => {
|
||||||
|
return name.substring(name.lastIndexOf('.') + 1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateExcel = (fileType: string, fileExtension: string): boolean => {
|
||||||
|
// 常见的 Excel MIME 类型
|
||||||
|
const excelMimeTypes = [
|
||||||
|
'application/vnd.ms-excel', // .xls
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||||
|
'application/vnd.ms-excel.sheet.macroEnabled.12', // .xlsm
|
||||||
|
'text/csv' // CSV
|
||||||
|
]
|
||||||
|
|
||||||
|
// 常见的 Excel 文件扩展名
|
||||||
|
const excelExtensions = ['xls', 'xlsx', 'xlsm', 'csv']
|
||||||
|
|
||||||
|
return excelMimeTypes.includes(fileType) || excelExtensions.includes(fileExtension.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateImage = (fileType: string, fileExtension: string): boolean => {
|
||||||
|
// 常见的图片 MIME 类型
|
||||||
|
const imageMimeTypes = [
|
||||||
|
'image/jpeg', // JPEG
|
||||||
|
'image/jpg', // JPG
|
||||||
|
'image/png', // PNG
|
||||||
|
'image/gif', // GIF
|
||||||
|
'image/bmp', // BMP
|
||||||
|
'image/webp', // WebP
|
||||||
|
'image/svg+xml' // SVG
|
||||||
|
]
|
||||||
|
|
||||||
|
// 常见的图片文件扩展名
|
||||||
|
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
|
||||||
|
|
||||||
|
return imageMimeTypes.includes(fileType) || imageExtensions.includes(fileExtension.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateVideo = (fileType: string, fileExtension: string): boolean => {
|
||||||
|
// 常见的视频 MIME 类型
|
||||||
|
const videoMimeTypes = [
|
||||||
|
'video/mp4', // MP4
|
||||||
|
'video/mpeg', // MPEG
|
||||||
|
'video/quicktime', // MOV
|
||||||
|
'video/x-msvideo', // AVI
|
||||||
|
'video/x-matroska', // MKV
|
||||||
|
'video/webm', // WebM
|
||||||
|
'video/3gpp', // 3GP
|
||||||
|
'video/3gpp2', // 3G2
|
||||||
|
'video/x-flv', // FLV
|
||||||
|
'video/mp2t', // TS (MPEG Transport Stream)
|
||||||
|
'application/x-mpegURL', // M3U8 (HLS)
|
||||||
|
'video/H264', // H264
|
||||||
|
'video/H265' // H265/HEVC
|
||||||
|
]
|
||||||
|
|
||||||
|
// 常见的视频文件扩展名
|
||||||
|
const videoExtensions = [
|
||||||
|
'mp4',
|
||||||
|
'mpeg',
|
||||||
|
'mpg',
|
||||||
|
'mov',
|
||||||
|
'avi',
|
||||||
|
'mkv',
|
||||||
|
'webm',
|
||||||
|
'3gp',
|
||||||
|
'3g2',
|
||||||
|
'flv',
|
||||||
|
'ts',
|
||||||
|
'm3u8',
|
||||||
|
'h264',
|
||||||
|
'h265',
|
||||||
|
'hevc'
|
||||||
|
]
|
||||||
|
|
||||||
|
return videoMimeTypes.includes(fileType) || videoExtensions.includes(fileExtension.toLowerCase())
|
||||||
|
}
|
||||||
|
```
|
||||||
1766
docs/Web/Vue/JavaScript-Guide.md
Normal file
1766
docs/Web/Vue/JavaScript-Guide.md
Normal file
File diff suppressed because it is too large
Load Diff
95
docs/Web/Vue/Package.md
Normal file
95
docs/Web/Vue/Package.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
title: 前端包管理器
|
||||||
|
date: 2025-12-13
|
||||||
|
---
|
||||||
|
|
||||||
|
# npm
|
||||||
|
  2010年随Node.js发布的npm,首次为JavaScript引入了标准的包管理系统。其核心创新是**package.json文件**,通过语义化版本规范定义了依赖声明标准。这一设计解决了手动管理依赖时的版本混乱问题,为模块化开发提供了基础设施。
|
||||||
|
|
||||||
|
## 第一阶段:嵌套依赖架构
|
||||||
|
  早期版本采用树状嵌套的依赖结构。每个包将其依赖安装在自身的node_modules目录中,形成深层嵌套。这种设计保证了依赖隔离,但导致了几个严重问题:
|
||||||
|
- 路径深度问题:依赖树深度可达十几层,触发Windows 260字符路径限制
|
||||||
|
- 空间极度浪费:相同包在不同层级重复安装,项目体积指数级膨胀
|
||||||
|
- 安装性能低下:大量递归操作导致I/O效率极低
|
||||||
|
|
||||||
|
## 第二阶段:扁平化依赖重构
|
||||||
|
  2015年发布的npm v3引入扁平化依赖安装策略。该方案将**可共享的依赖提升到顶层node_modules**,减少嵌套深度。
|
||||||
|
  主要解决方案为:
|
||||||
|
- 依赖提升算法:将兼容版本的依赖包提升至顶层
|
||||||
|
- 冲突处理:版本冲突时,低版本保持嵌套结构
|
||||||
|
- 确定性牺牲:提升顺序影响最终结构
|
||||||
|
|
||||||
|
  但同时也引入了新问题:
|
||||||
|
- 幻影依赖:未声明但被提升的包意外可用
|
||||||
|
- 依赖不确定性:相同package.json产生不同目录结构
|
||||||
|
- 模块解析复杂化:Node.js模块解析算法与扁平结构不匹配
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
  2016年发布的Yarn针对npm核心痛点提出了系统性解决方案:
|
||||||
|
- 确定性版本锁定:yarn.lock文件记录精确版本和依赖树
|
||||||
|
- 并行下载优化:多线程并发下载提升网络利用率
|
||||||
|
- 离线缓存机制:全局缓存支持离线安装
|
||||||
|
- 完整性校验:checksum验证确保包完整性
|
||||||
|
|
||||||
|
  Yarn的成功迫使npm进行重大改进。npm v5版本借鉴了Yarn的核心设计,增加了package-lock.json和缓存优化,形成了技术竞争的良性循环。
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
  2017年发布的pnpm从存储层面重新设计了包管理模型。其核心是**基于内容寻址的全局存储与符号链接架构**。
|
||||||
|
  实现原理:
|
||||||
|
- 内容可寻址存储:包按内容哈希存储,全局唯一
|
||||||
|
- 硬链接复用:相同包文件在所有项目间共享
|
||||||
|
- 符号链接树:通过软链接构建符合Node.js解析规则的依赖树
|
||||||
|
- 严格依赖隔离:每个包只能访问其声明的依赖
|
||||||
|
|
||||||
|
  npm/yarn的node_modules目录:
|
||||||
|
```cmd
|
||||||
|
项目A: node_modules/lodash@4.17.21
|
||||||
|
项目B: node_modules/lodash@4.17.21 # 重复存储
|
||||||
|
项目C: node_modules/lodash@4.17.21 # 重复存储
|
||||||
|
```
|
||||||
|
|
||||||
|
  pnpm的node_modules目录:
|
||||||
|
```cmd
|
||||||
|
全局存储: .pnpm-store/lodash@4.17.21
|
||||||
|
项目A: 硬链接 → 全局存储
|
||||||
|
项目B: 硬链接 → 全局存储
|
||||||
|
项目C: 硬链接 → 全局存储
|
||||||
|
```
|
||||||
|
|
||||||
|
  npm/Yarn采用扁平化结构,包可访问非声明依赖,pnpm采用符号链接嵌套结构,包仅可访问声明依赖。
|
||||||
|
  pnpm的符号链接架构在Monorepo场景中表现优异。跨包依赖通过本地文件系统链接实现,避免了重复安装。Workspaces功能通过优化符号链接策略,确保开发环境与生产环境一致性。
|
||||||
|
|
||||||
|
## 简单使用
|
||||||
|
```cmd
|
||||||
|
# 通过 npm 安装
|
||||||
|
npm install -g pnpm
|
||||||
|
|
||||||
|
# 检查版本
|
||||||
|
pnpm --version
|
||||||
|
|
||||||
|
# 安装所有依赖(根据 package.json)
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 安装生产依赖
|
||||||
|
pnpm add <package-name>
|
||||||
|
|
||||||
|
# 安装开发依赖
|
||||||
|
pnpm add -D <package-name>
|
||||||
|
|
||||||
|
# 移除依赖
|
||||||
|
pnpm remove <package-name>
|
||||||
|
|
||||||
|
# 查看所有配置
|
||||||
|
pnpm config list
|
||||||
|
|
||||||
|
# 设置存储路径
|
||||||
|
pnpm config set store-dir ~/.pnpm-store
|
||||||
|
|
||||||
|
# 使用淘宝镜像
|
||||||
|
pnpm config set registry https://registry.npmmirror.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
npm确立了基本范式,Yarn解决了确定性问题,pnpm重构了存储模型
|
||||||
|
:::
|
||||||
|
|
||||||
171
docs/Web/Vue/Vite-Principle.md
Normal file
171
docs/Web/Vue/Vite-Principle.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
---
|
||||||
|
title: Vite核心原理
|
||||||
|
date: 2025-12-13
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、Vite简介
|
||||||
|
  Vite是新一代的前端构建工具,在尤雨溪开发Vue3.0的时候诞生。类似于Webpack+ Webpack-dev-server。
|
||||||
|
  其主要利用浏览器**ESModule特性**导入组织代码,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。生产中利用Rollup作为打包工具,号称下一代的前端构建工具。
|
||||||
|
|
||||||
|
# 二、核心原理
|
||||||
|
## 2.1 ESModule
|
||||||
|
  ESModule是JavaScript提出的官方标准化模块系统,不同于之前的CJS,AMD,CMD等等,ESModule提供了更原生以及更动态的模块加载方案,最重要的就是它是浏览器原生支持的,也就是说我们可以**直接在浏览器中去执行import**,动态引入我们需要的模块,而不是把所有模块打包在一起。
|
||||||
|
  例如声明一个script标签:`<script type="module" src="/src/main.js"></script>`,当浏览器解析资源时,会往当前域名发起一个GET请求`main.js`文件:
|
||||||
|
```javascript
|
||||||
|
// main.js
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
createApp(App).mount('#app')
|
||||||
|
```
|
||||||
|
  请求到了main.js文件,会检测到内部含有import引入的包,又会import引用发起HTTP请求获取模块的内容文件,如App.vue文件。
|
||||||
|
|
||||||
|
## 2.2 基于ESModule的Dev Server
|
||||||
|
  Vite其核心原理是利用浏览器现在已经支持ES6的import,碰见import就会发送一个HTTP请求去加载文件,**Vite启动一个koa服务器拦截这些请求,并在后端进行相应的处理将项目中使用的文件通过简单的分解与整合,然后再以ESM格式返回返回给浏览器**。
|
||||||
|
  Vite整个过程中没有对文件进行打包编译,做到了真正的按需加载,所以其运行速度比原始的webpack开发编译速度快出许多。
|
||||||
|
|
||||||
|
## 2.3 基于ESModule的热更新
|
||||||
|
  热更新主要思路是通过WebSocket创建浏览器和服务器的通信监听文件的改变,当文件被修改时,服务端发送消息通知客户端修改相应的代码,客户端对应不同的文件进行不同的操作的更新。
|
||||||
|
  **Vite 通过 chokidar 来监听文件系统的变更,只用对发生变更的模块重新加载**,只需要精确的使相关模块与其临近的 HMR边界连接失效即可,这样HMR 更新速度就不会因为应用体积的增加而变慢而 Webpack 还要经历一次打包构建。所以 HMR 场景下,Vite 表现也要好于 Webpack。
|
||||||
|
|
||||||
|
## 2.4 基于Esbuild的开发环境编译
|
||||||
|
  Esbuild是一个JavaScript Bundler 打包和压缩工具,它提供了与Webpack、Rollup等工具相似的资源打包能力。可以将JavaScript 和TypeScript代码打包分发在网页上运行。但其打包速度却是其他工具的10~100倍。
|
||||||
|
  在开发环境中,Vite 使用 esbuild 进行快速的热重载和模块编译。
|
||||||
|
|
||||||
|
### 2.4.1 Esbuild高性能原理
|
||||||
|
1. Go 语言实现:避开 JavaScript 运行时的性能瓶颈。
|
||||||
|
2. 并行编译:利用多核 CPU 同时处理多个文件。
|
||||||
|
3. 内存优化:在单个进程中完成解析、转换和生成,减少内存开销。
|
||||||
|
|
||||||
|
### 2.4.2 Esbuild关键特性
|
||||||
|
1. 语法转换:TypeScript → JS(保留类型信息或移除)、现代 JS 语法降级(如 ?.、?? 转换)等。
|
||||||
|
2. 压缩优化:变量重命名、死代码消除、支持 CSS 压缩(通过 esbuild-plugin-css-minify)等。
|
||||||
|
|
||||||
|
## 2.5 基于Rollup的生产环境打包
|
||||||
|
  Rollup是基于ESM的JavaScript打包工具。相比于其他打包工具如Webpack,他总是能打出更小、更快的包。因为 Rollup 基于 ESM 模块,比 Webpack 和 Browserify 使用的 CommonJS模块机制更高效。Rollup的亮点在于同一个地方,一次性加载。能针对源码进行 Tree Shaking(去除那些已被定义但没被使用的代码),以及 Scope Hoisting 以减小输出文件大小提升运行性能。
|
||||||
|
  在生产环境中,Vite 使用 Rollup 进行最终的打包和优化。
|
||||||
|
|
||||||
|
### 2.5.1 Rollup构建流程
|
||||||
|
1. 解析入口文件:从 index.js 开始,递归分析所有 import 语句。
|
||||||
|
2. 生成抽象语法树(AST):使用 acorn 解析 JS 文件为 AST。
|
||||||
|
3. 应用插件转换:如 @rollup/plugin-typescript 处理 TypeScript。
|
||||||
|
4. 生成依赖图:分析模块间的依赖关系。
|
||||||
|
5. 输出优化:
|
||||||
|
Tree-shaking:基于 ESM 静态分析,移除未使用的代码
|
||||||
|
代码分割:识别动态导入(import())并拆分包。。
|
||||||
|
|
||||||
|
# 三、Mini-Vite
|
||||||
|
## 3.1 初始化工程
|
||||||
|
  初始化NodeJs工程,并添加Vue3依赖
|
||||||
|
|
||||||
|
## 3.2 Node Server
|
||||||
|
```javascript
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const miniVueParse = require('./mini-vue-parser')
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
// 获取请求的路径
|
||||||
|
const reqPath = req.url;
|
||||||
|
// 构建本地文件路径
|
||||||
|
const filePath = path.join(__dirname, reqPath);
|
||||||
|
|
||||||
|
console.log('filePath: ', filePath)
|
||||||
|
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
fs.access(filePath, fs.constants.R_OK, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('文件不存在:', err);
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('文件不存在');
|
||||||
|
} else {
|
||||||
|
// 读取文件并发送给客户端
|
||||||
|
fs.readFile(filePath, 'utf8', (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('读取文件出错:', err);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('内部服务器错误');
|
||||||
|
} else {
|
||||||
|
// 根据文件类型设置合适的 Content-Type
|
||||||
|
const extname = path.extname(filePath);
|
||||||
|
let contentType = 'text/plain';
|
||||||
|
switch (extname) {
|
||||||
|
case '.html':
|
||||||
|
contentType = 'text/html';
|
||||||
|
break;
|
||||||
|
case '.js':
|
||||||
|
contentType = 'text/javascript';
|
||||||
|
break;
|
||||||
|
case '.vue':
|
||||||
|
contentType = 'text/javascript';
|
||||||
|
break;
|
||||||
|
case '.css':
|
||||||
|
contentType = 'text/css';
|
||||||
|
break;
|
||||||
|
// 添加其他需要支持的文件类型
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
|
|
||||||
|
if (extname === '.js') {
|
||||||
|
data = data.replace(/from ['"]vue['"]/g, "from './node_modules/vue/dist/vue.esm-browser.js'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extname === '.vue') {
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
data = miniVueParse.parse(data, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = 3000;
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`代理服务器正在监听端口 ${port}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
  在浏览器输入`http://localhost:3000/index.html`,会首先解析`<script type="module" src="main.js"></script>`,获取`main.js`,然后会把`import { createApp } from 'vue'`中的`Vue`换成替换成:`./node_modules/vue/dist/vue.esm-browser.js'`,在遇到`Vue`文件时,会利用`Vue`的模板编译器转成Javascript文件:
|
||||||
|
  mini-vue-parser.js:
|
||||||
|
```JavaScript
|
||||||
|
const compilerSfc = require("@vue/compiler-sfc");
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function parse(vueFileString,fileName) {
|
||||||
|
const sfc = compilerSfc.parse(
|
||||||
|
vueFileString
|
||||||
|
);
|
||||||
|
const sfcTemplate = compilerSfc.compileTemplate({
|
||||||
|
id: 'sfc',
|
||||||
|
filename: fileName + '.template.vue',
|
||||||
|
source: sfc.descriptor.template.content
|
||||||
|
})
|
||||||
|
|
||||||
|
const sfcScript = compilerSfc.compileScript(sfc.descriptor, {
|
||||||
|
filename: 'sfc.script.vue'
|
||||||
|
})
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
result += sfcTemplate.code.replace("export", "") + '\n';
|
||||||
|
result += sfcScript.content.replace("export default ", "const __script = ") + '\n';
|
||||||
|
result += `__script.render=render;\n`
|
||||||
|
result += `export default __script;`
|
||||||
|
result = result.replace(/from ['"]vue['"]/g, "from './node_modules/vue/dist/vue.esm-browser.js'");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parse
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 四、参考资料
|
||||||
|
1. [深入浅出vite(核心原理 + 手撕mini-vite)](https://juejin.cn/post/7026285200766140453#heading-37)
|
||||||
|
2. [深入理解Vite核心原理](https://juejin.cn/post/7064853960636989454)
|
||||||
|
3. [step-vite](https://github.com/Llyonss/step-vite)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user