Scala与PowerMock

这个题目对搜索引擎应该是比较友好的,虽然土了些。

PowerMock是个好东西,在某种程度上拯救了架构设计,使得我们不用过分考虑如何用复杂的架构设计来达到可以单元测试的目的。

Java里面如何使用PowerMock就不用多说了,随便google一下满屏幕都是,如果是同事还可以跟我要codekata的材料。但是这事儿换成Scala,情况就完全不同了。

首先要解决的问题是Scala用什么测试框架。比较流行的有ScalaTestSpecs,二者都支持BDD、ATDD,也就是这个BDD、ATDD把我折磨了一个晚上。

撇开ATDD不谈,我非常想认真尝试一下BDD,但是ScalaTest的BDD需要使用它自己的Runner,Specs的虽然支持JUnit,但是也需要用一个特殊的Runner,这对于PowerMock来说几乎是致命的。PowerMock+Junit,必须使用PowerMockRunner才能mockStatic,mockNew,PowerMock+TestNG倒是不需要Runner,但是ScalaTest不支持用TestNG来做BDD,而Specs根本就不支持TestNG。

有点儿混乱,总之一句话,现阶段,Scala+BDD+PowerMock这条路完全走不通。如果我的判断有问题,望指正。

那么就退一步,还是JUnit。从一个例子来说明我遭受到的痛苦以及整个解决过程。

1
2
3
4
5
6
7
8
9
10
11
12
public class MyStatic {
    public static String hello() {
        // connect to twitter for example
        return tweet;
    }
}
 
public class HelloWorld {
    public String helloWorld() {
        return MyStatic.hello();
    }
}

上面一段代码,Java+PowerMock很容易处理:

1
2
3
4
5
6
7
8
9
@RunWith(PowerMockRunner.class)
@PrepareForTest(MyStatic.class)
public HelloWorldTest {
    @Test
    public testHelloWorld() {
        mockStatic(MyStatic.class);
        ....
    }
}

如果是Scala,问题就接踵而至了。先看功能相似的Scala代码片段:

1
2
3
4
5
6
7
8
9
10
object MyStatic {
  def hello = {
    // connect to twitter for example
    tweet
  }
}
 
class HelloWorld {
  def helloWorld() = MyStatic.hello
}

@RunWith没有问题:

1
@RunWith(classOf[PowerMockRunner])

@PrepareForTest就开始出状况了。

首先貌似Scala对annotation的解释有些不太好,不能简单地传一个东西给@PrepareForTest,因为这玩意儿有两个参数,每个都是数组

1
2
3
4
5
6
7
8
9
@Target( { ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface PrepareForTest {
    Class[] value() default IndicateReloadClass.class;
 
    String[] fullyQualifiedNames() default "";
}

所以不能:

1
@PrepareForTest(classOf[SomeClass])

必须要:

1
@PrepareForTest(Array(classOf[SomeClass]))

这个还算是比较容易解决,但是对于Scala里的object,情况又有变化。object不支持classOf这个运算符,classOf[MyStatic]是非法的;MyStatic.getClass倒是可以拿到类,但这个类是另一个,名字是MyStatic$(后面会详细解释这两个类以及它们之间的关系)。但即使getClass得到的类是正确的,也没法直接传给annotation,因为annotation要求参数必须是一个constant,不能是一个方法调用,因此@PrepareForTest(Array(MyStatic.getClass))也是不行的。

怎么办呢?还好@PrepareForTest还有第二个参数,我们可以这样:

1
@PrepareForTest(fullyQualifiedNames = Array("com.honnix.test.MyStatic"))

虽然比较恶心,而且不利于重构,但是聊胜于无吧。

然后就是mockStatic的问题。怎么写呢?

1
mockStatic(MyStatic.getClass)

这肯定不行,因为之前说了getClass得到的类是MyStatic$。那么怎么样才能mock MyStatic呢?答案就是,反射。

1
mockStatic(Class.forName("com.honnix.test.MyStatic"))

没有其他任何办法得到一个object的class信息,这里有一个很古老的bug

好吧,两个问题都“解决”了,跑跑看。结果却是我想mock的东西根本就没有被mock。要命了。

事情还得从头说起。Scala对于一个object编译的结果是生成两个类,一个MyStatic,一个MyStatic$:

1
2
3
4
5
6
7
public final class MyStatic
{
  public static final String hello()
  {
    return MyStatic$.MODULE$.hello();
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public final class MyStatic$
  implements ScalaObject
{
  public static final  MODULE$;
 
  static
  {
    new ();
  }
 
  public String hello()
  {
    return "hello";
  }
 
  private MyStatic$()
  {
    MODULE$ = this;
  }
}

这是反汇编的结果,稍微有点乱,凑合着看吧。不用多解释,代码已经很明白了。

问题是,我对MyStatic进行mock,理论上说也应该可以的,也就是mock之后就无视MyStatic$了。为什么不行呢?还得来看看调用地方的反汇编代码:

1
2
3
4
5
public testHelloWorld {
    ...
    MyStatic$.MODULE$.hello();
    ...
}

这里面压根儿就没有MyStatic的事儿了!MyStatic被华丽的忽视了!

到此为止终于真相大白了。咱们得这样来mock。

1
2
3
4
5
6
7
8
9
10
@RunWith(classOf[PowerMockRunner])
@PrepareForTest(fullyQualifiedNames = Array("com.honnix.test.MyStatic$"))
class HelloWorldTest {
  @Test
  def testStartAndStop() {
    val mockMyStatic = mock(MyStatic.getClass)
    Whitebox.setInternalState(MyStatic.getClass, mockMyStatic)
    ...
  }
}

想法很简单,把MyStatic$里面的MODULE$给换喽。

代码很丑陋,所以需要强调一下PowerMock应该被用在那些确实不需要复杂的依赖注入的地方来帮助简化架构设计,对于需要做依赖注入的地方,我们还是绝对不能松懈的,不能因为PowerMock啥都能mock就乱写代码。

顺便说一句,Specs对于Mockito已经有很好的支持了,加上Scala那种类似DSL的表达式,用起来很爽。希望PowerMock的作者能考虑支持Scala。

Java调用Scala的一个真实案例

Scala调用Java,完全没有问题;但是反过来总有那么一点不爽,或者必须要修改。这里是项目中一个真实的案例,拿出来分享一下。

1. 类型转换

1
2
3
4
5
object AsnLoader {
  ...
  def batchLoadFile(files: List[String]): Map[String, AsnModule] = files.foldLeft(Map[String, AsnModule]())((x, y) => x ++ load(y))
  ...
}

这里的List是Scala里的类,从Java里面调这个方法是不行的,所以需要弄一个wrapper方法:

1
2
3
import collection.JavaConversions._
...
def batchLoadFile(files: java.util.List[String]): java.util.Map[String, AsnModule] = batchLoadFile(asScalaBuffer(files).toList)

虽然asScalaBuffer本身就是个implicit,但是这里仍需要显示调用一下,否则编译器会认为batchLoadFile(file: java.util.List[String])更符合调用匹配规则,结果就变成了无限递归……

Java里面这样调用:

1
AsnLoader.batchLoadFile(list);

这样看起来解决问题了,但是Eclipse的Scala插件会报一个莫名其妙的函数签名错误,其实根本就没错,直接maven或者用Intellij都是对的,Eclipse插件太烂或者太老。无奈只好把函数名也一起改了:batchLoadFileJ,这样就可以了。如果还不是很明白,去看看JavaConversions里面的implicit。

把Java的Map转换成Scala的immutale map稍微曲折了些,先转换成mutable map,再调用toMap方法转换成immutable map。

2. 构造方法

1
2
3
4
5
class DefaultTransformer(private val asnModule: AsnModule, private val dependencies: Map[String, AsnModule] = Map.empty,
                         private val repository: Map[String, AsnModule] = Map.empty,
                         private val messageHandler: MessageHandler = ConsoleMessageHandler(false)) {
  ...
}

这里面的Map都是Scala的Map,所以从Java里不能直接调用,而Scala的构造方法又比较特别,重载的话只能减少参数个数而不能改变类型。这时候就需要companion object上场了。

1
2
3
4
5
6
object DefaultTransformer {
  def apply(asnModule: AsnModule,
                 dependencies: java.util.Map[String, AsnModule],
                 repository: java.util.Map[String, AsnModule],
                 messageHandler: MessageHandler) = new DefaultTransformer(asnModule, dependencies, repository, messageHandler)
}

Java这样调用:

1
Transformer transformer = DefaultTransformer.apply(...);

3. Scala的bug

嗯,XML在什么语言里都是让人恶心的东西,即使Scala已经尽力做好了:https://issues.scala-lang.org/browse/SI-4865

Lift的REST支持

跟所有Lift的feature一样,支持REST也是非常非常得简单。

Mixin这个trait:

1
object AdminAPI extends RestHelper

Boot.scala里面加上:

1
LiftRules.dispatch.append(AdminAPI)

如果不需要创建session(不创建著名的S对象),还可以:

1
LiftRules.statelessDispatchTable.append(AdminAPI)

然后就是URL匹配:

1
2
3
4
5
6
serve {
  case "api" :: "add" :: Nil JsonGet _ => add
  case "api" :: "delete" :: Nil JsonGet _ => delete
  case "api" :: "edit" :: Nil JsonGet _ => edit
  case "api" :: "get" :: Nil JsonGet _ => get
}

以上都是GET的JSON接口。JsonGet会检查HTTP头里面的Accept以确认client是否支持JSON,这里需要注意的是“*/*”表示所有都接受(这个应该已经在2.3里面被修复掉了);当然如果Accept里面不支持JSON,JsonGet还会去查询URL是否是.json结尾。

具体解释一下上面的例子。

对于URL为 http://server/api/add.json 的请求,第一条匹配规则命中,最后那个“_”是Req,然后调用add方法,add的返回值需要是LiftResponse类型;其实LiftReponse是个啥都不干的trait,有很多实现,JsonResponse、XmlResponse等等,总之几乎不用特别在意,Lift里面能作为response返回给client的都能用。

其他的匹配类似处理。

给个add方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
private def add = {
  val linkId = param(ShortenedUrl.originUrl.name).map(x => {
    shortenedUrl.find(ShortenedUrl.originUrl.name -> x).map(_.linkId.value) or {
      val tmp = (DependencyFactory.inject[NextIdGenerator].open_! !? 'id).toString
      shortenedUrl.createRecord.linkId(tmp).originUrl(x).shortUrl(Props.get(Site).open_! + "/" + tmp).date(new Date).
              ip(containerRequest.map(_.remoteAddress).toString).clickCount(0).save
      Full(tmp)
    }
  })
  (StatusField -> linkId.map(_.map(x => SuccessStatus)).openOr(Full(FailedStatus)).openOr(FailedStatus)) ~
          (ShortenedUrl.linkId.name -> linkId.openOr(Full("")).openOr(""))
}

返回一个JSON对象{status: “successful”, linkId: “1″}。

Lift利用Scala对DSL的支持把JSON整得很舒服,几乎跟js原生的用起来没区别:

1
2
3
("name" -> "honnix") ~
  ("address" -> "somewhere") ~
    ("phone" -> List("111", "222", "333"))

等价于:

1
2
3
{name: "honnix",
 address: "somewhere",
 phone: ["111", "222", "333"]}

具爽吧?

Lift的i18n

基本上是转帖:http://www.assembla.com/wiki/show/liftweb/Internationalization

但是还上要说一下,设计得太爽了。没怎么用过其他的web框架,不过Lift提供的真是好用。尤其是最后一点,template也可以直接国际化:index_en_US.html,index_zh_CN.html。不是所有都可以直接通过resource bundle来进行翻译,所以直接对template也就是页面本身来搞,真是很爽。

Lift里进行MongoDB的MapReduce

其实说Lift里不够准确,Lift对MapReduce没做什么封装,基本上就是直接调用mongo-java-driver的API。

举例说明,计算所有URL的点击次数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    def countClicks = {
      MongoDB.useCollection(shortenedUrl.collectionName) {
        x => {
          val map = """function() { emit("totalClickCount", this.%s); }""" format ShortenedUrl.clickCount.name
          val reduce = """function(key, values) { return Array.sum(values); }"""
          val results = x.mapReduce(map, reduce, null, null).results
 
          /**
           * all numbers returned by mongodb is Double since this is how number defined by javascript
           */
          if (results.hasNext) results.next.get("value").asInstanceOf[Number].intValue.toString else "0"
        }
      }
    }

map的时候把this.clickCount的值塞给”totalClickCount”这个key,reduce把所有这些值加起来,MongoDB会生成一个临时的collection,从里面选择“value”这个字段就可以了。

需要注意的是所有返回的数值类型都是Double,虽然BSON里对各种类型都有定义,但是目前为止MongoDB只支持返回Double。对js了解的人可能会比较清楚,但我就惨了。调了很长时间,一个bit一个bit的看,甚至还去MongoDB的JIRA上问:http://jira.mongodb.org/browse/SERVER-2688。土死了……

Lift, MongoDB以及分页

说来惭愧,从来没写过分页的代码,知道是怎么回事儿,但没干过总归差了这么一点儿。

MongoDB和传统的数据库类似,提供了skip和limit,前者用来跳过一批记录,后者用来选择多少条。怎么用这里有很详细的解释:http://www.mongodb.org/display/DOCS/Advanced+Queries,就不多说了。重点讨论Lift里面怎么用。

分页通常有这么几个条件:排序字段、排序方式(升序还是降序)、每页显示条数、当前页面。这些参数可以通过URL传递,譬如:sort-by=linkId&sort-order=-1&perpage=3&page=2,然后HTTP GET发送。

对于这种per request的参数,当然可以用的时候直接从著名的“S”里面取,也可以用RequestVar:

1
2
3
4
5
object page extends RequestVar[Int](S.param("page").openOr("1").toInt)
object perpage extends RequestVar[Int](S.param("perpage").openOr("10").toInt)
object offset extends RequestVar[Int]((page - 1) * perpage)
object sortBy extends RequestVar[String](S.param("sort-by").openOr(ShortenedUrl.linkId.name))
object sortOrder extends RequestVar[Int](S.param("sort-order").openOr("-1").toInt)

这其中offset表示当前页的起始条目,-1表示降序,默认排序字段为linkId。

只有这些还不过瘾,我们再加上搜索条件:

  1. 关键字
  2. 点击次数
1
2
3
4
object search extends RequestVar[String](S.param("search").openOr(""))
object searchIn extends RequestVar[String](S.param("search-in").openOr(ShortenedUrl.originUrl.name))
object clickFilter extends RequestVar[String](S.param("click-filter").openOr("gte"))
object clickLimit extends RequestVar[String](S.param("click-limit").openOr(""))

默认搜索originUrl,点击次数大于等于某个值。

有过数据库搜索设计经验的人一般都会这么干:

1
select * from table where 1=1

然后在后面拼搜索条件:

1
and originUrl like '%google%' and clickCount >= 10 sort by linkId skip 2 limit 10

我们如法炮制,不过稍微改变一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
object clickObject extends RequestVar[JObject](
    if (clickLimit.isEmpty) JObject(Nil)
    else (ShortenedUrl.clickCount.name -> (("$" + clickFilter) -> clickLimit.is.toInt))
)
 
object searchObject extends RequestVar[JObject](
  if (search.isEmpty) JObject(Nil)
  else ("$where" -> ("this." + searchIn + ".indexOf('" + search + "') != -1"))
)
 
object findOptions extends RequestVar[List[FindOption]](
  List(Skip(offset), Limit(perpage))
)
 
shortenedUrl.findAll(clickObject.is ~ searchObject.is,
    (sortBy.is -> sortOrder.is), findOptions: _*)

如果没有搜索条件,返回Nil表示空的List;否则(x -> y)构建一个JObject(这里是Scala的语法糖衣,背后发生了很多);$where那里纯粹是MongoDB特有的,根本就是js;Skip和Limit跟SQL的含义完全相同。这样对最后一个statement的解读也就很自然了。

数据的事情搞定了,剩下就是页面处理,Lift再次表示了自己的牛逼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    def generateNav: NodeSeq = {
      def generateHref(page: Int) = "/?search=" + search + "&sort-by=" + sortBy + "&sort-order=" + sortOrder +
              "&search-in=" + searchIn + "&click-filter=" + clickFilter + "&click-limit=" + clickLimit +
              "&perpage=" + perpage + "&page=" + page
 
      val left = if (page.is != 1) <a title="{&quot;«" href="{generateHref(page">«</a>
      else Nil
 
      val pages = (1 to totalPages).toList.map {
        x =>
          if (x == page.is)
            <strong>
              {"[" + x + "]"}
            </strong>
          else
            <a title="{&quot;Page" href="{generateHref(x)}">
              {x}
            </a>
      }
 
      val right = if (page.is != totalPages) <a title="{&quot;Go" href="{generateHref(page.is">»</a>
      else Nil
 
      left ++ pages ++ right
    }

我写得比较烂,也就这么几行。效果就是这样:

 

Lift与MongoDB

越来越多的公司和组织掺和到NoSQL运动里来,我也跟风玩玩儿MongoDB。

Lift对MongoDB已经有了很好的支持:http://www.assembla.com/wiki/show/liftweb/MongoDB

下面这段例子是一个非常简单的model设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class ShortenedUrl extends MongoRecord[ShortenedUrl] with MongoId[ShortenedUrl] {
  def meta = ShortenedUrl
 
  object linkId extends StringField(this, 10)
 
  object originUrl extends StringField(this, 500)
 
  object shortUrl extends StringField(this, 100)
 
  object date extends DateField(this)
 
  object ip extends StringField(this, 15)
 
  object clickCount extends IntField(this)
}
 
object ShortenedUrl extends ShortenedUrl with MongoMetaRecord[ShortenedUrl]
 
class NextId extends MongoRecord[NextId] with MongoId[NextId] {
  def meta = NextId
 
  object next extends StringField(this, 10)
}
 
object NextId extends NextId with MongoMetaRecord[NextId]

这个东西是我仿照yourls做的数据模型。很简单,两个collection,一个记录缩短域名的所有信息,一个记录下一个URL的id是什么。

查找:

1
ShortenedUrl.find(ShortenedUrl.linkId.name -> id)

存储:

1
2
currentShortenedUrl.linkId(linkId).shortUrl(Props.get("site").open_! + "/" + linkId).
                ip(containerRequest.open_!.remoteAddress).clickCount(0).save

根本不用描述,太简单了。

关于Lift的CSS Binding

具体怎么回事儿,这里写得很清楚,就不啰嗦了:http://www.assembla.com/wiki/show/liftweb/Binding_via_CSS_Selectors

有一点需要特别说明一下:为了彻底把页面设计和后台代码分离,我希望所有和页面布局展示的地方都用静态html实现,然后在snippet里面作binding。这时候就需要TemplateFinder了。

看下面这段:

1
2
3
4
5
6
7
8
9
10
TemplateFinder.findAnyTemplate(List(TemplatesHidden, "edit")) map {
  "#real-content ^^")#> "true" andThen
    "#real-conent [id]" #> (EditPrefix + shortenedUrl.linkId.value) &
    "#edit-url [name]" #> urlId &
    "#edit-url [value]" #> shortenedUrl.originUrl.value &
    "#edit-url [id]" #>; urlId &
    "#save-button [onclick]" #&gt; js.toJsCmd &
    "#save-button [id]" #> (EditPrefix + "submit-" + shortenedUrl.linkId.value) &
    "#cancel-button [onclick]" #> ("hide_edit('" + shortenedUrl.linkId.value + "')") &
    "#cancel-button [id]" #> (EditPrefix + "close-" + shortenedUrl.linkId.value)

edit.html是一个完整的静态html,用来显示一个table,上面代码做的事情就是选中id为real-content的这个节点,然后对它做CSS的binding以实现动态页面。

如何输出完整的菜单系统

Lift默认不会输出整个菜单系统,只有top level,点击包含子项的菜单才会输出下面的层次。这样带来的问题是,如果使用superfish之类的js来控制和渲染就不行了。这个问题相当容易解决:

1
<span class="lift:Menu.builder?expandAll"></span>

加上expandAll就行了。

关于Lift的一些事情

最近在试着用Lift写一个缩URL的东西,由于这个东西相对来说比之前做的小玩意儿复杂一些,写几篇东西作为记录,省得总是要解决之前早就已经解决过的问题。