Tosainu Lab

route なしの Rules で Hakyll のビルドを高速化する

TL;DR

  • Hakyll の Rulesroute は省略できる
    • 省略すると compile されるが出力されない
  • この挙動は何度も呼ばれる処理のプリコンパイルに応用できそう
  • blog.myon.info のフッタ生成処理にこれを採用してビルドを高速化できた
    • 全ページのフッタに最新の記事へのリンクなどを入れているため時間がかかっていた

route なしの Rules

Hakyll の Rules には、「どこに配置するのか」を指定する route と「どう加工するか」を指定する compile を記述します。このうち route は、ドキュメントにもあるように省略することができ、省略した場合はファイルが出力されなくなります。

Finally, some special cases:

  • If there is no route for an item, this item will not be routed, so it will not appear in your site directory.
  • If an item matches multiple routes, the first rule will be chosen.

実際に次のコードで確認してみましょう。

#!/usr/bin/env stack
-- stack --resolver lts-12.6 script --package hakyll

{-# LANGUAGE OverloadedStrings #-}

import           Hakyll

main :: IO ()
main = hakyll $ do
  create ["hoge.txt"] $
    compile $
      makeItem ("にゃーん" :: String)

  create ["fuga.txt"] $ do
    route idRoute
    compile $ do
      hoge <- loadBody "hoge.txt"
      makeItem $ ("Λ__Λ < " <> hoge :: String)

コードを実行したあと出力ディレクトリを確認すると、route を指定した fuga.txt のみが出力されているのがわかります。また出力された fuga.txt の内容を確認してみると hoge.txtRules で指定した結果が表れており、hoge.txtcompile の処理はちゃんと実行されているのがわかります。

$ chmod +x site.hs
$ ./site.hs -v build
Initialising...
  Creating store...
  Creating provider...
  Running rules...
Checking for out-of-date items
  [DEBUG] fuga.txt is out-of-date because it is new
  [DEBUG] hoge.txt is out-of-date because it is new
Compiling
  [DEBUG] Processing fuga.txt
  [DEBUG] Hakyll.Core.Compiler.Internal: Adding dependency: IdentifierDependency hoge.txt
  [DEBUG] Require hoge.txt (snapshot _final): chasing
  [DEBUG] Processing hoge.txt
  updated hoge.txt
  [DEBUG] Processing fuga.txt
  [DEBUG] Require hoge.txt (snapshot _final): OK
  [DEBUG] Processing fuga.txt
  updated fuga.txt
  [DEBUG] Routed to _site/fuga.txt
Success
  [DEBUG] Removing tmp directory...
$ ls _site/
fuga.txt
$ cat _site/fuga.txt
Λ__Λ < にゃーん

この挙動はうまく利用するといろいろなことができそうです。今回は何度も呼ばれる処理のプリコンパイルに使ってみた例を紹介したいと思います。

全ページに最新記事へのリンクを貼りたい

Hakyll で生成する全てのページに最新記事数件へのリンクを入れたいとします。ちょうどこのブログのフッタのような感じですね。

例として Hakyll のサンプルプロジェクトでこれをやってみます。stack でプロジェクトを作成し、

$ stack --resolver lts-12.6 new site hakyll-template

デフォルトテンプレートをこんな感じに変更、

diff --git a/templates/default.html b/templates/default.html
index cd20808..980bb9b 100644
--- a/templates/default.html
+++ b/templates/default.html
@@ -25,6 +25,18 @@
 
             $body$
         </div>
+
+        <div id="recent-posts">
+            <h2>Recent posts</h2>
+            <ul>
+                $for(recent-posts)$
+                    <li>
+                        <a href="$url$">$title$</a> - $date$
+                    </li>
+                $endfor$
+            </ul>
+        </div>
+
         <div id="footer">
             Site proudly generated by
             <a href="http://jaspervdj.be/hakyll">Hakyll</a>

最後に site.hs で最新記事の情報を Context に入れてやります。loadAll を使うと posts/* などで依存関係のエラーが出てしまう1ので、snapshot を作成してそれを利用するようにします。

diff --git a/site.hs b/site.hs
index 1214769..53650a8 100644
--- a/site.hs
+++ b/site.hs
@@ -17,16 +17,28 @@ main = hakyll $ do
 
     match (fromList ["about.rst", "contact.markdown"]) $ do
         route   $ setExtension "html"
-        compile $ pandocCompiler
-            >>= loadAndApplyTemplate "templates/default.html" defaultContext
-            >>= relativizeUrls
+        compile $ do
+            recent <- fmap (take 5) . recentFirst =<< loadAllSnapshots "posts/*" "content"
+            let ctx = listField "recent-posts" postCtx (return recent) `mappend`
+                      defaultContext
+
+            pandocCompiler
+                >>= loadAndApplyTemplate "templates/default.html" ctx
+                >>= relativizeUrls
 
     match "posts/*" $ do
         route $ setExtension "html"
-        compile $ pandocCompiler
-            >>= loadAndApplyTemplate "templates/post.html"    postCtx
-            >>= loadAndApplyTemplate "templates/default.html" postCtx
-            >>= relativizeUrls
+        compile $ do
+            content <- pandocCompiler
+                >>= saveSnapshot "content"
+
+            recent <- fmap (take 5) . recentFirst =<< loadAllSnapshots "posts/*" "content"
+            let ctx = listField "recent-posts" postCtx (return recent) `mappend`
+                      postCtx
+
+            loadAndApplyTemplate "templates/post.html" postCtx content
+                >>= loadAndApplyTemplate "templates/default.html" ctx
+                >>= relativizeUrls
 
     create ["archive.html"] $ do
         route idRoute
@@ -37,9 +49,13 @@ main = hakyll $ do
                     constField "title" "Archives"            `mappend`
                     defaultContext
 
+            recent <- fmap (take 5) . recentFirst =<< loadAllSnapshots "posts/*" "content"
+            let ctx = listField "recent-posts" postCtx (return recent) `mappend`
+                      archiveCtx
+
             makeItem ""
                 >>= loadAndApplyTemplate "templates/archive.html" archiveCtx
-                >>= loadAndApplyTemplate "templates/default.html" archiveCtx
+                >>= loadAndApplyTemplate "templates/default.html" ctx
                 >>= relativizeUrls
 
 
@@ -52,9 +68,13 @@ main = hakyll $ do
                     constField "title" "Home"                `mappend`
                     defaultContext
 
+            recent <- fmap (take 5) . recentFirst =<< loadAllSnapshots "posts/*" "content"
+            let ctx = listField "recent-posts" postCtx (return recent) `mappend`
+                      indexCtx
+
             getResourceBody
                 >>= applyAsTemplate indexCtx
-                >>= loadAndApplyTemplate "templates/default.html" indexCtx
+                >>= loadAndApplyTemplate "templates/default.html" ctx
                 >>= relativizeUrls
 
     match "templates/*" $ compile templateCompiler

これでとりあえずの目的は達成できました。(この図は雑に記事を増やしたあとにキャプチャしたものです)

hakyll1

遅い!!!

先程のコードのまま記事数を増やしてみます。すると・・・

$ for y in {2016..2050}; do
    for m in {01..12}; do
      cp posts/{2015-08,$y-$m}-23-example.markdown
    done
  done
$ time stack exec site rebuild
Removing _site...
Removing _cache...
Removing _cache/tmp...
Initialising...
  Creating store...
  Creating provider...
  Running rules...
Checking for out-of-date items
Compiling
  updated templates/default.html
  updated about.rst
  updated templates/post.html
  updated posts/2015-08-23-example.markdown
  updated posts/2016-01-23-example.markdown
  ...
  updated templates/post-list.html
  updated archive.html
  updated contact.markdown
  updated css/default.css
  updated index.html
Success
stack exec site rebuild  89.15s user 5.75s system 103% cpu 1:31.86 total

ビルドにめちゃくちゃ時間がかかるようになってしまいました。もちろん生成されるページ数が増えたというのもありますが、変更前はログがバッと流れていたものが、1行毎に一瞬止まるようになってしまいました。ページ生成毎に全ての記事情報を読み込んで並べ替えて…なんてやっているので仕方ないですが、やっぱりなんとかしたいところですね。

route なし Rules を使ったプリコンパイル

最新記事を列挙する処理は各ページ固有の情報に依存しないので、全てのページで同じ結果になるはずです。ということは、あらかじめ最新記事リストを生成しておいて、各ページ生成時にその結果を呼び出すようにすれば高速化できそうです。これを route なし Rules を使って実装してみます。

まず、最新記事リストのテンプレート templates/recent-posts.html を作成します。

<div id="recent-posts">
    <h2>Recent posts</h2>
    <ul>
        $for(recent-posts)$
            <li>
                <a href="$url$">$title$</a> - $date$
            </li>
        $endfor$
    </ul>
</div>

このテンプレートを使って、recent-posts.html に最新記事リストを生成するようにします。このファイルはサイトを公開する際には必要ないので、route は記述しません。

diff --git a/site.hs b/site.hs
index 53650a8..2d738ab 100644
--- a/site.hs
+++ b/site.hs
@@ -77,6 +77,14 @@ main = hakyll $ do
                 >>= loadAndApplyTemplate "templates/default.html" ctx
                 >>= relativizeUrls
 
+    create ["recent-posts.html"] $
+        compile $ do
+            recent <- fmap (take 5) . recentFirst =<< loadAllSnapshots "posts/*" "content"
+            let ctx = listField "recent-posts" postCtx (return recent) `mappend`
+                      defaultContext
+            makeItem ""
+                >>= loadAndApplyTemplate "templates/recent-posts.html" ctx
+
     match "templates/*" $ compile templateCompiler

そして、最新記事を毎回列挙するかわりに recent-posts.htmlloadBody するようにします。今回は読み込んだ最新記事リストを recent-list という Context でテンプレート側に渡すことにしました。

diff --git a/site.hs b/site.hs
index 2d738ab..7a9d26a 100644
--- a/site.hs
+++ b/site.hs
@@ -18,8 +18,8 @@ main = hakyll $ do
     match (fromList ["about.rst", "contact.markdown"]) $ do
         route   $ setExtension "html"
         compile $ do
-            recent <- fmap (take 5) . recentFirst =<< loadAllSnapshots "posts/*" "content"
-            let ctx = listField "recent-posts" postCtx (return recent) `mappend`
+            recent <- loadBody "recent-posts.html"
+            let ctx = constField "recent-list" recent `mappend`
                       defaultContext
 
             pandocCompiler
@@ -32,8 +32,8 @@ main = hakyll $ do
             content <- pandocCompiler
                 >>= saveSnapshot "content"
 
-            recent <- fmap (take 5) . recentFirst =<< loadAllSnapshots "posts/*" "content"
-            let ctx = listField "recent-posts" postCtx (return recent) `mappend`
+            recent <- loadBody "recent-posts.html"
+            let ctx = constField "recent-list" recent `mappend`
                       postCtx
 
             loadAndApplyTemplate "templates/post.html" postCtx content
@@ -49,8 +49,8 @@ main = hakyll $ do
                     constField "title" "Archives"            `mappend`
                     defaultContext
 
-            recent <- fmap (take 5) . recentFirst =<< loadAllSnapshots "posts/*" "content"
-            let ctx = listField "recent-posts" postCtx (return recent) `mappend`
+            recent <- loadBody "recent-posts.html"
+            let ctx = constField "recent-list" recent `mappend`
                       archiveCtx
 
             makeItem ""
@@ -68,8 +68,8 @@ main = hakyll $ do
                     constField "title" "Home"                `mappend`
                     defaultContext
 
-            recent <- fmap (take 5) . recentFirst =<< loadAllSnapshots "posts/*" "content"
-            let ctx = listField "recent-posts" postCtx (return recent) `mappend`
+            recent <- loadBody "recent-posts.html"
+            let ctx = constField "recent-list" recent `mappend`
                       indexCtx
 
             getResourceBody

最後に、templates/default.html に追加した最新記事リスト生成部分を $recent-list$ に変更します。

diff --git a/templates/default.html b/templates/default.html
index 980bb9b..0963027 100644
--- a/templates/default.html
+++ b/templates/default.html
@@ -26,16 +26,9 @@
             $body$
         </div>
 
-        <div id="recent-posts">
-            <h2>Recent posts</h2>
-            <ul>
-                $for(recent-posts)$
-                    <li>
-                        <a href="$url$">$title$</a> - $date$
-                    </li>
-                $endfor$
-            </ul>
-        </div>
+        $if(recent-list)$
+            $recent-list$
+        $endif$
 
         <div id="footer">
             Site proudly generated by

これで再度ビルドしてみます。すると・・・

$ time stack exec site rebuild
Removing _site...
Removing _cache...
Removing _cache/tmp...
Initialising...
  Creating store...
  Creating provider...
  Running rules...
Checking for out-of-date items
Compiling
  updated templates/recent-posts.html
  updated recent-posts.html
  updated templates/default.html
  updated about.rst
  updated templates/post.html
  updated posts/2015-08-23-example.markdown
  updated posts/2016-01-23-example.markdown
  ...
  updated templates/post-list.html
  updated archive.html
  updated contact.markdown
  updated css/default.css
  updated index.html
Success
stack exec site rebuild  3.93s user 0.31s system 103% cpu 4.098 total

めちゃくちゃ速くなりました ∩(>◡<*)∩

まとめ

route なしの Rules の挙動を使ってプリコンパイルのようなことを行い、ビルドを高速化する方法を紹介しました。blog.myon.info では、この方法でフッタのプリコンパイルをするようにしたことで、1分半程度掛かっていたビルドが40秒ほどで済むようになりました。

route なしの Rules の活用法はまだたくさんありそうです。Extra Dependencies in Hakyll - Blaenk Denum では、scss のような複数のファイルから1つのファイルを生成したいというケースを Extra Dependencies を使って実現する例を紹介していますが、ここでも活躍しています。

ということで、Hakyll 便利なのでみんな使いましょう!

Footnotes

  1. 自身が自身に依存してしまうので