接前一篇博客。这篇文章将专注于分析系统的核心功能。
管理员界面
在这个系统中,普通用户只能发表评论,只有管理员才能够创建文章。项目的README中介绍了进入管理员界面的方法:访问地址/admin。
首先来看登录检查
前文已经介绍到,web.xml指明所有/admin路径下的请求都要先经过AdminFilter
。观察它的代码,就是把请求按以下逻辑处理:
- 对于"/admin/login.ftl"和"/admin/login.do",交给下一级处理。这两个请求将分别被
DynamicFilter
和AdminLogin
类接管。 - 对于"/admin",重定向到"/admin/admin_index.ftl"。
- 对于其它/admin路径下的请求,先调用
AbstractServlet.isAdminLogin()
检查有没有登录。如果有则交给下一级处理;否则重定向到"/admin/login.ftl"要求登录。
逻辑很清晰。AdminLogin
类的逻辑也很简单,比对密码的哈希和配置是否一致。如果一致,就重定向到"/admin/admin_index.ftl",并且在这之前,在Session中设置admin属性;否则,就重定向到"/admin/login.ftl"要求重新登录。
可想而知AbstractServlet.isAdminLogin()
的逻辑,就是检查Session中的admin属性。
管理后台
管理员主页非常简单,只有如下几个功能。
后台点击不同的链接会跳转到不同的FTL页面。例如“最新回复”,它的地址是admin/new_comment.ftl。
在com.zuoxiaolong.dynamic包中找到上面地址对应的动态数据类NewComment
,它的代码非常简单,就是从数据库中找到评论,然后交给模板去呈现:
@Namespace("admin")
public class NewComment implements DataMap {
@Override
public void putCustomData(Map<String, Object> data,HttpServletRequest request, HttpServletResponse response) {
data.put("newComments", DaoFactory.getDao(CommentDao.class).getComments());
}
}
编辑文章
在文章管理页面,通过新建文章可以进入admin/article_input.ftl页面。
这个页面使用了著名的在线文本编辑器TinyMCE。简单的说,它只是一个JS库,能够“所见即所得”地把在编辑框中输入的富文本翻译成HTML文本。
下面来分析这个FTL文件的代码。
在正文部分定义了一个textarea,作为编辑器的占位元素。
<textarea class="html_editor" style="width:100%"></textarea>
在正文结尾处有如下Javascript代码:
<script type="text/javascript">
var settings = {width:900,height:400,content:''};
<#if article?? && article.escapeHtml??>
settings.content = '${article.escapeHtml}';
</#if>
tinymceInit(settings);
$(document).ready(function(){
$("#submitButton").click(function(){
...
$.ajax({
url:"${contextPath}/admin/updateArticle.do",
data:{"id":$("input[name=id]").val(),"content":tinymce.activeEditor.getContent()
,"subject":$("input[name=subject]").val(),"status":status,"type":$("select[name=type]").val()
,"tags":$("input[name=tags]").val(),"categories":categories,"updateCreateTime":updateCreateTime},
type:"POST",
success:function(data){
if(data && data == 'success') {
alert("保存成功");
window.location.href="${contextPath}/admin/article_manager.ftl"
} else {
alert("保存失败");
}
}
});
});
});
</script>
其中tinymceInit(settings);
这句话就是初始化TinyMCE的代码,把编辑器显示出来。settings.content = '${article.escapeHtml}';
是把编辑器中的内容设置为已有的内容——因为更新文章也是用的这个模板页面。至于文章的HTML内容是从哪里得到的,我们后面再谈。
后面的这段AJAX代码意思是当点击保存按钮时,把文章的所有数据提交到"admin/updateArticle.do"这个Url上来,注意代码使用了tinymce.activeEditor.getContent()来获得编辑器产生的HTML文本。我们再来看看这个Url对应的处理类做的事情:
@RequestMapping("/admin/updateArticle.do")
public class AdminUpdateArticle extends AbstractServlet {
@Override
protected void service() throws ServletException, IOException {
String id = getRequest().getParameter("id");
String subject = getRequest().getParameter("subject");
String html = getRequest().getParameter("content");
String status = getRequest().getParameter("status");
String type = getRequest().getParameter("type");
String icon = getRequest().getParameter("icon");
String updateCreateTime = getRequest().getParameter("updateCreateTime");
String[] categories = getRequest().getParameter("categories").split(",");
String[] tags = getRequest().getParameter("tags").split(",");
html = handleQuote(html);
StringBuffer stringBuffer = new StringBuffer();
JsoupUtil.appendText(Jsoup.parse(html), stringBuffer);
Integer articleId =
DaoFactory.getDao(ArticleDao.class).saveOrUpdate(id,
subject,
Status.valueOf(Integer.valueOf(status)),
Type.valueOf(Integer.valueOf(type)),
Integer.valueOf(updateCreateTime),
"左潇龙", html, stringBuffer.toString(), icon);
...
主要的逻辑就是把数据从请求中读出来,做一些检查和处理后存入数据库。
尽管创建新文章和编辑已有文章共享同一套逻辑,但是在DAO层保存到数据库时,前者是insert,后者是update。另外创建新文章的时候并不知道文章的Id,所以DAO层插入记录时将获得数据库自动生成的ID。
// ArticleDao 的代码
statement = connection.prepareStatement(insertSql,Statement.RETURN_GENERATED_KEYS);
更新文章时如何获得已有文章的内容
在admin主页中点击文章管理可以进入文章管理页面(admin/article_manager.ftl)。点击每篇文章的标题就可以进入编辑文章页面,但这个Url还带了一个参数:文章id。
而article_input.ftl页面作为一个FTL模板文件,自然也会受到DynamicFilter
的过滤,与它对应的动态数据类是ArticleInput
类。这个类的putCustomData()
方法会从请求中拿到id参数,然后根据这个id读取数据库,把对应的文章的数据存放到FreeMarker变量"article"中去。模板文件再根据这个参数呈现内容。
上传图片
无论是编辑文章还是提交评论,编辑器都支持上传图片的功能。如何实现的呢?
在项目中使用的TinyMCE 4.1.10版本自身还不支持上传图片,只能借助一些插件实现。而这里的实现另辟蹊径。我们来看看位于webapp/resources/js/tinymce路径下的tinymce.init.js文件。
function tinymceInit(settings) {
$(document).ready(function() {
var defaultSettings = {width:600,height:400,content:'',skin:'lightgray'};
$.extend(defaultSettings,settings);
tinymce.init({
selector: "textarea.html_editor",
language: "zh_CN",
menubar : false,
skin: defaultSettings.skin,
width: defaultSettings.width,
height: defaultSettings.height,
toolbar_items_size:'small',
setup: function(editor) {
editor.addButton('upload',
{
icon: 'print',
title: '上传本地图片',
onclick: function() {
editor.windowManager.open({
title: "上传本地图片",
url: contextPath + "/html/upload_image.html",
width: 400,
height: 150
});
}
});
editor.addButton('insertcode',
{
...
这个文件是我们自己建立的,函数tinymceInit()
在前面讲到的article_input.ftl文件中被调用。它调用了真正的API tinymce.init()
来完成初始化。可以看到函数中调用了editor.addButton()
来添加一个上传图片的按钮,被点击时的行为则是弹出一个upload_image.html页面。
upload_image.html页面是在运行时根据模板文件common/upload_image.ftl生成的。至于如何生成,且听下回分解。它的主要内容如下:
<body>
<table class="float_left" style="width: 340px;height: 90px;border: 1px solid #d5d5d5;margin: 5px;">
<form id="upload_image_form" method="POST" action="http://localhost:8080/uploadImage.do" enctype="multipart/form-data">
<tr>
<td class="form_input">
<a class="file_input_a" href="#">
选择图片
<input class="file_input" type="file" name="imageFile" />
</a>
</td>
</tr>
<tr>
<td class="form_input">
<input type="text" class="text_input" id="file_path" readonly="readonly" style="width:340px;max-width: 340px;"/>
</td>
</tr>
<tr>
<td class="form_input">
<input type="submit" class="form_button" value="上传"/>
</td>
</tr>
</form>
</table>
...
<script type="application/javascript">
$(document).ready(function(){
$("input[name=imageFile]").change(function(){
$("#file_path").val($(this).val());
});
$("#upload_image_form").ajaxForm({
beforeSubmit:function(){
if(!$("input[name=imageFile]").val()) {
window.parent.alert("请选择图片");
return false;
}
return true;
},
success:function(url){
if (url && url == 'format_error') {
alert("只能上传png,jpg,gif格式的文件");
return;
}
if (url) {
top.tinymce.activeEditor.insertContent("![](" + url + ")");
top.tinymce.activeEditor.windowManager.close();
}
}
});
});
</script>
就是一个上传文件的表单,目的地址是uploadImage.do。上传图片成功后,得到一个图片的Url。JS脚本再调用top.tinymce.activeEditor.insertContent()
向编辑器中插入图片元素。
负责处理uploadImage.do的是UploadImage类。它使用commons-fileupload包来获取文件,保存到image/路径下,再返回动态生成后的真实Url。
整个上传过程到此就结束了。再看tinymce.init.js文件可以发现它还增加了一个插入代码的按钮,原理都是类似的。
评论、投票与统计
直接来看blog/article.ftl文件。文章正文和评论部分在article_list.ftl中。
在文章的尾部有一排投票的表情,只能选一个,数据最终提交到数据库中的article表里。如果看一下article表的设计,可以发现它很长。其中不仅包含内容、日期这种信息,还包含评论数量等等。
当访问article页面时,所有与文章相关的数据都被数据库中读取到,显示到页面中。点击页面中的评论、投票等按钮时,就会发送请求到相应的xxx.do地址,请求被合适的Servlet处理,最终反映到数据库中。这些都略去不表。
下面来看下如何跟踪文章的访问次数。可以注意到article表中有一个access_times字段,就是文章的访问次数。那么它是如何更新的呢?
在article.ftl文件中有这么一段Javascript脚本:
<script type="text/javascript">
// 建立评论区的编辑器
tinymceInit({width:700,height:150,skin:'comment'});
$(document).ready(function() {
counter({"articleId":$("#articleId").val(),"type":1,"column":"access_times"});
...
这里的counter()函数像是跟访问计数有关。它在common/common.js中定义:
function counter(data) {
$.ajax({
url:contextPath + "/counter.do",
type:"POST",
data:data
});
}
就是把数据发到"/counter.do"地址。来看它的处理类Counter
:
public class Counter extends AbstractServlet {
@Override
protected void service() throws IOException {
HttpServletRequest request = getRequest();
Integer type = Integer.valueOf(request.getParameter("type"));
if (type == 1) {
updateArticle(request);
} else if (type == 2) {
updateQuestion(request);
} else if (type == 3) {
updateRecord(request);
} else {
throw new RuntimeException("unknown type.");
}
}
...
private void updateArticle(HttpServletRequest request) {
Integer articleId = Integer.valueOf(request.getParameter("articleId"));
String column = request.getParameter("column");
if (logger.isInfoEnabled()) {
logger.info("counter param : articleId = " + articleId + " , column = " + column);
}
if (!column.equals("access_times")) {
if (logger.isInfoEnabled()) {
logger.info("there is someone remarking...");
}
String username = getUsername();
String ip = HttpUtil.getVisitorIp(request);
if (DaoFactory.getDao(ArticleIdVisitorIpDao.class).exists(articleId, ip, username)) {
writeText("exists");
if (logger.isInfoEnabled()) {
logger.info(ip + " has remarked...");
}
return ;
} else {
DaoFactory.getDao(ArticleIdVisitorIpDao.class).save(articleId, ip, username);
}
}
boolean result = DaoFactory.getDao(ArticleDao.class).updateCount(articleId, column);
...
这里的逻辑是,判断一下同一个IP和User是不是已经访问过这篇文章了。如果没有,就把访问历史先保存起来,再调用Dao层把access_times加1。
网友评论