読者です 読者をやめる 読者になる 読者になる

この日記は私的なものであり、所属会社の見解ではありません。 GitHub: takahashikzn

Javassistでpropertyタグにパッチをあてる

Struts2

(2010-8-29追記) ココに記載しているコードは、最新版の2.2.1では適用できません。ご注意を。



ずいぶん前の日記で、struts-2.1.8にしてからタグの挙動が変わって困っているということを書きました。


ちょうど同じ現象で困っていた方からの質問を受け、
とりあえず一番簡単な方法を回答したのですが、
個人的な興味から、Javassistを使ってクラスファイルを
書き換える方法を試してみることにしました。

テストコード

こんな感じです。struts2のクラスがロードされるよりも前に実行されるようにします。
僕はSpringのコンテキストファイルの先頭で実行するようにしています。

/*
 * Copyright (C) 2010 root42 Inc. All rights reserved.
 */
package javassist_test;

import java.io.File;
import java.io.FileOutputStream;

import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.LogFactory;


public class PropertyTagPatcherBean {

    public PropertyTagPatcherBean() throws Exception {
        this.modifyClass();
    }

    private void modifyClass() throws Exception {

        final ClassPool classPool = ClassPool.getDefault();
        classPool.appendClassPath(new ClassClassPath(this.getClass()));

        final CtClass ctClass =
            classPool.get("org.apache.struts2.components.Property");

        final CtMethod ctMethod = ctClass.getDeclaredMethod("prepare");

        ctMethod
            .setBody("{"
                + "String result = value;" //
                + "if (this.escape) {" //
                + "    result = result.replaceAll(\"<\", \"&lt;\").replaceAll(\">\", \"&gt;\").replaceAll(\"\\\"\", \"&quot;\").replaceAll(\"&\", \"&amp;\");" //
                + "}" //
                + "if (this.escapeJavaScript) {" //
                + "    result = StringEscapeUtils.escapeJavaScript(result);" //
                + "}" //
                + "return result;" //
                + "}");

        this.writeClassFile(ctClass);
    }

    private void writeClassFile(final CtClass ctClass) throws Exception {

        final File webinfDir = ClasspathUtil.getWebInfAbsoluteDir();
        final File classDir = new File(webinfDir.getAbsolutePath() + "/classes/org/apache/struts2/components");
                
        classDir.mkdir();

        final File classFile = new File(classDir, "Property.class");
        classFile.deleteOnExit();

        final FileOutputStream fos = new FileOutputStream(classFile);
        try {
            IOUtils.write(ctClass.toBytecode(), fos);
        } finally {
            fos.close();
        }
    }
}


で、早速実行してたところ、こんな例外が。

Caused by: compile error: no such class: StringEscapeUtils
	at javassist.compiler.MemberResolver.searchImports(MemberResolver.java:436)
	at javassist.compiler.MemberResolver.lookupClass(MemberResolver.java:412)

あーはいはい、import文なんかないから、流石にわからんよなそりゃ。

というわけで、StringEscapeUtilsをFQCNにしました。

ctMethod
    .setBody("{"
        + "String result = value;" //
        + "if (this.escape) {" //
        + "    result = result.replaceAll(\"<\", \"&lt;\").replaceAll(\">\", \"&gt;\").replaceAll(\"\\\"\", \"&quot;\").replaceAll(\"&\", \"&amp;\");" //
        + "}" //
        + "if (this.escapeJavaScript) {" //
        + "    result = org.apache.commons.lang.xwork.StringEscapeUtils.escapeJavaScript(result);" //  ← ココを修正
        + "}" //
        + "return result;" //
        + "}");

ここまでの作業を経て

とりあえず正常に起動できるようになりました。


では、…いざ往かん!!


予想通り動かない

なんというか、予想通りなんですが見事に動きません。
の結果がnullになる模様。


うーむ何故だ?と考えてみた。すぐにピーンと来た。


さっきsetBodyで置換したコードでは、メソッドの引数(value)を使っています。

private String prepare(final String value) {
    String result = value;
    
    ...
}


しかし、バイトコードになった時点で引数名は失われる。
実際に、なんか適当にクラスファイルを逆コンパイルしてみると分かります。
引数名が「arg1, arg2, ...」のように自動付与されたモノに変化しているはずです。


ということは、引数に何か問題があるのでは。。。


調べてみると、引数は"$1", "$2"という名称でアクセスしなければならないようです。


早速修正。

今度こそ。。。?

自動的に生成された

WEB-INF/classes/org/apache/struts2/components/Property.class

を逆コンパイルしてみました。

// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.kpdus.com/jad.html
// Decompiler options: packimports(3) 
// Source File Name:   Property.java

package org.apache.struts2.components;

import com.opensymphony.xwork2.util.ValueStack;
import com.opensymphony.xwork2.util.logging.Logger;
import com.opensymphony.xwork2.util.logging.LoggerFactory;
import java.io.IOException;
import java.io.Writer;
import org.apache.commons.lang.xwork.StringEscapeUtils;

// Referenced classes of package org.apache.struts2.components:
//            Component

public class Property extends Component
{

    public Property(ValueStack stack)
    {
        super(stack);
        escape = true;
        escapeJavaScript = false;
    }

    public void setDefault(String defaultValue)
    {
        this.defaultValue = defaultValue;
    }

    public void setEscape(boolean escape)
    {
        this.escape = escape;
    }

    public void setEscapeJavaScript(boolean escapeJavaScript)
    {
        this.escapeJavaScript = escapeJavaScript;
    }

    public void setValue(String value)
    {
        this.value = value;
    }

    public boolean start(Writer writer)
    {
        boolean result = super.start(writer);
        String actualValue = null;
        if(value == null)
            value = "top";
        else
            value = stripExpressionIfAltSyntax(value);
        actualValue = (String)getStack().findValue(value, java/lang/String, throwExceptionOnELFailure);
        try
        {
            if(actualValue != null)
                writer.write(prepare(actualValue));
            else
            if(defaultValue != null)
                writer.write(prepare(defaultValue));
        }
        catch(IOException e)
        {
            LOG.info((new StringBuilder()).append("Could not print out value '").append(value).append("'").toString(), e, new String[0]);
        }
        return result;
    }

    private String prepare(String s)
    {
        String s1 = s;
        if(escape)
            s1 = s1.replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("&", "&amp;");
        if(escapeJavaScript)
            s1 = StringEscapeUtils.escapeJavaScript(s1);
        return s1;
    }

    private static final Logger LOG = LoggerFactory.getLogger(org/apache/struts2/components/Property);
    private String defaultValue;
    private String value;
    private boolean escape;
    private boolean escapeJavaScript;

}

prepareメソッドを見ればわかる通り、コードレベルではうまくいっているように見えます。


実際、動かしてみるときちんと動きました。

それにしても

Javassistに渡しているのはコードの断片だけなんですが、これだけでコンパイルできるものなんですねぇ。


おそらく、

  • Propertyクラスの全ソースを復元して、引数で渡したコード断片と入れ替えてクラス全体を再コンパイルしている

のではなく、

んでしょうねぇ。
Javassistのコードを読む気力がないので裏はとってませんが。。。

完成!!

最終的なコードを再掲しておきます。どうぞご自由にお使い下さいませ。(当然ながら無保証です)


PropertyTagPatcherBean.java

/*
 * Copyright (C) 2010 root42 Inc. All rights reserved.
 */
package javassist_test;

import java.io.File;
import java.io.FileOutputStream;

import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.LogFactory;


public class PropertyTagPatcherBean {

    public PropertyTagPatcherBean() throws Exception {
        this.modifyClass();
    }

    private void modifyClass() throws Exception {

        final ClassPool classPool = ClassPool.getDefault();
        classPool.appendClassPath(new ClassClassPath(this.getClass()));

        final CtClass ctClass = classPool.get("org.apache.struts2.components.Property");

        // もう実行済みなら何もしない
        if (ctClass.isFrozen()) {
            return;
        }

        final CtMethod ctMethod = ctClass.getDeclaredMethod("prepare");

        ctMethod
            .setBody("{"
                + "String result = $1;" //
                + "if (this.escape) {" //
                + "    result = result.replaceAll(\"&\", \"&amp;\").replaceAll(\"<\", \"&lt;\").replaceAll(\">\", \"&gt;\").replaceAll(\"\\\"\", \"&quot;\");" //
                + "}" //
                + "if (this.escapeJavaScript) {" //
                + "    result = org.apache.commons.lang.xwork.StringEscapeUtils.escapeJavaScript(result);" //
                + "}" //
                + "return result;" //
                + "}");

        this.writeClassFile(ctClass);
    }

    private void writeClassFile(final CtClass ctClass) throws Exception {

        final File webinfDir = ClasspathUtil.getWebInfAbsoluteDir();
        final File classDir = new File(webinfDir.getAbsolutePath() + "/classes/org/apache/struts2/components");

        if (!classDir.exists() && !classDir.mkdir()) {
            throw new IllegalStateException("mkdir failed: " + classDir);
        }

        final File patchedMarkerFile = new File(classDir, "Property.patched");

        if (patchedMarkerFile.exists()) {
            return;
        } else {
            FileUtils.touch(patchedMarkerFile);
            patchedMarkerFile.deleteOnExit();
        }

        final File classFile = new File(classDir, "Property.class");
        classFile.deleteOnExit();

        LogFactory.getLog(PropertyTagPatcher.class).info("write patched class file to: " + classDir);

        final byte[] bytecode = ctClass.toBytecode();

        final FileOutputStream fos = new FileOutputStream(classFile);
        try {
            IOUtils.write(bytecode, fos);
        } finally {
            fos.close();
        }
    }
}


ClasspathUtil.java

/*
 * Copyright (C) 2010 root42 Inc. All right reserved.
 */
package javassist_test;

import java.io.File;
import java.net.URL;


public final class ClasspathUtil {

    /***/
    private ClasspathUtil() {
    }

    /**
     * WEB-INFディレクトリの絶対パスを取得します。
     * 
     * @return File
     */
    public static File getWebInfAbsoluteDir() {

        final String thisClassPath = "/" + ClasspathUtil.class.getName().replace('.', '/') + ".class";

        final URL thisClassURL = ClasspathUtil.class.getResource(thisClassPath);

        if (!thisClassURL.getPath().contains("WEB-INF")) {
            throw new IllegalStateException("this environment seems non-webapp context: " + thisClassURL);
        }

        // ----
        final boolean isInJar = "jar".equals(thisClassURL.getProtocol());

        if (isInJar) {
            final String jarFilePath = thisClassURL.getPath().replaceFirst("^file:/", "").replaceFirst("[!](/\\w+)+[.]class$", "");

            final String webInfoDir = jarFilePath.replaceFirst("/lib/\\w+[.]jar$", "");

            return new File(webInfoDir);
        } else {
            final String path = thisClassURL.getPath();
            final String classesDir = path.substring(0, path.length() - thisClassPath.length());

            final String webInfoDir = classesDir.replaceFirst("/classes$", "");

            return new File(webInfoDir);
        }
    }
}

2010-12-17追記

とても初歩的なバグが含まれていたので修正しました。

"    result = result.replaceAll(\"<\", \"&lt;\").replaceAll(\">\", \"&gt;\").replaceAll(\"\\\"\", \"&quot;\").replaceAll(\"&\", \"&amp;\");" //

"    result = result.replaceAll(\"&\", \"&amp;\").replaceAll(\"<\", \"&lt;\").replaceAll(\">\", \"&gt;\").replaceAll(\"\\\"\", \"&quot;\");" //

に修正。