Skip to main content

3 posts tagged with "Nginx"

View All Tags

· 20 min read

可以点击右上角的[Ask AI]体验,目前只是一个基础的RAG服务

其实我的需求很简单:

  • 原先基于algolia的文本检索有点落后了,想给自己的知识库接入AI问答,可以同时检索多处内容并做对话式的总结,而不是简单的带文本字符串的文章列表(algolia后面推出了NeuralSearch,不过得额外开启,还要付费)
  • 学习 ai agent 相关技术,本篇主要涉及 prompt 和 RAG 技术

整体设计

怎么交互?

因为原先algolia的检索窗口在右上角,所以直接在旁边加上ai问答按钮,触发对话窗口。有点像客服助手,emm说起来,客服助手算是入门ai agent最好的项目吧,根据问题设计多种回复prompt,通过function calling调用api方法或查询数据库等等,我刚开始练手就是搞了一个客服助手,熟悉了ai agent的基本体

然后需要一个轻量的后端服务来实现RAG服务接口,对接向量数据库,这里用了 chroma db来存储知识库的文章向量,到这里就先明确技术选型如下:

  • 前端:docusaurus(SSG)
  • 后端:hono。hono 是一个轻量级的 web 框架,支持 nodejs,据说这个库还是一个跨运行时的轻量框架,能支持 Bun/Deno 等运行时环境。这个框架对比express 体积更小(12-15kb),api 风格跟 express 挺接近的,其实都可以,这里主要是尝鲜,这个服务也相对不会很复杂,就用hono来实现
  • 向量数据库:chroma db。这是一个轻量的向量数据库,基于 sqlite(文件型存储),并且 nodejs 也能快速接入,而且我的知识库属于个人场景,单用户访问,不需要分布式高 QPS。关于向量,可以先记住这句话:语义相近的文本,向量在空间中的距离就小
  • 模型:deepseek-v3,性价比高,这种场景只是简单的总结和问答,不需要复杂的模型
  • 向量模型:阿里的 text-embedding-3

关键工程问题

文档切片

切片策略:根据标题栏和段落,按语义切分文本

将 Docusaurus 项目中的 docs/blog/ 目录下的所有 Markdown (.md/.mdx) 文档提取出来,进行合理的切片处理,以便后续进行向量化 (Embedding)。因为 Markdown 本身就是结构化的,H1/H2/H3 天然是语义边界,切出来的每片都是一个完整的小主题,直接喂给 embedding 模型。每个切片必须保留路由信息 (sourceUrl),以便于前端问答时回显来源链接

文档切片

这里使用了几个库来协助处理:

  • unified + remark-parse:用于解析 Markdown 生成 AST(抽象语法树)。如果不这么做,只能正则匹配 #,碰到代码块里的 # 就误判
  • remark-frontmatter:用于识别并提取文件头部的 YAML 属性(如 title/tags)。切片丢了标题,回显时无法显示"这是哪篇文档"
  • mdast-util-to-string:用于将 AST 节点转回纯文本进行分析。因为经过上述处理后拿到的还是带结构的对象,没法直接喂给 embedding

整个 pipeline

.md 源文件
→ unified + remark-parse:解析成 AST(树状结构)
→ remark-frontmatter:把头部的 YAML 抽出来作为 metadata
→ 遍历 AST 找 H2/H3 节点作为切片锚点
→ mdast-util-to-string:把切片节点的文本内容转成纯文本
→ 输出:{ sourceUrl, title, content, ...metadata }

为什么不一步到位?Markdown 看似简单,其实头部的 YAML、代码块、链接、图片都是「结构化信息」,得先把它们都识别出来,才能精准地切、按层级地切、不破坏语境地切。

注意:只切到 H2/H3 比较合适,再细就碎了;遇到特别长的章节,可以按段落再细分

这里可以配置 package.json 的 scripts 命令,这样在 github 的 workflow 流程里就能执行相关脚本来触发文档切片,并自动上传向量数据库

"scripts": {
"rag:parse": "tsx scripts/rag/test-parser.ts"
}

文本向量化

借助 text-embedding-3 模型,把切好的文本转成向量表示(就是把「文字」转成「坐标」,后续在向量空间里找距离最近的那几个)。这里选了阿里的向量模型,性价比相对高一些,中文支持也 OK。

embedding

存到 ChromaDB 时几个细节:

  • 向量维度:text-embedding-3 默认输出 1024 维,每片文本对应一个 1024 维的浮点数数组
  • 元数据一起存:除了向量,把原文、文档路径、章节标题作为 metadata 一起入库,召回时靠 metadata 做过滤和展示

切完的格式如下:

{
"content": "[文档路径: /docs/afreshjs/Node.js/高级核心概念 | 章节: 高级核心概念 > 高级核心概念 > 3. 模块化的底层原理 (CJS vs ESM) > 3.2 循环引用 (Circular Dependency)]\n### 3.2 循环引用 (Circular Dependency)\n\n当 `a.js` 引用 `b.js`,同时 `b.js` 又引用 `a.js` 时:\n\n- **CommonJS 的表现**:\n CJS 在加载模块时,会优先在 `require.cache` 中创建该模块的空对象 `module.exports`。当发生循环引用时,`b.js` 会拿到 `a.js` **还没执行完的、不完整的 `exports` 对象**。这会导致运行时拿到 `undefined` 而报错。\n _(CJS 导出的是值的拷贝/浅拷贝)_\n\n- **ESM (ECMAScript Modules) 的表现**:\n ESM 的加载分为“解析”、“实例化”、“执行”三个阶段。ESM 导出的是**实时绑定 (Live Bindings)**,即导出的变量和原模块内部的变量指向同一块内存地址。\n 因此在处理循环引用时,只要你不立刻去读取那个还没初始化的变量,引擎就能完美处理好模块的依赖图谱。\n\n---",
"metadata": {
"sourceUrl": "/docs/afreshjs/Node.js/高级核心概念",
"title": "高级核心概念",
"h1": "高级核心概念",
"h2": "3. 模块化的底层原理 (CJS vs ESM)",
"h3": "3.2 循环引用 (Circular Dependency)",
"chunkIndex": 5
}
},

增量灌库

这里有个小插曲,第一次需要全量灌库,接口携带的数据太大,导致接口报了body体积过大的异常(对应http响应码413)。除了调整nginx对应配置的阈值(client_max_body_size),这里还用了多个接口分批上传,稳妥点,毕竟接口处理或写库太久了估计还要触发接口超时异常(504)

增量灌库主要原理是基于 Git Diff 仅对变更文件重新 Embedding,避免每次修改知识库后,都需要重新全量上传,而是只上传新增或修改的文章向量。并且增量灌库逻辑都是写到github actions中,每次修改知识库后,都需要触发github actions来增量灌库。

chroma 数据库是怎么存取和更新的?

ChromaDB 本质就是 SQLite(存元数据 + 原文)+ HNSW 索引(存向量,做相似匹配)。每个 collection 是一组「记录」,每条含 id / document / embedding / metadata 四个字段。

// 新增
await collection.add({ ids, documents, embeddings, metadatas });
// 有则更新、无则新增(按 id)—— 灌库最常用
await collection.upsert({ ids, documents, embeddings, metadatas });
// 更新已有(不存在会报错)
await collection.update({ ids, embeddings, metadatas });
// 删除
await collection.delete({ ids: [...] });

为啥这么简单?因为底层 HNSW 索引是自维护的——你 add / upsert / delete 的时候,索引自动更新,不用手动 rebuild。这也是个人项目适合用 Chroma 的原因:不用自己折腾向量索引的实现。

prompt设计

针对ai问答的回复,设计了如下 prompt:

# 角色

你是一个知识库助手,只能基于「参考文档」回答用户问题,不要编造。

# 回答要求

1. 先用 1-2 句话直接回答用户问题
2. 再补充关键细节(来自参考文档)
3. 最后列出引用来源(仅列文档名 + 章节,不要贴 URL 长链接)
4. 如果参考文档不足以回答,明确说"知识库没有相关内容"

# 参考文档

${retrieved_chunks}

# 用户问题

${user_query}

流式问答

这里就是SSE的典型应用场景,用户输入问题,后端检索数据库将最相关的数据喂给模型,然后将模型返回的结果处理成一个流式响应,前端可以实时接收并展示给用户。

开启 SSE 接口,三件事缺一不可:响应头 + 后端实现 + Nginx 反代配置

1. 响应头

content-type: text/event-stream
cache-control: no-cache # 防止中间代理缓存
connection: keep-alive # 保持长连接
x-accel-buffering: no # 告诉 Nginx 不要缓冲(关键)

2. 后端实现

hono/streamingstreamSSE(把后端响应包成 SSE 格式(data: ...\n\n),上游 stream: true 让 DeepSeek 边想边吐——两端一配合才有"打字机"效果),几行就能写出来,核心是「透传上游流」,参考代码如下:

import { Hono } from "hono";
import { streamSSE } from "hono/streaming";

const app = new Hono();

app.get("/rag-api/ask", async (c) => {
return streamSSE(c, async (stream) => {
// 1. 请求上游 LLM,开启流式
const upstream = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [{ role: "user", content: prompt }],
stream: true,
}),
});

if (!upstream.body) return;

// 2. 透传上游流:read → 解析 → writeSSE
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
let buffer = "";

while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // 最后一行可能不完整,留到下轮

for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") {
await stream.writeSSE({ data: "[DONE]" });
continue;
}
try {
const json = JSON.parse(data);
const content = json.choices[0]?.delta?.content || "";
if (content) {
await stream.writeSSE({ data: JSON.stringify({ content }) });
}
} catch (e) {
console.error("SSE 解析错误:", e);
}
}
}
});
});

3. Nginx 反代必须关缓冲(最常踩的坑)

Nginx 默认会缓冲响应,SSE 会被"卡住"看不到流。必须加:

location /test-api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_buffering off; # 关键!禁用缓冲
proxy_cache off; # 禁用缓存
proxy_http_version 1.1; # SSE 需要 HTTP/1.1
chunked_transfer_encoding on;
proxy_read_timeout 60s; # 防止超时断流
}

4. 客户端断开要清理上游(不浪费 token)

stream.onAbort(async () => {
await reader.cancel(); // 关闭上游连接
});

易踩的坑:

症状解法
Nginx 默认缓冲流"卡"住,输出一大坨proxy_buffering off
客户端关闭但上游还在跑token 继续消耗stream.onAbort + reader.cancel
chunk 跨行JSON.parse 报错buffer 累积 + lines.pop()
EventSource 不支持 POST传参不方便@microsoft/fetch-event-source

总结下上述流程:

DeepSeek API (ReadableStream)
│ raw bytes (SSE 格式: data: {...}\n\n)

getReader() 异步迭代


TextDecoder → buffer 累积 → 按 \n 切行


JSON.parse → 提取 content


writeSSE({ data: JSON.stringify({content}) })


Hono 内部写到 response.body (writable)


Nginx 不缓冲 → 客户端立即可见

话说怎么中断ai的回复?

  1. 在对话窗口点击取消按钮
  2. 后端需要在收到用户中断请求后,立即停止模型调用,返回一个空的流式响应
  3. 前端需要在收到空的流式响应后,立即关闭对话窗口

为啥要用 @microsoft/fetch-event-source 这个库来做SSE请求?

  • 原生 EventSource:只支持 GET 请求,header 也不能自定义——传个 token 都麻烦
  • fetch + getReader():微信内置浏览器(X5/WKWebView)会拿到 null 直接抛异常

核心思路:fetch 发请求 + 自己解析流式响应体,各取两条路的一半优点:

  • fetch:支持 POST、自定义 header——EventSource 做不到的它都能做
  • 自己解析:内部封装了 ReadableStream 兼容处理,遇到 X5/WKWebView 拿不到 body 时会自动 fallback 到 XHR streaming 方案

把"流式解析"这层脏活在 SDK 里干完了,业务代码只关心 onmessage 回调

import { fetchEventSource } from "@microsoft/fetch-event-source";

await fetchEventSource("/rag-api/ask", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer xxx" },
body: JSON.stringify({ query: "什么是 RAG?" }),
onmessage(msg) {
console.log(msg.data); // 每个 SSE 事件触发一次
},
onerror(err) {
console.error("SSE 异常", err);
},
});

API 跟 EventSource 几乎一样:onmessage / onerror / onopen——但底层是 fetch,兼容性和灵活性兼得

评估模型回复的质量

RAG 跑起来之后,怎么知道它"答得好不好"?靠人工抽查显然不靠谱。这里用了一个比较简单的评估流程:

一、先建立一份「黄金评测集」

说白了就是测试用例集。人工标注一批「问题-标准答案」对,覆盖知识库核心知识点。

  • 规模:起步 50~100 条就够
  • 来源:从真实用户问题里筛,再人工写标准答案
  • 维护:知识库更新时同步补几条(其实可以让ai协助写,跟写测试用例一样,要不然很累)

评测集不是一次性产物,是跟着系统一起迭代的"活文档"。

二、给每个回答打个分

两种打法:

  • 传统指标:准确率(回答对不对)、召回率(覆盖了多少关键点)、命中率(召回到的 chunks 有多少相关的)
  • 省事打法:直接让 LLM 当裁判 A/B 评分——准备一份评分 prompt,让 LLM 对比「标准答案」和「模型回答」打分

LLM 当裁判简单粗暴,但要注意 prompt 写得清楚,否则它会"一团和气"全给高分。

三、把所有问答日志落库,方便回头查

// 每次问答时记录
{
query: "怎么部署 RAG?",
retrievedChunks: ["chunk-1", "chunk-2", "chunk-3"],
finalAnswer: "...",
timestamp: Date.now(),
userId: "xxx",
feedback: "good" | "bad" | null, // 用户反馈
}

落库之后能干嘛:

  • 抽样核对:每周抽 20 条人工看一眼质量
  • 找「召回失败」案例:query 没匹配到正确 chunk 的反例
  • 反哺优化:高频 bad case 优先修复

四、指标差了怎么调优——先定位再下手

判断标准很简单:

现象问题出在调优方向
召回到的 chunk 不对召回阶段换切片策略 / 换 embedding / 调 Top-K
召回到的 chunk 对了,但 LLM 没理解生成阶段改 prompt / 升档模型

黄金思路:召回问题比生成问题更常见,先看召回。90% 的"AI 答错"案例其实是"压根没找到对的资料"。

经验法则:

  • 别追求一步到位:先跑通 50 条评测集,看大致分数
  • 优先修召回:换个 embedding 模型、Top-K 调大,可能比改 prompt 效果明显
  • 引入「用户反馈」按钮:👍👎 标记,真实反馈比评测集更准
  • 指标好 ≠ 体验好:技术指标只是参考,最终还是要看用户用得爽不爽

token消耗把控

目前只是通过 prompt 限制输入输出 token,以及增量灌库减少向量模型token的消耗

如果后面要用上高级模型的话,肯定得做好更精细的把控,毕竟真的贵 ~

自动化部署

最近 vibe coding 了几个 web app,基本都走的这个流程,还挺方便的

  • github actions,编写 workflow deploy 脚本,push 代码后自动触发镜像构建
  • docker镜像部署,写 dockerfile,上传docker hub
  • 个人服务器从docker hub拉取镜像部署

注意:要先在github设置secrets,以及在服务器相关项目目录里设置环境变量,避免明文存储敏感信息

最后

这只是一个简单的RAG服务,仅仅是向量召回和模型总结回复,后面其实还能做混合检索(可叠加 BM25 关键词)和 advanced RAG,甚至还能接入function calling,从「问答」走向「任务执行」,比如让其检索其他数据库的内容或者联网搜索外部资源(如果知识库欠缺的内容,可以靠联网搜索来弥补)

· 16 min read

选择 gatsby 主要有几点理由:

  • 基于 react
  • 内置 markdown 处理器
  • 生态良好,插件较丰富
  • 无后端、部署简单

比较明显的缺点应该就是需要在本地编辑文章和上传,但我也经常在本地写 markdown 文章,所以对我而言问题不大

搭建开发环境

先确保 node 已安装,然后全局安装 gatsby-cli,基于 gatsby-starter-blog 来快速开启博客页面

npm install -g gatsby-cli
gatsby new my-blog https://github.com/gatsbyjs/gatsby-starter-blog
cd my-blog
npm run dev

然后就可以打开 localhost:8000 访问页面了,刚开始还是一些模板代码,可以替换或者去掉

GraphQL

页面数据是通过 GraphQL 查询拿到的,在你本地启动 Gatsby 服务时,也会同步启动 GraphQL 的服务。你的文件、图片等所有资源会被 Gatsby 和一些安装的插件解析到 GraphQL 的节点上,通过特定的语法就可以按需获取需要的数据

简而言之,Gatsby 的工作原理就是通过 GraphQL 的 api 和你指定的语法完成数据的按需获取,再用获取到的数据渲染成静态网页

接入评论功能

可以通过 utterances + github issues 实现,实际上就是先拿到用户的 github 信息,然后将评论作为 issue 推送至指定的仓库。这样也就不用专门去搞个数据库存储评论数据了

  1. 创建一个存放评论信息的 github 仓库
  2. 安装 utterances 并授权
  3. 配置信息,比如按文章名作为 issue 名称。可以参考这个 https://utteranc.es/
  4. 新建一个 Comments 组件
import * as React from "react";
import { useEffect, useRef } from "react";

const Comments = () => {
const commentsRef = useRef < HTMLDivElement > null;
useEffect(() => {
const script = document.createElement("script");
script.src = "https://utteranc.es/client.js";
script.setAttribute("repo", "GitHubJackson/blog-comments");
script.setAttribute("issue-term", "title");
script.setAttribute("label", "💬");
script.setAttribute("theme", "github-light");
script.setAttribute("crossorigin", "anonymous");
script.async = true;

if (commentsRef.current) {
commentsRef.current.appendChild(script);
}

return () => {
if (commentsRef.current) {
commentsRef.current.innerHTML = "";
}
};
}, []);
return <div ref={commentsRef} />;
};

export default Comments;

src/templates/blog-post.js 中插入组件

//...
<Layout location={location} title={siteTitle}>
//...
<Comments />
</Layout>
//...

效果如图:

新增页面

直接在 pages 文件夹下新增页面即可,可以直接用 typescript 编写组件(tsx),项目已经默认支持

我的博客页面如下:

  • Archive 归档,文章归档页,按照发布时间排序
  • Categories 分类,文章分类页
  • Tags 标签,文章标签页、以标签云的方式呈现
  • About 关于,展示作者的信息、提供留言板
  • Lab 实验室,展示自己的一些小项目

上面几个是比较常见的博客页面了,还可以往后追加自己的 Github 主页等等

在文章前加上对应信息,比如:

---
title: 文章标题
createTime: "2020-10-23"
updateTime: ""
type: "js"
tags: "js,class,es6,原型,面向对象"
description: "balabala..."
---

markdown 文件会被 gatsby-plugin-remark 解析成 markdownRemark 的节点,以上描述信息会被解析到 frontmatter

可以通过修改 frontmatter 代码获取指定数据,比如我想获取文章分类,新建一个分类页面categories.tsx,参考代码如下

// categories.tsx
import { graphql } from "gatsby";
import * as React from "react";
import Layout from "../components/layout";
import "../css/categories.css";

export default ({ data, location }) => {
const siteTitle = data.site.siteMetadata?.title || `Title`;
// 拿到所有的文章数据
let posts = data.allMarkdownRemark.nodes;
let categories: any[] = [];
// 计算各分类文章的数量
posts.forEach((post) => {
const current = categories.find(
(category) => category.title === post.frontmatter.type
);
if (!current) {
categories.push({
title: post.frontmatter.type,
count: 1,
});
} else {
current.count = current.count + 1;
}
});

return (
<Layout location={location} title={siteTitle}>
{categories.map((category) => {
return (
<div key={category.title} className="category">
{category.title}{category.count}
</div>
);
})}
</Layout>
);
};

export const pageQuery = graphql`
query {
site {
siteMetadata {
title
}
}
allMarkdownRemark(
sort: { fields: [frontmatter___createTime], order: DESC }
) {
nodes {
excerpt
fields {
slug
}
// 通过该字段查询文章开头的描述信息
frontmatter {
type
}
}
}
}
`;

最终的分类页面参考 https://blog.zhouweibin.top/categories/

文章相关

toc

基于 Tocbot 为 md 文章增加一个目录

npm install tocbot

在文章代码中增加目录初始化逻辑如下:

// blog-post.tsx
useEffect(() => {
// ...
// 指定作为目录标题的标签
const headerArr = ["H1", "H2", "H3", "H4"];
const blogContentNode = document.getElementsByClassName("blog-content")[0];
if (!blogContentNode?.children?.length) {
return;
}
// 遍历文章节点,给所有的目录标题节点增加 id,可用于锚点定位
// @ts-ignore
[...blogContentNode.children].forEach((child) => {
if (headerArr.includes(child.nodeName)) {
// 去除空格以及多余标点
let headerId = child.innerText.replace(
// eslint-disable-next-line no-useless-escape
/[\s|\~|`|\!|\@|\#|\$|\%|\^|\&|\*|\(|\)|\_|\+|\=|\||\|\[|\]|\{|\}|\;|\:|\"|\'|\,|\<|\.|\>|\/|\?|\:|\,|\。]/g,
""
);
headerId = headerId.toLowerCase();
// NOTE 需要确保name唯一,最好加个自增id
child.setAttribute("id", headerId + "-" + num);
num++;
}
});
tocbot.init({
// Where to render the table of contents.
tocSelector: ".js-toc",
// Where to grab the headings to build the table of contents.
contentSelector: ".blog-content",
// Which headings to grab inside of the contentSelector element.
headingSelector: "h1, h2, h3, h4",
});
// ...
});

通过修改对应的类名可以调节目录样式。如果感觉自己写的样式不好看,可以去掘金或者其他网站借鉴下源码 ~

参考 https://tscanlin.github.io/tocbot/

阅读时长

gatsby-transformer-remark 插件已经帮我们计算好了阅读时长,直接修改 GraphQL,再到组件代码中获取

// helper/utils.ts
export function formatReadingTime(minutes: number) {
let cups = Math.round(minutes / 5);
if (cups > 4) {
return `${new Array(Math.round(cups / 4))
.fill("🍚")
.join("")}${minutes} mins`;
} else {
return `${new Array(cups || 1).fill("🍵").join("")}${minutes} mins`;
}
}
// pages/index.tsx
// 找个合适的位置
<span style={{ marginLeft: 8 }}>{`${formatReadingTime(
post.timeToRead
)}`}</span>;
// ...
export const pageQuery = graphql`
query {
site {
siteMetadata {
title
}
}
allMarkdownRemark(
sort: { fields: [frontmatter___createTime], order: DESC }
) {
nodes {
excerpt
fields {
slug
}
timeToRead
frontmatter {
createTime(formatString: "YYYY/MM/DD")
updateTime(formatString: "YYYY/MM/DD")
title
type
tags
description
}
}
}
}
`;

效果如下:

夜间模式

借助 gatsby-plugin-use-dark-mode 来实现夜间模式,保护眼睛 ~

yarn add gatsby-plugin-use-dark-mode @fisch0920/use-dark-mode
// use-dark-mode 和 Gatsby v3 的react版本有冲突
// 所以这里使用社区的fork解决版本 @fisch0920/use-dark-mode

增加插件

// post-config.js
plugins: [
"gatsby-plugin-use-dark-mode",
// ...
];

组件就先简单用 antd 的 Switch 组件

// components/dark-mode-toggle.tsx
import * as React from "react";
import useDarkMode from "@fisch0920/use-dark-mode";
import { Switch } from "antd";

const DarkModeToggle = () => {
const darkMode = useDarkMode(false);
return (
<Switch
checked={darkMode.value}
onChange={darkMode.toggle}
checkedChildren=""
unCheckedChildren=""
/>
);
};

export default DarkModeToggle;

在布局组件 Layout.js 中引入组件

import DarkModeToggle from "./dark-mode-toggle";
import useDarkMode from "@fisch0920/use-dark-mode";

//...
const darkMode = useDarkMode(false);
//...
<DarkModeToggle mode={darkMode} />

增加夜间模式相关的全局样式

/* src/style.css */
/* 主题模式 */
body.light-mode {
background-color: #fff;
color: #333;
transition: background-color 0.3s ease;
}
body.dark-mode {
background-color: #212121;
color: #999;
transition: background-color 0.3s ease;
}
.dark-mode .global-header {
background-color: #212121;
transition: background-color 0.3s ease;
}

上面只是实现了基础功能,DarkModeToggle 组件建议自行美化下。点击切换模式应该就能看到效果了。但实际效果可能还会有问题,比如有一些你自定义颜色或背景色的模块,需要在 src/style.css 针对性地去定义夜间模式下的颜色

其他组件

返回顶部

当文章太长时,往往需要增加一个返回顶部的小按钮,便于快速回到顶部,主要代码如下:

// blog-post.tsx
function handleScrollToTop() {
// 滚动到顶部
document.documentElement.scrollTo({
top: 0,
behavior: "smooth",
});
}
// ...
useEffect(() => {
// ...
function handleScroll(e) {
const rootElement = document.documentElement;
const scrollToTopBtn = document.querySelector(
".back-to-top"
) as HTMLElement;
const scrollTotal = rootElement.scrollHeight - rootElement.clientHeight;
if (rootElement.scrollTop / scrollTotal > 0.5) {
// 显示按钮
scrollToTopBtn.style.bottom = "36px";
scrollToTopBtn.style.opacity = "1";
} else {
// 隐藏按钮
scrollToTopBtn.style.bottom = "-36px";
scrollToTopBtn.style.opacity = "0";
}
}
document.addEventListener("scroll", handleScroll);
return (() => {
document.removeEventListener("scroll", handleScroll);
})
})

按钮样式自行发挥吧,可以简单写个过渡动画 ~

分析网站

可以通过谷歌的 Google Analytics 来分析自己的网站,包括网站流量、访客信息、访问设备、浏览次数等

其实工作原理就类似于埋点,将一段谷歌的代码注入博客网页,它会帮忙收集和分析登录网页的用户信息

教程详情参照 设置 Google Analytics(分析)全局网站代码

获取到代码后,将其注入到 components/seo.js 组件中即可

import { Helmet } from "react-helmet";

<Helmet>
{/* <!-- Global site tag (gtag.js) - Google Analytics --> */}
<script
async
src="https://www.googletagmanager.com/gtag/js?id=你的跟踪ID"
></script>
<script>
{`
window.dataLayer = window.dataLayer || [];
function gtag() {dataLayer.push(arguments)}
gtag('js', new Date()); gtag('config', '你的跟踪ID');
`}
</script>
</Helmet>;

这其实是 react-helmet 这个依赖帮忙将这段代码注入到网页的 head 中,感兴趣可以自行去了解 ~

sitemap

可以借助 gatsby-plugin-sitemap 插件自动生成 sitemap

// gatsby-config.js
module.exports = {
siteMetadata: {
siteUrl: `https://blog.zhouweibin.top`,
},
plugins: [`gatsby-plugin-sitemap`],
};

打包部署后,会自动在根目录下生成 sitemap 文件。我是用的 v5 版本的插件,会生成 sitemap 文件夹,存放 sitemap-index.xml(站点地图索引,会指向最终的站点地图),可以通过 'https://blog.zhouweibin.top/sitemap/sitemap-index.xml' 访问验证

之后可以上 google(google 站点地图)或百度(百度收录)上传站点地图。上传会有延迟,不代表 sitemap 失效,大概半小时生效吧

seo 优化

其实这方面已经有做了一些处理,参见 components/seo.js

部署到服务器

需要有个服务器部署博客页面(也可以试试用 CMS),我个人使用 centos 系统,服务器的话,选腾讯云阿里云的轻量服务器就行,新人可以点以下链接领取大额优惠券

还有个人域名、https 证书也都是个人网站需要的,建议在同一家云服务方按需购买

配置 nginx

配置下 nginx ,可以接入个人域名,以及配置二级域名转发等等

yum install nginx
systemctl start nginx # 启动
systemctl enable nginx # 开发自启动

nginx 配置文件目录为 /etc/nginx/nginx.conf,简单配置如下:

#...
http {
#...
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;

# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;

location / {
root /home/blog-next; # 静态资源存放位置,比如博客项目打包生成的 public 文件
index index.html;
}
}
}

修改完,重启一下 nginx systemctl restart nginx

然后通过 http://[你的ip地址]:8080 其实就可以访问到你的页面了

追加个人域名、https 和二级域名转发的配置如下:

https 证书在腾讯云或者阿里云都有对应的免费证书可领

#...
http {
#...
server {
listen 8080;
server_name zhouweibin.top;
root /usr/share/nginx/html;

# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;

location / {
root /home/blog-next;
index index.html;
#try_files $uri $uri/ /index.html; # 单页面应用需要该配置
}
}

server {
listen 80;
server_name blog.zhouweibin.top; # 二级域名转发
location / {
proxy_pass http://127.0.0.1:8080;
#root /home/blog-next;
#index index.html;
#try_files $uri $uri/ /index.html;
}
}

# Settings for a TLS enabled server.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name blog.zhouweibin.top;
root /usr/share/nginx/html;

ssl_certificate "/etc/nginx/cert/6687351_blog.zhouweibin.top.pem"; # 指向证书存放位置
ssl_certificate_key "/etc/nginx/cert/6687351_blog.zhouweibin.top.key";
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 10m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;

# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;

location / {
proxy_pass http://127.0.0.1:8080;
}
}
}

打包上传

npm run build

将打包生成的 public 文件夹上传到服务器 nginx 指定的静态资源文件夹,比如我是放在 /home/blog-next。接下来就可以通过 https://blog.zhouweibin.top 访问了

服务器相关操作可以参考我之前总结的文章 - 服务器环境入门级搭建

快速上传文件可以用 FileZilla 可视化界面直接操作

自动部署

可以借助 github 和 jenkins 实现一个简单的自动部署能力

  1. 先创建一个 github 项目,与本地项目关联上
  2. 搭建 jenkins 环境。这个可以参考我之前写的文章 - 服务器环境入门级搭建
  3. github 给项目添加一个 webhook,和 jenkins 关联上

jenkins shell 脚本如下:

#!/bin/sh
cd /var/lib/jenkins/workspace/blog-next
rm -rf node_modules public
npm config set registry https://registry.npmmirror.com/ # 也可以在项目中增加 npmrc 文章指定默认源
npm install #安装项目中的依赖
npm run build
cd public
cd /home #进入web项目根目录
if [ ! -d "blog-next" ]; then
sudo mkdir blog-next
fi
cd /home/blog-next #进入web项目根目录
sudo rm -rf *
sudo mv /var/lib/jenkins/workspace/blog-next/public/* ./ #移动刚刚打包好的项目到web项目根目录

接下来就可以尝试提交代码(git push),测试下自动部署的能力了

最后

接下来,就可以开始经营你的个人博客了,写博客、实现更多的功能(文章目录、分类等)、增加其他页面,等等...这篇文章也会在后续持续更新,带来更多的玩法 ~!

参考

· 11 min read

其实之前读书期间就折腾了好长时间在整服务器,主要就是做自己的博客网页,这篇文章算是把之前磕磕绊绊的经验稍微总结了一下吧,入个门应该还是可以的 ~

购置

服务器

操作系统看个人喜好吧,我选择的是 centos 7.6,因为比较熟悉..下面也是基于这个操作系统来讲的

域名

个人网站需要

https 证书

  • FreeSSL。可以申请免费的证书
  • 腾讯云和阿里云也都可以申请一年期限的免费证书

腾讯云免费证书申请 https://console.cloud.tencent.com/ssl

远程连接

mac 远程连接服务器,终端软件推荐使用 iterm2,FTP 软件推荐使用 FileZilla

具体连接过程参考 https://github.com/GitHubJackson/efficient-mac/blob/master/frontend-dev.md

yum

linux 的包管理工具,常用命令如下:

# -- 检索(会同时列出 Installed Packages 和 Available Packages)
yum list nodejs
yum list installed # 单独列出 Installed Packages
yum search nodejs # list 只搜索软件包名称,而 search 不光搜索包名,还包括摘要和描述

# -- 安装
yum install nodejs (加 -y 可自动应答 yes)

# -- 更新
yum check-update # 列出每个包可升至的版本
yum update
yum update nodejs

# -- 查看详情(可查看安装的也可查看未安装的包)
yum info nodejs

列出全部/可用/不可用仓库
yum repolist enabled

# -- 卸载
yum remove nodejs

# -- 缓存
yum clean all 清除缓存
yum makecache 生成新的缓存

详情参考 https://wangchujiang.com/linux-command/c/yum.html

zsh

因为我自己在本地使用的是 zsh,而且 zsh 也兼容 bash,为了保持一致,先配置一下 zsh 吧(当然,你要是觉得麻烦,完全可以继续用 bash 或者其他 Shell,想知道 zsh 优势的话,可以参考这篇文章 https://zhuanlan.zhihu.com/p/19556676 ~)

yum install git // 后续需要从git仓库下载插件
yum install zsh
which zsh
chsh -s /usr/bin/zsh // 切换 shell

安装 oh my zsh(命令记忆、补全能力和主题太香了 ~)

sh -c "$(wget https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh -O -)"

安装插件和配置的具体过程跟本地配置类似,参考 https://github.com/GitHubJackson/efficient-mac/blob/master/tools.md

前端环境搭建

这个网站主要是放前端相关的项目,所以必备的环境得先搭建好

nvm

管理 node 版本

curl https://raw.githubusercontent.com/creationix/nvm/master/install.sh | bash

node/npm

nvm install stable
node -v
npm -v

jenkins

用于搭建 CI/CD,主要是拉取 github 项目,将其部署到服务器上。在提交代码到 github 时,可以通过 webhook 触发 jenkins 自动部署 ~

  1. 安装好 java 环境和 jenkins
yum install -y java
wget -O /etc/yum.repos.d/jenkins.repo http://pkg.jenkins-ci.org/redhat/jenkins.repo
rpm --import https://pkg.jenkins.io/redhat/jenkins.io.key
yum install -y jenkins
  1. 修改 jenkins 的默认端口
// /etc/sysconfig/jenkins
JENKINS_HOME -- Jenkins的主目录
JENKINS_USER -- Jenkins的用户,拥有$JENKINS_HOME和/var/log/jenkins的权限
JENKINS_PORT -- Jenkins的端口,默认端口是8080,建议改变,防止占用端口冲突
  1. 启动服务和开机自启
systemctl start jenkins
systemctl enable jenkins
  1. 安装插件和创建管理员用户

安装 ssh 插件Publish Over SSH,可以验证服务器是否开启远程登录

nginx

做子域名映射和负载均衡 balabala...

yum install nginx
systemctl start nginx
systemctl enable nginx

nginx 配置文件目录为/etc/nginx/nginx.conf

其他常用命令:

systemctl restart nginx
systemctl status nginx
systemctl stop nginx

pm2

为 node 应用守护进程,比如博客后台、ssr 服务等...

npm -g install pm2

创建软链接:

ln -s [pm2命令位置,which pm2] /usr/bin/pm2

开启 nextjs 应用

pm2 start npm --name "my-next" -- run start

搭建 CI/CD

一个简单的 CI/CD 流程主要有几点:

  1. 设置 github 项目 webhook
  2. github 代码更新,触发 webhook
  3. jenkins 执行脚本
    1. 拉取最新代码到指定目录(覆盖)
    2. 安装依赖
    3. 执行项目

项目配置

node 应用

  1. 关联 github 仓库
    1. Github 项目填写对应的 url(非仓库 url)
    2. 源码管理选择 Git(如果是私有仓库,要创建带 ssh 秘钥的凭据,通过 ssh 方式连接仓库)
    3. 构建触发器选择 GitHub hook trigger for GITScm polling
    4. 构建环境选择 node,需要先配置 nodejs 插件
    5. 构建脚本选择 shell,脚本可以参考下面的代码片段

ssh 方式连接仓库,需要先在主机生成 ssh key,cd /.ssh 查看密钥,pub结尾的为公钥,追加到在 settings > SSH And GPG keys的 ssh keys 列表中

ssh-keygen -t rsa -b 4096 -C "your email"
ll /.ssh
  1. 构建环境选择 ==node==
  2. 开始构建,执行 shell 脚本
# 以下的_name_都要替换成你实际的项目名
# 要先在 /var/lib/jenkins/workspace 创建项目,name跟jenkins项目名保持一致
#!/bin/bash
cd /var/lib/jenkins/workspace/blog-server
rm -rf node_modules
node -v
npm -v
npm install
tar -zcvf blog-server.tar.gz *

cd /home/servers
if [ ! -d "blog-server" ]; then
sudo mkdir blog-server
fi
cd /home/servers/blog-server
npm run prd
# pm2 start app --watch
sudo mv /var/lib/jenkins/workspace/blog-server/blog-server.tar.gz ./
sudo tar -zxvf blog-server.tar.gz -C ./
sudo rm -rf blog-server.tar.gz
# pm2 -v
# pm2 restart all --watch # 第一次要先手动将项目添加进pm2进程

如果遇到 pm2: command not found,就找到 pm2 的地址,做一个软连接到/usr/bin/

ln -s [pm2地址,可用 whereis pm2 查找] /usr/bin/pm2

如果是运行 ts 代码,需要先在主机给 pm2 安装 ts 和 ts-node,执行以下命令即可

pm2 install typescript
# "start": "NODE_ENV=production ts-node app --port $PORT",
pm2 start npm --name 'node' -- run start --watch
  1. 触发自动构建

a.先关联 github server 并测试连接。这一步是先建立主机和 github 的信任

image.png

b.新建凭据。setting --> Personal Access Token --> Generate new token,需要配置读写权限,之后会生成一个 token,作为凭据的内容(secret key)。

c.在 github > settings 配置 webhook。这一步是为了后续 github 项目在触发对应节点时能发送请求给 jenkins。在[项目仓库] > settings > webhooks 里面新增一个,Payload URL 格式类似于

http://[ip]:[jenkins port]/github-webhook/

之后可以在 Recent Deliveries 里面看最近的触发记录

d.git push 触发自动构建试试!

域名解析

目标是将 http://x.x.x.x:3001/api/test 替换成 http://blog-api.jacksonzhou.com:3001/api/test

  1. 申请域名
  2. 解析二级域名,记录值填 ip,主机记录填写 blog-api(自行定义)

nginx 重定向域名

目标是将 http://blog-api.jacksonzhou.com:3001/api/test 替换成 http://blog-api.jacksonzhou.com/api/test

编辑 nginx.conf,一般在 /etc/nginx

server {
listen 80;
location /api/ {
proxy_pass http://x.x.x.x:3001;
}
}

重启 nginx 试试

systemctl restart nginx

https 配置

目标是将 http://blog-api.zhouweibin/api/test 替换成 https://blog-api.zhouweibin/api/test

  1. 申请证书。可以到腾讯云或阿里云申请免费的证书(这里是用的 pem 与 key 文件)
  2. 安装 Nginx 的 SSL 模块。可以使用 nginx -V 检查是否已安装(在输出中查找–with-http_ssl_module
  3. 在 nginx 目录新建 cert 文件夹存放证书文件。将证书文件上传(可以使用scp命令或者 FileZilla 软件)
  4. Nginx.conf 配置

先新增 https server

server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name zhouweibin.top www.zhouweibin.top;
root /usr/share/nginx/html;

ssl_certificate "/etc/nginx/cert/zhouweibin.top.pem";
ssl_certificate_key "/etc/nginx/cert/zhouweibin.top.key";
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 10m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;

# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;

location / {
proxy_pass http://nextblog;
# root /home/blog;
# index index.html;
}

location /api/ {
proxy_pass http://127.0.0.1:3001;
}

error_page 404 /404.html;
location = /40x.html {
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}

将 http 重定向到 https

server {
listen 80;
server_name zhouweibin.top www.zhouweibin.top;
return 301 https://$server_name$request_uri;
}
  1. 重启 nginx

SPA

部署单页面应用的步骤如下:

  1. build 生成静态页面
  2. nginx 配置端口,用来访问这个静态页面
  3. 注意重定向到 index.html(spa history 模式)

搭建 ci:

  1. github 项目配置 webhook
  2. jenkins 新增项目(jenkins 配置参照之前的 node 项目即可)
  3. shell 脚本
#!/bin/sh
cd /var/lib/jenkins/workspace/_name_
rm -rf node_modules dist
node -v
npm -v
npm config set registry https://registry.npm.taobao.org/
npm install #安装项目中的依赖

#!/bin/sh
npm run build
cd dist
rm -rf _name_.tar.gz #删除上次打包生成的压缩文件
tar -zcvf _name_.tar.gz * #把生成的项目打包成压缩包,方便移动到项目部署目录

cd /home #进入web项目根目录
if [ ! -d "_name_" ]; then
sudo mkdir _name_
fi
cd /home/_name_ #进入web项目根目录
sudo mv /var/lib/jenkins/workspace/_name_/dist/_name_.tar.gz ./ #移动刚刚打包好的项目到web项目根目录
sudo tar -zxvf _name_.tar.gz -C ./ #解压项目到dist目录
sudo rm -rf _name_.tar.gz #删除压缩包
  1. nginx 配置

next 应用(ssr)

部署 next 应用的步骤如下:

  1. next build
  2. next start

搭建 ci:

  1. github 项目配置 webhook
  2. jenkins 新增项目(jenkins 配置参照之前的 node 项目即可)
  3. shell 脚本
#!/bin/sh
cd /var/lib/jenkins/workspace/next-blog
tar -zcvf next-blog.tar.gz

cd /home/next-blog
sudo mv /var/lib/jenkins/workspace/next-blog/next-blog.tar.gz ./
sudo tar -zxvf next-blog.tar.gz -C ./
sudo rm -rf next-blog.tar.gz

rm -rf node_modules .next
node -v
npm -v
npm config set registry https://registry.npm.taobao.org/
npm install

npm run build
pm2 restart next-blog --watch
  1. nginx 配置代理
upstream nextblog {
server 127.0.0.1:3000; # next-blog
keepalive 64;
}
server {
# ...
location / {
proxy_pass http://nextblog;
}
}