CLOVER🍀

That was when it all began.

JMXを使用して、Tomcat内のセッションIDと属性を取得する

JMXを使ったTomcatのモニタリングの一環です。TomcatJMXに関する以下のページ
http://tomcat.apache.org/tomcat-7.0-doc/monitoring.html#JMXAccessorInvokeTask:__invoke_MBean_operation_Ant_task
を見ていた感じ、もしかしてこれってJMXで普通に呼べるんじゃない?と思い、トライしてみました。

幸い、ObjectNameはストレートに書いてあるので、typeがManagerのものを探してみればよいことになります。

前回の例ではAttributeばかり触っていましたが、上記2つの情報を見る限り今度はOperationなるものを利用する必要がありそうですね。

なので、今回使うAPIはこちらになりそうです。
javax.management.MBeanServerConnection#invoke

では、まずはセッションを扱うサンプルアプリケーションを作成してみます。ここはメインでは無いので、さらっと流します。
build.sbt

name := "hello-servlet-tomcat"

version := "0.0.1"

scalaVersion := "2.9.1"

organization := "littlewings"

seq(webSettings: _*)

libraryDependencies ++= Seq(
  "org.apache.tomcat" % "tomcat-servlet-api" % "7.0.21" % "provided",
  "org.eclipse.jetty" % "jetty-webapp" % "8.0.1.v20110908" % "jetty"
)

artifactName := { (config: String, module: ModuleID, artifact: Artifact) =>
  artifact.name + "." + artifact.extension
}

project/plugins/build.sbt

resolvers += "Web plugin repo" at "http://siasia.github.com/maven2"    

libraryDependencies <+= sbtVersion(v => "com.github.siasia" %% "xsbt-web-plugin" % ("0.1.1-" + v))

src/main/webapp/WEB-INF/web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
                      http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
  version="3.0"
  metadata-complete="true">  

  <description>
    Hello Servlet Tomcat.
  </description>
  <display-name>Hello Servlet Tomcat</display-name>

  <servlet>
    <servlet-name>HelloServlet</servlet-name>
    <servlet-class>littlewings.HelloServlet</servlet-class>
  </servlet>

  <servlet-mapping>
    <servlet-name>HelloServlet</servlet-name>
    <url-pattern>/hello</url-pattern>
  </servlet-mapping>
</web-app>

サンプルとなるサーブレット
src/main/scala/littlewings/HelloServlet.scala

package littlewings

import scala.collection.JavaConverters._

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

class HelloServlet extends HttpServlet {
  override protected def doGet(request: HttpServletRequest, response: HttpServletResponse): Unit = {
    request.setCharacterEncoding("UTF-8")

    val session = request.getSession()

    request.getParameterNames.asScala.foreach { name =>
      session.setAttribute(name, request.getParameter(name))
    }

    val html =
      <html>
        <head>
          <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
        </head>
        <body>
          <h3>Session Dump</h3>
          {session.getAttributeNames.asScala.map { name =>
            <p>Name:{name}, Value:{session.getAttribute(name)}</p>
          }}
        </body>
      </html>

    response.getWriter.write(html.toString)
  }
}

これを実行すると、HTTP Parameterをセッションに格納しつつ、現在セッションに格納されている全情報を画面に表示するプログラムになっています。

では、今度はJMXを利用して情報を取得する側のプログラムです。

import javax.management.{MBeanServerConnection, ObjectName}
import javax.management.remote.{JMXConnector, JMXConnectorFactory, JMXServiceURL}

object JmxTomcatMonitor {
  val URL: String = "service:jmx:rmi:///jndi/rmi://localhost:9012/jmxrmi"

  def main(args: Array[String]): Unit = {
    try {
      val monitor = new JmxTomcatMonitor(URL)
      monitor.manageConnect {
        monitor.printCatalinaSession("/hello-servlet-tomcat")
      }
    } catch {
      case e: Exception => e.printStackTrace()
    }
  }
}

class JmxTomcatMonitor(val jmxUrl: String) {
  val jmxServiceUrl: JMXServiceURL = new JMXServiceURL(jmxUrl)
  var jmxConnector: Option[JMXConnector] = None

  def manageConnect[A](body: => A): A = {
    try {
      jmxConnector = Some(JMXConnectorFactory.connect(jmxServiceUrl, null))
      body
    } finally {
      try {
        jmxConnector.foreach(_.close())
      } finally {
        jmxConnector = None
      }
    }
  }

  protected def requiredConnector[A](body: MBeanServerConnection => A): A = jmxConnector match {
    case Some(connector) => body(connector.getMBeanServerConnection())
    case None => throw new IllegalStateException("Not Manage Connection")
  }

  def printCatalinaSession(context: String): Unit = requiredConnector {
    connection =>
      val managerMBeanName = new ObjectName("Catalina:type=Manager,context=%s,host=localhost".format(context))
      invoke(managerMBeanName, "listSessionIds", classOf[String]).split(" ").filterNot(_.isEmpty).foreach {
        id =>
          val param1 = invoke(managerMBeanName, "getSessionAttribute", classOf[String], id, "param1")
          println("SessionId[%s]:param1=%s".format(id, param1))
      }
  }

  protected def invoke[T](objectName: ObjectName, operationName: String, resultType: Class[T], params: Object*): T = {
    requiredConnector { connection =>
      // params is WrappedArray's instance...
      if (!params.isEmpty) {
        val signatures = params.map(_.getClass.getName)
        resultType.cast(connection.invoke(objectName, operationName, params.toArray, signatures.toArray))
      } else {
        resultType.cast(connection.invoke(objectName, operationName, null, null))
      }
    }
  }
}

今回は、セッション情報を取得する部分に絞っています。

では、とりあえずセッション情報を作成してみましょう。

$ telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET /hello-servlet-tomcat/hello?param1=foo HTTP/1.1
Host: localhost

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=73D8551E6AFAAA5964EB274485DF5D77; Path=/hello-servlet-tomcat/; HttpOnly
Content-Length: 239
Date: Sun, 18 Sep 2011 13:04:06 GMT

<html>
        <head>
          <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"></meta>
        </head>
        <body>
          <h3>Session Dump</h3>
          <p>Name:param1, Value:foo</p>
        </body>
      </html>
Connection closed by foreign host.
$ telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET /hello-servlet-tomcat/hello?param1=test HTTP/1.1
Host: localhost

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=3312268FF23FDA614340F0A28104CEF4; Path=/hello-servlet-tomcat/; HttpOnly
Content-Length: 240
Date: Sun, 18 Sep 2011 13:04:44 GMT

<html>
        <head>
          <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"></meta>
        </head>
        <body>
          <h3>Session Dump</h3>
          <p>Name:param1, Value:test</p>
        </body>
      </html>
Connection closed by foreign host.

ApplicationContextは「hello-servlet-tomcat」としています。

それぞれGETパラメータに

/hello-servlet-tomcat/hello?param1=foo
/hello-servlet-tomcat/hello?param1=test

と別々のパラメータを付与しているので、それぞれのセッションにAttributeName「param1」で値が格納されたはずです。

では、先ほどのプログラムを実行してみます。

> run
[info] Running JmxTomcatMonitor 
SessionId[3312268FF23FDA614340F0A28104CEF4]:param1=test
SessionId[73D8551E6AFAAA5964EB274485DF5D77]:param1=foo
[success] Total time: 0 s, completed Sep 18, 2011 10:05:10 PM

格納した情報が取得できていますね。

いくつか解説を。

JConsoleとかでTomcatのこの手のサーブレットとかセッションに関するMBeanを見ていると、必ずcontextとhostが付きまといます。今回の例だと「/hello-servlet-tomcat」がコンテキスト名となっているので

      val managerMBeanName = new ObjectName("Catalina:type=Manager,context=%s,host=localhost".format(context))

の変数contextで、どのコンテキストのManagerを取得するのか指定しています。ホストは今回はlocalhostで固定で。

続いて、Operationを呼び出す部分。

  protected def invoke[T](objectName: ObjectName, operationName: String, resultType: Class[T], params: Object*): T = {
    requiredConnector { connection =>
      // params is WrappedArray's instance...
      if (!params.isEmpty) {
        val signatures = params.map(_.getClass.getName)
        resultType.cast(connection.invoke(objectName, operationName, params.toArray, signatures.toArray))
      } else {
        resultType.cast(connection.invoke(objectName, operationName, null, null))
      }
    }
  }

上記で、変数connectionはMBeanServerConnectionインターフェースのインスタンスです。これに対して、ObjectName、オペレーション名、引数、シグニチャを引数として渡すことで、Operationを実行することができます。

ここで、最初シグニチャの意味がわからずにず〜っとnullを渡していて

javax.management.RuntimeOperationsException: Inconsistent arguments and signature
	at org.apache.tomcat.util.modeler.ManagedBean.getInvoke(ManagedBean.java:583)
	at org.apache.tomcat.util.modeler.BaseModelMBean.invoke(BaseModelMBean.java:293)
	at com.sun.jmx.interceptor.DefaultMBeanServerInterceptor.invoke(DefaultMBeanServerInterceptor.java:836)
…省略…

のようなエラーをもらってハマっていたのですが、結局Tomcatの「org.apache.tomcat.util.modeler.ManagedBean#getInvoke」のソースを見て解決。シグニチャって、引数のクラス名を渡せばよかったのね…。

そもそも、MBeanServerConnection#invokeのパラメータの説明が

    name - メソッドの呼び出しが行われる MBean のオブジェクト名
    operationName - 呼び出されるオペレーションの名前
    params - オペレーションの呼び出し時に設定されるパラメータを含む配列
    signature - オペレーションのシグニチャーを含む配列。クラスオブジェクトのロードには、オペレーションを呼び出した MBean をロードするときと同じクラスローダーが使用される。

なので、シグニチャの意味がピンと来なかったんですよね。わかりにくいです…。

あとは、これを使ってOperationを呼び出すだけです。

      val managerMBeanName = new ObjectName("Catalina:type=Manager,context=%s,host=localhost".format(context))
      invoke(managerMBeanName, "listSessionIds", classOf[String]).split(" ").filterNot(_.isEmpty).foreach {
        id =>
          val param1 = invoke(managerMBeanName, "getSessionAttribute", classOf[String], id, "param1")
          println("SessionId[%s]:param1=%s".format(id, param1))
      }

Operation「listSessionIds」の結果は、全セッションIDがスペース区切りで返ってくるので、splitして分解しています。filterNotがかかっているのは、セッションが1件もない場合は空文字が返ってくるからです。

Operation「getSessionAttribute」は、セッションIDと属性名を引数に取るので、それを指定しています。

一応TomcatJMX MBeanを使用するプログラムは書けたものの、これだと特定の属性名に限った情報しか取得できないのでイマイチですね。もうちょっと調べてみようかな。