内容


纯 servlet:重新考虑视图

使用 Servlet API 简化设计

Comments

设计 JSP 的目的是将 Web 开发人员的任务与设计动态页面 UI 的非开发人员的任务分离开来。遗憾的是,JSP 对于许多设计人员来说太复杂了,为解决各种动态内容问题添加的软件层让他们觉得非常棘手。(例如,国际化要求将文本存储在其他地方并通过键来引用。)所以对于大多数项目,Java 开发人员只好自己处理 JSP 代码,这常常会包含本属于设计人员的工作,使他们的精力消耗在标记库和其他东西上,无法集中于 Java 代码。

与正统的方式不同,可以使用简单的 helper 对象,根据常规 servlet 构建简洁优美的 Web 界面。本文讲解如何以标准的 Java 形式编写动态 Web 页面的视图输出。我将解释这种方法的好处,并用一个计分应用程序演示这种方法,这个程序管理一个 NCAA 三月狂热 奖金池。

HTML 是动态的

这种纯 servlet 方法非常简单。它涉及一个 servlet 基类和一个定制的写出器对象,servlet 子类使用这个对象产生输出。代码很简洁,因为大多数 HTML 封装在 helper 对象的方法中,都可以按需重写。代码重用总是令人愉快,而且大多数 Web 站点的页面共享许多 HTML,所以重用应该是个重要的考虑因素。HTML 输出方法产生直观紧凑的 servlet 代码,因此可维护性很高,这使代码的维护成本差不多直接与代码规模成正比。通过将 JSP 界面重写成纯 servlet,可以将代码缩减三分之二。

例如,要根据用户权限输出一个链接,就需要下面这样冗长的构造代码:

<c:if test="${user.permission[ sessionScope.ConstantMap[ EDIT_WIDGET ] ] != 0}">
  <c:url var="editUrl" value="/EditWidget.jsp"/>
  <div class="navigation"><a href="<c:out value="${editUrl}"/>">Edit
        this widget</a></div>
</c:if>

通过使用 Java 语法,代码就简洁多了:

if (user.getPermission(Constants.EDIT_WIDGET) != 0)
  out.printNavlinkDIV("/EditWidget.jsp", "Edit this widget");

另外,在同一个地方获取和输出业务对象,而不是通过请求对象传递它们,这也会节省大量代码。简洁是美。

使用 JSP 和其他视图技术可能是 Web 开发中最让人头疼的部分。JSP 页面不是 HTML 或 XML、Java 代码、JavaServer Pages Standard Tag Library(JSTL)代码或表达式语言(EL),而是这些东西的大杂烩。JSP 代码不但是奇怪的组合体,而且每个抽象层都给开发带来新的障碍。例如,对 JSP 页面进行调试简直就像探矿那样困难。您知道某个地方出了毛病,但是无法找到出问题的位置;神秘难懂的错误消息虽然指出了行号,但这个行号往往不是问题的真正所在。

JSP 技术不能扩展基类,所以代码重用只能通过 bean、include 文件和定制的标记库来进行。标记库太麻烦,不适合进行有效的重用。为您所做的每处 API 修改维护一个 XML 是非常麻烦的,而且 “标记设计就是语言设计”(参见 参考资料 中 Noel Bergman 的文章)。结果是在本已分了很多层的接口上又加了一层。

我们正面对着全新的 World Wide Web。无论 Ajax 能否引领 Web 开发的方向,Web 站点都会继续向着更加智能化的方向发展。另外,尽管 HTML 本身总是声明性的,但是产生它的代码却不一定如此。JSP 技术和其他模板化系统必然过分复杂,因为它们试图以声明式的方式表达本质上动态的输出。这正是开发人员无法容忍在 JSP 源代码中添加 scriptlet 的原因:我们试图表达的逻辑 具有各种各样的形式。

通过将 HTML 封装成 Java 代码,可以简洁地表达输出逻辑。if 语句和 for 循环可以采用大家熟悉的形式。页面元素可以重构成方法,这样就很容易理解和维护它们。(对较大的 JSP 页面进行维护是非常麻烦的,非常容易出现错误,尤其是在缺少良好的注释的情况下。)通过使用纯 servlet,可以尽可能增加代码重用,因为不需要为每个页面的构造编写新的类。

狂热的设计

为了演示纯 servlet 的概念,我为一个 NCAA March Madness 锦标赛奖金池构建了一个计分界面。(参见 三月狂热下载)。用户可以从参加锦标赛的 64 支球队中选择他们认为最出色的 20 支球队,并给每个球队分配一个加权的分数。比赛开始之后,他们的选择就变成只读的;当比赛结束时,管理员输入获胜球队的名称。根据用户选择的球队,自动地计算用户的累积分数并显示分数的排名。

这个项目大约花费了我三周的业余时间,大部分时间花在样式和图像上(毕竟我不是画家)。除了一个 HTML 文件和其他静态资源之外,UI 层由 21 个 Java 类组成,根据 JavaNCSS 的度量标准,一共有 1,334 个 Java 语句(参见 参考资料)。

逃离 MVC

这里演示的纯 servlet 设计在客户机和业务逻辑之间建立一个视图层。Model-View-Controller(MVC,或者说 Model 2)实际上不是万能的,而且支持它的 Web 框架往往比较难以处理。Spring MVC 和 JavaServer Faces(JSF)太过复杂,我可以断言,Struts 的麻烦程度不亚于此,每次调整控制逻辑时都必须调整臃肿复杂的配置文件。N. Alex Rupp(参见 参考资料)甚至将 MVC 称为反模式,一种 “看似聪明其实非常愚蠢的” Web 技术。

例如,开发人员常常误解 Struts 中 Action 模块的用途。业务逻辑常常被放在这里(如果不是都放在 JSP 中的话)。将视图和控制器实现为 servlet 可以促使业务逻辑放入恰当位置,因为 servlet 明确关注与浏览器的接口。

对于这个项目,我使用了几个来自我自己的 elseforif-servlet 库的类(参见 参考资料)。这 设计的关键,因为它为生成 HTML 提供了一个方便的接口。但是,本文的重点不是这个库,而是证明我的方法的优点。

图 1 是部分类图,其中的 elseforif-servlet 元素以绿色表示:

图 1. 部分类图
类图
类图

树结构的顶部是一个包含 HTML 字符串常量的接口,它为 HTML 写出器对象和使用它们的 servlet 提供了方便。(在后面将看到它们的作用。)接下来是 HTMLWriterHTMLFlexiWriter,它们实现一些基本的低级 HTML 方法,它们对于任何 Web 站点都是有用的。这两者之间的区别是,HTMLWriter 直接写到输出中,而 HTMLFlexiWriter 还可以以字符串形式返回输出。将一个输出方法的结果作为参数传递给另一个方法常常是很方便的,例如:

out.printA(URL_ELSEFORIF, out.IMG("/img/elseforif.gif", 88, 31));

然后是 MadnessWriter 类,它增加了这个 Web 站点需要的高级输出特性:页眉、页脚和菜单等常见元素,即这个站点特有的所有重复内容。这是一个轻量级、非线程安全的对象,抽象 servlet 基类 MadnessServlet 使用一个工厂方法为各请求实例化此对象。

这个基类负责处理核心 servlet 控制逻辑,使具体子类可以将注意力放在它们特有的任务上。在设置一些标准的 HTTP 头并执行一些页面级安全检查之后,它将 MadnessWriter 实例传递给受保护的 doBoth() 方法:

protected void doBoth(HttpServletRequest request, HttpServletResponse response,
      HttpSession session, MadnessWriter out) throws ServletException, IOException

MadnessServlet 还实现了 MadnessConstants,它使子类能够轻松地访问 HTMLConstants 中定义的静态值。所以,通过结合使用 MadnessWriter 对象和这些常量,servlet 实现了非常紧凑的 Java 风格的代码。

按照 MVC 的说法,servlet(这里的 UI 基本单元)构成了视图层和控制层。对于 HTTP 这样的无状态接口,这是有意义的。对视图的请求和对数据更新的请求采用同样的基本形式,这两者之间没有明确的区别。为了保持模块化,我在一个 servlet 类中实现表单页面,在另一个 servlet 类中实现它的处理器。但是,无论怎样对功能进行分隔,HTML 输出逻辑、servlet 参数的处理和页面流逻辑都自我封闭的同级别的对象。虽然 MVC 对它们进行抽象是出于好意,但是会导致功能混乱。

业务层的实现应该与视图层没有关联。关键是要有一个简单明了的业务接口,这样的话,UI 代码就可以只处理 UI 问题。(对于示例应用程序的业务层,我在 Apache Derby 上构建了一个相当粗糙的 CRUD 接口。)

运行应用程序

这个 Web 应用程序是几乎完全自含的,但是可能需要修改 web.xml 描述符中的一些环境属性,然后才能将它部署到 webapps 目录中。至少需要指定创建嵌入式 Derby 实例和存储它的数据文件的位置。默认设置是 UNIX 路径 —— /var/derby/ —— 所以如果您运行 Linux,那么只需要创建这个目录(并允许 servlet 容器写这个目录)。用用户名 admin 和密码 password 登录这个站点。在下载包的 README 文件中可以找到更多信息。

表单和它的处理器

现在该看看代码了。在锦标赛的第一轮开始之前,用户进入 Picks 页面(见图 2),选择他们喜欢的球队。在此之后,他们可以通过只读输出的形式查看自己和其他玩家的选择情况。

图 2. Picks 页面
Picks 页面
Picks 页面

在生成这个页面时,Picks servlet 做的第一件事情是从业务层获取它的用户对象(在这个系统中,是 Player),并执行一项安全检查:

PlayerManager playerMan = PlayerManager.GetInstance();
Player player = playerMan.select(session.getAttribute(P_PLAYER_ID), true);
boolean readOnly = GetCutoffDateIsPassed() && !player.getAdmin();
String playerID = request.getParameter(P_PLAYER_ID);
if (playerID != null)
  if (readOnly || player.getAdmin())
    player = playerMan.select(playerID, true);
  else
    throw new ServletException("You may not view other players' picks"
          " until the cutoff date has passed:  " + CutoffDate + ".");

这确保正常用户根据业务规则查看或编辑选择的球队。它还建立一些局部变量,这些变量将决定页面的表现,尤其是 readOnly。接下来,建立一个 Team 对象数组,每个对象代表一支参赛球队。然后,调用一个方法,从数组生成按字母表排序的 map,下拉控件需要用到这个 map:

TeamManager teamMan = TeamManager.GetInstance();
Team[] teams = teamMan.selectAll();
Map selectTeams = getDropDownMap(teams);

现在,开始输出:

out.printPreContent(null, out.SCRIPTFile("/js/picks.js"));

这个方法输出页面的第一部分,包括完整的 HEAD 标记、BODY 开始标记和页面顶部的徽标。注意指向一个 JavaScript 文件的 URL,它添加在 HEAD 中。您可能会认为,在部署 WAR 文件时这种方法会失效,因为它将在 URL 的开头添加上下文前缀 /madness。实际上,上下文前缀是动态地传递给 MadnessWriter 构造函数的,然后构造函数自动地将它加在任何 URL 的开头,并加上斜线;如果您的上下文是不确定的,那么这个特性就非常有用。

下一个调用输出主菜单:

out.printMenu(URL_PICKS);

通过传递要显示的页面的 URL,让 MadnessWriter 实例跳过这个页面的链接(也可以禁用它)。然后调用一个方法,开始输出 TABLE 元素,我将这个元素称为

out.printBeginBox();

这会开始几个标记,直到框包含的具体内容为止。(后面将通过一个相似的调用结束这些标记。注意,上面的 printMenu() 调用了同样的方法。)这种封装方式可以大大简化调试。例如,我曾经遇到一个 bug,框中的某些边界 TD 的宽度是 1%,对于浏览器窗口来说,这个宽度太大了。我将它改为 0%,从而在一个地方进行修改就纠正了整个站点上的效果。这可以用定制的标记库来完成,但是没这么容易。

下面几行输出一个或两个 DIV 元素,第一个在提交表单之后向用户表示成功:

if ("true".equals(request.getAttribute(P_SUCCESS)))
  out.printDIV("smallHeading", "Team picks were saved successfully.");
out.printDIV("reminder", "(Reminder:  \"Pick 20\" represents the team you"
      + " think likeliest to win.  \"Pick 1\" is the least likely.)");

"smallHeading""reminder" 自变量指定要应用于 DIV 开始标记的层叠样式表(CSS)类名,第二个自变量是在 DIV 标记之前输出的文本。如果 reminder DIV 的内容比较复杂,我会调用 out.printBeginDIV("reminder"),这个方法只输出 DIV 开始标记。HTMLWriterHTMLFlexiWriter 中也使用同样的命名模式。但是,HTMLConstants 中的字符串常量不太一样,例如默认的 DIV 开始和结束标记分别使用 DIVEND_DIV

在 reminder 后面,输出一个表单,其中提供下拉控件让用户选择 20 支球队。如果用户只能查看已经做出的选择,那么只输出球队的名称。按照 Java 语法,这个逻辑的表达非常自然:

if (!readOnly)
  out.printSELECT(P_PICK + i, selectTeams, teamID);
else
  {
  String teamName = (String)(selectTeams.get(teamID));
  out.print((teamName != null) ? teamName : "(no pick)");
  }

printSELECT() 方法为 map 中的每个键/值对创建一个 OPTION,它预先选择键与 teamID 匹配的对象。

为了完成表单,需要输出显示在页面右边的球队列表。球队的数组按照 NCAA 地区和排名进行排序。每个地区有一个小标题,整个列表显示为两列。这需要一些数学计算,所以将它放在一个单独的方法中,见清单 1:

清单 1. 将输出代码放在一个方法中
private void doRegionList(Team[] teams, MadnessWriter out) throws IOException
  {
  out.print(TABLE + TR);
  out.printBeginTD(null, "regionList");
  for (int i = 0; i < teams.length; i++)
    {
    if ((i & 15) == 0)
      {
      if (i == 32)
        {
        out.print(END_TD + NL);
        out.printBeginTD(null, "regionList");
        }
      out.print(NL + DIV);
      out.print(REGION_NAMES[i >> 4]);
      out.print(":" + END_DIV + OL);
      }
    out.print(NL + LI);
    out.printHTMLEscape(teams[i].getFullName());
    out.print(" (");
    out.print((teams[i].getRank() & 15) + 1);
    out.print(")");
    out.print(END_LI);
    if ((i % 16) == 15)
      out.print(END_OL);
    }
  out.print(END_TABLE_3);
  }

END_TABLE_3 常量仅仅是 TDTRTABLE 结束标记的组合。这种方式似乎有点儿古怪,但是掌握了它之后,就可以用简洁的代码建立良好的 HTML 设计,这意味着只将它用于页面结构,而将尽可能多的样式放在样式表中。

现在完成这个页面:

out.printEndBox();
out.printPostContent();

第一行结束前面开始的框,printPostContent() 输出页面的其余部分,包括页脚。Picks 表单页面完成了。

处理器 servlet(PicksAction)对提交的 Picks 页面进行响应,它从请求对象收集选择的球队 ID,并将它们传递给业务层来更新适当的 Player 实体,在此之后返回到 Picks 表单页面。这里也执行一项安全检查,确保用户在比赛开始之后无法更新他们的选择。表单和它的处理器都是 servlet,不需要将它们写到单独的界面。它们都使用同样的业务对象来响应参数化的浏览器请求,它们一起构成一个 UI 组件。如果使用 MVC 框架,那么就会将原本简单的事情复杂化了。

其他方面

尽管 Web 框架往往让事情变得复杂,但是它们能够解决许多比较小的问题。基于 servlet 的设计提供了很高的灵活性,可以适当地解决这些问题,而不需要依赖于任何解决方案。

安全性

在企业应用程序中,页面级的安全性往往在 XML 描述符文件中以声明性方式来处理。同样,根据我的经验,往往需要一个更动态的代码级接口来管理页面中特殊的行为 —— 例如,Picks servlet 中与日期相关的逻辑。这可以用 Servlet API 内置的安全方法来处理,比如请求对象上的 isUserInRole(),也可以将它写成单独的接口。使用 Servlet API 对这两种方式都有帮助。

国际化

尽管许多框架都可以以属性文件的形式对文本值进行国际化,但是可以在 HTML 写出器中用少量代码实现同样的结果。可以添加一个方法,比如 printText(),它以一个键作为自变量并输出翻译后的文本值(text() 会直接返回文本)。servlet 输出代码仍然很简洁,而且执行与等效的 JSP 相同的功能(如果不是更多的话)。这还可以更好地控制如何处理缺失的翻译词 —— 是抛出异常,还是使用默认语言。

智能皮肤

March Madness 设计实现了一些很有意思的东西。进入主页并登录,就会看到一个欢迎消息。如果点击 “Welcome” 后面的逗号,就会发现外观和感觉发生了变化。替换的皮肤仅仅是另一个 CSS 文件。我扩展了 MadnessWriter;当选择替换皮肤时,servlet 基类对这个子类进行实例化,并将实例传递给受保护的服务方法。因此,MadnessWriter 子类不但可以覆盖默认的样式表,还可以覆盖结构性 HTML 输出代码,例如显示不同的徽标以及在框周围显示更复杂的边框。servlet 中不需要特殊代码。

关于缩进的说明

关于这种方式,有一点需要注意:生成的 HTML 没有缩进,其格式的可读性不好。(但是,通过混合 HTML 和 scriptlet 在模板代码中创建缩进常常导致混乱。即使不使用 scriptlet,随着时间的推移,剪切和粘贴也会使代码支离破碎。)

只需在输出中添加一些新行字符,就能够让 March Madness 站点生成的 HTML 具有更好的可读性。但是对于这种方法来说,HTML 的格式是否漂亮并不重要,因为可以通过检查 Java 代码轻松地找到大多数布局 bug,不需要查看生成的 HTML 源代码。将元素和结构放在方法中大大提高了简洁性和可维护性。

结束语

本文鼓励读者脱离常用 Web 框架的思维模式,考虑直接用 Java Servlet API 构建 Web 界面。Java Web 开发人员可用的框架和模板系统非常多,这让人误以为这些是必不可少的,但是它们往往非常复杂,很难使用。尽管有的框架非常适合某种类型的 Web 应用程序,但是也可以考虑用内置的语言特性(比如扩展和封装)来实现。正如 Bruce Tate 所说的(参见 参考资料),“以简单灵巧的方法来解决问题往往更好”。

Web 框架有适合它们的场景,当项目有专门的 HTML 设计人员来生成和维护 JSP/模板时,JSP/模板是非常合适的。但是对于某些项目来说,纯 servlet 的简单性是非常有意义的。这种方法提供了控制能力和灵活性,而且不要求将所有动态内容都放在请求对象中。可以简便地对纯 servlet 进行单元测试。重用 HTML 输出也很简单,只需添加或覆盖一个方法。

所以试试这种方法吧。您可能会对它所带来的结果感到吃惊。


下载资源


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology, Web development
ArticleID=208965
ArticleTitle=纯 servlet:重新考虑视图
publish-date=04132007