はじめに
Goでパッケージを作成する際の最終目標は、通常、他の開発者がそのパッケージを使用できるようにすることです。これは、より高いレベルのパッケージや全体のプログラムで使用されることを意味します。パッケージをインポートすることで、あなたのコードは他のより複雑なツールの構成要素として機能することができます。しかし、インポート可能なパッケージは特定のものだけです。これは、パッケージの可視性によって決定されます。
可視性とは、この文脈では、パッケージや他の構造体が参照できるファイル空間を意味します。例えば、関数内で変数を定義した場合、その変数の可視性(スコープ)は、その変数が定義された関数内に限られます。同様に、パッケージ内で変数を定義する場合、その変数をそのパッケージ内だけで見えるようにするか、パッケージ外からも見えるようにするかを選択できます。
パッケージの可視性を慎重に制御することは、特にパッケージに対して将来行いたい変更を考慮する際に、エルゴノミックなコードを書く上で重要です。バグを修正したり、パフォーマンスを向上させたり、機能を変更したりする必要がある場合、パッケージを使用している誰かのコードを壊さない方法で変更したいでしょう。破壊的な変更を最小限に抑える一つの方法は、パッケージが適切に使用されるために必要な部分だけにアクセスを許可することです。アクセスを制限することで、他の開発者がパッケージをどのように使用しているかに影響を与える可能性を低くして、パッケージの内部で変更を加えることができます。
この記事では、パッケージの可視性を制御する方法と、パッケージ内でのみ使用されるべきコードの一部を保護する方法を学びます。これを行うために、アイテムの可視性の程度が異なるパッケージを使用して、基本的なロガーを作成し、ログとデバッグメッセージを記録します。
前提条件
この記事の例に従うためには、以下が必要です:
- Goのインストール方法とローカルプログラミング環境のセットアップに従ってセットアップされたGoワークスペース。このチュートリアルでは、以下のファイル構造を使用します:
.
├── bin
│
└── src
└── github.com
└── gopherguides
エクスポートされたアイテムとエクスポートされていないアイテム
JavaやPythonのような他のプログラミング言語がアクセス修飾子を使ってスコープを指定するのとは異なり、Goはアイテムがエクスポートされた
かエクスポートされていない
かを宣言方法によって判断します。この場合、アイテムをエクスポートすることで、現在のパッケージの外から見える
ようになります。エクスポートされていない場合、それは定義されたパッケージ内からのみ見えて使用可能です。
この外部可視性は、宣言されたアイテムの最初の文字を大文字にすることで制御されます。型
、変数
、定数
、関数
など、大文字で始まるすべての宣言は、現在のパッケージの外から見えます。
次のコードを見て、大文字小文字に注意してください:
package greet
import "fmt"
var Greeting string
func Hello(name string) string {
return fmt.Sprintf(Greeting, name)
}
このコードは、greet
パッケージに属していることを宣言しています。そして、Greeting
という変数と、Hello
という関数の2つのシンボルを宣言しています。これらはどちらも大文字で始まっているため、exported
されており、外部のプログラムから利用可能です。前述の通り、アクセスを制限するパッケージを作成することで、より良いAPI設計が可能になり、パッケージを内部で更新しても依存しているコードを壊さないようにすることが容易になります。
パッケージの可視性の定義
プログラム内でのパッケージの可視性がどのように機能するかをより詳しく見るために、logging
パッケージを作成しましょう。外部に公開するものと非公開にするものを念頭に置いてください。このロギングパッケージは、プログラムのメッセージをコンソールにログ出力する役割を担います。また、どのレベルでロギングしているかを確認します。レベルはログの種類を示し、info
、warning
、error
の3つのステータスのいずれかになります。
まず、src
ディレクトリ内に、ロギングファイルを配置するためのlogging
というディレクトリを作成しましょう。
次に、そのディレクトリに移動します。
そして、nanoのようなエディタを使って、logging.go
というファイルを作成します。
作成したlogging.go
ファイルに以下のコードを配置します。
このコードの最初の行では、logging
というパッケージが宣言されています。このパッケージには、Debug
とLog
という2つのエクスポートされた
関数があります。これらの関数は、logging
パッケージをインポートする他のどのパッケージからも呼び出すことができます。また、debug
というプライベート変数もあります。この変数は、logging
パッケージ内からのみアクセス可能です。関数Debug
と変数debug
は同じスペルですが、関数は大文字で始まり、変数は小文字で始まります。これにより、異なるスコープを持つ異なる宣言となります。
ファイルを保存して終了します。
このパッケージをコードの他の部分で使用するために、インポート
して新しいパッケージに取り込むことができます。この新しいパッケージを作成しますが、まずそのソースファイルを保存する新しいディレクトリが必要です。
logging
ディレクトリから移動し、cmd
という新しいディレクトリを作成して、その新しいディレクトリに移動します。
先ほど作成したcmd
ディレクトリにmain.go
というファイルを作成します。
次のコードを追加できます:
これで、プログラム全体が書き終わりました。しかし、このプログラムを実行する前に、コードが正しく動作するための設定ファイルをいくつか作成する必要があります。GoはGo Modulesを使用して、リソースのインポートのためのパッケージ依存関係を設定します。Goモジュールは、コンパイラにパッケージのインポート先を指示するパッケージディレクトリに配置される設定ファイルです。モジュールについて学ぶことはこの記事の範囲を超えていますが、この例をローカルで動作させるためには、設定を数行書くだけで済みます。
以下のgo.mod
ファイルをcmd
ディレクトリで開いてください:
そして、ファイルに以下の内容を配置します:
module github.com/gopherguides/cmd
replace github.com/gopherguides/logging => ../logging
このファイルの最初の行は、cmd
パッケージがgithub.com/gopherguides/cmd
というファイルパスを持っていることをコンパイラに伝えます。2行目は、github.com/gopherguides/logging
パッケージがローカルディスクの../logging
ディレクトリにあることをコンパイラに伝えます。
また、logging
パッケージ用のgo.mod
ファイルも必要です。logging
ディレクトリに戻って、go.mod
ファイルを作成しましょう:
ファイルに以下の内容を追加します:
module github.com/gopherguides/logging
これは、作成したlogging
パッケージが実際にはgithub.com/gopherguides/logging
パッケージであることをコンパイラに伝えます。これにより、以前に書いた以下の行でmain
パッケージにパッケージをインポートすることが可能になります:
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
パッケージからmain
プログラムを以下のコマンドで実行できます:
次のような出力が得られます:
Output2019-08-28T11:36:09-05:00 This is a debug statement...
プログラムは、RFC 3339形式の現在時刻を出力し、その後にロガーに送信したステートメントを出力します。RFC 3339は、インターネット上で時間を表すために設計された時間形式であり、ログファイルで一般的に使用されています。
ロギングパッケージからDebug
およびLog
関数がエクスポートされているため、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)
}
ファイルを保存して実行します。次のようなエラーが表示されます:
Output. . .
./main.go:10:14: cannot refer to unexported name logging.debug
パッケージ内のエクスポートされた
およびエクスポートされていない
アイテムの動作を見たので、次に構造体
からフィールド
およびメソッド
をエクスポートする方法を見ていきます。
構造体内の可視性
前節で構築したロガーの可視性スキームは単純なプログラムでは機能するかもしれませんが、複数のパッケージ内から有用であるためには状態を共有しすぎています。これは、エクスポートされた変数が複数のパッケージからアクセス可能であり、それらが変数を矛盾した状態に変更する可能性があるためです。このようにパッケージの状態を変更できるようにすると、プログラムがどのように振る舞うかを予測することが難しくなります。例えば、現在の設計では、あるパッケージがDebug
変数をtrue
に設定し、別のパッケージが同じインスタンスでfalse
に設定することができます。これは、logging
パッケージをインポートしている両方のパッケージに影響を与えるため、問題を引き起こします。
ロガーを分離するために、構造体を作成し、その構造体にメソッドを付加することができます。これにより、ロガーのinstance
を各パッケージで独立して使用できるようになります。
コードをリファクタリングし、ロガーを分離するために、logging
パッケージを以下のように変更します。
このコードでは、Logger
構造体を作成しました。この構造体は、出力する時間のフォーマットやdebug
変数の設定(true
またはfalse
)など、非公開の状態を保持します。New
関数は、ロガーを作成するための初期状態(時間のフォーマットやデバッグ状態)を設定し、与えられた値を非公開変数timeFormat
とdebug
に内部で保存します。また、Logger
型にLog
というメソッドを作成し、出力したいステートメントを受け取ります。Log
メソッド内では、ローカルのメソッド変数l
を参照して、l.timeFormat
やl.debug
などの内部フィールドにアクセスします。
このアプローチにより、多くの異なるパッケージでLogger
を作成し、他のパッケージの使用方法に依存せずに独立して使用できます。
別のパッケージで使用するために、cmd/main.go
を以下のように変更しましょう:
このプログラムを実行すると、以下の出力が得られます:
Output2019-08-28T11:56:49-05:00 This is a debug statement...
このコードでは、公開された関数New
を呼び出してロガーのインスタンスを作成し、その参照をlogger
変数に保存しました。これにより、logging.Log
を呼び出してステートメントを出力できます。
もしLogger
の非公開フィールド(例えばtimeFormat
フィールド)を参照しようとすると、コンパイル時エラーが発生します。以下の強調表示された行を追加して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)
}
これにより、以下のエラーが発生します:
Output. . .
cmd/main.go:14:20: logger.timeFormat undefined (cannot refer to unexported field or method timeFormat)
コンパイラは、logger.timeFormat
がエクスポートされておらず、そのためlogging
パッケージから取得できないことを認識します。
メソッド内の可視性
構造体のフィールドと同様に、メソッドもエクスポートされるかされないかのどちらかです。
これを説明するために、ロガーにレベル付きロギングを追加してみましょう。レベル付きロギングは、ログを特定の種類のイベントで分類する手段で、ログを検索して特定のタイプのイベントを見つけることができます。ロガーに追加するレベルは以下の通りです:
-
info
レベルは、Program started
やEmail sent
のような、ユーザーにアクションを通知する情報タイプのイベントを表します。これらは、プログラムの期待される動作が行われているかどうかをデバッグおよび追跡するのに役立ちます。 -
warning
レベルは、Email failed to send, retrying
のような、エラーではない予期しない事象を特定するイベントタイプです。これらは、プログラムの一部が期待通りにスムーズに進んでいないことを確認するのに役立ちます。 -
error
レベル。これは、プログラムがファイルが見つかりません
のような問題に遭遇したことを意味します。これにより、プログラムの動作が失敗することが多いです。
プログラムが期待通りに動作していない場合、特にデバッグを行いたい場合には、特定のロギングレベルをオンまたはオフにしたいことがあります。この機能を追加するために、プログラムを変更して、debug
がtrue
に設定されている場合はすべてのレベルのメッセージを出力し、そうでない場合はエラーメッセージのみを出力するようにします。
logging/logging.go
に以下の変更を加えることでレベル付きロギングを追加します:
この例では、Log
メソッドに新しい引数を導入しました。これにより、ログメッセージのlevel
を渡すことができます。Log
メソッドは、そのメッセージがどのレベルなのかを判断します。info
またはwarning
メッセージで、debug
フィールドがtrue
の場合、メッセージを書き出します。それ以外の場合はメッセージを無視します。他のレベル、例えばerror
の場合、メッセージを書き出します。
メッセージが出力されるかどうかを決定するロジックの大部分は、Log
メソッドに存在しています。また、非公開メソッドとしてwrite
というメソッドを導入しました。write
メソッドは、実際にログメッセージを出力するものです。
このレベル別ロギングを他のパッケージで使用するために、cmd/main.go
を以下のように変更します。
これを実行すると、以下の結果が得られます。
Output[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
に切り替えることで、各メッセージのlevel
を渡すことができます。
これで、error
レベルのメッセージのみが出力されることがわかります。
Output[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...")
}
Outputcmd/main.go:16:8: logger.write undefined (cannot refer to unexported field or method logging.(*Logger).write)
コンパイラは、小文字で始まる別のパッケージから何かを参照しようとしていることを検出すると、それが非公開であることを認識し、コンパイルエラーを投げます。
このチュートリアルのロガーは、他のパッケージが消費する部分のみを公開するコードをどのように書くかを示しています。パッケージのどの部分が外部から見えるかを制御しているため、将来の変更がパッケージに依存するコードに影響を与えないようにすることができます。例えば、debug
がfalseの場合にのみinfo
レベルのメッセージをオフにしたい場合、この変更をAPIの他の部分に影響を与えることなく行うことができます。また、プログラムが実行されていたディレクトリなど、ログメッセージにより多くの情報を含めるように変更することも安全に行うことができます。
結論
この記事では、パッケージ間でコードを共有しながら、パッケージの実装詳細を保護する方法を示しました。これにより、下位互換性のためにほとんど変更されないシンプルなAPIをエクスポートできる一方で、将来の改善のために必要に応じてパッケージ内でプライベートに変更することができます。これは、パッケージとそれに対応するAPIを作成する際のベストプラクティスと考えられています。
Go言語のパッケージについてさらに学ぶために、GoでのパッケージのインポートとGoでのパッケージの書き方の記事をチェックしてみてください。また、Goでコーディングする方法シリーズ全体を探求することもできます。
Source:
https://www.digitalocean.com/community/tutorials/understanding-package-visibility-in-go