非同期作用
この章の目標
この章ではAff
モナドに集中します。
これはEffect
モナドに似ていますが、非同期な副作用を表現するものです。
非同期にファイルシステムとやり取りしたりHTTPリクエストしたりする例を実演していきます。
また非同期作用の直列ないし並列な実行の管理方法も押さえます。
プロジェクトの準備
この章で導入する新しいPureScriptライブラリは以下です。
aff
-Aff
モナドを定義します。node-fs-aff
-Aff
を使った非同期のファイルシステム操作。affjax
- AJAXとAff
を使ったHTTPリクエスト。parallel
-Aff
の並列実行。
(Node.js環境のような)ブラウザ外で実行する場合、affjax
ライブラリにはxhr2
NPMモジュールが必要です。
このモジュールはこの章のpackage.json
中の依存関係に挙げられています。
以下を走らせてインストールします。
$ npm install
非同期なJavaScript
JavaScriptで非同期なコードに取り組む上で便利な手段はasync
とawait
です。
非同期な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
なお、main
はEffect Unit
でなければならないので、launchAff_
を使ってAff
からEffect
へと変換せねばなりません。
上のコード片をコールバックや同期関数を使って書き換えることも可能です(例えばNode.FS.Async
やNode.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
パターンを組み合わせることで、以下の演習に挑戦できるでしょう。
演習
-
(簡単)2つのテキストファイルを連結する関数
concatenateFiles
を書いてください。 -
(普通)複数のテキストファイルを連結する関数
concatenateMany
を書いてください。 入力ファイル名の配列と出力ファイル名が与えられます。 手掛かり:traverse
を使ってください。 -
(普通)ファイル中の文字数を返すか、エラーがあればそれを返す関数
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", ...}}
演習
- (簡単)与えられた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
動作を組み合わせます。
両方の結果が返されたら、最終結果を計算してメインの継続に渡せます。
アプリカティブ関手では任意個の引数の関数の持ち上げができるので、このアプリカティブコンビネータを使ってより多くの計算を並列に実行できます。
traverse
やsequence
といった、アプリカティブ関手を扱う全ての標準ライブラリ関数から恩恵を受けることもできます。
直列的なコードの一部と並列計算を組み合わせることもできます。
それにはdo記法ブロック中でアプリカティブコンビネータを使います。
その逆も然りで、必要に応じてparralel
とsequential
を使って型構築子を変更すれば良いのです。
直列実行と並列実行の間の違いを実演するために、100個の10ミリ秒の遅延からなる配列をつくり、それからその遅延を両方の手法で実行します。REPLで試すとseqDelay
がparDelay
より遥かに遅いことに気付くでしょう。並列実行が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人の利用者から並列で情報を取得します。
この場合ではparTarverse
(traverse
の並列版)が使われていますね。
この例は代わりに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のドキュメントの節にもより多くの例が含まれています。
演習
-
(簡単)前の
concatenateMany
関数と同じシグネチャを持つconcatenateManyParallel
関数を書いてください。 ただし全ての入力ファイルを並列に読むようにしてください。 -
(普通)与えられたURLへHTTP
GET
を要求して以下の何れかを返すgetWithTimeout :: Number -> String -> Aff (Maybe String)
関数を書いてください。Nothing
: 要求してから与えられた時間制限(ミリ秒単位)より長く掛かった場合。- 文字列の応答:時間制限を越える前に要求が成功した場合。
-
(難しい)「根」のファイルを取り、そのファイルの中の全てのパスの一覧(そして一覧にあるファイルの中の一覧も)の配列を返す
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
ライブラリを使って並列に非同期コードを走らせる。