Elixir/Phoenixで、Agentを使用してサイトマップを生成する方法とその考察

SEOをしっかり考慮したWebサービスを開発する場合、サイトマップの出力は必須になりますが、Rubyの[sitemap_generator](https://github.com/kjvarga/sitemap_generator)のようなElixirの便利パッケージは現時点で存在しません。

パッケージを使わずにゴリゴリ書いて生成することも可能なのですが、他のプロジェクトでも共用することを考慮して、ここはパッケージを作ってしまいましょう。

# 考察1

まずは`sitemap_generator`の動作を調査してみましょう。DSLは下記のようになっています。

```rb
SitemapGenerator::Sitemap.default_host = "http://www.example.com"
SitemapGenerator::Sitemap.create(:include_root => false) do
  add '/', changefreq: 'daily', priority: 1.0
  add '/welcome', changefreq: 'weekly', priority: 0.5
end
```

このDSLによって出力されるXMLは下記のようになります。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns:xsi=...>
  <url>
    <loc>http://www.example.com/</loc>
    <lastmod>2011-05-21T00:03:38+00:00</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>http://www.example.com/welcome</loc>
    <lastmod>2011-05-21T00:03:38+00:00</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.5</priority>
  </url>
</urlset>
```

`sitemap_generator`には他にも色々な機能が用意されていますが、最低限とりあえず上記の動作が実現出来れば良さそうです。

# 考察2

`sitemap_generator`と似た感じのDSLを定義してみましょう。とりあえず下記のようなDSLでオッケーなのではないでしょうか。

```ex
defmodule MyApp.Sitemap do
  use MySitemap, default_host: "http://www.example.com"
  urlset do
    add "/", changefreq: "daily", priority: 1.0
    add "/welcome", changefreq: "weekly", priority: 0.5
    Entry |> Repo.all |> Enum.each(fn(entry) ->
      add "/entries/#{entry.id}", lastmod: entry.updated_at, changefreq: "daily", priority: 1.0
    end)
  end
end
```

`"/"`と`"/welcome"`に加えて、記事詳細ページ用の定義が追加されていますが、ここで一つの問題が発生します。

`"/"`と`"/welcome"`は固定URLなので特に問題無いのですが、記事詳細ページ用の`"/entries/:id"`という形式のURLを生成するには、データベースの参照処理が必要になります。

つまり、上記の`urlset`マクロに渡される`do`ブロック内の処理は、コンパイル時ではなく実行時に呼び出される必要があるということになりますが、これはどのような方法で実現すれば良いのでしょうか？

# 考察3

いくつか方法はあると思いますが、最もシンプルなのは「**`do`ブロックを関数定義の内部で展開する**」という方法だと思いますので、`urlset`マクロと`add`マクロを下記のように定義してみます。

```ex
defmodule MySitemap do
  defmacro urlset([do: block]) do
    quote do
      def render do
        unquote(block)
      end
    end
  end
  defmacro add(path, opts \\ []) do
    quote do
      ...
    end
  end
end
```

関数内で展開された`do`ブロック内の処理は、その関数が呼び出されるまでは実行されませんので、とりあえずこの問題に関してはこの対応でオッケーそうです。

# 考察4

しかしここでまた新たな疑問が生じます。`add`マクロ内の処理でurlの情報を追加していきたいわけですが、Elixirは関数型言語ですので、`MySitemap`モジュールのメンバ変数にurlの情報を追加していくような処理は行えません。

コンパイル時であれば[Module attribute](http://elixir-lang.org/getting-started/module-attributes.html#as-temporary-storage)をtemporary storageとして使用可能ですが、上記の`render`関数は実行時に呼び出されますので、`Module attribute`は使用出来ません。

ということで、ここはstate管理機能を提供している[Agentモジュール](http://elixir-lang.org/getting-started/mix-otp/agent.html)の出番ということになりそうです。

  * (2016/05/13追記) Erlangの提供している[Process Dictionary](http://erlang.org/course/advanced.html#dict)を使用する方法でも良さそうです。[Processモジュール](http://elixir-lang.org/docs/stable/elixir/Process.html)に`put`や`get`関数が用意されています。

# 考察5

`Agent`を使用して、下記のようなモジュールを定義してみましょう。

```ex
defmodule MySitemap.Generator do
  def start_link do
    Agent.start_link(fn -> [] end, name: __MODULE__)
  end

  def add(path, opts) do
    entry = {path, opts}
    Agent.update(__MODULE__, &([entry | &1]))
  end

  def urlset do
    Agent.get(__MODULE__, &(&1 |> Enum.reverse))
  end
end
```

`MySitemap.add/2`マクロは、上記の`MySitemap.Generator.add/2`関数を下記のように呼び出せば良さそうです。

```ex
defmodule MySitemap do
  ...
  defmacro add(path, opts \\ []) do
    quote do
      MySitemap.Generator.add(unquote(path), unquote(opts))
    end
  end
end
```

これで、`MySitemap.add/2`内の処理が実行される度に、`Agent`の管理しているリストにurl情報が追加され、`MySitemap.Generator.urlset`関数を使用してurlのリストを取得出来るようになりました。

# まとめ

上記までの処理に、`supervisor`によるプロセス管理や、gzip圧縮されたサイトマップファイルの出力処理等を追加したパッケージが下記になります。(使用方法は`README.md`に記載されています。`urlset`の各属性値に関しては`sitemap_generator`内のコードをそのまま流用しています)

[plain_sitemap](https://github.com/kenta-aktsk/plain_sitemap)

# 補足

検索エンジンへのping処理等は今後追加していきたいと思います。