GSPにpageEncodingディレクティブを作ってみた![追記]

pageEncodingを作ってみたところで、Parseクラスのソースを見ていたところ、こんなところが。

    public InputStream parse() {

        StringWriter sw = new StringWriter();
        out = new GSPWriter(sw,this);
        page();
        finalPass = true;
        scan.reset();
        page();
//		if (DEBUG) System.out.println(buf);
        InputStream in = new ByteArrayInputStream(sw.toString().getBytes());
        scan = null;
        return in;
    } // parse()

new ByteArrayInputStream(sw.toString().getBytes())としているので、GSPファイルを出力した結果が結局、デフォルト文字コードでバイト列に直されてストリームになってしまうみたいだ。ぐは!!
とりあえず、テストしてみよう。先日のJUnitテストケースをちょっと修正。

 	public void testParseWithPageEncoding() throws Exception {
		String output = parseCodeBytes("myTest", (
				"<%@ page pageEncoding=\"utf-8\" import=\"some.test.package.*\"%>" +
				"<div>\u3053\u3093\u306b\u3061\u306f\ud840\udca2[Japanese]</div>"
				).getBytes("utf-8"));
		String expected = 
			"import org.codehaus.groovy.grails.web.pages.GroovyPage\n" +
			"import org.codehaus.groovy.grails.web.taglib.*\n"+
			"import some.test.package.*\n" +
			"\n"+
			"class myTest extends GroovyPage {\n"+
			"public Object run() {\n"+
			"out.print('<div>\u3053\u3093\u306b\u3061\u306f\ud840\udca2[Japanese]</div>')\n"+
			"}\n"+
			"}";

//		System.out.println(output);
		assertEquals(expected, trimAndRemoveCR(output));
		
		String output2 = parseCodeBytes("myTest", (
				"<%@ page import=\"some.test.package.*\"%><%@ page pageEncoding=\"utf-8\"%>" +
				"<div>\u3053\u3093\u306b\u3061\u306f\ud840\udca2[Japanese]</div>"
				).getBytes("utf-8"));
		
//		System.out.println(output2);
		assertEquals(expected, trimAndRemoveCR(output2));
 	}

\ud840\udca2を入れてみた。これは、𠂢という文字(表示されていないかもしれない)で、MS932(テストマシンの環境:日本語WindowsXP)では多分変換できない。
実行してみたところ、やっぱりエラーだ。うは。
まずは、テストケースから直す必要があるようだ。

    public static void send(InputStream in, Writer out) throws IOException {
        try {
            Reader reader = new InputStreamReader(in);
            char[] buf = new char[8192];
            for (;;) {
                int read = reader.read(buf);
                if (read <= 0) break;
                out.write(buf, 0, read);
            }
        } finally {
            out.close();
            in.close();
        }
    } // writeInputStreamToResponse()

これを、

    public static void send(InputStream in, Writer out) throws IOException {
    	send(in, null, out);
    }
    
    public static void send(InputStream in, String charset, Writer out) throws IOException {
        try {
            Reader reader = (charset == null)
                ? new InputStreamReader(in)
                : new InputStreamReader(in, charset);
            char[] buf = new char[8192];
            for (;;) {
                int read = reader.read(buf);
                if (read <= 0) break;
                out.write(buf, 0, read);
            }
        } finally {
            out.close();
            in.close();
        }
    } // writeInputStreamToResponse()

こんなふうにして、UTF-8で出力結果をチェックするようにして、呼び出し側も修正。

	public String parseCodeBytes(String uri, byte[] gsp) throws IOException {
		StringWriter sw = new StringWriter();
		PrintWriter pw = new PrintWriter(sw);
		InputStream gspIn = new ByteArrayInputStream(gsp);
        Parse parse = new Parse(uri, gspIn);
        InputStream in = parse.parse();
        send(in, "utf-8", pw);

		return sw.toString();
	}
	

次に、Parse#parse()を以下のように修正。

    public InputStream parse() {

        StringWriter sw = new StringWriter();
        out = new GSPWriter(sw,this);
        page();
        finalPass = true;
        scan.reset();
        page();
//		if (DEBUG) System.out.println(buf);
        try {
            InputStream in = (pageEncoding == null)
                ? new ByteArrayInputStream(sw.toString().getBytes())
                : new ByteArrayInputStream(sw.toString().getBytes("utf-8"));
            scan = null;
            return in;
        }
        catch (UnsupportedEncodingException neverHappen) {
        	throw	new GrailsRuntimeException(neverHappen);
        }
    } // parse()

これで、JUnitテストは通るようになった。問題は、これでGSPファイルが正しくコンパイルされるのかどうか、だ。というか、今までデフォルトエンコーディングでできていたのだから、できっこない。
前の日記でちょっと追っかけたところをみると、GroovyPagesTemplateEngine#createPageMetaInfo()か、compileGroovyPage()あたりだろうか。
GroovyPagesTemplateEngine#createPageMetaInfo()のソースは以下のとおり。

    private GroovyPageMetaInfo createPageMetaInfo(Parse parse, long lastModified, InputStream in) {
        GroovyPageMetaInfo pageMeta = new GroovyPageMetaInfo();
        pageMeta.setContentType(parse.getContentType());
        pageMeta.setLineNumbers(parse.getLineNumberMatrix());
        pageMeta.setLastModified(lastModified);
            // just return groovy and don't compile if asked
        if (GrailsUtil.isDevelopmentEnv()) {
            pageMeta.setGroovySource(in);
        }

        return pageMeta;
    }

InputStreamは使っていない。ならば、GroovyPagesTemplateEngine#compileGroovyPage()か。ソースは以下のとおり。

    private Class compileGroovyPage(InputStream in, String name) {
        // Compile the script into an object
        Class scriptClass;
        try {
            scriptClass =
                this.classLoader.parseClass(in, name);
        } catch (CompilationFailedException e) {
        	LOG.error("Compilation error compiling GSP ["+name+"]:" + e.getMessage(), e);
            throw new GroovyPagesException("Could not parse script [" + name + "]: " + e.getMessage(), e);
        }
        return scriptClass;
    }

ここで使ってるようだ。さて、GroovyClassLoader#parseClass(InputStream,String)ってInputStreamをどう扱うのかな。おそらくデフォルト文字コードとして扱うのだとおもう。
ちなみにGroovyClassLoaderのソースはここでみれる(リビジョンは今日の時点での最新)。それによると、こんな感じになっている。

    public Class parseClass(final InputStream in, final String fileName) throws CompilationFailedException {
        // For generic input streams, provide a catch-all codebase of
        // GroovyScript
        // Security for these classes can be administered via policy grants with
        // a codebase of file:groovy.script
        GroovyCodeSource gcs = (GroovyCodeSource) AccessController.doPrivileged(new PrivilegedAction() {
            public Object run() {
                return new GroovyCodeSource(in, fileName, "/groovy/script");
            }
        });
        return parseClass(gcs);
    }

これだけでは、文字コードをどう扱うかはわからない。でも、こんなコードがある。

    public Class parseClass(String text) throws CompilationFailedException {
        return parseClass(new ByteArrayInputStream(text.getBytes()), "script" + System.currentTimeMillis() + ".groovy");
    }

文字列として与えられたスクリプトは、デフォルトの文字コードとして処理されるようだ。そういう動作をストリームでもするのではなかろうか。うわ、八方塞がりの香りがぷんぷんしてきた。
これ以降は、Groovyの方のソースを読まないとわからないなあ、何か良い方法があればよいのだけれど。とりあえず、先日のパッチではダメだということはわかった。orz
Groovy自体は -e とかでエンコーディングの指定ができたはずだ。ということはどこかに設定が隠されているはず。で、GroovyClassLoaderのソースを見ているとこんな箇所が。

    /**
     * this cache contains the loaded classes or PARSING, if the class is currently parsed 
     */
    protected Map classCache = new HashMap();
    protected Map sourceCache = new HashMap();
    private CompilerConfiguration config;
    private Boolean recompile = null;
    // use 1000000 as offset to avoid conflicts with names form the GroovyShell 
    private static int scriptNameCounter = 1000000;
    

CompilerConfigurationですって、奥様!!ここらへんに設定があるに違いありませんわ!!で、ソースはここ
ソースを見ると、

    /**
     * Sets the encoding to be used when reading source files.
     */
    public void setSourceEncoding(String encoding) {
        this.sourceEncoding = encoding;
    }

をを、あるある。これをいじればよいのかな。GroovyTemplatesEngineのソースを見ると、

public class GroovyPagesTemplateEngine  extends ResourceAwareTemplateEngine implements ApplicationContextAware, ServletContextAware {


    private static final Log LOG = LogFactory.getLog(GroovyPagesTemplateEngine.class);
    private static Map pageCache = Collections.synchronizedMap(new HashMap());
    private GroovyClassLoader classLoader = new GroovyClassLoader();
    private int scriptNameCount;
    private ResourceLoader resourceLoader;
    [...]    

となっているが、classLoaderに関してはsetterが存在している。プラグインを作ってみた際に、がっつりと上書きしたところにclassLoaderの指定があったはずだ。

def doWithSpring = {
	groovyPagesTemplateEngine(LocaledTemplateEngine) {
		classLoader = ref("classLoader")
		if(grails.util.GrailsUtil.isDevelopmentEnv()) {
			resourceLoader = groovyPageResourceLoader
		}
	}
	[...]
}

classLoaderを設定しているのは、CoreGrailsPlugin.groovyだ。ソースは、以下のとおり。

	def doWithSpring = {
		classLoader(MethodInvokingFactoryBean) {
			targetObject = ref("grailsApplication", true)
			targetMethod = "getClassLoader"
		}
		classEditor(ClassEditor) {
			classLoader = classLoader
		}
		customEditors(CustomEditorConfigurer) {
			customEditors = [(java.lang.Class.class):classEditor]
		}
	}

となっている。このクラスローダを親のクラスローダにして、文字コードを設定したクラスローダをclassLoaderに設定してやれば、万事うまく行くような気もする。でも、プラグインをインストールしないと動かないパッチっていうのもなぁ。
pageEncodingを廃止して、環境変数とかをいじらずにすむプラグインとかなら簡単にできそうな気がしなくもない。それじゃあ、あまり意味がない?
ふと思ったのだが、CompilerConfigurationでソースのエンコーディングの指定ができるのだとしたら、以下のソース

    public Class parseClass(String text) throws CompilationFailedException {
        return parseClass(new ByteArrayInputStream(text.getBytes()), "script" + System.currentTimeMillis() + ".groovy");
    }

って違くね?