非同期作用

この章の目標

この章ではAffモナドに集中します。 これはEffectモナドに似ていますが、非同期な副作用を表現するものです。 非同期にファイルシステムとやり取りしたりHTTPリクエストしたりする例を実演していきます。 また非同期作用の直列ないし並列な実行の管理方法も押さえます。

プロジェクトの準備

この章で導入する新しいPureScriptライブラリは以下です。

  • aff - Affモナドを定義します。
  • node-fs-aff - Affを使った非同期のファイルシステム操作。
  • affjax - AJAXとAffを使ったHTTPリクエスト。
  • parallel - Affの並列実行。

(Node.js環境のような)ブラウザ外で実行する場合、affjaxライブラリにはxhr2NPMモジュールが必要です。 このモジュールはこの章のpackage.json中の依存関係に挙げられています。 以下を走らせてインストールします。

$ npm install

非同期なJavaScript

JavaScriptで非同期なコードに取り組む上で便利な手段はasyncawaitです。 非同期なJavaScriptに関するこの記事を見るとより背景情報がわかります。

以下は、この技法を使ってあるファイルの内容を別のファイルに複製する例です。

import { promises as fsPromises } from 'fs'

async function copyFile(file1, file2) {
  let data = await fsPromises.readFile(file1, { encoding: 'utf-8' });
  fsPromises.writeFile(file2, data, { encoding: 'utf-8' });
}

copyFile('file1.txt', 'file2.txt')
.catch(e => {
  console.log('There was a problem with copyFile: ' + e.message);
});

コールバックや同期関数を使うことも可能ですが、以下の理由から望ましくありません。

  • コールバックは過剰な入れ子に繋がります。これは「コールバック地獄」や「悪夢のピラミッド」として知られています。
  • 同期関数はアプリ中の他のコードの実行を堰き止めてしまいます。

非同期なPureScript

PureScriptでのAffモナドはJavaScriptのasync/await構文に似た人間工学を供します。以下は前と同じcopyFileの例ですが、Affを使ってPureScriptで書き換えられています。

import Prelude
import Data.Either (Either(..))
import Effect.Aff (Aff, attempt, message, launchAff_)
import Effect (Effect)
import Effect.Class.Console (log)
import Node.Encoding (Encoding(..))
import Node.FS.Aff (readTextFile, writeTextFile)
import Node.Path (FilePath)

main :: Effect Unit
main = launchAff_ program

program :: Aff Unit
program = do
  result <- attempt $ copyFile "file1.txt" "file2.txt"
  case result of
    Left e -> log $ "There was a problem with copyFile: " <> message e
    _ -> pure unit

copyFile :: FilePath -> FilePath -> Aff Unit
copyFile file1 file2 = do
  my_data <- readTextFile UTF8 file1
  writeTextFile UTF8 file2 my_data

なお、mainEffect Unitでなければならないので、launchAff_を使ってAffからEffectへと変換せねばなりません。

上のコード片をコールバックや同期関数を使って書き換えることも可能です(例えばNode.FS.AsyncNode.FS.Syncをそれぞれ使います)。 しかし、JavaScriptで前にお話ししたのと同じ短所がここでも通用するため、それらのコーディング形式は推奨されません。

Affを扱う文法はEffectを扱うものと大変似ています。 どちらもモナドですし、したがってdo記法で書けます。

例えばreadTextFileのシグネチャを見れば、これがファイルの内容をStringとし、Affに包んで返していることがわかります。

readTextFile :: Encoding -> FilePath -> Aff String

do記法中では束縛矢印 (<-) で返却された文字列を「開封」できます。

my_data <- readTextFile UTF8 file1

それからwriteTextFileに文字列引数として渡します。

writeTextFile :: Encoding -> FilePath -> String -> Aff Unit

上の例で他に目を引くAff固有の特徴はattemptのみです。これはAffのコードの実行中に遭遇したエラーや例外を捕捉してEither内に保管するものです。

attempt :: forall a. Aff a -> Aff (Either Error a)

読者ならきっと、前の章から概念の知識を引き出し、その知識と上のcopyFileの例で学んだ新しいAffパターンを組み合わせることで、以下の演習に挑戦できるでしょう。

演習

  1. (簡単)2つのテキストファイルを連結する関数concatenateFilesを書いてください。

  2. (普通)複数のテキストファイルを連結する関数concatenateManyを書いてください。 入力ファイル名の配列と出力ファイル名が与えられます。 手掛かりtraverseを使ってください。

  3. (普通)ファイル中の文字数を返すか、エラーがあればそれを返す関数countCharacters :: FilePath -> Aff (Either Error Int)を書いてください。

更なるAffの資料

もしまだ公式のAffの手引きを見ていなければ、今ざっと目を通してください。 この章の残りの演習を完了する上で事前に直接必要なことではありませんが、Pursuitで何らかの関数を見付けだす助けになるかもしれません。

以下の補足資料についてもあたってみるとよいでしょう。しかし繰り返しになりますがこの章の演習はこれらの内容に依りません。

HTTPクライアント

affjaxライブラリはAffで非同期なAJAXのHTTP要求をする上での便利な手段を提供します。 対象としている環境が何であるかによって、purescript-affjax-webまたはpurescript-affjax-nodeのどちらかのライブラリを使う必要があります。

この章の以降ではnodeを対象としていくので、purescript-affjax-nodeを使います。 より詳しい使用上の情報はaffjaxのドキュメントにあたってください。 以下は与えられたURLに向けてHTTPのGET要求をして、応答本文ないしエラー文言を返す例です。

import Prelude
import Affjax.Node as AN
import Affjax.ResponseFormat as ResponseFormat
import Data.Either (Either(..))
import Effect.Aff (Aff)

getUrl :: String -> Aff String
getUrl url = do
  result <- AN.get ResponseFormat.string url
  pure case result of
    Left err -> "GET /api response failed to decode: " <> AN.printError err
    Right response -> response.body

これをREPLで呼び出す際は、launchAff_AffからREPLに互換性のあるEffectへと変換する必要があります。

$ spago repl

> :pa
… import Prelude
… import Effect.Aff (launchAff_)
… import Effect.Class.Console (log)
… import Test.HTTP (getUrl)
…
… launchAff_ do
…   str <- getUrl "https://reqres.in/api/users/1"
…   log str
…
unit
{"data":{"id":1,"email":"george.bluth@reqres.in","first_name":"George","last_name":"Bluth", ...}}

演習

  1. (簡単)与えられたURLにHTTPのGETを要求し、応答本文をファイルに書き込む関数writeGetを書いてください。

並列計算

Affモナドとdo記法を使って、非同期計算を順番に実行されるように合成する方法を見てきました。 非同期計算を並列にも合成できたら便利でしょう。 Affがあれば2つの計算を次々に開始するだけで並列に計算できます。

parallelパッケージはAffのようなモナドのための型クラスParallelを定義しており、並列実行に対応しています。 以前に本書でアプリカティブ関手に出会ったとき、並列計算を合成するときにアプリカティブ関手がどれほど便利なのかを見ました。 実はParallelのインスタンスは、(Affのような)モナドmと、並列に計算を組み合わせるために使えるアプリカティブ関手fとの対応関係を定義しているのです。

class (Monad m, Applicative f) <= Parallel f m | m -> f, f -> m where
  sequential :: forall a. f a -> m a
  parallel :: forall a. m a -> f a

このクラスは2つの関数を定義しています。

  • parallel:モナドm中の計算を取り、アプリカティブ関手f中の計算に変えます。
  • sequential:反対方向に変換します。

affライブラリはAffモナドのParallelインスタンスを提供します。 これは、2つの継続のどちらが呼び出されたかを把握することによって、変更可能な参照を使用して並列にAff動作を組み合わせます。 両方の結果が返されたら、最終結果を計算してメインの継続に渡せます。

アプリカティブ関手では任意個の引数の関数の持ち上げができるので、このアプリカティブコンビネータを使ってより多くの計算を並列に実行できます。 traversesequenceといった、アプリカティブ関手を扱う全ての標準ライブラリ関数から恩恵を受けることもできます。

直列的なコードの一部と並列計算を組み合わせることもできます。 それにはdo記法ブロック中でアプリカティブコンビネータを使います。 その逆も然りで、必要に応じてparralelsequentialを使って型構築子を変更すれば良いのです。

直列実行と並列実行の間の違いを実演するために、100個の10ミリ秒の遅延からなる配列をつくり、それからその遅延を両方の手法で実行します。REPLで試すとseqDelayparDelayより遥かに遅いことに気付くでしょう。並列実行がsequence_parSequence_で置き換えるだけで有効になるところに注目です。

import Prelude

import Control.Parallel (parSequence_)
import Data.Array (replicate)
import Data.Foldable (sequence_)
import Effect (Effect)
import Effect.Aff (Aff, Milliseconds(..), delay, launchAff_)

delayArray :: Array (Aff Unit)
delayArray = replicate 100 $ delay $ Milliseconds 10.0

seqDelay :: Effect Unit
seqDelay = launchAff_ $ sequence_ delayArray

parDelay :: Effect Unit
parDelay = launchAff_ $ parSequence_ delayArray
$ spago repl

> import Test.ParallelDelay

> seqDelay -- This is slow
unit

> parDelay -- This is fast
unit

以下は並列で複数回HTTP要求する、より現実味のある例です。 getUrl関数を再利用して2人の利用者から並列で情報を取得します。 この場合ではparTarversetraverseの並列版)が使われていますね。 この例は代わりにtraverseでも問題なく動きますがより遅くなるでしょう。

import Prelude

import Control.Parallel (parTraverse)
import Effect (Effect)
import Effect.Aff (launchAff_)
import Effect.Class.Console (logShow)
import Test.HTTP (getUrl)

fetchPar :: Effect Unit
fetchPar =
  launchAff_ do
    let
      urls = map (\n -> "https://reqres.in/api/users/" <> show n) [ 1, 2 ]
    res <- parTraverse getUrl urls
    logShow res
$ spago repl

> import Test.ParallelFetch

> fetchPar
unit
["{\"data\":{\"id\":1,\"email\":\"george.bluth@reqres.in\", ... }"
,"{\"data\":{\"id\":2,\"email\":\"janet.weaver@reqres.in\", ... }"
]

利用できる並列関数の完全な一覧はPursuitのparallelのドキュメントにあります。parallelのaffのドキュメントの節にもより多くの例が含まれています。

演習

  1. (簡単)前のconcatenateMany関数と同じシグネチャを持つconcatenateManyParallel関数を書いてください。 ただし全ての入力ファイルを並列に読むようにしてください。

  2. (普通)与えられたURLへHTTP GETを要求して以下の何れかを返すgetWithTimeout :: Number -> String -> Aff (Maybe String)関数を書いてください。

    • Nothing: 要求してから与えられた時間制限(ミリ秒単位)より長く掛かった場合。
    • 文字列の応答:時間制限を越える前に要求が成功した場合。
  3. (難しい)「根」のファイルを取り、そのファイルの中の全てのパスの一覧(そして一覧にあるファイルの中の一覧も)の配列を返すrecurseFiles関数を書いてください。 一覧にあるファイルを並列に読んでください。 パスはそのファイルが現れたディレクトリから相対的なものです。 手掛かりnode-pathモジュールにはディレクトリを扱う上で便利な関数があります。

例えば次のようなroot.txtファイルから始まるとします。

$ cat root.txt
a.txt
b/a.txt
c/a/a.txt

$ cat a.txt
b/b.txt

$ cat b/b.txt
c/a.txt

$ cat b/c/a.txt

$ cat b/a.txt

$ cat c/a/a.txt

期待される出力は次の通り。

["root.txt","a.txt","b/a.txt","b/b.txt","b/c/a.txt","c/a/a.txt"]

まとめ

この章では非同期作用と以下の方法を押さえました。

  • affライブラリを使ってAffモナド中で非同期コードを走らせる。
  • affjaxライブラリを使って非同期にHTTPリクエストする。
  • parallelライブラリを使って並列に非同期コードを走らせる。