前段时间刚写了《Catalyst Tutorial 最佳实践》,现在又手痒,给大家奉献这篇《Appfuse 最佳实践》,目的主要是趁这段相对比较空闲的时间,多写一些有用的教程,一方面在网上也看到过很多关于 Appfuse 的教程,但是总觉得写的不够系统,看起来不够过瘾~所以这次石头特意通过一个完整的“员工管理系统”的实例来比较系统的介绍一下这个框架的开发技巧,希望大家喜欢~
[Appfuse Best Tutorial]
首先,按照《Appfuse & tapestry 小记》中的第3节(开发笔记)中建立好`Employee`表并用appfuse工具把代码生成好了。
附带DDL:
CREATE TABLE `Employee` (
`id` bigint(20) NOT NULL auto_increment,
`code` varchar(10) NOT NULL,
`dept` varchar(50) NOT NULL,
`name` varchar(20) NOT NULL,
`status` varchar(10) NOT NULL,
`telephone` varchar(20) default NULL,
`title` varchar(50) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
然后启动jetty,就可以看到首页的“登录”菜单旁边多出来一个“Employee List”的菜单项。
接下来我们做一些界面上的修改(在ApplicationResources_zh.properties添加):
... ...
# -- add by james --
webapp.name=员工管理系统
webapp.tagline=我们以一个员工管理系统来作为开发Appfuse的入门实例.
company.name=员工管理系统
company.url=http://localhost:8080
... ...
# -- Employee-START copied from ApplicationResources.properties
employee.id=Id
employee.code=Code
employee.dept=部门
employee.name=姓名
employee.status=目前状态
employee.telephone=电话
employee.title=职位
employee.added=新员工添加成功。
employee.updated=员工信息更新成功。
employee.deleted=员工信息删除成功。
# -- employee list page --
employeeList.title=员工管理
employeeList.heading=员工列表
employeeList.employee=员工
employeeList.employees=员工
# -- employee detail page --
employeeDetail.title=员工详细信息
employeeDetail.heading=员工详细信息
# -- Employee-END
... ...
然后为菜单赋权,即在menu-config.xml的EmployeeMenu加上roles="ROLE_ADMIN,ROLE_USER",允许用户添加/修改,重启后你就可以看到菜单和界面变成中文了,登录之后你可以试着在“员工管理”板块下做一些简单的CRUD操作。
以下是appfuse:gen所产生/改动的代码,请参考:
resources/struts.xml
resources/ApplicationResources.properties
webapp/WEB-INF/applicationContext.xml
webapp/WEB-INF/menu-config.xml
webapp/common/menu.jsp
webapp/pages/employeeList.jsp
webapp/pages/employeeForm.jsp
java/com/appfuse/app/model/Employee.java
java/com/appfuse/app/model/Employee-validation.xml
java/com/appfuse/app/dao/EmployeeDao.java
java/com/appfuse/app/dao/hibernate/EmployeeDaoHibernate.java
java/com/appfuse/app/service/EmployeeManager.java
java/com/appfuse/app/service/impl/EmployeeManagerImpl.java
java/com/appfuse/app/webapp/action/EmployeeAction.java
java/com/appfuse/app/webapp/action/EmployeeAction-validation.java
这里遇到两个问题需要注意:
a> 输入中文的时候保存数据不正常。
解决方法:这种问题一般都是数据库字段字符集问题,我建议你先修改mysql的配置文件my.ini的default-character-set=utf8,然后再重新建表,检查一下新表的字段字符集是否都为utf8_general_ci,如果是的话这个问题应该就能迎刃而解。
b> 重启jetty的时候修改过的数据会被覆盖回去。
解决方法:重新设置pom.xml里面关于hibernate3-maven-plugin的配置,删除executions命令(把pom.xml的154行到161行注释掉)。同时把969行的<dbunit.operation.type>CLEAN_INSERT</dbunit.operation.type>改成<dbunit.operation.type>NONE</dbunit.operation.type>。
接下来我们就可以开始做一些更深入的设计和编码。
>>> 前期系统设计
光是一张Employee表当然没有办法架设出一个比较完整的公司员工结构,于是我又添加了`Status`,`Dept`和`Title`分别用于存储员工状态、部门信息和职位头衔,DDL如下:
CREATE TABLE `Status` (
`id` int(11) NOT NULL auto_increment,
`status` varchar(10) NOT NULL,
`description` text NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `Status` SET `status`='在职', `description`='工作中';
INSERT INTO `Status` SET `status`='入职', `description`='等待入职';
INSERT INTO `Status` SET `status`='离职', `description`='离开公司';
==========
CREATE TABLE `Dept` (
`id` int(11) NOT NULL auto_increment,
`name` varchar(50) NOT NULL,
`description` text NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `Dept` SET `name`='董事', `description`='决策';
INSERT INTO `Dept` SET `name`='人事', `description`='招聘';
INSERT INTO `Dept` SET `name`='财务', `description`='算账';
INSERT INTO `Dept` SET `name`='开发', `description`='产品';
INSERT INTO `Dept` SET `name`='市场', `description`='宣传';
==========
CREATE TABLE `Title` (
`id` int(11) NOT NULL auto_increment,
`name` varchar(50) NOT NULL,
`description` text NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `Title` SET `name`='经理', `description`='决策管理';
INSERT INTO `Title` SET `name`='主管', `description`='管理员工';
INSERT INTO `Title` SET `name`='员工', `description`='日常工作';
然后生成model并为`Dept`建立基本代码结构(关于appfuse命令,参考http://static.appfuse.org/plugins/appfuse-maven-plugin/plugin-info.html):
#mvn appfuse:gen-model
#mvn appfuse:gen -Dentity=Dept
#mvn appfuse:gen -Dentity=Title
#mvn appfuse:gen-core -Dentity=Status
这里我们生成了“Employee List”和“Title List”两个完整模块以及Status的Dao和Manager核心类。这里值得注意的是,由于我们只需要Status的数据结构,不需要Action和页面代码,所以这里我们使用“mvn appfuse:gen-core -Dentity=Status”命令指定只生成“核心代码”,执行过程中有报错,不过没关系,代码还是正确生成的。
然后我们要做的就是和上面提到的“Employee List”类似的设置(但是DeptMenu最好加上roles="ROLE_ADMIN"只允许admin用户添加/修改,这样比较不容易出问题),重启服务,看到界面已经变了,多出了一个菜单“部门管理”,你会注意到这个菜单跑到第二行去了,不是很美观,于是我首先考虑能不能把一些没用的菜单去掉~ 按照主流的设计风格,我们很自然的会想要把“退出”这个菜单移到右上方的位置。于是打开menu-config.xml,删除“<Menu name="Logout" title="user.logout" page="/logout.jsp" roles="ROLE_ADMIN,ROLE_USER"/>”这行,以及menu.jsp的“<menu:displayMenu name="Logout"/>”这行,然后在header.jsp相应位置加上如下代码:
... ...
<security:authorize ifAnyGranted="ROLE_ADMIN,ROLE_USER">
<a href="<c:url value='/logout.jsp'/>"><fmt:message key="user.logout"/></a>
</security:authorize>
... ...
由于“退出”选项只对登录用户才有意义,所以我这里使用了SpringSecurity的authorize标签来限定用户。这里顺便提一下这个比较常用的SpringSecurity标签的用法(参考http://static.springframework.org/spring-security/site/reference/html/authorization-common.html):
*ifAllGranted: 满足所有角色。
*ifAnyGranted: 满足任意一个角色。
*ifNotGranted: 所有角色都不被允许。
到这里该系统最主要的系统前期设计工作已经完成,基本代码也生成好了,接下来我们从一些细节地方进行讨论。
>>> 基本功能设计
代码Appfuse已经帮我们生成了,真是省去了我们不少“造轮子”的时间,但是仔细看看,还是有一些不合理的地方,我们到“员工管理”打开“添加”页面,我们看到“部门”这里还是一个输入框,这显然不合理,接下来我们要把这里变成一个下拉菜单并关联刚才添加的“部门管理”的数据。
applicationContext-struts.xml:
... ...
<bean id="employeeAction" class="com.appfuse.app.webapp.action.EmployeeAction" scope="prototype">
<property name="employeeManager" ref="employeeManager"/>
<property name="deptManager" ref="deptManager"/>
</bean>
... ...
struts.xml
... ...
<!--EmployeeAction-START-->
<action name="employees" class="employeeAction" method="list">
<result>/WEB-INF/pages/employeeList.jsp</result>
</action>
<action name="editEmployee" class="employeeAction" method="edit">
<result>/WEB-INF/pages/employeeForm.jsp</result>
<result name="error">/WEB-INF/pages/employeeList.jsp</result>
</action>
<action name="saveEmployee" class="employeeAction" method="save">
<result name="input">/WEB-INF/pages/employeeForm.jsp</result>
<result name="cancel" type="redirect-action">employees</result>
<result name="delete" type="redirect-action">employees</result>
<result name="success" type="redirect-action">employees</result>
</action>
<!--EmployeeAction-END-->
... ...
EmployeeAction.java:
... ... /** * Add for selecting department */ private DeptManager deptManager; private List<Dept> deptList; public void setDeptManager(DeptManager deptManager) { this.deptManager = deptManager; } public List<Dept> getDeptList() { return deptList; } ... ... public String edit() { if (id != null) { employee = employeeManager.get(id); } else { employee = new Employee(); } // get department list deptList = deptManager.getAll(); return SUCCESS; } ... ... public String save() throws Exception { ... ... if (!isNew) { // get department list deptList = deptManager.getAll(); // stay input page return INPUT; } else { return SUCCESS; } } ... ...
employeeForm.jsp:
<<< remove 1 line
<s:textfield key="employee.dept" required="true" maxlength="50" cssClass="text medium"/>
>>> change to
<s:select key="employee.dept" headerKey="" headerValue="Select Dept"
list="deptList"
listKey="id"
listValue="name"
value="employee.dept"
required="true"
/>
<<< 7 lines
做完这些修改后会发现,员工编辑页面“部门”这一栏已经不再是随便输入的文本框,而是下拉菜单了,这样子不仅从系统操作安全性方面提高了很多,而且也使整个系统的各个数据结构可以更好的结合起来。接下来,我们用同样的方法加入“职位管理”这个模块,同样的代码修改过后,于是员工编辑页面的“职位”这个选项也变成关联的下拉菜单了。
我们保存一下,功能正常,但是返回“员工列表”的时候发现了一个不好的事情,那就是编辑过的“部门”和“职位”栏都变成数字了,这是怎么回事呢,很明显我们刚才使用的方法是有问题的~ 我们只是从界面的角度把`Dept`和`Title`这两张表的内容结合到“员工管理”去,但是实际上从数据层面这几张表并没有真正的“关联”起来~ 于是我们对`Employee`表作如下调整:
CREATE TABLE `employee` (
`id` bigint(20) NOT NULL auto_increment,
`code` varchar(10) NOT NULL,
`dept_id` int(11) NOT NULL,
`name` varchar(20) NOT NULL,
`status` varchar(10) NOT NULL,
`telephone` varchar(20) default NULL,
`title_id` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
我们看到原varchar型的dept和title字段分别变成了int类型的dept_id和title_id(建议这里命名遵循一般的Hibernate的设计原则),准备作为外键关联`Dept`和`Title`表(如果是大范围的字段修改我们也可以使用mvn appfuse:gen-model来重新生成model类,但是像这种小范围的修改,我还是建议大家手动来修改一下对应的model类)。
然后我们修改com.appfuse.app.model.Employee类:
... ...
<<< remove 2 lines
private String dept;
private String title;
>>> change to
private Dept dept;
private Title Title;
<<< 2 lines
... ...
@ManyToOne
@JoinColumn(name = "title_id")
public Title getTitle() {
return this.title;
}
public void setTitle(Title title) {
this.title = title;
}
@ManyToOne
@JoinColumn(name = "dept_id")
public Dept getDept() {
return this.dept;
}
public void setDept(Dept dept) {
this.dept = dept;
}
... ...
可以看到我们为Employee的新model加入了@ManyToOne映射(注意appfuse新版本使用的是JPA的设置规范,个人认为这比写映射文件更简洁和直观),接着我们修改employeeList.jsp:
<<< 2 line2
<display:column property="dept" sortable="true" titleKey="employee.dept"/>
<display:column property="title" sortable="true" titleKey="employee.title"/>
>>> change to
<display:column property="dept.name" sortable="true" titleKey="employee.dept"/>
<display:column property="title.name" sortable="true" titleKey="employee.title"/>
<<< 2 lines
... ...
然后我们重启一下服务,重新进入“员工列表”页面,我们发现原先的数字不见了,取代的是关联的职位名称,酷~ 到这里大家应该也可以体会我开始的时候为什么说appfuse是“第一次让我感觉到‘轻量’的J2EE框架”了吧,代码改动可以说是“前所未有”的小了~
* 这里大家可能会遇到以下问题:org.hibernate.ObjectNotFoundException: No row with the given identifier exists
有两张表,table1和table2. 产生此问题的原因就是table1里做了关联<one-to-one>或者<many-to-one unique="true">(特殊的多对一映射,实际就是一对一)来关联table2.当hibernate查找的时候,table2里的数据没有与table1相匹配的,这样就会报这个错(简单来说就是数据的问题)。
别忘了还有employeeForm.jsp:
... ...
<s:select name="employee.dept.id" key="employee.dept" headerKey="" headerValue="Select Dept" cssStyle="width:120px"
list="deptList"
listKey="id"
listValue="name"
value="employee.dept.id"
required="true"
/>
<s:select name="employee.title.id" key="employee.title" headerKey="" headerValue="Select Title" cssStyle="width:120px"
list="titleList"
listKey="id"
listValue="name"
value="employee.title.id"
required="true"
/>
<s:radio key="employee.status"
list="statusList"
listKey="status"
listValue="status"
value="employee.status"
required="true"
/>
... ...
这里看到"employee.dept"和"employee.title"的select控件的默认值我们已经设成"employee.dept.id"和"employee.title.id",这样才可以在编辑页面载入正确的默认值,至于"employee.status"我们做的修改是把listKey="id"变成listKey="status"直接记录status的值到`Employee`表的status字段中去,并没有关联`Status`表,其实对于一些比较固定的小配置表我们完全可以选择这种方案(减少表关联操作),适合的才是最好的嘛,呵呵~
* 另外,大家如果要查看Hibernate生成的sql语句,可以在log2j.xml里面打开如下注释即可:
... ...
<!--logger name="org.hibernate.SQL">
<level value="DEBUG"/>
</logger-->
... ...
>>> 高级功能设计
到这里我们的“员工管理系统”的功能已经基本完整了,但是如果要变成一个真正的企业管理工具,还有很多的工作要做,由于篇幅限制,我们在这个部分只介绍一下分页功能的实现吧,其他更高级的用法就留给大家自由发挥了:)
实际上由于Appfuse使用displayTag作为表格展示的工具,所以也使我们省去了不少界面和编码方面的工作,所以我们接下来就来介绍一下displayTag的常用功能,然后分析一下优缺点,最后我们就大数量分页进行一下研究。
1> 常用功能
分页功能:如果想对代码分页,只需在display:table标签中添加一项pagesize="每页显示行数",为了测试我们在employeeList.jsp里面把分页数设小一点<display:table name="employees" class="table" requestURI="" id="employeeList" export="true" pagesize="5">,这样应该就可以看到分页的links,至于显示样式,我们可以在styles/displaytag.css修改。
排序功能:可以为table设置默认排序列(defaultsort),以及排序方式(defaultorder:"ascending"or"descending"),以及排序范围(sort:"page"or"list"),另外,需要排序的列只要为该column加上sortable="true"就好了。
导出数据:默认有CSV,Excel,XML,PDF这几种输出方式,appfuse默认就设置了可以看看代码。
其他功能:displayTag还有很多功能,例如计算总数等,可以参考:http://displaytag.sourceforge.net/1.2/displaytag/tagreference.html
2> 优缺点分析
优点:很明显用displayTag帮我们节省了很多重复“造轮子”的时间,而且看起来功能也算强大。
缺点:分页样式不够灵活(links),似乎只能打出所有页数。另外,如果你打出取数据的sql语句就可以发现,displayTag默认是一次把所有的数据取出来,然后再排序的,如果数据量比较大的时候会有性能问题。
3> 大数据分页
上面分析到了在默认情况下,displayTag的分页是很低效的,因此我们必须像一个方法来,关于这点displayTag推荐两种解决方案,一种是使用partialList="true"和size="resultSize"这两个标签来解决,但实际上这个方案是从内存分页的基础上改过来的,打出来的sql实际上没有变化,所以我们使用第二种方式,那就是实现PaginatedList接口。
PageList.java:
... ...
import java.util.List;
import org.displaytag.pagination.PaginatedList;
import org.displaytag.properties.SortOrderEnum;
/**
* 实现分页列表
*/
public class PageList implements PaginatedList {
/**
* 每页的列表
*/
private List list;
/**
* 当前页码
*/
private int pageNumber = 1;
/**
* 每页记录数 page size
*/
private int objectsPerPage = 15;
/**
* 总记录数
*/
private int fullListSize = 0;
private String sortCriterion;
private SortOrderEnum sortDirection;
private String searchId;
public List getList() {
return list;
}
public void setList(List list) {
this.list = list;
}
public int getPageNumber() {
return pageNumber;
}
public void setPageNumber(int pageNumber) {
this.pageNumber = pageNumber;
}
public int getObjectsPerPage() {
return objectsPerPage;
}
public void setObjectsPerPage(int objectsPerPage) {
this.objectsPerPage = objectsPerPage;
}
public int getFullListSize() {
return fullListSize;
}
public void setFullListSize(int fullListSize) {
this.fullListSize = fullListSize;
}
public String getSortCriterion() {
return sortCriterion;
}
public void setSortCriterion(String sortCriterion) {
this.sortCriterion = sortCriterion;
}
public SortOrderEnum getSortDirection() {
return sortDirection;
}
public void setSortDirection(SortOrderEnum sortDirection) {
this.sortDirection = sortDirection;
}
public String getSearchId() {
return searchId;
}
public void setSearchId(String searchId) {
this.searchId = searchId;
}
}
以上就是PaginatedList的实现。
GenericDao.java:
... ...
/**
* Find a list of records by using a named query
* @param where can be a sql like : "t.id > 2" (t is a reference for current table)
* @param offset limit start
* @param length limit end
* @return a list of the records found
*/
List<T> getListForPage(String where, final int offset, final int length);
/**
* Generic method to get total count (mysql)
* @param where can be a sql like : "t.id > 2" (t is a reference for current table)
* @return total count
*/
int getTotalCount(String where);
... ...
以上为GenericDao添加两个方法,准备在GenericDaoHibernate中实现,getListForPage取得分页列表,getTotalCount取得数据总数。
GenericDaoHibernate.java:
... ...
/**
* {@inheritDoc}
*/
@SuppressWarnings("unchecked")
public List<T> getListForPage(String where, final int offset, final int length) {
String whereSql = (where != null && where.length() > 0) ? " where " + where : where;
final String hql = "from " + this.persistentClass.getName() + " t " + whereSql;
List list = getHibernateTemplate().executeFind(new HibernateCallback() {
public Object doInHibernate(Session session)
throws HibernateQueryException, SQLException {
Query query = session.createQuery(hql);
query.setFirstResult(offset);
query.setMaxResults(length);
List list = query.list();
return list;
}
});
return list;
}
/**
* {@inheritDoc}
*/
public int getTotalCount(String where) {
String whereSql = (where != null && where.length() > 0) ? " where " + where : where;
final String hql = "select count(t) from " + this.persistentClass.getName() + " t " + whereSql;
Object result = getHibernateTemplate().execute(new HibernateCallback() {
public Object doInHibernate(Session session)
throws HibernateQueryException, SQLException {
Query query = session.createQuery(hql);
return query.uniqueResult();
}
});
return Integer.parseInt(result.toString());
}
... ...
以上为getListForPage和getTotalCount两个方法的实现,注意的一点是我们这里使用hibernateTemplate的回调函数来传参给hibernate的sessionFactory进行处理,参考这种方法可以自己编写需要的sql,让程序更加灵活。
EmployeeDao.java:
... ...
/**
* Fetch paging employee list
* @param offset limit start
* @param length limit end
* @return a list of the records found
*/
List getPageList (int start, int length);
/**
* Fetch paging employee total count
* @return total count
*/
int getPageCount ();
... ...
EmployeeDaoHibernate.java:
... ...
public List getPageList(int page, int size) {
return this.getListForPage("", (page-1)*size, size);
}
public int getPageCount() {
return this.getTotalCount("");
}
... ...
以上使用GenericDao中定义的两个方法很方便的取得employee分页信息。
applicationContext-struts.xml:
... ...
<bean id="employeeAction" class="com.appfuse.app.webapp.action.EmployeeAction" scope="prototype">
<property name="employeeManager" ref="employeeManager"/>
<property name="employeeDao" ref="employeeDao"/>
<property name="deptManager" ref="deptManager"/>
<property name="titleManager" ref="titleManager"/>
<property name="statusManager" ref="statusManager"/>
</bean>
... ...
然后把employeeDao通过Spring Ioc注入到EmployeeAction中使用。
EmployeeAction.java:
... ...
private EmployeeDao employeeDao;
public void setEmployeeDao(EmployeeDao employeeDao) {
this.employeeDao = employeeDao;
}
... ...
/**
* Add for paging
*/
private static int PAGE_SIZE = 5;
private PageList employeePageList;
public PageList getEmployeePageList() {
return employeePageList;
}
public String list() {
//employees = employeeManager.getAll();
// 获取当前页数,displaytag通过参数"page"传递这个值
int pageNumber;
if (getRequest().getParameter("page") != null
&& !"".equals(getRequest().getParameter("page"))) {
pageNumber = Integer.parseInt(getRequest().getParameter("page"));
} else {
pageNumber = 1;
}
PageList pageList = new PageList();
List pageResults = employeeDao.getPageList(pageNumber, PAGE_SIZE);
int pageTotalCount = employeeDao.getPageCount();
// 设置当前页数
pageList.setPageNumber(pageNumber);
// 设置当前页列表
pageList.setList(pageResults);
// 设置page size
pageList.setObjectsPerPage(PAGE_SIZE);
// 设置总页数
pageList.setFullListSize(pageTotalCount);
employeePageList = pageList;
return SUCCESS;
}
... ...
以上使用getPageList和getPageCount两个方法把取得的分页信息赋给pageList,然后传给显示层的displayTag组件进行渲染展示。
employeeList.jsp:
... ...
<display:table name="employeePageList" class="table" requestURI="" id="employeeList" export="true"
pagesize="5" defaultsort="2" defaultorder="descending" sort="list"
partialList="true" size="8">
... ...
我们这里把name换成employeePageList,但是要注意这么做了之后,原先的排序等功能就不再起作用的,而必须通过程序实现,所以这里可以先把sortable="true"去掉。
到这里我们这次的代码修改算是结束了,我们成功的实现了PaginatedList接口,并通过自己编写的sql来取得分页信息,应该说通过这次修改diplayTag的性能已经“脱胎换骨”,可以适用于大数量的结果查询了。
>>> 教程总结
到这里我们使用appfuse框架只花了很少的时间,编写了很少的代码,就已经搭建了一个完整的包含权限管理的员工管理系统,我们可以使用admin用户建立新的普通管理帐户来分配给指定的人员来使用这个系统,普通管理帐户只有“员工管理”模块的权限,而admin则还有“部门管理”和“职位管理”的权限,整个系统层次清晰,功能完整;另外,基于Spring Security的框架还让该系统可以无缝集成到一些主流的SSO系统集群和基于LDAP的企业工具中去,真是非常棒一个解决方案~
>>> 回顾展望
实际上appfuse还有很多功能没有介绍完,比如xfire做webservice(访问http://localhost:8080/services可见),dwr的使用以及发送邮件等,但是由于篇幅问题,这里只介绍到这里了,有空的话我会抽时间把这些部分内容补充一下,如果朋友有什么疑问或者建议,欢迎与我联系交流~ 待续~