このページの本文へ

Goならわかるシステムプログラミング第11回

ファイルシステムと、その上のGo言語の関数たち(2)

2017年02月08日 17時00分更新

文● 渋川よしき、編集● 鹿野桂一郎

  • この記事をはてなブックマークに追加
  • 本文印刷

前回の記事では、まずファイルやディレクトリとは何かを知るためにファイルシステムの基礎をおさえ、それからGo言語でファイルやディレクトリを扱うosパッケージの関数を紹介しました。 今回は、ファイルパスを扱いやすくするGo言語のパッケージについて説明してから、osパッケージだけでは実現できないファイルシステムの操作として「ファイルの変更監視」を紹介します。

ファイルパスとマルチプラットフォーム

アプリケーションからのファイルアクセスはすべて、「どのファイル」に対して「何をするか」で説明できます。このうち、「どのファイル」を指定するのがファイルパスです。

現在のコンピュータシステムにおけるパスの表記方法には、大きく2種類あります。POXIS共通の表記と、Windowsのファイルパス表記です。 かつては別のパス表記も使われていましたが(たとえばOS 9以前のMacではフォルダの区切り文字がコロン(:)でした)、いま一般的に使われているのはこの2種類でしょう。

項目 POSIX表記 Windows表記
パスの区切り文字 / (スラッシュ) \ (バックスラッシュ)
ルート / スタート C:\など (ドライブレター+コロン+バックスラッシュ)
複数パスの区切り文字(環境変数などでパスを列挙するときの文字) : (コロン) ; (セミコロン)

Go言語でパス表記を扱うパッケージ

Go言語でパス表記を取り扱うパッケージは2つあります。

  • path/filepath: OSのファイルシステムに使う
  • path: URLに使う

ファイルやディレクトリに対するパス表記を操作するには、path/filepathパッケージを使います。 path/filepathを使えば、動作環境のファイルシステムで2種類のパス表記のどちらが使われていても、その違いを吸収して各プラットフォームに適した結果が得られます。 OS依存のセパレータ文字をコードから追い出すことができれば、アプリケーションをマルチプラットフォーム化することも簡単になります。

Windows限定ですが、POSIX形式と相互変換する関数として、 filepath.FromSlash()filepath.ToSlash() もあります (これらの関数はPOSIX系OSだと何もしません)。

pathパッケージのほうは、常に「/」(スラッシュ)で区切るパス表記に対して使うパッケージで、主にURLを操作するときに使います。 pathパッケージの関数を実行した結果は、WindowsでもPOSIX系OSでも変わりません。 今回の記事ではpathパッケージを使いませんが、path/filepathで提供されている関数と同じものがいくつか提供されています。

path/filepathパッケージの関数たち

ディレクトリのパスとファイル名とを連結する

いちばんよく使うpath/filepathパッケージの関数は、ディレクトリのパスとファイル名とを連結するfilepath.Join()でしょう。

package main
 
import (
    "os"
    "fmt"
    "path/filepath"
)
 
func main() {
    fmt.Printf("Temp File Path: %s\n", filepath.Join(os.TempDir(), "temp.txt"))
}

上記の例では、filepath.Join()を使い、一時ファイル置き場のディレクトリ内のtemp.txtファイルを示す絶対パスを作成しています。 os.TempDir()関数は、システムの一時ファイル置き場のディレクトリパスを返す関数です。 すでに第9回のUnixドメインソケットの回で、ソケットファイルの置き場を決めるときに登場しています。

パスを分割する

パスからファイル名とその親ディレクトリに分割するfilepath.Split()もよく使います。

package main
 
import (
    "fmt"
    "os"
    "path/filepath"
)
 
func main() {
    dir, name := filepath.Split(os.Getenv("GOPATH"))
    fmt.Printf("Dir: %s, Name: %s\n", dir, name)
}

ファイルパスの全要素を配列にしたいこともあるでしょう。 "/a/b/c.txt"[a b c.txt]にするには、次のようにセパレータ文字を取得してきて分割するのが簡単です。

fragments := strings.Split(path, string(filepath.Separator))

パス名を分解する関数には他にも次の4種類があります。

関数 説明 "/folder1/folder2 /example.txt" を入力した結果 "C:\folder1\folder2 \example.txt" を入力した結果
filepath.Base() パスの最後の要素を返す "example.txt" "example.txt"
filepath.Dir() パスのディレクトリ部を返す "/folder1/folder2" "C:\folder1\folder2"
filepath.Ext() ファイルの拡張子を返す ".txt" ".txt"
filepath.VolumeName() ファイルのドライブ名を返す(Windows用) "" "C:"

Base()Dir()Split()を目的別に特化させたものです。

複数のパスからなる文字列を分解する

filepath.SplitList()という名前の関数もあります。名前だけ見るとパスの分割に使えそうですが、これは別の用途の関数で、 環境変数の値などにある「複数のパスを1つのテキストにまとめたもの」を分解するのに使います。

たとえば、次のコードは、Unix系OSにあるwhichコマンドをGoで実装してみたものです。 PATH環境変数のパス一覧を取得してきて、それをfilepath.SplitList()で個々のパスに分割します。 その後、各パスの下に最初の引数で指定された実行ファイルがあるかどうかをチェックしています。

package main
 
import (
    "fmt"
    "os"
    "path/filepath"
)
 
func main() {
    if len(os.Args) == 1 {
        fmt.Printf("%s [exec file name]", os.Args[0])
        os.Exit(1)
    }
    for _, path := range filepath.SplitList(os.Getenv("PATH")) {
        execpath := filepath.Join(path, os.Args[1])
        _, err := os.Stat(execpath)
        if !os.IsNotExist(err) {
            fmt.Println(execpath)
            return
        }
    }
    os.Exit(1)
}

パスのクリーン化

パス表記の文字列をきれいに整えたいことがあります。 filepath.Clean()関数を使うと、 重複したセパレータを除去したり、上に行ったり下に降りたりを考慮して/abc/../def/からabc/..の部分を削除したり、現在のパス「.」を削除したりすることが可能です。

絶対パスに変換するfilepath.Abs()や、基準のパスから相対パスを算出するfilepath.Rel()といった関数も、パス表記の整形に使えます。

package main
 
import (
    "fmt"
    "path/filepath"
)
 
func main() {
    fmt.Println(filepath.Clean("./path/filepath/../path.go"))
    // path/path.go
 
    abspath, _ := filepath.Abs("path/filepath/path_unix.go")
    fmt.Println(abspath)
    // /usr/local/go/src/path/ilepath/path_unix.go
 
    relpath, _ := filepath.Rel("/usr/local/go/src",
                               "/usr/local/go/src/path/filepath/path.go")
    fmt.Println(relpath)
    // path/filepath/path.go
}

パス中のシンボリックリンクを展開したうえでClean()をかけた状態のパスを返してくれる、filepath.EvalSymlinks()という関数もあります (osパッケージを駆使して自分で書くこともできないではないですが)。

環境変数などの展開

パス文字列のクリーン化に使う関数では、環境変数の展開や、POSIX系OSのシェルでホームパスを表す~(チルダ)の展開はできません。

環境変数については、osパッケージのExpandEnv()を使って展開できます。

path := os.ExpandEnv("${GOPATH}/src/github.com/shibukawa/tobubus")
fmt.Println(path)
// /Users/shibu/gopath/src/github.com/shibukawa/tobubus

~については少し工夫がいります。 ~がホームを表すのはOSではなくてシェルが提供する機能なので、プログラム内では特別なハンドリングが必要です。

まず、ホームディレクトリは次のようにしてos/userパッケージで取得できます。

my, err := user.Current()
if err != nil {
    panic(err)
}
fmt.Println(my.HomeDir)

この取得した値に、~を事前に置換しておくとよいでしょう。

以上の知見をまとめて、チルダも環境変数も展開したうえでパスをクリーン化するコードは次のようになります。

func Clean2(path string) string {
    if len(path) > 1 && path[0:2] == "~/" {
        my, err := user.Current()
        if err != nil {
            panic(err)
        }
        path = my.HomeDir + path[1:]
    }
    path = os.ExpandEnv(path)
    return filepath.Clean(path)
}

● 設定ファイル置き場

マルチプラットフォームのアプリケーションを作成しようとすると、OSごとに設定ファイルをどこに書けばよいのか迷うことがあります。

Windows、Linux、macOSの情報が横断的にそろっているページとして、筆者はQtのQStandardPathsクラスのドキュメントをよく参考にしています。

筆者が作成した、アプリケーションの設定ファイルに特化したパッケージもあります。

アプリケーションの設定では、まずユーザーごとの設定ファイルを探し、なければユーザー共通の設定ファイルを読み込む、という2段階の方法をよく使います。 あるいは、ひとまず両方の設定を読み込んで、共通の項目に対する設定があればユーザーの設定を優先して使用します。 アプリケーションが作成するプロジェクトフォルダの設定については、現在いるパスから親フォルダに辿っていき、特定のファイルを探すという方法がよく使われます。

これらの設定ファイルを扱う処理を書くときも、今回と前回の記事で紹介しているファイルとディレクトリ操作の関数を組み合わせていけばいいでしょう。

ファイル名のパターンにマッチするファイルの抽出

Go言語でファイル名をパターンで探すのに使える関数は2つあります。 1つめは、パターンと指定したファイル名が同じかどうか調べるfilepath.Match()関数です。 パターンとしては、1文字の任意の文字にマッチするワイルドカード(?)と、ゼロ文字以上の文字にマッチするワイルドカード(*)が使えるほか、 マッチする文字範囲([0-9])や、マッチしない文字範囲([^a])も指定できます。

fmt.Println(filepath.Match("image-*.png", "image-100.png"))

2つめはGlob()関数です。 filepath.Match()はtrue/flaseでマッチしたかどうかを返す関数ですが、Glob()関数を使うと、ルールに合致するファイル名の一覧を取得できます。

files, err := filepath.Glob("./*.png")
if err != nil {
    panic
}
fmt.Println(files)

ディレクトリのトラバース

前回の記事では、ディレクトリ内部の要素の情報を集めるのに、os.FileReaddir()メソッドかReaddirnames()メソッドを使いました。 これらの関数を使って、ディレクトリ内部にあるディレクトリの中まで探索していこうとすると、関数を再帰的に何度も呼び出すことになります。

ディレクトリのような木構造をすべてたどることを、コンピュータ用語ではトラバースといいます。 filepathパッケージには、ディレクトリのトラバースに便利なfilepath.Walk()という関数もあります。 この関数は、ディレクトリの木構造を深さ優先探索でたどります。

filepath.Walk()関数には、探索を開始するパス(root)と、トラバース中に各ファイルやディレクトリで呼び出されてほしい関数とを渡します。 この関数は、filepath.Walk()がディレクトリツリーをトラバースしていってファイルやディレクトリに行きつくたびに、 そのファイルやディレクトリのパス(探索を開始するrootを含めたパス)と、前回紹介したos.FileInfoを引数として呼び出されます(このような関数のことをコールバック関数と呼びます)。

具体的なコードで説明しましょう。 次の例は、指定したディレクトリ以下を探索して画像ファイルの名前を集めてくるというコードです。 この例には、ディレクトリのトラーバースでよく使われるイディオムがいろいろ入っています。

package main
 
import (
    "fmt"
    "os"
    "path/filepath"
    "strings"
)
 
var imageSuffix = map[string]bool{
    ".jpg":  true,
    ".jpeg": true,
    ".png":  true,
    ".webp": true,
    ".gif":  true,
    ".tiff": true,
    ".eps":  true,
}
 
func main() {
    if len(os.Args) == 1 {
        fmt.Printf(`Find images
 
Usage:
    %s [path to find]
`, os.Args[0])
        return
    }
    root := os.Args[1]
 
    err := filepath.Walk(root,
        func(path string, info os.FileInfo, err error) error {
            if info.IsDir() {
                if info.Name() == "_build" {
                    return filepath.SkipDir
                }
                return nil
            }
            ext := strings.ToLower(filepath.Ext(info.Name()))
            if imageSuffix[ext] {
                rel, err := filepath.Rel(root, path)
                if err != nil {
                    return nil
                }
                fmt.Printf("%s\n", rel)
            }
            return nil
        })
    if err != nil {
        fmt.Println(1, err)
    }
}

このコードではファイル名を収集するので、ディレクトリにたどりついたときには何もしません。 そこで、コールバック関数の先頭にある次の処理でその意図を明確にしています。

if info.IsDir() {
    (略)
    return nil
}

上記で略としている部分は、実際のコードでは次のようになっています。

if info.Name() == "_build" {
    return filepath.SkipDir
}

これは、_buildという名前のフォルダのトラバースをスキップするために入れている処理です。 筆者が執筆で使っているSphinxというツールでは、生成したHTMLやPDFを_buildフォルダに出力するので、 そのディレクトリより下のトラバースをスキップするためにreturn filepath.SkipDirしています。

対象がディレクトリでなかった場合には、filepath.Extで拡張子を取り出して、それを参考に画像ファイルかどうかを判断します(画像ファイルとみなす拡張子はあらかじめ定義してあります)。 画像ファイルだった場合にはパス名を表示するのですが、コールバック関数に渡るパス名には探索を開始するパスも含まれているので、 パス名のクリーン化の節で紹介したfilepaht.Rel()を利用して表記が煩雑にならないようにしています。

rel, err := filepath.Rel(root, path)

今回みたいな情報収集のときはパス表記が冗長にならないようにこうした処理を入れていますが、 ファイルの内容を読み込むような場合には、むしろpathをそのままos.Open()に渡せるので便利でしょう。

コールバック関数がfilepath.SkipDir以外のエラーを返すと、即座にトラバースが中断され、 filepath.Walk()の返り値として返ってきます。 これをログに出すなりして出力すれば、求める処理が完成します。

実行結果は次の通りです。

filesystem/filesystem.png
introduction/breakpoint.png
introduction/c-system-programing-layer.png
introduction/debug.png
introduction/go-system-programing-layer.png
introduction/hello-callgraph.png
introduction/intellij-init.png
introduction/intellij.png
introduction/keychain01.png
introduction/keychain02.png
introduction/newfile.png
introduction/newproject.png
introduction/plugin.png
introduction/return-run.png
introduction/step-into.png
introduction/step-over.png
:

ファイルの変更監視(syscall.Inotify*

変更があったローカルのファイルをエディタで再読み込みできるようにしたり、ソースコードに変更があったときに自動的にコンパイルしたり、ファイルに対する変更をプログラムで監視したいことがあります。 開発言語や環境によらず、プログラムでファイルを監視する方法には、次の2種類があります1

  • 監視したいファイルをOS側に通知しておいて、変更があったら教えてもらう(パッシブな方式)
  • タイマーなどで定期的にフォルダを走査し、os.Stat()などを使って変更を探しに行く(アクティブな方式)

Go言語の標準ライブラリではファイルの監視を簡単におこなう機能は提供されていません。 ゼロから実装するのであれば、コードが短く分かりやすいのはアクティブな方式です。 しかし、アクティブな方式では監視対象が増えるとCPU負荷やIO負荷が上がり、ノートパソコンで実行しているとバッテリーがどんどん消耗します。

パッシブな方式については、ファイルの変更検知が各OSでシステムコールやAPIとして提供されています。しかし、環境ごとのコードの差は大きくなります。 それぞれを実装しようとすると長くなるため、ここではサードパーティーのパッケージであるgopkg.in/fsnotify.v1を利用したパッシブな方式の例を説明します。

fsnotifyはサードパーティー製のパッケージなので、以下のようにしてライブラリをインストールしてください。

$ go get gopkg.in/fsnotify.v1 

なお、fsnotify以外にも同じ機能を提供するライブラリはあります (https://github.com/rjeczalik/notifyや、Goの実験的パッケージが入ったリポジトリにある、Windows、macOS、その他のPOSIX系OS向けのライブラリなど)。 今回の記事でfsnotifyを選んだのは、APIが使いやすく複数ディレクトリの監視がやりやすいことと、1つのパッケージでマルチプラットフォームが実現できるからです。

fsnotifyライブラリを使ったコード例を以下に示します。 監視対象のファイルの変更を4回検知したらプログラムを終了するという動作のサンプルです。

package main
 
import (
    "gopkg.in/fsnotify.v1"
    "log"
)
 
func main() {
    counter := 0
    watcher, err := fsnotify.NewWatcher()
    if err != nil {
        panic(err)
    }
    defer watcher.Close()
 
    done := make(chan bool)
    go func() {
        for {
            select {
            case event := <-watcher.Events:
                log.Println("event:", event)
                if event.Op & fsnotify.Create == fsnotify.Create {
                    log.Println("created file:", event.Name)
                    counter++
                } else if event.Op & fsnotify.Write == fsnotify.Write {
                    log.Println("modified file:", event.Name)
                    counter++
                } else if event.Op & fsnotify.Remove == fsnotify.Remove {
                    log.Println("removed file:", event.Name)
                    counter++
                } else if event.Op & fsnotify.Rename == fsnotify.Rename {
                    log.Println("renamed file:", event.Name)
                    counter++
                } else if event.Op & fsnotify.Chmod == fsnotify.Chmod {
                    log.Println("chmod file:", event.Name)
                    counter++
                }
            case err := <-watcher.Errors:
                log.Println("error:", err)
            }
            if counter > 3 {
                done<-true
            }
        }
    }()
 
    err = watcher.Add(".")
    if err != nil {
        log.Fatal(err)
    }
    <-done
}

まず、fsnotify.NewWatcher()で監視用のインスタンスを生成しています。 その後、watcher.Add()メソッドを必要なだけ呼び出して監視対象フォルダを追加します。

watcher.Eventsが、変更イベントが入るチャネルです。forループの中で、このチャネルからイベントを何度も取り出しています。

case event := <-watcher.Events:の節で、ブロッキングしてイベントを待っています。 イベントには、変更のステータスがビットフラグとして格納されているので、ビット演算をして、編集されたのか削除されたのかの区別を付けています。 4回変更を検知したら、doneチャネルのブロックを解除し、プログラムを終了します。

このすっきりしたコードの裏側では、Linuxの場合はinotify系API、BSD系OSの場合はkqueue、Windowsの場合はReadDirectoryChangesWをfsnotifyが内部で使い分けてくれるので、ファイルの変更が効率よく検知できます。

まとめと次回予告

今回はファイル操作の基本となるファイルパス関係の関数群と、osパッケージをそのまま使うだけでは実現できない、ファイルの変更監視を取り上げました。

次回も少し高度なファイル操作機能を紹介します。

注釈

  1. たとえばNode.jsではこれらの2種類の手法に対応するAPIがそれぞれ用意されていますが、アクティブ方式のためのAPIは非推奨になっています(「てっく煮ブログ:Node.jsのfs.watch()とfs.watchFile()の違い」

カテゴリートップへ

この特集の記事
ピックアップ