Docker安装Answer以及配置html代码片段。

引言

好久不见,最近一直在忙活学校期末和实习的事情,好久都没有更新博客了。最近闲下来了之后,又开始折腾docker了。

先简单介绍一下Answer吧,它是国内的思否平台开源的一个问答社区软件,简单来说,就是一个开源的类似stackoverflow和思否的问答论坛,可以由我们自行部署。

SegmentFault 思否正式开源问答社区软件 Answer

image.png

Answer的安装和配置

安装

Answer的官方文档是我折腾过这么多docker以来,个人感觉最完善的。一是因为answer本身的安装就不是特别复杂,二是文档中把常见的场景和可能遇到的问题都涉及到了,还是很棒的。

文档:安装Answer

这里推荐大家使用docker-compose来安装,后续升级方便一些。你可以用官方提供的命令快速下载配置文件并启动answer

1
curl -fsSL https://raw.githubusercontent.com/apache/incubator-answer/main/docker-compose.yaml | docker compose -p answer -f - up

这个命令的链接是官方文档给出的yaml示例文件,内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
version: "3"
services:
answer:
image: apache/answer
ports:
- '9080:80'
restart: on-failure
volumes:
- answer-data:/data

volumes:
answer-data:

这里默认采用了docker自带的volums来作为answer数据持久化的方式。为了更好的管理本地文件,你可以使用下面的docker-compose配置

1
2
3
4
5
6
7
8
9
version: "3"
services:
answer:
image: apache/answer
ports:
- '9080:80'
restart: on-failure
volumes:
- ./data:/data

创建一个专门的文件夹,然后将上述配置写入docker-compose.yml文件,使用如下命令启动容器即可。

1
2
docker-compose pull
docker-compose up -d

随后你就可以访问IP:9080,进行answer的后续配置了。都是可视化的,且支持中文。配置比较简单,文档中也给出了截图,这里就不演示了。Answer支持sqlite、MySQL、PostgreSQL三种数据库,支持的还是比较全面的。如果你只是自己私下用用,或者服务器配置不是很高,直接用sqlite就可以了,性能足够了。

按指引创建了默认的管理员账户后,使用该账户登录answer,即可在页面左侧进入answer的管理控制台,在这里能对站点做更多的配置,包括配置站点logo、配置smtp、限制站点是否需要登录才能查看、限制可供注册的邮箱域名等等,这些都是常见的个人站点配置项,都比较直观,这里就不赘述了。

目前answer的最新版本是1.4.1,后文所述内容基于此版本。

升级

因为我们是用docker-compose部署的answer,所以升级很简单,直接pull拉取了新镜像,升级就ok了。

1
2
3
docker-compose down  # 停止并删除原有容器
docker-compose pull # 拉取新镜像
docker-compose up -d # 拉起新容器

以防万一,在升级之前一定要备份原有的容器volumes数据和数据库!

插件

Answer的插件并不能直接在容器中动态安装,如果需要新的插件,得自己重新基于原有的docker镜像构建出一个新的带插件的镜像。这部分操作在answer的官方文档中有很详细的说明。

文档:使用插件

下面是一个示例的dockerfile,用于安装几个常见的插件。注意官方的镜像在默认情况下提供了reviewer-basic、connector-basic、captcha-basic这三个插件,如果你需要使用它们,在构建自己的新镜像的时候也需要加上这三个插件。

为了方便在本地构建这个镜像,我在官方给出的dockerfile的基础上新增了一个sed命令来修改golang:1.22-alpine容器里面的镜像源配置,这样在本地构建的速度能加快。否则容易因为网络问题导致无法正常构建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
FROM apache/answer AS answer-builder

FROM golang:1.22-alpine AS golang-builder

COPY --from=answer-builder /usr/bin/answer /usr/bin/answer

# dockerfile中修改镜像源进行构建
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories && \
apk update && \
apk upgrade && \
apk --no-cache add \
build-base git bash nodejs npm go && \
npm install -g pnpm@8.9.2

# 添加插件,注意基础审核插件也需要添加
RUN answer build \
--with github.com/apache/incubator-answer-plugins/storage-s3 \
--with github.com/apache/incubator-answer-plugins/search-elasticsearch \
--with github.com/apache/incubator-answer-plugins/render-markdown-codehighlight \
--with github.com/apache/incubator-answer-plugins/editor-formula \
--with github.com/apache/incubator-answer-plugins/reviewer-baidu \
--with github.com/apache/incubator-answer-plugins/reviewer-basic \
--with github.com/apache/incubator-answer-plugins/connector-basic \
--with github.com/apache/incubator-answer-plugins/captcha-basic \
--output /usr/bin/new_answer

FROM alpine
LABEL maintainer="linkinstar@apache.org"

ARG TIMEZONE
ENV TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}

RUN apk update \
&& apk --no-cache add \
bash \
ca-certificates \
curl \
dumb-init \
gettext \
openssh \
sqlite \
gnupg \
tzdata \
&& ln -sf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime \
&& echo "${TIMEZONE}" > /etc/timezone

COPY --from=golang-builder /usr/bin/new_answer /usr/bin/answer
COPY --from=answer-builder /data /data
COPY --from=answer-builder /entrypoint.sh /entrypoint.sh
RUN chmod 755 /entrypoint.sh

VOLUME /data
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]

使用如下命令构建带插件的镜像,构建出来的新镜像tag为answer-with-plugin

1
docker build -t answer-with-plugin .

如果像我一样想在arm的mac上构建一个给x86运行的镜像,则需要使用如下命令,否则构建出来的镜像会是arm的,无法在x86机器上部署。

1
docker buildx build --platform=linux/amd64 -t answer-with-plugin .

构建完成后,将容器从没有插件的版本升级到有插件的版本,需要修改docker-compose.yml文件中使用的镜像为新构建出的镜像tag(按上述构建命令,即修改为answer-with-plugin),然后先docker-compose down把原有容器删除并终止,再docker-compose up -d拉起新容器就可以了。

以防万一,在升级之前一定要备份原有的容器volumes数据和数据库!

Answer站点”声望“说明

Answer站点用声望值来标明一个用户在站点里面的贡献值或活跃度,注意Answer的核心是一个问答社区,所以声望值的获取是和发送、回答问题、采纳回答息息相关的。

官方文档中关于声望值的部分没有被汉化,这里我给出我自行翻译后的版本。

声望可以通过如下方式获得:

条件声望
他人点赞了您的提问+10
他人点赞了您的回答+10
他人采纳了您的回答+15
您采纳了他人的回答+2
您的修改建议被采纳+2
您给他人的回答点踩-1
您的问题被点踩-2
您的回答被点踩-2

额外规则:

  1. 用户在注册激活后,默认拥有有1声望值;
  2. 用户首次发布回答后,会获得20声望值;
  3. 每天最多可以获取到200声望值,但从“他人采纳了您的回答”中获取的声望值不受每天200上限的限制;
  4. 采纳自己的回答不会获得声望值;
  5. 如果某个行为导致用户的声望值小于1,则之后的任何降低声望值的行为都不会降低该用户的声望值;

其中“修改建议”是A用户申请对某个问题或者回复进行编辑(即用户可以编辑其他人的回答回复),编辑需要管理员审核,审核通过了,就会给A用户加2点声望值。

可用的HTML/CSS代码

为了加快站点访问速度,你可以使用https://www.wetools.com/js-compress工具压缩一下js代码再配置到站点里面去。博客为了更好的展示js代码,没有对代码进行压缩。

左侧快速链接

官方示例问答社区meta.answer.dev的左侧有几个快速链接,这部分不能直接在后台配置,需要使用自定义的HTML代码来实现。

image.png

参考论坛里面的帖子:https://meta.answer.dev/questions/D1Jd/about-the-side-quick-link-navigation

代码片段如下,注意帖子里面给出的代码片段href后用的还是/users,已经是老版本的代码片段了,1.4.1版本在用户后还多了一个徽章按键,所以要将/user修改成/badges,插入的位置才是正确的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script>
const htmlstr = `<div class="py-2 px-3 mt-3 small fw-bold quick-link">Quick Links</div>
<a class="nav-link" href="/link1">
link name
</a>
<a class="nav-link" href="/link2">
link name
</a>`;

window.onload = function () {
const navUser = document.querySelector('#sideNav a[href="/badges"]');
const navQuick = document.querySelector("#sideNav .quick-link");
if (navUser && !navQuick) {
navUser.insertAdjacentHTML("afterend", htmlstr);
}
};
const timer = setInterval(() => {
const navUser = document.querySelector('#sideNav a[href="/badges"]');
const navQuick = document.querySelector("#sideNav .quick-link");
if (navUser && !navQuick) {
navUser.insertAdjacentHTML("afterend", htmlstr);
}

// If you don't need to keep the selected style, you can remove the following code
// nav active style start
const links = document.querySelectorAll('#sideNav a[href^="/tags/"]');
links.forEach((link) => {
const href = link.getAttribute("href");
const currentPathname = window.location.pathname;
if (href === currentPathname) {
link.classList.add("active");
} else {
link.classList.remove("active");
}
});
}, 500);
// nav active style end

window.addEventListener("beforeunload", function (event) {
clearInterval(timer);
});
</script>

将这个代码片段放到站点管理-自定义代码中的页脚部分即可(一定要放页脚,不然不会生效)

文章TOC栏

因为answer本身是一个问答社区,所以它压根就没有考虑有人会在上面发带标题层级的问题的情况,也就没有原生支持TOC栏。不过借助上述HTML代码的经验,我认为同样可以使用JS脚本来自己搓一个TOC栏出来。在gpt的帮助下,算是完成了这样的功能。

因为我只是想要一个基础的toc栏,如果想支持多级toc的显示会更加麻烦,所以就只弄了一个支持文章内的H2标题跳转的版本。只有H2单级标题的情况下,可以直接根据原有的card来实现。

用F12打开开发者终端,用选择器选中answer右侧的关注标签和热门问题卡片,能定位到如下html代码。

image.png

我们要做的就是在<div class="page-right-side mt-4 mt-xl-0 col">父容器的已有元素之后,插入一个新的<div class="card>容器,作为我们的标题大纲栏。根据已有的热门问题的这个card,最终需要创建出来的大纲html代码是如下格式的。

1
2
3
4
5
6
7
8
<div class="card">
<div class="card-header">本文大纲</div>
<div class="list-group list-group-flush">
<a class="list-group-item list-group-item-action" href="#heading-1">标题1</a>
<a class="list-group-item list-group-item-action" href="#heading-2">标题2</a>
</div>
</div>
<br>

你可以将如下代码粘贴到HTML配置的侧边栏中,看看它的最终显示效果。和已有的热门问题卡片是一致的。

image.png

知道了样式之后,接下来就是怎么通过js来创建出这个样式并展示在前端了。这部分代码是gpt写的,来回battle了好几个回合,再加上用我自己浅显的前端知识小修小补一下,gpt才写出来一个可用的代码,可算是搞定了。

代码里面要注意的是,不能直接在整个页面扫H2标题,这样会把回答里面存在的H2标题也给扫描出来,不符合预期。需要扫描的是正文部分,正文是在<article class="fmt text-break text-wrap mt-4"></article>容器中。在js代码的遍历H2标题部分,需要将这个设置为父容器,这样就能避免扫描出回答里面的H2标题了。

1
document.querySelectorAll('article.fmt.text-break.text-wrap.mt-4 h2')

这里把完整的js代码分享给大家。核心思想是借助MutationObserver监听站点页面的变化,然后遍历文章内容中的H2标题,并将其按侧边栏的热门问题的样式创建一个卡片(前文提到的html格式),并插入到站点中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<script>
// DOM 变化回调函数
const observer = new MutationObserver(() => {
timeoutId = setTimeout(() => {
// 只有问题内容页面才去扫描TOC
if (!window.location.href.includes('/questions/')) {
return;
}
// 如果没有插入TOC卡片,且存在h2标题(通过我们自定义的class名来精准检查是否已经插入了toc)
if (!document.querySelector('.toc-card') &&
document.querySelectorAll('article.fmt.text-break.text-wrap.mt-4 h2').length > 0) {
console.log('[toc] target container exists, begin insert toc card.');
insertOutlineCard();
}
}, 1000); // 这里延时1000ms再去执行,因为原始的侧边栏可能还没有加载完成,插入的位置不对
});

// 配置 MutationObserver 监听 DOM 变化
observer.observe(document.body, {
childList: true, // 监听直接子元素变化
subtree: true // 监听整个文档树
});

const insertOutlineCard = () => {
// 创建卡片
const card = createOutlineCard();
if (!card) {
console.log('[toc] no h2 found in current page.');
return;
}
// 给卡片添加唯一的 class 或 id,用于后续检查是否已经插入
card.classList.add('toc-card');
// 插入卡片
const targetContainer = document.querySelector('.page-right-side.mt-4.mt-xl-0.col');
targetContainer.appendChild(document.createElement('br')); // 换行符
targetContainer.appendChild(card);
console.log('[toc] toc card insert success.');
};

const createOutlineCard = () => {
// 只扫描正文里面的h2标题
const h2Elements = document.querySelectorAll('article.fmt.text-break.text-wrap.mt-4 h2');
if (h2Elements.length == 0) {
return null;
}

const card = document.createElement('div');
card.className = 'card';

const cardHeader = document.createElement('div');
cardHeader.className = 'card-header';
cardHeader.textContent = '本文大纲';
card.appendChild(cardHeader);

const listGroup = document.createElement('div');
listGroup.className = 'list-group list-group-flush';
card.appendChild(listGroup);

h2Elements.forEach(h2 => {
const id = h2.id;
const text = h2.textContent;
const link = document.createElement('a');
link.className = 'list-group-item list-group-item-action';
link.href = `#${id}`;
link.textContent = text;

listGroup.appendChild(link);
});

return card;
};
</script>

将这个代码片段放到站点管理-自定义代码中的页脚部分即可(一定要放页脚,不然不会生效)

最终的标题大纲效果如下,黑夜和白天模式都能正常支持,点击跳转也是正常的。

image.png

image.png

The end

写这个文的主要目的还是分享一下部署的经验和我自己整出来的这个TOC栏的代码片段,感觉还是很有用的。如果帮到了你,可以在评论区吱一声,给慕雪来点鼓励,谢谢!

目前Answer其实已经算是非常好部署的一个论坛了,文档也很齐全,虽然部分文档还是英文的。

原本我想折腾flarum的,结果使用docker部署的flarum死活无法正常配置smtp。检查了好几遍,smtp的配置没填错,相同的smtp配置Answer能用,但是flarum不行,不知道是docker容器有问题还是我安装的有问题。后来觉得浪费了太多时间了,还是直接回避了这个问题,直接换了一个论坛,转到Answer上了。

如果关于本文有任何问题,欢迎在评论区一同讨论。