Skip to main content

3 posts tagged with "Nginx"

View All Tags

· 18 min read

其实我的需求很简单。原先自己的知识库网站基于 Algolia 的纯文本检索有点落后了,于是想接入 AI 问答,可以同时检索多处内容并做对话式的总结,而不是简单的返回带有文本字符串的文章列表。(Algolia 后面推出了 NeuralSearch,不过得额外开启还要付费)。

作为前端,我一直想找个切入点探索 AI Agent 相关技术,而给自己的知识库接入基于 RAG(检索增强生成)的 AI 问答,无疑是跑通 Prompt 工程和 RAG 技术最好的练手项目。

怎么交互呢?因为原先 Algolia 的检索窗口在右上角,所以我直接在旁边加上 AI 问答按钮,触发对话窗口。效果如下:

ai问答效果演示

点击本网站右上角【Ask AI】就可以体验

这有点像客服助手——说起来,客服助手可以算是入门 AI Agent 最好的项目了,根据问题设计多种回复 Prompt,通过 Function Calling 调用 API 方法或查询数据库,我刚开始练手就是搞了一个客服助手,熟悉了 AI Agent 的基本体系。

接下来,我需要一个轻量的后端服务来实现 RAG 服务接口,对接向量数据库。

整体设计与技术选型

做全栈开发,第一步是定技术栈。我的核心诉求是轻量、低成本、免运维

  • 前端:docusaurus (SSG)。博客原有的基建,负责展示和发起问答。
  • 后端:hono。没有选 Express,Hono 是一个极度轻量的 Web 框架 (12-15kb),原生支持 Web Standard API,不仅能在 Node.js 跑,还能跨运行时支持 Bun/Deno。API 风格跟 Express 很接近,这次主要是尝鲜,服务也不复杂,就用 Hono 来实现。
  • 向量数据库:chroma db。这是一个轻量的向量数据库,基于 SQLite(文件型存储),Node.js 也能快速接入。我的知识库属于个人场景,单用户访问,不需要分布式高 QPS。关于向量,可以先记住这句话:语义相近的文本,向量在空间中的距离就小
  • 大模型:deepseek-v3。这种场景只是简单的总结和问答,不需要复杂的模型,性价比极高。
  • 向量模型:阿里的 text-embedding-3。中文支持好,费用低。

关键工程问题

下面这几个模块,刚好按 RAG 的数据流转顺序铺开:离线数据生产(切片 -> 灌库) -> 在线服务接口(Prompt 拼接 -> LLM 流式透传) -> 前端展示(流式渲染)

1. 文档切片 (Chunking)

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

我们需要将 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"
}

2. 文本向量化与增量灌库

借助 text-embedding-3 模型,把切好的文本转成向量表示(就是把「文字」转成「坐标」,后续在向量空间里找距离最近的那几个)。

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
}
}

增量灌库

第一次全量灌库时有个小插曲:接口携带的数据太大,导致 Nginx 报了 body 体积过大的异常 (413)。除了调整 Nginx 阈值 (client_max_body_size),稳妥起见还用了多个接口分批上传,避免触发接口超时 (504)。

后续更新采用增量灌库:基于 Git Diff 仅对变更文件重新 Embedding,避免每次修改知识库都要全量上传。这部分逻辑写在 GitHub Actions 中。

ChromaDB 的 API 设计非常契合这种场景,底层 HNSW 索引是自维护的,不用手动 rebuild:

// 新增
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: [...] });

3. Prompt 设计

针对 AI 问答的回复,我设计了如下 Prompt,严格限制 AI 的行为,避免幻觉:

# 角色

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

# 回答要求

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

# 参考文档

${retrieved_chunks}

# 用户问题

${user_query}

4. 流式问答 (SSE) 与 Nginx 代理坑点

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

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

1. 响应头

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

2. 后端实现 (Hono)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: {
/* ... */
},
body: JSON.stringify({ /* ... */ stream: true }),
});

if (!upstream.body) return;

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

// 监听客户端断开,清理上游(不浪费 token)
stream.onAbort(async () => {
await reader.cancel();
});

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 /rag-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; # 防止超时断流
}

总结下易踩的坑:

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

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

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

这个库底层用 fetch 发请求,支持 POST 和自定义 Header,内部封装了 ReadableStream 兼容处理,业务代码只关心 onmessage 回调即可。

总结下上述流程:

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 不缓冲 → 客户端立即可见

评估模型回复的质量

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

一、建立「黄金评测集」 人工标注一批「问题-标准答案」对,覆盖知识库核心知识点。起步 50~100 条就够,跟着系统一起迭代。

二、给每个回答打个分 省事打法:直接让 LLM 当裁判 A/B 评分,准备一份评分 Prompt,让 LLM 对比「标准答案」和「模型回答」打分。

三、问答日志落库 每次问答记录 query, retrievedChunks, finalAnswer, feedback (用户点赞/踩)。落库后方便每周抽样核对,找出「召回失败」的反例。

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

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

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

Token 消耗把控

目前只是通过 Prompt 限制输入输出 token,以及增量灌库减少向量模型 token 的消耗。如果后面要用上高级模型的话,肯定得做好更精细的把控,毕竟真的贵。

自动化部署

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

  • GitHub Actions 编写 workflow deploy 脚本,push 代码后自动触发镜像构建。
  • 编写 Dockerfile,上传 Docker Hub。
  • 个人服务器从 Docker Hub 拉取镜像部署。

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

最后

坦白说,这只是一个简单的 RAG 服务,仅仅是向量召回和模型总结回复(Naive RAG)。在实际使用中,你会发现搜极度具体的专有名词时,纯向量检索很容易漏召回。

我也是刚接触 ai agent 开发,修行尚浅,望路过大佬们见谅。后面其实还能做进阶优化,这也是我打算在这个专栏接着探索的尝试:

  1. Hybrid Search (混合检索):可叠加 BM25 关键词,解决专业术语丢失的问题。
  2. Re-ranking (重排):引入专门的重排模型(如 BGE-Reranker),精准过滤无效的“相似废话”。
  3. Agentic Flow (智能体):接入 Function Calling,从「问答」走向「任务执行」,比如让其检索其他数据库的内容或者联网搜索外部资源。

拥抱 AI 时代,把手弄脏,我们下篇见。

· 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;
}
}