James君!〜Avalon Loggerのターゲット(応用)

JMSTargetFactory

JMS サーバと通信してログを送る。Log4j の JMSAppender に近いが、Log4j だとTopic にしか出せなかったが、こいつは Queue にも出せる。

まあ、こいつを理解するためにはとりあえず先に 「Log4j徹底解説〜JMSAppender」 を読んでおいてくれたまえ。少々厄介なものなので、説明は重複させたくない。とりあえず JBoss で動かしてみるので、そっちの用意がまず先だ。

でコイツの動作モードは Queue/Topic 動作(Queue or Topic)×送るオブジェクト(TextMessage or ObjectMessage)で4通りある。ということは、まず JBoss の上で Queue と Topic を先に作っておこうか。

JBoss の設定ファイルである、${JBOSS_HOME}/server/default/deploy/jbossmq-destinations-service.xml の、まあ一番最後にでもこういうエレメントを追加してくれ。これが JNDI で見える Queue と Topic ということになる。

  <mbean code="org.jboss.mq.server.jmx.Topic"
         name="jboss.mq.destination:service=Topic,name=MyTopic">
    <depends optional-attribute-name="DestinationManager">
         jboss.mq:service=DestinationManager</depends>
  </mbean>

  <mbean code="org.jboss.mq.server.jmx.Queue"
	 name="jboss.mq.destination:service=Queue,name=MyQueue">
    <depends optional-attribute-name="DestinationManager">jboss.mq:service=DestinationManager</depends>
  </mbean>

</server>

まあ、こうやってやると、queue/MyQueue, topic/MyTopic が JNDI の上で見えることになる。これを使うキューとして、environment.xml の上で指定してやればいい。こんな感じになるか。

Queue でオブジェクトを流す場合
<jms id="name">
   <connection-factory>java:/RMIConnectionFactory</connection-factory>
   <destination type="queue">queue/MyQueue</destination>
   <message type="object">
</jms>

まあ、これが一番シンプルな設定になる。connection-factory に「RMIConnectionFactory」を指定する理由はあっちを見てくれ。ObjectMessage で流すオブジェクトは言うまでもなく org.apache.log.LogEvent になる。

Topic でテキストを流す場合
<jms id="echoservice">
  <connection-factory>java:/RMIConnectionFactory</connection-factory>
  <destination type="topic">topic/MyTopic</destination>
  <message type="text">
    <format>%{time:dd/MM/yy HH:mm:ss} %5.5{priority} %{category}: %{message}\n%{throwable}</format>
  </message>
</jms>

要するに message タグの type 属性が text だと、テキスト出力系のターゲットの通例に従って、format タグ指定フォーマットで出力がされるわけだ。とはいえ、JMS のメッセージには「プロパティ」という Hashtable があり、そこに個別のログ要素を分離して運ばせることもできる。その場合には、

<jms id="echoservice">
  <connection-factory>java:/RMIConnectionFactory</connection-factory>
  <destination type="queue">topic/MyTopic</destination>
  <message type="text">
  <property>
    <category>CATEGORY</category>
    <priority>PRIORITY</priority>
    <time>TIME</time>
    <throwable>THROWABLE</throwable>
  </property>
  <format>%{time:dd/MM/yy HH:mm:ss} %5.5{priority} %{category}: %{message}\n%{throwable}</format>
</message>

みたいな書き方をさせてもいい。

で、ここからが問題だ。Phoenix サーバを使う場合、例のクラスローダ問題がやはりひっかかる。定石だと jndi.properties をどこかクラスパスの通ったディレクトリに置いておけばいいのだが、James/Phoenix の場合は、「どこに置いても認識しない..」という情けない目にあう。まあ、これは奥の手で解決するしかない。要するに起動スクリプトを書き換えて、起動コマンド(${JAVA_HOME}/bin/java)の引数として、

-Djava.naming.factory.initial=org.jnp.interfaces.NamingContextFactory
-Djava.naming.provider.url=localhost:1099
-Djava.naming.factory.url.pkgs=org.jboss.naming:org.jnp.interfaces

が渡るようにしてやればいい(値は JBoss の場合。他のは知らんぞ)。

であとはライブラリがいろいろと必要になる...当然、JMS ライブラリが必要なので、jbossall-client.jar を、lib ディレクトリに入れておかなくてはならない。まあ、logkit-*.jar は Echo サーバだろうと、James だろうとすでに lib ディレクトリに入っているだろうからイイとして、問題は....意外なことに、log4j-*.jar が必要なんである(苦笑)。どうも JBoss で作った jbossall-client.jar の内部で Log4j をロギング用途で使っているようなので、これは仕方ない。log4j-*.jar を入れたまえ。何か凄い矛盾している気がするが、仕方がないな....

JDBCTargetFactory

DBに出力する。Log4j の JDBCAppender に近いが、これは設定ファイルの記述で具体的なDB仕様を決めて出す。設定はこんな感じである。

  <jdbc id="echoservice-aux">
     <datasource>java:/MySqlDS</datasource>
      <normalized>true</normalized>
      <table name="LOG">
          <category>CATEGORY</category>
          <priority>PRIORITY</priority>
          <message>MESSAGE</message>
          <time>TIME</time>
          <rtime>RTIME</rtime>
          <throwable>THROWABLE</throwable>
          <context aux="principal">PRINCIPAL</context>
          <context aux="ipaddress">IPADDRESS</context>
          <context aux="username">USERNAME</context>
      </table>
  </jdbc>

要するに、table タグの name 属性が「テーブル名」であり、table タグの各エレメントが各ログ要素になっている。context タグの aux 属性は MDC のキーだわな...まあ、この table タグのエレメントが、実際のDB上の各カラム名に対応しているわけである。というわけで、実際に使う前に、このtable タグのエレメントで指定したカラムを持ったテーブルを作っておきたまえ。まあ、本質的に String 型じゃないログ要素もあるが、time はDBの TIMESTAMP型、rtime は INTEGER型で保存される他は、すべて toString() されてDB保存されるだけである。

で、問題は「どのSQLサーバを使うのか?」を特定するオプション、datasource である。要するに JNDI を使って、サービスをルックアップして...という具合にするのだが、ちょいと困ったことに最近の JBoss だとデータソースを外部に公開してくれないのだ。まあ、Phoenix サーバだから、JBoss(JNDIサーバ)とは別 JVM で動くわけで、ルックアップが失敗してしまう....というわけで、ちょっとこれは実働を確認できなかった。すまぬ。

それも悔しいので、ちょいと実験を兼ねて「Logkitをテストする」というノリで、こんなの書いた。

package jp.or.nurs.sug;

import org.apache.avalon.excalibur.logger.factory.*;

import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.sql.DataSource;

/* 杉浦による追加 */
import java.sql.DriverManager;
import java.sql.Connection;
import java.sql.SQLException;
/* 追加終り */

import org.apache.avalon.excalibur.logger.LogTargetFactory;
import org.apache.avalon.framework.configuration.Configuration;
import org.apache.avalon.framework.configuration.ConfigurationException;
import org.apache.log.LogTarget;
import org.apache.log.output.db.ColumnInfo;
import org.apache.log.output.db.ColumnType;
import org.apache.log.output.db.DefaultJDBCTarget;
import org.apache.log.output.db.NormalizedJDBCTarget;

/* 要するに org.apache.avalon.excalibur.logger.factory.JDBCTargetFactory 
  をカンニングして、その模倣 Factory を書いたわけ */
public class SugJDBCTargetFactory implements LogTargetFactory
{
    public LogTarget createTarget( Configuration configuration )
        throws ConfigurationException
    {
        final String dataSourceName =
            configuration.getChild( "datasource", true ).getValue();

        final boolean normalized =
            configuration.getChild( "normalized", true ).getValueAsBoolean( false );
	/* 杉浦による追加 */
	// 追加するオプションを拾い出す
	String user = configuration.getChild( "user", true ).getValue();
	String passwd = configuration.getChild( "passwd",true ).getValue();
	String driver = configuration.getChild( "driver", true ).getValue();
	/* 追加終り */

        final Configuration tableConfiguration =
            configuration.getChild( "table" );

        final String table = tableConfiguration.getAttribute( "name" );

        final Configuration[] conf = tableConfiguration.getChildren();
        final ColumnInfo[] columns = new ColumnInfo[ conf.length ];

        for( int i = 0; i < conf.length; i++ )
        {
            final String name = conf[ i ].getValue();
            final int type = ColumnType.getTypeIdFor( conf[ i ].getName() );
            final String aux = conf[ i ].getAttribute( "aux", null );

            columns[ i ] = new ColumnInfo( name, type, aux );
        }

        final DataSource dataSource;

	/* 削除...ホントはこうなってる
        try
        {
            Context ctx = new InitialContext();
            dataSource = (DataSource)ctx.lookup( dataSourceName );
        }
        catch( final NamingException ne )
        {
            throw new ConfigurationException( "Cannot lookup data source", ne );
        }
	*/
	// その代理
	dataSource = new MyDataSource( dataSourceName, driver, user, passwd );

        final LogTarget logTarget;
        if( normalized )
        {
            logTarget = new NormalizedJDBCTarget( dataSource, table, columns );
        }
        else
        {
            logTarget = new DefaultJDBCTarget( dataSource, table, columns );
        }

        return logTarget;
    }

    // 内部クラス新設
    class MyDataSource implements DataSource {
	String url;
	int timeout;
	java.io.PrintWriter log;
	String driver, user, passwd;

	public MyDataSource( String name, String driver, String user, String passwd ) {
	    url = name;
	    this.driver = driver;
	    this.user = user;
	    this.passwd = passwd;
	}

	public Connection getConnection() throws SQLException {
	    return getConnection( user, passwd );
	}

	public Connection getConnection(String username, String password) 
	    throws SQLException {
	    if( username == null || password == null || driver == null ||
		url == null ) {
		throw new SQLException( "初期化要素が足りません!" );
	    }
	    try {
		Class.forName( driver );
	    } catch( Exception e ) {
		throw new SQLException( "ドライバが見つかりません" );
	    }
	    return DriverManager.getConnection( url, username, password );
	}
	public java.io.PrintWriter getLogWriter() throws SQLException {
	    return log;
	}
	public void setLogWriter(java.io.PrintWriter out) throws SQLException {
	    log = out;
	}
	public void setLoginTimeout(int seconds) throws SQLException {
	    timeout = seconds;
	}
	public int getLoginTimeout() throws SQLException {
	    return timeout;
	}
    }
}

まあ、要するに DataSource を JNDI で拾い出すんじゃなくて、フツーの JDBC 使いみたいに、内部でドライバをロードして DataSource をでっちあげる MyDataSource クラスを使ったものである。なので、driver とか、user とか、passwd とかオプションを追加しちゃってるし、datasource オプションも実際は DB の URL だ。

でこのソースをコンパイルして、jar で固めたものを phoenix-echo/lib に放りこむ。あと、当然使うデータベースの JDBC ドライバも忘れずに phoenix-echo/lib に放りこんでおいてくれ。

なので、設定はこんな風にした。

<server>
  <logs version="1.1">
    <factories>
      <factory type="jdbc" class="jp.or.nurs.sug.SugJDBCTargetFactory"/>
<!--
      <factory type="jdbc" class="org.apache.avalon.excalibur.logger.factory.JDBCTargetFactory"/>
-->
   </factories>
   ....
  <jdbc id="echoservice-aux" >
     <!-- datasource>java:/LogTargetDataSource</datasource -->
      <datasource>jdbc:mysql://localhost/avalon_log</datasource>
      <user>avalon</user>
      <passwd>avalon</passwd>
      <driver>org.gjt.mm.mysql.Driver</driver>
      <normalized>true</normalized>
      <table name="log">
          <category>category</category>
          <priority>priority</priority>
          <message>message</message>
          <time>time</time>
          <rtime>rtime</rtime>
          <throwable>throwable</throwable>
          <static aux="-">static</static>
          <context aux="principal">principal</context>
          <context aux="ipaddress">ipaddress</context>
          <context aux="username">username</context>
      </table>
  </jdbc>

一応これで、MySQL の avalon_log データベース下で log というテーブルを作ってやればOKだ。テーブルの仕様は、

CREATE TABLE log (
  category TEXT,
  priority TEXT,
  message TEXT,
  time TIMESTAMP,
  rtime INTEGER,
  throwable TEXT,
  static TEXT,
  principal TEXT,
  ipaddress TEXT,
  username TEXT );

みたいなものだ。あ、ちなみに static 項目は固定値「-(aux属性値)」が入るだけのものである。

で、normalized オプションは、category と prioity だけを特別扱いし、それぞれ「category用のカラム」「priority用のカラム」を INTEGER型 で定義して、実名ではなくて「数値のID」で保存するようにする。その「数値のID」は category, priority の内容の「登場順」であり、要するにDB使用量を節約する機能が余計についているものだ。このため、「テーブル名_category_SET」「テーブル名_priority_SET」というIDと名前の対応表を保存する別テーブルを作っておかなくてはならない。仕様は、

CREATE TABLE テーブル名_category_SET (
   NAME TEXT,
   ID   INTEGER );

という程度のものだ。何か名前が大げさなわりにすることはショボい...特に使う必要もなさそうだ。

SMTPTargetFactory

さて、メールでログを送るターゲットである。これはLog4j の SMTPAppender と同様のものであるが、リングバッファで特に WARN 以上でないと送らない..とかいう仕様はない。まあ、設定はこんな感じである。

 <smtp id="echoservice-aux" context-key="session-context">
   <to>sug@shopmail.jp</to>
   <from>echoservice@localhost</from>
   <subject>EchoService Report</subject>
   <maximum-size>0</maximum-size>
 </smtp>

とはいえ、context-key 属性をどう設定したらいいか、よく判らない....ソースの感じでは、親から渡された context の中で、context-key の値(デフォルトで session-context)を検索すると、それが java.mail.Session オブジェクトとなっている...というわけで Session が取れるはず、なんだが、Phoenix から使う場合、この「親から渡される context」は org.apache.framework.context.DefaultContext のオブジェクトで、中身を覗いて見たが、どうも「どうセットしたらイイのか?」が判らない。「app.home」とか「classloader」が入っているところを見ると、かなり上の方で設定されているものであり、これは実際にはローダーである Phoenix で設定されて渡されるもので、ちょいとハックしないと Phoenix では実際にプロパティとして渡すことができない(後で解明する)。

しかし、James に付いてくる excalibur-logger-1.0.jar ではなくて、そのすぐ後のバージョンからは、

 <smtp id="echoservice-aux" >
   <to>sug@shopmail.jp</to>
   <from>echoservice@localhost</from>
   <subject>EchoService Report</subject>
   <maximum-size>0</maximum-size>
   <session>
      <parameter name="mail.host" value="localhost" />
   </session>
 </smtp>

と書けるようになっており、これだともし <session> があれば、こっちをまず見て使うようになっている。だからまあ、これは excalibur-logger のライブラリを新しくしてこっちを使うか、あるいは、

package jp.or.nurs.sug;

import org.apache.avalon.excalibur.logger.factory.*;

import javax.mail.Address;
import javax.mail.Session;
import javax.mail.internet.AddressException;
import javax.mail.internet.InternetAddress;
import org.apache.avalon.framework.configuration.Configuration;
import org.apache.avalon.framework.configuration.ConfigurationException;
import org.apache.avalon.framework.context.ContextException;
import org.apache.avalon.framework.parameters.Parameters;
import org.apache.log.LogTarget;
import org.apache.log.format.Formatter;
import org.apache.log.output.net.SMTPOutputLogTarget;

public class SugSMTPTargetFactory extends AbstractTargetFactory
{
    public final LogTarget createTarget( final Configuration config )
        throws ConfigurationException
    {
        try
        {
            return new SMTPOutputLogTarget(
                getSession( config ),
                getToAddresses( config ),
                getFromAddress( config ),
                getSubject( config ),
                getMaxSize( config ),
                getFormatter( config )
            );
        }
        catch( final ContextException ce )
        {
            throw new ConfigurationException( "Cannot find Session object in " +
                                              "application context", ce );
        }
        catch( final AddressException ae )
        {
            throw new ConfigurationException( "Cannot create address", ae );
        }
    }

    protected Formatter getFormatter( final Configuration config )
    {
        final Configuration confFormat = config.getChild( "format" );

        if( null != confFormat )
        {
            final FormatterFactory formatterFactory = new FormatterFactory();
            return formatterFactory.createFormatter( confFormat );
        }

        return null;
    }

    protected Session getSession( Configuration config )
        throws ContextException, ConfigurationException
    {
        /* 問題の部分のロジック */
	// まず session タグを見るようにする
        final Configuration sessionConfig = config.getChild( "session", false );

        if( null == sessionConfig )
        {
	    /* これがうまく行かないケース */
	    // context-key 属性を拾う
            final String contextkey =
                m_configuration.getAttribute( "context-key", "session-context" );

            if( m_context == null )
            {
                throw new ConfigurationException( "Context not available" );
            }
	    // 設定されないので、例外を投げる...かなり迷惑
            return (Session)m_context.get( contextkey );
        }
        else
        {
	    // で、こっちはOK
            return Session.getInstance(
                Parameters.toProperties(
                    Parameters.fromConfiguration( sessionConfig ) ) );
        }
    }

    private String getSubject( Configuration config )
        throws ConfigurationException
    {
        return config.getChild( "subject" ).getValue();
    }

    private int getMaxSize( Configuration config )
        throws ConfigurationException
    {
        return config.getChild( "maximum-size" ).getValueAsInteger( 1 );
    }

    private int getMaxDelayTime( Configuration config )
        throws ConfigurationException
    {
        return config.getChild( "maximum-delay-time" ).getValueAsInteger( 0 );
    }

    private Address[] getToAddresses( final Configuration config )
        throws ConfigurationException, AddressException
    {
        final Configuration[] toAddresses = config.getChildren( "to" );
        final Address[] addresses = new Address[ toAddresses.length ];

        for( int i = 0; i < toAddresses.length; ++i )
        {
            addresses[ i ] = createAddress( toAddresses[ i ].getValue() );
        }

        return addresses;
    }

    private Address getFromAddress( final Configuration config )
        throws ConfigurationException, AddressException
    {
        return createAddress( config.getChild( "from" ).getValue() );
    }

    protected Address createAddress( final String address )
        throws AddressException
    {
        return new InternetAddress( address );
    }
}

みたいなコードで、SMTPTargetFactory の代理をさせればよかろう。このファイルだけをコンパイルして jar で固めて phoenix-echo/lib に放りこめ。で environment.xml を、

<server>
  <logs version="1.1">
    <factories>
<!--
      <factory type="smtp" 
        class="org.apache.avalon.excalibur.logger.factory.SMTPTargetFactory"/>
-->
      <factory type="smtp" 
        class="jp.or.nurs.sug.SugSMTPTargetFactory"/>
   </factories>

してやればいい。一応 SMTPTargetFactory を継承するのも書いてみたんだが、こっちはうまくいかない。どうもリフレクションで無視されるみたいだ。

じゃあ、オプション項目の説明だ。

 <smtp id="echoservice-aux" >
   <to>sug@shopmail.jp</to>
   <from>echoservice@localhost</from>
   <subject>EchoService Report</subject>
   <maximum-size>0</maximum-size>
   <session>
      <parameter name="mail.host" value="localhost" />
   </session>
 </smtp>

「to」は勿論宛先、「from」は送信元、「subject」は Subject:、って辺りまでは何の問題もなかろうが、「max-size」はやはりこのターゲットは、「発生したログをいちいちメールしてたんじゃタマンない...」ということから、何件かまとめて送るようにできる。要するにこれは「ログの件数」をここで指定して、max-size=4 ならログが4件貯まったら送るように設定できる。で、先ほど出た「parameter」タグの name 属性、value 属性は、javax.mail に馴染んでいればお馴染みの、「メールサーバが立っているホスト指定(mail.host)」である。まあ、ここで「James が立っているホスト」を指定して James で使うのは悪趣味だと思うよ。

あ、ちなみにあとライブラリとして、

  1. mail-1.3.1.jar (java.mail パッケージ)
  2. とそのお供の activation.jar

が必要になるのは当然だ。phoenix-echo/lib に放りこんでくれたまえ。どうせ James が使ってるので、james.sar の中にあるはずだ。

とはいえ、少しくらい感想書いてもいいかな? 意外に Avalon Logger って使えんな。やっぱり FileTargetFactory でちゃんとローテーションしないのが印象悪いぞ...で、特に Logkit の方はソースの JavaDoc コメントも凄いイイカゲンだし、はっきり言って疲れたぞ。こう見てみると Log4j って凄い!



copyright by K.Sugiura, 1996-2006