CLOVER🍀

That was when it all began.

Velocityのテンプレート配信に、InfinispanのGrid File Systemを使う

InfinispanのGrid File Systemを使った、思い付きのネタです。

Webアプリケーションのテンプレートの配信に、InfinispanのGrid File Systemを使ってみようかなと思いまして。テンプレートエンジンには、Velocityを選択。

あ、そもそもInfinispanのGrid File Systemって、相変わらずexperimental APIですので。そのあたり、ご注意を。

21. Grid File System
http://infinispan.org/docs/6.0.x/user_guide/user_guide.html#_grid_file_system

構成

VelocityViewServletを使った、単純なServletアプリケーションを用意します。ただし、Velocityテンプレートを読み込むResourceLoaderは、InfinispanのGrid File Systemから読み込むような実装を定義します。

テンプレートの配信は、別途クライアント的な簡易プログラムを作成して行います。

つまり、配信側からテンプレートを登録すると、クラスタに参加しているWebアプリケーション側でVelocityのテンプレートが見える、もしくは修正したら反映される、そんな感じですね。

WebアプリケーションにはVelocityテンプレートを含めず、Infinispanが構成するクラスタ内にテンプレートが存在するところがポイントです。

用意

今回、配信側とWebアプリケーション側をsbtのプロジェクトとしては分割しました。いわゆる、マルチプロジェクトですね。実は、初めて定義します。

で、結果こんな感じになりました。
project/Build.scala

import sbt._
import sbt.Keys._

import com.earldouglas.xsbtwebplugin.WebPlugin

object BuildSettings {
  val buildOrganization = "org.littlewings"
  val buildVersion = "0.0.1-SNAPSHOT"
  val buildScalaVersion = "2.11.1"
  val buildScalacOptions = Seq("-Xlint", "-deprecation", "-unchecked", "-feature")

  val buildSettings = Seq(
    organization := buildOrganization,
    version := buildVersion,
    scalaVersion := buildScalaVersion,
    scalacOptions ++= buildScalacOptions,
    incOptions := incOptions.value.withNameHashing(true)
  )
}

object Dependencies {
  val infinispanVersion = "6.0.2.Final"
  val infinispan = "org.infinispan" % "infinispan-core" % infinispanVersion excludeAll(
    ExclusionRule(organization = "org.jgroups", name = "jgroups"),
    ExclusionRule(organization = "org.jboss.marshalling", name = "jboss-marshalling-river"),
    ExclusionRule(organization = "org.jboss.marshalling", name = "jboss-marshalling"),
    ExclusionRule(organization = "org.jboss.logging", name = "jboss-logging"),
    ExclusionRule(organization = "org.jboss.spec.javax.transaction", name = "jboss-transaction-api_1.1_spec")
  )
  val infinispanAsProvided = "org.infinispan" % "infinispan-core" % infinispanVersion % "provided" excludeAll(
    ExclusionRule(organization = "org.jgroups", name = "jgroups"),
    ExclusionRule(organization = "org.jboss.marshalling", name = "jboss-marshalling-river"),
    ExclusionRule(organization = "org.jboss.marshalling", name = "jboss-marshalling"),
    ExclusionRule(organization = "org.jboss.logging", name = "jboss-logging"),
    ExclusionRule(organization = "org.jboss.spec.javax.transaction", name = "jboss-transaction-api_1.1_spec")
  )
  val jgroups = "org.jgroups" % "jgroups" % "3.4.1.Final"
  val jbossTransactionApi = "org.jboss.spec.javax.transaction" % "jboss-transaction-api_1.1_spec" % "1.0.1.Final"
  val jbossMarshallingRiver = "org.jboss.marshalling" % "jboss-marshalling-river" % "1.4.4.Final"
  val jbossMarshalling = "org.jboss.marshalling" % "jboss-marshalling" % "1.4.4.Final"
  val jbossLogging = "org.jboss.logging" % "jboss-logging" % "3.1.2.GA"
  val jcipAnnotations = "net.jcip" % "jcip-annotations" % "1.0"

  val velocity = "org.apache.velocity" % "velocity" % "1.7"
  val velocityTools = "org.apache.velocity" % "velocity-tools" % "2.0"

  val javaeeWebApi = "javax" % "javaee-web-api" % "7.0" % "provided"

  val containerJettyVersion = "9.2.1.v20140609"
  val containerJettyWebapp = "org.eclipse.jetty" % "jetty-webapp" % containerJettyVersion % "container"
  val containerJettyPlus = "org.eclipse.jetty" % "jetty-plus" % containerJettyVersion % "container"
}

object InfinispanVelocityPractice extends Build {
  import BuildSettings._
  import Dependencies._

  val templatePublisherDeps = Seq(
    infinispan,
    jgroups,
    jbossTransactionApi,
    jbossMarshallingRiver,
    jbossMarshalling,
    jbossLogging,
    jcipAnnotations
  )

  val webViewDeps = Seq(
    containerJettyWebapp,
    containerJettyPlus,
    javaeeWebApi,
    velocity,
    velocityTools,
    infinispanAsProvided
  ) ++ (Seq(
    jgroups,
    jbossTransactionApi,
    jbossMarshallingRiver,
    jbossMarshalling,
    jbossLogging,
    jcipAnnotations
  ) map ( _ % "provided"))

  lazy val root =
    Project("infinispan-velocity-practice",
            file("."),
            settings = buildSettings)
      .aggregate(templatePublisher, webView)

  lazy val templatePublisher =
    Project("template-publisher",
            file("template-publisher"),
            settings = buildSettings ++ Seq(fork in run := true) ++ Seq(libraryDependencies ++= templatePublisherDeps))

  lazy val webView =
    Project("web-view",
            file("web-view"),
            settings =
              buildSettings
            ++ Seq(
                artifactName := { (version: ScalaVersion, module: ModuleID, artifact: Artifact) =>
                  "javaee7-web." + artifact.extension
                })
            ++ WebPlugin.webSettings
            ++ Seq(libraryDependencies ++= webViewDeps))
}

ああ、長い長い(笑)。「template-publisher」が配信側、「web-view」がWebアプリケーション側です。

あと、Webアプリケーション側はxsbt-web-pluginも使用するので、プラグインの追加も行います。
project/plugins.sbt

addSbtPlugin("com.earldouglas" % "xsbt-web-plugin" % "0.9.0")

先のBuild.scalaでの「web-view」側に、しれっとxsbt-web-pluginの設定が追加されています。

Infinispanの設定

配信側とWebアプリケーション側で、同じ設定を持ちます。というわけで、同じ設定をそれぞれコピーして配置…。
template-publisher/src/main/resources/infinispan.xml
web-view/src/main/resources/infinispan.xml

<?xml version="1.0" encoding="UTF-8"?>
<infinispan
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="urn:infinispan:config:6.0 http://www.infinispan.org/schemas/infinispan-config-6.0.xsd"
    xmlns="urn:infinispan:config:6.0">

  <global>
    <transport clusterName="VelocityTemplateCluster">
      <properties>
        <property name="configurationFile" value="jgroups.xml" />
      </properties>
    </transport>
    <globalJmxStatistics
        enabled="true"
        jmxDomain="org.infinispan"
        cacheManagerName="DefaultCacheManager"
        />

    <shutdown hookBehavior="REGISTER"/>
  </global>

  <namedCache name="templateDataCache">
    <clustering mode="dist">
      <hash capacityFactor="${template.data.capacityFactor:0}" />
    </clustering>
  </namedCache>

  <namedCache name="templateMetaDataCache">
    <clustering mode="repl" />
  </namedCache>
</infinispan>

Grid File Systemは、データを管理する部分とメタデータを管理する部分に分かれますが、データをdist、メタデータをreplとして定義。また、データの方はcapacityFactorを使って、システムプロパティで設定しない限りは0のヘテロクラスタにします。

まあ、配信側はデータを持たなくていいよね?ってことで…。

JGroupsの設定は、端折ります(GitHubにはアップしています)。

配信側

で、テンプレートを配信するプログラム。
template-publisher/src/main/scala/org/littlewings/infinispan/velocity/VelocityTemplatePublisher.scala

package org.littlewings.infinispan.velocity

import java.nio.file.{Files, Paths}

import org.infinispan.io.{GridFile, GridFilesystem}
import org.infinispan.manager.DefaultCacheManager

object VelocityTemplatePublisher {
  def main(args: Array[String]): Unit = {
    System.setProperty("java.net.preferIPv4Stack", "true")

    val (fromFilePath, toFilePath) = args.toList match {
      case f :: t :: Nil => (f, t)
      case _ =>
        Console.err.println("Input: [fromFilePath] [toFilePath]")
        sys.exit(1)
    }

    publishGridFileSystem(fromFilePath, toFilePath)
  }

  private def publishGridFileSystem(fromFilePath: String, toFilePath: String): Unit = {
    val manager = new DefaultCacheManager("infinispan.xml")

    try {
      val templateDataCache = manager.getCache[String, Array[Byte]]("templateDataCache")
      val metaDataCache = manager.getCache[String, GridFile.Metadata]("templateMetaDataCache")

      val gridFileSystem = new GridFilesystem(templateDataCache, metaDataCache)

      val file = gridFileSystem.getFile(toFilePath)
      if (!file.exists() && file.getParentFile != null) {
        file.getParentFile.mkdirs()
      }

      val os = gridFileSystem.getOutput(file.asInstanceOf[GridFile])
      try {
        for (b <- Files.readAllBytes(Paths.get(fromFilePath))) {
          os.write(b)
        }
      } finally {
        os.close()
      }
    } finally {
      manager.stop()
    }
  }
}

これは、単純にローカルファイルの内容をGrid File Systemに放り込むだけです。起動引数で、読み込むファイルとGrid File Systemに登録する時のパスを指定します。

Webアプリケーション側

Webアプリケーション側は、今時ですが単純なServletプログラムで書きました。

で、最初はGrid File Systemというか、クラスタに参加する部分がネックになるので、とりあえずServletContextListener+Scalaのコンパニオンオブジェクトで。
web-view/src/main/scala/org/littlewings/infinispan/velocity/InfinispanGridFileSystemLocator.scala

package org.littlewings.infinispan.velocity

import scala.collection.JavaConverters._

import javax.servlet.{ServletContextEvent, ServletContextListener}

import org.infinispan.io.{GridFile, GridFilesystem}
import org.infinispan.manager.{DefaultCacheManager, EmbeddedCacheManager}

object InfinispanGridFileSystemLocator {
  private var manager: EmbeddedCacheManager = _
  private var gridFileSystem: GridFilesystem = _

  private def init(): Unit = {
    manager = new DefaultCacheManager("infinispan.xml")

    val templateDataCache = manager.getCache[String, Array[Byte]]("templateDataCache")
    val metaDataCache = manager.getCache[String, GridFile.Metadata]("templateMetaDataCache")

    gridFileSystem = new GridFilesystem(templateDataCache, metaDataCache)
  }

  def getGridFileSystem: GridFilesystem = gridFileSystem

  private def destroy(): Unit = {
    try {
      for (cacheName <- manager.getCacheNames.asScala) {
        manager.getCache(cacheName).stop()
      }
    } finally {
      manager.stop()
    }
  }
}

class InfinispanGridFileSystemLocator extends ServletContextListener {
  override def contextInitialized(sce: ServletContextEvent): Unit =
    InfinispanGridFileSystemLocator.init()

  override def contextDestroyed(sce: ServletContextEvent): Unit =
    InfinispanGridFileSystemLocator.destroy()
}

これを使用する、VelocityのResourceLoaderの実装を作成します。
web-view/src/main/scala/org/littlewings/infinispan/velocity/InfinispanGridFileResourceLoader.scala

package org.littlewings.infinispan.velocity

import java.io.InputStream

import org.infinispan.io.GridFilesystem

import org.apache.commons.collections.ExtendedProperties
import org.apache.velocity.exception.ResourceNotFoundException
import org.apache.velocity.runtime.resource.Resource
import org.apache.velocity.runtime.resource.loader.ResourceLoader

class InfinispanGridFileResourceLoader extends ResourceLoader {
  private def withGfs[A](fun: GridFilesystem => A): A =
    fun(InfinispanGridFileSystemLocator.getGridFileSystem)

  override def init(configuration: ExtendedProperties): Unit = {
  }

  override def getLastModified(resource: Resource): Long =
    withGfs { gfs =>
      val file = gfs.getFile(resource.getName)

      if (file.exists)
        file.lastModified
      else
        0
    }

  override def isSourceModified(resource: Resource): Boolean =
    withGfs { gfs =>
      val file = gfs.getFile(resource.getName)

      if (file.exists)
        file.lastModified != resource.getLastModified
      else
        true
    }

  override def resourceExists(name: String): Boolean =
    withGfs { gfs =>
      gfs.getFile(name).exists
    }

  @throws(classOf[ResourceNotFoundException])
  override def getResourceStream(templateName: String): InputStream =
    withGfs { gfs =>
      val file = gfs.getFile(templateName)

      if (file.exists)
        gfs.getInput(templateName)
      else
        throw new ResourceNotFoundException(s"Resource[$templateName] Not Found in GridFileSystem")
    }
}

で、Velocityにフォワードを行うServlet
web-view/src/main/scala/org/littlewings/infinispan/velocity/TemplateViewServlet.scala

package org.littlewings.infinispan.velocity

import javax.servlet.http.{HttpServlet, HttpServletRequest, HttpServletResponse}

class TemplateViewServlet extends HttpServlet {
  override protected def doGet(req: HttpServletRequest, res: HttpServletResponse): Unit = {
    req.setCharacterEncoding("UTF-8")

    req.setAttribute("name", Option(req.getParameter("name")).getOrElse("かずひら"))
    req.setAttribute("word", Option(req.getParameter("word")).getOrElse("こんにちは!"))

    val templateFile =
      Option(req.getParameter("template"))
        .getOrElse("default") + ".vm"

    req
      .getRequestDispatcher(s"/WEB-INF/template/${templateFile}")
      .forward(req, res)
  }
}

単純ですが、テンプレート名は外から渡せるように…。

あと、さっきからServlet 3.0より前っぽい書き方してますが、まあweb.xml書かざるをえないので、承知の上で…。

web.xmlは、こんな感じですね。
web-view/src/main/webapp/WEB-INF/web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">

  <listener>
    <listener-class>org.littlewings.infinispan.velocity.InfinispanGridFileSystemLocator</listener-class>
  </listener>

  <servlet>
    <servlet-name>TemplateViewServlet</servlet-name>
    <servlet-class>org.littlewings.infinispan.velocity.TemplateViewServlet</servlet-class>
  </servlet>

  <servlet>
    <servlet-name>VelocityViewServlet</servlet-name>
    <servlet-class>org.apache.velocity.tools.view.VelocityViewServlet</servlet-class>
  </servlet>

  <servlet-mapping>
    <servlet-name>TemplateViewServlet</servlet-name>
    <url-pattern>/view</url-pattern>
  </servlet-mapping>

  <servlet-mapping>
    <servlet-name>VelocityViewServlet</servlet-name>
    <url-pattern>*.vm</url-pattern>
  </servlet-mapping>
</web-app>

Velocityの設定は、Velocity Viewのデフォルトの設定をちょっとコピーしてきて、ResourceLoaderを先ほど作成したInfinispanのGrid File Systemから取得するものに差し替えます。
web-viewwebapp/WEB-INF/velocity.properties

# default to servletlogger, which logs to the servlet engines log
runtime.log.logsystem.class = org.apache.velocity.runtime.log.ServletLogChute,org.apache.velocity.tools.view.ServletLogChute

input.encoding = UTF-8
output.encoding = UTF-8

# by default, load resources with webapp resource loader and string resource loader (in that order)
resource.loader = infinispan
infinispan.resource.loader.class = org.littlewings.infinispan.velocity.InfinispanGridFileResourceLoader

# allows getting and setting $request, $session and $application attributes using Velocity syntax,
# like in #set($session.foo = 'bar'), instead of $session.setAttribute('foo','bar')
runtime.introspector.uberspect = org.apache.velocity.util.introspection.UberspectImpl,org.apache.velocity.tools.view.WebappUberspector

Infinispanはprovidedスコープにしたので、jboss-deployment-structure.xmlに利用する定義を行いました。
web-view/src/main/webapp/WEB-INF/jboss-deployment-structure.xml

<?xml version="1.0" encoding="UTF-8"?>
<jboss-deployment-structure xmlns="urn:jboss:deployment-structure:1.2">
  <deployment>
    <dependencies>
      <module name="org.infinispan" />
    </dependencies>
  </deployment>
</jboss-deployment-structure>

が、WildFly 8.1.0.Final(他のバージョンはちゃんと見てませんが)だと、「キミ、内部のモジュール使ってるね?」と怒られました…。

20:36:07,772 WARN  [org.jboss.as.dependency.private] (MSC service thread 1-3) JBAS018567: Deployment "deployment.javaee7-web.war" is using a private module ("org.infinispan:main") which may be changed or removed in future versions without notice.
20:36:07,773 WARN  [org.jboss.as.dependency.private] (MSC service thread 1-3) JBAS018567: Deployment "deployment.javaee7-web.war" is using a private module ("org.infinispan:main") which may be changed or removed in future versions without notice.

むー、Infinispanも、WARに含めた方がいいのかなぁ…?とりあえず、今回はWildFlyのモジュールを使うものとします。

デプロイ

で、Webアプリケーション側のWARファイルを作成して

> package
[info] Updating {file:/xxxx/infinispan-velocity-practice/}web-view...
[info] Resolving org.eclipse.jetty#jetty-jndi;9.2.1.v20140609 ...
[info] Done updating.
[info] Compiling 3 Scala sources to /xxxx/infinispan-velocity-practice/web-view/target/scala-2.11/classes...
[info] Packaging /xxxx/infinispan-velocity-practice/web-view/target/scala-2.11/javaee7-web.war ...
[info] Done packaging.
[success] Total time: 7 s, completed 2014/06/15 21:16:00

WildFlyに放り込みます。

$ cp web-view/target/scala-2.11/javaee7-web.war /path/to/wildfly-8.1.0.Final/standalone/deployments/

WildFlyの起動は、システムプロパティでcapacityFactorを調整しておきます。

$ wildfly-8.1.0.Final/bin/standalone.sh -Dtemplate.data.capacityFactor=1.0

これをやらずに最初に配信側を起動すると、capacityFactorが0のNodeしかいないクラスタになってしまって、コケることになります。

では、curlで確認してみます。

$ curl http://localhost:8080/javaee7-web/view?template=hello
<html><head><title>Error</title></head><body>/WEB-INF/template/hello.vm</body></html>

テンプレートはまだ配信していないので、当然Not Foundです。

テンプレートの配信

それでは、Velocityテンプレートを配信してみます。こんなテンプレートを用意。
template-publisher/template/hello.vm

名前: ${name}
Word: ${word}

これを、配信側のsbtプロジェクトに移って

> project template-publisher
[info] Set current project to template-publisher (in build file:/xxxxx/infinispan-velocity-practice/)

配信します。

> run template/hello.vm /WEB-INF/template/hello.vm

ちなみに、ここで苦労したのはWildFlyにデプロイしたWebアプリケーションが構成するクラスタに、最初入れなかったことですね。最終的に、配信側のプロジェクトをForkするように設定したら入れました。

            settings = buildSettings ++ Seq(fork in run := true) ++ Seq(libraryDependencies ++= templatePublisherDeps))

本来は、sbtからじゃなくて普通に起動しろということでしょうか…。

これでテンプレートが登録できたので、先ほどのcurlコマンドを再実行。

$ curl http://localhost:8080/javaee7-web/view?template=hello
名前: かずひら
Word: こんにちは!

今度は、結果が返ってきました。

では、テンプレートをちょっと修正して
template-publisher/template/hello.vm

名前: ${name}!
Word: ${word}?
ちょっと修正

で、再配信。

> run template/hello.vm /WEB-INF/template/hello.vm

確認。

$ curl http://localhost:8080/javaee7-web/view?template=hello
名前: かずひら!
Word: こんにちは!?
ちょっと修正

反映されてますね。この要領で、さらにテンプレートを追加することもできます。というか、最初からテンプレート追加してましたか…。

今回作成したコードは、こちらにアップしています。
https://github.com/kazuhira-r/infinispan-examples/tree/master/infinispan-velocity-practice

気になるところは、Webアプリケーション側でInfinispanのCacheを作る時にServletContextListener使っちゃいましたが、もうちょっといい方法ないかなぁというところでしょうか。VelocityのResourceLoaderから参照しなくてはいけないので、CDIの@Injectはできないし…BeanManager使うのは、今回パスしました。パッと思い付くのは、そんな感じ。

ちなみに、仕事でもこういうテンプレート配信みたいなことやる機会そこそこあるのですが、その時は普通にrsyncだったりします。

ちょっとやってみたかったネタでした。