Haskell
日記

パス名がShift_JISで格納されたzipファイルの展開ツールをHaskellで書いた話

概要

Haskellで適当なツールでも作ってみたいなと思い、簡単なお題を考えたら意外と大変だった話です。僕のHaskellレベルはすごいH本を読み終わった程度です。

主なハマりポイント:

  • Shift_JISをUnicodeに変換する
  • zipファイルの展開に使うライブラリの選定
  • Conduit

環境

  • Windows10 (Fall Creators Update)
  • msys2
  • stack 1.6.1
  • stackage lts-9.18

すべて64bit版です。

Shift_JISをUnicodeに変換する

Haskell 文字列変換入門 - Qiitaを参考にしました。
当初、iconvを使おうとしてみましたが、あまりうまくいかず、Data.Text.ICU.Convertを使うことにしました。
こういった外部ライブラリに依存したパッケージは、Windows環境では、stackのインストール時に一緒にインストールされるmsys2の中で、mingw64系のパッケージをインストールし、msys2の中で/mingw64/libに利用するライブラリが存在する状態になっていればよいようです。
msys2は%LOCALAPPDATA%\Programs\stack\x86_64-windows\msys2-20150512にあります。msys2_shell.cmdを実行すればminttyが開きます。

$ pacman -S mingw64/mingw-w64-x86_64-icu

でライブラリがインストールされ、stack側でtext-icuライブラリを使用するように.cabalファイルに記述しておけば、ビルドして実行できるようになります。

Data.Text.ICU.ConvertShift_JISからUnicodeへ変換することを考えます。これに使う関数の型は次のとおりです。

toUnicode :: Converter -> ByteString -> Text

第1引数はopen "Shift_JIS" NothingShift_JIS用のConverterを作成して渡してやればよさそうです。
第2引数に注目してください。ByteStringになっています。ByteString[Word8]packすると得られます。つまりbyte列です。
Haskellでは、文字はUCS-4で表現されるChar、文字列はCharのリストであるString、そしてtextパッケージで提供されるData.Text.TextはUnicode文字列を表しています。どれもUnicodeが前提となっていて、それ以外の文字コードではbyte列で表現するしかないわけです。そこで、ByteStringを引数にとるようになっています。

ライブラリの選定

zipファイルを展開するライブラリを選びます。まずAll packages by name | Hackage で検索してみます。多い。
最初に登場するzipパッケージには、他の3つのライブラリを比較したものもあります。
これを読んでみると、用意されたインターフェイスにかなり差があり、またメモリ消費などの点でそれぞれ特徴があるようです。
しかし、重要なのはそこではありません。前節の通り、Unicodeでない文字列はByteStringで取れなければなりません。Unicodeでない文字列データを、StringTextで読むと化けるからです。
それぞれのライブラリで、パス名を取り出す関数の型を見てみましょう。なお、FilePathStringの型シノニムです。

パッケージ 関数の型
zip getEntryName :: EntrySelector -> Text, getEntries :: ZipArchive (Map EntrySelector EntryDescription)1
zip-archive eRelativePath :: FilePath
LibZip zs'name :: String
bindings-libzip #field name, Ptr CChar
zip-conduit entryNames :: Archive [FilePath]
zip-stream zipEntryName :: ByteString

レコードからフィールドを取り出す関数や、モナドのアクションなどが混ざっていますが、ほとんどがStringTextを使っている2ことがわかると思います。
使えるのはzip-streamです。これしかありません。

Conduit

zip-streamはConduitを前提に書かれたライブラリです。展開用の関数は1つだけ。

unZipStream :: (MonadBase b m, PrimMonad b, MonadThrow m) => ConduitM ByteString (Either ZipEntry ByteString) m ZipInfo

Conduitを使うのは初めてなので、よくわかりません。ConduitMの定義を見てみましょう。

data ConduitM i o m r

Core datatype of the conduit package. This type represents a general component which can consume a stream of input values i, produce a stream of output values o, perform actions in the m monad, and produce a final result r. The type synonyms provided here are simply wrappers around this type.

つまり、iに入力ストリームの型を、oに出力ストリームの型を、mにアクションを実行するモナドを、rに最後に返す値の型を渡すわけですね。
unZipStreamの型に戻ると、この関数はByteStringを入力するとEither ZipEntry ByteStringを出力し、最後にZipInfoを返すらしいこと、また、アクションを実行するモナドはMonadBase b m, PrimMonad b, MonadThrow mのそれぞれの型クラスのインスタンスである必要があることがわかります。

いろいろと調べた結果、以下のことがわかりました。

  • (.|)ConduitM型のアクションを連結していくことで機能する。この時、前段の出力と後段の入力の型が一致している必要がある。
  • runConduitConduitM () Void m rを実行してm rを得ることができる。

つまり、最初のConduitMの入力i()、最後のConduitMの出力oVoidになるように、ConduitMを連結して、最後にrunConduitに渡せばよいようです。
だいたいの形としてはこうなるはずです。

do
  info <- runConduit $ 入力が()Conduit .| unZipStream .| 出力がVoidConduit

unZipStreamの入力はByteStringなので、まずConduitM () ByteString m rのようなアクションが必要です。これはzipファイルを読み取って、中身のバイナリ列をByteStringで出力するようなものになるでしょう。幸い、conduit-combinatorsパッケージにお誂え向きのアクションがあります。

sourceFile :: MonadResource m => FilePath -> Producer m ByteString

これを使いましょう。

do
  info <- runConduit $ sourceFile zipPath .| unZipStream .| 出力がVoidConduit

まず、適当に「すべての入力を捨て、何も出力しないアクション」を作って、コンパイルしてみます。

module Main where

import Codec.Archive.Zip.Conduit.UnZip
import Data.Conduit
import Data.Conduit.Combinators
import Data.Void

dropAll :: ConduitM i Void m ()
dropAll = return ()

main :: IO ()
main = do
    let zipPath = "" -- stub
    info <- runConduit $ sourceFile zipPath .| unZipStream .| dropAll
    return ()
Main.hs:14:48: error:
    ? Couldn't match type ▒eZipInfo▒f with ▒e()▒f
      Expected type: ConduitM
                       Data.ByteString.Internal.ByteString
                       (Either ZipEntry Data.ByteString.Internal.ByteString)
                       IO
                       ()
        Actual type: ConduitM
                       Data.ByteString.Internal.ByteString
                       (Either ZipEntry Data.ByteString.Internal.ByteString)
                       IO
                       ZipInfo
    ? In the first argument of ▒e(.|)▒f, namely ▒eunZipStream▒f
      In the second argument of ▒e(.|)▒f, namely ▒eunZipStream .| dropAll▒f
      In the second argument of ▒e($)▒f, namely
        ▒esourceFile zipPath .| unZipStream .| dropAll▒f

ところどころ化けています3が、要はunZipStream .| dropAllで前後のConduitのrの型が異なる、ということのようです。

(.|)の型を見てみます。

(.|) :: Monad m
     => ConduitM a b m () -- ^ upstream
     -> ConduitM b c m r -- ^ downstream
     -> ConduitM a c m r

上流側である第1引数のr()で固定されています。これではunZipStreamの後ろに何も繋げられません。

Conduitのチュートリアルはいくつかあるようなのですが、rについては必ず()で固定して説明されていました。少し読んだくらいではダメなようです。
そこで、zip-streamパッケージのソースを見てみます。すると、cmd/unzip.hsという、zip展開のサンプルソースがあります。
この中で、unZipStreamfuseUpstreamに渡されています。fuseUpstreamData.Conduitにあります。

http://hackage.haskell.org/package/conduit-1.2.12.1/docs/Data-Conduit.html#v:fuseUpstream

fuseUpstream :: Monad m => ConduitM a b m r -> Conduit b m c -> ConduitM a c m r 

Same as fuseBoth, but ignore the return value from the downstream Conduit. Same caveats of forced consumption apply.

これは(.|)とは異なり、上流側の第1引数のrが出力のrに反映され、下流側の第2引数のr()に固定されています。これで解決することができます。さっそく試します。

module Main where

import Codec.Archive.Zip.Conduit.UnZip
import Data.Conduit
import Data.Conduit.Combinators
import Data.Void

dropAll :: ConduitM i Void m ()
dropAll = return ()

main :: IO ()
main = do
    let zipPath = "" -- stub
    info <- runConduit $ sourceFile zipPath .| fuseUpstream unZipStream dropAll
    return ()
Main.hs:14:26: error:
    ? No instance for (resourcet-1.1.9:Control.Monad.Trans.Resource.Internal.MonadResource
                         IO)
        arising from a use of ▒esourceFile▒f
    ? In the first argument of ▒e(.|)▒f, namely ▒esourceFile zipPath▒f
      In the second argument of ▒e($)▒f, namely
        ▒esourceFile zipPath .| fuseUpstream unZipStream dropAll▒f
      In a stmt of a 'do' block:
        info <- runConduit
                $ sourceFile zipPath .| fuseUpstream unZipStream dropAll

MonadResource IOのインスタンスが無い、と言われます。MonadResourceって何でしょうか。再びsourceFileの型を見ます。

sourceFile :: MonadResource m => FilePath -> Producer m ByteString

こいつのようです。
MonadResourceの説明を見ると、確かに、MonadResource IOのインスタンスはありません。

調べた結果、Conduitの基底モナドをResourceT IOに固定すればいいことがわかりました。MonadResource (ResourceT m)のインスタンスは存在するからです。また、このインスタンスにはMonadIO mという制約も付いているので、mIOを渡すことができるようです。

型を明示するために別の関数に分け、またrunConduitの結果がResourceT IO ZipInfoになってしまったのでResourceTを剥がすためにrunResourceTも呼びましょう。

module Main where

import Codec.Archive.Zip.Conduit.UnZip
import Control.Monad.Trans.Resource
import Data.Conduit
import Data.Conduit.Combinators
import Data.Void

dropAll :: ConduitM i Void m ()
dropAll = return ()

stream :: FilePath -> ConduitM () Void (ResourceT IO) ZipInfo
stream zipPath = sourceFile zipPath .| fuseUpstream unZipStream dropAll

main :: IO ()
main = do
    let zipPath = "" -- stub
    info <- runResourceT $ runConduit $ stream zipPath
    return ()

今度はうまくいきます。

あとは、プログラム引数を取るようにして、dropAllの代わりにファイル名をデコードしてファイルを出力するConduitを作って繋げばOKです。

ひとまず完成したものがこちらです。
https://github.com/cobodo/zip-join-dir-extract

エラー処理が甘く、大量に投げると落ちることがあったり、テストが無だったりするので、追い追いやっていきます。

まとめ

  • Monad Transformerの使い方が少しわかった
  • Conduitの使い方が少しわかった
  • お願いですから非Unicode文字列が来る可能性のあるデータはByteStringで取るインターフェイスを用意してください

  1. EntrySelectorFilePathから作るようになっていて、恐らく内部表現としてFilePathを使っていると思われます。 

  2. bindings-libzipはよくわかりませんでした。おそらくCレベルのAPIをFFIでラップしているのでしょう。 

  3. これはWindows版stackをmsys2で使っていると必ず遭遇する文字化けなんですが、原因不明です。Linux版では、化けた部分が全角の引用符になっています。対処法の情報求む。