飛行機の中で Haskell プロジェクトをビルドする

いや別に飛行機の中でビルドするのが主題なわけではないのですが、オフラインモードのことを機内モードと言いますからね。最近の飛行機は Wi-Fi の提供があったりするらしいですが。

さて、あなたの Haskell プロジェクトをオフラインモードでビルドすることができますか? まあ、今時オフライン環境も珍しいですし「そんな必要あるのか?」という感覚もあるかもしれません。Nix ではビルド再現性のためにオフライン環境でビルドできることが求められます1。

Nix で Haskell プロジェクトをビルドするには Input Output(iohk.io)の作成した haskell.nix が使用されるのが普通です。

github.com

これは cabal ファイルや stack ファイル、もしくは plan ファイルを入力とし、Cabal のパッケージひとつにつきひとつの Nix デリベーションに翻訳するシステムです2。Cabal がやってることを Nix(と Haskell)で再実装したと言ってもいいんじゃないかと思います。

いやあ、これが「結構大掛かりなシステムだなあ」と思うわけですよね。「もうちょっと cabal-install に頼ってビルドできないかなあ」と前々から思っていて、とりあえず Proof of Concept が動いたので紹介しようと思います。

コンセプト

自分のアイデアの肝腎は Cabal の「ローカルリポジトリー」を使うことです。普通はローカルリポジトリーを使うことはありません。Hackage つまりリモートリポジトリーを使いますから。ですが、Cabal は Hackage 以外のリポジトリーも使えるように作られています。一般的には ~/.config/cabal/config にある設定ファイルには下記のように書かれています3。

repository hackage.haskell.org
    url: http://hackage.haskell.org/ 

このファイルは、cabal update の実行時にこのファイルがなければデフォルトの内容で生成されます。そのデフォルトの内容に Hackage の URL が記載されているので、みんなは Hackage からパッケージを取ってこられます。なので、やろうと思えば社内リポジトリーを立てて社内ライブラリーを配布したりすることもできます(認証は秘密鍵でできるっぽい)4。そして、この URL の部分は file+nocache というスキーマの URL も書くことができます5。

repository my-local-repository
    url: file+noindex:///absolute/path/to/directory

/absolute/path/to/directory のディレクトリーにはパッケージの sdist ファイルを配置します。

/absolute/path/to/directory/
    foo-0.1.0.0.tar.gz
    bar-0.2.0.0.tar.gz

ここまで準備できれば後は簡単で、下記ステップでオフラインビルドができるようになります。

  1. ローカルリポジトリーのパスを書いた config ファイルを用意する
  2. 依存ライブラリーの sdist ファイルをローカルリポジトリーに配置する
  3. 環境変数やコマンドライン引数で、用意した config ファイルを参照するようにする
  4. cabal build する

ツール

さて「ここまで準備できれば後は簡単」と書きましたが、依存ライブラリーの sdist を取得するはちょっと邪魔くさいです。間接依存や依存間のバージョン制約もあります。なので plan ファイルを元に sdist を取得するちょっとしたスクリプトを用意しました。

github.com

…

dependencies=$(cabal-plan topo --hide-builtin | grep -v ' ')

…

for d in $dependencies
do
  url=https://hackage.haskell.org/package/$d/$d.tar.gz
  if [ $verbose -eq 0 ]
  then
    wget --quiet "$url"
  else
    wget --no-verbose "$url"
  fi
done

cabal-plan で依存のリストを取得して Hackage から wget でダウンロードするようにしています。

あとは flake.nix の buildPhase が肝腎なので気になる人は見ておくといいと思います6。

github.com

runHook preBuild

# `cabal build` writes a file at a local repository,
# and so it must be writable.
cp -r --no-preserve=all $src/.local-repository .
# This cabal.config file declares using the local repository.
export CABAL_CONFIG=$src/cabal.config
# Set a writable directory for cabal
export CABAL_DIR=$TMPDIR/cabal
cabal="cabal --project-dir=$src --builddir=$TMPDIR --verbose"
$cabal v2-build --only-dependencies all
$cabal v2-build all

runHook postBuild

まだ確認できていないこと

cabal.project ファイルに source-repository-package が記載されていたらどうなるのか確認できていません。もしかすると cabal.project ファイルも切り替えないといけないかもしれないです。cabal.project.local に何か記載すれば source-repository-package を無効化できたりしないかなあ。誰か教えてください。


  1. 厳密に言うと Nix の用意する環境の中がオフラインで、Nix の処理系自体はネットワークで情報を取ってこれる。これは「Haskell の言語の中では副作用禁止だが、Haskell 処理系は副作用できる」みたいな話と似ているように思う。↩
  2. もしかしたらデリベーションは、コンポーネントつまり lib とか exe とか test とかと対応するのかも。↩
  3. ファイルの場所は環境や環境変数で変わる。詳しくはセクション 4.1.3. Directories に記載がある。↩
  4. 詳しくはセクション 4.1.4. Repository specification に記載がある。↩
  5. 詳しくはセクション 4.1.4.2. Local no-index repositories に記載がある。↩
  6. 記事を書いていたら「If the directory is not writable, you can append #shared-cache fragment to the URI, then the cache will be stored inside the remote-repo-cache directory.」というのを見つけたので、ローカルリポジトリーをコピーするステップはなくすことができそう。↩

Shake のキャッシュが効いてなかった

下記の記事を覚えていましょうか?

kakkun61.hatenablog.com

Shake を使って静的ウェブサイト生成するようにしたことを書いた記事です。

ただ、どうも生成が遅いんです。しょっちゅうは更新しないので「まあいいか」と放置していたのですが、最近手を付けることがあったので改善してみました。

fsatrace はどう追跡してくれるのか

前方型1の Shake アクションの場合、「fsatrace で変更を追跡する」というようなことがリファレンスに書いてあり、「よく分からんけどそうなんか」ぐらいに思って使っていました。

hackage.haskell.org

実は fsatrace は動的リンクライブラリーのロード時にトレース付きのラッパーを噛ますツールで、外部実行バイナリーを実行したときにしか依存の追跡をしてくれないということが分かりました2。

つまり、今回の場合は主たる処理を Hint で行なっているため、ほぼキャッシュがされていなかったのです!

そこで、何をキャッシュするべきかを明示しないといけません。

キャッシュするよう Shake に伝える

Shake に「これキャッシュしておいて」と指示するために cacheActionWith を使用しました。

cacheActionWith
  :: ( Typeable a, Binary a, Show a
     , Typeable b, Binary b, Show b
     , Typeable c, Binary c, Show c
     )
  => a -> b -> Action c -> Action c 

a が呼び出し箇所ごとに異なるキーの型で、b はその値がキャッシュ時と同じならキャッシュ済みの c の値を返す、c はキャッシュされる結果の型です。

今回は下記のようにしました。

lucid source destination param = do
  …
  hsContents <- Shake.forP hsFiles Shake.readFile' -- hsFiles はインタープリター内で読むファイル
  let hash = Shake.hash (hsContents, param) -- インタープリターで実行する処理の入力をまとめてハッシュ値を得る
  result <- Shake.cacheActionWith ("hint: " ++ source) hash $ Hint.runInterpreter $ do
  …

hsContents と param のどちらも変更されなければキャッシュされた値が result になります。

(今書いていて思ったけどハッシュ値を取らなくても cacheActionWith の第2引数にペアのタプルを渡せばいいかも。)

また、キャッシュの都合、result の型が Binary のインスタンスでなければならず、元々は関数を内部に持つ HtmlT だったため単純にはインスタンスにできず、インタープリター内で show までしてしまうことで対処しました。

結果、初回ビルドは

Build completed in 1m27s

無変更で2度目は

Build completed in 0.85s

やったね🎉


  1. Makefile のようなビルドツールが後方型。↩
  2. なので Go 製実行バイナリーだと追跡できない。↩

Haskell で Open Telemetry を利用してオブザーバビリティーを向上させよう

Open Telemetry って何?

この記事では Open Telemetry のトレースの機能を使います。トレースを使うと、サーバーを越境してコールグラフとその実行時間などを取得することができます。下の画像は Jaeger のスクリーンショットです。Jaeger は Open Telemetry の規格にのっとったコレクター実装のひとつです。

この例では HTTP サーバーと HTTP クライアントでトレースを取得しています。まずサーバーが /1 のパスでリクエストを受けつけたことが分かります。このリクエストに対してレスポンスを返すまでに 845μs かかっていますね。このトレースにおけるひとつの区間をスパンといいます。

次にサーバーはこのリクエストに対して処理をする途中で localhost:7777/2 に HTTP リクエストを投げたことが分かります。リクエストを投げてレスポンスが返ってくるまでに 729μs かかっています。

最後に /2 へのリクエストに対してサーバーが応答したスパンが記録されています。

この例では便宜上、サーバーは自分に対して再度リクエストをしていますが、これは物理的なサーバーが別であっても同様にトレースが取得できます。

Haskell のプログラムに対してトレースを記録したい

Open Telemetry はプログラミング言語や OS などに依存しない仕様ですから、Haskell でもトレースを記録したいです。そうすれば Istio や Node などのスパンとつながったトレースを見ることができます。Haskell では hs-opentelemetry ライブラリーを使用します。

github.com

自分もいっぱいコントリビュートしています。HERP 社からの委託を受け開発しています。

インターフェースは今後破壊的変更が入る可能性が多分にありますが、HERP 社で本番運用している程度に完成しています。

hs-opentelemetry の使い方

hs-opentelemetry はいくつかのパッケージに分かれています。まず基本となるものは hs-opentelemetry-api と hs-opentelemetry-sdk です。api と sdk に分かれているのは Open Telemetry の仕様が分けるよう指示しているためであまり意味はありません。トレースを取得するためのトレーサーおよびトレーサーを作成するためのトレーサープロバイダーを作成するために使用します。また「ここからここまでスパンを取得する」というように手動で指定する場合に使用します。手動で指定するには下記の型をもつ inSpan 関数を使用します。

module OpenTelemetry.Trace.Core

…

inSpan ::
  (MonadUnliftIO m, HasCallStack) =>
  Tracer ->
  -- | The name of the span. This may be updated later via 'updateName'
  Text ->
  -- | Additional options for creating the span, such as 'SpanKind',
  -- span links, starting attributes, etc.
  SpanArguments ->
  -- | The action to perform. 'inSpan' will record the time spent on the
  -- action without forcing strict evaluation of the result. Any uncaught
  -- exceptions will be recorded and rethrown.
  m a ->
  m a

inSpan の第4引数の所要時間をスパンとして記録します。

これでスパンは記録できますが、全部を inSpan で書いていくのはいささか邪魔くさいです。そこでインスツルメンテーションが用意されています。初めのトレースの例では wai 用のインスツルメンテーションと http-client インスツルメンテーションを使用しています。インスツルメンテーションを使用すると初めのトレースの例の実装は下のようになります。

{-# LANGUAGE OverloadedStrings #-}

import qualified Network.HTTP.Client as H
import qualified Network.HTTP.Types.Status as H
import qualified Network.Wai as W
import qualified Network.Wai.Handler.Warp as W
-- Network.HTTP.Client の代わりに he-opentelemetry のインスツルメンテーションを使用する
import OpenTelemetry.Instrumentation.HttpClient (
  Manager (),
  defaultManagerSettings,
  httpLbs,
  newManager,
 )
-- he-opentelemetry のインスツルメンテーションで提供される WAI ミドルウェアを使用する
import OpenTelemetry.Instrumentation.Wai (newOpenTelemetryWaiMiddleware)
import OpenTelemetry.Trace (
  initializeTracerProvider,
  setGlobalTracerProvider,
 )


main :: IO ()
main = do
  -- デフォルト設定でトレーサープロバイダーを作成する
  tracerProvider <- initializeTracerProvider
  -- グローバルな IORef に作成したトレーサープロバイダーを参照させる
  setGlobalTracerProvider tracerProvider
  -- トレースが取れるようラップされた http-client を作成する
  httpClient <- newManager defaultManagerSettings
  -- トレースを取得する WAI ミドルウェアを作成する
  tracerMiddleware <- newOpenTelemetryWaiMiddleware
  W.run 7777 $ tracerMiddleware $ app httpClient


app :: Manager -> W.Application
app httpManager req res =
  case W.pathInfo req of
    ["1"] -> do
      newReq <- H.parseRequest "http://localhost:7777/2"
      newRes <- httpLbs newReq httpManager
      res $ W.responseLBS H.ok200 [] $ "1 (" <> H.responseBody newRes <> ")"
    ["2"] -> res $ W.responseLBS H.ok200 [] "2"
    _ -> res $ W.responseLBS H.ok200 [] "other"

app 関数はこれまで通りの書きごこちですが、HTTP リクエストを受けてレスポンスを返すまで、HTTP リクエストを投げてレスポンスを受けるまでのスパンが取得できるようになっています。簡単ですね。

インスツルメンテーションには他にも mysql-simple 版や grpc-haskell 版などが用意されています(というか作成しました)。また Datadog 仕様のトレースと接続するためにプロパゲーターなども用意されています(これも作成しました)。

実際に手元で動かしてみたい場合はリポジトリーの examples ディレクトリーを参照してください。

Open Telemetry を活用してオブザーバビリティーを上げていきましょう。

それではメリークリスマス!


これは Haskell アドベントカレンダー 2023 25日目の記事です。

qiita.com

wd コマンドをリリースした

wd コマンドって?

これがしたかった。

$ wd ディレクトリー コマンド オプション

とすると「ディレクトリー」をワーキングディレクトリーにして「コマンド」を「オプション」付きで実行する。

pushd でもできるけど popd と合わせるとタイプ数が多かった。

インストール

GitHub のリリースページに Windows・Linux・macOS (x64) 用のバイナリーがある1。

github.com

自分でビルドする場合は cabal と ghc が必要。

$ make install

気に入ったら GitHub にスターをよろしくね。


  1. GitHub Actions に macOS (ARM) が提供されるとそのバイナリーを追加するつもり。↩

Windows で Haskell SDL2

Hackage にある SDL2 ライブラリーを Windows で利用する方法のメモ。

hackage.haskell.org

Haskell-jp の Slack の質問をきっかけに手元で試したことを思い出しながら書いている。

sdl2.cabal に下記の記述があるので C ライブラリーを事前にインストールする必要がある。

    pkgconfig-depends:
      sdl2 >= 2.0.6

今回は stack に附属する MSYS2 を利用する。

stack exec -- pacman -S mingw64/mingw-w64-x86_64-SDL2 でインストールできるはずだが、MSYS2 パッケージメンテナー入れ替えの影響で証明書のインストールをしないと次のようにエラーになることがある。

> stack exec -- pacman -S mingw-w64-x86_64-SDL2
…
error: mingw-w64-x86_64-mpfr: signature from "David Macek <david.macek.0@gmail.com>" is unknown trust
…
error: failed to commit transaction (invalid or corrupted package (PGP signature))
Errors occurred, no packages were upgraded.

MSYS2 のサイトに解決手順が書いてあるのでその通りにする。

www.msys2.org

そうすると stack exec -- pacman -S mingw64/mingw-w64-x86_64-SDL2 が成功する。

pkg-config 自体がインストールされてなかったので stack -- exec pacman -S mingw64/mingw-w64-x86_64-pkg-config でインストールする。

これで使用できるはずなので試してみる。

GitHub 上のリポジトリーに examples があるのでこれを実行する。

github.com

clone してきたディレクトリーで stack init して stack プロジェクトにする。

examples は無効になっているので下記のように stack.yaml を改変して有効にする。

- # flags: {}
+ flags:
+   sdl2:
+     examples: true

ビルドする。

> stack build
…
sdl2> copy/register
Installing library in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\lib\x86_64-windows-ghc-9.0.2\sdl2-2.5.3.3-LQ1fiw2pm1OGrmM1xeYJnd
Installing executable twinklebear-lesson-01 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-15 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-14 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable twinklebear-lesson-04 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-01 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-08 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-09 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable userevent-example in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-05 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-03 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-17 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-02 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable audio-example in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-07 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-10 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-04 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-19 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-43 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable twinklebear-lesson-02 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable eventwatch-example in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-11 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-12 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-13 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable twinklebear-lesson-04a in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable lazyfoo-lesson-18 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Installing executable twinklebear-lesson-05 in C:\Users\kazuki\Projects\Sub\haskell-game\sdl2\.stack-work\install\723b28be\bin
Registering library for sdl2-2.5.3.3..

実行する。

> stack exec lazyfoo-lesson-43

おわり

重ね着したバービー人形 in Haskell

うやむやで終わる記事なので事前にご了承ください。

前回のあらすじ

(前回などないので探さなくていいです。)

高カインドデータ型(Higher-kinded Datatypes; HKD)というものがあります。

fumieval.hatenablog.com

qiita.com

簡単に説明すると下記のようなデータ型 D があるとき

data D =
  D { a :: A
    , b :: B
    }

D の代わりに H のようなデータ型を作ると便利という発想です。

data H f =
  H { a :: f A
    , b :: f B
    }

H Identity だと D と同じ意味になりますし、H Maybe だと部分的に(もしくは全部)値が欠けたものを意味します。

HKD をサポートするライブラリーとして barbies がポピュラーです。

hackage.haskell.org

H を D のように使うには Identity をたくさん書くことになりますが barbies の提供する機能を使うと Identity を書く手間が減らせます。

こういうものが用意されているので、

data Bare
data Covered

type family Wear t f a where
  Wear Bare    f a = a
  Wear Covered f a = f a

H を B のように書き換えます。

data B b f =
  B { a :: Wear b f A
    , b :: Wear b f B
    }

このとき B Bare f は D と同じ構造に B Covered f は H と同じ構造になります。

こういうデータ型に対する操作も barbies は提供しています。

class FunctorB (b Covered) => BareB b where
  bstrip :: b Covered Identity -> b Bare Identity
  bcover :: b Bare Identity -> b Covered Identity

ここまでが「前回のあらすじ」です。

重ね着

ここからは「なんかこういうのがほしいな」という発展です。

data L f =
  L { a :: f A
    , d :: f (D f) -- D も HKD
    }

HKD が入れ子になっててもいいじゃない。

経緯としては、抽象構文木(Abstract Syntax Tree; AST)のデータ型を書いていて、位置情報を持つファンクター(例えば WithLocation とする)を f に与えるようにするとすっきりならんかと思いました。

data Expression f
  = Abstract
      { variable :: f Variable
      , expression :: f (Expression f)
      }
  | Application
      { expression1 :: f (Expression f)
      , expression2 :: f (Expression f)
      }

ファイルをパースして構築するときは Expression WithLocation として、テスト時は Expression Identity として使用します。

こうするとノードに付与する追加データと AST 自体の構造とが分離できて嬉しいです。

ここで barbies にあった Wear を使うとこうなります。

data Expression b f
  = Abstract
      { variable :: Wear b f Variable
      , expression :: Wear b f (Expression b f)
      }
  | …

ここで Expression が BareB のインスタンスになるか考えます。

そのためには FunctorB のインスタンスでないといけません。

FunctorB は次のような型クラスです。

class FunctorB b where
  bmap :: (forall a . f a -> g a) -> b f -> b g

Expression に対する bmap を実装してみます。

instance FunctorB (Expression Covered) where
  bmap f (Abstract v e) = Abstract (f v) (_ $ f e)
  bmap f (Application e1 e2) = …

穴 _ の部分の型は g (Expression Covered f) -> g (Expression Covered g) となるはずですが、うーん実装できなさそうです。

入れ子 HKD では bmap の型は Functor g => (forall a. f a -> g a) -> b f -> b g もしくは Functor f => (forall a. f a -> g a) -> b f -> b g となる必要がありそうです。

そんなわけで入れ子 HKD は barbies の提供する FunctorB や BareB とは別の型クラスが必要となります。

さて、ここでリストを考えます。

newtype List x b f = List { unlist :: [Wear b f (x b f)] }

こういうデータ型があると、これに関して bmap を定義できて便利そうです。

同様にタプルについてもこんな Tuple2 を考えると……

newtype Tuple2 x y b f = Tuple2 { untuple2 :: (Wear b f (x b f), Wear b f (y b f)) }

というふうに考えて進めてたんですが、元の型と Wear 版の型との相互変換がいっぱいになったり、List x Bare Identity が [] になってほしくなって型族を使いだしたりして収拾がつかなくなって一旦やめます、というお話です。

なんやそら。