第 10 章 综合电子留言板

注意

将前九章的知识结合起来,实现一个电子留言板,包括注册登录,发帖回复功能。
如果你不满足以下任一条件,请继续阅读,否则请跳过此后的部分,进入下一章: 第 11 章 文件上传
  1. 对电子留言板不感兴趣。

10.1. 电子留言板用户指南

首页显示的是主题列表。
jsp-ch-10-01-guide-01.png
用户如果想发表新主题或者对主题进行回复,必须先注册为会员。
jsp-ch-10-01-guide-02.png
注册后进入登录页面进行登录。
jsp-ch-10-01-guide-03.png
登录后即出现在用户在线列表中。
jsp-ch-10-01-guide-04.png
点击标题可以看到主题的详细信息。
jsp-ch-10-01-guide-05.png
登录以后即可发布新主题。
jsp-ch-10-01-guide-06.png

10.2. 数据库设计

数据库er图
jsp-ch-10-02-db-01.png
共定义了三张表:
  1. user用户,保存注册用的信息。
  2. thread主题,用户发起的主题帖子,外键关联user,对应发表主题的用户
  3. comment回复,对主题帖子发起的回复,外键关联user和thread,对应发表回复的用户和回复的主题。
建表sql脚本放在10-01/WEB-INF/sql/import.sql。
-- 用户
create table user(
    id bigint, -- 主键
    username varchar(100), -- 帐号
    password varchar(100), -- 密码
    reg_time datetime, -- 注册时间
    last_login datetime -- 上次登录时间
);
-- 主题
create table thread(
    id bigint, -- 主题
    title varchar(200), -- 标题
    content varchar(2000), -- 内容
    create_time datetime, -- 发帖时间
    update_time datetime, -- 更新时间
    hit integer, -- 点击数
    user bigint -- 发帖用户
);
-- 回复
create table comment(
    id bigint, -- 主题
    content varchar(2000), -- 内容
    create_time datetime, -- 发布时间
    user bigint, -- 回复用户
    thread bigint -- 回复的主题
);
        
根据数据库表建模。每张表对应三部分:domain,dao和servlet。domain是简单的javabean用来封装数据表中的数据,dao中进行对数据库的业务操作,servlet作为控制器处理请求调用dao和domain实现业务功能。
为了便于管理,将使用到的类分成四个包,domain,dao,utils和web。domain, dao, web中分别包含domain, dao和servlet类,utils包中是数据库连接工具和过滤器。
jsp-ch-10-02-db-02.png
这里的domain和dao都是按照理想状态编写的,将数据库表中的字段对应到domain类中,然后dao提供CRUD功能,不过dao中的有些功能并没有用到,比如update和remove。

10.3. 功能设计

整个在线留言板可分为两大功能部分:用户管理与主题回复管理。

10.3.1. 用户管理

用户管理功能包括:新用户注册,用户登录,用户注销。用户登录的时候顺便带上一个用户在线列表。
这部分的页面主要在security目录下,操作代码都放在anni.web.UserServlet.java和对应的anni.domain.User,anni.dao.UserDao中。
  1. 新用户注册
    这是CRUD中的create,向用户表中添加一条新信息,我们只在前台页面中使用javascript进行数据校验,要求用户输入用户名,密码,并且在两次密码输入相同的时候才能提交。
    提交的请求交由UserServlet的register()方法处理。
    /**
     * 注册新用户.
     */
    public void register(HttpServletRequest request,HttpServletResponse response) throws Exception {
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        String confirmPassword = request.getParameter("confirmPassword");
        boolean userExists = userDao.checkExists(username);
        if (userExists) {
            request.setAttribute("error", "用户名:" + username + "已被使用了,请更换其他用户名注册。");
            request.getRequestDispatcher("/security/register.jsp").forward(request, response);
        } else {
            User user = new User();
            user.setUsername(username);
            user.setPassword(password);
            userDao.save(user);
            response.sendRedirect(request.getContextPath() + "/security/registerSuccess.jsp");
        }
    }
                        
    获得用户名和密码后,先通过userDao.checkExists()检测数据库中是否已经有了同名的用户,如果用户名重复,就跳转到/security/register.jsp显示错误信息。如果用户名没有重复,则将此用户信息添加入库,然后页面重定向到/security/registerSuccess.jsp显示注册成功信息。
    保存信息之后使用redirect是个避免重复提交的简易方法,如果使用forward,浏览器上的url不会改变,用户刷新页面就会导致重复提交信息。
  2. 用户登录与注销
    登录与注销的流程与之前介绍的大体相同。 第 4.2 节 “例子:在线列表”
    /**
     * 登录.
     */
    public void login(HttpServletRequest request,HttpServletResponse response) throws Exception {
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        User user = userDao.login(username, password);
        if (user != null) {
            user.setLastLogin(new Date());
            userDao.update(user);
            HttpSession session = request.getSession();
            session.setAttribute("user", user);
            // 加入在线列表
            session.setAttribute("onlineUserBindingListener", new OnlineUserBindingListener(username));
            response.sendRedirect(request.getContextPath() + "/security/loginSuccess.jsp");
        } else {
            request.setAttribute("error", "用户名或密码错误!");
            request.getRequestDispatcher("/security/login.jsp").forward(request, response);
        }
    }
    /**
     * 注销.
     */
    public void logout(HttpServletRequest request,HttpServletResponse response) throws Exception {
        request.getSession().invalidate();
        response.sendRedirect(request.getContextPath() + "/security/logoutSuccess.jsp");
    }
                        
    我们先根据请求中的用户名和密码去数据库搜索用户信息。如果能找到,说明用户输入无误可以登录,这时更新用户最后登录时间,并将user保存到session中,同时使用listener操作在线列表。
    如果用户名或密码错误,则将请求转发至/security/login.jsp页面,显示错误信息。
  3. 控制用户访问权限
    与用户操作相关的还有anni.utils.SecurityFilter,我们使用它来控制用户的访问权限。可以参考之前的讨论: 第 7.2 节 “用filter控制用户访问权限”
    web.xml中对SecurityFilter的配置如下:
    <filter>
        <filter-name>SecurityFilter</filter-name>
        <filter-class>anni.utils.SecurityFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>SecurityFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
                        
    因为filter-mapping太不灵活,我们让SecurityFilter过滤所有的请求,在代码里判断哪些请求需要保护。
    public void doFilter(ServletRequest request,
            ServletResponse response,
            FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        String url = req.getServletPath();
        String method = req.getParameter("method");
        if ("/create.jsp".equals(url) ||
            ("/thread.do".equals(url) && "post".equals(method)) ||
            ("/comment.do".equals(url) && "post".equals(method))) {
            HttpSession session = req.getSession();
            if (session.getAttribute("user") == null) {
                res.sendRedirect(req.getContextPath() + "/security/securityFailure.jsp");
                return;
            }
        }
        chain.doFilter(request, response);
    }
                        
    在此我们只保护三个请求:/create.jsp(进入发布新主题的页面),/thread.do?method=post(发布新主题),/comment.do?method=post(发布回复)。这三个操作只有在用户登录之后才能访问,如果用户还没有的登录就会页面重定向到/security/securityFailure.jsp,显示权限不足无法访问的提示信息。

10.3.2. 主题回复管理

主题回复管理功能包括:查看所有主题,查看某一主题的详细信息和对应回复,发表新主题,发表回复。点击主题时还会计算点击数。
  1. 查看所有主题信息
    进入应用,index.jsp会立即跳转到/forum.do?method=list,并在list.jsp中显示所有主题,包括主题标题,回复数,作者,点击数,最后回复时间,最后回复人。这些信息按照“最后回复时间”进行逆序排列。
    实现代码在anni.web.ForumServlet的list()方法内。
    /**
     * 显示所有帖子.
     */
    private void list(HttpServletRequest request, HttpServletResponse response) throws Exception {
        List list = forumDao.getAll();
        request.setAttribute("list", list);
        request.getRequestDispatcher("/list.jsp").forward(request, response);
    }
                        
    调用anni.dao.ForumDao的pagedQuery()方法返回我们需要的信息,这里只用domain中定义的类已经无法满足我们了(显示的信息包含了三个表的信息),为了方便起见我们直接使用了Map来传递数据。
    public List getAll() throws Exception {
        Connection conn = null;
        Statement state = null;
        List list = new ArrayList();
        try {
            conn = DbUtils.getConn();
            state = conn.createStatement();
            String sql = "select " +
                    "t.id, " +
                    "t.title, " +
                    "(select count(id) from comment where thread=t.id) as reply, " +
                    "(select username from user where id=t.user) as author, " +
                    "t.hit, " +
                    "(select top 1 create_time from comment where thread=t.id order by create_time desc) as create_time, " +
                    "(select top 1 u.username from comment c,user u where c.thread=t.id and c.user=u.id " +
                    "order by create_time desc) as user " +
                "from thread t " +
                "order by user desc";
            ResultSet rs = state.executeQuery(sql);
            while (rs.next()) {
                Map map = new HashMap();
                map.put("id", rs.getLong(1)); // 主键
                map.put("title", rs.getString(2)); // 标题
                map.put("reply", rs.getInt(3)); // 回复数
                map.put("author", rs.getString(4)); // 作者
                map.put("hit", rs.getInt(5)); // 点击数
                map.put("updateDate", rs.getTimestamp(6)); // 最后发言时间
                map.put("user", rs.getString(7)); // 最后发言人
                list.add(map);
            }
        } finally {
            DbUtils.close(null, state, conn);
        }
        return list;
    }
                        
    或许有人会奇怪为什么不直接使用ResultSet。这其实是一种理念问题,如果你返回ResultSet到jsp页面,的确免去了封装成Map的步骤,但是同时产生了两个问题。
    第一,数据库操作对应的代码蔓延到前台页面,有违我们分层设计的初衷。如果觉得我们这是过度设计的话,那么第二个问题则是更严重的,将ResultSet放到jsp上很难控制何时关闭数据库连接,如果发生了异常可能来不及关闭数据连接,用不了多长时间就会耗尽资源了。
    ForumDao中,勉强拼凑出三个表连接查询的sql,还不清楚性能是否有保证。
  2. 显示主题详细信息
    点击主题标题/forum.do?method=view&id=1,会进入显示对应详细信息的页面/view.jsp。顶部显示的是主题帖子的标题,发布时间,作者和内容。主题内容下面列出所有的回复内容,页面底部是回复使用的表单,只有登录之后才能使用。
    ForumServlet中的view()方法用来获得我们需要的主题信息和对应的回复信息。
    /**
     * 显示帖子内容.
     */
    private void view(HttpServletRequest request, HttpServletResponse response) throws Exception {
        long id = Long.parseLong(request.getParameter("id"));
        Map thread = forumDao.viewThread(id);
        List list = forumDao.getCommentsByThread(id);
        request.setAttribute("thread", thread);
        request.setAttribute("list", list);
        request.getRequestDispatcher("/view.jsp").forward(request, response);
    }
                        
    我们从请求中获得主题的id,获得主题详细信息和对应的回复信息列表,这两项都是使用Map传递数据传递到view.jsp页面中再使用el和jstl显示出来。
    在显示主题详细信息时,顺便讲主题的点击数加一。
    public Map viewThread(long id) throws Exception {
        Connection conn = null;
        PreparedStatement state = null;
        Map map = new HashMap();
        try {
            conn = DbUtils.getConn();
            state = conn.prepareStatement("select t.id,t.title,t.content,t.create_time,u.username " +
                "from thread t,user u where t.user=u.id and t.id=?");
            state.setLong(1, id);
            ResultSet rs = state.executeQuery();
            if (rs.next()) {
                map.put("id", rs.getLong(1)); // 主键
                map.put("title", rs.getString(2)); // 标题
                map.put("content", rs.getString(3)); // 内容
                map.put("createTime", rs.getTimestamp(4)); // 发布时间
                map.put("username", rs.getString(5)); // 作者名
            }
            // 增加点击数
            state = conn.prepareStatement("update thread set hit=hit+1 where id=?");
            state.setLong(1, id);
            state.executeUpdate();
        } finally {
            DbUtils.close(null, state, conn);
        }
        return map;
    }
                        
    我们把这个更新操作放到查询之后,使用update将hit字段加一,也是为了避免在异常情况下找不到对应主题时,不必出现更新异常。
  3. 发布新主题和发布回复
    这两项对应了anni.web.ThreadServlet和anni.web.CommentServlet中的post()方法。
    为了简易起见,我们仅仅在页面上使用javascript检验输入的数据不能为空。
    提交之后会调用对应dao中的save()方法将数据保存进数据库。最后页面重定向到/forum.do?method=list或/forum.do?method=view&id=1。实际上它们都是单纯的create操作(CRUD中的C)。

10.3.3. 显示在线用户列表

我们使用了HttpSessionBindingListener来实现在线用户列表。详细介绍见 第 8.2 节 “使用HttpSessionBindingListener”
/list.jsp和/view.jsp两个页面上的在线用户列表显示效果完全一样,如果有可能的话,我们希望将这些重复的部分从原来的页面中剥离出来,集中在一起让其他页面调用,这样更容易管理和维护。
为了实现这一功能,我们需要借用另一个jsp指令(directive):include。
<%@ include file="/include/onlineUser.jsp"%>
            
这里的file可以使用相对路径,也可以使用绝对路径。这里的绝对路径与使用forward时一致,都是以应用目录为根目录,参考这里的讨论 第 3.4.1.2 节 “绝对路径”
我们顺便再看一下/include/onlineUser.jsp的内容:
<%@ page contentType="text/html; charset=gb2312"%>
<fieldset>
  <legend>在线用户</legend>
  <div>
    <c:forEach var="item" items="${onlineUserList}">
    &nbsp;${item}
    </c:forEach>
  </div>
</fieldset>
            
这就是一个单独的jsp页面,可以在里边使用jsp指令(directive),el,甚至是taglib。
不过taglib还是要在使用前定义的,因为每个页面都使用了相同的taglib定义和其他一些相同的html配置(编码,css等),我们也把这部分提取成一个jsp页面,让其他页面引用。这个页面也放在include目录下,meta.jsp的内容如下。
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<c:set var="ctx" value="${pageContext.request.contextPath}"/>
<meta http-equiv="Content-Type" content="text/html; charset=gb2312" />
<link rel="stylesheet" type="text/css" href="${ctx}/styles/forum.css" />
            
meta.jsp里定义了我们使用的taglib,设置了默认的contextPath,gb2312的编码格式和forum.css样式表。在其他页面里对它进行引用就可以让其他页面内容也得到里边定义的功能,包括标签库定义和使用c:set设置的变量。
<head>
    <%@ include file="/include/meta.jsp"%>
    <title>index</title>
</head>
            
include之间的jsp中的定义和变量都是可以相互调用的,但是我们必须为每个jsp页面都指定正确的编码格式。这依然是为了处理中文乱码,meta.jsp中没有指定编码格式就是因为这也里没有中文。/include/onlineUser.jsp里包含了中文,如果不设置charset就会显示乱码,使用include的时候需要注意这一点。

10.4. 总结

这个在线留言板包含了之前讨论过的问题:
  1. 使用servlet处理转发请求 第 6 章 贴近servlet,结合数据库进行CRUD操作 第 5 章 结合javabean实现CRUD,并使用foward和redirect进行请求转发和页面重定向 第 3 章 请求的跳转与转发
  2. 页面显示数据的时候使用了el和taglib 第 9 章 封装taglib组件
  3. 使用过滤器控制访问权限 第 7.2 节 “用filter控制用户访问权限”,使用监听器操作在线用户列表 第 8 章 配置listener监听器
例子在10-01目录下,将目录复制到tomcat的webapps目录下即可使用。
源代码在10-01/WEB-INF/src目录下,在将整个目录复制到webapps下后,可以使用compile.bat进行编译。
数据库脚本在10-01/WEB-INF/sql目录下,修改import.sql后,执行run.bat可改变数据库中的初始数据。