背景介绍
KBS 是我入职并行后接触的第一个项目,该项目之前有同事基于 OpenKB(采用 Node + Express + Mongodb技术栈的开源知识库应用,github地址:https://github.com/mrvautin/openKB) 搭建过一个简单的知识库,但是初步实施后的效果不是很符合预期,便安排我着手知识库相关的后续工作。
以前在 TRS 的时候有接触过三一项目的知识库项目,该项目基于 TRS 自主研发的企业知识门户产品 —— TRS EKP V6.5 进行实施,是整合了公司的EKP、全文检索系统、数据网关系统等产品综合实施的一套企业内部知识分享的平台,是一个将公司内部各领域专家们的经验进行梳理沉淀的知识管理工具。
经过前期的了解,我们将要做的知识库跟之前实施的三一知识库项目有非常大的出入,主要是用于一些产品文档的发布,类似阿里云、腾讯云的产品文档生成工具。
最开始有考虑采用纯前端的方式实现,如当前较主流的VuePress实现,这样可以很方便的实现markdown文档编写 + algolia全文检索,并能基于git很方便达到文档版本控制的目的,但在深入沟通后,发现该方式不能满足其他的需求,如支持审核发布、支持用户认证后才可浏览。
所以只能考虑其他的解决方案了。经过沟通后,决定先找个开源的 CMS,做个基础效果,再论证可行性
技术调研及产品选型
结合以往工作经验,经过沟通,最终考虑采用CMS的方式来实现我们的知识库项目。可考虑开源 CMS 或低成本的采购商业 CMS。以下是一些当时(2021-07-05)调研的一些开源 CMS 情况:
经过沟通,最终选择了基于 jspxCMS 来进行项目可行性验证实施工作,主要有如下原因:
1、jspxCMS 采用 SpringBoot + FreeMarker 跟我们团队技术栈较接近
2、编辑器支持Ueditor 和 Markdown 两种编辑模式,适合给运营人员进行富文本排版及程序员轻量内容排版
3、可商用,但需在页面底部保留jspxCMS版权声明,购买商业版后可去除,商业版价格也就几千块钱,且商业版支持站群模式,功能试用满足运营要求的话,可购买商业版License。
项目实施过程
1、搭建示例效果
项目初期在本地部署了 jspxCMS 应用,准备基于该CMS实现基本的内容管理及网站发布工作。有阿里云文档做参考,主要是仿站,经过两天的实施工作,在没有UI参与的情况下实现了站点的最初的界面效果:
知识库首页
产品首页
内容详情页
搜索结果页
按照目前的规划,KBS 系统可以通过栏目的方式很方便的做成公司整个产品体系的知识库门户。
2、系统功能改造
经过需求梳理,整理了如下待办事项:
1、知识库站点模板
- 知识库首页模板 zzl
- 产品页模板 zzl
- 内容页模板 zzl
- 实现检索功能 zzl
- 用户登录、认证对接 zzl
- 北龙超云版知识库模板(优先级低) zzl
2、后台改造
- 改造成并行蓝风格 zzl
- 改造左侧菜单导航样式 zzl
- 改造顶部显示逻辑 zzl
- 将列表中的操作列调整到表格最后一列 zzl
- 去掉左侧菜单中商业版相关菜单入口 zzl
- 去掉系统业务界面中商业版相关的功能入口 zzl
- 将启动方式改成 spring-boot 模式,即 java -jar [xxx].war ,不用放到 tomcat 里 hx
- 调研是否能支持多例 hx
- 用户认证对接 hx
- 支持多环境配置,即有生产,stage 和本地。 使用配置中心 hx
- 支持 JDK 17 hx
- 购买商业版后整合商业版相关功能模块 zzl
- 应用资源文件分离改造。将上传文件目录、模板目录、索引文件目录从war包中分离 zzl
- 后台界面支持并行蓝风格和北龙红风格 zzl
3、技术实现细节
完成该项目后主要有如下可圈可点的部分技术细节用于分享
1、知识库前台模板
制作网站模板的工作其实主要就是在html静态文件中插入 CMS 的标签,并结合CMS的静态资源引入机制改写页面相关的静态资源文件。一般会提取一些公共底部、公共底部模板之类的。
CMS 实施过程中常用的标签其实并不多,主要也就是获取栏目列表、内容列表、栏目信息、内容信息这些最基础的。经过实践,发现jspxCMS的标签还是非常灵活的,支持模板嵌套、标签嵌套等jspxCMS 使用的freemarker实现静态化,自带了非常灵活的判断、循环等表达式。
经过整理,部分标签使用记录如下:
获取栏目列表及栏目信息
以下是本项目中获取知识库左侧栏目结构树的模板示例
<ul class="menu-list-container">
[@NodeList parentId=node.id;list]
[#list list as pnode]
<li class="level1" data-node-id="${pnode.id}">
<a href="javascript:void(0);">
<i class="icon-triangle kbsIconfont kbs-triangle"></i>
<span class="menu-item-text">${pnode.name}</span>
</a>
<ul>
[@NodeList parentId=pnode.id;subNodes]
[#list subNodes as subNode]
<li class="level2">
<a href="javascript:void(0);">
<i class="icon-triangle kbsIconfont kbs-triangle"></i>
<span class="menu-item-text">${subNode.name}</span>
</a>
<ul>
[@InfoList nodeId=subNode.id;subNodeDocs]
[#list subNodeDocs as info]
<li class="level3">
<a href="${info.url}" target="_self">
<span class="menu-item-text">${info.title}</span>
</a>
</li>
[/#list]
[/@InfoList]
</ul>
</li>
[/#list]
[/@NodeList]
[@InfoList nodeId=pnode.id;infos]
[#list infos as info]
<li class="level2">
<a href="${info.url}" target="_self">
<span class="menu-item-text">${info.title}</span>
</a>
</li>
[/#list]
[/@InfoList]
</ul>
</li>
[/#list]
[/@NodeList]
[@InfoList nodeId=node.id;infos]
[#list infos as info]
<li class="level1">
<a href="${info.url}" target="_self">
<span class="menu-item-text">${info.title}</span>
</a>
</li>
[/#list]
[/@InfoList]
</ul>
获取内容列表及内容信息
<div class="box-product-docs">
<div class="box">
<h3 class="box-title">最新发布</h3>
<div class="box-content row">
[@InfoList nodeId=node.id limit='6' isIncludeChildren='true';list]
[#list list as info]
<div class="col-md-4 col-sm-6">
<div class="list-body">
<a href="${info.url}">
<p class="list-title text-left">${info.title}</p>
<p class="list-content">${info.description}</p>
<span class="list-more">详情+</span>
<span class="list-date">更新时间:${info.publishDate?string('yyyy-MM-dd HH:mm')}</span>
</a>
</div>
</div>
[/#list]
[/@InfoList]
</div>
</div>
<div class="box">
<h3 class="box-title">热门知识</h3>
<div class="box-content row">
[@InfoList nodeId=node.id sort='views desc' limit='6' isIncludeChildren='true';list]
[#list list as info]
<div class="col-md-4 col-sm-6">
<div class="list-body">
<a href="${info.url}">
<p class="list-title text-left">${info.title}</p>
<p class="list-content">${info.description}</p>
<span class="list-more">详情+</span>
<span class="list-date">浏览次数:${info.views}</span>
</a>
</div>
</div>
[/#list]
[/@InfoList]
</div>
</div>
</div>
模板嵌套
[#include "include_header.html"/]
当前位置
<div class="body-position">
<i class=" kbsIconfont kbs-home"></i> [#list node.hierarchy as n]<a href="${n.url}">${n.name}</a>[#if n_has_next] > [/#if][/#list]
</div>
内容分页
[@InfoPage nodeId=node.id pageSize='15';pagedList]
<ul class="list-unstyled mt10">
[#list pagedList.content as info]
<li style="padding:15px 0;border-bottom:1px dotted #ccc;">
[#if info.withImage]
<div class="left" style="padding:3px 10px 3px 0;width:22%;">
<a href="${info.url}" target="_blank"><img src="${info.smallImageUrl}" width="138" height="92"/></a>
</div>
[/#if]
<div class="left" style="[#if info.withImage]width:75%;[/#if]">
<div>[@A bean=info class='ff-yh fs18 a c-000' target="_blank"/]</div>
<div class="" style="line-height:1.8;padding:2px 0;color:#818181;">${substring(info.description,100,'...')}</div>
<div class="" style="padding:2px;color:#a1a1a1;">${info.publishDate?string('yyyy-MM-dd HH:mm:ss')}</div>
</div>
<div class="clear"></div>
</li>
[/#list]
</ul>
<div class="mt20">
[#include "inc_page.html"/]
</div>
[/@InfoPage]
分页嵌套模板(inc_page.html)
[#escape x as (x)!?html]
<div class="pager">
[#if page>1]<a href="${paging(1)}" class="page"><i class="kbsIconfont kbs-first"></i></a>[/#if]
[#if page>1]
<a href="${paging(page-1)}" class="page"><i class="kbsIconfont kbs-prev"></i></a>
[/#if]
[#assign start=page-4/][#if start<1][#assign start=1/][/#if]
[#assign end=start+8/][#if end>pagedList.totalPages][#assign end=pagedList.totalPages/][/#if][#if end<1][#assign end=1/][/#if]
[#if end-start<8][#assign start=end-8/][/#if][#if start<1][#assign start=1/][/#if]
[#list start..end as p]
<a[#if page!=p] href="${paging(p)}"[/#if] class="[#if page!=p]page[#else]page-curr[/#if]">${p}</a>
[/#list]
[#if page < pagedList.totalPages]
<a href="${paging(page+1)}" class="page"><i class="kbsIconfont kbs-next"></i></a>
<a href="${paging(pagedList.totalPages)}" class="page" ><i class="kbsIconfont kbs-last"></i></a>
[/#if]
</div>
[/#escape]
超级方便的app自由模版
app自由模板,主要是可以传参给模板获取一些指定的内容片段,这就极大的扩展了模板的功能,目前我们用得最多的场景是不同系统间通过app模板获取KBS的内容列表及内容详情等json数据,然后在异构系统中进行个性化展现。app自由模板的官网介绍如下(https://www.ujcms.com/documentation/269.html):
整理了部分自由模板内容以作备忘:
1、内容列表页(app_newsPage.html)
[#-- 栏目内容分页列表数据,用于列表新品快报、公告更多页面分页显示数据 --]{
[@InfoPage node=Param.nodeCode pageSize=Param.pageSize page=Param.pageNum;pagedList]
"total":${pagedList.totalElements},
"size":${pagedList.size},
"pages":${pagedList.totalPages},
"current":${pagedList.number + 1},
"records": [
[#list pagedList.content as info]
{
"id":"${info.id}",
"title":"${info.title?js_string}",
"publishDate":"${info.publishDate?string('yyyy-MM-dd HH:mm:ss')}",
"url":"${info.url?js_string}"
}[#if info_has_next],[/#if]
[/#list]
]
[/@InfoPage]
}
2、内容详情页(app_newsDetail.html)
[#-- 内容详情接口,用于点击查看内容详情 --][@Info id=Param.articleId;bean]
{
"id":"${bean.id}",
"url":"${bean.url?js_string}",
"title":"${bean.title?js_string}",
"publishDate":"${bean.publishDate?string('yyyy-MM-dd HH:mm')}",
"content":"${bean.text?replace("/uploads/1/","${site.domain}/uploads/1/")?js_string}",
"editorType":"${bean.customs.text_editor_type}"
}
[/@Info]
2、应用资源文件分离改造
jspxCMS 默认的部署方式是将war包部署到tomcat下,相关的应用资源文件(模板文件、后台上传的图片及音视频文件、静态化文件等)也都在应用根目录下,功能强大的jspxCMS提供了发布点功能,将上传的资源文件、静态化的html文件生成到指定的目录下,但是该方式功能有限,不能完全满足我们的需求,如索引文件目录、站点模板文件等均不可配置。
我们的实际需求是需要将应用采用docker部署并使用java -jar 的方式进行启动,那样就需要将原有跟应用整合在一起的资源文件分离出来,独立部署的其他存储目录中。充分理解该需求及使用场景后,特制定了本改造方案。改造过程如下:
1、application.yml 增加分离文件相关根目录配置信息
经过前期梳理,考虑到应用实际情况,将分离文件拆分为两个目录:
kbs.appDataPath
:应用数据(临时文件/日志/缓存/索引等)的存放路径kbs.resourceRootPath
:需本地持久化保存应用资源数据(模板数据/上传文件/静态化文件等)的存放路径
目录支持%{Parent}
、%{Self}
这样的相对路径进行占位,默认为应用当前目录下的 appdata 和 KBSData 目录。亦可在启动应用时增加启动参数的方式修改路径,如--kbs.resourceRootPath=c:/workspace/KBSData/
kbs:
appDataPath: '%{Parent}/appdata/'
resourceRootPath: '%{Parent}/KBSData/'
2、启动应用时对目录进行初始化
阅读源代码后,发现应用在启动时,有调用Constants.loadEnvironment()
方法,我们可以利用该方法作为入口,调用我们的目录初始化方法 KbsConfig.init()
,在应用启动时初始化相关目录,保障相关资源目录是绝对存在的。KbsConfig
相关核心代码如下:
/**
* 知识库初始化配置
* @author 朱治龙
* @Date 2021-08-27 10:18:32
*/
@Component
public class KbsConfig {
private static String appDataPathInConfig;
private static String resourceRootPathInConfig;
/**
* 应用数据目录
*/
protected static String appDataPath = null;
/**
* 资源数据目录
*/
protected static String resourceDataPath = null;
public static void init(Environment env) {
appDataPathInConfig = env.getProperty("kbs.appDataPath");
resourceRootPathInConfig = env.getProperty("kbs.resourceRootPath");
initPaths();
}
/**
* 初始化相关文件路径
*/
private static void initPaths() {
// 初始化应用数据目录
String applicationRealPath = KbsUtil.getApplicationRealPath();
String tempAppDataPath = applicationRealPath;
if (ObjectUtil.isNotEmpty(appDataPathInConfig)) {
tempAppDataPath = appDataPathInConfig;
tempAppDataPath = KbsUtil.replacePathHolder(tempAppDataPath);
} else {
tempAppDataPath += "appdata/";
}
appDataPath = tempAppDataPath;
AppDataPath.initialAppDataPath();
// 初始化资源目录
String tempResourceDataPath = applicationRealPath;
if (ObjectUtil.isNotEmpty(resourceRootPathInConfig)) {
tempResourceDataPath = resourceRootPathInConfig;
tempResourceDataPath = KbsUtil.replacePathHolder(tempResourceDataPath);
} else {
tempResourceDataPath += "KBSData";
}
resourceDataPath = tempResourceDataPath;
ResourceDataPath.initialResourcePath();
}
/**
* 获取应用数据目录
* @return
*/
public static String getAppDataPath() {
if(ObjectUtil.isEmpty(appDataPath)) {
initPaths();
}
return appDataPath;
}
/**
* 获取应用数据目录
* @return
*/
public static String getResourceRootPath() {
if(ObjectUtil.isEmpty(resourceDataPath)) {
initPaths();
}
return resourceDataPath;
}
}
3、改造模板、索引文件、上传资源资源文件的代码逻辑
相关逻辑主要涉及 jspxCMS 的核心代码,均在阅读相关源码后进行改造,此处就不做代码罗列了。
4、系统资源目录中的静态资源文件提供对外访问服务
/**
* 系统资源目录中的静态资源文件提供对外访问服务
* @author 朱治龙
* @Date 2021-08-27 14:48:36
*/
@Configuration
public class KbsWebMvcConfigurer implements WebMvcConfigurer {
/**
* 资源目录相关文件提供后台访问
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/uploads/**").addResourceLocations("file:" + ResourceDataPath.getUploadsPath() + "/");
registry.addResourceHandler("/template/**").addResourceLocations("file:" + ResourceDataPath.getTemplatePath() + "/");
}
/**
* 默认首页跳转到index.html
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("redirect:/index.html");
}
}
3、后台界面支持并行蓝风格和北龙红风格
在之前对 jspxCMS 改造为并行蓝风格时,有将并行蓝风格相关的样式文件提取到paratera-kbs.css中,项目在升级改造过程中后端的同事有整合Spring Cloud 及配置中心组件spring cloud config,可在配置中心中设置当前主题信息,然后应用拿到主题信息后调用不同的样式文件,即可达到同一套后台代码展现不同风格的目的。具体实现过程如下:
1、配置中心增加 kbs.theme-mode
配置项
值为 blsc 或 paratera,若不配置则默认为 paratera
2、全局注入主题风格变量,名称为themeMode
在阅读源码后,修改后台拦截器 BackInterceptor
相关的代码逻辑,全局注入themeMode变量,便于在后台jsp文件中拿到该值进行主题相关业务逻辑判断
3、修改后台jsp视图文件
主要修改代码为:
实施成果
后台效果
1、并行知识库
2、北龙知识库
前台效果
并行知识库
1、站点首页
2、产品页
3、内容详情页
4、检索结果页
北龙知识库
1、站点首页
2、产品页
3、内容详情页
4、检索结果页
评论 (0)