☰
FreeCMS1.5实战:Spring MVC + Hibernate + FreeMarker + Lucene全代码解析
2026/10/20 13:17:13 网站建设 项目流程

简介:FreeCMS 1.5 是一套基于 Java Web 技术的开源内容管理系统,这份 SVN 完整源代码包适合 Java Web 开发者、CMS 二次开发人员以及计算机相关专业学生用来学习真实项目的搭建与定制。资源共 2830 个文件,压缩包约 19.97 MB,其中包含 384 个 .java 源码、521 个 .class 编译文件、184 个 .jsp 页面、362 个 HTML 以及超过 300 个 JS 文件和丰富的 CSS、图片资源,清晰体现了从源码到页面渲染的完整路径,便于按目录结构系统阅读。代码覆盖数据库管理、模板引擎、内容发布、分类管理、评论功能、角色权限控制及插件扩展等 CMS 核心模块,同时包含与 SVN 版本控制配套的项目配置文件,可帮助理解多人协作开发时的工程组织方式。已有 282 人学习/下载,适合作为课程设计、毕业设计或中小企业网站开发的起步参考,在此源码基础上可快速搭建个人博客、企业官网或教育平台,并进行模板定制与 API 二次扩展。

1. FreeCMS1.5 全代码实战:一个 SVN 拉下来就能跑完链路的 Java 门户

从 SVN 刚拉下来的 FreeCMS1.5,解压就是一个能直接导入 IDE 的完整 Java Web 工程。它不是玩具 Demo,而是一套能支撑信息门户的内容管理系统:栏目支持无限层级,文章带富文本和附件,前台由 FreeMarker 模板驱动,后台自带整套管理界面,还内置了基于 Lucene 的站内全文搜索。这份代码最值钱的地方在于把链路串全了——数据库表设计、Hibernate 实体映射、Spring MVC 请求分发、模板标签扩展、全文索引构建,每一层都能翻到对应源码。

适合三类人:刚入行想读一个完整 Java 项目的后端开发,要给中小站点快速搭内容平台的实施人员,以及想研究 FreeMarker 二次开发的模板工程师。FreeCMS1.5 模块边界清楚、部署链路不长,比对着几十个微服务的仓库学架构舒服得多。下面按「先看懂设计、再跑起来、最后动手改」的顺序把这套代码完整过一遍。

2. 技术栈与整体设计:Spring MVC + Hibernate + FreeMarker 的配置拆解

2.1 技术选型:一套经典 J2EE 组合

FreeCMS1.5 的技术栈放在今天不算新,但非常纯粹,没有任何花哨的中间件:

组件版本代际职责
Spring MVC3.x请求分发,前后台共用一套 DispatcherServlet
Hibernate3.x实体映射与数据访问
FreeMarker2.x前台页面模板渲染
Lucene3.x站内全文索引与检索

这套组合是 2010 年前后 Java 门户站的标准答案,jQuery 和 CKEditor 负责后台的交互和富文本编辑。相比 Spring Boot 全家桶,它的优势是透明:web.xml 里注册了什么、Bean 怎么装配、模板引擎怎么接进来,全部肉眼可见。现在很多新手第一次学 Spring 用的就是 Spring Boot,配置都被收敛干净了,反而看不到 DispatcherServlet 的挂载过程,也看不到 FreeMarker 的 TemplateDirectiveModel 是怎么注册成 Bean 的。FreeCMS1.5 把这些 XML 配置全部摊开在 resources 目录下,改一处能立刻感知影响面,是补 J2EE 基础课的现成教材。

选 FreeMarker 而不是 JSP 也有实际考虑:模板对前端人员友好,页面里<@cms_article_list>这种自定义标签语义清楚,写模板的人不用追着后端问数据从哪来,业务方看模板也大概能懂逻辑。

2.2 工程目录:包名已经把模块边界画好了

解压后先扫一眼目录,干净得可以直接当项目骨架用:

FreeCMS1.5 ├── src/com/freems/cms │ ├── controller/ # Spring MVC 控制器 │ ├── service/ # 业务接口与实现,事务边界所在 │ ├── dao/ # Hibernate 数据访问层 │ ├── entity/ # 实体类,跟数据库表一一对应 │ ├── tag/ # FreeMarker 自定义指令 │ ├── lucene/ # 索引构建与检索 │ └── common/ # 分页、常量、工具类 ├── resources │ ├── jdbc.properties # 数据库连接参数 │ ├── spring-mvc.xml # MVC 与 FreeMarker 配置 │ ├── applicationContext.xml # 数据源、事务、Service 装配 │ └── hibernate.cfg.xml # Hibernate 方言与实体注册 └── webapp ├── WEB-INF/web.xml # Servlet 入口 ├── admin/ # 后台管理页面 ├── templates/ # 前台 FreeMarker 模板 └── static/ # css、js、images

实体类、DAO、Service、Controller 严格四层分离,tag 和 lucene 两个包是清晰的扩展点。看代码建议从 controller 入手,顺着一个接口往 service、dao 里钻,比从实体类开始读更容易建立整体感。resources 下的几个配置文件分工不同,是老工程里最容易绕晕的地方:web.xml 只负责把 DispatcherServlet 挂上容器;spring-mvc.xml 负责 controller 扫描、视图解析和 FreeMarker 注册;applicationContext.xml 负责数据源、事务和 service 装配。Controller 不允许直接碰 DAO,这个单向调用纪律是这套代码能保持清晰的根本原因。

2.3 数据模型:栏目、文章、附件三张表的链条

浏览器里看到的门户结构,落到数据库就是三张核心表:

CREATE TABLE cms_channel ( id INT PRIMARY KEY AUTO_INCREMENT, parent_id INT DEFAULT 0, -- 父栏目,0 表示顶级 name VARCHAR(100) NOT NULL, path VARCHAR(100), -- 栏目路径 template_path VARCHAR(200), -- 绑定的前台模板 is_show TINYINT DEFAULT 1 -- 是否在导航显示 ); CREATE TABLE cms_article ( id INT PRIMARY KEY AUTO_INCREMENT, channel_id INT NOT NULL, -- 所属栏目 title VARCHAR(200) NOT NULL, content TEXT, publish_time DATETIME, is_publish TINYINT DEFAULT 1, -- 发布状态 is_top TINYINT DEFAULT 0 -- 置顶 ); CREATE TABLE cms_attachment ( id INT PRIMARY KEY AUTO_INCREMENT, article_id INT NOT NULL, -- 所属文章 file_path VARCHAR(300) NOT NULL, file_name VARCHAR(200) );

栏目通过 parent_id 自关联成树,可以无限级嵌套;文章挂在栏目下,附件挂在文章下,三张表串成一条链。template_path是设计亮点:栏目和模板解耦,同一套数据改一个字段就换一套页面样式,后台栏目管理里改一行就能换前台皮肤。is_publish和is_top两个字段直接决定前台可见性与列表排序,后台发布操作本质就是改这两个值。

除这三张表,工程里还有 cms_member 会员表、cms_comment 评论表和 cms_site 站点表。多站点能力在 1.5 里已经具备,cms_site 记录站点根路径和站点配置,模板里的${base}变量就是启动时从这张表读出来塞进 FreeMarker 上下文的。

3. 核心模块拆解:一个请求从进入到渲染的完整链路

3.1 前台调度:URL 是怎么找到 Controller 的

老工程习惯用.do后缀区分动态请求。web.xml 里 DispatcherServlet 映射:

<servlet> <servlet-name>springMvc</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>springMvc</servlet-name> <url-pattern>*.do</url-pattern> </servlet-mapping>

规则是:任何/xxx.do请求先进 DispatcherServlet,再按 @RequestMapping 匹配到具体方法,方法返回值当作 FreeMarker 模板路径。spring-mvc.xml 里用<context:component-scan base-package="com.freems.cms.controller"/>扫描所有 @Controller,所以新增控制器不需要手动注册 Bean。

文章详情页的控制器长这样:

@Controller @RequestMapping("/article") public class ArticleController { @Autowired private ArticleService articleService; @RequestMapping("/detail") public String detail(Integer id, ModelMap model) { Article article = articleService.getById(id); if (article == null || article.getIsPublish() != 1) { return "error/404"; // 未发布或不存在,走统一错误页 } model.addAttribute("article", article); return "templates/article/detail"; // 匹配 webapp/templates/article/detail.html } }

访问/article/detail.do?id=1时,Spring 自动把 id 绑定到方法参数;Service 从库里取出 Article;ModelMap 里的对象被 FreeMarker 渲染进模板。返回的字符串不带.html后缀,由视图解析器统一补全。这里有个值得注意的细节:detail 方法里直接判断了is_publish,老项目的权限控制往往就是这样散落在 Controller 里,二次开发时想统一补权限,要留意各处类似的散点判断。

3.2 自定义 FreeMarker 标签:模板里如何循环文章列表

首页不能写死文章,FreeCMS1.5 的做法是自定义指令。模板里这样调用:

<@cms_article_list channelId="1" limit="10"> <#list articleList as item> <li> <a href="${base}/article/detail.do?id=${item.id}">${item.title}</a> <span>${item.publishTime?string("yyyy-MM-dd")}</span> </li> </#list> </@cms_article_list>

对应 tag 包里的 ArticleListDirective,实现 TemplateDirectiveModel 接口:

public class ArticleListDirective implements TemplateDirectiveModel { private ArticleService articleService; @Override public void execute(Environment env, Map<String, TemplateModel> params, TemplateModel[] loopVars, TemplateDirectiveBody body) throws TemplateException, IOException { // 从模板标签属性里取参数 int channelId = Integer.valueOf(params.get("channelId").toString()); int limit = Integer.valueOf(params.get("limit").toString()); List<Article> list = articleService.findByChannelId(channelId, limit); // 把结果命名为 articleList 塞回模板上下文 env.setVariable("articleList", new SimpleHash(list)); body.render(env.getOut()); // 渲染标签包裹的内部模板 } }

指令要生效,还差最后一步注册。spring-mvc.xml 里:

<bean id="articleListDirective" class="com.freems.cms.tag.ArticleListDirective"/> <bean class="org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer"> <property name="templateLoaderPath" value="/templates/"/> <property name="defaultEncoding" value="UTF-8"/> <property name="freemarkerVariables"> <map> <entry key="cms_article_list" value-ref="articleListDirective"/> </map> </property> </bean>

关键点两个:freemarkerVariables里的 key 就是模板里的标签名,cms_article_list对应模板里的<@cms_article_list>;Directive 里env.setVariable的变量名必须和模板<#list>后面的变量名一致。很多二次开发翻车都是改了一边忘改另一边,报错信息还特别隐晦,只说标签未定义。

3.3 Lucene 全文搜索:索引构建与检索的完整实现

站内搜索不用 LIKE,而是 Lucene。后台发布文章时把标题和正文写入索引,前台搜索框走检索接口,拿到文章 id 列表后再回查数据库。索引构建核心代码:

public class IndexBuilder { public void buildAll() throws Exception { Directory dir = FSDirectory.open(new File("data/index")); IndexWriterConfig config = new IndexWriterConfig( Version.LUCENE_36, new StandardAnalyzer(Version.LUCENE_36)); IndexWriter writer = new IndexWriter(dir, config); List<Article> articles = articleService.findPublishArticles(); for (Article article : articles) { Document doc = new Document(); doc.add(new Field("id", String.valueOf(article.getId()), Field.Store.YES, Field.Index.NOT_ANALYZED)); doc.add(new Field("title", article.getTitle(), Field.Store.YES, Field.Index.ANALYZED)); doc.add(new Field("content", article.getContent(), Field.Store.YES, Field.Index.ANALYZED)); writer.addDocument(doc); } writer.commit(); writer.close(); } }

id 字段用 NOT_ANALYZED,因为编号要精确匹配,分词反而会拆坏;title 和 content 用 ANALYZED,交给 StandardAnalyzer 分词。索引目录 data/index 默认相对 Tomcat 工作目录,权限不对会直接抛 IOException。IndexWriterConfig 里的 Version 参数记得和 Lucene jar 包版本对齐,版本不一致时系统会提示性报错,这是排查索引问题时的第一检查点。

检索端同样不长:

public List<Integer> search(String keyword, int max) throws Exception { Directory dir = FSDirectory.open(new File("data/index")); IndexSearcher searcher = new IndexSearcher(DirectoryReader.open(dir)); QueryParser parser = new QueryParser(Version.LUCENE_36, "title", new StandardAnalyzer(Version.LUCENE_36)); Query query = parser.parse(keyword); TopDocs docs = searcher.search(query, max); List<Integer> ids = new ArrayList<Integer>(); for (ScoreDoc scoreDoc : docs.scoreDocs) { ids.add(Integer.valueOf(searcher.doc(scoreDoc.doc).get("id"))); } return ids; }

搜索只拿 id,拿到后批量查 cms_article 表,再走正常模板渲染。索引和数据库分开维护的好处是索引坏了重建一次就行,数据不丢。QueryParser默认搜 title 字段,想搜正文就把字段名换成 content,或者用两个字段各建一个 Query 再合并。

3.4 后台发布与静态化:发布不是改一行那么省事

后台点「发布」时,并不是只把 is_publish 置 1。通常要串联三件事:状态更新、重建这篇文章的索引、生成静态 HTML。静态化代码大致长这样:

public void createStaticPage(Article article) throws Exception { Configuration cfg = freemarkerConfig.getConfiguration(); Template template = cfg.getTemplate("article/detail.html"); Map<String, Object> root = new HashMap<String, Object>(); root.put("article", article); File htmlFile = new File(webRoot + "/html/" + article.getId() + ".shtml"); template.process(root, new OutputStreamWriter( new FileOutputStream(htmlFile), "UTF-8")); }

发布时生成 .shtml,前台访问静态文件不经过 Controller,压力留在发布瞬间而不是用户访问瞬间。这套思路就是现在「生成时预热、访问时零计算」的雏形。改需求时要注意:如果只发布动态页忘了重建静态页,前台看到的就是旧内容,排查时先分清访问的是静态文件还是动态请求。

4. 部署运行与避坑排查:编译、乱码和路径失效逐个击破

4.1 环境准备与数据库初始化

我用 JDK 8 + Tomcat 8 + MySQL 5.7 跑通的。老 Spring 3.x 配 JDK 8 没有任何问题,JDK 9 以上会踩模块化兼容的坑,建议不要追新。数据库初始化两条命令就够了:

mysql -uroot -p --default-character-set=utf8 \ -e "CREATE DATABASE freems15 DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;" mysql -uroot -p --default-character-set=utf8 freems15 < freems1.5.sql

第一条指定 UTF-8 建库,避免表继承 latin1 导致后面全部乱码;第二条导入 SQL 脚本同样带--default-character-set=utf8,否则脚本里的中文默认数据会变成问号,甚至直接导入报错。如果本地装了多个 MySQL 实例,记得确认 3306 端口连的是你要用的那个。

4.2 修改配置与启动步骤

工程里默认连接参数集中在 resources/jdbc.properties,改动量很小:

jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/freems15?useUnicode=true&characterEncoding=utf-8 jdbc.username=root jdbc.password=你的数据库密码

关键参数是useUnicode=true&characterEncoding=utf-8,少一个字符都会遇到中文乱码。

提示:&在 properties 文件里不需要转义,但同样的连接串挪进 XML 后必须写成&amp;,这是 XML 转义最常见的坑。

代码拿到手,先看根目录有没有 pom.xml。有就是 Maven 工程:

mvn clean package -DskipTests cp target/freems.war $TOMCAT_HOME/webapps/ $TOMCAT_HOME/bin/startup.sh tail -f $TOMCAT_HOME/logs/catalina.out

没有 pom.xml 就是个传统 Web 工程,用 IDE 配置 Tomcat 后直接 Run,或者导出 WAR 手动拖进 webapps。启动完成后访问这两个地址:

http://localhost:8080/freems 前台首页 http://localhost:8080/freems/admin/login.do 后台登录

后台默认账号一般是 admin / admin。登录后第一件事是去「系统设置」里核对站点地址,把 localhost 改成真实访问地址,否则模板拼接出来的链接全是错的。这一步容易被忽略,但几乎每个环境迁移都会遇到。

4.3 避坑记录:五个实际踩过的坑

坑一:页面中文全部变成问号

现象:首页标题、栏目名全是 ?,但数据库里中文正常。

原因:jdbc.url 漏了 characterEncoding,MySQL 连接默认按 latin1 传输字符。

解决:把 jdbc.properties 的 URL 改成完整带参版本,重启 Tomcat 再刷新。

坑二:启动抛 NoClassDefFoundError 或 UnsupportedClassVersionError

现象:Tomcat 启动到一半红字报错,类找不到或者版本不支持。

原因:JDK 版本过高,老 Spring 3.x / Hibernate 3.x 在 JDK 9+ 上兼容性很差。

解决:切回 JDK 8。任何新版 JDK 运行老工程的行为都是在给自己制造麻烦。

坑三:模板标签解析报错,cms_article_list 未定义

现象:前台页面 FreeMarker 直接抛异常,说 cms_article_list 指令未定义。

原因:spring-mvc.xml 的 freemarkerVariables 里没有注册对应 Bean,或者 Bean 类没有被扫描到。

解决:打开 spring-mvc.xml 核对 entry 的 key 是否与模板标签一致,再确认 value-ref 指向的类路径写在 component-scan 扫描范围内。

坑四:Lucene 建索引抛 IOException Permission denied

现象:后台发布文章时报 data/index 目录无权限。

原因:Tomcat 运行用户对相对路径下的索引目录不可写。

解决:把索引目录改成绝对路径,比如 /data/freems/index,启动前建好目录。绝对路径换环境不翻车,相对路径每次都要重新排查。

坑五:点了栏目链接 404

现象:首页能开,导航里的栏目页全部 404。

原因:cms_channel 表的 template_path 指向的模板文件不存在,或者 templates 目录没有打进部署包。

解决:查这条栏目记录的 template_path 字段,去 webapp/templates 下核对文件。最常见的是模板文件夹拷漏了,重新同步 templates 目录即可。

5. 二次开发实战:模板换肤、自定义标签与轻量接口

5.1 模板换肤:不动 Java 代码的前台改造

FreeCMS1.5 换主题不需要碰 Java。新建一套模板放到 webapp/templates/theme2/,后台栏目管理里把 cms_channel.template_path 改成 theme2 下的对应文件,前台栏目立即切换。CSS 和 JS 放 static 目录,模板里用${base}/static/...引用,base 由系统从站点配置读出。改皮肤阶段只动 templates 和 static,不碰 src,这是对这套架构最基本的尊重。

5.2 自定义标签三步走

加一个「热门文章」之类的新指令,三步:写一个实现 TemplateDirectiveModel 的类,抄现有 Directive 改参数逻辑;在 spring-mvc.xml 注册 Bean 并加入 freemarkerVariables;模板里按注册名调用。动手前先翻 tag 包里现有实现,能找到一模一样的模板代码。

注意:注册名、变量名、参数名三处一致,指令才能生效。

5.3 用 @ResponseBody 给外部系统让出一个 JSON 口子

老工程想给手机端或外部系统提供数据,没必要上新框架,Spring MVC 本身就能输出 JSON:

@RequestMapping("/api/article") @ResponseBody public Map<String, Object> api(Integer id) { Map<String, Object> result = new HashMap<String, Object>(); Article article = articleService.getById(id); result.put("code", article == null ? 404 : 0); result.put("data", article); return result; }

Spring 会用 Jackson 自动把 Map 序列化成 JSON,前提是依赖里引了 jackson-databind;没引就自己用 JSONObject 拼,效果一样。这个接口对现有渲染链路零侵入,是二次开发里性价比最高的一类改动。

5.4 上线验证:改完必须走完的五步

每次改完模板或接口,我都有个强制习惯:新库导入后核对栏目、文章数量;前台每个栏目点一遍确认 template_path 全通;搜一个关键词确认索引可用;发布一篇新文章确认静态页生成;最后检查 data/index 和上传目录的写权限。这套顺序是踩了多次坑才攒下来的,省得改完模板才发现搜索坏了,或者上线两天才发现上传目录写不进去。拿这套 FreeCMS1.5 完整代码最省事的方式,是把它当作对照工程,先跑通再逐层改,改一层验证一层。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询