Golangにおけるパッケージの可視性について

Golang

はじめに

Goでパッケージを作成するときの最終目標は、通常、そのパッケージを他の開発者がより高次のパッケージやプログラム全体に利用できるようにすることです。パッケージをインポートすることで、あなたのコードの一部は、他のより複雑なツールのビルディングブロックとして機能します。ただし、インポート可能なパッケージは特定のものに限られています。これは、パッケージの可視性によって決まります。

ここでいう可視性とは、パッケージやその他の構成要素が参照できるファイル空間を意味します。例えば、関数の中で変数を定義した場合、その変数の可視性(スコープ)は、その変数が定義された関数の中だけになります。同様に、パッケージ内で変数を定義した場合、そのパッケージ内だけで見えるようにすることも、パッケージ外でも見えるようにすることもできます。

パッケージの可視性を慎重にコントロールすることは、人間工学に基づいたコードを書く上で重要です。

特に、将来的にパッケージに加えたいと思う変更を考慮する際には重要です。バグを修正したり、パフォーマンスを向上させたり、機能を変更したりする必要がある場合、パッケージを使用している人のコードを壊さないような方法で変更したいと思うでしょう。壊れやすい変更を最小限に抑えるためには、パッケージを正しく使用するために必要な部分にのみアクセスを許可することです。アクセスを制限することで、他の開発者がパッケージを使用する際に影響を与える可能性を低くして、パッケージを内部的に変更することができます。

この記事では、パッケージの可視性をコントロールする方法と、パッケージ内でのみ使用されるべきコードの一部を保護する方法を学びます。そのために、アイテムの可視性の程度が異なるパッケージを使用して、メッセージを記録・デバッグするための基本的なロガーを作成します。

この記事の前提条件

この記事の例では以下のファイル構成となっております。

.
├── bin 
│ 
└── src
    └── github.com
        └── gopherguides

exportとunexportの違いについて

JavaやPythonなどのプログラム言語では、public、private、protectedなどのアクセス修飾子を使ってスコープを指定しますが、Goではアイテムの宣言方法によってエクスポートされるかどうかを判断します。

この場合、アイテムをエクスポートすると、そのアイテムは現在のパッケージの外で見ることができます。エクスポートされていない場合は、定義されているパッケージ内でのみ表示され、使用することができます。

この外部からの可視性は、宣言されたアイテムの最初の文字を大文字にすることで制御されます。型、変数、定数、関数など、大文字で始まる宣言はすべて、現在のパッケージの外から見えるようになります。

大文字小文字に注意しながら、次のコードを見てみましょう。

package greet

import "fmt"

var Greeting string

func Hello(name string) string {
    return fmt.Sprintf(Greeting, name)
}

このコードは、greetパッケージであることを宣言しています。

そして、Greetingという変数と、Helloという関数の2つのシンボルを宣言しています。どちらも大文字で始まっているので、どちらもエクスポートされ、外部のプログラムから利用できるようになっています。

先に述べたように、アクセスを制限するパッケージを作ることで、より良いAPI設計が可能になり、パッケージに依存している人のコードを壊すことなく、パッケージを内部的に更新することが容易になります。

パッケージの可視性の定義

パッケージの可視性がプログラムの中でどのように機能するかをより詳しく見るために、パッケージの外に見えるようにしたいものと、見えないようにしたいものを念頭に置いて、loggingパッケージを作ってみましょう。

この logging パッケージは、プログラムのメッセージをコンソールに記録する役割を果たします。また、どのレベルでログを取っているかを調べます。レベルとは、ログの種類を表すもので、info、warning、errorの3つのステータスのうちの1つになります。

まず、srcディレクトリ内にloggingというディレクトリを作成し、ログファイルを置きましょう。

$ mkdir logging

次はそのディレクトリに移動します。

$ cd logging

次に、nanoなどのエディターを使って、logging.goというファイルを作成します。

$ nano logging.go

先ほど作成したlogging.goファイルに以下のコードを入れます。

package logging

import (
    "fmt"
    "time"
)

var debug bool

func Debug(b bool) {
    debug = b
}

func Log(statement string) {
    if !debug {
        return
    }

    fmt.Printf("%s %s\n", time.Now().Format(time.RFC3339), statement)
}

このコードの最初の行では,loggingというパッケージを宣言しています。このパッケージには,2つのエクスポートされた関数があり、DebugとLogです。

これらの関数は、loggingパッケージをインポートしている他のパッケージから呼び出すことができます。

また、debugというプライベート変数があります。この変数は、loggingパッケージ内からのみアクセス可能です。関数Debugと変数debugは同じ綴りですが、関数は大文字で、変数は小文字であることに注意してください。これにより、異なるスコープを持つ別の宣言となっています。

ファイルを保存して終了します。

このパッケージをコードの他の部分で使用するには、新しいパッケージにインポートします。この新しいパッケージを作成しますが、その前にソースファイルを保存するための新しいディレクトリが必要です。

loggingディレクトリから移動し、cmdという新しいディレクトリを作成して、その新しいディレクトリに移動してみましょう。

$ cd ..
$ mkdir cmd
$ cd cmd

先ほど作成したcmdディレクトリにmain.goというファイルを作成します。

$ nano main.go

ここで、以下のコードを追加します。

package main

import "github.com/gopherguides/logging"

func main() {
    logging.Debug(true)

    logging.Log("This is a debug statement...")
}

これで、プログラム全体の記述が完了しました。しかし、このプログラムを実行する前に、コードを正しく動作させるためのいくつかの設定ファイルを作成する必要があります。Goは、リソースをインポートするためにパッケージの依存関係を設定するためにGoモジュールを使用します。

Goモジュールは、パッケージディレクトリに置かれる設定ファイルで、コンパイラにどこからパッケージをインポートするかを伝えます。モジュールについて学ぶことはこの記事の範囲を超えていますが、この例をローカルで動作させるために、ほんの数行の設定を書きます。

cmdディレクトリにある以下のgo.modファイルを開きます。

$ nano go.mod

そして、そのファイルに以下の内容を入れます。

module github.com/gopherguides/cmd

replace github.com/gopherguides/logging => ../logging

このファイルの 1 行目は、cmd パッケージのファイル パスが github.com/gopherguides/cmd であることをコンパイラーに伝えます。2 行目は、github.com/gopherguides/logging パッケージがディスク上のローカルな ../logging ディレクトリにあることをコンパイラーに伝えます。

また、loggingパッケージ用のgo.modファイルも必要になります。logging ディレクトリに戻って go.mod ファイルを作成しましょう。

$ cd ../logging
$ nano go.mod

以下の内容をファイルに追加してください。

module github.com/gopherguides/logging

これにより、作成した logging パッケージが実際には github.com/gopherguides/logging パッケージであることをコンパイラに伝えます。これにより、先ほど書いた以下の行で、メインパッケージでパッケージをインポートすることが可能になります。

package main

import "github.com/gopherguides/logging"

func main() {
    logging.Debug(true)

    logging.Log("This is a debug statement...")
}

これで、以下のようなディレクトリ構造とファイルレイアウトになっているはずです。

├── cmd
│   ├── go.mod
│   └── main.go
└── logging
    ├── go.mod
    └── logging.go

これですべての設定が完了したので、次のコマンドでcmdパッケージからメインプログラムを実行することができます。

$ cd ../cmd
$ go run main.go

以下のような出力が得られます。

2019-08-28T11:36:09-05:00 This is a debug statement...

このプログラムでは、RFC 3339形式の現在時刻の後に、ロガーに送った文が表示されます。RFC 3339とは、インターネット上で時間を表現するために作られた時間フォーマットで、ログファイルでよく使われます。

Debug関数とLog関数はloggingパッケージからエクスポートされているので、mainパッケージで使用することができます。しかし、logging パッケージの debug 変数はエクスポートされていません。エクスポートされていない宣言を参照しようとすると、コンパイル時にエラーが発生します。

次のコメントアウトで示された行をmain.goに追加します。

package main

import "github.com/gopherguides/logging"

func main() {
    logging.Debug(true)

    logging.Log("This is a debug statement...")

    fmt.Println(logging.debug)  // <==追加
}

ファイルを保存して実行してください。以下のようなエラーが表示されます。

./main.go:10:14: cannot refer to unexported name logging.debug

パッケージ内のエクスポートされた項目とされていない項目の動作を見てきましたが、次に構造体からフィールドやメソッドがどのようにエクスポートされるかを見てみましょう。

構造体の可視化

前節で作成したロガーの可視化スキームは、単純なプログラムでは機能するかもしれませんが、複数のパッケージ内で使用するにはあまりにも多くの状態を共有してしまいます。これは、エクスポートされた変数が複数のパッケージからアクセス可能であり、変数を矛盾した状態に変更できるからです。このようにパッケージの状態を変更できると、プログラムがどのように動作するかを予測することが困難になります。例えば、現在の設計では、あるパッケージがDebug変数をtrueに設定し、別のパッケージがそれをfalseに設定することが同じインスタンスで可能です。この場合、loggingパッケージをインポートしている両方のパッケージが影響を受けるため、問題が生じます。

そこで、構造体を作成し、その構造体からメソッドをぶら下げることで、ロガーを分離することができます。これにより、ロガーを消費する各パッケージで独立して使用するためのロガーのインスタンスを作成することができます。

ロギングパッケージを以下のように変更し、コードをリファクタリングしてロガーを分離します。

package logging

import (
    "fmt"
    "time"
)

type Logger struct {
    timeFormat string
    debug      bool
}

func New(timeFormat string, debug bool) *Logger {
    return &Logger{
        timeFormat: timeFormat,
        debug:      debug,
    }
}

func (l *Logger) Log(s string) {
    if !l.debug {
        return
    }
    fmt.Printf("%s %s\n", time.Now().Format(l.timeFormat), s)
}

このコードでは、Logger構造体を作成しました。この構造体には、出力する時間フォーマットやデバッグ変数の設定(trueまたはfalse)など、移植されていない状態が格納されます。New関数では、時間フォーマットやデバッグ状態など、ロガーを作成するための初期状態を設定します。そして、内部で与えた値を、ポートされていない変数timeFormatとdebugに格納しています。また、LoggerタイプにLogというメソッドを作成し、プリントアウトしたいステートメントを受け取ります。Logメソッドの中には、ローカルメソッド変数lへの参照があり、l.timeFormatやl.debugといった内部フィールドにアクセスできるようになっています。

この方法により、さまざまなパッケージでLoggerを作成し、他のパッケージがどのように使用しているかに関係なく使用することができます。

他のパッケージで使用するために、cmd/main.goを以下のように変更してみましょう。

package main

import (
    "time"

    "github.com/gopherguides/logging"
)

func main() {
    logger := logging.New(time.RFC3339, true)

    logger.Log("This is a debug statement...")
}

このプログラムを実行すると、次のような出力が得られます。

2019-08-28T11:56:49-05:00 This is a debug statement...

このコードでは、エクスポートされた関数「New」を呼び出して、ロガーのインスタンスを作成しました。このインスタンスへの参照を logger 変数に格納しました。これで logging.Log を呼び出してステートメントを出力できるようになりました。

timeFormat フィールドのように、Logger からエクスポートされていないフィールドを参照しようとすると、コンパイル時エラーが発生します。以下のハイライト行を追加して、cmd/main.goを実行してみてください。

package main

import (
    "time"

    "github.com/gopherguides/logging"
)

func main() {
    logger := logging.New(time.RFC3339, true)

    logger.Log("This is a debug statement...")

    fmt.Println(logger.timeFormat)
}

これにより、以下のようなエラーが発生します。

. . .
cmd/main.go:14:20: logger.timeFormat undefined (cannot refer to unexported field or method timeFormat)

コンパイラは logger.timeFormat がエクスポートされていないことを認識しているため、logging パッケージから取得することはできません。

メソッド内での視認性

構造体のフィールドと同じように、メソッドもエクスポートしたり、エクスポートしないことができます。

これを説明するために、ロガーにレベルド・ロギングを追加してみましょう。レベル付きロギングとは、ログを分類して、特定のタイプのイベントを検索できるようにする手段です。ロガーに追加するレベルは次のとおりです。

  • 情報レベルでは、「プログラムが開始された」「電子メールが送信された」など、ユーザーにアクションを知らせる情報タイプのイベントを表します。これらは、プログラムの一部をデバッグし、期待される動作が起こっているかどうかを追跡するのに役立ちます。
  • 警告レベルです。このタイプのイベントは、Emailの送信に失敗した、再試行したなど、エラーではない予期せぬことが起こった場合に特定します。このイベントは、プログラムの一部が期待した通りにスムーズに進んでいないことを確認するのに役立ちます。
  • エラーレベルは、プログラムに問題が発生したことを意味します。例えば、File not found.これは多くの場合、プログラムの動作が失敗することを意味します。

また、プログラムが期待通りに動作しておらず、プログラムをデバッグしたい場合など、特定のレベルのログをオン/オフしたい場合もあるでしょう。この機能を追加するために、プログラムを変更し、debugがtrueに設定されている場合は、すべてのレベルのメッセージを表示するようにします。そうでない場合は、falseにするとエラーメッセージだけを表示します。

logging/logging.goに以下の変更を加えて、レベル付きロギングを追加します。

package logging

import (
    "fmt"
    "strings"
    "time"
)

type Logger struct {
    timeFormat string
    debug      bool
}

func New(timeFormat string, debug bool) *Logger {
    return &Logger{
        timeFormat: timeFormat,
        debug:      debug,
    }
}

func (l *Logger) Log(level string, s string) {
    level = strings.ToLower(level)
    switch level {
    case "info", "warning":
        if l.debug {
            l.write(level, s)
        }
    default:
        l.write(level, s)
    }
}

func (l *Logger) write(level string, s string) {
    fmt.Printf("[%s] %s %s\n", level, time.Now().Format(l.timeFormat), s)
}

この例では、Logメソッドに新しい引数を導入しました。ログメッセージのレベルを渡せるようになったのです。Logメソッドは、メッセージのレベルを判断します。infoやwarningメッセージで、debugフィールドがtrueであれば、メッセージを書き込みます。それ以外の場合は、メッセージを無視します。エラーのような他のレベルであれば、関係なくメッセージを書き出します。

メッセージが出力されるかどうかを判断するロジックのほとんどは、Logメソッドに存在します。また、writeというエクスポートされないメソッドも導入しました。writeメソッドは、実際にログメッセージを出力するものです。

cmd/main.goを以下のように変更することで、他のパッケージでもこのレベルド・ロギングを使用できるようになります。

package main

import (
    "time"

    "github.com/gopherguides/logging"
)

func main() {
    logger := logging.New(time.RFC3339, true)

    logger.Log("info", "starting up service")
    logger.Log("warning", "no tasks found")
    logger.Log("error", "exiting: no work performed")

}

実行結果は次のようになります。

[info] 2019-09-23T20:53:38Z starting up service
[warning] 2019-09-23T20:53:38Z no tasks found
[error] 2019-09-23T20:53:38Z exiting: no work performed

この例では、cmd/main.goがエクスポートされたLogメソッドを正常に使用しています。

これで、debugをfalseに切り替えることで、各メッセージのレベルを渡すことができます。

package main

import (
    "time"

    "github.com/gopherguides/logging"
)

func main() {
    logger := logging.New(time.RFC3339, false)

    logger.Log("info", "starting up service")
    logger.Log("warning", "no tasks found")
    logger.Log("error", "exiting: no work performed")

}

ここでは、エラーレベルのメッセージだけが印刷されることを確認します。

[error] 2019-08-28T13:58:52-05:00 exiting: no work performed

loggingパッケージの外からwriteメソッドを呼ぼうとすると、コンパイル時にエラーが発生します。

package main

import (
    "time"

    "github.com/gopherguides/logging"
)

func main() {
    logger := logging.New(time.RFC3339, true)

    logger.Log("info", "starting up service")
    logger.Log("warning", "no tasks found")
    logger.Log("error", "exiting: no work performed")

    logger.write("error", "log this message...")
}
cmd/main.go:16:8: logger.write undefined (cannot refer to unexported field or method logging.(*Logger).write)

コンパイラは、他のパッケージから小文字で始まるものを参照しようとすると、それがエクスポートされていないことを認識して、コンパイラエラーを投げます。

このチュートリアルのロガーは、他のパッケージに消費させたい部分だけを公開するコードの書き方を説明しています。パッケージのどの部分がパッケージの外に見えるかをコントロールすることで、パッケージに依存するコードに影響を与えることなく、将来の変更を行うことができるようになりました。例えば、debugがfalseの時にinfoレベルのメッセージだけをオフにしたい場合、APIの他の部分に影響を与えることなく、この変更を行うことができます。また、プログラムが実行されていたディレクトリなど、より多くの情報を含むログメッセージへの変更も安全に行うことができます。

本記事の結論・まとめ

この記事では、パッケージの実装の詳細を保護しつつ、パッケージ間でコードを共有する方法を紹介しました。これにより、後方互換性のためにめったに変更されないシンプルなAPIをエクスポートすることができますが、将来的にはより良い動作をさせるために必要に応じてパッケージを非公開で変更することができます。これは、パッケージとそれに対応するAPIを作成する際のベスト・プラクティスと考えられています。

タイトルとURLをコピーしました