序言

关于这个文档

这是一个在你开发Lift网络应用的时候, 可以参考的解决方案的集合 Lift web framework.

这个文档的目标是, 给出一个单一的, 简练的解决方案对于特定的问题, 解决一个问题也许会有很多方法, 这个本书只提出一种方法, 但是你可以找到更多的信息在讨论中.

学习Lift架构

如果你在学习Lift, 请看以下书籍:

版权

This document is licensed Creative Commons Attribution, Non Commercial, No Derivatives.

贡献者

  • Richard Dallaway

  • Jono Ferguson

  • Franz Bettag Franz is an enthusiastic Scala Hacker for several years now. He joined the Lift team in January 2012 and actively tweets and blogs about his newest Scala adventures. Find him at https://twitter.com/fbettag

  • Marek Żebrowski

  • Peter Robinett Peter is a web and mobile developer and a Lift committer. He can be found on the web at http://www.bubblefoundry.com and on Twitter at http://twitter.com/pr1001.

  • Kevin Lau is a founder of few web apps with a focus in AWS cloud, iOS and Lift.

翻译

Readman QQ 44546836. You can find me @ www.liftweb.cn

更新

软件版本

如果没有特别声明, 所有源码运行在 Lift 2.5, SBT 0.12, Scala 2.9

安装和运行

这种主要覆盖了关于如何开始开发Lift的相关问题: 其中包括, 运行一个Lift应用和设置一个编程环境. 你将会找到关于生产部署在 [deployment].

下载和运行Lift

Problem

你想安装和运行Lift在你的计算机.

Solution

安装和运行Lift的唯一前提是你需要有Java 1.5或者更新的版本在你的计算机上安装. 你可以找到关于如何安装Java在http://java.com/.

你可以测试是否正确安装Java, 或检查Java的版本, 运行以下命令:

$ java -version
java version "1.7.0_13"
Java(TM) SE Runtime Environment (build 1.7.0_13-b20)
Java HotSpot(TM) 64-Bit Server VM (build 23.7-b01, mixed mode)

当你安装好Java后, 通过运行以下命令, 将会下载, 建立和运行一个基础的Lift应用.

For Mac and Linux
  • 打开 http://liftweb.net/download 然后下载 Lift 2.5-RC2 ZIP 文件.

  • 解压缩ZIP文件.

  • 打开 Terminal 或者你喜欢的命令行工具.

  • 打开解压后的文件,然后找到 scala_29 子文件夹, 然后进入 lift_basic 文件夹.

  • 运行:./sbt.

  • 程序会自动下载所需要的依赖库.

  • 在 SBT 上输入: container:start.

  • 打开你的浏览器, 然后打开 http://127.0.0.1:8080/.

  • 当你想结束时, 输入 exit 在SBT上, 然后会自动结束.

For Windows
  • 打开 http://liftweb.net/download 找到ZIP文件的 Lift 2.5-RC2, 存到你的计算机上.

  • 解压缩ZIP文件.

  • 打开解压缩的文件夹, 然后找到 scala_29 文件夹, 然后打开 lift_basic.

  • 双击 sbt.bat 运行SBT编译工具, 一个窗口会自动打开.

  • 程序会自动下载所需要的依赖库.

  • 如果Windows的防火墙禁止运行Java, 请选择"允许访问".

  • 在 SBT 上输入: container:start.

  • 打开你的浏览器, 然后打开 http://127.0.0.1:8080/.

  • 当你想结束时, 输入 exit 在SBT上, 然后会自动结束..

Expected result

通过以上步骤, 你会运行一个基础的Lift应用, 结果会像以下一样 [LiftBasicScreenshot].

images/apphome.png
Figure 1. The basic Lift application home page.

Discussion

Lift没有通过常规的"安装"的方法运行, 而是通过编译工具来运行, 如SBT,Maven. 在这章中,我们下载了一个ZIP文件包含了四个尽可能简单的Lift应用, 然后运行了他们通过编译工具.

Simple Build Tool

输入 sbt 打开SBT, 一个基于Scala工程的(不仅仅是对于Lift)依赖库管理工具. SBT将会检查工程的定义, 然后下载所有需要的依赖库, 包括Lift.

整个下载过程只进行一次, 然后下载的文件会存在 .ivy2/ 在你的主目录下.

你的应用编译文件是 build.sbt. 如果你打开它, 你会看到:

  • 基础信息关于你的应用, 包含一个名字和版本号;

  • resolvers, 一个字符串, 告诉SBT应该在哪里找到所需要的依赖库;

  • 插件的设置和Scala的编译器; 还有

  • 一个List的依赖库, 他们用来运行你的程序, 包括了Lift.

运行你的应用

SBT命令 container:start 打开了一个web服务器在默认的端口8080上,并且 传递requests到你的Lift应用. 名词 container 的意思是一个能让你部署Lift应用的软件 这里有许多不同的选择. 比如 (Jetty 或者Tomcat 是比较受欢迎的) 他们都是通过同一个标准进行部署的. 其实,你可以部署Lift在任何你想要的container上,. 命令 container:start 是Jetty的命令.

Source Code

程序的源代码在目录 src/main/webappsrc/main/scala.如果你看 index.html 文件在 webapp 文件夹下. 你会看到里面有一段 lift:helloWorld. 这是这个文件的引用 scala/code/snippet/HelloWorld.scala. 这是一个 snippet invocation 并且是一个Lift的 view first 网络应用设计模式. 这个模式没有routing的设置: 从前端的index页面,收集数据然后转发到view. 相反, view定义了后端函数取代的位置, 就像函数定义在 HelloWorld.scala.

Lift 知道如何去看 code 包去寻找Snippet, 是因为那个包定义了位置在 scala/bootstrap/liftweb/Boot.scala. Boot文件会在Lift运行后首先运行, 你可以在这里 定义许多Lift的行为.

See Also

SBt的文档在 http://www.scala-sbt.org.

Lift的教程在 Simply Lift at http://simply.liftweb.net/ and in Lift in Action (Tim Perrett, 2011, Manning Publications Co).

使用SBT建立一个Lift Project

Problem

你想建立一个Lift文档,但是不使用官网的ZIP文件

Solution

你需要设置SBT和Lift. 幸运的是, 只需要五个简单的文件

第一, 建立一个SBT文件 project/plugins.sbt (所有文件名字都关联到文件的root目录):

libraryDependencies <+= sbtVersion(v => v match {
  case "0.11.0" => "com.github.siasia" %% "xsbt-web-plugin" % "0.11.0-0.2.8"
  case "0.11.1" => "com.github.siasia" %% "xsbt-web-plugin" % "0.11.1-0.2.10"
  case "0.11.2" => "com.github.siasia" %% "xsbt-web-plugin" % "0.11.2-0.2.11"
  case "0.11.3" => "com.github.siasia" %% "xsbt-web-plugin" % "0.11.3-0.2.11.1"
  case x if x startsWith "0.12" =>
    "com.github.siasia" %% "xsbt-web-plugin" % "0.12.0-0.2.11.1"
})

这个文件告诉了SBT, 你将会使用xsbt-web-plugin并且让SBT选择正确的版本.

然后,建立sbt编译文件, build.sbt:

organization := "org.yourorganization"

name := "liftfromscratch"

version := "0.1-SNAPSHOT"

scalaVersion := "2.10.0"

seq(com.github.siasia.WebPlugin.webSettings :_*)

libraryDependencies ++= {
  val liftVersion = "2.5-RC2"
  Seq(
    "net.liftweb" %% "lift-webkit" % liftVersion % "compile",
    "org.eclipse.jetty" % "jetty-webapp" % "8.1.7.v20120910"  % "container,test",
    "org.eclipse.jetty.orbit" % "javax.servlet" % "3.0.0.v201112011016" %
      "container,compile" artifacts Artifact("javax.servlet", "jar", "jar")
  )
}

请随意改变到不同的版本, 不过主版本的Lift只能建立在住版本的Scala上.

现在你有一个基础的Lift工程, 你可以使用 sbt 命令行. 它将会自动的下载所有需要的依赖库, 和适当的Scala版本, 最后返回一个prompt.

然后, 建立以下文件 src/main/webapp/WEB-INF/web.xml:

<!DOCTYPE web-app SYSTEM "http://java.sun.com/dtd/web-app_2_3.dtd">
<web-app>
  <filter>
    <filter-name>LiftFilter</filter-name>
    <display-name>Lift Filter</display-name>
    <description>The Filter that intercepts Lift calls</description>
    <filter-class>net.liftweb.http.LiftFilter</filter-class>
  </filter>
  <filter-mapping>
    <filter-name>LiftFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
</web-app>

web.xml 文件告诉web容器, 比如说Jetty, 把所有的request都传递给Lift.

然后,建立简单的 index.html 文件在 src/main/webapp/index.html. 比如:

<!DOCTYPE html>
<html>
  <head>
    <title>Lift From Scratch</title>
  </head>
  <body>
    <h1>Welcome, you now have a working Lift installation</h1>
  </body>
</html>

最后, 设置基础的Lift启动文件 Boot.scalasrc/main/scala/bootstrap/Boot.scala.

package bootstrap.liftweb

import net.liftweb.http.{Html5Properties, LiftRules, Req}
import net.liftweb.sitemap.{Menu, SiteMap}

/**
 * A class that's instantiated early and run.  It allows the application
 * to modify lift's environment
 */
class Boot {
  def boot {
    // where to search snippet
    LiftRules.addToPackages("org.yourorganization.liftfromscratch")

    // Build SiteMap
    def sitemap(): SiteMap = SiteMap(
      Menu.i("Home") / "index"
    )

    // Use HTML5 for rendering
    LiftRules.htmlProperties.default.set((r: Req) =>
      new Html5Properties(r.userAgent))
  }
}

恭喜, 你现在有一个可以运行的Lift工程了!

你现在可以验证是不是有一个可以使用的Lift工程,通过打开Jetty,使用 sbt 命令行的 container:start 命令. 首先 Boot.scala 文件将会编译然后你会被提示, Jetty运行在 http://localhost:8080. 你应该可以看到你先前建立的 index.html 文件

Discussion

就像上边展示的那样,从一个模版建立一个新的Lift工程是一个相当简单的过程. 然而, 这是对一个新手来说非常有诀窍, 特别是你对JVM环境不熟悉, 或者对web容器不熟悉的时候. 如果你遇到任何问题, 请确保文件目录的完整性. 如果还有其他问题, 请到google group里寻求 Lift mailing list.

Lift使用SBT或者相似的编译工具,编译一个同样架构的工程. 这个架构Scala的源码在 src/main/scala web源码在 src/main/webapp. 你的Scala文件必须都放在 src/main/scala 或者在任何你定义在build.sbt 的organization下, 我们的例子是 src/main/scala/org/yourorganization/liftfromscratch/. 测试文件需要放在 src/test/ 而不是 src/main/. 同样, web.xml 文件必须放在 src/main/webapp/WEB-INF/ 才能被容器正确的调用.

为了方便, 你需要你的工程像如下架构一样:

- project root directory
  | build.sbt
  - project/
    | plugins.sbt
  - src/
    - main/
      - scala/
        - bootstrap/
          | Boot.scala
        - org/
          - yourorganization/
            - liftfromscratch/
              | <your Scala code goes here>
      - webapp/
        | index.html
        | <any other web resources - images, HTML, JavaScript, etc - go here>
        - WEB-INF/
          | web.xml
    - test/
      - scala/
        - org/
          - yourorganization/
            - liftfromscratch/
              | <your tests go here>

See Also

这里有一个简单的工程你可以直接使用: https://github.com/bubblefoundry/lift-from-scratch.

使用文本编辑器开发

Problem

你想开发Lift应用,使用你喜欢的文档编译器,然后在浏览器中即时的查看修改结果.

Solution

当你修改的时候,运行SBT, 让SBT去检测Scala文件的修改. 为了达到目标, 你需要用命令 sbt 然后输入以下命令:

~; container:start; container:reload /

当你保存文件的时候, SBT会检测到保存的文件是否被修改,然后刷新工程.

Discussion

当一个SBT命令使用前缀 ~ 的时候, 意思是, 当文件改变的时候, 执行以下命令. 第一个分号后边继续跟着另一个命令的意思是, 当前一个命令成功后, 执行后边的命令. 在这里, 当 start 运行成功后, reload 便会在文件改变的时候, 运行.

当你使用这条SBT的命令时, 你会看到以下信息

1. Waiting for source changes... (press enter to interrupt)

当你SBT窗口下, 键入enter, 你会退出 triggered execution 模式 并且 SBT不再监听文件改变. 然而, 当SBT坚挺的时候, 文件改变后 你会看到以下信息:

[info] Compiling 1 Scala source to target/scala-2.9.1/classes...
[success] Total time: 1 s, completed 15-Nov-2012 18:14:46
[pool-301-thread-4] DEBUG net.liftweb.http.LiftServlet - Destroyed Lift handler.
[info] stopped o.e.j.w.WebAppContext{/,[src/main/webapp/]}
[info] NO JSP Support for /, did not find org.apache.jasper.servlet.JspServlet
[info] started o.e.j.w.WebAppContext{/,[src/main/webapp/]}
[success] Total time: 0 s, completed 15-Nov-2012 18:14:46
2. Waiting for source changes... (press enter to interrupt)

修改HTML文件, 不会触发SBT的编译和重载. 这是因为SBT默认的行为是只监听Scala和Java的文件改变, 而且必须在目录`src/main/resources/`下. 这个是没有问题的, 因为Jetty会重载你的HTML文件, 当你刷新页面时

每次修改Scala文件的时候, 重启web容器并不是很理想, 你可以用过使用Jrebel来减少不必要的重启. [jrebel].

然而, 如果你真的有很多的修改, 你最好使用 container:stop 命令, 直到你真的准备好了, 然后再用 container:start. 这样可以防止SBT一直重启. SBT控制台能查看以前输入过的命令, 通过使用下箭头, 可以翻看以前的命令, 这样可以减少多次输入的麻烦.

你可能在使用过程中看到以下错误:

java.lang.OutOfMemoryError: PermGen space

这里的 permanent generation 是一个Java虚拟机的概念. 它是内存空间中存放class文件的一个地方 (与其他文件存放在一起). 他是一个固定大小的空间, 所以会出现溢出. 你可以想象, 持续的重启容器会不断的加载,重载class文件, 但是整个过程不是完美的, 所以最好的情况就是, 你把容器停止, 然后当修改完成代码后, 再重启. 如果你经常看到这个错误, 检查以下命令的设置 -XX:MaxPermSizesbt (or sbt.bat) 脚本中, 如果你能修改它, 修改成双倍.

使用 JRebel

Problem

当你修改Scala源文件的时候, 通过JRbel. 你将避免重启应用.

Solutions

安装JRbel需要三步, 安装, 每年更新一次Scala的License文件, 设置SBT调用JRebel.

首先, 打开 http://zeroturnaround.com/software/jrebel/ 获得一个免费的Scala License.

其次, 下载 "Generic ZIP Archive" 版本的JRebel, 解压缩并放到一个文件夹下. 在这里, 我使用 /opt/zt/jrebel/.

当你收到你的JRebel账户信息时, 你可以复制你的验证token, 它在ZeroTurnaround网站的 "Active" 区域. 为了使用Token在本地, 运行JRbel的设置脚本:

$ /opt/zt/jrebel/bin/jrebel-config.sh

Windows的用户可以使用 bin\jrebel-config.cmd.

在 "Activation" 设置里选择 "I want to use myJRebel", 然后在 "License"里, 粘贴你的actionvation token. 点击 "Activate" 按钮, 当你看到你的状态变成 "You have a valid myJRebel token" 点击 "Finish".

最后, 设置 SBT, 在 sbt 脚本中开启JRbel. 这意味着设置 -javaagent-noverify Java标签, 然后开启JRebel的Lift插件.

对于Mac和Linux, 脚本是在Lift的下载中就有的:

java -Drebel.lift_plugin=true -noverify -javaagent:/opt/zt/jrebel/jrebel.jar \
 -Xmx1024M -Xss2M -XX:MaxPermSize=512m -XX:+CMSClassUnloadingEnabled -jar \
 `dirname $0`/sbt-launch-0.12.jar "$@"

对于Windows, 设置 sbt.bat:

set SCRIPT_DIR=%~dp0
java -Drebel.lift_plugin=true -noverify -javaagent:c:/opt/zt/jrebel/jrebel.jar \
 -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256m -Xmx1024M -Xss2M \
 -jar "%SCRIPT_DIR%\sbt-launch-0.12.jar" %*

这就是配置JRbel所需要的所有设置. 当你开始SBT的时候, 你会看到类似于如下的提示:

#############################################################

  JRebel 5.1.1 (201211271929)
  (c) Copyright ZeroTurnaround OU, Estonia, Tartu.

  Over the last 30 days JRebel prevented
  at least 335 redeploys/restarts saving you about 13.6 hours.
....

当JRbel启动时, 你可以使用container:start 运行你的应用, 修改和编译Scala文件后会自动重载 你会看到重载的提示类似于如下:

[2012-12-16 23:15:44] JRebel: Reloading class 'code.snippet.HelloWorld'.

That change is live, without having to restart the container.

Discussion

JRebel会极大的提高你的开发速度. 他重载一个Java或Scala代码在一个已经运行的JVM中, 并且不需要重启. 你可以只编译一个文件, 当你打开浏览器时, 就会自动的看到新的结果.

尽管使用了Jrebel, 你依然需要每次都重启你的应用, 但是JRebel通常会减少重启的次数. 比如说, Boot.scala 在你应用开始时会运行, 所以尽管你使用JRebel, 当你修改它时, 你依然需要重启容器.

而且这里还有另外的一些情况下 JRebel是没用的, 比如说一个superclass改变. 一般来说, 这种情况下, JRebel会显示一个提示在命令行. 如果你看到了提示, 你需要停止, 并且重启你的容器.

命令 -Drebel.lift_plugin=true 添加Lift的功能性到JRebel. 特别是, 他允许JRebel重载 LiftScreen, WizardRestHelper. 这意味着你可以改变, fields 或者 screens, 和 REST serve code.

Purchased licenses

在这里, 我们使用的是一个免费的license, 叫做myJRebel. 他通过activation code和JRebel服务器通信. 如果你曾经购买过 ZeroTurnaround 的license, 情况有一些不同. 你会有你的license key 在一个名叫 jrebel.lic 的文件中. 你可以把它放在你Home的 .jrebel 文件夹下. 或者一个叫 jrebel.jar (e.g., in the /opt/zt/jrebel/ 的文件夹 如果你安装到这个目录), 或者你曾经安装的特定的目录. 对于其他设置, 改变 sbt 脚本, 并且设置特定目录, 通过使用以下的命令:

-Drebel.license=/path/to/jrebel.lic

See Also

你可以找到更多关于JRebel如何工作的信息在: http://zeroturnaround.com/software/jrebel/resources/faq/.

对Lift的支持, 是写在一个2012年的blog中: http://zeroturnaround.com/jrebel/lift-support-in-jrebel/, 在这里, 你会找到更多的插件.

使用Eclipse开发

Problem

你希望使用Eclipse IDE来开发Lift应用.并且点击浏览器的reload 查看更新.

Solution

使用 "Scala IDE for Eclipse" Eclipse插件. 以下链接有如何配置: http://scala-ide.org. 这里有很多的可以选择的地方: (nightly builds, milestones) 但是, 请用一个稳定版本, 这会让你的Eclipse更稳定的支持Scala.

为了建立一个Eclipse自动重载的工程, 你需要安装 "sbteclipse", 这需要配置 projects/plugins.sbt 在你的Lift工程中:

addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.1.2")

你可以建立剩下的文件 (.project and .classpath) 通过以下的命令:

eclipse

打开工程, 然后点击"File > Import.." 然后选择 "General > Existing Projects into Workspace". 找到, 并且选择你的Lift project. 然后, 你就可以 使用你的Eclipse开发应用了.

如果你想看它是如何工作的, 运行 SBT 在一个单独的窗口. 键入 sbt 在任何一个Eclipse外的窗口, 然后键入如下命令:

~; container:start; container:reload /

这个命令的用法, 在 [texteditor] 这里, 但是, 如果你使用JRebel (see [jrebel]) 你只需要运行 container:start .

然后你就可以保存修改的问题, 然后点击编译, 然后重载你的浏览器, 你会看到更新.

Discussion

一个IDE最大的用途之一就是浏览源代码, 通过 cmd+click (Mac) 或者 F3 (PC). 你可以让SBT eclipse 下载Lift的源代码和Scaladoc, 你可以通过点击Lift的方法名字和class名字, 查看到Lift的源代码, 这会是学习Lift的一个很好的方法.

你可以通过以下命令来实现它, 在SBT运行 eclipse with-source=true , 如果你想把它设置成默认,你需要在 build.sbt 文件里添加:

EclipseKeys.withSource := true

如果你发现你经常使用一些插件, 你也许会希望在全局下声明它, 这样你可以使用它在所有的工程里. 你可以通过以下命令实现它: 建立一个问题 ~/.sbt/plugins/plugins.sbt 包含以下信息:

resolvers += Classpaths.typesafeResolver

addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.1.2")

请注意 resolversaddSbtPlugin 之间的空格. 在 .sbt 文件里, 每条语句之间必须有一个空行.

设置全局配置 (比如说 withSource) 在 ~/.sbt/global.sbt.

See Also

还有很多sbteclipse上有用的设置, 你可以在这里找到: https://github.com/typesafehub/sbteclipse/wiki. 你也可以在这里找到更新的插件.

使用IntelliJ IDEA开发

Problem

你想要开发Lift应用在 IntelliJ IDEA 环境下.

Solution

你需要IntelliJ的Scala插件, 和SBT插件去生成一个IDEA的工程.

你只需要安装一次IntelliJ的插件, 以下的步骤是对于 IntelliJ IDEA 12的. 安装的细节也许对于不同版本是不同的, 但是大体的思想是下载和使用Scala的插件.

在 IntelliJ 的 "Welcome to Intellij IDEA" 界面, 选择 "Configure" 然后选择 "Plugins". 算泽 "Browse repositories…". 在搜索中, 右上角, 键入 "Scala". 你会发现只有几个会匹配结果: 选择 "Scala". 在右边, 你会看到发行的信息为 "Plugin for Scala language support" 和公司名称为 JetBrains Inc. 选择 "Download and Install" (或者右键点击,下载).用 "Close" 关闭窗口, 然后点击 OK 推出. IntelliJ将会让你重新启动.

当你设置完成后, 你将需要在你的工程里,添加一个SBT的插件, 添加以下命令到 projects/plugins.sbt:

addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.3.0")

打开SBT, 在SBT控制台里输入:

gen-idea

这个命令会生成 .idea.iml IntelliJ需要的文件. 在IntelliJ中, 你可以直接用以下方式打开工程 "File" 目录, 点 "Open…" 然后选择你的工程.

如果你想看它是如何工作的, 运行 SBT 在一个单独的窗口. 键入 sbt 在任何一个IntelliJ外的窗口, 然后键入如下命令:

~; container:start; container:reload /

这个命令的用法, 在 [texteditor] 这里, 但是, 如果你使用JRebel (see [jrebel]) 你只需要运行 container:start .

每次你编译工程的时候, 容器会自动找到改变的文件, 你在浏览器重载的时候, 你会看到更新.

Discussion

默认情况下, gen-idea 命令会下载依赖库的源码. 这意味着, 你可以直接点击方法名字或者class名字查看Lift的源码.

如果你想使用最新版本的插件, 你需要添加以下命令到 plugin.sbt:

resolvers += "Sonatype snapshots" at "http://oss.sonatype.org/content/repositories/snapshots/"

如果你想把它设置成全局命令, 请参考 [eclipse].

See Also

sbt-idea 插件在 https://github.com/mpeltonen/sbt-idea 没有教你如何配置. 你可以看 notes 文件夹下的文档来作为配置的参考.

JetBrains 有一个讲述 Scala plugin 的blog: http://blog.jetbrains.com/scala/.

查看lift_proto H2 Database

Problem

你使用默认的 lift_proto.db H2 database, 你想查看里面的tables.

Solution

使用 H2 集成的默认web接口. 请参考以下步骤:

  • 找到 H2 JAR 文件. 对于我来说, 它在: ~/.ivy2/cache/com.h2database/h2/jars/h2-1.2.147.jar.

  • 打开一个命令行, 然后用以下命令打开JAR文件: java -cp /path/to/h2-version.jar org.h2.tools.Server

  • 它会打开你的浏览器, 并且让你登入.

  • 选择 "Generic H2 Server" 在 "Saved Settings".

  • 输入 jdbc:h2:/path/to/youapp/lift_proto.db;AUTO_SERVER=TRUE 在 "JDBC URL", 你可以调整你数据库的URL和名字, 默认是("lift_proto.db") .

  • 点击 "Connect" 查看你当前的数据库.

Discussion

默认的Lift工程包含一个数据库, 比如 lift_basic, 使用的是 H2 relational database, 因为它可以不需要特别的配置, 只需要添加依赖库就可以使用. 它是一个非常不错的工程, 尽管生产环境下, 一般使用独立的数据库, 比如 PostgreSQL 或者 MySQL.

尽管你部署的时候, 也许使用的是非H2数据库, 但是我们希望你能保留H2, 因为它的 in-memory 功能很适合测试, 它不会把文件存在本地, 你可以直接删除它, 当你不需要再测试.

如果你不喜欢web接口, 你可以使用它的连接设置, 连接其他的SQL数据库工具.

See Also

H2数据库的特性: http://www.h2database.com.

如果你经常使用命令行, 请考虑确保它能访问. Diego Medina 在他的博客中描述了如何操作 https://fmpwizard.telegr.am/blog/lift-and-h2.

Lift的例子工程使用 [Squeryl] 里开启了H2的命令行. 源代码在: https://github.com/LiftCookbook/cookbook_squeryl.

使用最新的Lift

Problem

你想使用 ("snapshot") 版本的 Lift.

Solution

你需要改变两个地方在 build.sbt 文件. 首先, 添加依赖库:

resolvers += "snapshots" at "http://oss.sonatype.org/content/repositories/snapshots"

然后, 修改 liftVersion 的参数. 比如说, 使用 2.5-SNAPSHOT 版本的 Lift:

val liftVersion = "2.5-SNAPSHOT"

重启 SBT (或者使用 reload 命令) 会触发下载.

Discussion

生产版本的Lift (e.g., "2.4", "2.5"),和 milestone releases (e.g., "2.5-M3") 和 release candidates (e.g., "2.5-RC1") 被发布在一个依赖库目录下. 所以当SBT下载他们的时候, 只需要下载一次.

Snapshot releases 是不同的: 他们是自动生成的, 并且经常改变. 你可以让SBT强制 使用他们通过 clean 然后 update.

See Also

使用新版本Scala

Problem

当一个新版本的Scala被释放出后, 你想第一时间使用它在你的Lift工程.

Solution

你会找到最新的snapshot版本的lift使用最新版本的Scala. 不过, 也许你更希望是等待一个稳定版本. 提供最新版本的Scala是 binary compatible. 不过你可以通过修改Build文件, 强制使用最新版本.

比如说, 假设你的build.sbt 文件 设置的是使用 Lift 2.5 和 Scala 2.9.1:

scalaVersion := "2.9.1"

libraryDependencies ++= {
  val liftVersion = "2.5"
  Seq(
    "net.liftweb" %% "lift-webkit" % liftVersion % "compile->default"
  )
}

假设你现在想使用Scala 2.9.2 但是 Lift 2.5 只支持 Scala 2.9.1. 替换 %%% 在 `net.liftweb`上, 可以强制使用新的版本:

scalaVersion := "2.9.2"

libraryDependencies ++= {
  val liftVersion = "2.5"
  Seq(
    "net.liftweb" % "lift-webkit_2.9.1" % liftVersion % "compile->default"
  )
}

这里我们改变的是 scalaVersion, 变成一个新的版本, 但是明确的说,我们希望使用 2.9.1 Scala 版本. 如果两个版本的Scala是 binary compatible 那么这个是有效的.

Discussion

依赖库通常都有自己的命名方法. 比如说, lift-webkit 库, 对于 Lift 2.5-RC2 被叫做 lift-webkit_2.9.1-2.5-RC2.jar. 一般来说, 在 build.sbt`我们简单的使用 `"net.liftweb" %% "lift-webkit" , SBT会把他变成文件的名字, 然后下载它.

然而, 在这里我们强制SBT下载, 使用Scala的 2.9.1 version, 而不是让它自己计算下载文件的名字. 这就是, 在一个依赖库上, 使用 %%% 的区别: 使用 %% 你不需要制定Scala的版本. SBT会自动的添加 scalaVersion 到文件名上; 当使用 % SBT将不会自动添加, 所以我们必须自己添加更精确的库的名字.

请记住, 这只是能使用在Scala小版本上: 每个大版本的释放, 都会打破兼容性. 比如说 Scala 2.9.1 兼容 Scala 2.9.0, 但是不兼容 2.10.

See Also

Scala二进制兼容性, 在Scala用户的mailing list: http://article.gmane.org/gmane.comp.lang.scala.user/39290.

[snapshot] 讲述了如何使用snapshot版本的Lift.

HTML

生成HTML文件, 通常是一个web应用的主要组件. 这章主要介绍Lift的 View First 设计模式 以及如何使用 CSS Selectors.后几章主要专注于表格的处理, REST web服务, JavaScript, Ajax 和 Comet.

你可以在这里找到本章的Code: https://github.com/LiftCookbook/cookbook_html.

测试一个 CSS Selecltor

Problem

你想互动地, 探索和debug CSS selectors.

Solution

你可以使用Scala REPL来运行你的 CSS selectors.

这里有一个我们测试 CSS selector 的例子,通过添加一个 href 属性到一个链接中. 在运行的SBT控制台中输入 `console`命令, 打开REPL:

> console
[info] Starting scala interpreter...
[info]
Welcome to Scala version 2.9.1.final
Type in expressions to have them evaluated.
Type :help for more information.
scala> import net.liftweb.util.Helpers._
import net.liftweb.util.Helpers._

scala> val f = "a [href]" #> "http://example.org"
f: net.liftweb.util.CssSel =
  (Full(a [href]), Full(ElemSelector(a,Full(AttrSubNode(href)))))

scala> val in = <a>click me</a>
in: scala.xml.Elem = <a>click me</a>

scala> f(in)
res0: scala.xml.NodeSeq =
  NodeSeq(<a href="http://example.org">click me</a>)

Helpers._ 载入了 CSS Selector 的功能, 我們可以通过创建一个新的selector去测试它, 你可以让它调用一个简单的模版, 然后观察结果.

Discussion

CSS selector transforms 是Lift与其他架构不同的地方之一. 它简单的声明一个html node (左手边的部分) 然后取代它(右手边的方程). 学习它需要很长时间, 所以我们推荐使用REPL这种更直观的方法.

你需要知道 CSS selectors 中的Lift snippet是一个方法: 输入一个 NodeSeq 然后, 返回一个 NodeSeq, 通过方法 bind. Lift将会使用你的HTML模版, 作为输入的 NodeSeq, 通过方程的转化, 返回一个新的 NodeSeq. 虽然你不会看到bind方程(已经被#>替代), 但是它使用的概念是一样的.

Lift中的CSS selector 功能有一个方法 CssSel, 他是一个 NodeSeq => NodeSeq. 在上一个例子中, 我们利用它去建立一个输入的 NodeSeq (名为 in), 然后建立一个CSS方程 (叫做 f). 因为我们知道CssSel 是一个为`NodeSeq ⇒ NodeSeq`的方法, 所以最直接使用 selector 的方法是提供一个名为 in 的参数, 然后它会返回我们需要的结果, 为 res0.

如果你的IDE支持 worksheet, Eclipse 和 IntelliJ IDEA 都支持, 你可以在worksheet中查看结果.

See Also

selector的语法在这里有更好的解释: Simply Lift at http://simply.liftweb.net/.

连续的CSS Selector操作

Problem

你希望你的 CSS selector binding 使用在另一个binding 表达式前.

Solution

使用 andThen 而不是 & 在你的 selector 表达式上.

比如说, 我们希望替换 <div id="foo"/><div id="bar">bar content</div>, 但是因为一些原因, 我们需要生成 bar div 作为一个独立的过程在 selector 表达式中:

sbt> console
[info] Starting scala interpreter...
[info]
Welcome to Scala version 2.9.1.final (Java 1.7.0_05).
Type in expressions to have them evaluated.
Type :help for more information.
scala> import net.liftweb.util.Helpers._
import net.liftweb.util.Helpers._

scala> def render = "#foo" #> <div id="bar"/> andThen "#bar *" #> "bar content"
render: scala.xml.NodeSeq => scala.xml.NodeSeq

scala> render(<div id="foo"/>)
res0: scala.xml.NodeSeq = NodeSeq(<div id="bar">bar content</div>)

Discussion

当你使用 & 时, 想象 CSS selectors 永远是使用你原本的HTML模版, 不管你用的是什么样的表达式. 这是因为 & 把所有的表达式集合起来, 然后一起使用他们; 反之 andThen 是把Scala方法放在一起, 然后一个一个的调用, 位于后边的方法会等待前边的方法调用完成后, 才调用.

如果在上面的例子中, 我们替换 andThen&:

scala> def render = "#foo" #> <div id="bar" /> & "#bar *" #> "bar content"
render: net.liftweb.util.CssSel

scala> render(<div id="foo"/>)
res1: scala.xml.NodeSeq = NodeSeq(<div id="bar"></div>)

第二个表达式将不会被匹配, 因为它使用第一个表达式原先的HTML, <div id="foo"/>.所以选择器 #bar 找不到匹配的对象, 返回的 render 没有任何结果.

See Also

Lift Wiki 里的 CSS Selectors 也叙述了如何使用 andThen: https://www.assembla.com/spaces/liftweb/wiki/Binding_via_CSS_Selectors.

设置Meta Tag

Problem

你想在Lift中添加Meta tag在你的HTML文件.

Solution

使用 @ CSS binding name selector. 比如:

<meta name="keywords" content="words, here, please" />

下面的代码会更新HTLM的meta的值:

"@keywords [content]" #> "words, we, really, want"

Discussion

@ selector 选择包含给于名称的所有的元素. 它在这里用来改变 <meta name="keyword"> tag, 不过你也会看到它被用在其他地方. 比如说,在一个HTML表格中, 你可以选择 <input name="address"> 通过使用 "@address".

[content] 部分是一个关于 replacement rule 的例子, 它是一个在selector后的选择. 在这个例子中, 它用来替换一个名为 "content" 的元素的特定属性的值(这里指得是, keywords). 如果meta tag 没有 "content" 属性, 它将会被添加.

还有另外两个有用的替换法则,用来修改属性:

  • [content!] — 删除一个特定属性的值.

  • [content+] — 添加一个值.

以下是使用的例子:

scala> import net.liftweb.util.Helpers._
import net.liftweb.util.Helpers._

scala> val in = <meta name="keywords" content="words, here, please" />
in: scala.xml.Elem = <meta name="keywords" content="words, here, please"></meta>

scala> val remove = "@keywords [content!]" #> "words, here, please"
remove: net.liftweb.util.CssSel = CssBind(Full(@keywords [content!]),
  Full(NameSelector(keywords,Full(AttrRemoveSubNode(content)))))

scala> remove(in)
res0: scala.xml.NodeSeq = NodeSeq(<meta name="keywords"></meta>)

…和…

scala> val add = "@keywords [content+]" #> ", thank you"
add: net.liftweb.util.CssSel = CssBind(Full(@keywords [content+]),
  Full(NameSelector(keywords,Full(AttrAppendSubNode(content)))))

scala> add(in)
res1: scala.xml.NodeSeq = NodeSeq(<meta content="words, here, please, thank you"
  name="keywords"></meta>)
Appending to a class Attribute

尽管没有与 meta tags 的直接联系, 你应该知道这是一种, 添加属性和值的更简单的方法. 如果属性是一个 class, 一个空格会自动添加到你的classs值之间. 下面是一个示范, 它添加一个 "btn-primary" 属性到一个 div node:

scala> def render = "div [class+]" #> "btn-primary"
render: net.liftweb.util.CssSel

scala> render(<div class="btn"/>)
res0: scala.xml.NodeSeq = NodeSeq(<div class="btn btn-primary"></div>)

See Also

这里有对selector的语法更好的说明: Simply Lift at http://simply.liftweb.net/.

在这里 [TestingAndDebuggingSelectors], 你可以找到如何运行selector在REPL下.

设置Title

Problem

你想在Lift中设置页面的 <title>.

Solution

选择所有包含 title 的元素, 然后替换成你想要的文本:

"title *" #> "I am different"

假设, 你有一个 <title> tag 在你的HTML模版中, 上面的code有如下结果:

<title>I am different</title>

Discussion

这个例子用了一个element selector, 它会找到HTML模版中的tags, 然后替换成你想要的.

另一个可供选择的是, 你也可以在 SiteMap 设置你页面的title, 这意味着, 你可以给你所有的页面自由分配一个title在sitemap中. 你需要使用 Menu.title 在你的模版目录下:

<title data-lift="Menu.title"></title>

Menu.title 代码添加到所有现存的文本到title中. 这意味着, 下面的例子会有 "Site Title - " 在title的前边:

<title data-lift="Menu.title">Site Title - </title>

如果你需要更多的操作, 你可以在你的代码中, 绑定(bind) <title> 到一个普通的snippet里. 下面是一个使用的例子:

<title data-lift="MyTitle"></title>
object MyTitle {
  def render = <title><lift:Menu.title /> - Site Title</title>
}

See Also

https://www.assembla.com/spaces/liftweb/wiki/SiteMap 有更多关于Site Map 和 Menu 代码的示范.

HTML的注释

Problem

你想使用Internet Explorer HTML conditional comments在你的代码中.

Solution

把修饰的代码放到一个snippet中, 然后包含snippet在你的页面里.

比如说, 假设我们想使用 HTML5 Shiv (比如. HTML5 Shim) JavaScript, 为了我们可以使用HTML5元素在老版本的IE中. 以下是示例:

package code.snippet

import scala.xml.Unparsed

object Html5Shiv {
  def render = Unparsed("""<!--[if lt IE 9]>
    <script src="http://html5shim.googlecode.com/svn/trunk/html5.js">
    </script><![endif]-->""")
}

然后我们引用这段代码在 <head> 标签中, 也可以放进模版 `templates-hidden/default.html`中:

<script data-lift="Html5Shiv"></script>

Discussion

Lift中的HTML5解析不包含已经生成了的页面的comments. 如果你只是想粘贴 html5shim 修饰到你的模版中, 你会发现, 你的页面会找不到它.

我们通过解析未修饰的模版去解决这个问题. 如果你看到 未解析 然后松了一口气, 你的直觉是对的. 一般情况下, Lift会使用已经解析好的了模版然后套用代码, 但是在这里例子中, 我们希望的是一段未解析的 XML 内容(the comment tag) 并且输出它.

如果你发现, 你经常使用 IE conditional comments, 你需要建立一个更普遍的snippet. 比如说:

package code.snippet

import xml.{NodeSeq, Unparsed}
import net.liftweb.http.S

object IEOnly {

  private def condition : String =
    S.attr("cond") openOr "IE"

  def render(ns: NodeSeq) : NodeSeq =
    Unparsed("<!--[if " + condition + "]>") ++ ns ++ Unparsed("<![endif]-->")
}

它将使用在如下…

<div data-lift="IEOnly">
  A div just for IE
</div>

…然后生成以下输入:

<!--[if IE]><div>
  A div just for IE
</div><![endif]-->

请注意 condition 测试默认是对"IE", 但是首先, 请看属性 "cond". 它允许你写入:

<div data-lift="IEOnly?cond=lt+IE+9">
  You're using IE 8 or earlier
</div>

+ 符号用来进行 URL 编码, 作为一个空格, 结果:

<!--[if lt IE 9]><div>
  You're using IE 8 or earlier
</div><![endif]-->

See Also

IEOnly 的例子是 Antonio Salazar Cardozo 在有Mail List上提出的: https://groups.google.com/d/msg/liftweb/kLzcJwfIqHQ/K91MdtoNz0MJ.

Html5Shim可以在找到: http://code.google.com/p/html5shim/.

返回一个没有改变的makeup

Problem

你想一个snippet返回它修饰前的代码.

Solution

使用 PassThru .

比如说, 假设, 在某下条件达成下, 你有一个执行一些转化的snippet, 但是在没达成下, 你希望返回原先的html markup.

从原先的markup开始…

<h2>Pass Thru Example</h2>

<p>There's a 50:50 chance of seeing "Try again" or "Congratulations!":</p>

<div data-lift="PassThruSnippet">
  Try again - this is the template content.
</div>

…我们可以通过以下snippet, 保留它, 或者修改它:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.util.PassThru

import scala.util.Random
import xml.Text

class PassThruSnippet {

  private def fiftyFifty = Random.nextBoolean

  def render =
    if (fiftyFifty) "*" #> Text("Congratulations! The content was changed")
    else PassThru

}

Discussion

PassThru 是一个 identity function, 它的类型为 NodeSeq => NodeSeq. 它返回它输入的NodeSeq:

object PassThru extends Function1[NodeSeq, NodeSeq] {
  def apply(in: NodeSeq): NodeSeq = in
}

一个相关的例子未 ClearNodes :

object ClearNodes extends Function1[NodeSeq, NodeSeq] {
  def apply(in: NodeSeq): NodeSeq = NodeSeq.Empty
}

转化一个 NodeSeq 到另一个 NodeSeq 是非常简单的, 但是它能强大到让你肆意的更改, 重写NodeSeq.

使用HTML5, 出现没找到Snippet

Problem

你使用Lift 和 HTML5解析, 你的snippet之一发生了 "Class Not Found" 错误. 它发生在 <lift:HelloWorld.howdy />.

Solution

请使用新的 designer-friendly 机制. 例如:,

<div data-lift="HellowWorld.howdy">...</div>

Discussion

HTML5解析和原始的Lift XHTML解析有很多不同. 事实上, HTML5解析转化所有的元素和属性到小写字母. 这意味着, Lift看到 <lift:HelloWorld.howdy /> 然后查找 "helloworld" 而不是 "HelloWorld", 这会造成一个 "Class Not Found Error".

通过使用 designer-friendly 机制, 这个问题可以解决, 并且你还免费的得到了验证HTML文件的功能.

在这里, 我们使用HTML5 解析, 它被设置在 Boot.scala:

// Use HTML5 for rendering
LiftRules.htmlProperties.default.set( (r: Req) =>
  new Html5Properties(r.userAgent) )

See Also

在 XHTML 和 HTML5 解析中最大的不同, 请看: https://groups.google.com/d/msg/liftweb/H-xe1uRLW1c/B60UH8P54VAJ.

避免 CSS 和 JavaScript缓存

Problem

你正在修改你應用的 CSS 或者 JavaScript, 但是浏览器缓存了你应用以前的版本. 你想停止缓存.

Solution

添加 with-resource-id data-lift 属性到你的脚本中,或者链接中:

<script data-lift="with-resource-id" src="/myscript.js"
 type="text/javascript"></script>

通过使用这个属性, Lift将会添加 "resource id" 到你的 src (或者 href), 每次Lift重新启动, 它将生成一个单独的id, 这确保了浏览器不缓存它.

生成的HTML, 如下:

<script src="/myscript.js?F619732897824GUCAAN=_"
  type="text/javascript" ></script>

Discussion

当你的Lift重启的时候, 一个随机的值会添加到你的资源后. 这意味着每次你更新时, 你将会确保看到新的内容.

如果你需要使用 with-resource-id 在别的地方, 你可以配置一个 String => StringLiftRules.attachResourceId. 默认的实现,就像上边的 "/myscript.js" 一样, 然后它会返回你资源的名字, 并且附加一个id.

你也可以包含很多的tags, 通过使用<lift:with-resource-id>...<lift:with-resource-id> 语句. 然而, 不要在 <head> 里做这些, 因为HTML5解析会把head以外的tag删除.

请注意, 一些代理默认情况下, 不会给有id的文件, 做任何缓存. 如果这个影响到你, 你可以写一段代码, 将自动生成的id移出文件名, 放到文件路径下.

这里有一个例子教你如何实现. 与其生成一个有id后缀的文件 /assets/style.css?F61973, 我们将生成 /cache/F61973/assets/style.css. 我们将告诉Lift, 使用新的格式, 并且找到正确的文件. 以下是代码:

package code.lib

import net.liftweb.util._
import net.liftweb.http._

object CustomResourceId {

 def init() : Unit = {
  // The random number we're using to avoid caching
  val resourceId = Helpers.nextFuncName

  // Prefix with-resource-id links with "/cache/{resouceId}"
  LiftRules.attachResourceId = (path: String) => {
   "/cache/" + resourceId + path
  }

  // Remove the cache/{resourceId} from the request if there is one
  LiftRules.statelessRewrite.prepend( NamedPF("BrowserCacheAssist") {
   case RewriteRequest(ParsePath("cache" :: id :: file, suffix, _, _), _, _) =>
    RewriteResponse(file, suffix)
  })

 }
}

这些代码是在 Boot.scala 中的…

CustomResourceId.init()

通过使用上面的代码, 我们能, 比如说, 修改 templates-hidden/default.html 并且添加一个源码id到Jquery, 如下:

<script id="jquery" data-lift="with-resource-id"
  src="/classpath/jquery.js" type="text/javascript"></script>

上面的代码会生成以下HTML:

<script type="text/javascript" id="jquery"
  src="/cache/F352555437877UHCNRW/classpath/jquery.js"></script>

大多数操作都在 statelessRewrite 中, 它在Lift的底层处理. 它包含两个部分:

  • 一个 RewriteRequest 我们用来做匹配; 和

  • 一个 RewriteResponse 我们希望匹配的结果.

首先请看 RewriteRequest , 它需要三个参数: 路径, 方法 (比如说, GetRequest, PutRequest, 等等) 和 HTTPRequest 它自己. 在路径中, 我们希望找到匹配开始为 "cache"的语句, 并且后边跟着一些东西(是什么,我们并不在意), 然后其他的不分, 代表了名字 file. 在这个情况下, 我们重写原先的路径, 就是 filesuffix, 然后删除/cache/F352555437877UHCNRW 部分. 这就是Lift如何做的.

See Also

源码关于 LiftRules 展示了`attachResourceId`的默认的实现 : https://github.com/lift/framework/blob/master/web/webkit/src/main/scala/net/liftweb/http/LiftRules.scala.

Google的 Optimize caching 是一个很好的学习浏览器行为的资源: https://developers.google.com/speed/docs/best-practices/caching.

你可以学到更多关于 URL 重写在Lift wiki: https://www.assembla.com/spaces/liftweb/wiki/URL_Rewriting.

添加语句到Head中

Problem

你的代码使用了一个模版, 不过你想在其中特定的页面添加 <head>.

Solution

使用 head snippet 或者 CSS class 才能让Lift整合你页面的 <head>.比如说, 假设你有以下HTML在 templates-hidden/default.html:

<html lang="en" xmlns:lift="http://liftweb.net/">
  <head>
    <meta charset="utf-8"></meta>
    <title data-lift="Menu.title">App: </title>
    <script id="jquery" src="/classpath/jquery.js"
      type="text/javascript"></script>
    <script id="json" src="/classpath/json.js"
      type="text/javascript"></script>
 </head>
 <body>
     <div id="content">The main content will get bound here</div>
 </body>
</html>

假设你有 index.html, 而且你想添加 red-titles.css 去只改变这个页面的CSS.

你需要做的是把CSS文件加入head(data-lift)到 head:

<!DOCTYPE html>
<html>
 <head>
   <title>Special</title>
 </head>
 <body data-lift-content-id="main">
  <div id="main" data-lift="surround?with=default;at=content">
    <link data-lift="head" rel="stylesheet"
       href="red-titles.css" type="text/css" />
    <h2>Hello</h2>
  </div>
 </body>
</html>

请注意, index.html 页面是一个有效的HTML5页面, 会生成有效的 <head> tag, 就像如下:

<!DOCTYPE html>
<html lang="en">
 <head>
  <meta charset="utf-8">
  <title>App:  Home</title>
  <script type="text/javascript"
    src="/classpath/jquery.js" id="jquery"></script>
  <script type="text/javascript"
    src="/classpath/json.js" id="json"></script>
  <link rel="stylesheet" href="red-titles.css" type="text/css">
 </head>
 <body>
   <div id="main">
     <h2>Hello</h2>
   </div>
  <script type="text/javascript" src="/ajax_request/liftAjax.js"></script>
  <script type="text/javascript">
  // <![CDATA[
  var lift_page = "F557573613430HI02U4";
  // ]]>
  </script>
 </body>
</html>

Discussion

如果你发现你的tag没有在 <head> 中, 请检查你是否在使用一个有效的HTML5.

你也可以使用 <lift:head>...</lift:head> 去包含很多的表达式, 然后使用<head_merge>...</head_merge> 在code中, 替代 <lift:head>.

另一个可选的方法是 class="lift:head", 可以替代 data-lift="head".

head snippet 是一个内建的 snippet, 其他和你写的snippet没有任何区别. 它是用来发布, 包含你需要的信息的<head> . 它可以是 <title>, <link>, <meta>, <style>, <script> 或者 <base> tags. 它是如何将 <head> 放入 head snippet 中加工, 并且最后插入到页面的<head> 中的? 当Lift处理你的HTML模版时, 它自动地整合所有的 <head> tags 到页面 <head> 中.

你也许会怀疑, 你是否可以用一个纯文本的 <head> 在你的模版中, 但是那将不是一个有效的HTML5文件.

还有一个 tail 命令, 也可以做相同的事情, 它把所有你想的东西, 放到body的闭合tag之前.

See Also

[JavaScriptTail] 讲述了, 如何把JavaScript放到页面的最后.

以下是一个验证器, 用来测试是不是有效的HTML5, 这会对于你的纠错有很大帮助. http://validator.w3.org/.

自定义404页面

Problem

你想使用一个自定义的 "404" (not found) 页面.

Solution

Boot.scala 添加以下:

import net.liftweb.util._
import net.liftweb.http._

LiftRules.uriNotFound.prepend(NamedPF("404handler"){
  case (req,failure) =>
    NotFoundAsTemplate(ParsePath(List("404"),"html",true,false))
})

文件 src/main/webapp/404.html 将被用来显示, 当没有找到可用的页面时.

Discussion

uriNotFound 这条Lift法则,需要返回 NotFound 为了回复ReqBox[Failure]. 它 允许你自定义response,基于特定的类型错误或者请求.

一共有三种 NotFound:

  • NotFoundAsTemplate —  用来处理关于`ParsePath`的Lift处理HTML模版的事情.

  • NotFoundAsResponse — 允许你返回一个特定的 LiftResponse.

  • NotFoundAsNode — 包裹一个 NodeSeq 并返回 404 response.

在这里例子中, 我们匹配任何 not found 的请求, 不管是请求失败, 或者页面没找到, Lift会计算 ParsePath, 并使用路径 /404.html.

如果你对, 最后的参数 true and false`还有`ParsePath 感到奇怪. 他们告诉Lift, 我们输入的路径是绝对路径, 并且在结尾没有 / . ParsePath 是一个URI 路径, 不过在这里例子中, 他们之间没有关系.

请注意, 在如果使用这种方法创建的404页面, 是不会出现在site map里的. 因为我们没有添加 404.html 文件到site map, 而且我们不需要添加因为我们创建页面是通过NotFoundAsTemplate 而不是直接发送一个请求到 /404.html. 然而, 这意味着, 如果你显示一个错误页面使用HTML模版, 并且包含Menu.builder 或者类似的, (比如 templates-hidden/default.html), 你将会看到 "No Navigation Defined". 为了解决这个问题, 你也许需要一个不同的模版在404页面上.

另一个选择是, 你可以在你的sitemap里设置404页面, 当时把他设置成hideen, 这样在 Menu.builder 就看不到:

Menu.i("404") / "404" >> Hidden

See Also

[CatchException] 这里有关于如何catch所有的异常的讲解.

另一些可以自定义的状态页面

Problem

你想自定义一个关于一个确定的HTTP状态code的页面.

Solution

使用 LiftRules.responseTransformers 去匹配response.

比如说, 假设,我们想设置一个自定义页面对于403 ("Forbidden") 状态在Lift应用中. 然后假设这个页面包含snippet, 所以他必须通过Lift进行修饰.

为了实现它, 在Boot.scala 中, 我们定义 LiftResponse 我们想生成, 然后使用一个response, 当403状态将要被Lift处理:

def my403 : Box[LiftResponse] =
  for {
    session <- S.session
    req <- S.request
    template = Templates("403" :: Nil)
    response <- session.processTemplate(template, req, req.path, 403)
  } yield response

LiftRules.responseTransformers.append {
  case resp if resp.toResponse.code == 403 => my403 openOr resp
  case resp => resp
}

当出现403的时候, 文件 src/main/webapp/403.html 将被用来显示. 其他非403的回应将无视.

Discussion

LiftRules.responseTransformers 允许你使用 LiftResponse => LiftResponse 方法去改变在HTTP处理末期的回复. 这里一个很普遍的机制: 在这个例子中, 我们只匹配一个HTTP的状态code, 但是我们能匹配任意 LiftResponse.

这章里, 我们制造了一个HTML模版,去返回response响应, 但是你也许遇到别的情况, 这时其他的response会更有用, 比如说 InMemoryResponse.

你可以把以上的例子简化为:

LiftRules.responseTransformers.append {
  case resp if resp.toResponse.code == 403 => RedirectResponse("/403.html")
  case resp => resp
}

这个例子也同样能工作, 只有一个缺点是, 当HTTP状态code发送回浏览器时, 它不是一个403code.

这里还有一个更普遍的用法, 如果你自定义很多个页面, 你需要定义你想要自定义的HTTP状态code, 建立页面对每一个code, 然后设置匹配:

LiftRules.responseTransformers.append {
  case Customised(resp) => resp
  case resp => resp
}

object Customised {

  // 这些页面将会匹配: 403.html and 500.html
  val definedPages = 403 :: 500 :: Nil

  def unapply(resp: LiftResponse) : Option[LiftResponse] =
    definedPages.find(_ == resp.toResponse.code).flatMap(toResponse)

  def toResponse(status: Int) : Box[LiftResponse] =
    for {
      session <- S.session
      req <- S.request
      template = Templates(status.toString :: Nil)
      response <- session.processTemplate(template, req, req.path, status)
  } yield response

}

我们一般喜欢用 Customised 是当我们有一个HTML文件在 src/main/webapp,当他匹配code的时候, 使用它.不过你也可以使用不同的 Templates.

如果你想测试以上的代码, 你需要添加以下代码到, Boot.scala, 它会把所有的请求发到 /secret 并且返回 403:

val Protected = If(() => false, () => ForbiddenResponse("No!"))

val entries = List(
  Menu.i("Home") / "index",
  Menu.i("secret") / "secret" >> Protected,
  // rest of your site map here...
)

如果你请求 /secret, 一个403回复会被触发, 它将会返回 403.html 模版.

Note
在Lift3 中 responseTransformers 将被改变为一个偏函数, 这意味着你需要把这个例子改成 case r => r.

See Also

[Custom404] 解释了内建的对404页面的支持.

[CatchException] 介绍了如何catch你code中的所有异常.

Notice中的超级链接

Problem

你想添加一个能点击的S.error, S.notice 或者 S.warning 信息.

Solution

添加一个 NodeSeq 有超级链接的提示:

S.error("checkPrivacyPolicy",
  <span>See our <a href="/policy">privacy policy</a></span>)

你可以把它和以下code同时使用…

<div data-lift="Msg?id=checkPrivacyPolicy"></div>

Discussion

你也许对`S.error(String)的Lift提示比用 `NodeSeq 作为参数的函数签名更熟悉, 但是 String 版本, 只是将 String 转变为 scala.xml.Text 类型的 NodeSeq.

See Also

Problem

你想点击一个按钮, 或者一个链接, 然后会触发下载, 而不是打开一个页面.

Solution

使用 SHtml.link 建立一个链接, 建立一个函数, 返回LiftResponse, 然后用 ResponseShortcutException 包装它.

比如说, 我们建立一个snippet, 它可以让用户阅览一段诗, 并且下载它. HTML模版中, 我们使用 <br> 将每段诗分开:

<h1>A poem</h1>

<div data-lift="DownloadLink">
  <blockquote>
    <span class="poem">
        <span class="line">line goes here</span> <br />
    </span>
  </blockquote>
  <a href="">download link here</a>
</div>

这段Snippet会修饰每段诗, 并且替换下载链接, 它将会回复一个下载请求,并且下载诗:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.http._
import xml.Text

class DownloadLink {

  val poem =
    "Roses are red," ::
    "Violets are blue," ::
    "Lift rocks!" ::
    "And so do you." :: Nil

  def render =
    ".poem" #> poem.map(line => ".line" #> line) &
    "a" #> downloadLink

  def downloadLink =
    SHtml.link("/notused",
      () => throw new ResponseShortcutException(poemTextFile),
      Text("Download") )

  def poemTextFile : LiftResponse =
    InMemoryResponse(
      poem.mkString("\n").getBytes("UTF-8"),
      "Content-Type" -> "text/plain; charset=utf8" ::
      "Content-Disposition" -> "attachment; filename=\"poem.txt\"" :: Nil,
      cookies=Nil, 200)
}

请注意, SHtml.link 生成一个超级链接, 并且运行一个你提供的方法.

这里的窍门是, 当一个`LiftResponse` 在 ResponseShortcutException 中, 将会暗示Lift, 这个response已经完成, 所以页面将会被记录 (而不是使用), 但是不被处理. 对于浏览器来说: 它收到了一个请求的完整回复, 并且将会修饰页面, 在这里, 我们不让它修饰页面, 而是转到下载.

Discussion

SHtml.link 的工作原理是, 生成一个URL, 它可以包含一个方法. 在一个叫做 `downloadlink`的页面, 这个下载链接像如下这样:

downloadlink?F845451240716XSXE3G=_#notused

当这样一个链接被使用时, Lift会查看方法, 并且调用它在跳转链接之前. 然而, 在这里, 我们使用了一种快捷的Lift通道, 通过throw一个异常,并且catch它. 异常会被Lift catch, 并且会封装一个response给浏览器.

这种快捷方式是通过使用: S.redirectTo 通过 ResponseShortcutException.redirect. 这个对象被定义为 shortcutResponse, 你可以使用如下的方法使用它:

import net.liftweb.http.ResponseShortcutException._

def downloadLink =
  SHtml.link("/notused",
    () => {
      S.notice("The file was downloaded")
      throw shortcutResponse(poemTextFile)
    },
    Text("Download") )

当下一次页面重载的时候, 我们通过使用 S.notice 去高亮 throw shortcutResponse , 反之,则抛出 throw new ResponseShortcutException. 在这里, 当用户下载的时候, 这个提示并不会显示, 它会在下一次提示的时候, 才显示, 比如用户跳转到其他页面. 在很多情况下, 这没有本质区别.

这章使用了Lift的stateful属性. 你可以看出来: 从查看, 跳转到诗, 到下载文件 , state的使用是多么的重要. 如果你做一个数据库的报告, 你可以直接提供一个下载文件, 从数据库中重新生成一个文件.

然而, 在另一个情况下, 你也许想避免让一个链接直接连到一个文件. 这个情况下, 你需要使用REST 服务然后返回一个 LiftResponse.

See Also

[REST] 请看基于REST的Lift服务.

[RestStreamContent] 中讨论了 `InMemoryResponse`和类似的response

对于做报告, the Apache POI project, http://poi.apache.org/, 包含了自动生成Excel文件的库;以下是OpenCSV, http://opencsv.sourceforge.net, 是一个可以自动生成 CSV 文件的库.

测试一个 Req

Problem

你想测试一个包含`Req`的方法.

Solution

提供一个request的模型MockWeb.testReq, 然后用你的test作为 testReq 的内容.

首先, 需要添加Lift的 Test Kit作为一个依赖库到build.sbt:

libraryDependencies += "net.liftweb" %% "lift-testkit" % "2.5-RC2" % "test"

为了展示如何使用 testReq 我们测试一个可以察觉Google crawler 的方法. Google设置crawlers 通过变量 "User-Agent" 在一个request的头里, 所以我们想测试的方法为:

package code.lib

import net.liftweb.http.Req

object RobotDetector {

  val botNames =
    "Googlebot" ::
    "Mediapartners-Google" ::
    "AdsBot-Google" :: Nil

  def known_?(ua: String) =
    botNames.exists(ua contains _)

  def googlebot_?(r: Req) : Boolean =
    r.header("User-Agent").exists(known_?)
}

我们有一个List的 botNames, 他们是用来让Google发送作为user agent的, 然后我们定义一个检查函数, known_?, 它用 user agent string 作为参数, 然后去查找是否有bot包含这个 user agent string.

googlebot_? 方法以 Req 对象作为参数, 然后通过它查找header. 它的结果是存在一个 Box[String] 因为有可能没找到任何header. 我们可以通过查看 Box 中是否有header, 并且它 的值符合 known_? 的条件, 来得到结果.

为了测试它, 我们建立一个user agent string, 准备一个 MockHttpServletRequest 和它的header, 然后使用Lift的 MockWeb 把一个底层的request变为Lift的Req 供我们测试:

package code.lib

import org.specs2.mutable._
import net.liftweb.mocks.MockHttpServletRequest
import net.liftweb.mockweb.MockWeb

class SingleRobotDetectorSpec extends Specification {

  "Google Bot Detector" should {

    "spot a web crawler" in {

      val userAgent = "Mozilla/5.0 (compatible; Googlebot/2.1)"

      // Mock a request with the right header:
      val http = new MockHttpServletRequest()
      http.headers = Map("User-Agent" -> List(userAgent))

      // Test with a Lift Req:
      MockWeb.testReq(http) { r =>
        RobotDetector.googlebot_?(r) must beTrue
      }
    }

  }

}

运行SBT,并且使用 "test" 命令会产生:

[info] SingleRobotDetectorSpec
[info]
[info] Google Bot Detector should
[info] + spot a web crawler
[info]
[info] Total for specification SingleRobotDetectorSpec
[info] Finished in 18 ms
[info] 1 example, 0 failure, 0 error

Discussion

尽管 MockWeb.testReq 负责处理 Req, 但是环境上的 Req 是由 MockHttpServletRequest`提供的. 如果你想设置一个request, 建立一个mock的实例, 在使用它和 `testReq 之前, 配置你想要的state.

另外关于 HTTP headers, 你可以设置cookies, content type, query parameters, the HTTP method, authentication type, 和 body. 他们有多样的配置在 body 上, 这使得你可以轻易的依据你设置的值改变content类型:

  • JValue 的 content type 为 "application/json".

  • NodeSeq 则为 "text/xml" (或者你可以提供其他类型).

  • String 则为 "text/plain" (或者你可以提供其他类型.

  • Array[Byte] 没有content type.

Data Table

在上一个例子中, 重复的设置相同的user agents会让人十分讨厌. Specs2’s Data Table 提供了一个方法, 可以让你运行不同的值在相同的test中:

package code.lib

import org.specs2._
import matcher._
import net.liftweb.mocks.MockHttpServletRequest
import net.liftweb.mockweb.MockWeb

class RobotDetectorSpec extends Specification with DataTables {

  def is = "Can detect Google robots" ^ {
    "Bot?" || "User Agent" |
    true   !! "Mozilla/5.0 (Googlebot/2.1)" |
    true   !! "Googlebot-Video/1.0" |
    true   !! "Mediapartners-Google" |
    true   !! "AdsBot-Google" |
    false  !! "Mozilla/5.0 (KHTML, like Gecko)" |> {
    (expectedResult, userAgent) => {
      val http = new MockHttpServletRequest()
      http.headers = Map("User-Agent" -> List(userAgent))
      MockWeb.testReq(http) { r =>
        RobotDetector.googlebot_?(r) must_== expectedResult
      }
     }
    }

  }

}

Test的核心内容没有改变: 我们建立了一个模型, 设置了user agent, 检查 `googlebot_?`的结果. 不同的是 Specs2 提供了一个更好的方法, 列出了不同变量的情况, 并且pipe他们到方法中.

在SBT上运行以上code的结果是:

[info] Can detect Google robots
[info] + Bot?  | User Agent
[info]   true  | Mozilla/5.0 (Googlebot/2.1)
[info]   true  | Googlebot-Video/1.0
[info]   true  | Mediapartners-Google
[info]   true  | AdsBot-Google
[info]   false | Mozilla/5.0 (KHTML, like Gecko)
[info]
[info] Total for specification RobotDetectorSpec
[info] Finished in 1 ms
[info] 1 example, 0 failure, 0 error

尽管期待的值首先显示在我们的table上, 但是这里没有任何要求让它显示在第一位.

See Also

Lift wiki里有关于这个的更多讨论, 包括使用Selenium. https://www.assembla.com/spaces/liftweb/wiki/Testing_Lift_Applications.

使用Textile

Problem

你想使用 Textile markup在你的Lift web应用中.

Solution

安装 Lift Textile module 在你的 build.sbt 文件, 你需要安装以下依赖库:

"net.liftmodules" %% "textile_2.5" % "1.3"

你可以是用这个模块去, 修饰 Textile到HTML, 通过使用 toHtml 方法.

比如说, 在添加模块后, 运行 SBT, SBT console 命令允许你直接使用这个模块:

scala> import net.liftmodules.textile._
import net.liftmodules.textile._

scala> TextileParser.toHtml("""
 | h1. Hi!
 |
 | The module in "Lift":http://www.liftweb.net for turning Textile markup
 | into HTML is pretty easy to use.
 |
 | * As you can see.
 | * In this example.
 | """)
res0: scala.xml.NodeSeq =
NodeSeq(, <h1>Hi!</h1>,
, <p>The module in <a href="http://www.liftweb.net">Lift</a> for turning Textile
  markup<br></br>into HTML is pretty easy to use.</p>,
, <ul><li> As you can see.</li>
<li> In this example.</li>
</ul>,
, )

如果我们使用PrettyPrinter, 结果会更容易懂:

scala> val pp = new PrettyPrinter(width=35, step=2)
pp: scala.xml.PrettyPrinter = scala.xml.PrettyPrinter@54c19de8

scala> pp.formatNodes(res0)
res1: String =
<h1>Hi!</h1><p>
  The module in
  <a href="http://www.liftweb.net">
    Lift
  </a>
  for turning Textile markup
  <br></br>
  into HTML is pretty easy to use.
</p><ul>
  <li> As you can see.</li>
  <li> In this example.</li>
</ul>

Discussion

使用一个Lift的模块不需要其他多余的code, 但是这里有一些我们编程的习惯: 他们通常被包装成 net.liftmodules, 但是也不是都是这样的; 他们通常依赖于某一个版本的Lift; 他们通常使用hook在 `LiftRules`中来提供一种特定的服务. 每一个人都可以建立和公开一个Lift的模块, 并且任何人都可以给现有的模块做贡献. 最后, 他们在SBT中被声明为依赖库, 然后和你使用其他依赖库一样使用.

一个依赖库的名字有两部分组成: 模块使用的Lift版本, 就像在 [ModuleVersioning] 中讲解的一样. 当我们说"版本"的时候, 我们指得是第一个可用的Lift版本. 这意味着, 这个模块将对比此版本更新的版本兼容.

images/moduleversioning.png
Figure 2. The structure of a module version.

这个结构被采用, 是因为模块有自己的发行周期, 和Lift的周期不同. 然而, 模块也基于现在Lift的功能和Lift改变自己的API在每个主要版本中, 因此, 使用Lift版本的一部分来定义模块名称是非常必要的.

See Also

并没有一个准备的对Textile的定义, 但是这里有一些关于mark up和HTML的用法的引用, 也许对你有帮助; http://redcloth.org/hobix.com/textile/.

Textile 模块的主页: https://github.com/liftmodules/textile.

对Textile的Unit test, 可以让你更好的了解使用的方法和一些有用的示例: https://github.com/liftmodules/textile/blob/master/src/test/scala/net/liftmodules/textile/TextileSpec.scala.

[modules] 介绍了如何建立一个模块.

Lift的表格处理

这章给出了使用Lift 表格的各种方法. 你会在以下地址找到更多的例子: https://github.com/LiftCookbook/cookbook_forms.

老式的表格处理

Problem

你想使用一个非AJAX的方法来处理表格数据.

Solution

使用 S.param 来解析数据, 然后生成输出.

比如说, 我们可以展示一个表格, 处理输入数据, 然后返回一个提示. HTML模版一般是HTML表格, 和一个snippet:

<form data-lift="Plain" action="/plain" method="post">
  <input type="text" name="name" placeholder="What's your name?">
  <input type="submit" value="Go" >
</form>

在这里例子中,我们可以得到 "name" 的值通过使用 S.param("name"):

package code.snippet

import net.liftweb.common.Full
import net.liftweb.http.S
import net.liftweb.util.PassThru

object Plain {

  def render = S.param("name") match {
    case Full(name) =>
      S.notice("Hello "+name)
      S.redirectTo("/plain")
    case _ =>
      PassThru
  }

}

当你的浏览器读取到数据时, 如果是第一次, 因为没有URL参数, 所以不执行任何操作. 然后你可以在name区域输入一些值, 然后提交表格. 这个行为会让Lift再次处理模版, 但是这次因为有"name"的input. 结果将时跳到另一个页面, 并显示一个提示.

Discussion

手动的从URL中提取数据, 这不是Lift推荐的, 但是有时候你需要这么做, S.param 是你取得URL参数值的一种方法.

S.param 的结果是一个 Box[String], 在上一个例子中, 我们使用匹配来处理数据. 当你处理超过一个参数的时候 S.param 是这样使用的:

def render = {
  for {
    name <- S.param("name")
    pet <- S.param("petName")
  } {
    S.notice("Hello %s and %s".format(name,pet))
    S.redirectTo("/plain")
  }

 PassThru
}

如果"name" 和 "petName" 都有值, for 里面的语句将被执行.

相关的 S 方法包括:

  • S.params(name) — 处理一个 List[String], 然后返回他们在URL参数上的值.

  • S.post_?, S.get_?-- 看request是 GET 或者 POST.

  • S.getRequestHeader(name) — 使用 Box[String] 作为header.

  • S.request — 用来访问 Box[Req], 它会给你访问一个特定信息的能力.

作为一个使用 S.request`的例子, 我们可以处理一个 `List[String] 中所有的request参数 包含 "name" :

val names = for {
  req <- S.request.toList
  paramName <- req.paramNames
  if paramName.toLowerCase contains "name"
  value <- S.param(paramName)
} yield value

请注意, 当打开 S.request 时, 我们可以访问所有丢的参数通过 paramNames 方法在 `Req`上.

Screen 和 Wizard 提供了其他可供选择的表格处理方法, 但是有时候, 你需要取得request中的值, 这时候, 你需要使用这章的内容.

See Also

Simply Lift 里面有很多表格处理的方法. 请看: http://simply.liftweb.net.

Ajax表格处理

Problem

你想处理一个表格通过Ajax, 而不是重载整个页面.

Solution

请标注你的表格为 data-lift="form.ajax" 然后, 在服务端提供一个需要运行的方法.

这里有一个可以收集我们名字的表格的例子, 它通过Ajax提交到服务端:

<form data-lift="form.ajax">
  <div data-lift="EchoForm">
    <input type="text" name="name" placeholder="What's your name?">
    <input type="submit">
  </div>
</form>

<div id="result">Your name will be echoed here</div>

以下的snippet会返回输入的名字:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.http.SHtml.{text,ajaxSubmit}
import net.liftweb.http.js.JsCmd
import net.liftweb.http.js.JsCmds.SetHtml
import xml.Text

object EchoForm extends {

  def render = {

    var name = ""

    def process() : JsCmd = SetHtml("result", Text(name))

    "@name" #> text(name, s => name = s) &
    "type=submit" #> ajaxSubmit("Click Me", process)
  }
}

当你点击按钮时, 方法 process 被调用, 将会返回 name 的值, 并替换ID为 result 的HTML元素.

请注意, 你会经常看到 s => name = s 被简写为 name = _.

Discussion

data-lift="form.ajax" 部分, 确保了Lift添加Ajax机制到表格中. 这意味着 <form> 在Lift修饰后, 会变成:

<form id="F2203365740CJME2G" action="javascript://"
  onsubmit="liftAjax.lift_ajaxHandler(
    jQuery('#'+&quot;F2203365740CJME2G&quot;).serialize(),
    null, null, &quot;javascript&quot;);return false;">
  ...
</form>

换句话说, 当表格被提交时, Lift会通过Ajax处理表格. 这意味着, 你根本不需要单独的提交按钮. 在这个例子中只有一个单一的text输入框, 当你省略了提交按钮, 你可以触发序列化通过按 return. 这回触发方法 s => name = s , 它被绑定在 data-lift="EchoForm" snippet中. 换句话说, name 变量将被赋值, 即使没有提交按钮.

添加一个提交按钮, 可以让我们处理一些行为, 当所有的输入框的方法都被执行过后.

请注意, Lift的处理是序列化表格到服务器, 执行绑定输入框上的方法, 执行提交的按钮上的方法(如果存在), 然后返回一个JavaScript到客户端.

Submit Styling

SHtml.ajaxSubmit 方法生成一个 <input type="submit"> 元素到页面. 你也许想要一个可以设计的提交按钮. 比如说, 使用 Twitter Bootstrap, 一个button上有一个icon, 比如说如下:

<button id="submit" class="btn btn-primary btn-large">
  <i class="icon-white icon-ok"></i> Submit
</button>

点击 <button> 在一个表格中会触发提交. 然而, 如果你把按钮通过 `Shtml.ajaxSubmit`绑定到, 将会丢失你的style.

为了解决这个问题, 你可以设置一个方法到一个隐藏域上. 这个方法将被调用和其他的域上的方法一样. 在我们的snippet中, 唯一改变的是CSS selector binding:

import net.liftweb.http.SHtml.hidden

"@name" #> text(name, s => name = s) &
"button *+" #> hidden(process)

他将包含一个隐藏域, 比如说….

<input type="hidden" name="F11202029628285OIEC2" value="true">

…然后当表格提交时, 隐藏域也提交, 和其他域一样, Lift将会调用绑定的方法: process.

这个例子的用法像 ajaxSubmit, 但不完全一样. 在这个例子中我们添加了一个隐藏郁在 <button>`之后, 但是你可以放在任何你觉得方便的地方. 然而, 这里有一点容易混淆: 什么时候`process 被调用? 是在调用 name 前还是后? 这个由你域的先后顺序决定. 这就是说, 在你的HTML模版中, 把按钮放在文本域前, process 方法在name绑定的方法前被掉用.

还有很多其他的方法关于这个例子. 不管哪种方法, 请都保证你的隐藏域在最后, 或者保证你的方法在一个`formGroup`:

import net.liftweb.http.SHtml.hidden
import net.liftweb.http.S

"@name" #> text(name, s => name = s) &
"button *+" #> S.formGroup(1000) { hidden(process) }

一个 formGroup 是一个而外的操控, 他可以让你的方法排序, 在这个例子中, 排序的结果是 process 被调用在group (0)之前.

Note
Lift 2.6 和 3.0 也许包含 ajaxOnSubmit, 它包含可靠的ajaxSubmit 和 灵活的隐藏域. 如果你想使用它在Lift 2.5, Antonio Salazar Cardozo 建立一个Helper, 你可以使用: https://gist.github.com/Shadowfiend/5042131.

See Also

方法调用的顺序可以参考: http://www.assembla.com/spaces/liftweb/wiki/cool_tips.

Ajax表格返回JSON

Problem

你想通过Ajax表格发送一个json到服务器.

Solution

使用 Lift的 jlift.js Javascript 和 JsonHandler 代码. 请看如下HTML, 它不是一个表格, 但是使用 jlift.js:

<div data-lift="JsonForm" >

 <!--  required for JSON forms processing -->
 <script src="/classpath/jlift.js" data-lift="tail"></script>

 <!--  placeholder script required to process the form -->
 <script id="jsonFormScript" data-lift="tail"></script>

 <div id="formToJson" name="formToJson">
  <input type="text" name="name" value="Royal Society" />
  <input type="text" name="motto" value="Nullius in verba" />
  <input type="submit" name="sb" value="go!" />
 </div>
 <div id="result"></div>
</div>

服务端可以收到json, 通过使用如下code:

package code.snippet

import net.liftweb.util._
import Helpers._

import net.liftweb.http._
import net.liftweb.http.js._
import JsCmds._

import scala.xml._

class JsonForm {

  def render =
     "#formToJson" #> ((ns:NodeSeq) => SHtml.jsonForm(jsonHandler, ns)) &
     "#jsonFormScript" #> Script(jsonHandler.jsCmd)

    object jsonHandler extends JsonHandler {

      def apply(in: Any): JsCmd = in match {
          case JsonCmd("processForm", target, params: Map[String, _], all) =>
            val name = params.getOrElse("name", "No Name")
            val motto = params.getOrElse("motto", "No Motto")
            SetHtml("result",
                Text("The motto of %s is %s".format(name,motto)) )

          case _ =>
            SetHtml("result",Text("Unknown command"))
      }

    }
}

如果你点击go按钮, 然后关键网络流, 你会看到以下信息发送到服务器:

{
  "command": "processForm",
  "params": {"name":"Royal Society","motto":"Nullius in verba"}
}

服务器端将会把results 替换为 "The motto of the Royal Society is Nullius in verba".

Discussion

这里需要注意的是:

  1. jlift.js 脚本可以使用变量的json; 并且

  2. 生成 JavaScript code (jsonHandler.jsCmd), 这些都在表格提交时会用到.

在绑定过程中, SHtml.jsonForm 使用 jsonHandler 对象来处理表格, 然后使用你的HTML模版包裹, ns, 和 <form> tag. 我们也绑定JavaScript 和 jsonFormScript 的placeholder.

当表格提交时, JsonHandler.apply 允许我们可以匹配一个input, 然后从 Map 中找到需要的值. 请注意, 这里编译的时候, 会出现 Map[String,_] 是一个 "unchecked since it is eliminated by erasure".

如果你需要一个 REST service 处理 JSON, 请考虑使用REST HELPER.

See Also

条件性的disable一个checkbox

Problem

你想添加 disabled 属性到 SHtml.checkbox, 基于你现在的点击状态.

Solution

建立一个 CSS selector 转换来添加disabled属性, 并且在你的checkbox使用这个转换. 比如, 假设你有一个checkbox:

class Likes {
  var likeTurtles = false
  def checkbox = "*" #> SHtml.checkbox(likeTurtles, likeTurtles = _ )
}

假设你想 disable 它在50%几率下:

def disabler =
 if (math.random > 0.5d) "* [disabled]" #> "disabled"
 else PassThru

def conditionallyDisabledCheckbox =
  "*" #> disabler( SHtml.checkbox(likeTurtles, likeTurtles = _ ) )

使用 lift:Likes.conditionallyDisabledCheckbox checkbox将会在一半的时间被disabled.

Discussion

命令 disabler 返回 NodeSeq=>NodeSeq , 这意味着 当我们使用它在 conditionallyDisabledCheckbox`中, 我们需要传递给它一个 `NodeSeq, 而它正是由 SHtml.checkbox 提供的.

CSS selector 的 [disabled] 不分是选择一个包含 disabled属性的元素, 然后使用 #> 替换它为右边的元素, 在这里个例子中是 "disabled".

这个组合的意思是, 一半时间 disabled属性都被设置在checkbox上, 另一半时间 NodeSeq 将没人碰, 因为 PassThru 没有改变 NodeSeq.

以上的例子中, 我们没有test, 是为了让大家看例子更清楚. 你当然可以in-line测试它, 就像在mailing list, 如下的方法一样.

See Also

使用一个选择框进行多选

Problem

你想给用户展示很多可选项在一个多选box中, 然后允许用户多选他们.

Solution

使用 SHtml.multiSelect:

class MySnippet {
  def multi = {
    case class Item(id: String, name: String)
    val inventory = Item("a", "Coffee") :: Item("b", "Milk") ::
       Item("c", "Sugar") :: Nil

     val options : List[(String,String)] =
       inventory.map(i => (i.id -> i.name))

     val default = inventory.head.id :: Nil

     "#opts *" #>
       SHtml.multiSelect(options, default, xs => println("Selected: "+xs))
  }
}

相关的HTML如下:

<div data-lift="MySnippet.multi?form=post">
  <p>What can I getcha?</p>
  <div id="opts">options go here</div>
  <input type="submit" value="Submit" />
</div>

他们会被修饰为:

<form action="/" method="post"><div>
  <p>What can I getcha?</p>
  <div id="opts">
   <select name="F25749422319ALP1BW" multiple="true">
     <option value="a" selected="selected">Coffee</option>
     <option value="b">Milk</option>
     <option value="c">Sugar</option>
   </select>
  </div>
  <input value="Submit" type="submit">
</form>

Discussion

请回忆, 一个CSS selector由很多可选的参数, 他们中每一个都有一个属性和对应的值. 请看上边的例子, 他使用inventory 的对象, 然后返回 (value,name) 一对string, 叫做 options.

multiSelect`的方法, 将会使用value (ids), 而不是 names. 当你运行上边代码的时候 u安泽 "Coffee" 和 "Milk", 你将会看到`List("a", "b").

Selected No Options

请注意, 如果没有任何选项被选, 你的方法叫不会被调用. 这个在ticket 1139中有提到过. 有一个解决方案, 是使用一个隐藏域并绑定方法. 比如说, 我们修改以上的code为stateful的snippet, 然后记录我们选择的值:

class MySnippet extends StatefulSnippet {

  def dispatch = {
    case "multi" => multi
  }

  case class Item(id: String, name: String)
  val inventory = Item("a", "Coffee") :: Item("b", "Milk") ::
    Item("c", "Sugar") :: Nil

  val options : List[(String,String)] = inventory.map(i => (i.id -> i.name))

  var current = inventory.head.id :: Nil

  def multi = "#opts *" #> (
    SHtml.hidden( () => current = Nil) ++
    SHtml.multiSelect(options, current, current = _)
  )
}

每次提交表格后, current list会被设置为你在浏览器中选择的选项. 但是请注意, 我们有一个隐藏域, 可以重置 current 为一个空List, 这意味着, 在 multiSelect 中收到的方法将不会被调用, 这意味着, 你没有选择任何东西. 这也许会很有用, 不过取决于你的应用的行为.

Type-safe Options

如果你不想使用 String 在一个option中, 你可以使用 multiSelectObj. 这个情况下, options的例子依旧提供一个text name,但是值是一个class. 同时, 默认的list将是一个class实例的list:

val options : List[(Item,String)] = inventory.map(i => (i -> i.name))
val current = inventory.head :: Nil

从数据中生成一个的方法是相似的, 但是请注意, 方法将收到一个 Item list:

"#opts *" #> SHtml.multiSelectObj(options, current,
  (xs: List[Item]) => println("Got "+xs) )
Enumerations

你可以使用 multiSelectObj 作为枚举:

object Item extends Enumeration {
  type Item = Value
  val Coffee, Milk, Sugar = Value
}

import Item._

val options : List[(Item,String)] =
  Item.values.toList.map(i => (i -> i.toString))

var current = Item.Coffee :: Nil

def multi = "#opts *" #> SHtml.multiSelectObj[Item](options, current,
  xs => println("Got "+xs) )

See Also

[SelectOptionChange] 介绍了如何触发一个服务端的行为, 当用户选择一个选项时.

Exploring Lift, Chapter 6, "Forms in Lift", http://exploring.liftweb.net/.

文件上传

Problem

你想你的snippet允许用户上传文件.

Solution

使用 FileParamHolder 在你的snippet中, 然后解析文件信息, 当表格提交时.

从设置一个表格为 "multipart=true" 开始:

<html>
<head>
  <title>File Upload</title>
  <script id="jquery" src="/classpath/jquery.js" type="text/javascript"></script>
  <script id="json" src="/classpath/json.js" type="text/javascript"></script>
</head>
<body>
<form data-lift="FileUploadSnippet?form=post;multipart=true">
   <label for="file">
     Select a file: <input id="file"></input>
   </label>
   <input type="submit" value="Submit"></input>
</form>
</body>
</html>

通过以下code绑定snippet:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.http.SHtml._
import net.liftweb.http.FileParamHolder
import net.liftweb.common.{Loggable, Full, Empty, Box}


class FileUploadSnippet extends Loggable {

  def render = {

    var upload : Box[FileParamHolder] = Empty

    def processForm() = upload match {
      case Full(FileParamHolder(_, mimeType, fileName, file)) =>
        logger.info("%s of type %s is %d bytes long" format (fileName, mimeType, file.length))

      case _ => logger.warn("No file?")
    }

    "#file" #> fileUpload(f => upload = Full(f)) &
      "type=submit" #> onSubmitUnit(processForm)
  }
}

这段代码允许你, 在提交表格时, 访问 Array[Byte] 在 `processForm`方法中.

Discussion

HTTP包含一种编码为"multipart/form-data"用来提供上传二进制代码的功能. ?form=post;multipart=true 参数是告诉HTML使用这种编码, 当HTML生成时会有如下:

<form enctype="multipart/form-data" method="post" action="/fileupload">

当浏览器提交表格时, Lift发现 "multipart/form-data" 编码然后从参数中解析文件. 这个参数时 uploadedFilesReq 对象中, 比如说:

val files : List[FileParamHolder] = S.request.map(_.uploadedFiles) openOr Nil

然而, 当我们处理只有一个文件上传的时候, 使用 SHtml.fileUpload 绑定input的 upload 变量是一种简单的实现方法. Lift会组织 f => upload = Full(f) 方法, 当一个文件被选择为上传文件的时候. 如果文件的长度是0, 方法将不会被调用.

Lift的默认行为是将浏览器中的文件读入内存, 然后作为 FileParamHolder. 在这里我们使用匹配去找到 FileParamHolder 然后print出我们知道关于文件的信息. 我们忽略了第一个参数, 它是一个Lift自动生产的名字, 并且保存这mime类型, 原始的文件名和书都存在里面.

你也许不能用这个方法上传一个很大的文件. 事实上, LiftRules 提供了一个上传文件大小的限制:

  • LiftRules.maxMimeFileSize — 最大单一上传文件 (7MB by default).

  • LiftRules.maxMimeSize — 最大多文件上传 (8MB by default)

为什么使用这两个设置? 因为当表格提交的时候, 表格里会有很多个域. 比如说, 在这个例子中, 提交按钮的值将会作为一部分, 文件会是另一部分. 因此, 你需要限制文件大小, 但是允许别的域上传, 或者多文件上传.

如果你超过了文件上传大小, 一个异常会从上传文件库中抛出. 你可以catch这个异常 [CatchException]:

LiftRules.exceptionHandler.prepend {
  case (_, _, x : FileUploadIOException) =>
    ResponseWithReason(BadResponse(), "Unable to process file. Too large?")
}

请注意, 容器 (Jetty, Tomcat) 或者其他web容器 (Apache, NGINX)都有自己的上传文件大小限制.

上传文件缓存在内存中, 也许在一些情况下是可以的, 但是你也许想上传一个大点的文件, 并且让Lift以stream来处理它们. Lift提供对这个需求的支持:

LiftRules.handleMimeFile = OnDiskFileParamHolder.apply

handleMimeFile 需要一个方法包含 name, mime type, filename 和 InputStream 然后返回一个 FileParamHolder. 默认的实现是使用 InMemFileParamHolder, 但是,当改成 OnDiskFileParamHolder`时, 意味着Lift将会首先把文件写入硬盘. 你当然也可以自己写 `OnDiskFileParamHolder 或者 InMemFileParamHolder.

当使用 OnDiskFileParamHolder, 文件会被写入一个临时空间(System.getProperty("java.io.tmpdir")) 当你想删除时, 你可以随意删除它. 比如说, 我们的例子可以改成:

def processForm() = upload match {

  case Full(content : OnDiskFileParamHolder) =>
    logger.info("File: "+content.localFile.getAbsolutePath)
    val in: InputStream = content.fileStream
    // ...do something with the stream here...
    val wasDeleted_? = content.localFile.delete()

  case _ => logger.warn("No file?")
}

请注意, OnDiskFileParamHolder 实现了 FileParamHolder 所以会匹配 FileParamHolder. 然而, 如果你想访问 OnDiskFileParamHolderfile 域, 你将会被文件缓存到内存中, 这将会和存到硬盘, 然后用stream访问冲突.

如果你想看服务器端如何处理上传文件的, 你可以使用, LiftRules 的一个hook:

def progressPrinter(bytesRead: Long, contentLength: Long, fieldIndex: Int) {
  println("Read %d of %d for %d" format (bytesRead, contentLength, fieldIndex))
}

LiftRules.progressListener = progressPrinter

这是全部 multi-part 上传过程, 不知是一个文件被上传. 事实上, contentLength 是未知的 (在这里例子中为 -1), 当时当全部完成的时候, 他将会被设置. 在这个例子中, 他会是文件的大小, 并且是提交按钮的值. 这也解释了 fieldIndex, 它是确定域被处理的顺序. 他将会是值 0 和 1 在这个例子中.

See Also

HTTP上传的文档 RFC 1867, Form-based File Upload in HTML: http://tools.ietf.org/html/rfc1867

[RestBinaryData] REST服务的文件上传.

[AjaxFileUpload] 使用JavaScript的Ajax文件上传, 包含了拖拽功能.

REST

这章是讲关于REST服务的, 通过使用 Lift 的 RestHelper trait. 作为一个介绍, 请看Lift Wiki https://www.assembla.com/spaces/liftweb/wiki/REST_Web_Services and chapter 5 of Simply Lift at http://simply.liftweb.net.

这章的代码在以下地方可以找到: https://github.com/LiftCookbook/cookbook_rest.

干净的URLs

Problem

你发现你在重复一部分URL, 在使用 `RestHelper`的时候, 你不想重复的写同一段code.

Solution

使用 prefix 在你的 RestHelper:

package code.rest

import net.liftweb.http.rest.RestHelper
import net.liftweb.http.LiftRules

object IssuesService extends RestHelper {

  def init() : Unit = {
    LiftRules.statelessDispatch.append(IssuesService)
  }

  serve("issues" / "by-state" prefix {
    case "open" :: Nil XmlGet _ => <p>None open</p>
    case "closed" :: Nil XmlGet _ => <p>None closed</p>
    case "closed" :: Nil XmlDelete _ => <p>All deleted</p>
  })

}

这个服务回复URLs, /issues/by-state/open and /issues/by-state/closed 并且, 我们使用了 prefix.

把这个连入 Boot.scala:

import code.rest.IssuesService
IssuesService.init()

我们可以测试cURL:

$ curl -H 'Content-Type: application/xml'
    http://localhost:8080/issues/by-state/open
<?xml version="1.0" encoding="UTF-8"?>
<p>None open</p>

$ curl -X DELETE -H 'Content-Type: application/xml'
    http://localhost:8080/issues/by-state/closed
<?xml version="1.0" encoding="UTF-8"?>
<p>All deleted</p>

Discussion

你有很多的 serve` 语句在你的 RestHelper, 他们构建了你的REST服务.

在这里例子中, 我们决定返回 XML 并且匹配一个 XML request, 通过使用 XmlGetXmlDelete. 对一个XML request的测试, 需要使用 text/xml 或者 application/xml 的content-type, 它是一个request以 .xml`结尾. 这就是为什么cURL使用 `-H 标签. 如果我们没有使用这个标签, 这个request将不会匹配任何条目, 然后将会返回 404 response.

See Also

[JSONREST] 给出一个接收并返回 JSON 的例子.

缺少文件后缀

Problem

你的 RestHelper 需要一个文件名作为URL的一部分, 但是suffix(扩展名)丢失了, 但是你需要他.

Solution

访问 req.path.suffix 来修复 suffix.

比如说, 当处理 /download/123.png 你想你能够重新构建 123.png:

package code.rest

import net.liftweb.http.rest.RestHelper
import net.liftweb.http.LiftRules
import xml.Text

object Reunite extends RestHelper  {

  private def reunite(name: String, suffix: String) =
    if (suffix.isEmpty) name else name+"."+suffix

  serve {
    case "download" :: file :: Nil Get req =>
      Text("You requested "+reunite(file, req.path.suffix))
  }

  def init() : Unit = {
    LiftRules.statelessDispatch.append(Reunite)
  }

}

我们匹配 download 但不是直接使用 file 的值, 我们把它放入 reunite 方法中, 让他找回后缀 (如果有后缀的话).

对这种URL, 使用cURL做请求, 你会看到:

$ curl http://127.0.0.1:8080/download/123.png
<?xml version="1.0" encoding="UTF-8"?>
You requested 123.png

Discussion

当Lift处理一个request的时候, 它将request分割成几部分(比如说, 把一个路径变成List[String]). 这里包含了后缀. 这是一个非常好的做法, 因为有时候, 你需要匹配一个后缀, 但是却出现了我们这里提到的问题.

这里设置了后缀的使用 LiftRules.explicitlyParsedSuffixes, 它把一个文件名分割出后缀. 它包含很多带逗号的后缀, (比如说 "png", "atom", "json")还有一些你也许不想要, 比如说 "com".

请注意, 如果后缀没有在 explicitlyParsedSuffixes 中, 后缀将是一个空string, 并且 name (在上个例子中)会包含它的后缀.

这取决于你的需求, 你也可以设置一个保护措施, 检查后缀:

case "download" :: file :: Nil Get req if req.path.suffix == "png" =>
  Text("You requested PNG file called "+file)

或者简单的把后缀附在名字上, 有时候并不用修改后缀的. 比如说, 如果客户端支持 WebP 图像格式, 你这样发送它:

package code.rest

import net.liftweb.http.rest.RestHelper
import net.liftweb.http.LiftRules
import xml.Text

object Reunite extends RestHelper  {

  def init() : Unit = {
    LiftRules.statelessDispatch.append(Reunite)
  }

  serve {
    case "negotiate" :: file :: Nil Get req =>
      val toSend =
        if (req.header("Accept").exists(_ == "image/webp")) file+".webp"
        else file+".png"

      Text("You requested "+file+", would send "+toSend)
  }

}

通过调用这个服务, 将会在决定哪个资源发送之前, 检查HTTP Accept header:

$ curl http://localhost:8080/negotiate/123
<?xml version="1.0" encoding="UTF-8"?>
You requested 123, would send 123.png

$ curl http://localhost:8080/negotiate/123 -H "Accept: image/webp"
<?xml version="1.0" encoding="UTF-8"?>
You requested 123, would send 123.webp

See Also

[MissingDotCom] 展示了如何删除 explicitlyParsedSuffixes.

HttpHelpers.scala 的源码包含了 explicitlyParsedSuffixes list, 是一个默认的list包含所有的后缀: https://github.com/lift/framework/blob/master/core/util/src/main/scala/net/liftweb/util/HttpHelpers.scala .

Email地址中缺少.com

当我们通过REST提交一个email时, 一个域名以 ".com" 结尾, 但是在被 REST 处理前被删除.

Solution

修改 LiftRules.explicitlyParsedSuffixes, 这样Lift不会改变后缀 ".com".

Boot.scala:

import net.liftweb.util.Helpers
LiftRules.explicitlyParsedSuffixes = Helpers.knownSuffixes &~ (Set("com"))

Discussion

默认情况下, Lift会捕获文件的后缀, 这是为了匹配更方便: 也许你需要匹配 ".xml" or ".pdf". 然而, ".com" 是其中之一, 但是这会造成它的丢失.

请注意, 这个不会影响在URL中间的email. 比如说, 请看如下的REST服务:

package code.rest

import net.liftweb.http.rest.RestHelper
import net.liftweb.http.LiftRules
import xml.Text

object Suffix extends RestHelper {

  def init() : Unit = {
    LiftRules.statelessDispatch.append(Suffix)
  }

  serve {
    case "email" :: e :: "send" :: Nil Get req =>
      Text("In middle: "+e)

    case "email" :: e :: Nil Get req =>
      Text("At end: "+e)
  }

}

在这个服务中, init 方法在 Boot.scala 中被调用, 我们可以通过request看发生的问题:

$ curl http://localhost:8080/email/you@example.com/send
<?xml version="1.0" encoding="UTF-8"?>
In middle: you@example.com

$ curl http://localhost:8080/email/you@example.com
<?xml version="1.0" encoding="UTF-8"?>
At end: you@example

".com" 被认为是一个文件的后缀, 这就是为什么从后缀List中删除它会解决问题.

请注意, 因为他是一个顶级域名, 比如说 ".uk", ".nl", ".gov", 不再 explicitlyParsedSuffixes 中, 这里的Email将不会被修改.

See Also

[MissingSuffix] 中介绍了更多关于后缀的介绍.

无法匹配前缀

Problem

你尝试匹配一个后缀, 但是匹配失败.

Solution

确保你匹配的后缀在 LiftRules.explicitlyParsedSuffixes.

比如说, 假设你想匹配所有的问题以.csv 结尾, 在 /reports/ URL中:

case Req("reports" :: name :: Nil, "csv", GetRequest) =>
  Text("Here's your CSV report for "+name)

你希望的到的是让 /reports/foo.csv 返回 "Here’s your CSV report for foo", 但是你得到的是 404.

为了解决这个问题, 包含 "csv" 作为一个Lift的后缀, 让Lift知道如何分离它. 在 Boot.scala 中添加:

LiftRules.explicitlyParsedSuffixes += "csv"

然后这个匹配就可以使用了.

Discussion

如果不添加 ".csv" 到 explicitlyParsedSuffixes, URL将会使用:

case Req("reports" :: name :: Nil, "", GetRequest) =>
  Text("Here's your CSV report for "+name)

在这里我们匹配一个 (""). 在这里例子中, name 被设置为 "foo.csv". 这是因为Lift分割一个后缀, 只能分割在 explicitlyParsedSuffixes`中的. 因为默认下, `csv 并不在, "foo.csv" 将不会被分割. 这就是为什么 csv 在后缀匹配的时候, 不会被匹配.

See Also

[MissingSuffix] 解释了更多关于Lift的后缀缺失.

在REST服务中接收二进制数据

Problem

你想让用户上传一个图片, 或者二进制数据通过RESTFUL服务.

Solution

通过访问request的body:

package code.rest

import net.liftweb.http.rest.RestHelper
import net.liftweb.http.LiftRules

object Upload extends RestHelper {

  def init() : Unit = {
    LiftRules.statelessDispatch.append(Upload)
  }

  serve {
    case "upload" :: Nil Post req =>
      for {
        bodyBytes <- req.body
      } yield <info>Received {bodyBytes.length} bytes</info>
  }

}

把这个连入你的Boot.scala:

import code.rest.Upload
Upload.init()

你可以使用cURL来测试:

$ curl -X POST --data-binary "@dog.jpg" -H 'Content-Type: image/jpg'
    http://127.0.0.1:8080/upload
<?xml version="1.0" encoding="UTF-8"?>
<info>Received 1365418 bytes</info>

Discussion

在上边的例子中, 访问二进制数据通过req.body, 并且返回一个 Box[Array[Byte]]. 我们把它转换为 Box[Elem] 并发回客户端. 在 RestHelper 里, 会转为 XmlResponse 以供Lift处理.

请注意, 作为web容器, 比如说 Jetty 和 Tomcat, 会有上传限制. 当你看到以下错误的时候 "java.lang.IllegalStateException: Form too large705784>200000" 你就会知道上限是什么了. 你也可以通过查看文档来了解上限.

为了限制上传文件的类型, 你添加一个条件去匹配文件, 但是你会发现, 一个更简单的方法是, 你可以使用对象的 unapply . 比如说, 只允许上传JPEG文件:

serve {
  case "jpg" :: Nil Post JPeg(req) =>
    for {
      bodyBytes <- req.body
    } yield <info>Jpeg Received {bodyBytes.length} bytes</info>
  }

object JPeg {
  def unapply(req: Req): Option[Req] =
    req.contentType.filter(_ == "image/jpg").map(_ => req)
}

我们定义了一个过滤器, 它将解析 JPeg , 如果文件类型为 "image/jpg", 返回一个 Some[Req], ; 如果不是, 结果是 None. 这就是REST服务的匹配 JPeg(req). 请注意, 方法 unapply 需要返回 Option[Req] 这是 Post 解析器所需要的类型.

See Also

Odersky et al., (2008), Programming in Scala, chapter 24, 解释了什么是解析器: http://www.artima.com/pins1ed/extractors.html.

[FileUpload] 解释了多文件上传

返回JSON

Problem

你想返回一个JSON当使用REST Call.

Solution

使用 Lift JSON domain specific language (DSL). 比如说:

package code.rest

import net.liftweb.http.rest.RestHelper
import net.liftweb.http.LiftRules
import net.liftweb.json.JsonAST._
import net.liftweb.json.JsonDSL._

object QuotationsAPI extends RestHelper {

  def init() : Unit = {
    LiftRules.statelessDispatch.append(QuotationsAPI)
  }

  serve {
    case "quotation" :: Nil JsonGet req =>
      ("text" -> "A beach house isn't just real estate. It's a state of mind.") ~
        ("by" -> "Douglas Adams") : JValue
  }

}

Wire this into Boot.scala:

import code.rest.QuotationsAPI
QuotationsAPI.init()

Running this example produces:

$ curl -H 'Content-type: text/json' http://127.0.0.1:8080/quotation
{
  "text":"A beach house isn't just real estate. It's a state of mind.",
  "by":"Douglas Adams"
}

Discussion

type ascription 在 JSON 表达式的最后 (: JValue) 告诉了编译器, 表达式希望获得的类型为 JValue. 这是使用DSL时必须的. 如果没有强制它, 比如说, 你将会调用一个名叫 JValue 的方法.

JSON DSL 允许你创建一个嵌套结构, lists 和其他你所有想做为JSON的东西.

See Also

lift-json工程的README文件是一个非常好的学习lift-json的资源: https://github.com/lift/framework/tree/master/core/json.

Google Sitemap

Problem

你想使用Lift去创建一个Google Sitemap.

Solution

建立一个sitemap的结构, 然后把它绑定在HTML模版上.

我们从 sitemap.html 在你的 webapp 文件夹开始, 它包含一个有效的XML-Sitemap:

<?xml version="1.0" encoding="utf-8" ?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <url data-lift="SitemapContent.base">
        <loc></loc>
        <changefreq>daily</changefreq>
        <priority>1.0</priority>
        <lastmod></lastmod>
    </url>
    <url data-lift="SitemapContent.list">
        <loc></loc>
        <lastmod></lastmod>
    </url>
</urlset>

创建一个snippet去填补需要的地方:

package code.snippet

import org.joda.time.DateTime
import net.liftweb.util.CssSel
import net.liftweb.http.S
import net.liftweb.util.Helpers._

class SitemapContent {

  case class Post(url: String, date: DateTime)

  lazy val entries =
    Post("/welcome", new DateTime) :: Post("/about", new DateTime) :: Nil

  val siteLastUdated = new DateTime

  def base: CssSel =
    "loc *" #> "http://%s/".format(S.hostName) &
      "lastmod *" #> siteLastUdated.toString("yyyy-MM-dd'T'HH:mm:ss.SSSZZ")

  def list: CssSel =
    "url *" #> entries.map(post =>
      "loc *" #> "http://%s%s".format(S.hostName, post.url) &
        "lastmod *" #> post.date.toString("yyyy-MM-dd'T'HH:mm:ss.SSSZZ"))

}

这个例子是, 用存储的信息在两个页面上.

使用HTML模版和snippet在一个REST服务中在URL `/sitemap`上:

package code.rest

import net.liftweb.http._
import net.liftweb.http.rest.RestHelper

object Sitemap extends RestHelper {
  serve {
    case Req("sitemap" :: Nil, _, GetRequest) =>
      XmlResponse(
        S.render(<lift:embed what="sitemap" />,
        S.request.get.request).head)
  }
}

把它连入你的 Boot.scala, 比如说:

LiftRules.statelessDispatch.append(code.rest.Sitemap)

测试服务, 通过使用cURL:

$ curl http://127.0.0.1:8080/sitemap

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <url>
        <loc>http://127.0.0.1/</loc>
        <changefreq>daily</changefreq>
        <priority>1.0</priority>
        <lastmod>2013-02-10T19:16:12.433+00:00</lastmod>
    </url>
    <url>
        <loc>http://127.0.0.1/welcome</loc>
        <lastmod>2013-02-10T19:16:12.434+00:00</lastmod>
    </url><url>
        <loc>http://127.0.0.1/about</loc>
        <lastmod>2013-02-10T19:16:12.434+00:00</lastmod>
    </url>
</urlset>

Discussion

你也许很奇怪, 为什么我们在这里使用 REST , 这里我们只有一些普通的HTML和snippet. 原因是我们想使用XML而不是HTML作为输出. 我们使用相同的机制, 但是把他作为一个 XmlResponse.

S.render 方法, 需要 NodeSeqHTTPRequst 作为参数. 第一个参数, 我们提供它通过运行sitemap.html snippet; 第二个参数我们使用现在的request. XmlResponse 需要一个 Node`而不是一个 `NodeSeq, 这就是为什么我们调用 head — 它是这里唯一的Node, 并且是我们需要的类型.

请注意, Google Sitemaps 需要日期是 ISO 8601 格式. 内奸的 java.text.SimpleDateFormat 直到Java 7都不支持这种格式. 如果你正在使用Java 6 你可以使用 org.joda.time.DateTime.

在IOS应用中调用REST服务

Problem

你想使用HTTP POST通过REST服务在IOS中.

Solution

使用 NSURLConnection 确保你的内容类型为 "application/json".

比如说, 假设我们想调用这个服务:

package code.rest

import net.liftweb.http.rest.RestHelper
import net.liftweb.json.JsonDSL._
import net.liftweb.json.JsonAST._

object Shouty extends RestHelper {

  def greet(name: String) : JValue =
    "greeting" -> ("HELLO "+name.toUpperCase)

  serve {
    case "shout" :: Nil JsonPost json->request =>
      for { JString(name) <- (json \\ "name").toOpt }
      yield greet(name)
  }

}

这个服务需要一个 JSON post 和一个名为 "name"的参数, 然后它返回一个JSON对象. 为了展示数据是如何从服务器输入, 输出, 我们可以添加以下语句到 Boot.scala

LiftRules.statelessDispatch.append(Shouty)

…and then call it from the command line:

$ curl -d '{ "name" : "Richard" }' -X POST -H 'Content-type: application/json'
   http://127.0.0.1:8080/shout
{
  "greeting":"HELLO RICHARD"
}

我们实现POST通过 NSURLConnection:

static NSString *url = @"http://localhost:8080/shout";

-(void) postAction {
  // JSON data:
  NSDictionary* dic = @{@"name": @"Richard"};
  NSData* jsonData =
    [NSJSONSerialization dataWithJSONObject:dic options:0 error:nil];
  NSMutableURLRequest *request = [
    NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]
    cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:60.0];

  // Construct HTTP request:
  [request setHTTPMethod:@"POST"];
  [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
  [request setValue:[NSString stringWithFormat:@"%d", [jsonData length]]
    forHTTPHeaderField:@"Content-Length"];
  [request setHTTPBody: jsonData];

  // Send the request:
  NSURLConnection *con = [[NSURLConnection alloc]
    initWithRequest:request delegate:self];
}

- (void)connection:(NSURLConnection *)connection
  didReceiveResponse:(NSURLResponse *)response {
   // Start off with new, empty, response data
   self.receivedJSONData = [NSMutableData data];
}

- (void)connection:(NSURLConnection *)connection
  didReceiveData:(NSData *)data {
   // append incoming data
   [self.receivedJSONData appendData:data];
}

- (void)connection:(NSURLConnection *)connection
  didFailWithError:(NSError *)error {
   NSLog(@"Error occurred ");
}

- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
  NSError *e = nil;
  NSDictionary *JSON =
    [NSJSONSerialization JSONObjectWithData: self.receivedJSONData
    options: NSJSONReadingMutableContainers error: &e];
  NSLog(@"Return result: %@", [JSON objectForKey:@"greeting"]);
}

显然的, 这里我们有很多很难的code, 不过不用担心, 他们只是你的一个你应用的开始点.

Discussion

做HTTP POST在IOS中有很多方法, 并且很难判断哪种是最好的方法, 特别是没有一个很好的外部支持库. 上面的例子使用的是原生的IOS API.

另一个方法是使用 AFNetworking. 这是一个非常流行的外部库在iOS开发中, 在很多情况下, 它很有用:

#import "AFHTTPClient.h"
#import "AFNetworking.h"
#import "JSONKit.h"

static NSString *url = @"http://localhost:8080/shout";

-(void) postAction {
  // JSON data:
  NSDictionary* dic = @{@"name": @"Richard"};
  NSData* jsonData =
   [NSJSONSerialization dataWithJSONObject:dic options:0 error:nil];

  // Construct HTTP request:
  NSMutableURLRequest *request =
   [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]
    cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:60.0];
  [request setHTTPMethod:@"POST"];
  [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
  [request setValue:[NSString stringWithFormat:@"%d", [jsonData length]]
    forHTTPHeaderField:@"Content-Length"];
  [request setHTTPBody: jsonData];

  // Send the request:
  AFJSONRequestOperation *operation =
    [[AFJSONRequestOperation alloc] initWithRequest: request];
  [operation setCompletionBlockWithSuccess:^(AFHTTPRequestOperation *operation,
    id responseObject)
  {
     NSString *response = [operation responseString];

     // Use JSONKit to deserialize the response into NSDictionary
     NSDictionary *deserializedJSON = [response objectFromJSONString];
     [deserializedJSON count];

     // The response object can be a NSDicionary or a NSArray:
      if([deserializedJSON count]> 0) {
         NSLog(@"Return value: %@",[deserializedJSON objectForKey:@"greeting"]);
      }
      else {
        NSArray *deserializedJSONArray = [response objectFromJSONString];
        NSLog(@"Return array value: %@", deserializedJSONArray );
      }
  }failure:^(AFHTTPRequestOperation *operation, NSError *error)
  {
    NSLog(@"Error: %@",error);
  }];
  [operation start];
}

NSURLConnection 的做法是给你更多功能的选择, 让你的starting point去构造你自己的解决方案, 比如说, 让内容类型(content-type)更明确. 然而, AFNetworking 更流行, 所以你也许更喜欢它.

See Also

你能找到 "Complete REST Example" 在 Simply Lift. http://simply.liftweb.net/index-5.4.html.

JavaScript, Ajax, Comet

作为一个LiftAjax和Comet功能的介绍, 请参考 Simply Lift at http://simply.liftweb.net, chapter 9 of Lift in Action (Perrett, 2012, Manning Publications Co.), 或者观看 Diego Medina的演讲 https://fmpwizard.telegr.am/blog/comet-actors-presentation.

通过使用按钮触发服务端代码

Problem

你想, 当用户按某个按钮时, 触发一段服务端的代码.

Solution

使用 ajaxInvoke:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.http.SHtml
import net.liftweb.http.js.{JsCmd, JsCmds}
import net.liftweb.common.Loggable

object AjaxInvoke extends Loggable {

  def callback() : JsCmd = {
    logger.info("The button was pressed")
    JsCmds.Alert("You clicked it")
  }

  def button = "button [onclick]" #> SHtml.ajaxInvoke(callback)
}

在这个例子中, 我们绑定 ajaxInvoke 到一个按钮: 当用户按下时, Lift会执行你设置在 ajaxInvoke 中的命令.

那个`callback`方法, 只是log一个信息然后返回JavaScript报警到浏览器中. 相关的HTML代码为:

<div data-lift="AjaxInvoke.button">
  <button>Click Me</button>
</div>

Discussion

这里, 最重要的方法你传递给ajaxInvokeUnit => JsCmd, 这意味着你想触发一系列命令, 当你使用 Noop 时, 你不想任何事情发生, 或者你想改变DOM元素, 他们都是JavaScript的实现.

在上一个例子中, 你使用了一个按钮, 但是它会工作在任何你绑定的元素中. 我们绑定在 onclick 事件上, 不过你可以绑定在任何你想要的事件上.

ajaxInvoke 相关的, 有以下方法:

  • SHtml.onEvent 需要一个 String => JsCmd 作为参数, 因为它需要传递一个node的 value. 在上个例子中, 这将是一个空String, 因为一个button没有value.

  • SHtml.ajaxCall 是一个比 onEvent 更具体的方法, 你可以直接给它一个你想调用的服务端的方法.

  • SHtml.jsonCall 也是一个更具体的方法: 你给它一个能返回JSON的客户端的方法, 这个方法可以把JSON传递给服务端.

让我们分别看看他们是怎么用的.

onEvent — 接收一个DOM上的 value

你可以使用 onEvent 在任何包含 value 属性的元素上. 你提供给 onEvent 的方法, 将会被赋值, 这个值就是DOM的value. 作为一个例子, 我们可以写一个snippet, 给用户一个挑战, 然后验证是否有正确的回复:

package code.snippet

import scala.util.Random
import net.liftweb.util.Helpers._
import net.liftweb.http.SHtml
import net.liftweb.http.js.JsCmds.Alert

object OnEvent {

  def render = {
    val x, y = Random.nextInt(10)
    val sum = x + y

    "p *" #> "What is %d + %d?".format(x,y) &
    "input [onchange]" #> SHtml.onEvent( answer =>
      if (answer == sum.toString) Alert("Correct!")
      else Alert("Try again")
     )
  }

}

这个snippet让用户给两个随机数做加法,在 <p> tag中, 然后绑定验证方法在 <input>:

<div data-lift="OnEvent">
  <p>Problem appears here</p>
  <input placeholder="Type your answer"></input>
</div>

onchange 被触发 (用户按回车, 或者tab), 输入的文字会发送到 onEvent 成为一个 String.

ajaxCall — 接受任意的客户端String

onEvent 发送 this.value 到你的服务端时, ajaxCall 允许你指定一个服务端表达式, 然后传递值.

为了展示它, 我们建立一个模版, 里面包含两个元素: 一个按钮和一个文本域. 我们绑定我们的方法到按钮上, 但是从文字域里读取数值:

<div data-lift="AjaxCall">
  <input id="num" value="41"></input>
  <button>Increment</button>
</div>

我们想让按钮读取 num 域, 增加它, 然后返回到文本域:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.http.SHtml
import net.liftweb.http.js.JE.ValById
import net.liftweb.http.js.JsCmds._

object AjaxCall {

 def increment(in: String) : String =
  asInt(in).map(_ + 1).map(_.toString) openOr in

 def render = "button [onclick]" #>
   SHtml.ajaxCall(ValById("num"), s => SetValById("num", increment(s)) )

 }

ajaxCall 的第一个参数是一个表达式, 它会生成功一个我们方法需要的值. 它可以是任何的 JsExp, 我们通过使用 ValById 可以通过id查看元素. 我们可以直接调用Jquery使用以下语句 JsRaw("$('#num').val()").

ajaxCall 第二个参数是把 JsExp 表达式作为一个 String. 我们使用Lift JavaScript之一的命令去替换一个值. 新的值是增加后的值.

这个代码的结果是, 用户按按钮, 然后数值增加. It should go without saying that these are simple illustrations, and you probably don’t want a server round-trip to add one to a number. The techniques come into their own when there is some action of value to perform on the server.

You may have guessed that onEvent is implemented as an ajaxCall for JsRaw("this.value").

jsonCall — Receiving a JSON Value

不管是 ajaxCall 还是 onEvent 都最后要执行一个 String => JsCmd 方法. 理论上, jsonCall 的参数为 JValue => JsCmd, 这意味着你可以传递一个复杂的数据结构从JavaScript到你的服务端.

为了展示这个, 我们将建立一个HTML模版, 要求用户输入, 然后用一个方法把用户输入变成JSON, 然后一个按钮, 可以把JSON送到服务端:

<div data-lift="JsonCall">
  <p>Enter an addition question:</p>
  <div>
    <input id="x"> + <input id="y"> = <input id="z">.
  </div>
  <button>Check</button>
</div>

<script type="text/javascript">
// <![CDATA[
function currentQuestion() {
  return {
    first:  parseInt($('#x').val()),
    second: parseInt($('#y').val()),
    answer: parseInt($('#z').val())
  };
}
// ]]>

在服务端, 我们检查JSON是否代表着一个数值的增加:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.http.SHtml
import net.liftweb.http.js.{JsCmd, JE}
import net.liftweb.common.Loggable
import net.liftweb.json.JsonAST._
import net.liftweb.http.js.JsCmds.Alert
import net.liftweb.json.DefaultFormats

object JsonCall extends Loggable {

  implicit val formats = DefaultFormats

  case class Question(first: Int, second: Int, answer: Int) {
    def valid_? = first + second == answer
  }

  def render = {

    def validate(value: JValue) : JsCmd = {
      logger.info(value)
      value.extractOpt[Question].map(_.valid_?) match {
        case Some(true) => Alert("Looks good")
        case Some(false) => Alert("That doesn't add up")
        case None => Alert("That doesn't make sense")
      }
    }

    "button [onclick]" #>
      SHtml.jsonCall( JE.Call("currentQuestion"), validate _ )
  }
}

从下往上看这个snippet, 我们看到一个绑定在 <button>jsonCall. 我们处理的数值来自于一个JavaScript方法 currentQuestion. 这个方法定义在HTML模版上. 当按钮按下的时候, 这个方法被调用然后它返回的值会传入 validate 中, 它是我们的 JValue => JsCmd 方法.

所有的 validate 做的事是, 查看JSON数据, 然后提示用户是否正确. 为了做这个, 我们使用解析器, 把一个JSON解析到一个cass class中, 并且调用 valid_? 测试数值是否增加成功. 它会计算出一个 Some(true) 如果增加成功, Some(false) 如果不成功, 或者 None 如果没有输入, 或者输入的不是数字.

运行这段代码, 然后输入1, 2, 和 3, 将会产生以下log:

JObject(List(JField(first,JInt(1)), JField(second,JInt(2)),
  JField(answer,JInt(3))))

这是一个 JValue 表达的JSON.

See Also

[SelectOptionChange] 包含一个例子 SHtml.onEvents 绑定一个方法到一些 NodeSeq.

另一个例子关于 AjaxInvoke 请看 Call Scala code from JavaScript section of Diego Medina’s blog at: http://blog.fmpwizard.com/scala-lift-custom-wizard.

Exploring Lift, chapter 10, lists various JsExp classes you can use for ajaxCall: http://exploring.liftweb.net/master/index-10.html.

当选择的选项改变时, 调用服务器端代码

Problem

当一个HTML的选择框改变的时候, 你想调用服务器端的一些代码.

Solution

注册一个 String => JsCmd 方法到 SHtml.ajaxSelect.

在这里例子中, 我们将查看用户选择的一个星球到地球的距离. 这个查看会在服务器端发生, 并且更新客户端:

<div data-lift="HtmlSelectSnippet">
  <div>
    <label for="dropdown">Planet:</label>
    <select id="dropdown"></select>
  </div>
  <div id="distance">Distance will appear here</div>
</div>
package code.snippet

import net.liftweb.common.Empty
import net.liftweb.util.Helpers._
import net.liftweb.http.SHtml.ajaxSelect
import net.liftweb.http.js.JsCmd
import net.liftweb.http.js.JsCmds.SetHtml
import xml.Text

class HtmlSelectSnippet {

  // Our "database" maps planet names to distances:
  type Planet = String
  type LightYears = Double

  val database = Map[Planet,LightYears](
    "Alpha Centauri Bb" -> 4.23,
    "Tau Ceti e" -> 11.90,
    "Tau Ceti f" -> 11.90,
    "Gliese 876 d" -> 15.00,
    "82 G Eridani b" -> 19.71
  )

  def render = {

    // To show the user a blank label and blank value option:
    val blankOption = ("" -> "")

    // The complete list of options includes everything in our database:
    val options : List[(String,String)] =
      blankOption ::
      database.keys.map(p => (p,p)).toList

    // Nothing is selected by default:
    val default = Empty

    // The function to call when an option is picked:
    def handler(selected: String) : JsCmd = {
      SetHtml("distance", Text(database(selected) + " light years"))
    }

    // Bind the <select> tag:
    "select" #> ajaxSelect(options, default, handler)
  }
}

最后一行代码是主要工作代码. 它生成选项, 并且绑定方法 handler 到选项. handler方法将会在选项改变的时候被调用.

我们使用相同的 String (星球的名字) 作为选项的标签和值, 但是他们可以不相同.

Discussion

为了了解这里发生了什么, 请看以下Lift生成的HTML:

<select id="dropdown"
  onchange="liftAjax.lift_ajaxHandler('F470183993611Y15ZJU=' +
    this.options[this.selectedIndex].value, null, null, null)">
  <option value=""></option>
  <option value="Tau Ceti e">Tau Ceti e</option>
  ...
</select>

这个 handler 方程被Lift缓存的id为 "F470183993611Y15ZJU" (只在这里有效).一个 "onchange" 事件处理被添加到选项上, 并且方程会传递值到服务器端. 一个 lift_ajaxHandler 的 JavaScript 方法, 定义在 liftAjax.js 会被自动的添加到你的页面底端.

收集表格提交的数值

如果你需要额外的, 在一个表格提交的时候收集一个选项的值, 你可以使用 SHtml.onEvents. 它会添加一个事件监听到 NodeSeq, 当事件发生的时候触发一个服务端代码. 我们可以使用它到一个普通的选择框或者表格, 但是把它连到Ajax, 会让它传送数据到服务器端.

为了实现它, 需要我们修改一小部分我们的代码:

var selectedValue : String = ""

"select" #> onEvents("onchange")(handler) {
  select(options, default, selectedValue = _)
} &
"type=submit" #> onSubmitUnit( () => S.notice("Destination "+selectedValue))

我们使用相同的 handler 方法, 当一个 "onchange" 事件被触发时. 这个事件会发生在 SHtml.select 上, 当表格提交时, 它储存着 selectedValue . 我们也绑定了提交按钮到一个方法, 它可以在用户选择的时候, 生成一个提示.

相关的HTML也会有一些变化. 我们需要添加一个按钮, 确保表格工作:

<div data-lift="HtmlSelectFormSnippet?form=post">

  <div>
    <label for="dropdown">Planet:</label>
    <select id="dropdown"></select>
  </div>

  <div id="distance">Distance will appear here</div>

  <input type="submit" value="Book Ticket"/>

</div>

现在, 当你改变一个选项的时候, 会自动的看到距离被计算, 并且显示出来, 但是当按 "Book Ticket" 按钮的时候, 也会把值传到服务器端.

See Also

[MultiSelectBox] 介绍了如何使用class而不是 String 作为选择框的选项.

使用Scala代码创建客户端行为

Problem

你想使用Lift的Scala代码, 通过Lift转化成一个纯粹的JavaScript代码在客户端.

Solution

绑定你的 JavaScript 直接到一个你想运行的事件上.

这里有一个例子, 当你点击一个按钮的时候, 我们让它逐渐消失, 不过请注意, 我们在服务器端写这段代码:

package code.snippet

import net.liftweb.util.Helpers._

object ClientSide {
  def render = "button [onclick]" #> "$(this).fadeOut()"
}

在HTMl模版中, 我们这样写:

<div data-lift="ClientSide">
  <button>Click Me</button>
</div>

Lift将生成:

<button onclick="$(this).fadeOut()">Click Me</button>

Discussion

Lift包含一个抽象的JavaScript, 你可以使用它建立一个更复杂的逻辑在服务器端. 比如说, 你可以建立一些基础的命令…

import net.liftweb.http.js.JsCmds.{Alert, RedirectTo}

def render = "button [onclick]" #>
  (Alert("Here we go...") & RedirectTo("http://liftweb.net"))

…将跳出一个提示, 并且跳转到 http://liftweb.net. The HTML would be rendered as:

<button onclick="alert(&quot;Here we go...&quot;);
window.location = &quot;http://liftweb.net&quot;;">Click Me</button>

另一个选项是使用 JE.Call 去运行一个JavaScript带参数的方法. 假设我们有以下方法:

function greet(who, times) {
  for(i=0; i<times; i++)
    alert("Hello "+who);
}

我们可以绑定一个按钮, 当它点击时, 显示:

import net.liftweb.http.js.JE

def render =
  "button [onclick]" #> JE.Call("greet", "World!", 3)

在客户端, 我们将会看到:

<button onclick="greet(&quot;World!&quot;,3)">Click Me For Greeting</button>

请注意, 类型 StringInt 是一个固定的类型, 在JavaScript的语法里. 因为 JE.Call 使用一个变参 JsExp , 在JavaScript方法后. 它们已经被JavaScript的主要类型包裹 (JE.Str, JE.Num, JsTrue, JsFalse) 并且从Scala转换到JavaScript.

See Also

Chapter 10 of Exploring Lift at http://exploring.liftweb.net/ 给出一个List的 JsCmdsJE 表达式.

焦点一个域, 当页面加载时

Problem

当页面加载时, 你希望浏览器可以选择一个特定的域供用户输入.

Solution

使用 FocusOnLoad 命令:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.http.js.JsCmds.FocusOnLoad

class Focus {
  def render = "name=username" #> FocusOnLoad(<input type="text"/>)
}

在这里 CSS transform 在 render 将会匹配 name="username" 元素并且替换它为一个文本域, 它将会在页面加载后, 自动称为焦点.

尽管我们焦点一个HTML, 但是它可以是任何的 NodeSeq, 比如说 SHtml.text.

Discussion

FocusOnLoad 是一个 NodeSeq => NodeSeq transformation 的例子. 它添加 NodeSeq 到相关的JavaScript上, 这使得它称为焦点.

JavaScript执行焦点的原理非常简单, 它找到ID对应的DOM, 然后调用 focus. 尽管以上的代码没有特定的ID, FocusOn 命令非常聪明的自动添加了一个.

这里有两种与 JsCmd 相关的方法:

  • Focus — 以一个ID作为参数, 然后设置该ID上的元素为焦点.

  • SetValueAndFocus — 和 Focus 类似, 但是需要一个参数 String 去改变值.

这两个都很重要, 如果你需要通过Ajax或者Comet动态改变的时候.

See Also

FocusOnLoad 的源代码是学习它的一方法, 你还可以看到其他相关的方法. 这个可以帮助你自己组装你需要的JavaScript命令到一个方法中: https://github.com/lift/framework/blob/master/web/webkit/src/main/scala/net/liftweb/http/js/JsCommands.scala.

给一个Ajax表格添加CSS

Problem

你想给一个Ajax表格添加CSS.

Solution

使用a ?class= 参数:

<form data-lift="form.ajax?class=boxed">
...
</form>

Discussion

如果你需要添加多个CSS, 在CSS文件间添加一个空格, 使用, class=boxed+primary.

form.ajax 建造是一个简单的snippet掉欧阳那个: Form snippet是一个内部的snippet, 在这个例子中, 我们调用方法 ajax 在它的对象中. 然而, 默认的调用不会把属性添加到HTML中, 但是通过我们修改后的snippet, 可以实现.

See Also

一个访问语句参数的例子, see [ConditionalIncludes].

Simply Lift, chapter 4, 介绍了Ajax表格在 http://simply.liftweb.net/.

使用JavaScript运行一个HTMl

Problem

你想在当前页面中, 加载一个新的执行完snippet后的页面 (但是, 不用刷新当前页面).

Solution

使用 Template 加载HTML模版, 并且使用 SetHtml 替换HTML元素.

让我们看一个例子. 这里 <div> 元素将显示主页, 当一个按钮按下:

<div data-lift="TemplateLoad">
  <div id="inject">Content will appear here</div>
  <button>Load Template</button>
</div>

相关的snippet为:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.http.{SHtml, Templates}
import net.liftweb.http.js.JsCmds.{SetHtml, Noop}
import net.liftweb.http.js.JsCmd

object TemplateLoad {

  def content : JsCmd =
    Templates("index" :: Nil).map(ns => SetHtml("inject", ns)) openOr Noop

  def render = "button [onclick]" #> SHtml.ajaxInvoke(content _)
}

点击按钮将会发生 /index.html 被加载到 inject 元素.

Discussion

Templates 生成一个 Box[NodeSeq]. 在上一个例子中, 我们将它传入 JsCmd, 并且它会处理 inject div.

如果你对HTML模版使用Unit Tests, 请注意, 你需要修改你的开发和测试环境, 使他们包含 webapps 文件夹. 你可以使用 SBT, 并添加以下code到 build.sbt:

unmanagedResourceDirectories in Test <+= (baseDirectory) { _ / "src/main/webapp" }

对于IDE, 你需要添加 webapp 作为一个源代码文件夹.

See Also

[ButtonTriggerServerCode] 解释了 ajaxInvoke 和相关方法.

把JavaScript放到文件末端

Problem

你想把你snippet中建立的JavaScrpt放到HTML的末端.

Solution

使用 S.appendJs 将会把你的JavaScript放到 </body> tag前.

在这个HTMl中, 我们把 <script> tag放到中间, 然后把他绑定到一个snippet叫 JavascriptTail:

<!DOCTYPE html>
<head>
  <meta content="text/html; charset=UTF-8" http-equiv="content-type" />
  <title>JavaScript in Tail</title>
</head>
<body data-lift-content-id="main">
<div id="main" data-lift="surround?with=default;at=content">
  <h2>Javascript in the tail of the page</h2>

  <script type="text/javascript" data-lift="JavascriptTail">
  </script>

  <p>
    The JavaScript about to be run will have been moved
    to the end of this page, just before the closing
    body tag.
  </p>
</div>
</body>
</html>

这个 <script> 内容会被snippet自动生成. 请注意, 它不一定是一个 <script> tag: snippet只是把所有的内容都取代成空内容的, 然后放到tag中:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.http.js.JsCmds.Alert
import net.liftweb.http.S
import xml.NodeSeq

class JavascriptTail {
  def render = {
    S.appendJs(Alert("Hi"))
    "*" #> NodeSeq.Empty
  }
}

尽管上边的snippet把所有内容取代成空内容, 但是它调用了 S.appendJs 和一个 JsCmd. 这将会在页面末端生成以下内容:

<script type="text/javascript">
// <![CDATA[
jQuery(document).ready(function() {
  alert("Hi");
});
// ]]>
</script>

尽管这段snippet以前是在页面中间, 但是JavaScript生成在最后.

Discussion

还有其他三种方法你可以解决这个问题. 第一种是你可以把你的JavaScrpt文件放到一个外部文件中, 然后简单的放到你想放的任何地方.

第二种是 S.appendJs 的另一个类似方法: S.appendGlobalJs 的工作原理和它相同, 但是不会包含 ready 方法在JavaScript中, 这意味着你无法保证, 等你的DOM加载后, 你的JavaScript会被加载.

第三个选择是你可以把你的JavaScript包裹在<lift:tail> snippet :

class JavascriptTail {
  def render =
    "*" #> <lift:tail>{Script(OnLoad(Alert("Hi")))}</lift:tail>
}

请注意, lift:tail 是一个Lift内建方法, 可以把里面的内容放到页面最后, 它甚至可以放置除JavaScript以外的东西.

See Also

[AddToHead] 介绍了如果把文件放到Head tag里.

当Comet Session丢失时, 运行一个JavaScript

Problem

你正在使用comet actor, 你希望你的一个JavaScript可以运行, 当comet session丢失的时候.

Solution

设置你的JavaScript通过 LiftRules.noCometSessionCmd.

作为一个例子, 我们使用一个标准的Lift聊天室demo, 我们希望当session丢失的时候, 可以保存聊天记录. 在这里, 我们有一个Ajax的表格作为输入框, 然后使用comet在聊天区域上:

<form data-lift="form.ajax">
  <input type="text" data-lift="ChatSnippet" id="message"
    placeholder="Type a message" />
</form>

<div data-lift="comet?type=ChatClient">
  <ul>
    <li>A message</li>
  </ul>
</div>

在这里, 我们可以添加一个方法 stash, 它将在session丢失时, 被调用:

<script type="text/javascript">
// <![CDATA[
function stash() {
  saveCookie("stashed", $('#message').val());
  location.reload();
}

jQuery(document).ready(function() {
  var stashedValue = readCookie("stashed") || "";
  $('#message').val(stashedValue);
  deleteCookie("stashed");
});

// Definition of saveCookie, readCookie, deleteCookie omitted.

</script>

我们的 stash 方法将会保存现在的聊天记录到一个cookie "stashed" 中. 我们设置, 当页面刷新的时候, Lift查看cookie, 然后把里面内容插入到聊天区域.

最后一个部分是修改 Boot.scala 注册你的 stash :

import net.liftweb.http.js.JsCmds.Run

LiftRules.noCometSessionCmd.default.set( () => Run("stash()") )

在这里, 当一个session丢失时, 服务器会调用 stash 方法, 记录聊天信息, 然后等再次访问这个页面时, 插入聊天信息

为了测试这个例子, 输入一些信息到聊天区域, 然后重启你的Lift应用. 等10秒, 你会看到效果.

Discussion

如果你不改变 noCometSessionCmd, Lift默认的行为是加载 LiftRules.noCometSessionPage — 它是 / 除非你改变它. 这样你可以调用 lift_sessionLostcometAjax.js.

通过提供我们自定义的 () => JsCmd 方法到 LiftRules.noCometSessionCmd, Lift会调用这个方法, 然后传递 JsCmd, 而不是 lift_sessionLost. 如果你看HTTP流, 你将会看到 stash 方法被调用, 作为一个对comet request的回复.

Factory

The noCometSessionCmd.default.set call is making use of Lift’s dependency injection. Specifically, it’s setting up the "supply side" of the dependency. Although we’re setting a default here, it’s possible in Lift to supply different behaviours with different scopes: request or session. See https://www.assembla.com/spaces/liftweb/wiki/Dependency_Injection.

这章中, 我们介绍了如何处理一个session丢失, 和 Ajax, 以及相关的 LiftRules.noAjaxSessionCmd 设置.

See Also

你会找到 The ubiquitous Chat app in Simply Lift: http://simply.liftweb.net/.

观看HTTP流, 是一个很好的学习和观察comet如何工作的方法. 有很多的插件和产品, 支持这个功能, 比如说 HttpFox plugin for Firefox: https://addons.mozilla.org/en-us/firefox/addon/httpfox/.

Ajax文件上传

Problem

你想提供给你的用户一个Ajax文件上传的功能, 并且可以支持拖拉文件.

Solution

添加 Sebastian Tschan’s jQuery File Upload widget (https://github.com/blueimp/jQuery-File-Upload) 到你的工程 然后使用REST接受文件.

第一步是下载widget, 然后把 js 文件夹放到你的应用里 src/main/webapp/js. 然后我们就可以使用它:

<!DOCTYPE HTML>
<html>
<head>
  <meta charset="utf-8">
  <title>jQuery File Upload Example</title>
</head>
<body>

<h1>Drag files onto this page</h1>

<input id="fileupload" type="file" name="files[]" data-url="/upload" multiple>

<div id="progress" style="width:20em; border: 1pt solid silver; display: none">
  <div id="progress-bar" style="background: green; height: 1em; width:0%"></div>
</div>

<script src="//ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min.js"></script>
<script src="js/vendor/jquery.ui.widget.js"></script>
<script src="js/jquery.iframe-transport.js"></script>
<script src="js/jquery.fileupload.js"></script>

<script>
  $(function () {
    $('#fileupload').fileupload({
      dataType: 'json',
      add: function (e,data) {
        $('#progress-bar').css('width', '0%');
        $('#progress').show();
        data.submit();
      },
      progressall: function (e, data) {
        var progress = parseInt(data.loaded / data.total * 100, 10) + '%';
        $('#progress-bar').css('width', progress);
      },
      done: function (e, data) {
        $.each(data.files, function (index, file) {
          $('<p/>').text(file.name).appendTo(document.body);
        });
        $('#progress').fadeOut();
      }
    });
  });
</script>

</body>
</html>

这个HTML模版提供了一个输入框用于上传文件, 一个区域用来提供状态显示, 和一个设置当使用Jquery $( ... ) 语句.

最后的一部分是一个Lift REST服务用来收取文件. 服务的URL是 /upload, 被设置在 data-urlinput 域, 我们通过匹配来找到它:

package code.rest

import net.liftweb.http.rest.RestHelper
import net.liftweb.http.OkResponse

object AjaxFileUpload extends RestHelper {

  serve {

    case "upload" :: Nil Post req =>
      for (file <- req.uploadedFiles) {
        println("Received: "+file.fileName)
      }
      OkResponse()

  }

}

这个现实只是返回一个文件名称和一个HTTP 200 状态到widget上.

所有的REST服务, 都需要被注册在 Boot.scala:

LiftRules.dispatch.append(code.rest.AjaxFileUpload)

默认情况下, 这个widget让整个的HTML下沉, 来显示文件, 这意味着你可以通过拖拉来实现文件的上传.

Discussion

通过这章, 我们简单的介绍了如何集成widget到Lift应用. DEMO网站在, http://blueimp.github.com/jQuery-File-Upload/, 显示了其他功能, 并且提供了文档来介绍如何集成它们.

很多功能只需要 JavaScript 设置. 比如说, 我们使用widget的 add, progressall, 和 done 来处理show, update 和淡出一个状态条. 当上传结束时, 上传的文件名称被加载到页面上.

在REST服务中, 上传通过 uploadedFiles 方法在请求中. 当Lift收到一个多个文件上传的情况时, Lift会自动解析文件到 uploadedFiles, 给每个文件一个 FileParamHolder 用来访问 fileName, length, mimeTypefileStream.

默认情况下, 文件是暂时存在内存中 (请看 [UploadToDisk] in [FileUpload]).

在这章中, 我们返回一个 200 (OkResponse). 如果我们想告诉widget, 上传的文件被拒绝, 我们可以返回一个别的状态代码. 比如说, 我们假设想拒绝任何, 除了PNG后缀的文件. 在服务端, 可以取代 OkResponse 为一个测试:

import net.liftweb.http.{ResponseWithReason, BadResponse, OkResponse}

if (req.uploadedFiles.exists( _.mimeType != "image/png" ))
  ResponseWithReason(BadResponse(), "Only PNGs")
else
  OkResponse()

我们简单的让它称为一个 fail 处理在JavaScript中:

fail: function (e, data) {
  alert(data.errorThrown);
}

当上传后, 比如说时一个JPEG文件, 浏览器将提示用户, "Only PNGs".

See Also

Diego Medina 发布了一个完整的Gist, 里面包裹图像上传, 阅览, 特别是里面还有JSON功能, 你可以在以下地址找到: https://gist.github.com/a6715d1e3664f73cd03a.

[FileUpload] 介绍了基本的文件上传功能, 和如何控制文件存储.

Antonio Salazar Cardozo 发布了一个例子, 里面包裹Ajax文件上传, 和外部JavaScript库: https://groups.google.com/d/msg/liftweb/OuN1sqRMO_c/TrUGUaSvoN4J.

请求通道

当一个请求到Lift的时候, 你可以在很多个地方设置Lift, 来调整Lift的行为, 或者返回各种回复, 或者做访问控制. 在这章中, 我们来看看各种的 LiftResponse 和通道设置.

在这个链接里, 你可以看到关于通道的各种信息, 包括形成图和如何工作的 http://www.assembla.com/spaces/liftweb/wiki/HTTP_Pipeline.

请见 https://github.com/LiftCookbook/cookbook_pipeline 里面有这章的源代码.

Debug一个Request

Problem

你想Debug一个请求, 并且看请求是如何到达Lift应用的

Solution

添加一个 onBeginServicing 方法在 Boot.scala 去看请求的log.

比如说:

LiftRules.onBeginServicing.append {
  case r => println("Received: "+r)
}

Discussion

这里的 onBeginServicing 调用是在Lift早期的, 在 S 被设置前, 并且在返回404前. 这个方程需要的参数为 Req => Unit. 我们只做Log, 但是这个方法可以用在被的地方.

如果你想用当前路径. 比如说, 测试错有开始为_/paypal_的路径:

LiftRules.onBeginServicing.append {
  case r @ Req("paypal" :: _), _, _) => println(r)
}

这个匹配会匹配到任何开始为_/paypal_, 我们将忽略后缀和请求类型 (e.g., GET, POST or so on).

还有一个方法叫 LiftRules.earlyonBeginServicing`之前被调用. 它需要一个 `HTTPRequest => Unit 方法, 所以它有底层的 Req 使用在 onBeginServicing. 然而, 它将被所有通过Lift处理的请求所调用. 比如说, 你可以设置一些请求, 让container自己处理:

LiftRules.liftRequest.append {
  case Req("robots" :: _, _, _) => false
}

文件 robots.txt 将会存储 LiftRules.early 的log, 但是其他的方法不会被log.

如果你需要访问states (比如说, S), 请使用 earlyInStateful, 它是基于 Box[Req] 而不是 Req:

LiftRules.earlyInStateful.append {
  case Full(r) => // access S here
  case _ =>
}

你可以调用两次 earlyInStateful 方法. 这将会发生, 当session被重置. 你可以通过匹配一个请求在Lift中, 来防止它:

LiftRules.earlyInStateful.append {
  case Full(r) if LiftRules.getLiftSession(r).running_? => // access S here
  case _ =>
}

最后, Lift有一个 earlyInStateless 方法和 earlyInStateful 一样. 它使用 Box[Req] , 不过在其他地方和 onBeginServicing`一样. 它被触发在 `early 之后, 但是在 `earlyInStateful`之前.

总结, 以上提到的方法, 调用顺序为:

  • LiftRules.early

  • LiftRules.onBeginServicing

  • LiftRules.earlyInStateless

  • LiftRules.earlyInStateful

See Also

如果你想要在最后catch一个请求, 请使用 onEndServicing, 它以一个 (Req, Box[LiftResponse]) => Unit 作为参数.

[RunningStateless] 讲述了如何使一个请求为stateless.

执行一个代码, 当session被破坏, 或者丢失

Problem

你想执行一些命令, 当一个session被建立或者丢失.

Solution

请使用 LiftSession. 比如说在 Boot.scala 中:

LiftSession.afterSessionCreate ::=
 ( (s:LiftSession, r:Req) => println("Session created") )

LiftSession.onBeginServicing ::=
 ( (s:LiftSession, r:Req) => println("Processing request") )

LiftSession.onShutdownSession ::=
 ( (s:LiftSession) => println("Session going away") )

如果请求被标为stateless在LiftRules.statelessReqTest 中, 以上的代码将只执行 onBeginServicing 方法.

Discussion

在 `LiftSession`中, 允许你添加代码在很多的插入点中: 当session建立后, 在服务开始的时候, 在服务结束后, 当session停止的时候, 在停止中, 等等… 在这章开始的时候的形成图, 介绍了这些插入点的位置.

请注意, Lift的session和HTTP的session是不一样的. Lift有自己的session管理. 它在这里有讲述 Exploring Lift (see See Also).

下面是各个插入点的介绍:

  • onSetupSession — 这是第一个被调用的插入点.

  • afterSessionCreate — 当所有的 onSetupSession 方法被调用完成后, 调用这个.

  • onBeginServicing — 在request被处理的开始阶段.

  • onEndServicing — 在request被处理的最后阶段.

  • onAboutToShutdownSession — 当session关闭的时候, 比如说Lift应用被关闭, 或者Lift丢失session.

  • onShutdownSession — 在 onAboutToShutdownSession 方法后调用.

如果你测试这些插入点, 你也许希望一个session快速的过期, 而不是等待30分钟后. 为了实现它, 提供一个毫秒到 LiftRules.sessionInactivityTimeout:

// 30 second inactivity timeout
LiftRules.sessionInactivityTimeout.default.set(Full(1000L * 30))

LiftSession 中还有其他两种插入点: onSessionActivateonSessionPassivate. 他们也许在你使用分布式的容器中有用, 他们使用来设置容器来进行序列化和非系列化的. 其实他们很少被用到.

See Also

Session管理在 Exploring Lift: http://exploring.liftweb.net/.

[RunningStateless] 解释了如何运行应用stateless.

执行代码当Lift停止

Problem

你想在Lift停止工作的时候, 运行一些代码.

Solution

使用 LiftRules.unloadHooks.

LiftRules.unloadHooks.append( () => println("Shutting down") )

Discussion

你添加一个 () => UnitunloadHooks, 他们会在Lift最后的阶段执行, 在session被摧毁前, Lift actor已经停止工作, request也将被停止.

他们将被触发在Java容器中, "by the web container to indicate to a filter that it is being taken out of service".

See Also

[RunTasksPeriodically] 讲述了一个unhook的例子.

运行在Stateless环境下

Problem

你想强制你的应用, 在HTTP下, 是一个Stateless的.

Solution

Boot.scala:

LiftRules.enableContainerSessions = false
LiftRules.statelessReqTest.append { case _ => true }

所有的请求将被视为Stateless. 任何常识访问一个State的动作, 如SessionVar, 将会触发一个警告: "Access to Lift’s statefull features from Stateless mode. The operation on state will not complete."

Discussion

HTTP session的建立是通过 enableContainerSessions, 并且使用在所有请求上. 默认情况下(true), 他允许一个细腻的操作在stateless下.

使用 statelessReqTest 将允许你决定, 基于 StatelessReqTest case class, 来判断一个请求是否是stateless (true) 或者 (false).

比如说:

def asset(file: String) =
  List(".js", ".gif", ".css").exists(file.endsWith)

LiftRules.statelessReqTest.append {
  case StatelessReqTest("index" :: Nil, httpReq) => true
  case StatelessReqTest(List(_, file),  _) if asset(file) => true
}

这个例子只使index上, 所有的GIF, JavaScript和CSS文件 stateless. httpReq 部分是一个 HTTPRequest 实例, 允许你以内容来做决定 (cookies, user agent, etc).

另一个选项是 LiftRules.statelessDispatch 他允许你注册一个方法, 并返回LiftResponse. 它将被执行在无session环境下, 这便于使用 REST 服务.

如果你只想让其中的一个页面变成Stateless, 你可以这样:

Menu.i("Stateless Page") / "demo" >> Stateless

一个对 /demo 链接的request将是Stateless.

See Also

[REST] 中包含了如何使用REST在Lift中.

Lift Wiki有更多关于Stateless的介绍: http://www.assembla.com/wiki/show/liftweb/Stateless_Requests.

Stateless是在Lift2.2中介绍的, 这里有更多介绍: https://groups.google.com/d/msg/liftweb/2rVMCnWppSo/KoaUMHeQAEAJ.

Catch任何异常

Problem

你想让所有的request都在一个包裹中, 并且提示用户那里出现了异常.

Solution

声明一个异常处理器在 Boot.scala :

LiftRules.exceptionHandler.prepend {
  case (runMode, request, exception) =>
    logger.error("Failed at: "+request.uri)
    InternalServerErrorResponse()
}

以上例子中, 所有的异常在run mode将被匹配, 并且返回一个500状态 (internal server error) 到浏览器.

Discussion

这里, exceptionHandler 方法需要一个参数, 它是一个方法返回一个 LiftResponse (比如说, 一些你需要发送到浏览器的提示). 默认的行为是返回 XhtmlResponse, 并且在 Props.RunModes.Development 中将给出异常的详细信息, 并且在其他别的run mode中返回: "Something unexpected happened".

你可以返回任意类型的 LiftResponse, 包括 RedirectResponse, JsonResponse, XmlResponse, JavaScriptResponse, 等等.

上面的例子只是返回一个500异常, 这对你的用户来说, 不太方便. 你可以使用一个自定义的页面来显示异常, 不过保持异常的状态为500, 因为这对外部监视服务很重要:

LiftRules.exceptionHandler.prepend {
  case (runMode, req, exception) =>
    logger.error("Failed at: "+req.uri)
    val content = S.render(<lift:embed what="500" />, req.request)
    XmlResponse(content.head, 500, "text/html", req.cookies)
}

在这里, 我们返回一个500异常, 但是内容是一个 Node, 它是运行 src/main/webapp/template-hidden/500.html 的结果. 这样你就可以用来提示用户:

<html>
<head>
  <title>500</title>
</head>
<body data-lift-content-id="main">
<div id="main" data-lift="surround?with=default;at=content">
  <h1>Something is wrong!</h1>
  <p>It's our fault - sorry</p>
</div>
</body>
</html>

当处理Ajax时, 你可以控制哪些发送到浏览器. 在下面的例子中, 我们匹配Ajax的POST, 然后返回一个自定义的JavaScript到浏览器:

import net.liftweb.http.js.JsCmds._

val ajax = LiftRules.ajaxPath

LiftRules.exceptionHandler.prepend {
  case (mode, Req(ajax :: _, _, PostRequest), ex) =>
    logger.error("Error handing ajax")
    JavaScriptResponse(Alert("Boom!"))
}

你可以测试这个代码, 通过建立一个按钮, 每次按的时候都返回一个异常:

package code.snippet

import net.liftweb.util.Helpers._
import net.liftweb.http.SHtml

class ThrowsException {
  private def fail = throw new Error("not implemented")

  def render = "*" #> SHtml.ajaxButton("Press Me", () => fail)
}

这个Ajax代码将运行在Lift默认的Ajax行为之前. 默认的行为是尝试三次 (LiftRules.ajaxRetryCount), 然后运行 LiftRules.ajaxDefaultFailure, 它将弹出一个对话框: "The server cannot be contacted at this time"

See Also

[Custom404] 讲述了如何子自定义一个404页面.

Stream形式的内容

Problem

你想以Stream的形式发送内容到浏览器

Solution

使用 OutputStreamResponse. 它需要一个参数,返回Lift支持的 OutputStream.

在这里例子中, 我们将Stream所有的整数, 返回到浏览器:

package code.rest

import net.liftweb.http.{Req,OutputStreamResponse}
import net.liftweb.http.rest._

object Numbers extends RestHelper {

  // Convert a number to a String, and then to UTF-8 bytes
  // to send down the output stream.
  def num2bytes(x: Int) = (x + "\n") getBytes("utf-8")

  // Generate numbers using a Scala stream:
  def infinite = Stream.from(1).map(num2bytes)

  serve {
    case Req("numbers" :: Nil, _, _) =>
      OutputStreamResponse( out => infinite.foreach(out.write) )
  }
}

Scala的 Stream 类是生成一个序列的lazy计算. 它的值是被 infinite 计算后, 返回浏览器.

你需要把它连入 Boot.scala:

LiftRules.dispatch.append(Numbers)

浏览 http://127.0.0.1:8080/numbers 将会返回一个200状态代码, 并且开始从1生成整数. 整数的生成很快, 所以你肯定不希望使用浏览器看它, 所以请使用一些能让它停止的工具, 比如cURL.

Discussion

OutputStreamResponse 需要一个 OutputStream => Unit 类型的方法. OutputStream 的参数是输出的Stream到浏览器. 这意味着我们写入Stream的Byte都会传入浏览器. 在上个例子中…

OutputStreamResponse(out => infinite.foreach(out.write))

…我们这里使用 write(byte[]) 方法在Java的 OutputStream (out)上, 并且以 infinite 生成的 `Array[Byte]`做参数.

为了有更多的控制在state code, headers 和 cookies, OutputStreamResponse 对象有另一个签名. 为了更多的控制, 建立一个 OutputStreamResponse 的实例:

case class OutputStreamResponse(
  out: (OutputStream) => Unit,
  size: Long,
  headers: List[(String, String)],
  cookies: List[HTTPCookie],
  code: Int)

任何你设置的header (比如 Content-type), 或者 status code, 会在你输出方法前被设置好.请注意, 设置 size-1 使得 Content-length header 被忽略.

还有另外两种相同类型的response: InMemoryResponseStreamingResponse.

InMemoryResponse

InMemoryResponse 方法, 在你已经组装好了内容准备发送到浏览器的时候, 非常游泳. 它的参数非常直接:

case class InMemoryResponse(
  data: Array[Byte],
  headers: List[(String, String)],
  cookies: List[HTTPCookie],
  code: Int)

作为一个例子, 我们修改刚才的代码, 强制 infinite 序列生成一些 Array[Byte] 在内存中:

import net.liftweb.util.Helpers._

serve {
  case Req(AsInt(n) :: Nil, _, _) =>
    InMemoryResponse(infinite.take(n).toArray.flatten, Nil, Nil, 200)
}

这里的 AsInt 是Lift的一个用来匹配整数的方法, 这意思是, 这个请求开始为一个整数, 然后我们会返回相同数量的整数在回复中. 我们这里没有设置 headers 或者 cookies, 以下是测试:

$ curl http://127.0.0.1:8080/3
1
2
3
StreamingResponse

StreamingResponse 把byte数据从Stream中拉出. 这个行为和 OutputStreamResponse 的, 把数据push到客户端, 相反.

为了建立这类的回复, 我们提供一个有 read 方法的类:

case class StreamingResponse(
  data: {def read(buf: Array[Byte]): Int},
  onEnd: () => Unit,
  size: Long,
  headers: List[(String, String)],
  cookies: List[HTTPCookie],
  code: Int)

请注意, data 参数的结构的使用. 任何 read 方法都可以使用在这里, 包括 java.io.InputStream - 比如 objects, 这意味着 StreamingResponse 可以作为一个管道从输入到输出.

你的 data read 方法应该遵循JavaIO的语法 "the total number of bytes read into the buffer, or -1 is thereis no more data because the end of the stream has been reached".

See Also

使用访问控制在文件服务上

Problem

你在本地硬盘有一个文件, 你想让用户下载它, 但是只是特定的用户. 如果他们不允许, 你想告诉他们为什么.

Solution

使用 RestHelper 去处理一个下载, 或者解释页面.

比如说, 我们有一个文件 /tmp/important , 我们只想让用户从以下链接下载 /download/important . 代码如下:

package code.rest

import net.liftweb.util.Helpers._
import net.liftweb.http.rest.RestHelper
import net.liftweb.http.{StreamingResponse, LiftResponse, RedirectResponse}
import net.liftweb.common.{Box, Full}
import java.io.{FileInputStream, File}

object DownloadService extends RestHelper {

  // (code explained below to go here)

  serve {
    case "download" :: Known(fileId) :: Nil Get req =>
      if (permitted) fileResponse(fileId)
      else Full(RedirectResponse("/sorry"))
  }
}

我们允许用户下载 "known" 文件. 这就是我们遇到的文件. 我们这么做是因为, 如果你把内部文件结构展示给用户, 那么你的服务端将收到威胁.

比如说, Known 检查一个静态的文件列表:

val knownFiles = List("important")

object Known {
 def unapply(fileId: String): Option[String] = knownFiles.find(_ == fileId)
}

对于那些已知文件的请求, 我们的REST将变成 Box[LiftResponse]. 对于被允许访问的, 我们使用:

private def permitted = scala.math.random < 0.5d

private def fileResponse(fileId: String): Box[LiftResponse] = for {
    file <- Box !! new File("/tmp/"+fileId)
    input <- tryo(new FileInputStream(file))
 } yield StreamingResponse(input,
    () => input.close,
    file.length,
    headers=Nil,
    cookies=Nil,
    200)

如果不允许访问, 用户将跳转到 /sorry.html.

以上所有的代码将连入 Boot.scala :

LiftRules.dispatch.append(DownloadService)

Discussion

通过转变一个request到 Box[LiftResponse], 我们可以提供下载服务, 让用户跳转到不同页面, 并且让Lift处理 404 (Empty).

如果我们添加一个测试去查看是否文件在 fileResponse, 那会造成一个处理 empty 的请求, 将会触发一个404. 在代码上来看, 如果文件不存在,方法 tryo 将返回 Failure, 它将是一个 404 错误, 并且包含 "/tmp/important (No such file or directory)".

因为我们测试的是一个已知的源, Known 解析器将解析将作为部分的匹配在 /download/ 上, 一个未知的源将不会通过 File 访问代码. 所以, Lift将返回404.

一个Guard表达式将同样可以很好的作用在这里:

serve {
  case "download" :: Known(id) :: Nil Get _ if permitted => fileResponse(id)
  case "download" :: _ Get req => RedirectResponse("/sorry")
}

你可以混合和匹配extractors, guards 和 conditions 在你的回复中, 使得他们更好的为你工作.

See Also

Chatper 24: Extractors from Programming in Scala: http://www.artima.com/pins1ed/extractors.html.

HTTP Header 访问控制

Problem

你希望进行访问控制, 基于HTTPheader.

Solution

使用一个自定义的 If 在 SiteMap 中:

val HeaderRequired = If(
  () => S.request.map(_.header("ALLOWED") == Full("YES")) openOr false,
  "Access not allowed"
)

// Build SiteMap
val entries = List(
  Menu.i("Header Required") / "header-required" >> HeaderRequired
)

在这里例子中, header-required.html 只可以被有HTTP header 为 `ALLOWED`并且值为`YES`的请求访问. 其他的请求将被拒绝, 并返回 "Access not allowed".

这个代码可以被测试通过使用工具cURL:

$ curl http://127.0.0.1:8080/header-required.html -H "ALLOWED:YES"

Discussion

这里, If 测试确保了 () => Boolean 方法的第一个参数将返回 true, 在页面加载之前. 在这里例子中, 我们将会得到true, 在请求的header中包含 "ALLOWED", 并且它的值为 Full("YES"). 这是一个 LocParam (location parameter), 它将会修改 site map. 他可以添加任何的menu通过使用 >> 方法.

请注意, 如果没有header, 测试依旧是false. 这意味着方法 Menu.builder 将无法生成链接.

方法 If() 的第二个参数是告诉Lift该做什么, 当用户尝试访问页面, 并且测试的结果不是true的时候. 它是一个 () => LiftResponse 方法. 这意味着, 返回任何你想返回的, 包括跳转到其他页面. 在这里例子中, 我们使用了方便的转化方法从一个 String ("Access not allowed"), 到一个跳转到其他页面的回复.

如果你访问页面, 并且请求没有header, 你将会看到 "Access not allowed". 它将是主页, 不过这只是默认的.

你可以让Lift显示一个不同的页面通过设置, siteMapFailRedirectLocationBoot.scala:

LiftRules.siteMapFailRedirectLocation = "static" :: "permission" :: Nil

如果这时, 你想访问 header-required.html, 并且没有header, 你将会被跳转到 /static/permission 并且显示所有你放到那个页面上的内容.

See Also

Lift Wiki上有一个总结, 是关于Lift的Sitemap和关于Sitemap的测试的: https://www.assembla.com/wiki/show/liftweb/SiteMap.

这里有更多的解释: chapter 7 of Exploring Lift at http://exploring.liftweb.net, and "SiteMap and access control", chapter 7 of Lift in Action (Perrett, 2012, Manning Publications Co.).

访问 HttpServletRequest

Problem

为了实现一些API, 你想访问 HttpServletRequest.

Solution

通过 S.request:

import net.liftweb.http.S
import net.liftweb.http.provider.servlet.HTTPRequestServlet
import javax.servlet.http.HttpServletRequest

def servletRequest: Box[HttpServletRequest] = for {
  req <- S.request
  inner <- Box.asA[HTTPRequestServlet](req.request)
} yield inner.req

你可以使用API:

servletRequest.foreach { r => yourApiCall(r) }

Discussion

Lift从底层的HTTP请求中抽象出来, 并且能访问更细节的servlet容器. 然而, 如果你想用底层的HTTP, 你将可以访问它.

请注意, 方法 servletRequest 将返回 Box 因为当你计算 servletRequest 的时候, 这里也许不会返回一个值 — 或者你正在使用的是一个非标准的servlet容器.

如果你的代码对Java Servlet API有很强的依赖性, 你将需要添加如下依赖库:

"javax.servlet" % "servlet-api" % "2.5" % "provided->default"

强制HTTPS请求

Problem

你想强制你的客户端使用HTTPS.

Solution

添加一个 earlyResponse 方法在 Boot.scala 把HTTP请求变成等价的HTTPS. 比如说:

LiftRules.earlyResponse.append { (req: Req) =>
  if (req.request.scheme != "https") {
    val uriAndQuery = req.uri + (req.request.queryString.map(s => "?"+s) openOr "")
    val uri = "https://%s%s".format(req.request.serverName, uriAndQuery)
    Full(PermRedirectResponse(uri, req, req.cookies: _*))
  }
  else Empty
}

Discussion

这里的 earlyResponse 调用是在Lift处理请求的早期. 这是用来运行一些代码, 在Lift开始处理请求之前. 如果他被设置, Lift将推出通道, 并且返回一个response. 这个方法的参数为 Req => Box[LiftResponse].

在这个例子中, 我们测试它, 通过一个不是 "https" 的请求, 然后再使用一个 "https" 并且把它其余的参数添加到https上. 当建立好的时候, 我们跳转到一个新的URL上, 里面包含着所有以前的信息.

通过把其他请求计算为 Empty (比如说, https requets), Lift将会继续的将请求放到通道中.

理想的情况下, Lift确保请求通过web服务器设置, 被处理在一个正确的scheme下, 比如说 Apache 或者 Nginx. 但是在一些情况下是不可能的, 比如说你把你的应聘放到PaaS. 比如说 CloudBees.

Amazon负载平衡

对于Amazon的负载平衡, 你需要使用X-Forwarded-Proto header 去察觉 HTTPS. 像其他文档提到的 Overview of Elastic Load Balancing , "Your server access logs contain only the protocol used between the server and the load balancer; they contain no information about the protocol used between the client and the load balancer."

在这个情况下, 修改上个例子从: req.request.scheme != "https" 到:

req.header("X-Forwarded-Proto") != Full("https")

使用Record和Squeryl的关联性数据库

Squeryl是一个对象关联的库类. 他转化Scala的类到一个关联性数据库的表, 行和列, 并且提供一个方法来表达SQL语句通过使用Scala的编译器. Lift Squeryl Record模型集成了Squeryl和Record. 这意味着你的Lift应用可以使用Scala来加载和储存数据.并且, 你也可以使用Record的一些功能, 比如说一个数据的验证.

这章的数据可以在以下的地址找到: https://github.com/LiftCookbook/cookbook_squeryl.

设置Squeryl和Record

Problem

你想设置你的Lift应用使用Squeryl和Record.

Solution

在你的构建文件中包含Squeryl-Record依赖库, 并且在 Boot.scala`中提供一个数据库连接到 `SquerylRecord.initWithSquerylSession.

比如说, 设置Squeryl和PostgreSQL, 修改 build.sbt 文件, 添加两个依赖库, 一个是Squeryl-Record, 另一个是数据库的驱动:

libraryDependencies ++= {
  val liftVersion = "2.5-RC2"
  Seq(
    "net.liftweb" %% "lift-webkit" % liftVersion,
    "net.liftweb" %% "lift-squeryl-record" % liftVersion,
    "postgresql" % "postgresql" % "9.1-901.jdbc4"
    ...
    )
}

这个会给你一个访问到Squeryl版本为: 0.9.5-6.

Boot.scala 中, 我们定义了一个连接并且注册它到Squeryl:

Class.forName("org.postgresql.Driver")

def connection = DriverManager.getConnection(
  "jdbc:postgresql://localhost/mydb",
  "username", "password")

SquerylRecord.initWithSquerylSession(
  Session.create(connection, new PostgreSqlAdapter) )

所有的Squeryl语句需要作为一个transaction的内容才能运行. 一个方法是提供一个transaction的设置到所有的HTTP请求. 这也需要在 Boot.scala 中设置:

S.addAround(new LoanWrapper {
  override def apply[T](f: => T): T = inTransaction { f }
})

Discussion

你可以使用任何的JVM表达层机制在Lift中. Lift Record提供的是一个轻量级的接口, 它包含着表达层上的Lift的CSS转化, 界面和插件. Squeryl-Record是一个具体的, Record和Squeryl的实现. 这意味着你可以使用标准的Record对象和结构. 通过使用Squeryl, 你写的语句可以在编译的时候被校验.

作为插件插入Squeryl意味着初始化Squeryl的会话管理系统. 它允许我们包裹一个一句在Squeryl的 transactioninTranscation 方法中. 它们之间的区别是, inTranscation 会开启一个新的transaction, 如果你请求的transaction不存在. 然而 transaction 会永远建立一个新的transaction.

为了区别一个transaction是对于所有的HTTP请求可用的, 我们使用 addARound, 我们可以写一些语句在Lift中, 并且在大部分不建立一个新的transaction, 除非我们希望它建立. 比如说:

import net.liftweb.squerylrecord.RecordTypeMode._
val r = myTable.insert(MyRecord.createRecord.myField(aValue))

在这章中, 我们使用的是 PostgreSqlAdapter . Squeryl也支持 OracleAdapter, MySQLInnoDBAdapterMySQLAdapter, MSSQLServer, H2Adapter, DB2AdapterDerbyAdapter.

See Also

Squeryl的 Getting Started Guide 介绍了更多关于会话管理和设置: http://squeryl.org/getting-started.html.

请看 [SquerylJNDI] , 关于设置一个 Java Naming and Directory Interface (JNDI).

使用一个 JNDI 数据库

Problem

你想使用一个JNDI数据源到你的Squeryl-Record Lift应用.

Solution

Boot.scala 调用 initWithSquerylSession 通过使用 DataSource 来查找JDNI内容:

import javax.sql.DataSource
val ds = new InitialContext().
  lookup("java:comp/env/jdbc/mydb").asInstanceOf[DataSource]

SquerylRecord.initWithSquerylSession(
  Session.create(ds.getConnection(), new MySQLAdapter) )

…替换 mydb 为你的JNDI数据库名称, 并且替换 MySQLAdapter 为适当的数据库适配器.

Discussion

JNDI是一个容器的服务, 它允许你设置一个数据库连接在容器上, 并且引用它在你的应用中. 使用它的优势是, 你可以避免包含一个数据库密码在你的Lift应用上.

JNDI的设置对于每个容器是不同的, 并且对于各个版本也是不同的. See Also 包含了所有流行的容器上的设置方法.

一些环境也许要求你包含你的JNDI源在你的 src/main/webapp/WEB-INF/web.xml 文件:

<resource-ref>
 <res-ref-name>jdbc/mydb</res-ref-name>
 <res-type>javax.sql.DataSource</res-type>
 <res-auth>Container</res-auth>
</resource-ref>

See Also

一些JDNI设置的资源:

一对多的关系

Problem

你想建立一个一对多的关系, 比如说一个卫星属于一个单一的星球, 但是一个星球可能有多于一个的卫星.

Solution

使用Squeryl的 oneToManyRelation 在你的代码中, 并且在你的Lift模型上包含一个引用从卫星到星球.

我们的目标是设置一个关系模型就像 [SquerylPlanetOneToManyFigure].

images/planets.png
Figure 3. 一个星球可能有很多卫星, 但是一个卫星观测器只属于一个星球.

代码是:

package code.model

import org.squeryl.Schema
import net.liftweb.record.{MetaRecord, Record}
import net.liftweb.squerylrecord.KeyedRecord
import net.liftweb.record.field.{StringField, LongField}
import net.liftweb.squerylrecord.RecordTypeMode._

object MySchema extends Schema {

  val planets = table[Planet]
  val satellites = table[Satellite]

  val planetToSatellites = oneToManyRelation(planets, satellites).
    via((p,s) => p.id === s.planetId)

  on(satellites) { s =>
    declare(s.planetId defineAs indexed("planet_idx"))
  }

  class Planet extends Record[Planet] with KeyedRecord[Long] {
    override def meta = Planet
    override val idField = new LongField(this)
    val name = new StringField(this, 256)
    lazy val satellites = MySchema.planetToSatellites.left(this)
  }

  object Planet extends Planet with MetaRecord[Planet]

  class Satellite extends Record[Satellite] with KeyedRecord[Long] {
     override def meta = Satellite
     override val idField = new LongField(this)
     val name = new StringField(this, 256)
     val planetId = new LongField(this)
     lazy val planet = MySchema.planetToSatellites.right(this)
  }

  object Satellite extends Satellite with MetaRecord[Satellite]

}

上面的代码定义了两个表基于Record的类, 它们是 table[Planet]table[Satellite]. 它建立了一个 oneToManyRelation 基于 (via) planetId 在卫星的表中.

它给Squeryl了关于如何建立一个foreign key来约束 planetId 到一个现存的星球表中的引用. 可以通过查看Squeryl中自动生成的机制, 来观察它:

-- table declarations :
create table Planet (
    name varchar(256) not null,
    idField bigint not null primary key auto_increment
  );
create table Satellite (
    name varchar(256) not null,
    idField bigint not null primary key auto_increment,
    planetId bigint not null
  );
-- indexes on Satellite
create index planet_idx on Satellite (planetId);
-- foreign key constraints :
alter table Satellite add constraint SatelliteFK1 foreign key (planetId)
  references Planet(idField);

一个名为 planet_idx 的检索, 声明在 planetId 上, 增强语句的joins效率.

最后, 我们通过使用 planetToSatellites.leftright 来建立一个查找语句作为 Planet.satellitesSatellite.planet. 我们可以声明它们, 通过插入示例数据并且运行这些语句:

inTransaction {
  code.model.MySchema.create

  import code.model.MySchema._

  val earth = planets.insert(Planet.createRecord.name("Earth"))
  val mars = planets.insert(Planet.createRecord.name("Mars"))

  // .save as a short-hand for satellite.insert when we don't need
  // to immediately reference the record (save returns Unit).
  Satellite.createRecord.name("The Moon").planetId(earth.idField.is).save
  Satellite.createRecord.name("Phobos").planetId(mars.idField.is).save

  val deimos = satellites.insert(
    Satellite.createRecord.name("Deimos").planetId(mars.idField.is) )

  println("Deimos orbits: "+deimos.planet.single.name.is)
  println("Moons of Mars are: "+mars.satellites.map(_.name.is))

}

运行这个代码, 会产生以下输出:

Deimos orbits: Mars
Moons of Mars are: List(Phobos, Deimos)

在这个例子中, 我们调用 deimos.planet.single 将会返回一个结果, 或者, 如果没有找到相关的数据, 会返回一个异常. headOption 是一个更安全的方法, 如果相关的数据没有被找到, 它会返回一个 None 或者 Some[Planet].

Discussion

方法 planetToSatellites.left 不是一个简单的 Satellite 对象的集合. 它是一个Squeryl Query[Satellite], 这意味着你可以把它看做其他类型的 Queryable[Satellite]. 比如说, 我们可以请求一个星球上所有的卫星, 其中包含字母在"E"以后, 比如说对于 Mars 会是 "Phobos":

mars.satellites.where(s => s.name gt "E").map(_.name)

方法 left 会返回一个 OneToMany[Satellite], 它会添加以下的方法:

  • assign — 添加一个新的关系, 但是不会更新数据库.

  • associate — 它像 assign 一样, 不过更新数据库.

  • deleteAll — 删除关系.

方法 assign 给卫星一个到星球的关系:

val express = Satellite.createRecord.name("Mars Express")
mars.satellites.assign(express)
express.save

下次, 当我们调用 mars.satellites 我们会找到mars的卫星.

一个 associate 调用会进一步的帮我们自动的插入和更新卫星:

val express = Satellite.createRecord.name("Mars Express")
mars.satellites.associate(express)

第三个方法, deleteAll 就像它的名字一样. 它会执行以下代码, 并且返回很多被删除的行:

delete from Satellite

一对多关系的右边也可以添加额外的方法, 通过添加ManyToOne[Planet]assigndelete. 请注意, 当你删除一对多关系的`一`的那边的时候, 任何相关的另一边数据关系必需提前被删除, 这是为了防止一个数据库的错误, 比如说, 一些没有星球的卫星.

作为 leftright 是语句, 这意味着, 每次你调用它们的时候, 你都发送一个新的语句到数据库. Squeryl引用了这个格式 stateless relations.

stateful 版本的 leftright 是这样:

class Planet extends Record[Planet] with KeyedRecord[Long] {
 ...
 lazy val satellites : StatefulOneToMany[Satellite] =
   MySchema.planetToSatellites.leftStateful(this)
}

class Satellite extends Record[Satellite] with KeyedRecord[Long] {
  ...
  lazy val planet : StatefulManyToOne[Planet] =
    MySchema.planetToSatellites.rightStateful(this)
}

这个改变意味着, mars.satellites 的结果将被缓存. 以后关于这个 Planet 的实例的调用将不会出发一个重新的数据库调用. 你仍然可以使用 associate 建立一个新的关系, 或使用 deleteAll , 它们都和你想要的结果是一样的, 但是一个数据在其他地方被改变的时候, 你需要通过使用 refresh 在现有的关系上, 来查看更新的结果.

你应该使用哪个版本? 这取决于你的应用, 但是你可以同时使用它们在同一个record中.

See Also

Squeryl关系在以下地址有文档: http://squeryl.org/relations.html.

多对多关系

Problem

你想建立一个多对多的关系, 比如说一个星球可以被许多的空间探测器访问, 但是一个空间探测器可以访问许多的星球.

Solution

使用 Squeryl的 manyToManyRelation 在你的机制中, 并且建立一个record来保持的两边的join的关系. [SquerylPlanetManyToManyFigure] 中有我们这章中建的结构, 其中 Visit 是一个record, 它用来连接一个many到另一个many.

images/visits.png
Figure 4. Many-to-many: Jupiter被Juno 和 Voyager 1访问; Saturn 被 Voyager访问.

这个机制被定义在两个表中, 一个在星球中, 一个在探测器中, 并且还有一个record名为 Visit, 它保持着两边的关系:

package code.model

import org.squeryl.Schema
import net.liftweb.record.{MetaRecord, Record}
import net.liftweb.squerylrecord.KeyedRecord
import net.liftweb.record.field.{IntField, StringField, LongField}
import net.liftweb.squerylrecord.RecordTypeMode._
import org.squeryl.dsl.ManyToMany

object MySchema extends Schema {

  val planets = table[Planet]
  val probes = table[Probe]

  val probeVisits = manyToManyRelation(probes, planets).via[Visit] {
    (probe, planet, visit) =>
      (visit.probeId === probe.id, visit.planetId === planet.id)
  }

  class Planet extends Record[Planet] with KeyedRecord[Long] {
    override def meta = Planet
    override val idField = new LongField(this)
    val name = new StringField(this, 256)
    lazy val probes : ManyToMany[Probe,Visit] =
      MySchema.probeVisits.right(this)
  }

  object Planet extends Planet with MetaRecord[Planet]

  class Probe extends Record[Probe] with KeyedRecord[Long] {
    override def meta = Probe
    override val idField = new LongField(this)
    val name = new StringField(this, 256)
    lazy val planets : ManyToMany[Planet,Visit] =
      MySchema.probeVisits.left(this)
  }

  object Probe extends Probe with MetaRecord[Probe]

  class Visit extends Record[Visit] with KeyedRecord[Long] {
    override def meta = Visit
    override val idField = new LongField(this)
    val planetId = new LongField(this)
    val probeId = new LongField(this)
  }

  object Visit extends Visit with MetaRecord[Visit]
}

Boot.scala 我们可以把这种关系显示出来…

inTransaction {
  code.model.MySchema.printDdl
}

…以上代码会显示以下结果, 但是根据版本不同, 也许会有不同:

-- table declarations :
create table Planet (
    name varchar(256) not null,
    idField bigint not null primary key auto_increment
  );
create table Probe (
    name varchar(256) not null,
    idField bigint not null primary key auto_increment
  );
create table Visit (
    idField bigint not null primary key auto_increment,
    planetId bigint not null,
    probeId bigint not null
  );
-- foreign key constraints :
alter table Visit add constraint VisitFK1 foreign key (probeId)
  references Probe(idField);
alter table Visit add constraint VisitFK2 foreign key (planetId)
  references Planet(idField);

请注意 visit 表中将会保留所有的 planetIdprobeId 的每一个行的关系.

Planet.probesProbe.planets 提供了一个 associate 方法来建立一个新的关系. 比如说, 我们可以建立一个星球和探测器的集合…

val jupiter = planets.insert(Planet.createRecord.name("Jupiter"))
val saturn = planets.insert(Planet.createRecord.name("Saturn"))
val juno = probes.insert(Probe.createRecord.name("Juno"))
val voyager1 = probes.insert(Probe.createRecord.name("Voyager 1"))

…并且让它们互相连接:

juno.planets.associate(jupiter)
voyager1.planets.associate(jupiter)
voyager1.planets.associate(saturn)

我们也可以使用 Probe.planetsPlanet.probes 作为一个语句来查找关联. 为了在一个代码片段中访问所有访问每个星球的探测器, 我们可以这样写:

package code.snippet

class ManyToManySnippet {
  def render =
    "#planet-visits" #> planets.map { planet =>
      ".planet-name *" #> planet.name.is &
      ".probe-name *" #> planet.probes.map(_.name.is)
    }
}

这个代码片段可以和一个模板这样组合:

<div data-lift="ManyToManySnippet">
  <h1>Planet facts</h1>
  <div id="planet-visits">
    <p>
      <span class="planet-name">Name will be here</span> was visited by:
    </p>
    <ul>
      <li class="probe-name">Probe name goes here</li>
    </ul>
  </div>
</div>

上半部分在 [SquerylManyToManyScreengrab] 中给出了一个这个模板和代码输出的例子.

Discussion

SquerylDSL manyToManyRelation(probes, planets).via[Visit] 是一个核心的元素连接我们的 Planet, ProbeVisit 到一起.它允许访问 "left" 和 "right" 在我们的 Probe.planetsPlanet.probes.

就像 [SquerylOneToMany] 中的一对多关系, 左边和右边都是语句. 当你请求一个 Planet.probes 数据库执行一个语句, 并且访问join在 Visit records中:

Select
  Probe.name,
  Probe.idField
From
  Visit,
  Probe
Where
  (Visit.probeId = Probe.idField) and (Visit.planetId = ?)

就像 [SquerylOneToMany] 中提到的, 有一种stateful的 leftright 可以缓存对象.

在我们插入到数据的数据中, 我们不是一定要声明 Visit. Squeryl manyToManyRelation 有足够的信息来插入一次访问作为一个关系. 顺便提一下, 在多对多的关系中, 我们使用哪个方向来调用关系, 是无所谓的. 以下两个表达式是一样的, 并且结果是一样的:

juno.planets.associate(jupiter)
// ..or..
jupiter.probes.associate(juno)

你也许会质疑, 为什么我们需要一个 Visit record, 因为它有很多优点. 比如说, 你可以附加额外的信息到join表中, 比如说探测器访问一个星球的年份.

为了实现它, 我们修改表Visit来添加一个额外的信息:

class Visit extends Record[Visit] with KeyedRecord[Long] {
  override def meta = Visit
  override val idField = new LongField(this)
  val planetId = new LongField(this)
  val probeId = new LongField(this)
  val year = new IntField(this)
}

Visit 仍然是一个 planetIdprobeId 引用的容器, 但是在这里, 我们添加了一个整数的变量来表达访问的年份.

为了记录访问的年份, 我们需要 assign 方法提供一个 ManyToMany[T]. 这将会建立一个关系, 但是不会改变数据库. 并且, 它会返回一个 Visit 实例, 我们可以修改并且保存它到数据库:

probeVisits.insert(voyager1.planets.assign(saturn).year(1980))

assign 方法的返回类型在这里是 Visit, 并且 Visit 有一个 year 域. 插入 Visit record 通过使用 probeVisits 会在数据库中建立一个新的行.

为了访问这个额外的信息在 Visit 对象, 你可以使用 ManyToMany[T] 提供的几个方法:

  • associations — 一个语句, 返回关于 Planet.probes 或者 Probe.planets`的 `Visit 对象, .

  • associationMap — 一个语句返回一对 (Planet,Visit) 或者 (Probe,Visit), 取决于你在那边调用这个方法 (probes 或者 planets).

比如说, 在一个代码片段中, 我们可以列出所有的空间探测器, 并且对于每一个探测器, 我们显示它访问的星球和访问星球的时间, 这个代码如下:

"#probe-visits" #> probes.map { probe =>
  ".probe-name *" #> probe.name.is &
  ".visit" #> probe.planets.associationMap.collect {
    case (planet, visit) =>
      ".planet-name *" #> planet.name.is &
      ".year" #> visit.year.is
    }
}

我们使用 collect 而不是 map 是为了匹配 (Planet,Visit) tuple 并且给值一个有意义的名字. 你也可以使用 (for { (planet, visit) <- probe.planets.associationMap } yield ...) .

下半部的 [SquerylManyToManyScreengrab] 介绍了如何使以上代码和以下模板结合:

<h1>Probe facts</h1>

<div id="probe-visits">
  <p><span class="probe-name">Space craft name</span> visited:</p>
  <ul>
    <li class="visit">
      <span class="planet-name">Name here</span> in <span class="year">n</span>
    </li>
  </ul>
</div>
images/visitsscreengrab.png
Figure 5. 以上使用多对多关系的例子的输出.

为了删除一个关系, 你需要访问 dissociatedissociateAllleftright 语句. 为了删除一个单一的关系:

val numRowsChanged = juno.planets.dissociate(jupiter)

以上代码在SQL中为:

delete from Visit
where
  probeId = ? and planetId = ?

为了删除所有的关系:

val numRowsChanged = jupiter.probes.dissociateAll

以上代码在SQL为:

delete from Visit
where
  Visit.planetId = ?

如果你想删除的record在 Visit 中有没有删除的关系, 你是不能直接删除 Planet 或者 Probe`的. 如果你删除它, 你会得到一个异常. 所以你需要先使用 `dissociatateAll :

jupiter.probes.dissociateAll
planets.delete(jupiter.id)

然而, 如果你想 cascading deletes 你可以实现它, 通过重写默认的机制:

// To automatically remove probes when we remove planets:
probeVisits.rightForeignKeyDeclaration.constrainReference(onDelete cascade)

// To automatically remove planets when we remove probes:
probeVisits.leftForeignKeyDeclaration.constrainReference(onDelete cascade)

以上是部分的默认机制, 通过修改它, 将会改变表的限制, 使用 printDdl 来实现它 (取决于你的数据库):

alter table Visit add constraint VisitFK1 foreign key (probeId)
  references Probe(idField) on delete cascade;

alter table Visit add constraint VisitFK2 foreign key (planetId)
  references Planet(idField) on delete cascade;

See Also

[SquerylOneToMany], 在一对多关系上介绍了 leftStatefulrightStateful 关系, 这也适用于多对多关系.

Foreign keys, cascading deletes, 介绍在: http://squeryl.org/relations.html.

对一个域添加一个校验

Problem

你想在你的模型下添加一个域的校验信息, 它可以确保用户不会忘记填写信息或者填写一些错误的信息.

Solution

重写方法 validations 在你的域上, 并且提供多个校验方程.

作为一个例子, 假设我们有一个存储星球的数据库, 并且我们想确保用户输入的任何新的星球包含至少五个字母. 我们可以添加一个校验到我们的record:

 class Planet extends Record[Planet] with KeyedRecord[Long]   {
    override def meta = Planet
    override val idField = new LongField(this)

    val name = new StringField(this, 256) {
      override def validations =
        valMinLen(5, "Name too short") _ :: super.validations
    }

  }

为了检查校验, 我在我们的代码片段中, 我们调用 validate, 它将返回record所有的错误:

package code
package snippet

import net.liftweb.http.{S,SHtml}
import net.liftweb.util.Helpers._

import model.MySchema._

class ValidateSnippet {

  def render = {

    val newPlanet = Planet.createRecord

    def validateAndSave() : Unit = newPlanet.validate match {
      case Nil =>
        planets.insert(newPlanet)
        S.notice("Planet '%s' saved" format newPlanet.name.is)

      case errors =>
        S.error(errors)
    }

    "#planetName" #> newPlanet.name.toForm &
    "type=submit" #> SHtml.onSubmitUnit(validateAndSave)
  }
}

当这个代码片段运行时, 我们修饰一个 Planet.name 表格并且把它连到一个`validateAndSave` 方法.

如果方法 validate 显示没有任何错误(Nil), 我们存储结果, 并且提示用户. 如果有错误, 我们使用 S.error 显示错误.

相关的模板是:

<html>
<head>
  <title>Planet Name Validation</title>
</head>
<body data-lift-content-id="main">
<div id="main" data-lift="surround?with=default;at=content">
  <h1>Add a planet</h1>

  <div data-lift="Msgs?showAll=false">
    <lift:notice_class>noticeBox</lift:notice_class>
  </div>

  <p>
    Planet names need to be at least 5 characters long.
  </p>

  <form class="ValidateSnippet?form">

    <div>
      <label for="planetName">Planet name:</label>
      <input id="planetName" type="text"></input>
      <span data-lift="Msg?id=name_id&errorClass=error">
        Msg to appear here
      </span>
    </div>

    <input type="submit"></input>

  </form>

</div>
</body>
</html>

在这个模板中, 错误信息显示在 input 域的下边, 包含一个CSS类名为 errorClass. 成功的信息显示在头部, 在 <h1> 标签附近, 使用了一个CSS名为 noticeBox.

Discussion

内建的验证有:

  • valMinLen — 验证一个string, 是否有和给的数字一样的长度.

  • valMaxLen — 验证一个string, 是否超过给的数字的长度.

  • valRegex — 验证一个string, 是否符合表达式.

一个关于正则表达式验证的例子:

import java.util.regex.Pattern

val url = new StringField(this, 1024) {
  override def validations =
    valRegex( Pattern.compile("^https?://.*"),
              "URLs should start http:// or https://") _ ::
    super.validations
}

validate 方法返回的错误是一个列表, 类行为 List[FieldError]. 方法 S.error 接受这个列表, 并且注册每一个验证错误, 所以它可以用来显示错误的内容在页面上. 它是通过分配给一个field一个ID, 然后允许你找到只关于这个域的错误信息, 就像我们这章介绍的一样. ID是存储在域上的, 并且在 Planet.name 上, 你可以使用 Planet.name.uniqueFieldId 访问ID, 它就是我们使用的 name_id .我们使用 lift:Msg?id=name_id&errorClass=error 来修饰这个域, 并且显示它的错误.

你不是必需使用 S.error 来显示一个校验信息. 你可以使用自己的显示代码来直接使用 FieldError. 就像你看到 FieldError 的源代码一样, 错误是在一个 msg 里的:

case class FieldError(field: FieldIdentifier, msg: NodeSeq) {
  override def toString = field.uniqueFieldId + " : " + msg
}

See Also

Lift中的 BaseField.scala 类包含了内建的 StringValidators. 你可以在以下地方找到它: https://github.com/lift/framework/blob/master/core/util/src/main/scala/net/liftweb/util/BaseField.scala.

[Forms] 介绍了表单的处理, 提示和错误.

自定义校验逻辑

Problem

你想自定义一个校验逻辑, 并且应用它在你的record中.

Solution

实现一个方法从域的类型到 List[FieldError], 并且在 validations 中引用它.

这里有一个例子: 我们有一个包含星球的数据库, 并且当一个用户输入一个新的星球时, 我们想要这个名字是唯一的. 星球的名字时一个 String , 所以为需要提供一个方法 String => List[FieldError] .

通过使用我们的校验方法, (valUnique, 下面), 我们包含它在 validations 在域 name 上:

import net.liftweb.util.FieldError

class Planet extends Record[Planet] with KeyedRecord[Long] {
  override def meta = Planet
  override val idField = new LongField(this)

  val name = new StringField(this, 256) {
    override def validations =
      valUnique("Planet already exists") _ ::
      super.validations
  }

  private def valUnique(errorMsg: => String)(name: String): List[FieldError] =
    Planet.unique_?(name) match {
      case true => FieldError(this.name, errorMsg) :: Nil
      case false => Nil
    }
}

object Planet extends Planet with MetaRecord[Planet] {
  def unique_?(name: String) = from(planets) { p =>
    where(lower(p.name) === lower(name)) select(p)
  }.isEmpty
}

这个校验被触发就像其他的 [FieldValidation] 的校验一样.

Discussion

一个校验方法有两部分组成:一个错误返回信息和一个待校验值.这允许你很简单的重用在其他域上. 比如说, 如果你想检验一个卫星有一个唯一的名字, 你可以用完全一样的方法, 只是提供一个不同的错误信息.

方法 FieldError 中, 你的返回需要知道它的域和返回的信息. 在这个例子中是 name, 但是我们使用 this.name 来避免与 name 参数产生歧义. name 是一个放入 valUnique 方法的参数.

这个例子使用的是一个文本作为返回的错误信息, 你同样可以使用 FieldError 的变形来接受一个 NodeSeq. 这样做可以人那个你的程序更加安全. 比如说:

FieldError(this.name, <p>Please see <a href="/policy">our name policy</a></p>)

为了国际化, 你也许更希望传递一个key到校验方法, 并且使用 S.?:

val name = new StringField(this, 256) {
    override def validations =
      valUnique("validation.planet") _ ::
      super.validations
  }

// ...combined with...

private def valUnique(errorKey: => String)(name: String): List[FieldError] =
  Planet.unique_?(name) match {
    case false => FieldError(this.name, S ? errorKey) :: Nil
    case true => Nil
  }

See Also

[FieldValidation] 介绍了域校验和内建校验.

文本本地化在以下地址有讨论: https://www.assembla.com/wiki/show/liftweb/Localization.

在它成为一个Set前, 修改一个域的值

Problem

你想修改一个域的值, 所以你模型里的值是修改以后的版本, 比如说, 清理一个值, 通过删除前边或者后边的空白.

Solution

重写 setFilter 并且在一个域上, 提供一个方法的列表.

为了删除用户输入的开头和结尾的空白, 这个域将使用 trim 过滤器:

val name = new StringField(this, 256) {
   override def setFilter = trim _ :: super.setFilter
}

Discussion

一些内建的过滤器有:

  • crop — 通过切断, 确定域的最大和最小长度.

  • trim — 使用 String.trim 在域的值上.

  • toUppertoLower — 改变域的大小写.

  • removeRegExChars — 删除匹配的正则表达式的字符.

  • notNull — 替换null为空白字符串.

一个对于 String 域的过滤器, 将会是一个为 String => String, 的类型, 并且 setFilter 方法需要一个 List. 知道了这个以后, 写一个过滤器就变得很直接. 比如说, 以下是一个过滤器, 它有一个简单的表格在我们的域 name 上:

 def titleCase(in: String) =
  in.split("\\s").
  map(_.toList).
  collect {
    case x :: xs  => (Character.toUpperCase(x).toString :: xs).mkString
  }.mkString(" ")

这个方法是分割一个有空格的字符串, 转化它们到一个字符的列表, 把第一个字符变成大写, 然后把其他字符放回到原位.

然后我们把他安装到过滤器…

val name = new StringField(this, 256) {
   override def setFilter =
    trim _ :: titleCase _ :: super.setFilter
}

…当用户输入 "jaglan beta" 作为一个星球名字的时候, 在数据库中, 我们看到的是 "Jaglan Beta".

See Also

Trait是最好理解一个狗氯气的地方 StringValidators 在源代码 BaseField 中: https://github.com/lift/framework/blob/master/core/util/src/main/scala/net/liftweb/util/BaseField.scala.

如果你真的很想做一个title case, 那么Apache Commons中有一个类为 WordUtils. 请见: http://commons.apache.org/lang/.

使用Specs2测试

Problem

你想写一个Specs2测试对于你的Squeryl 和 Record的数据库中.

Solution

使用一个内存数据库, 并且分配它作为你的测试数据库.

实现它需要三步:包含一个数据库在你的工程中, 并且把他设置成一个内存数据库. 建立一个可重用的trait来设置数据库. 使用这个trait在你的测试中.

H2数据库有一个内存模式, 这意味着它将不会保存数据到硬盘. 它需要被设置在 build.sbt 作为一个依赖库. 同时, 你需要修改 build.sbt, 并且取消SBT的同步调试功能来防止测试数据库和生产数据库互相影响:

libraryDependencies += "com.h2database" % "h2" % "1.3.170"

parallelExecution in Test := false

建立一个trait来初始化数据库并且建立一个机制:

package code.model

import java.sql.DriverManager

import org.squeryl.Session
import org.squeryl.adapters.H2Adapter

import net.liftweb.util.StringHelpers
import net.liftweb.common._
import net.liftweb.http.{S, Req, LiftSession }
import net.liftweb.squerylrecord.SquerylRecord
import net.liftweb.squerylrecord.RecordTypeMode._

import org.specs2.mutable.Around
import org.specs2.execute.Result

trait TestLiftSession {
  def session = new LiftSession("", StringHelpers.randomString(20), Empty)
  def inSession[T](a: => T): T = S.init(Req.nil, session) { a }
}

trait DBTestKit extends Loggable {

  Class.forName("org.h2.Driver")

  Logger.setup = Full(net.liftweb.util.LoggingAutoConfigurer())
  Logger.setup.foreach { _.apply() }

  def configureH2() = {
    SquerylRecord.initWithSquerylSession(
      Session.create(
        DriverManager.getConnection("jdbc:h2:mem:dbname;DB_CLOSE_DELAY=-1", "sa", ""),
        new H2Adapter)
    )
  }

  def createDb() {
    inTransaction {
      try {
        MySchema.drop
        MySchema.create
      } catch {
        case e : Throwable =>
          logger.error("DB Schema error", e)
          throw e
      }
    }
  }

}

case class InMemoryDB() extends Around with DBTestKit with TestLiftSession {
  def around[T <% Result](testToRun: =>T) = {
    configureH2
    createDb
    inSession {
      inTransaction {
        testToRun
      }
    }
  }
}

总结一下, 这个trait为Specs2提供一个 InMemoryDB context . 它确保了数据库被设置, 并且机制建立了一个transaction在你的测试中.

最后, 混入这个trait到你的测试中, 并且执行它的 InMemoryDB.

作为一个例子, 我们使用 [SquerylOneToMany] 的机制, 我们测试Mars有两个月亮:

package code.model

import org.specs2.mutable._
import net.liftweb.squerylrecord.RecordTypeMode._
import MySchema._

class PlanetsSpec extends Specification with DBTestKit {

  sequential

  "Planets" >> {

    "know that Mars has two moons" >> InMemoryDB() {

      val mars = planets.insert(Planet.createRecord.name("Mars"))
      Satellite.createRecord.name("Phobos").planetId(mars.idField.is).save
      Satellite.createRecord.name("Deimos").planetId(mars.idField.is).save

      mars.satellites.size must_== 2
    }

  }

}

使用SBT的 test 来运行这个, 将会出现一个成功的信息:

> test
[info] PlanetsSpec
[info]
[info] Planets
[info] + know that Mars has two moons
[info]
[info]
[info] Total for specification PlanetsSpec
[info] Finished in 1 second, 274 ms
[info] 1 example, 0 failure, 0 error
[info]
[info] Passed: : Total 1, Failed 0, Errors 0, Passed 1, Skipped 0
[success] Total time: 3 s, completed 03-Feb-2013 11:31:16

Discussion

DBTestKit trait 为我们做了很多工作. 在最底层, 它加载了H2的驱动, 并且设置了Squeryl连接到一个内存模式. JDBC的 mem 部分(jdbc:h2:mem:dbname;DB_CLOSE_DELAY=-1) 意味着H2将不会把数据保存在本地磁盘. 数据库在内存中, 确保了没有文件在硬盘中维护, 并且它运行的很快.

默认下, 当一个连接被关闭, 内存的数据库会被摧毁. 在这里, 我们取消这个操作, 通过添加 DB_CLOSE_DELAY=-1 , 它将允许我们写多连接的测试.

连接管理的下一步是建立一个数据库的机制在内存中. 我们在 createDb 中通过扔掉机制, 并且任何数据当我们重新启动时, 都是新的. 如果你有一个十分普通的数据库测试数据, 这将会是一个非常好的地方在测试运行前, 插入数据.

以上几部在 InMemoryDB 类中汇集, 它是一个Specs2接口来运行 Around 测试. 我们还包裹一个测试在 TestLiftSession 中. 它会建立一个空白的会话, 这将会对你进行的关于状态的测试有帮助 (比如说 S 对象). 但是它不是运行测试所必需的, 不过还是希望你把它包含在这里, 因为你将会在某时用到它.

在我们的设置中, 我们融合了 DBTestKit 和引用了 InMemoryDB 在测试中, 来访问数据库. 你也许会发现, 我们使用 >> 而不是Specs2中的 shouldin. 这是因为我们避免了Squeryl和Specs2的方法名字的重复.

就像我们取消了同步执行在SBT中, 我们也需要取消同步执行在Specs2中 通过使用 sequential . 我们这样做是为了防止一个情况: 一个测试也许需要一个数据, 这个数据正在被另一个测试修改.

如果所有的测试在同一个配置中将使用数据库, 你可以使用Specs2中的 AroundContextExample[T] 来避免使用 InMemoryDB 在每个测试中.为了实现它, 掺入 AroundContextExample[InMemoryDB] 并且定义 aroundContext:

package code.model

import MySchema._

import org.specs2.mutable._
import org.specs2.specification.AroundContextExample
import net.liftweb.squerylrecord.RecordTypeMode._

class AlternativePlanetsSpec extends Specification with
  AroundContextExample[InMemoryDB] {

  sequential

  def aroundContext = new InMemoryDB()

  "Solar System" >> {

    "know that Mars has two moons" >> {

      val mars = planets.insert(Planet.createRecord.name("Mars"))
      Satellite.createRecord.name("Phobos").planetId(mars.idField.is).save
      Satellite.createRecord.name("Deimos").planetId(mars.idField.is).save

      mars.satellites.size must_== 2
    }
  }
}

所有的测试在 AlternativePlanetsSpec 中将含有 InMemoryDB .

我们使用了一个内存数据库, 因为我们需要它的速度和无磁盘文件. 然而, 你可以用自己的数据库: 你需要改变驱动和连接.

See Also

请见 http://www.h2database.com/html/features.html#in_memory_databases 里有更多关于H2数据库的设置文档.

[MongoUnitTest] 中讨论了在MongoDB下的测试, 并且在里面SBT的其他测试命令在这里也同样适用.

存储一个随机值到列中

Problem

你需要一个列中有随机的值.

Solution

使用 UniqueIdField:

import net.liftweb.record.field.UniqueIdField
val randomId = new UniqueIdField(this, 32) {}

请注意, {} 在这里是必需的, UniqueIdField 是一个抽象类.

32是表示生成随机值的位数.

Discussion

UniqueIdField 域是一类 StringField 并且它默认的值是来自 StringHelpers.randomString. 值是随机生成的, 但是不能确保是唯一的在数据库里.

UniqueIdField 下生成的数据库语句为 `varchar(32) not null`或者类似的语句, 它们的值是:

GOJFGQRLS5GVYGPH3L3HRNXTATG3RM5M

就像你看到的一样, 值只是由数字和字母组成, 这使得它很适用于URL中. 比如说, 在 `ProtoUser`中, 你想生成一个唯一的link, 使得用户在email中, 可以使用它来验证用户的账户.

如果你需要改变这个值, 使用 reset 方法在域上将会生成一个随机的新的值.

如果你需要一个自动的值, 特别是每行一个的唯一值, 你可以给你的域包裹一个 universally unique identifier (UUID):

import java.util.UUID

val uuid = new StringField(this, 36) {
  override def defaultValue = UUID.randomUUID().toString
}

这将会自动插入值到表格"6481a844-460a-a4e0-9191-c808e3051519"中, 在你的数据库里.

See Also

Java的UUID在以下有介绍: http://docs.oracle.com/javase/7/docs/api/java/util/UUID.html 里面有一个讲述RFC 4122的文档.

自动生成和更新的时间标签

Problem

你想建立并且更新时间标签在你的record上, 并且希望它们自动更新, 当移行添加或者更新时.

Solution

定义以下的traits:

package code.model

import java.util.Calendar

import net.liftweb.record.field.DateTimeField
import net.liftweb.record.Record

trait Created[T <: Created[T]] extends Record[T] {
  self: T =>
  val created: DateTimeField[T] = new DateTimeField(this) {
    override def defaultValue = Calendar.getInstance
  }
}

trait Updated[T <: Updated[T]] extends Record[T] {
  self: T =>

  val updated = new DateTimeField(this) {
    override def defaultValue = Calendar.getInstance
  }

  def onUpdate = this.updated(Calendar.getInstance)

}

trait CreatedUpdated[T <: Updated[T] with Created[T]] extends
  Updated[T] with Created[T] {
    self: T =>
}

添加这个traits到你的模型中. 比如说, 我们修改 Planet record 中包含自动更新和创建的时间:

class Planet private () extends Record[Planet]
  with KeyedRecord[Long] with CreatedUpdated[Planet] {
    override def meta = Planet
    // field entries as normal...
}

最后, 分配 updated 域:

class MySchema extends Schema {
  ...
  override def callbacks = Seq(
    beforeUpdate[Planet] call {_.onUpdate}
  )
  ...

Discussion

尽管在 net.liftweb.record.LifecycleCallbacks trait 中有一个build可以让你触发onUpdate, afterDelete 等等, 它们只能用在单一的域上, 而不是所有的records. 我们的目标是当Record更新时, 更新每一个域, 所以在这里我们不能使用LiftcycleCallbacks.

CreatedUpdated trait简单的添加了一个 updatedcreated 域到一个record, 但是我们必需要记住添加一个挂钩到机制中来确保 updated 的值, 当我们改变一个record的时候, 改变.

我们需要在机制中添加的CreatedUpdated 需要包含以下两行:

updated timestamp not null,
created timestamp not null

timestamp 是给H2数据库用的. 对于其他数据库, 会不同.

这个值可以被访问就像其他的值一样 [SquerylOneToMany] 我们可以运行以下语句:

val updated : Calendar = mars.updated.id
val created : Calendar = mars.created.is

如果你只想要created time, updated time, 只需要混入 Created[T] 或者 Updaed[T] trait 而不是 CreatedUpdated[T].

这里应该知道的时, onUpdate 只在全部更新的时候才会被调用, 并且不能被Squeryl的部分更新调用. 一个全部更新是指当一个对象被声明, 并且存储时. 一个部分更新是指你尝试声明很多对象通过一条语句.

如果你对其他的Record自动化感兴趣, Squeryl的callback机制也支持其他的触发行为:

  • beforeInsertafterInsert

  • afterSelect

  • beforeUpdateafterUpdate

  • beforeDeleteafterDelete

See Also

以下是一个关于部分更新和全部更新的讨论: http://squeryl.org/inserts-updates-delete.html.

SQL日志

Problem

你想看Squeryl是如何执行SQL的.

Solution

添加以下语句在你有一个Squeryl会话时, 比如, 在你的语句前:

org.squeryl.Session.currentSession.setLogger( s => println(s) )

通过提供一个 String => Unit 方法到 setLogger, Squeryl 将会执行这个方法, 当SQL运行时. 在这个例子中, 我们只是简单的把输出打印到console.

Discussion

你也许希望使用日志机制在Lift中捕获SQL语句, 比如说:

package code.snippet

import net.liftweb.common.Loggable
import org.squeryl.Session

class MySnippet extends Loggable {

  def render = {
    Session.currentSession.setLogger( s => logger.info(s) )
    // ...your snippet code here...
  }
}

以上代码会根据日志系统的设置来记录语句的日志, 你只需要设置 src/resources/props/default.logback.xml 文件.

也许设置每个代码片段的日志非常不方便. 如果你想触发日志在所有的代码片段上, 你可以修改 addAround 方法在 Boot.scala 中.

请回忆以下, 在 [ConfiguringSqueryl] 中, 我们是这样定义的:

S.addAround(new LoanWrapper {
  override def apply[T](f: => T): T = inTransaction { f }
})

为了开启日志在每个会话中, 我们可以修改 Boot.scala 来混入日志系统, 并且开启日志:

class Boot extends Loggable {

  // ...boot as usual here...

  S.addAround(new LoanWrapper {
    override def apply[T](f: => T): T = inTransaction {
      Session.currentSession.setLogger( s => logger.info(s) )
      f
    }
  })

}

See Also

Squeryl 的 setLogger 在下面的地址有文档: http://squeryl.org/miscellaneous.html.

你可以学到更多关于Lift的日志在Lift Wiki: https://www.assembla.com/spaces/liftweb/wiki/Logging.

使用MySQL MEDIUMTEXT

Problem

你想使用MySQL的 MEDIUMTEXT 在一个列上, 但是 StringField 没有这个选项.

Solution

使用Squeryl的 dbType 在你的机制中:

object MySchema extends Schema {
  on(mytable)(t => declare(
    t.mycolumn defineAs dbType("MEDIUMTEXT")
  ))
}

这个机制设置会给你一个正确的列类型在MySQL中:

create table mytable (
    mycolumn MEDIUMTEXT not null
);

在Record中, 你可以想平常一样使用 StringField.

Discussion

这章涉及了Squeryl的机制设置的灵活性和DSL. 这里的列设置是很多个你可以自行调整的设置之一.

比如说, 你可以使用语句来连锁一个列属性对于一个单一的列, 并且定义很多个列在同一时间:

object MySchema extends Schema {
  on(mytable)(t => declare(
    t.mycolumn defineAs(dbType("MEDIUMTEXT"),indexed),
    t.id definedAs(unique, named("MY_ID"))
  ))
}

See Also

机制定义页面给你很多可以选择自定义的关于表和列属性

http://squeryl.org/schema-definition.html.

MySQL编码

Problem

一些存储在MySQL数据库的数据显示为 ???.

Solution

请确保:

  • LiftRules.early.append(_.setCharacterEncoding("UTF-8"))Boot.scala 中.

  • ?useUnicode=true&characterEncoding=UTF-8 在你的数据库连接中.

  • 你的数据库使用UTF-8编码建立.

Discussion

这里有很多的互相环节, 它们会影响字符的编码, 一个MySQL数据库, 基本的问题是, 数据的传输在网络之间没有任何意义, 除非你能知道它的编码.

setCharacterEncoding("UTF-8")Boot.scala 被调用在每一个 HTTPRequest , 在一个容器中 , 它作用在 ServletRequest. 这就是为什么一个请求的参数会被分析, 当一个容器收到这个请求时.

与它相反的是, 一个由Lift发出的回复会被用UTF-8编码, 你会在很多地方看到这个设置. 比如说, templates-hidden/default 包含:

<meta http-equiv="content-type" content="text/html; charset=UTF-8" />

并且, LiftResponse 设置为UTF-8编码.

另一个需要关注的是, 字符数据如何从Lift通过网络发送到数据库. 这是被JDBC驱动的参数控制的. 默认的MySQL会查看参数上的编码, 然后自动应用它. 在这里, 我们强制用UTF-8.

最后, MySQL数据库自己需要被设置成存储数据的编码为UTF-8. 默认的设置不是UTF-8, 所以当你创建数据库的时候, 你需要强制数据库为UTF-8.

CREATE DATABASE myDb CHARACTER SET utf8

使用mapper在表达层的关系型数据库

序列数和存在数据

Problem

你有一个数据库, 里面有已经存放的数据, 但是Lift插入数据是通过从1开始的primary key id, 或者其他已经存在的数据数据

Solution

通过修改数据库的自然序列数, 使得Lift知道你想开始的序列数的值.

第一步, 你需要知道, 你的数据库是从什么数开始的. 最可能的情况下, 这个数是在你的一个最大的primary key ID上. 在PostgreSQL中, 比如说, 你可以通过使用命令 psql 查看:

SELECT MAX(id) FROM mytable;

为了找到存在的数, 运行 \ds in psql. 序列的改变会包含你table的名字. 你可以这样改变它:

ALTER SEQUENCE mytable_id_seq RESTART WITH 1000;

Discussion

Lift遵循数据库的序列规定来生成一个primary key. 如果你想插入序列之间的数据, 序列数将不会知道它应该插入的位置 (这不是Lift所能做的). 所以以上答案是通过修改起始的序列, 跳过已有的序列数, 来找到正确的位置.

以上也可以用在Mapper和Record上.

See Also

监视schema的改变

Problem

你不想自动转移你的schema, 但是你想知道它是否被改变.

Solution

运行 Schemifier , 然后捕获它的改变. 比如说, 在 Boot.scala 中:

val cmds = Schemifier.schemify(false, true, Schemifier.infoF _, User, Company)

if (!cmds.isEmpty) {
  error("Database schema is out of date. The following is missing: \n"+
    cmds.mkString("\n"))
}

Discussion

命令 schemify 的参数和返回为:

  • performWrite - 这个例子使用了 false 意味着数据库将不会update.

  • structureOnly - true to check the tables and columns (not indexes).

  • logFunc - 作为Log, 但是只当我们需要写入数据库, 但是我们不需要.

  • tables (mapper objects) - 我们需要的tables.

  • 结果是一个 List[String] 是一个准备更新的语句.

根据以上的信息, 你可以自由的选择你的参数, 来满足你的需求

MongoDB Persistence with Record

这章中, 我们介绍关于使用MongoDB在Lift的应用中. 如果你想了解更多关于Mongo本身, 请看:http://cookbook.mongodb.org/.

这章的代码在如下地址: https://github.com/LiftCookbook/cookbook_mongo.

连接到MongoDB

Problem

你想连接到MongoDB.

Solution

添加Lift的Mongo依赖库到你的应用中, 并且设置连接, 通过使用 net.liftweb.mongodbcom.mongodb.

Build.sbt`中, 添加以下代码到`libraryDependencies:

"net.liftweb" %% "lift-mongodb-record" % liftVersion,

Boot.scala 添加:

import com.mongodb.{ServerAddress, Mongo}
import net.liftweb.mongodb.{MongoDB,DefaultMongoIdentifier}

val server = new ServerAddress("127.0.0.1", "20717")
MongoDB.defineDb(DefaultMongoIdentifier, new Mongo(server), "mydb")

以上代码会给你一个连接到本地的MongoDB, 连接名为: "mydb".

Discussion

如果你的数据库需要验证, 你需要使用MongoDB.defineDbAuth:

MongoDB.defineDbAuth(DefaultMongoIdentifier, new Mongo(server),
  "mydb", "username", "password")

一些云服务会给你一个URL让你接入, 比如说"mongodb://alex.mongohq.com:10050/fglvBskrsdsdsDaGNs1". 在这里, 前一部分是你的host和端口, 在`/`后是你的数据库名字.

如果你需要连接一个这样的URL到Mongo, 你可以使用 java.net.URI 分析URL然后使用它:

object MongoUrl {

  def defineDb(id: MongoIdentifier, url: String) {

    val uri = new URI(url)

    val db = uri.getPath drop 1
    val server = new Mongo(new ServerAddress(uri.getHost, uri.getPort))

    Option(uri.getUserInfo).map(_.split(":")) match {
      case Some(Array(user,pass)) =>
        MongoDB.defineDbAuth(id, server, db, user, pass)
      case _ =>
        MongoDB.defineDb(id, server, db)
    }
  }

}

MongoUrl.defineDb(DefaultMongoIdentifier,
  "mongodb://user:pass@127.0.0.1:27017/myDb")

Mongo上, 完整的URL scheme是非常复杂的, 它允许多个host和connection作为参数, 但是上边的代码可以处理可选的用户名和密码, 它足够帮你建立连接了.

DefaultMongoIdentifier 是一个用来设置特定连接的值.Lift把一个Identifier映射到一个连接上, 这样你可以使用多个数据库. 一般, 我们只用一个数据库, 所以我们使用DefaultMongoIdentifier.

然而, 如果你必须要同时访问两个Mongo数据库, 你建立一个新的Identifier, 然后就分配给Record.比如说:

object OtherMongoIdentifier extends MongoIdentifier {
  def jndiName: String = "other"
}

MongoUrl.defineDb(OtherMongoIdentifier, "mongodb://127.0.0.1:27017/other")

object Country extends Country with MongoMetaRecord[Country] {
  override def collectionName = "example.earth"
  override def mongoIdentifier = OtherMongoIdentifier
}

在这里, lift-mongodb-record 本身依赖于另一个Lift module下的lift-mongodb, 它提供了一个更底层的Mongo数据库的访问.

See Also

一些关于Mongo的设置, 参数集合都在以下地址有介绍: https://www.assembla.com/wiki/show/liftweb/Mongo_Configuration.

一个全面的介绍关于Mongo连接URL: http://docs.mongodb.org/manual/reference/connection-string/.

使用Mongo Record存储一个HashMap

Problem

你想存储一个HashMap在Mongo中.

Solution

建立一个Mongo Recrod, 并且包含 MongoMapField:

import net.liftweb.mongodb.record._
import net.liftweb.mongodb.record.field._

class Country private () extends MongoRecord[Country] with StringPk[Country] {
  override def meta = Country
  object population extends MongoMapField[Country,Int](this)
}

object Country extends Country with MongoMetaRecord[Country] {
  override def collectionName = "example.earth"
}

在这个例子中, 我们建立了一个Record来存储Country的信息. 在这里, population 是一个映射, 从 String 建到 Integer 值.

我们可以在snippet中这样使用:

class Places {

  val uk = Country.find("uk") openOr {
    val info = Map(
      "Brighton" -> 134293,
      "Birmingham" -> 970892,
      "Liverpool" -> 469017)

    Country.createRecord.id("uk").population(info).save
  }

  def facts = "#facts" #> (
    for { (name,pop) <- uk.population.is } yield
      ".name *" #> name & ".pop *" #> pop
  )
}

当这个snippet被调用时, 它会查找一个record通过使用 _id 的 "uk" 或者创建它通过一些加工后的信息. HTML模版可以这样调用它:

<div data-lift="Places.facts">
 <table>
  <thead>
   <tr><th>City</th><th>Population</th></tr>
  </thead>
  <tbody>
   <tr id="facts">
    <td class="name">Name here</td><td class="pop">Population</td>
   </tr>
  </tbody>
 </table>
</div>

在Mongo数据库中, 数据是这样存储的:

$ mongo cookbook
MongoDB shell version: 2.0.6
connecting to: cookbook
> show collections
example.earth
system.indexes
> db.example.earth.find().pretty()
{
  "_id" : "uk",
  "population" : {
    "Brighton" : 134293,
    "Birmingham" : 970892,
    "Liverpool" : 469017
  }
}

Discussion

如果你不给映射一个值, 默认的将是一个空映射, 在Mongo中这样表示:

({ "_id" : "uk", "population" : { } })

你也可以mark它为一个可选的映射:

object population extends MongoMapField[Country,Int](this) {
  override def optional_? = true
}

如果你现在写一个document而不包含 population, 在Mongo中将是这样:

> db.example.earth.find();
{ "_id" : "uk" }

为了在你的snippet中添加一个data到映射, 你可以修改一个record, 建立一个新的 Map:

uk.population(uk.population.is + ("Westminster"->81766)).update

请注意, 我们使用 update 在这里, 而不是 save. 方法 save 非常聪明, 并且将会插入一个新的document到数据库中, 或者替换一个现有的值通过匹配它的 _id. Update是不一样的: 它将只会检查document中改变的域, 然后更新它. 上面的语句将会使用以下的Mongo语句:

{ "$set" : { "population" : { "Brighton" : 134293 , "Liverpool" : 469017 ,
  "Birmingham" : 970892 , "Westminster" : 81766} }

所以这里, 你使用 update 而不是 save 来更新一个已经存在的信息.

为了访问一个映射中独立的元素, 你可以使用 get (或者 value):

uk.population.get("San Francisco")
// will throw java.util.NoSuchElementException

或者你可以通过使用标准的Scala映射接口:

val sf : Option[Int] = uk.population.is.get("San Francisco")
MongoMapField 能包含什么

你应该知道, MongoMapField 只支持primitive types.

在这章中, 使用的类型为 String => Int. 但是, Mongo也允许你使用一个mix的类型, 比如 String 或者一个 Boolean`作为population的值. 如果你修改Mongo Record在数据库, 而不是Lift, 你将会得到一个`java.lang.ClassCastException 在runtime中.

See Also

在邮件列表上, 有关于 MongoMapField 支持类型限制的讨论, 并且一个可行的方法是重载`asDBObject`, 你可以在以下地址找到: https://groups.google.com/d/msg/liftweb/XoseG-8mIPc/OLyIu6FrHIgJ.

在一个Document中镶嵌一个Mongo Record

Problem

你有一个Mongo Record, 你想镶嵌另一个值的集合到里面, 并且作为一个整体.

Solution

使用 BsonRecord 来定义镶嵌的元素, 然后使用`BsonRecordField`镶嵌它. 这里有一个存储一个照片的信息的例子:

import net.liftweb.record.field.{IntField,StringField}

class Image private () extends BsonRecord[Image] {
  def meta = Image
  object url extends StringField(this, 1024)
  object width extends IntField(this)
  object height extends IntField(this)
}

object Image extends Image with BsonMetaRecord[Image]

我可以引用 Image 的实例, 通过调用 BsonRecordField:

class Country private () extends MongoRecord[Country] with StringPk[Country] {
  override def meta = Country
  object flag extends BsonRecordField(this, Image)
}

object Country extends Country with MongoMetaRecord[Country] {
  override def collectionName = "example.earth"
}

为了加入一个值:

val unionJack =
  Image.createRecord.url("http://bit.ly/unionflag200").width(200).height(100)

Country.createRecord.id("uk").flag(unionJack).save(true)

在Mongo中, 结果是:

> db.example.earth.findOne()
{
  "_id" : "uk",
  "flag" : {
    "url" : "http://bit.ly/unionflag200",
    "width" : 200,
    "height" : 100
  }
}

Discussion

如果你不给镶嵌的document一个值, 结果是:

"flag" : { "width" : 0, "height" : 0, "url" : "" }

你可以防止以上情况, 通过使用默认的值:

object image extends BsonRecordField(this, Image) {
  override def optional_? = true
}

通过使用 optional_?, document将不会被存储, 如果你不给image一个值. 在Scala中, 你会想调用 valueBox:

val img : Box[Image] = uk.flag.valueBox

事实上, 无论`optional_?的设置是什么, 你都可以访问值, 通过使用 `valueBox.

另一个方法是你可以设置一个默认的值:

object image extends BsonRecordField(this, Image) {
 override def defaultValue =
  Image.createRecord.url("http://bit.ly/unionflag200").width(200).height(100)
}

See Also

连接两个Mongo Record

Problem

你有一个Mongo Record, 你想连到到另一个.

Solution

建立一个引用, 通过使用 MongoRefField, 比如说 ObjectIdRefField 或者 StringRefField, 然后通过使用 obj 来连接.

作为例子, 我们建立一个包含所有国家的Record, 然后我们连接国家到相关的植物:

class Planet private() extends MongoRecord[Planet] with StringPk[Planet] {
  override def meta = Planet
  object review extends StringField(this,1024)
}

object Planet extends Planet with MongoMetaRecord[Planet] {
  override def collectionName = "example.planet"
}

class Country private () extends MongoRecord[Country] with StringPk[Country] {
  override def meta = Country
  object planet extends StringRefField(this, Planet, 128)
}

object Country extends Country with MongoMetaRecord[Country] {
  override def collectionName = "example.country"
}

在下面的Snippet中, 我们建立连接:

class HelloWorld {

  val uk = Country.find("uk") openOr {
    val earth = Planet.createRecord.id("earth").review("Harmless").save
    Country.createRecord.id("uk").planet(earth.id.is).save
  }

  def facts =
    ".country *" #> uk.id &
    ".planet" #> uk.planet.obj.map { p =>
      ".name *" #> p.id &
      ".review *" #> p.review }
  }

对于 uk 的值, 我们查找一个现存的Record, 或者建立一个新的, 如果没有现存的. 请注意, earth 是一个独立的Mongo Record, 它有一个引用在 planet 的id上.

检索引用, 通过使用 obj 方法, 它将返回一个 Box[Planet] 在这个例子中.

Discussion

当你调用 obj 方法在一个 MongoRefField`上时, 被引用的部分将被fetch.你可以通过打开Mongo上的Log来观察这个行为. 你可以添加以下代码到 `Boot.scala:

System.setProperty("DEBUG.MONGO", "true")
System.setProperty("DB.TRACE", "true")

第一次你运行以上代码的时候, 你将会看到:

INFO: find: cookbook.example.country { "_id" : "uk"}
INFO: update: cookbook.example.planet { "_id" : "earth"} { "_id" : "earth" ,
    "review" : "Harmless"}
INFO: update: cookbook.example.country { "_id" : "uk"} { "_id" : "uk" ,
    "planet" : "earth"}
INFO: find: cookbook.example.planet { "_id" : "earth"}

在这里你看到的是初始的对于"uk"的查找, 接下来是建立 "earth" record 和 存储 "uk" record. 最后, 还有一个对 "earth" 的查找, 当 uk.objfact 方法调用.

方法 obj 将会缓存 planet 引用. 这意味着你可以这样…

".country *" #> uk.id &
".planet *" #> uk.planet.obj.map(_.id) &
".review *" #> uk.planet.obj.map(_.review)

…尽管你调用'obj'很多次, 但是你只会见到一个关于 "earth" record的语句. 这就意味着, 如果你调用的 "earth" record在Mongo的另一处被改变, 在你调用了 obj 后你将不会看到 uk.obj 的改变, 除非你重载`uk`record.

编写关于引用的语句

通过一个引用, 搜索Record是非常简单的:

val earth : Planet = ...
val onEarth : List[Country]= Country.findAll(Country.planet.name, earth.id.is)

或者, 在这个例子中, 因为我们有 String 引用, 我们可以这样:

val onEarth : List[Country]= Country.findAll(Country.planet.name, "earth")
更新和删除

像你期望的一样, 更新一个引用:

uk.planet.obj.foreach(_.review("Mostly harmless.").update)

以上语句的结果是:

INFO: update: cookbook.example.planet { "_id" : "earth"} { "$set" : {
   "review" : "Mostly harmless."}}

一个 uk.planet.obj 调用将会返回一个有新的review的植物.

或者你可以替换引用为另一个:

uk.planet( Planet.createRecord.id("mars").save.id.is ).save

请注意, 引用是通过id连接的 (save.id.is), 不是record本身.

为了取消引用:

uk.planet(Empty).save

它将会移除连接, 但是Mongo Record的指针还是存在的.如果你移除了一个引用, 然后调用 obj 将会返回一个 Empty box.

连接的类型

这个例子使用 StringRefField 作为 Mongo records,使用 String 作为 _id. 另外引用类型为:

  • ObjectIdRefField — 也许是最常用的引用类型, 当你想通过`ObjectId`作为引用时, 你可以使用它.

  • UUIDRefField — 适用于那些id基于 `java.util.UUID`的record.

  • StringRefField — 就像这章的例子一样, 当你的id为`String`时.

  • IntRefFieldLongRefField — 当你想使用一个数值作为ID时.

See Also

10Gen Inc’s 数据模型决策 解释了镶嵌文档和引用文档的不同. 你可以找到更多信息在: http://docs.mongodb.org/manual/core/data-modeling/.

使用 Rogue

Problem

你想使用Foursquare的 type-safe domain specific language (DSL), Rogue, 来编写语句和更新Mongo records.

Solution

你需要包含Rogue的依赖库在你的编译中.

首先, 编辑 build.sbt 和添加:

"com.foursquare" %% "rogue" % "1.1.8" intransitive()

在你的代码 import com.foursquare.rogue._ 然后开始用Rogue. 比如说, 使用Scala console (see [MongoScalaConsole]):

scala> import com.foursquare.rogue.Rogue._
import com.foursquare.rogue.Rogue._

scala> import code.model._
import code.model._

scala> Country.where(_.id eqs "uk").fetch
res1: List[code.model.Country] = List(class code.model.Country={_id=uk,
  population=Map(Brighton->134293, Liverpool->469017, Birmingham->970892)})

scala> Country.where(_.id eqs "uk").count
res2: Long = 1

scala> Country.where(_.id eqs "uk").
  modify(_.population at "Brighton" inc 1).updateOne()

Discussion

Rogue能使用你Lift Record中的信息, 提供了一个优雅的方法来编写Mongo语句. 它是类型安全的, 这意味着, 比如说, 如果你常识使用一个 IntString 类型上, Mongo将会允许运行这个语句, 但是在runtime上无法找到结果, 但是Rogue会拒绝编译这类语句.

scala> Country.where(_.id eqs 7).fetch
<console>:20: error: type mismatch;
 found   : Int(7)
 required: String
              Country.where(_.id eqs 7).fetch

DSL建立了一个语句, 它在 fetch 后会发送给 MongoDB. 最后的一个方法, fetch, 只是运行一段语句的方法之一. 其他的方法包括:

  • count — 告诉Mongo返回结果的数量.

  • countDistinct — 不同的值在返回结果的数量.

  • exists — 如果存在满足query的结果, 返回true.

  • get —  返回一个 Option[T] 从提供的Query.

  • fetch(limit: Int) — 类似 fetch 但是返回 limit 数量的结果.

  • updateOne, updateMulti, upsertOneupsertMulti — 修改符合query的, 一个单一的document, 或者全部 documents.

  • findAndDeleteOne and bulkDelete_!! — 删除record.

Query语言本身是很昂贵的, 最好测试不同query语言的地方是使用 QueryTest 特性在Rogue中. 你可以通过看它的Github上的README文件来学习.

Note
Rogue v2 版本有很多新的特性. 如果你想尝试它, 请看Rogue的邮件列表: https://groups.google.com/d/topic/rogue-users/SdtFCU-w3ng/.

See Also

对于 geospacial queries, 请见 [MongoGeospatial].

Rogue的README文件是一个对于新手来说很好的学习材料, 里面有一个链接是关于 QueryTest, 里面有很多实用的例子: https://github.com/foursquare/rogue.

存储地理空间的数据

Problem

你想在Mongo中存储经度和纬度.

Solution

使用 Rogue的 LatLong 类来存储地理信息在你的model中. 比如说, 我们可以存储一个城市的地理信息:

import com.foursquare.rogue.Rogue._
import com.foursquare.rogue.LatLong

class City private () extends MongoRecord[City] with ObjectIdPk[City] {
  override def meta = City

  object name extends StringField(this, 60)

  object loc extends MongoCaseClassField[City, LatLong](this)
}

object City extends City with MongoMetaRecord[City] {
  import net.liftweb.mongodb.BsonDSL._
  ensureIndex(loc.name -> "2d", unique=true)

  override def collectionName = "example.city"
}

我们可以这样存储一个位置:

val place = LatLong(50.819059, -0.136642)
val city = City.createRecord.name("Brighton, UK").loc(pos).save(true)

这将会生成一个数据:

{
  "_id" : ObjectId("50f2f9d43004ad90bbc06b83"),
  "name" : "Brighton, UK",
  "loc" : {
    "lat" : 50.819059,
    "long" : -0.136642
  }
}

Discussion

MongoDB 提供一个 geospatial indexes, 我们使用它做两件事情. 首先, 我们存储地理信息到一个Mongo许可的格式. 这个格式可以包含坐标. 我们还使用一个有两个数值的数组表达点.

其次, 我们建立了一个为 "2d" 的检索, 它将允许我们使用Mongo自带的方法, 比如说 $near 和as $within. 语句 unique=trueensureIndex 中高亮, 意味着你可以控制是否为唯一 (true, 没有重复) 或者no (false).

关于唯一的检索, 你会发现, 在这个例子中, 当我们调用 save(true)City 上, 而不是单纯的 save 像其他章一样. 我们可以使用 save, 而且工作的很好, 但是不同的是 save(true) 提升了等级从 "normal" 到 "safe".

调用 save 会在请求发送到Mongo服务器上后返回. 这会让你觉得 save 在网路掉线时, 会失败. 然而, 这不意味着服务器不处理这个请求. 比如说, 如果我们插入一个地理信息和服务器上的已有信息完全一样, 检索将发现, 并拒绝. 如果只使用 save (或者 save(false)) 我们的Lift应用将不会收到这个错误, 请求将会悄悄地停止. 如果你对 "safe" 更关心, 那么使用 save(true) ,直到Mongo服务器返回, 这意味着应用将收到一些异常.

作为一个例子, 如果我们尝试插入一个重复的city, 我们调用 save(true) 将会有以下结果:

com.mongodb.MongoException$DuplicateKey: E11000 duplicate key
  error index: cookbook.example.city.$loc_2d

你也可以使用其他类型的 save, 比如说以 WriteConcern 为参数.

如果你想删除一个检索, Mongo的命令为:

db.example.city.dropIndex( "loc_2d" )
编写语句

我们在这章使用Rogue的 LatLong 类的原因是让我们使用Rogue DSL来编写语句. 假设我们插入其他city到我们的collection中:

> db.example.city.find({}, {_id:0} )
{"name": "London, UK", "loc": {"lat": 51.5, "long": -0.166667} }
{"name": "Brighton, UK", "loc": {"lat": 50.819059, "long": -0.136642} }
{"name": "Paris, France", "loc": {"lat": 48.866667, "long": 2.333333} }
{"name": "Berlin, Germany", "loc": {"lat": 52.533333, "long": 13.416667} }
{"name": "Sydney, Australia", "loc": {"lat": -33.867387, "long": 151.207629} }
{"name": "New York, USA", "loc": {"lat": 40.714623, "long": -74.006605} }

我们现在可以找到方圆500千米内, 临近伦敦的城市:

import com.foursquare.rogue.{LatLong, Degrees}

val centre = LatLong(51.5, -0.166667)
val radius = Degrees( (500 / 6378.137).toDegrees )
val nearby = City.where( _.loc near (centre.lat, centre.long, radius) ).fetch()

你可以这样编写语句…

{ "loc" : { "$near" : [ 51.5 , -0.166667 , 4.491576420597608]}}

…以上语句将定位 London, Brighton 和 Paris 在 London 附近.

这个语句的格式是, 找到中心点和半径, 然后找到其中的city. 我们可以这样计算半径, 用500km除以地球的半径, 大概是6378km, 然后会得到一个夹角. Rogue可以使用它作为 Degrees.

See Also

Mongo DB上有很多关于地理信息检索的文档: http://docs.mongodb.org/manual/core/geospatial-indexes/.

你可以学到更多关于写入: http://docs.mongodb.org/manual/core/write-operations/, 和更多不同的 save: http://api.mongodb.org/java/current/.

在Scala控制台运行语句

Problem

你想互动地使用一些语句, 然后即时看到效果.

Solution

打开你的工程, 然后打开你的控制台, 调用 boot(), 然后和你的model互动.

比如说, 使用Mongo Record开发, 作为 [ConnectingToMongo] 的一部分, 我们可以使用以下基本语句:

$ sbt
...
> console
[info] Compiling 1 Scala source to /cookbook_mongo/target/scala-2.9.1/classes...
[info] Starting scala interpreter...
[info]
Welcome to Scala version 2.9.1.final ...
Type in expressions to have them evaluated.
Type :help for more information.

scala> import bootstrap.liftweb._
import bootstrap.liftweb._

scala> new Boot().boot

scala> import code.model._
import code.model._

scala> Country.findAll
res2: List[code.model.Country] = List(class code.model.Country={_id=uk,
  population=Map(Brighton -> 134293, Liverpool -> 469017,
  Birmingham -> 970892)})

scala> :q

Discussion

Boot 中运行一切也许会变得简单, 特别是当你使用后台程序的时候. 所有我们需要做的, 只是设置一个数据库连接. 比如说, 使用 [ConnectingToMongo] 中的例子, 我们可以这样初始化一个连接:

scala> import bootstrap.liftweb._
import bootstrap.liftweb._

scala> import net.liftweb.mongodb._
import net.liftweb.mongodb._

scala> MongoUrl.defineDb(DefaultMongoIdentifier,
  "mongodb://127.0.0.1:27017/cookbook")

scala> Country.findAll
res2: List[code.model.Country] = List(class code.model.Country={_id=uk,
  population=Map(Brighton -> 134293, Liverpool -> 469017,
    Birmingham -> 970892)})

See Also

[ConnectingToMongo] 中有如何连接到Mongo的介绍, [QueryingWithRogue] 有如何使用Rogue的介绍.

Unit Testing Record

Problem

你想写一些 unit tests 在你的Lift Record代码上, 并且使用Mongodb.

Solution

使用 Specs2 testing framework, 它会建立连接对于每一个测试, 然后在测试完成后摧毁连接.

建立一个Scala trait来建立和摧毁一个对Mongo的连接. 我们把这个trait融合到你的设置中:

import net.liftweb.http.{Req, S, LiftSession}
import net.liftweb.util.StringHelpers
import net.liftweb.common.Empty
import net.liftweb.mongodb._
import com.mongodb.ServerAddress
import com.mongodb.Mongo
import org.specs2.mutable.Around
import org.specs2.execute.Result

trait MongoTestKit {

  val server = new Mongo(new ServerAddress("127.0.0.1", 27017))

  def dbName = "test_"+this.getClass.getName
    .replace(".", "_")
    .toLowerCase

  def initDb() : Unit = MongoDB.defineDb(DefaultMongoIdentifier, server, dbName)

  def destroyDb() : Unit = {
    MongoDB.use(DefaultMongoIdentifier) { d => d.dropDatabase() }
    MongoDB.close
  }

  trait TestLiftSession {
    def session = new LiftSession("", StringHelpers.randomString(20), Empty)
    def inSession[T](a: => T): T = S.init(Req.nil, session) { a }
  }

  object MongoContext extends Around with TestLiftSession {
    def around[T <% Result](testToRun: =>T) = {
      initDb()
      try {
        inSession {
          testToRun
        }
      } finally {
        destroyDb()
      }
    }
  }

}

这个trait提供一个连接通道到一个本地运行的Mongo服务器中, 然后建立一个数据库基于它mix的类. 一个值得注意的地方是, MongoContext 确保了 around 你的设置是已经被初始化后的, 并且在你的测试完成后, 它会摧毁连接.

为了使用它, 混入一个设置中:

import org.specs2.mutable._

class MySpec extends Specification with MongoTestKit {

  sequential

  "My Record" should {

    "be able to create records" in MongoContext {
      val r = MyRecord.createRecord
      // ...your useful test here...
      r.valueBox.isDefined must beTrue
    }

  }
}

你现在可以运行SBT, 通过使用 test:

> test
[info] Compiling 1 Scala source to target/scala-2.9.1/test-classes...
[info] My Record should
[info] + be able to create records
[info]
[info]
[info] Total for specification MySpec
[info] Finished in 1 second, 199 ms
[info] 1 example, 0 failure, 0 error
[info]
[info] Passed: : Total 1, Failed 0, Errors 0, Passed 0, Skipped 0
[success] Total time: 1 s, completed 03-Jan-2013 22:47:54

Discussion

一般情况下, Lift提供了所有你需要来连接, 测试和运行MongoDB的支持.如果没有一个运行的Lift程序, 我们需要确保当我们运行测试时, Mongo的配置是正确的, 这就是trait MongoTestKit 提供给我们的服务.

在测试中, 一个不常用的部分是使用 TestLiftSession. 它提供了一个空会话在你的测试中, 这十分有用, 特别是你想测试一些stateful的代码 (比如说, 关于 S 的). 它不是Record必须使用的代码, 但是在这里, 比如说你想测试用户登入的时候, 你需要它.

这里有一些使用SBT测试的小技巧. 运行 test 将会运行所有你工程中的测试. 如果你想运行一个测试, 你可以:

> test-only org.example.code.MySpec

这个命令同样支持掩码, 如果我们只想运行所有以 "Mongo" 开头的测试, 我们可以:

> test-only org.example.code.Mongo*

还有一个 test-quick 方法(在 SBT 0.12 中), 它只运行没有运行过的测试, 被改变过的测试, 或者上一次失败的测试 并且 ~test 命令可以观看测试的改变和运行他们.

test-onlyaroundMongoTestKit 中一起使用, 是一个好的测试和跟踪你的测试的方法. 通过关闭 destroyDb() 调用, 你可以在一个测试运行时, 跳转到MongoDB shell, 并且测试数据库的状态.

你可以通过设置一个你想删除的collection, 来避免每次测试后全部删除. 你可以使用一个预先设置的collection, 然后替换 destroyDb 方法:

lazy val collections : List[MongoMetaRecord[_]] = List(MyRecord)

def destroyDb() : Unit = {
  collections.foreach(_ bulkDelete_!! new BasicDBObject)
  MongoDB.close
}

请注意, 这里的collection列表为 lazy, 这是为了避免Record在我们运行数据库连接前运行.

清理数据库

在我们每次使用数据库后, 我们可以简单的删除数据库, 然后下次我们使用时, 它便是一个空的数据库. 在一些情况下, 你不能这样做. 比如说, 你运行一些测试, 而数据库是建立在MongoHQ 或者MongoLabs上的. 如果你删除数据库, 你下次将不能再连入它.

平行测试

如果你的测试改变了数据, 并且有可能改变的数据需要和别的测试互动, 你会想让SBT停止平行运行你的测试. 一个简单的发现这种情况的方法是, 你会发现你的测试时而成功, 时而失败, 或者一个原本成功的测试, 在你添加了一些测试后失败. 你可以通过添加以下一句到 build.sbt:

parallelExecution in Test := false

你会发现, 这个例子的设置中有: sequential. 它默认的禁止了所有平行测试.

在IDE中运行测试

IntelliJ IDEA 检查并且允许你使用Specs2. 在 Eclipse 中, 你将需要包含JUnit测试声明在你的设置中, 如下:

import org.junit.runner.RunWith
import org.specs2.runner.JUnitRunner

@RunWith(classOf[JUnitRunner])
class MySpec extends Specification with MongoTestKit  {
...

然后, 你可以使用 "Run As…".

See Also

Specs2 的文档在: http://specs2.org/.

如果你更喜欢使用Scala的测试架构 (http://www.scalatest.org), 请看 Tim Nelson’s Mongo Auth Lift module 在 https://github.com/eltimn/lift-mongoauth. 它包含了如何使用它在Mongo环境下. 里面很多Tim写的内容, 在这章的 Specs2 中也有.

Lift Mongo Record library 包含了一个 Specs2, 使用 BeforeAfter 而不是这章中的 around. 如果你更喜欢它, 你可以在以下地方找到代码: https://github.com/lift/framework/tree/master/persistence/mongodb-record/src/test/scala/net/liftweb/mongodb/record.

Flapdoodle (https://github.com/flapdoodle-oss/embedmongo.flapdoodle.de 提供了一个能自动下载, 清理, 安装MongoDB的功能. 这个自动化工具可以让你包裹你的Unit Test在Specs2中, 并且一个 Specs2 集成包含在里面, 比如说 BeforeAfter 工具: https://github.com/athieriot/specs2-embedmongo.

测试的接口是由SBT提供的, 比如说命令 test, 它也提供了分享测试, 和对每个测试进行单独的配置的功能. 你可以察看更多设置在以下文档: http://www.scala-sbt.org/release/docs/Detailed-Topics/Testing.

Lift周边

这章将给出很多例子关于Lift和其他系统, 比如说发送email或者运行一个task.

Many of the recipes in this chapter have code examples in a project at Github: https://github.com/LiftCookbook/cookbook_around.

发送一个文本email

Problem

你想只用Lift应用发送一个纯文本的Email

Solution

使用 Mailer:

import net.liftweb.util.Mailer
import net.liftweb.util.Mailer._

Mailer.sendMail(
  From("you@example.org"),
  Subject("Hello"),
  To("other@example.org"),
  PlainMailBodyType("Hello from Lift") )

Discussion

Mailer 异步的发送一个信息, 这意味着 sendMail 将会立刻的返回值, 所以你不会担心这里会有时间花费在一个 SMTP 服务. 还有一个方法 blockingSendMail 它将block, 然后等待返回.

默认的, SMTP 将使用 localhost. 你可以通过添加 mail.smtp.host 属性来改变它. 比如说, 修改 src/mail/resources/props/default.props 然后添加:

mail.smtp.host=smtp.example.org

方法 sendMail 的参数是 From, Subject 和任意数量的 MailTypes:

  • To — 收件人地址.

  • CC — 抄送

  • BCC — 抄送

  • ReplyTo — 回复人的地址.

  • MessageHeader — key/value信息的头部.

  • PlainMailBodyType — 一个文本的, UTF-8编码的Email.

  • PlainPlusBodyType — 你自定义编码的文本Email.

  • XHTMLMailBodyType — HTML email ([HTMLEmail]).

  • XHTMLPlusImages —  有附件的email ([EmailWithAttachments]).

在上一个例子中, 我们添加了两个类型:PlainMailBodyTypeTo. 如果你喜欢, 你可以添加更多:

Mailer.sendMail(
  From("you@example.org"),
  Subject("Hello"),
  To("other@example.org"),
  To("someone@example.org"),
  MessageHeader("X-Ignore-This", "true"),
  PlainMailBodyType("Hello from Lift") )

类似于 MailTypes (To, CC, BCC, ReplyTo) 的地址可以给于 "personal name":

From("you@example.org", Full("Example Corporation"))

以上会显示在你的邮箱:

From: Example Corporation <you@example.org>

默认的编码是 UTF-8. 如果你需要改变它, 可以改变 PlainMailBodyTypePlainPlusBodyType("Hello from Lift", "ISO8859_1").

See Also

[EmailWithAttachments] 介绍了如何在email中添加附件.

如果你想用 HTML email, 请看 [HTMLEmail].

在Log中显示Email而不是发送

Problem

你在本地测试的时候, 不想看到Email发送出去, 而是想显示在Log中.

Solution

设置一个log方法到 Mailer.devModeSendBoot.scala:

import net.liftweb.util.Mailer._
import javax.mail.internet.{MimeMessage,MimeMultipart}

Mailer.devModeSend.default.set( (m: MimeMessage) =>
  logger.info("Would have sent: "+m.getContent)
)

当你发送一个Email通过 Mailer 时, 没有SMTP服务将被启用, 但是, 你会看到以下处处:

Would have sent: Hello from Lift

Discussion

Lift Mailer允许你控制Email是如何发送的在任何的run mode下:

  • devModeSend — 以默认发送.

  • testModeSend — 只显示在Log中.

  • stagingModeSend — 以默认发送.

  • productionModeSend — 以默认发送.

  • pilotModeSend — 以默认发送.

  • profileModeSend — 以默认发送.

方法 testModeSend 发送一个log引用到 MimeMessage, 这意味着, 你的log将会显示为:

Sending javax.mail.internet.MimeMessage@4a91a883

这章只是改变了默认的 Mailer 的行为, 当你在使用developer mode的时候. (这是默认的).

Java Mail 没有一个方法可以显示Email所有的部分, 如果你想看, 你需要写自己的方法. 比如说:

def display(m: MimeMessage) : String = {

  val nl = System.getProperty("line.separator")

  val from = "From: "+m.getFrom.map(_.toString).mkString(",")

  val subj = "Subject: "+m.getSubject

  def parts(mm: MimeMultipart) = (0 until mm.getCount).map(mm.getBodyPart)

  val body = m.getContent match {
    case mm: MimeMultipart =>
      val bodyParts = for (part <- parts(mm)) yield part.getContent.toString
      bodyParts.mkString(nl)

    case otherwise => otherwise.toString
  }

  val to = for {
    rt <- List(RecipientType.TO, RecipientType.CC, RecipientType.BCC)
    address <- Option(m.getRecipients(rt)) getOrElse Array()
  } yield rt.toString + ": " + address.toString

  List(from, to.mkString(nl), subj, body) mkString nl
}

Mailer.devModeSend.default.set( (m: MimeMessage) =>
  logger.info("Would have sent: "+display(m))
)

这个将产生以下的:

Would have sent: From: you@example.org
To: other@example.org
To: someone@example.org
Subject: Hello
Hello from Lift

在这里例子中, display 方法几乎是最直接的. 获得 body 的值需要从body的值中分解出来. 这个将会在一个有结构的email中被触发, 比如说HTML Email [HTMLEmail].

这章中, 最重要的部分是设置一个方法 MimeMessage => UnitMailer.devModeSend. 我们虽然使用的是Log, 但是你可以通过这个方法来处理你想要的各种信息. 包括输出Log, 然后发送Email, 或者记录发送信息到数据库.

如果你想测试一个正在发送email的系统, 打开Java Mail debug mode. 在 default.props 中添加:

mail.debug=true

这个生成一个底层的输出在 javax.mail 系统上, 当邮件被发出:

DEBUG: JavaMail version 1.4.4
DEBUG: successfully loaded resource: /META-INF/javamail.default.providers
DEBUG SMTP: useEhlo true, useAuth false
DEBUG SMTP: trying to connect to host "localhost", port 25, isSSL false
...

See Also

发送HTML Email

Problem

你想发送一个HTML email在你的Lift应用中.

Solution

Mailer 一个 NodeSeq 包含HTML信息:

import net.liftweb.util.Mailer
import net.liftweb.util.Mailer._

val msg = <html>
   <head>
     <title>Hello</title>
   </head>
   <body>
    <h1>Hello</h1>
   </body>
  </html>

Mailer.sendMail(
  From("me@example.org"),
  Subject("Hello"),
  To("you@example.org"),
  msg)

Discussion

一个不明确的转化从 NodeSeqXHTMLMailBodyType. 它确保了邮件的类型是 "text/html".尽管名字是 "XHTML", 信息将使用 HTML5 语法传递.

你可以通过改变 mail.charset 在你的Lift中, 来改变HTML编码.

如果你想设置为文本并且是HTML, 你可以设置在 BodyType class:

val html = <html>
  <head>
    <title>Hello</title>
  </head>
  <body>
    <h1>Hello!</h1>
  </body>
</html>

var text = "Hello!"

Mailer.sendMail(
  From("me@example.org"),
  Subject("Hello"),
  To("you@example.org"),
  PlainMailBodyType(text),
  XHTMLMailBodyType(html)
)

这个信息将为 "multipart/alternative":

Content-Type: multipart/alternative;
  boundary="----=_Part_1_1197390963.1360226660982"
Date: Thu, 07 Feb 2013 02:44:22 -0600 (CST)

------=_Part_1_1197390963.1360226660982
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit

Hello!
------=_Part_1_1197390963.1360226660982
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit

<html>
      <head>
        <title>Hello</title>
      </head>
      <body>
        <h1>Hello!</h1>
      </body>
    </html>
------=_Part_1_1197390963.1360226660982--

当收到一个这个Email时, 它让客户端选择是那种类型的 (text or HTML).

See Also

如果你想发送带附件的邮件, 请看[EmailWithAttachments].

发送一个验证Email

Problem

你想通过SMTP服务来发送一个验证Email.

Solution

设置 Mailer.authenticatorBoot 中, 为你的SMTP服务的信息, 并且开启 mail.smtp.auth 在你的设置中.

修改 Boot.scala :

import net.liftweb.util.{Props, Mailer}
import javax.mail.{Authenticator,PasswordAuthentication}

Mailer.authenticator = for {
  user <- Props.get("mail.user")
  pass <- Props.get("mail.password")
} yield new Authenticator {
  override def getPasswordAuthentication =
    new PasswordAuthentication(user,pass)
}

在这个例子中, 我们希望Lift属性中包含用户名和信息, 所以我们需要修改 src/main/resources/props/default.props:

mail.smtp.auth=true
mail.user=me@example.org
mail.password=correct horse battery staple
mail.smtp.host=smtp.sendgrid.net

当你发送一个email, 在 default.props 中的信息将用来作为验证资料在SMTP服务器上.

Discussion

我们使用了一个Lift的属性文件来设置验证信息. 这有一个好处是, 我们可以让验证过程只发生在运行模式下. 比如说, 如果我们的 default.props 不包含验证设置, 但是 production.default.props 包含, 这会让验证不发生在在 development mode, 这确保了我们不会错误的发送邮件在生产环境上.

如果你没有用一个属性文件在这里: Lift Mailer也支持JNDI, 或者你可以设置 Mailer.authenticator, 来寻找验证信息.

然而, 一些邮件服务, 比如说SendGrid, 他们需要设置 mail.smtp.auth=true , 这需要在你的属性文件中设置, 或者在JVM上设置: -Dmail.smtp.auth=true.

See Also

就像 mail.smtp.auth 一样, 还有很多别的设置来控制Java Mail API. 包括控制端口和数量. 你可以在如下地址找到他们: http://javamail.kenai.com/nonav/javadocs/com/sun/mail/smtp/package-summary.html.

发送一个有附件的Email

Problem

你想发送一个Email, 里面包含一个或者多个附件.

Solution

使用 Mailer XHTMLPlusImages 来打包一个或者多个附件.

假设我们想添加一而过 CSV 文件, 然后发送邮件:

val content = "Planet,Discoverer\r\n" +
  "HR 8799 c, Marois et al\r\n" +
  "Kepler-22b, Kepler Science Team\r\n"

case class CSVFile(bytes: Array[Byte],
  filename: String = "file.csv",
  mime: String = "text/csv; charset=utf8; header=present" )

val attach = CSVFile(content.mkString.getBytes("utf8"))

val body = <p>Please research the enclosed.</p>

val msg = XHTMLPlusImages(body,
  PlusImageHolder(attach.filename, attach.mime, attach.bytes))

Mailer.sendMail(
  From("me@example.org",
  Subject("Planets"),
  To("you@example.org"),
  msg)

在这里, 我们的类型是 XHTMLPlusImages 和附件. 在这里, 附件, PlusImageHolder, 是一个类型为 Array[Byte], mime-type 和一个filename.

Discussion

当你需要添加很多附件时, XHTMLPlusImages 可以接受超过一个的 PlusImageHolder. 尽管名字 PlusImageHolder 看起来像只是添加一个图片, 其实, 你可以添加任何文件通过使用 Array[Byte] 和一个适当的 mime type.

默认情况下, 邮件将使用一个 inline 的方法发送. 这控制了 Content-Disposition 的header在文件中, 并且 "inline" 意味着, 邮件将被自动打开, 当用户浏览的时候. 另一个选项是 "attachment", 这可以通过使用`PlusImageHolder`的最后一个参数来改变:

PlusImageHolder(attach.filename, attach.mime, attach.bytes, attachment=true)

在现实中, 邮件的客户端会自动选择它喜欢显示的方法, 但是这个方法给你了一些控制的权力.

添加一个事先准备好的文件, 你可以使用 LiftRules.loadResource 来加载文件. 作为一个例子, 如果我们的工程包含一个 Kepler-22b_System_Diagram.jpg 文件在, src/main/resources/ 文件夹, 我们可以这样使用:

val filename = "Kepler-22b_System_Diagram.jpg"

val msg =
  for ( bytes <- LiftRules.loadResource("/"+filename) )
  yield XHTMLPlusImages(
    <p>Please research this planet.</p>,
    PlusImageHolder(filename, "image/jpg", bytes) )

msg match {
  case Full(m) =>
    Mailer.sendMail(
      From("me@example.org"),
      Subject("Planet attachment"),
      To("you@example.org"),
      m)

  case _ =>
    logger.error("Planet file not found")
}

作为 src/main/resources, 它已经是在工程目录下, 我们将文件名发送到 loadResource 通过使用 /.

loadResource 返回一个`Box[Array[Byte]], 因为我们不确定文件是不是存在. 我们将它放入 `Box[XHTMLPlusImages] 然后使用它去发送附件, 或者发送log, 提示文件不存在.

See Also

信息被发送通过使用 "multipart/related" mime header, 和一个 "inline" 方法. Lift ticket #1197 链接里有资料是关于 "multipart/mixed" 将会解决一个使用 Microsoft Exchange的问题. 请看: https://github.com/lift/framework/issues/1197.

RFC 2183 describes the "Content-Disposition" header: http://www.ietf.org/rfc/rfc2183.txt.

在将来的时间运行一个任务

Problem

你想计划一个任务, 让它在将来的时间运行.

Solution

使用 net.liftweb.util.Schedule:

import net.liftweb.util.Schedule
import net.liftweb.util.Helpers._

Schedule(() => println("doing it"), 30 seconds)

它会在30秒后, 打印出"doing it".

Discussion

方法 Schedule 的参数为 () => Unit, 是将来要发生的事情, 和一个 TimeSpan 来自 Lift的 TimeHelpers 是我们想让它发生的时间. 这里的 30 seconds 返回一个 TimeSpan 使用了 Helpers._ import, 不过这里有一个变化, 你可以使用 Long millisecond, 如果你喜欢它:

Schedule.perform(() => println("doing it"), 30*1000L)

Lift使用 ScheduledExecutorServicejava.util.concurrent`中, 然后返回一个 `ScheduledFuture[Unit]. 你可以使用这个future cancel 这个名字, 在它返回之前.

也许这是一个惊喜, 你发现使用 Schedule 可以只传递一个方法作为参数, 并且没有一个延迟的值. 这个版本的方法, 立刻运行参数, 但是是在另一个线程上. 这是一个简单的方法, 既可以运行一个任务, 有可以免去设置actor的时间.

还有一个 Schedule.schedule 方法, 可以发送给一个特定的actor一个特定的信息, 在一段延迟以后. 它以 TimeSpan 延迟作为参数, 但是同样, Schedule.perform 版本可以使用 Long 作为参数.

See Also

[RunTasksPeriodically] 包含了如何计划一个actor.

ScheduledFuture 是一个关于 Future 的JavaDoc: http://docs.oracle.com/javase/6/docs/api/java/util/concurrent/Future.html. 如果你想建立一个低延迟, 底层的, 复杂的多线程系统, 可以参考 Java Concurrency in Practice close by (Goetz et al., 2006, Addison-Wesley Professional).

周期性的运行一个任务

Problem

你想运行一个任务在一段特定的周期中.

Solution

使用 net.liftweb.util.Schedule 确保你调用 schedule, 让Lift重新计划一个新的延迟. 比如说, 使用一个actor:

import net.liftweb.util.Schedule
import net.liftweb.actor.LiftActor
import net.liftweb.util.Helpers._

object MyScheduledTask extends LiftActor {

  case class DoIt()
  case class Stop()

  private var stopped = false

   def messageHandler = {
     case DoIt if !stopped =>
        Schedule.schedule(this, DoIt, 10 minutes)
       // ... do useful work here

     case Stop =>
       stopped = true
   }
}

这个例子建立了 LiftActor. 如果收到一个 DoIt 信息, actor会自动重新计划. 在这里, actor每隔10分钟被调用一次.

Discussion

方法 Schedule.schedule 确保了 this actor 发送 DoIt 信息在10分钟后.

为了运行这个例子, 在 Boot.scala`中, 发送 `DoIt 信息到actor:

MyScheduledTask ! MyScheduledTask.DoIt

为了确保在Lift关闭的时候, 线程正确的关闭, 我们注册一个关闭的hook在 Boot.scala 来发送 Stop 信息到actor, 去避免以后的重新计划:

LiftRules.unloadHooks.append( () => MyScheduledTask ! MyScheduledTask.Stop )

如果没有 Stop 信息, actor将一直重新计划, 直到JVM关闭. 这个也许是可以接受的, 但是在我们使用SBT开发的时候, 如果不用 Stop 信息, 你的JVM会一直运行直到 container:stop 命令.

Schedule返回一个 ScheduledFuture[Unit] 从Java的并行库中, 它将允许你使用 cancel 来控制停止线程.

See Also

Chapter 1 of Lift in Action (Perrett, 2011, Manning Publications Co) 包含了一个Comet Actor使用Schedule.

加载URLs

Problem

你想你的Lift应用加载一个URL, 并且处理它为text, JSON, XML 或者HTML.

Solution

使用 Dispatch, "一个HTTP异步互动的库".

在你开始前, 加载Dispatch依赖库在你的 build.sbt 文件:

libraryDependencies += "net.databinder.dispatch" %% "dispatch-core" % "0.9.5"

通过使用Dispatch文档的例子, 我们可以创造一个HTTP请求, 然后返回服务所在的国家 http://www.hostip.info/use.html:

import dispatch._
val svc = url("http://api.hostip.info/country.php")
val country : Promise[String] = Http(svc OK as.String)

println(country())

请注意, country 不是一个 String 而是一个 Promise[String], 并且我们 apply 它来等待返回的值.

返回的值会是 GB, 或者XX 如果你的IP地址是其他国家.

Discussion

这个简单的例子需要一个200状态的返回结果, 并且把返回的结果变成一个`String`, 不过这只是只用Dispatch的一个非常小的部分, 我们会在这章中介绍更多的例子.

如果请求返回的不是一个200状态会怎么样? 在这里, 我们会得到一个异常: "Unexpected response status: 404". 有几种方法可以改变这种行为.

我们需要一个`Option`:

val result : Option[String] = country.option()

就像你期待的一样, 它将返回 None 或者 Some[String]. 然而, 如果你有一个在debug level上的logging, 那么你会看到请求和回复, 并且还有一个错误信息从你的Netty库中发出. 如果你不想看到他们, 你可以添加以下到default.logback.xml 文件:

<logger name="com.ning.http.client" level="WARN"/>

另一个可能性是 either, 它的参数, Right 是一个你期待的结果, Left 是一个出错的结果:

country.either() match {
  case Left(status) => println(status.getMessage)
  case Right(cc) => println(cc)
}

Promise[T] 实现了 map, flatMap, filter, fold 和所有的压缩时所常用的方法. 这意味着, 你可以通过使用For语句:

val codeLength = for (cc <- country) yield cc.length

请注意 codeLength 是一个 Promise[Int]. 为了获得值, 你可以计算 codeLength() 然后你会得到一个结果2.

和解析 as.String 一样, 这里还有更多的选择供你使用..

  • as.Bytes — 和 Promise[Array[Byte]] 一起工作.

  • as.File — 写入一个文件, 在 Http(svc > as.File(new File("/tmp/cc")) ) 里.

  • as.Response — 允许你提供一个 client.Response => T 方法在response上.

  • as.xml.Elem — 用来解析XML response.

下面是一个关于 as.xml.Elem 的例子:

val svc = url("http://api.hostip.info/?ip=12.215.42.19")
val country  = Http(svc > as.xml.Elem)
println(country.map(_ \\ "description")())

这个例子是解析 XML response 到 request 中, 它讲返回一个 Promise[scala.xml.Elem]. 通过使用map`我们找到node中的一个"description", 它将是一个 `Promise[NodeSeq], 并且我们将要强制计算它. 它的输出:

<gml:description
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:gml="http://www.opengis.net/gml">
     This is the Hostip Lookup Service
</gml:description>

这个例子假设request是一个"标准格式". 你可以使用一些插件程序, 比如说JSoup或者TagSoup来分析那些不是标准格式的HTML.

比如说, 使用JSoup, 你需要添加以下依赖库:

libraryDependencies += "net.databinder.dispatch" %% "dispatch-jsoup" % "0.9.5"

然后你就可以使用JSoup了, 你可以很简单的分析出一个element通过使用CSS selectors:

import org.jsoup.nodes.Document

val svc = url("http://www.example.org").setFollowRedirects(true)
val title = Http(svc > as.jsoup.Document).map(_.select("h1").text).option
println( title() getOrElse "unknown title" )

在这里,我们使用JSoup的 select 方法来选择出页面上的 <h1> 元素, 我们把它中间的文本部分取出, 然后放入Promise[Option[String]].

作为最后使用Dispatch的例子, 我们可以使用做一个通道把一个request发送到Lift’s JSON library:

import net.liftweb.json._
import com.ning.http.client

object asJson extends (client.Response => JValue) {
  def apply(r: client.Response) = JsonParser.parse(r.getResponseBody)
}

val svc = url("http://api.hostip.info/get_json.php?ip=212.58.241.131")
val json : Promise[JValue] = Http(svc > asJson)

case class HostInfo(country_name: String, country_code: String)
implicit val formats = DefaultFormats

val hostInfo = json.map(_.extract[HostInfo])()

我们调用的这个URL返回一个JSON的表达式, 里面是我们当前IP上的地址信息.

通过提供一个 Response => JValue 到Dispatch, 我们可以发送一个response到JSON的解析器. 然后我们可以使用map在 `Promise[JValue]`上去实现任何我们想要的Lift JSON方法. 在这里例子中, 我们解析一个简单的case class.

上面代码的例子将会显示`hostInfo` 为:

HostInfo(UNITED KINGDOM,GB)

See Also

Dispacth的文档是一个非常完整并且丰富的文档, 并且它能引导你了解更多关于HTTP的行为. 请花费一些时间来看: http://dispatch.databinder.net/Dispatch.html.

如果你想了解更多关于 Dispatch的Promise, 请看: https://github.com/dispatch/reboot/blob/master/core/src/main/scala/promise.scala.

如果你想了解更多关于Dispatch的内容, 请到Google Group: https://groups.google.com/forum/!forum/dispatch-scala.

上一个Dispatch的主要版本是, 0.8.x ("Dispatch Classic"), 这和现在版本的0.9有很大不同. 因此, 如果你使用0.8.x版本的例子, 你需要做一些转化才能使用在0.9.x上. Nathan Hamblen的blog介绍了如何做改变: http://code.technically.us/post/17038250904/fables-of-the-reconstruction-part-2-have-you-tried.

对于Jsoup, 你可以在以下地址找到资料: http://jsoup.org/cookbook/.

产品部署

部署一个Lift应用. 这意味着, 不仅仅是打包一个程序, 然后把 run mode 设置为 production. 这章中, 我们将介绍如果部署在各种的主机上.

你也可以安装并且运行一个 container 比如说 Tomcat 或者 Jetty 在你自己的服务器上. Containers 在这里有介绍 [RunningYourApplication]. 这章中, 我们将介绍如何安装, 设置, 启动, 通知和管理各种不同的服务器, 并且设置他们负载平衡, 或者其他前端. 这是一个很大的主题, 你可以在下边的链接中找到更多有用的信息:

  • The deployment section of the Lift Wiki at https://www.assembla.com/spaces/liftweb/wiki/Deployment.

  • Timothy Perrett (2012), Lift in Action, Chapter 15, "Deployment and scaling", Manning Publications Co.

  • Jason Brittain and Ian F. Darwin (2007), Tomcat: The Definitive Guide, O’Reilly Media, Inc.

  • Tanuj Khare (2012) Apache Tomcat 7 Essentials, Packt Publishing.

部署在CloudBees

Problem

你有CloudBees PaaS主机的帐户, 你想部署Lift应用到其中.

Solution

使用 SBT的package 打包一个应用为WAR文件, 它可以被直接部署在Lift中, 你可以使用CloudBees自带的SDK来部署它.

在CloudBees的 "Grand Central" console中, 建立一个新的应用. 下面, 我们假设你的用户名为 myaccount , 你的应用为 myapp.

为了更好的perfermance, 你首先需要确定Lift是在production mode下. "production". 你可以使用以下命令:

$ bees config:set -a myaccount/myapp run.mode=production

这会把run mode 设置为 production 在你的应用"myaccount/myapp"上. 去掉 -a 将会设置在所有的应用上.

CloudBees会记录这些设置, 所以你只需要设置一次.

然后你就可以部署:

$ sbt package
...
[info] Packaging /Users/richard/myapp/target/scala-2.9.1/myapp.war...
...
$ bees app:deploy -a myaccount/myapp ./target/scala-2.9.1/myapp.war

这个命令会把你的WAR文件发到CloudBees上, 然后部署它. 当你使用 `app:deploy`完成后, 你的URL地址会显示出来.

如果你改变了一个设置, 你需要重启应用. 部署一个应用可以帮助你查看改变的设置, 你也可以使用 bees app:restart 命令重启:

$ bees app:restart -a myaccount/myapp

Discussion

如果你部署一个应用到很多个CloudBees实例中, 请注意, 默认情况下, CloudBess会轮询他们. 如果你想使用任何关于Lift state的功能, 你需要添加:

$ bees app:update -a myaccount/myapp stickySession=true

如果你想使用comet, 它会正常工作, 但是默认情况下, CloudBees开启了request buffering. 这个设置允许了CloudBees做一些"聪明"的事情, 比如说 re-routing 在一个簇中, 如果一个请求没有得到回复, 转向其他服务器. 但是, 一个request缓存会让使用长轮询的comet request更容易timeout. 为了避免它, 你需要运行一下命令:

$ bees app:update -a myaccount/myapp disableProxyBuffering=true

运行了以上命令后, CloudBees会记录这个设置, 所以你只需要运行一次.

最后, 你也许需要增加 permanent generation 内存设置. 默认情况下, 一个应用有64M分配的内存. 为了增加到128M, 运行 bees app:update 命令:

$ bees app:update -a myaccount/myapp jvmPermSize=128

命令 bees app:infobees config:list 会返回你应用现在的设置.

RDBMS 设置

如果你使用一个SQL数据库在你的应用, 你将需要设置 src/main/webapp/WEB-INF/cloudbees-web.xml. 比如:

<?xml version="1.0"?>
<cloudbees-web-app xmlns="http://www.cloudbees.com/xml/webapp/1">

<appid>myaccount/myapp</appid>

<resource name="jdbc/mydb" auth="Container" type="javax.sql.DataSource">
  <param name="username" value="dbuser" />
  <param name="password" value="dbpassword" />
  <param name="url" value="jdbc:cloudbees://mydb" />

  <!-- For these connections settings, see:
   http://commons.apache.org/dbcp/configuration.html
  -->
  <param name="maxActive" value="10" />
  <param name="maxIdle" value="2" />
  <param name="maxWait" value="15000" />
  <param name="removeAbandoned" value="true" />
  <param name="removeAbandonedTimeout" value="300" />
  <param name="logAbandoned" value="true" />

  <!-- Avoid idle timeouts -->
  <param name="validationQuery" value="SELECT 1" />
  <param name="testOnBorrow" value="true" />

 </resource>

</cloudbees-web-app>

上边的是JNDI数据库设置, 定义一个连接到数据库 "mydb". 它讲被使用在Boot.scala:

DefaultConnectionIdentifier.jndiName = "jdbc/mydb"

if (!DB.jndiJdbcConnAvailable_?) {
  // set up alternative local database connection here
}

因为JNDI设置只是在 `cloudbees-web.xml`上, 它讲只在CloudBees环境下有用. 这意味着, 你可以另外设置本地的数据库, 当你想部署到CloudBees时.

Host IP 和 Port Number

一般情况下, 你不需要知道你部署的实例的host name 和port number. CloudBees会自动路由request到相应的应用下. 然而, 在一些情况下, 特别是当你有很多个实例, 你需要知道请求是如何处理的. 比如说, 你想获得Amazon’s Simple Notification Service (SNS)的信息, 你需要对每个实例分配一个直接的URL.

为了获得public hostname, 你需要窗在一个HTTP请求到http://instance-data/latest/meta-data/public-hostname, 你可以在以下地方找到文档 https://developer.cloudbees.com/bin/view/Main/Finding+out+app+port+and+hostname. 比如:

import io.Source

val beesPublicHostname : Box[String] = tryo {
  Source.fromURL("http://instance-data/latest/meta-data/public-hostname").
    getLines().toStream.head
}

它将返回一个 Full hostname 在CloudBees环境上, 但是在本地运行时会返回 Failure. 比如:

Failure(instance-data,Full(java.net.UnknownHostException: instance-data),Empty)

Port number可以在 .genapps/ports 文件夹下的一个文件找到:

val beesPort : Option[Int] = {
  val portsDir = new File(System.getenv("PWD"), ".genapp/ports")
  for {
    files <- Option(portsDir.list)
    port <- files.flatMap(asInt).headOption
  } yield port
}

方法 java.io.File#list 返回一个List, 里面是一个目录下的所有文件名, 但是会返回Null, 如果目录不存在, 或者有其他IO错误. 所以, 我们使用 Option, 把一个Null的值变成 None.

在本地运行时, 会返回 None, 但是在CloudBees上, 你会看到 Full[Int] port number.

你把两个放在一起会看到:

import java.net.InetAddress

val hostAndPort : String =
  (beesPublicHostname openOr InetAddress.getLocalHost.getHostAddress) +
  ":" + (beesPort getOrElse 8080).toString

在本地运行时, hostAndPort192.168.1.60:8080, 在CloudBees 上,为 ec2-204-236-222-252.compute-1.amazonaws.com:8520.

Java Version

现在CloudBees用的是JDK7, 但是你可以选择6, 7 和 8. 为了改变默认的设置, 运行bees config:set 命令:

$ bees config:set -a myaccount/myapp -Rjava_version=1.8

通过使用 -a myaccount/myapp 将只是对特定的myapp应用进行设置. 命令 bees config:set 会设置一个相关的信息, 但是它将在下次重启的时候被应用.

JVM也可以通过重新部署一个应用, 或者使用以下命令改变:

$ bees app:deploy -a myaccount/myapp sample.war  -Rjava_version=1.6
$ bees app:update -a myaccount/myapp -Rjava_version=1.7

为了确定现在运行的JVM版本, 使用bees config:list 命令:

$ bees config:list -a myaccount/myapp
Runtime Parameters:
  java_version=1.6
Container Version

CloudBees提供了很多容器: Tomcat 6.0.32 (the default), Tomcat 7, JBoss 7.02, JBoss 7.1 和 GlassFish 3.

因为CloudBees使用不同的文件设置在不同的容器上, 所以你的应用必须重新部署. 我们需要使用 bees app:deploy 命令, 以下例子使用了Tomcat 7:

$ bees app:deploy -t tomcat7 -a myaccount/myapp sample.war

JVM和container命令可以使用一行 `bees app:deploy`命令:

$ bees app:deploy -t tomcat -a myaccount/myapp sample.war -Rjava_version=1.6

它将部署 sample.war 到 "myapp" 应用在帐户 "myaccount" 使用 Tomcat 6.0.32 和 JDK 6.

为了决定部署在哪个容器上, 使用 bees app:info:

$ bees app:info -a myaccount/myapp
Application     : myaccount/myapp
Title           : myapp
Created         : Wed Mar 20 11:02:40 EST 2013
Status          : active
URL             : myapp.myaccount.cloudbees.net
clusterSize     : 1
container       : java_free
containerType   : tomcat
idleTimeout     : 21600
maxMemory       : 256
proxyBuffering  : false
securityMode    : PUBLIC
serverPool      : stax-global (Stax Global Pool)
ClickStarts

ClickStart 应用是一个模板, 可以让你快速的取得, 自动生成应用, 运行在CloudBees上. Lift的ClickStart建立了一个似有的Git在CloudBees上包含了Lift 2.4应用, 使用MySQL database, 建立了一个Maven-based Jenkins build, 然后部署应用. 你需要的只是提供一个名字(不能使用空格).

为了访问这个私有的Git, 你需要上传一个SSH key. 你可以在CloudBees中的帐户设置里的"My Keys"中设置.

当你做出一些代码改变的时候, 它将自动的修改CloudBees上的部署.

如果你上所述的一切都是你想用的技术, ClickStart 是你最好的选择. 或者, 它给你一个start point, 你可以做出修改; 或者你使用一个Lift模板在CloudBees上 https://github.com/CloudBees-community/lift_template.

See Also

CloudBees SDK 提供了一个命令行程序, 可以修改控制应用. 你可以在以下地址找到https://wiki.cloudbees.com/bin/view/RUN/BeesSDK.

The CloudBees developer portal (https://developer.cloudbees.com)contains a "Resources" section which provides details of the CloudBees services.

The JVM PermGen settings for CloudBees are described at https://wiki.cloudbees.com/bin/view/RUN/JVM+PermGen+Space, and settings for which JVM is used can be found at https://developer.cloudbees.com/bin/view/RUN/JVMVersion. For information about the containers, see: https://developer.cloudbees.com/bin/view/RUN/ClickStack.

一个插件可以让SBT自动部署: https://github.com/timperrett/sbt-cloudbees-plugin.

部署到 Amazon Elastic Beanstalk

Problem

你想你的Lift应用部署到Amazon Web Services (AWS) Elastic Beanstalk.

Solution

建立一个新的Tomcat7 environment, 使用 SBT 打包你的Lift应用成WAR文件, 然后部署到你的环境上.

为了建立一个新的环境, 访问 AWS console, 找到 Elastic Beanstalk 并且选择 "Apache Tomcat 7" 作为你的环境. 它将自动的建立一个 Beanstalk 应用. 它将占用一段时间, 但是最后将会返回 "Successfully running version Sample Application". 你将会看到你的URL(类似于 http://default-environment-nsdmixm7ja.elasticbeanstalk.com), 打开URL, 你会看到默认的Amazon应用.

打包你的应用:

$ sbt package

它将输出一个WAR文件到target`文件夹, 你需要使用AWS Beanstalk web console 部署它(see <<ConsoleImage>>),选择 "Versions" 在 "Elastic Beanstalk Application Details" 里, 然后点击"Upload new version" 按钮. 你将会看到一个写着版本的对话框, 点击 "Choose file" 来选择你刚建立的WAR文件. 你可以选择上传后直接部署, 或者等待你想部署的时候, 点击`Deploy.

Beanstalk console 会显示 "Environment updating…" 然后过一会儿会显示 "Successfully running". 你的Lift应用现在运行在Beanstalk.

最后的一步是启动Lift的production运行模式. 在AWS Beanstalk web console中, 点击 "Edit Configuration" 链接. 一个对话框将会显示, 在 "Container" tab 下添加 -Drun.mode=production 到 "JVM Command Line Options" 然后点击 "Apply Changes" 重新部署你的应用.

images/beanstalkconsole.png
Figure 6. AWS Console, with Elastic Beanstalk service selected.

Discussion

Elastic Beanstalk 提供了一个预先构建的软件架构, 在这个例子中是: Linux, Tomcat 7, a 64 bit "t1.micro" EC2 instance, load balancing, 和 一个 S3 bucket. 这是它的 环境 和它默认的设置. Beanstalk也提供了一个简单的部署Lift应用的方法. 就像我们在这章中看到的一样, 你上传一个应用打包文件(WAR file)到 Beanstalk 并且部署到以上环境中.

就像使用其他云服务一样, 请不要把文件储存在本地. 这样可以避免在重新启动, 或者程序突然停止的时候造成数据丢失. 在你的Beanstalk应用中, 你有一个文件系统一个写入文件, 但是当镜像重启的时候, 文件将丢失. 你可以使用一个persistent的本地存储方法, 比如说使用 Amazon Elastic Block Storage, 不过使用它是和这个平台本身的环境不符合.

Log文件是写入到本地的. 为了访问它们,在AWS控制台中 , 选择你的环境, 在 "Logs" tab下 点击 "Snapshot" 按钮. 它会对Log做一个备份, 然后存储在S3 bucket, 并且返回一个可以访问他们的链接. 这是一个单一的文件, 里面显示了所有的Log文件, 并且 catalina.out 将是你应用的输出. 如果你想保留他们, 你可以设置你的环境, 每个小时都把Log存储到S3, 在"Container" tab 下点击 "Edit Configuration".

Lift应用的WAR文件和S3 bucket存储在同一个地方. 在AWS控制台, 你需要找到在S3页面下, 列出的名为类似于 elasticbeanstalk-us-east-1-5989673916964`的选项. 你会发现, 每次你上传的WAR文件, 会被自动的加一个prefix. 如果你需要知道这些文件在S3中的不同, 所以最好的方法是添加 `version 值到你的 build.sbt 文件中.

多实例

Beanstalks 默认开启了 auto scaling . 它会首先使用一个你的Lift应用的实例, 但是当你的应用负载增加后, 最多四个实例将被使用.

如果你创造了一个有状态的Lift应用, 你需要开启sticky sessions 在 "Load Balancer" tab, 在 environment configuration中. 它是一个checkbox, 名为 "Enable Session Stickiness"--它非常容易被忽视, 但是当你使用滚轮滚动的时候, 你会看到更多.

使用数据库

使用Beanstalk的数据库方法, 和使用Lift的一样. 然而, Beanstalk试着让你更简单的使用 Amazon的关系型数据库服务(RDS). 当你在创建Beanstalk环境, 或者在修改选项的时候, 你可以添加一个RDS实例, 它将是一个Oracle, SQL-Server 或者 MySQL 数据库.

其中的, MySQL选项将会创建一个 MySQL 5.5 InnoDB 数据库. 数据库可以通过Beanstalk访问, 但是不可以通过其他地方访问. 如果你想改变它, 修改AWS中关于数据库的安全设置. 比如说, 你可以允许访问数据库通过你的IP.

当你的应用运行在相关的RDS实例上的时候, 你有JVM系统的信息, 它们是database name, host, port, user 和 password. 你可以把他们放在 Boot.scala:

Class.forName("com.mysql.jdbc.Driver")

val connection = for {
  host <- Box !! System.getProperty("RDS_HOSTNAME")
  port <- Box !! System.getProperty("RDS_PORT")
  db   <- Box !! System.getProperty("RDS_DB_NAME")
  user <- Box !! System.getProperty("RDS_USERNAME")
  pass <- Box !! System.getProperty("RDS_PASSWORD")
} yield DriverManager.getConnection(
    "jdbc:mysql://%s:%s/%s" format (host,port,db),
    user, pass)

它将返回一个 Box[Connection], 如果它是Full, 你将可以使用它在 SquerylRecord.initWithSquerylSession (请见 [Squeryl]).

或者, 你想通过提供一个默认的设置, 确保一个连接的使用, 你可以这样做:

Class.forName("com.mysql.jdbc.Driver")

val connection = {
  val host = System.getProperty("RDS_HOSTNAME", "localhost")
  val port = System.getProperty("RDS_PORT", "3306")
  val db = System.getProperty("RDS_DB_NAME", "db")
  val user = System.getProperty("RDS_USERNAME", "sa")
  val pass = System.getProperty("RDS_PASSWORD", "")

  DriverManager.getConnection(
    "jdbc:mysql://%s:%s/%s" format (host,port,db),
    user, pass)
}

See Also

Amazon提供了一个包含屏幕截图的指南, 它提供了如何创建Beanstalk应用. 地址是: http://docs.amazonwebservices.com/elasticbeanstalk/latest/dg/GettingStarted.Walkthrough.html.

Elastic Beanstalk, by van Villet et al (2011, O’Reilly Media, Inc) 介绍了详细的Beanstalk设置, 如何在Eclipse上使用, 开启集成模式, 如何hack实例, 比如说使用一个NGINX作为前端在Beanstalk上.

Amazon的文档, "Configuring Databases with AWS Elastic Beanstalk" 介绍了设置数据库的信息: http://docs.amazonwebservices.com/elasticbeanstalk/latest/dg/using-features.managing.db.html.

部署到Heroku

Problem

你想部署你的Lift应用到你的Heroku云平台上.

Solution

把你的Lift应用打包成一个WAR文件, 然后通过Heroku的部署插件, 部署在云平台上. 它将默认把你的应用运行在Tomcat 7上. 任何人都可以通过这种方法部署一个应用, 但是Heroku只对Java企业用户提供服务.

这章中, 我们带你了解三个不同的步骤: 一次性设置, 部署WAR文件, 设置你的Lift应用.

如果你没有Heroku, 下载并且安装Heroku的命令行工具, 然后登录你的账户, 上传一个SSH key:

$ heroku login
Enter your Heroku credentials.
Email: you@example.org
Password (typing will be hidden):
Found the following SSH public keys:
1) github.pub
2) id_rsa.pub
Which would you like to use with your Heroku account? 2
Uploading SSH public key ~/.ssh/id_rsa.pub... done
Authentication successful.

安装部署插件:

$ heroku plugins:install https://github.com/heroku/heroku-deploy
Installing heroku-deploy... done

当这个一次性设置完成后, 你可以在Heroku上, 建立一个应用. 这里, 我们没有一个特定的名字, 我们使用 glacial-waters-6292 在这章中:

$ heroku create
Creating glacial-waters-6292... done, stack is cedar
http://glacial-waters-6292.herokuapp.com/ | git@heroku.com:glacial-waters-6292.git

在部署前, 我们需要设置Lift的运行模式为production. 你需要使用config:set 命令. 首先, 检查现在的 JAVA_OPTS`设置, 并且添加`-Drun.mode=production:

$ heroku config:get JAVA_OPTS --app glacial-waters-6292
-Xmx384m -Xss512k -XX:+UseCompressedOops

$ heroku config:set JAVA_OPTS="-Drun.mode=production -Xmx384m -Xss512k
  -XX:+UseCompressedOops" --app glacial-waters-6292

我们可以通过把应用打包, 部署到Heroku上, 使用 deploy:war 命令:

$ sbt package
....
[info] Packaging target/scala-2.9.1/myapp-0.0.1.war ...
....
$ heroku deploy:war --war target/scala-2.9.1/myapp-0.0.1.war
  --app glacial-waters-6292
Uploading target/scala-2.9.1/myapp-0.0.1.war............done
Deploying to glacial-waters-6292.........done
Created release v6

现在, 你的Lift应用已经运行在Heroku上了.

Discussion

这里有一些关于部署Lift应用到Heroku上的提示. 首先, 请注意, Heroku不支持session affinity.这意味着, 如果你部署到很多的dynos (Heroku对实例的术语), 他们将没有关于如何分配的信息. 结果是, 你将无法使用Lift的stateful的功能. 你需要看 ([RunningStateless], 它解释了如何关闭这个功能).

其次, 如果你使用Lift的comet功能, 这里你需要有一点的修改在`Boot.scala`文件中:

LiftRules.cometRequestTimeout = Full(25)

这个设置是控制Lift等待多长时间来测试一个comet连接. 默认的Lift等待为120秒, 在这里我设置25秒是因为, Heroku将会在30秒后自动挂断连接. 尽管Lift将会恢复这个连接, 但是你将会看到一段时间的延迟.

第三个重要提示是, dyno将会在每天都自动重启. 并且, 如果你的应用在一个小时中没有任何动作, 它将会被停止. 你可以通过看Log来知道现在的状态:

$ heroku logs -t --app glacial-waters-6292
...
2012-12-31T11:31:39+00:00 heroku[web.1]: Idling
2012-12-31T11:31:41+00:00 heroku[web.1]: Stopping all processes with SIGTERM
2012-12-31T11:31:43+00:00 heroku[web.1]: Process exited with status 143
2012-12-31T11:31:43+00:00 heroku[web.1]: State changed from up to down

任何人的访问都可以阻止你Lift应用的停止.

请注意, 应用停止为`SIGTERM`. 这是一个Unix发送给一个线程的信号, 在这里JVM将会停止. 不幸的是, tomcat应用在Heroku, 将不会使用这个信号来停止Lift应用 . 这也许对你有点以为, 但是为了让你的应用和JVM的停止同步, 你需要一个hook挂在JVM上来检测JVM.

比如说, 你可以添加以下信息到 Boot.scala:

Runtime.getRuntime().addShutdownHook(new Thread {
  override def run() {
    println("Shutdown hook being called")
    // Do useful clean up here
  }
})

不过别让Lift做太多的事情. Heroku当收到 `SIGTERM`后, 只有10秒的时间, 然后将会停止JVM.

一个很好的方法是使用Lift的unload hook (请看 [ShutdownHooks]) 然后在Heroku收到停止信息时, 使用hook:

Runtime.getRuntime().addShutdownHook(new Thread {
  override def run() {
    LiftRules.unloadHooks.toList.foreach{ f => tryo { f() } }
  }
})

这种对 SIGTERM 的处理, 也许是一个惊喜, 但是如果我们了解应用是如何在Heroku上运行的, 你将会对这种方法更清楚的了解. 一个dyno是一个分配的资源(512m内存)并且运行一个命令运行.这个命令是一个Java的线程, 为"webapp runner" 包. 你可以分两步的看它. 首先, 如果你看你的dyno, 你会看到你的WAR文件和一个JAR文件:

$ heroku run bash --app glacial-waters-6292
Running `bash` attached to terminal... up, run.8802
~ $ ls
Procfile  myapp-0.0.1.war  webapp-runner-7.0.29.3.jar

然后, 如果你查看dyno是如何运行的:

$ heroku ps --app glacial-waters-6292
=== web: `${PRE_JAVA}java ${JAVA_OPTS} -jar webapp-runner-7.0.29.3.jar
 --port ${PORT} ${WEBAPP_RUNNER_OPTS} myapp-0.0.1.war`
web.1: up 2013/01/01 22:37:35 (~ 31s ago)

这里, 我们看到一个Java线程执行了一个JAR文件 webapp-runner-7.0.29.3.jar, 并且它接受我们的WAR文件作为参数. 它不是一个tomcat的 catalina.sh`脚本, 但是它是一个运行脚本: https://github.com/jsimone/webapp-runner[https://github.com/jsimone/webapp-runner]. 它没有一个对 `SIGTERM 信号的处理方式, 所以我们必须要自己定义, 在关闭的时候, 如何处理运行的资源.

以上的意思是, 如果你想运行Lift应用通过一个不同的方法, 你可以, 但是你需要提供一个适合Lift的container(Jetty, Tomcat), 并且提供一个`main`方法. Heroku使用的, 使我们称为的 "无容器部署".

如果你不是Heroku的Java企业级用户, 并且你对现有的部署插件不适应, 你需要使用以下方法: 提供一个 main 方法运行你的应用, 然后监听访问的连接. 请看 See Also .

在Heroku中访问数据库

Heroku没有任何的限制, 它们尝试让用户使用他们的PostgreSQL更简单. 所以当你建立应用的时候, 他们会附加一个数据库.

你可以通过以下命令查看 pg:

$ heroku pg --app glacial-waters-6292
=== HEROKU_POSTGRESQL_BLACK_URL (DATABASE_URL)
Plan:        Dev
Status:      available
Connections: 0
PG Version:  9.1.6
Created:     2012-12-31 10:02 UTC
Data Size:   5.9 MB
Tables:      0
Rows:        0/10000 (In compliance)
Fork/Follow: Unsupported

数据库的URL是 DATABASE_URL 的值, 它类似如下:

postgres://gghetjutddgr:RNC_lINakkk899HHYEFUppwG@ec2-54-243-230-119.compute-1.
 amazonaws.com:5432/d44nsahps11hda

这个URL包含一个 user name, password, host 和 database name, 他们是通过JDBC来使用. 为了实现它, 你需要添加以下代码到 Boot.scala:

 Box !! System.getenv("DATABASE_URL") match {
  case Full(url) => initHerokuDb(url)
  case _ => // configure local database perhaps
}

def initHerokuDb(dbInfo: String) {
  Class.forName("org.postgresql.Driver")

  // Extract credentials from Heroku database URL:
  val dbUri = new URI(dbInfo)
  val Array(user, pass) = dbUri.getUserInfo.split(":")

  // Construct JDBC connection string from the URI:
  def connection = DriverManager.getConnection(
    "jdbc:postgresql://" + dbUri.getHost + ':' + dbUri.getPort +
      dbUri.getPath, user, pass)

  SquerylRecord.initWithSquerylSession(
    Session.create(connection, new PostgreSqlAdapter))

  S.addAround(new LoanWrapper {
    override def apply[T](f: => T): T = inTransaction { f }
  })
}

这里, 我们测试的是现在的 DATABASE_URL 环境变量, 它代表了我们正在使用Heroku. 它的代码可以在以下关于Squeryl的文章中找到(described in [Squeryl]).

为了运行 build.sbt, 我们需要一个正确的关于Record 和 PostgresSQL的设置:

...
"postgresql" % "postgresql" % "9.1-901.jdbc4",
"net.liftweb" %% "lift-record" % liftVersion,
"net.liftweb" %% "lift-squeryl-record" % liftVersion,
...

通过使用它, 你的Lift应用可以使用Heroku的数据库, 并且你可以使用shell访问它, 比如说:

$ pg:psql --app glacial-waters-6292
psql (9.1.4, server 9.1.6)
SSL connection (cipher: DHE-RSA-AES256-SHA, bits: 256)
Type "help" for help.

d44nsahps11hda=> \d
No relations found.
d44nsahps11hda=> \q
$

为了在Heroku环境外, 使用JDBC工具, 你需要给SSL提供一个参数, 如下:

jdbc:postgresql://ec2-54-243-230-119.compute-1.amazonaws.com:5432/d44nsahps11hda?
  username=gghetjutddgr&password=RNC_lINakkk899HHYEFUppwG&ssl=true&sslfactory=
  org.postgresql.ssl.NonValidatingFactory

See Also

Scala和Java的关于Heroku的文章可以帮助你理解这章的内容: https://devcenter.heroku.com/categories/scala and https://devcenter.heroku.com/categories/java.

Dynos 和 the Dyno Manifold 在以下地址有介绍: https://devcenter.heroku.com/articles/dynos.

JVM shutdown hook在以下地址有介绍: http://docs.oracle.com/javase/7/docs/api/java/lang/Runtime.html.

Heroku的关于使用"无容器部署", 在以下地址有介绍: https://devcenter.heroku.com/articles/java-webapp-runner. There are also a template SBT project from Matthew Henderson that includes a JettyLauncher class: https://github.com/ghostm/lift_blank_heroku.

Heroku如何处理comet长轮询在这里有介绍: https://devcenter.heroku.com/articles/request-timeout.

不同服务器上的分布式Comet

Problem

你正在使用Lift的Comet, 并且你想在多个服务器上做分布式服务, 来增加冗余或者增加服务器负载能力.

Solution

使用 publish/subscribe (pubsub)模型连接每一个服务器到一个 topic 并且指引comet信息发送到指定的topic, 让每一个运行你应用的服务器都能收到信息.

有很多不同的技术你可以达到这个效果, 比如说 databases, message systems, actor systems. 在这个例子中, 我们将使用RabitMQ消息队列,但是这里有使用CouchDB和Amazon Simple Notification Service的例子, 可以在 See Also 里找到.

抛去技术, 我们使用的方法是 [DistributedCometDiagram]. 一个comet的事件在Lift应用上发生, 并且发送到服务器做重定向. 这个服务的责任是 (标为 "topic" 在例子中)确保所有的服务器都收到Lift应用发的信息, 然后可以被Lift处理.

images/topic.png
Figure 7. Comet事件根据topic分配给服务器的, 如下图:.

第一步是下载, 并且安装RabbitMQ: http://rabbitmq.com/. 然后运行:

$ ./sbin/rabbitmq-server -detatched

这个命令会产生很多信息, 最后你会看到: "broker running".

这个Lift应用我们曾经用来展示 pubsub 模式, 它是一个聊天室应用, 介绍在 Simply Lift. 第一步需要改进的是我们需要让Lift可以和RabbitMQ对话. 我们需要在 libraryDependencies 中添加一句, 它在 `build.sbt`文件中:

"net.liftmodules" %% "amqp" % (liftVersion + "-1.1"),

AMQP 是 Advanced Message Queuing Protocol 的简写, RabbitMQ使用这个协议. AMQP模型提供了一个抽象的actor用来接受和发送信息, 我们通过以下两个方法实现这个接口 RemoteSendRemoteReceiver:

package code.comet

import net.liftmodules.amqp._
import com.rabbitmq.client._

object Rabbit {

  val factory = new ConnectionFactory(new ConnectionParameters)

  val host = "127.0.0.1"
  val port = 5672
  val exchange = "lift.chat"
  val routing = ""
  val durable = true
  val noAck = false

  object RemoteSend extends AMQPSender[String](factory, host, port,
    exchange, routing) {
    def configure(channel: Channel) =
      channel.exchangeDeclare(exchange, "fanout", durable)
  }

  object RemoteReceiver extends AMQPDispatcher[String](factory, host, port) {
    def configure(channel: Channel) = {
      channel.exchangeDeclare(exchange, "fanout", durable)
      val queueName = channel.queueDeclare().getQueue()
      channel.queueBind(queueName, exchange, routing)
      channel.basicConsume(queueName, noAck,
        new SerializedConsumer(channel, this))
    }
  }

}

这段代码展示了RemoteSendRemoteReceiver actors 序列化一个 String 值通过RabbitMQ. 下面的 Discussion 中有更多解释.

为了让comet信息能通过RabbitMQ, 我们需要在`Boot.scala`中做两个改变:

RemoteReceiver ! AMQPAddListener(ChatServer)

这段代码把 ChatServer 设置为 `RemoteReciver`的一个AMQP消息队列的监听器.

最好的改变是对 ChatServer 自己的. ChatServer`的作用是接收一个`String 信息从一个客户端然后更新所有comet的信息:

override def lowPriority = {
  case s : String => msgs :+= s; updateListeners()
}

这个改变是为了把所有的类型为 String 的信息加载到RabbitMQ中, 然后使用RabbitMQ分配信息:

override def lowPriority = {
  case AMQPMessage(s: String) => msgs :+= s; updateListeners()
  case s: String => RemoteSend ! AMQPMessage(s)
}

这个改变意味着我们所有的comet聊天信息都会先加载进RabbitMQ的队列中, 然后分配给所有运行RabbitMQ实例的服务器端.

Discussion

为了在本地测试RabbitMQ, 你需要创建多于一个实例的Lift应用. 这时候, 你需要打开SBT, 然后运行另一个控制台, 然后把Lift应用运行在另一个端口上:

$ sbt
...
> set port in container.Configuration := 9090
[info] Reapplying settings...
[info] Set current project to RabbitMQ Chat (in build file:rabbitmq_chat/)
> container:start

你可以在 http://127.0.0.1:8080 看到一个实例, 在 http://127.0.0.1:9090 看到另一个.

在上面的例子中, 你会看到 AMQPSender[T]AMQPDispatcher[T] 这两个方法非常重要, 并且我们对他们进行了一些设置. 对于 RemoteSend 我们设置了 AMQPSender 与一个类型为`String` 信息一起工作, 并且设置了一个 exchange 名为 "lift.chat". 在RabbitMQ中这个exchange就是我们发送信息的目的地, 信息到达exchange了后, 会分配到每个节点. 在这个例子中, exchange为 fanout (顾名思义), 每一个节点都会收到一个信息的副本. 这就是我们希望看到的, 每一个comet客户端都收到一份信息.

RemoteReceiver 也被用来接受 String 信息, 尽管对它的设置有些长. 在这里, 就像我们使用exchange一样, 我们声明了一个 temporary queue 对于我们的Lift实例. 这个queue接受我们发动的信息, 但是这里, 每一个接收者都有自己的queue. Fanout exchange将确保每一个接收者都收到信息, 并且加载到他们自己的queue里. Queue有一个随机的名字, 并且在关闭应用后消失.

RemoteReceiver 的最后一部分是定义我们如何处理这些信息. 默认的 RemoteSend 的行为是序列化object, 所以我们最小化这部分通过使用AMQP模型中的 SerializedConsumer 方法.

为了看到默认的RabbitMQ的行为, 比较好的方法是使用RabbitMQ的管理页面. 在你安装目录下:

$ ./sbin/rabbitmq-plugins enable rabbitmq_management

你可以看到一个管理的网络页面在 http://127.0.0.1:15672/ 然后login. 默认的用户名和密码均为"guest".

如果你觉得每次都在开发的时候, 都需要运行一次RabbitMQ很麻烦. 你可以简单的不初始化服务在 Boot.scala:

if (Props.productionMode)
  RemoteReceiver ! AMQPAddListener(ChatServer)

然后在聊天服务器上, 只发送给本地客户端:

override def lowPriority = {
  case AMQPMessage(s: String) => msgs :+= s; updateListeners()
  case s: String =>
    if (Props.productionMode) RemoteSend ! AMQPMessage(s)
    else { msgs :+= s; updateListeners() }
  }

请注意 Props.productionModetrue 当你使用运行模式为 Production , StagingPilot.

See Also

Lift Chat 例子在Simply Lift 有介绍: http://simply.liftweb.net/. 这章的代码可以在以下地址找到: https://github.com/LiftCookbook/rabbitmq_chat.

The Lift AMQP module is: https://github.com/liftmodules/amqp.

如果你想学更多关于RabbitMQ, 请看官方教程 (http://www.rabbitmq.com/tutorials/tutorial-five-java.html) or Alvaro Videla and Jason J.W. Williams (2012), RabbitMQ in Action: Distributed Messaging for Everyone, Manning Publications.

Diego Medina实现了一个分布式的comet解决方案, 使用了CouchDB, 在以下地址有介绍:https://fmpwizard.telegr.am/blog/distributed-comet-chat-lift.

Amazon’s Simple Notification Service (SNS) is a fanout facility so can also be used to implement this pattern. You can find a Lift module for SNS at https://github.com/SpiralArm/liftmodules-aws-sns.

贡献, Bug 报告 和 寻找帮助

你需要一些帮助

Problem

你有一些无法解决的问题, 需要一些Lift的帮助.

Solution

在Lift的Google邮件列表上问问题: https://groups.google.com/group/liftweb.

Discussion

你将会找到一些帮助在StackOverFlow, Quora和别的地方, 但是邮件列表是最好的, 获得最正确帮助的地方. 你可以搜索以前的帖子来看是否你的问题已经被解答过, 但是希望你的问问题的方法能很好的帮助解答你的人了解你的情况. 而且这本书的内容很多都是在邮件列表上问过的问题.

新会员在邮件列表上的发帖是被限制的, 这是为了防止垃圾邮件. 所以当你第一次发帖, 你会发现你的帖子会在几个小时后才显示在列表中

See Also

Lift的社区决定了Lift的未来. 请在发帖前, 看以下的文章 http://liftweb.net/community. 你将会的到一个很好的帮助, 如果你使用正确发帖的方法.

如果你需要一个付费的咨询, 开发或者SLA支持. 这里是一个商业支持的邮件列表: https://www.assembla.com/spaces/liftweb/wiki/Commercial_Support.

如何报告Bugs

Problem

你找到一个Bug, 你想报告给Lift社区.

Solution

请把Bug发送给邮件列表上, 并且附上你为什么觉得这是Bug和你希望看到的结果.

请先看已经存在的Ticket是否已经包含你的Bug, 请不要发起一个Ticket, 除非一个Lift委员让你这样做.

Discussion

If Lift is not behaving as you expect, please ask questions about what you’re seeing. The ideal form of these questions is "When I do X, my Lift app does Y, but I expect it to do Z, why?" This provides a set of language to discuss your application and the way that Lift responds to requests. Perhaps there’s a way of improving Lift. Perhaps there’s a concept that’s different in Lift than you might be used to. Perhaps there’s a documentation issue that can help bridge the gap between what Lift is doing and what you expect it to do. Most importantly, just because Lift is behaving differently than you expect it to, it’s not necessarily a bug in Lift.

http://lift.la/expected-behavior-in-the-lift-community
— David Pollack

一个很好得到帮助的方法是制作一个简单的例子, 里面包含了你的Bug, 然后发送它到Github. 并且里面包含了Bug的位置, 和你希望看到的结果. 这样可以让更多人知道Bug的具体情况.

See Also

你可以找到一个ticket的列表在: http://ticket.liftweb.net/.

如果你想知道如何制作一个包含Bug的例子. 请看: http://www.assembla.com/wiki/show/liftweb/Posting_example_code.

如果你找到了一个bug, 并且Lift的委员同意你建立一个ticket, 最重要的事情是你需要包含一个邮件列表关于这个bug的讨论的链接, 请看: http://www.assembla.com/wiki/show/liftweb/Creating_tickets.

贡献小段代码和ScalaDocs

Problem

你有一小段代码或者ScalaDocs的改进, 你想把它们放入Lift中.

Solution

你可以提供一个pull requests 在Github上, 你的改变必须满足以下条件之一:

  • 它是一个注释的改变.

  • 它是一个例子代码.

  • 它是一个 的改变, 增强, 或者弥补bug的代码.

你的pull request必须包含你的签名在你的 contributors.md 文件下面.

Discussion

Lift有严格的贡献者政策, 不接受任何除贡献者以外的人贡献的代码, 贡献者需要首先签署一个版本声明到Lift中. 这个政策保护了那些使用Lift作为商业开发的公司.

安全是最重要的. 所以如果你想贡献代码, 请看签署以下声明:

By submitting this pull request which includes my name and email address (the email address may be in a non-robot readable format), I agree that the entirety of the contribution is my own original work, that there are no prior claims on this work including, but not limited to, any agreements I may have with my employer or other contracts, and that I license this work under an Apache 2.0 license.

什么是一个小的改变? 这个问题问的好, 如果你不确定, 请在邮件列表上提问, 我们会回答你.

See Also

在2012年11月, 贡献者政策开始实施, 请见: http://www.lift.la/blog/new_contribution_policy.

contributors.md 文件可以在以下地址找到: https://github.com/lift/framework/blob/master/contributors.md.

The Lift source is on GitHub at https://github.com/lift/. The framework project is probably the one you want, although you’ll also find Git repositories for examples and Lift web sites there.

GitHub provide an introduction to pull requests at https://help.github.com/articles/using-pull-requests.

[modules] 介绍了如何分享任何大小的代码从Lift modules里.

贡献文档

Problem

你想给Lift贡献文档.

Solution

更新或者添加Lift文档在: https://www.assembla.com/wiki/show/liftweb.

你将会需要注册, 登陆一个免费的Assembla账户. 然后你需要称为一个 watcher 对Lift Wiki. 你可以通过点击Lift Wiki页面的右上角的按钮称为watcher. 作为一个watcher, 你可以修改页面, 并且建立新的页面.

Discussion

如果你不确定你将改变的地方. 你可以在邮件列表里询问.

一个对于watcher的限制是, 你不可以移动页面. 如果你建立新的页面在一个错误的地方, 或者你想重新整理页面, 你必须要在邮件列表中寻求一个Lift委员的帮助.

See Also

Lift Wiki的装饰使用的是Textile, 你可以在以下地方学到: http://redcloth.org/hobix.com/textile/.

如何添加一章到本书中

Problem

你想添加一章到本书中.

Solution

如果你习惯使用Git, 你可以fork 然后做一个pull request.

或者, 下载一个模版文件, 然后写你的自己章节, 然后发送到Lift邮件列表: https://groups.google.com/group/liftweb.

Discussion

任何你感到迷惑, 或者任何让你惊喜的, 让你印象深刻的代码, 例子, 都可以称为一个好的主题在本书里. 如果你想增强, 讨论和验证以存在的章节, 也可以写出来.

这本书使用的修饰语言是Asciidoc. 如果你习惯使用Markdown或者Textile. 你会发现他们是一样的. 对于本书, 你只需要知道如何设置章节, 源码格式和链接. 一个例子在 template.asciidoc 文件中有介绍这些内容.

为了做一个改变, 你需要知道, 每个章节都是独立的文件, 并且每个主题都是一个单独的段在文件中.

Licensing

我们要求贡献者有以下几点:

  • 你同意授权你的工作(包括, 你的文字, 你的代码和任何图片)给我们, 并且同意 Creative Commons Attribution. 非商业化, 非衍生物.

  • 你确保你的工作是自己完成的, 并且你有必要的权力对于你的作品.

所以简单来说, 所有的作者自愿的捐出自己的作品.

See Also

AsciiDoc的讲解在: http://powerman.name/doc/asciidoc , 它是一个快速的途径了解Asciidoc, 但是如果你需要更多, AsciiDoc页面有介绍: http://www.methods.co.nz/asciidoc/.

GitHub提供了一个如何制作pull request:https://help.github.com/articles/using-pull-requests.

[wiki] 介绍了如何贡献文档到Lift.

在Modules里分享代码

Problem

你有代码, 你想在Lift工程之间分享.

Solution

建立一个Lift module, 并且引用你以前Lift工程的module.

作为一个例子, 让我们建立一个module来镶嵌Snowstorm的下雪效果在你Lift工程的每个页面中.

Module没有任何特别的: 他们是代码, 包和像其他一样的依赖库. 让他们能互相分享的代码是在 LiftRules`中. 主要的代码是你有一个 `init 方法让Lift音乐用可以初始化你的module.

对于我们的下雪效果, 我们将会打包一些JavaScript然后把它插入到所有页面.

我们使用 lift_blank 模版开始, 你可以在Lift官网找到它的压缩包, 我们可以删除所有的不是Lift自己需要运行的源代码和HTML文件. 然后, 你会发现一个简单Lift的架构和生成配置.

我们的module需要Snowstorm的JavaScript文件,https://github.com/scottschiller/snowstorm/ 另存为 resources/toserve/snowstorm.js. 这会把JavaScript放到Lift工程的编译目录下.

最后一部分是确保JavaScript包含在每个页面中:

package net.liftmodules.snowstorm

import net.liftweb.http._

object Snowstorm {

 def init() : Unit = {

  ResourceServer.allow {
     case "snowstorm.js" :: Nil => true
  }

  def addSnow(s: LiftSession, r: Req) = S.putInHead(
    <script type="text/javascript" src="/classpath/snowstorm.js"></script> )

  LiftSession.onBeginServicing = addSnow _ ::  LiftSession.onBeginServicing

 }

}

这里, 我们把代码添加进Lift的处理通道, 并且加入每个页面中.

我们修改 build.sbt 来给module一个名字, 组织和版本号. 我们也能移除很多的依赖库和web插件:

name := "snowstorm"

version := "1.0.0"

organization := "net.liftmodules"

scalaVersion := "2.9.1"

resolvers ++= Seq(
   "snapshots" at "http://oss.sonatype.org/content/repositories/snapshots",
   "releases" at "http://oss.sonatype.org/content/repositories/releases"
)

scalacOptions ++= Seq("-deprecation", "-unchecked")

libraryDependencies ++= {
  val liftVersion = "2.5-RC2"
  Seq(
    "net.liftweb" %% "lift-webkit"  % liftVersion  % "compile"
  )
}

我们可以发布这个插件到任何硬盘上的依赖库, 通过使用SBT然后键入:

publish-local

在我们的module建立和发布后, 我们现在包含它到我们的Lift应用. 所以这样做, 修改我们的 build.sbt 来引用 "snowstorm" 的依赖库:

libraryDependencies ++= {
  val liftVersion = "2.5-RC2"
  Seq(
  ...
  "net.liftmodules" %% "snowstorm" % "1.0.0",
  ...

在我们Lift应用的 Boot.scala 中, 我们最后初始化插件:

import net.liftmodules.snowstorm.Snowstorm
Snowstorm.init()

当我们运行插件的时候, 每个页面都会有雪花效果.

Discussion

Module是自身包含文件的, 这意味着用户不用复制粘贴JavaScript到自己的HTML模版中. 为了实现它, 我们使用 ResourceServer. 当我们引用一个JavaScript文件通过 /classpath/snowstorm.js, Lift会尝试定位 snowstorm.js 文件. 这就是我们要让Lift做得, 因为 snowstorm.js 文件存在于module的JAR文件中.

然而, 我们不想把所有的文件都暴露于用户. 为了避免它, Lift在 toserve 文件夹下查找文件, 所以对于我们来说, 我们的文件是在 src/main/resources/toserve 下的. 你可以把 /classpath 想象成 toserve (尽管, 你可以通过改变 LiftRules.resourceServerPathResourceServer.baseResourceLocation 改变它).

更深一步想, 如果你想把文件暴露在外边, 你需要使用:

ResourceServer.allow {
  case "snowstorm.js" :: Nil => true
}

我们设置每次都返回 true 对于资源的请求, 但是我们可以动态的控制这个设置.

S.putInHead 添加JavaScript到页面顶端, 通过使用 LiftSession.onBeginServicing (请见 [OnSession]), 它每次都被触发. 我们可以使用 Req 在这里, 来限制使用这个插件的页面, 但是在这个例子中, 我们把它添加到每个页面.

事实上, 你可以把任何你能看到的东西, 在Lift中变成一个module. 一个经典的方法是, 你有一些功能在Lift的应用中, 然后你把他们独立出来一个module, 然后使用 Bootinit 方法初始化它. 比如说, 如果你想把REST做成一个module, 你就可以使用以上方法, 有一个例子是Lift的 Paypal module, 你可以参考它.

使用你的Module

如果你很希望你的module能被很多人使用, 你需要发布它到一个公共的依赖库平台, 比如说Sonatype或者CloudBees. 你也希望确保你的module能保持与Lift的更新.

首先, 你需要包含Lift的 "edition" 作为你module名字的一部分. 比如说版本 1.0.0, 你的module的名字为 "foo". 对于Lift2.5, 你的module应为 "foo_2.5". 这让使用者很清楚的知道你的版本对应的Lift的版本为2.5. 其中, 你可以包含 milestones, snapshots和final releases.

上面的要求也许让你觉得很复杂, 一个很简单的方法是, 你可以制作一个更正在你的build中, 并且允许Lift改变版本号. 这可以让你的module自动生成一个名字. 为了实现它, 你需要建立一个 project/LiftModule.scala 在你的module中:

import sbt._
import sbt.Keys._

object LiftModuleBuild extends Build {

  val liftVersion = SettingKey[String]("liftVersion",
    "Full version number of the Lift Web Framework")

  val liftEdition = SettingKey[String]("liftEdition",
    "Lift Edition (short version number to append to artifact name)")

  val project = Project("LiftModule", file("."))
}

这里定义了一个设置可以用来改变Lift的版本号, 你在你的module build.sbt 中这样使用:

name := "snowstorm"

organization := "net.liftmodules"

version := "1.0.0-SNAPSHOT"

liftVersion <<= liftVersion ?? "2.5-SNAPSHOT"

liftEdition <<= liftVersion apply { _.substring(0,3) }

name <<= (name, liftEdition) { (n, e) =>  n + "_" + e }

...

libraryDependencies <++= liftVersion { v =>
  "net.liftweb" %% "lift-webkit" % v % "provided" ::
  Nil
}

请注意, "provided" 在这里是对Lift的设置. 这意味着, 当你的module使用的是Lift webkit使用的版本的时候, 使用用户当前使用的应用的版本号.

以上的代码给你的是一个方法, 让你创建你的module的版本和Lift的版本一直("2.5"), 而不是特意的锁住你的module为一个更新("2.5-SNAPSHOT"). 通过使用 liftVersion 设置, 我们可以控制所有的版本号, 通过使用我们定义的脚本. 这就是我们希望发布的一个对于不同Lift版本, 设置我们module的版本. 下面的连接有更多介绍: https://www.assembla.com/spaces/liftweb/wiki/Releasing_the_modules.

当你的module发布后, 请不要忘记在邮件列表中发表它.

Module纠错

当你正在制作一个module, 测试和运行它在一个Lift应用上, 如果每次你改变它, 都要重新发布一次, 这将会是非常痛苦的. 幸运的是, SBT允许你的Lift应用决定于module的源代码. 为了实现它, 删除以前发布的module, 并且使用一个本地的依赖库, 你需要建立 project/LocalModuleDev.scala:

import sbt._
object LocalModuleDev extends Build {
  lazy val root = Project("", file(".")) dependsOn(snow)
  lazy val snow = ProjectRef(uri("../snowstorm"), "LiftModule")
}

这里, 我们假设我们能找到snowstorm的源代码在Lift的 ../snowstorm 目录下. 通过使用这个代码, 当你编译你的工程时, SBT将会自动的查找本地的 snowstorm module.

See Also

Lift本身包含一系列的module, 但是他们出自不同的工程, 请见: https://github.com/liftmodules/. Lift贡献者条约不包含module [LiftCodeContributions]. 这意味着: 你可以随意贡献你的module到Lift中.

Lift关于module的Wiki地址: http://liftmodules.net.

Snowstorm 工程 ("setting CPUs on fire worldwide every winter since 2003") 在: https://github.com/scottschiller/snowstorm/ 这章的module在: https://github.com/LiftCookbook/snowstorm-example-module.

为了发布在Sonatype, 请见: https://docs.sonatype.org/display/Repository/Sonatype+OSS+Maven+Repository+Usage+Guide. CloudBees提供一个开源的依赖库平台, 你可以通过以下地址学习: http://www.cloudbees.com/foss/foss-dev.cb.

还有其他分享代码在Lift应用之间的方法: "Modularize Lift Applications" at https://groups.google.com/d/msg/liftweb/7GA5t0lefzI/c1wf6W1keDcJ.