<@readAllLines>  FreeMarkerテンプレートがファイルをreadしてHTMLに埋め込むことを可能にするディレクティブ

## 解決したい問題

いまわたしが下記のような中身のCSVファイルを持っていたとします。

```
Name,Address
Alice,alice@foo.bar.com
Bob,bob@asure.hal.com
Chris,chris@rio.grande.com
...
```

わたしはこのCSVファイルの各行をペーストしたHTMLを生成したいと思いました。つまりこんなHTMLを生成したい。

```
<html>
<body>
<table>
<tbody>
<tr><td>Name,Address</td></tr>
<tr><td>Alice,alice@foo.bar.com</td></tr>
<tr><td>Bob,bob@asure.hal.com</td></tr>
<tr><td>Chris,chris@rio.grande.com</td></tr>
<tr><td>...</td></tr>

</tbody>
</table>
</body>
</html>
```

わたしはJavaプログラムのためのテンプレート・エンジン　[FreeMarker](https://freemarker.apache.org/)　を使ってこれを実現しようとおもいました。

FreeMarkerは基本的に下の図のような動きをします。

![FreeMarker_base.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52995/20f3a020-a757-4b39-b34d-a46e58a417e2.png)


FreeMarkerはテンプレートの中で`${name}`のように表記された箇所をJavaオブジェクトの中のデータで置換しながらテキストを生成して出力する。この基本型に準じて上記のHTMLを生成したければわたしはCSVファイルの中身をJavaオブジェクトの中に取り込む必要があります。JavaプログラムがCSVファイルをreadして中身を`List<String>`型の変数に格納してからFreeMarkerに手渡すことになる。難しくはありません。

しかしこのやり方はイマイチだとわたしは思った。ファイルのPathをテンプレートに記述するのはOKだ。しかしHTMLの中に埋め込むためだけに長大なデータファイルの中身をJavaオブジェクトの中に格納するのはうまいやり方ではない。テンプレートがデータファイルを直接読んだほうが好ましい。つまり次のような形を実現したいと思った。

![FreeMarker_readAllLines.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52995/0376e19b-ad0a-82ac-b161-2151c6a4ba22.png)

FreeMarkerの[組み込みディレクティブ](https://freemarker.apache.org/docs/ref_directives.html)の中で使えるものを探したが見つからなかった。

## 解決方法

FreeMarkerのAPIを使ってユーザーが独自のディレクティブを作ることができる。サンプルが下記にあった。

- [FreeMarker Programmer's Guide / The Data Model / Directives](https://freemarker.apache.org/docs/pgui_datamodel_directive.html)
- [Template Author's Guide / Miscellaneous / Defining your own Directives](https://freemarker.apache.org/docs/dgui_misc_userdefdir.html)

このサンプルを学習した後、自分のためにディレクティブ `readAllLines` を開発した。


## 説明

### 成果物のありか

GitHubにプロジェクトがあります。

- https://github.com/kazurayam/kazurayam_FreeMarker_directives

この `kazurayam_FreeMarker_directives` の成果物を格納したjarをMavenCentralレポジトリで公開しました。

- https://mvnrepository.com/artifact/com.kazurayam/kazurayam_FreeMarker_directives

### 書式

FreeMarkerテンプレートの中で`readAllLines`ディレクティブを使うとき、下記の書式で書きます。

```
<@readAllLines path="読みこみ対象のファイルの相対パス" ; loopVarの名前>
  <p>${loopVarの名前}</p>
</@readAllLines>
```

ファイルのパスをどう書くか、相対パスを絶対パスに読みかえるには基準となるディレクトリのパスを与えてやらねばならないが、どうやるか？といったことは、サンプルコードへの注釈として、後で説明します。

### ユーザが自作すべきプログラム

サンプルとしてのJavaプログラムを示します。あなたがもし`<@readAllLines>`を利用したいと思ったならば、このサンプルに準ずるJavaプログラムをあなたも自作することが必要です。

このサンプルはJUnit 5フレームワークを使ったユニットテストになっています。

```
package com.kazurayam.freemarker;

import freemarker.template.Template;
import freemarker.template.TemplateException;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class ReadAllLinesDirectiveTest extends TestBase {

    public ReadAllLinesDirectiveTest() throws IOException {
        super();
    }

    @Test
    public void test_execute() throws IOException, TemplateException {
        /* Get the template (uses cache internally) */
        Template temp = cfg.getTemplate("readAllLinesDemo.ftl");

        /* Merge data-model with template */
        Writer out = new StringWriter();
        temp.process(model, out);

        String output = out.toString();
        assertNotNull(output);
        assertTrue(output.contains("<tr><td>0</td><td>publishedDate,uri,title,link,description,author</td></tr>"));
        System.out.println(output);
    }
}
```

この `ReadAllLinesDirectiveTest`クラスは `TestBase`クラスをextendしています。 `TestCase`クラスの中でFreeMarkerのConfigurationを設定する処理を記述しています。


```
package com.kazurayam.freemarker;

import freemarker.template.Configuration;
import freemarker.template.TemplateException;
import freemarker.template.TemplateExceptionHandler;
import freemarker.template.TemplateModelException;
import org.junit.jupiter.api.extension.ExtensionContext;

import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;

public class TestBase {

    protected Configuration cfg;
    protected Map<String, Object> model;

    public TestBase() throws IOException {

        Path projectDir = Paths.get(System.getProperty("user.dir"));

        /* ---------------------------------------------------------- */
        /* You should do this ONLY ONCE in the whole application lifecycle */

        /* Create and adjust the configuration singleton */
        cfg = new Configuration(Configuration.VERSION_2_3_31);

        Path templatesDir = projectDir.resolve("src/test/resources/freemarker_templates");
        cfg.setDirectoryForTemplateLoading(templatesDir.toFile());

        // Recommended settings for new projects:
        cfg.setDefaultEncoding("UTF-8");
        cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
        cfg.setLogTemplateExceptions(false);
        cfg.setWrapUncheckedExceptions(true);
        cfg.setFallbackOnNullLoopVariable(false);
        cfg.setBooleanFormat("c");
        cfg.setOutputEncoding("UTF-8");

        // add custom directives
        try {
            cfg.setSharedVariable("readAllLines", new com.kazurayam.freemarker.ReadAllLinesDirective());
            Path store = projectDir.resolve("src/test/fixture").resolve("store");
            cfg.setSharedVariable("baseDir", store.normalize().toAbsolutePath().toString());
        } catch (TemplateModelException e) {
            throw new RuntimeException(e);
        }

        /* ---------------------------------------------------------- */
        /* You usually do these for MULTIPLE TIMES in the application life-cycle: */

        /* Create a data-model */
        model = new HashMap<>();
    }
}
```

ここでsharedVariableを二つ作っています。`readAllLines`と`baseDir`。

`readAllLines`変数にはカスタムなディレクティブを実装したJavaクラス `com.kazurayam.freemarker.ReadAllLines` のインスタンスを設定しています。つまりテンプレートの中で `<@readAllLines>`と書けば実装クラスの `execute()` メソッドが実行されるようになる。

次に`baseDir`ですが、これには読み込み対象のファイルを格納したディレクトリの絶対パスをString型の値として設定します。テンプレートの中で `<@readAllLines path="ファイルの相対パス">` と記述したところの相対パスを、この`baseDir`変数の値を基底として、絶対パスに変換します。



### テンプレート

```
<#-- readAllLinesDemo.ftlh -->
<#-- custom directive name "readAllLines" is defined as a shared variable. See TestBase.java -->
<#assign x = 0>
<@readAllLines path="AmznPress/20220310_203757/objects/e96bd4c2e345301b567d70071dcec04fda699ce4.csv"; line>
    <tr><td>${x}</td><td>${line}</td></tr>
    <#assign x++>
</@readAllLines>

```

### 入力としてのCSVファイル

```
publishedDate,uri,title,link,description,author
Thu Mar 10 20:00:00 JST 2022,31596,"OOO Until TBD? Majority of Canadian Office Workers Want Remote Work to Stay ",https://press.aboutamazon.com/news-releases/news-release-details/ooo-until-tbd-majority-canadian-office-workers-want-remote-work,"Half of Canadian office workers say working mostly/entirely remote is their ideal scenario; only one-quarter prefer mostly/entirely in office Ability to work remotely and flexible work hours are now more important to office workers than workplace culture, development/growth opportunities and","Amazon.com, Inc. - Press Room News Releases"
Sat Mar 05 10:00:00 JST 2022,31591,Amazon travaille en collaboration avec des ONG et ses employés pour offrir un soutien immédiat au peuple ukrainien,https://press.aboutamazon.com/news-releases/news-release-details/amazon-travaille-en-collaboration-avec-des-ong-et-ses-employes,"Comme beaucoup d'entre vous à travers le monde, nous observons ce qui se passe en Ukraine avec horreur, inquiétude et cœur lourds. Bien que nous n’ayons pas d'activité commerciale directe en Ukraine, plusieurs de nos employés et partenaires sont originaires de ce pays ou entretiennent un lien","Amazon.com, Inc. - Press Room News Releases"
...
以下１０行ほど省略
```

### 出力結果

```
　　　　　　　　<tr><td>0</td><td>publishedDate,uri,title,link,description,author</td></tr>
    <tr><td>1</td><td>Thu Mar 10 20:00:00 JST 2022,31596,"OOO Until TBD? Majority of Canadian Office Workers Want Remote Work to Stay ",https://press.aboutamazon.com/news-releases/news-release-details/ooo-until-tbd-majority-canadian-office-workers-want-remote-work,"Half of Canadian office workers say working mostly/entirely remote is their ideal scenario; only one-quarter prefer mostly/entirely in office Ability to work remotely and flexible work hours are now more important to office workers than workplace culture, development/growth opportunities and","Amazon.com, Inc. - Press Room News Releases"</td></tr>
　　　　　　　　<tr><td>2</td><td>Sat Mar 05 10:00:00 JST 2022,31591,Amazon travaille en collaboration avec des ONG et ses employés pour offrir un soutien immédiat au peuple ukrainien,https://press.aboutamazon.com/news-releases/news-release-details/amazon-travaille-en-collaboration-avec-des-ong-et-ses-employes,"Comme beaucoup d'entre vous à travers le monde, nous observons ce qui se passe en Ukraine avec horreur, inquiétude et cœur lourds. Bien que nous n’ayons pas d'activité commerciale directe en Ukraine, plusieurs de nos employés et partenaires sont originaires de ce pays ou entretiennent un lien","Amazon.com, Inc. - Press Room News Releases"</td></tr>
... (省略)
```

## 結論

FreeMarkerディレクティブ `readAllLines` を容易に自作することができた。FreeMarkerテンプレートの中で`readAllLines`を使えばテンプレートが直接テキストファイルを読み込んで出力の中にテキストを埋め込むことができる。