Go言語でのパッケージの可視性を理解する

はじめに

Goでパッケージを作成する際の最終目標は、通常、他の開発者がそのパッケージを使用できるようにすることです。これは、より高いレベルのパッケージや全体のプログラムで使用されることを意味します。パッケージをインポートすることで、あなたのコードは他のより複雑なツールの構成要素として機能することができます。しかし、インポート可能なパッケージは特定のものだけです。これは、パッケージの可視性によって決定されます。

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

パッケージの可視性を慎重に制御することは、特にパッケージに対して将来行いたい変更を考慮する際に、エルゴノミックなコードを書く上で重要です。バグを修正したり、パフォーマンスを向上させたり、機能を変更したりする必要がある場合、パッケージを使用している誰かのコードを壊さない方法で変更したいでしょう。破壊的な変更を最小限に抑える一つの方法は、パッケージが適切に使用されるために必要な部分だけにアクセスを許可することです。アクセスを制限することで、他の開発者がパッケージをどのように使用しているかに影響を与える可能性を低くして、パッケージの内部で変更を加えることができます。

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

前提条件

この記事の例に従うためには、以下が必要です:

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

エクスポートされたアイテムとエクスポートされていないアイテム

JavaやPythonのような他のプログラミング言語がアクセス修飾子を使ってスコープを指定するのとは異なり、Goはアイテムがエクスポートされたエクスポートされていないかを宣言方法によって判断します。この場合、アイテムをエクスポートすることで、現在のパッケージの外から見えるようになります。エクスポートされていない場合、それは定義されたパッケージ内からのみ見えて使用可能です。

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

次のコードを見て、大文字小文字に注意してください:

greet.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パッケージを作成しましょう。外部に公開するものと非公開にするものを念頭に置いてください。このロギングパッケージは、プログラムのメッセージをコンソールにログ出力する役割を担います。また、どのレベルでロギングしているかを確認します。レベルはログの種類を示し、infowarningerrorの3つのステータスのいずれかになります。

まず、srcディレクトリ内に、ロギングファイルを配置するためのloggingというディレクトリを作成しましょう。

  1. mkdir logging

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

  1. cd logging

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

  1. nano logging.go

作成したlogging.goファイルに以下のコードを配置します。

logging/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というパッケージが宣言されています。このパッケージには、DebugLogという2つのエクスポートされた関数があります。これらの関数は、loggingパッケージをインポートする他のどのパッケージからも呼び出すことができます。また、debugというプライベート変数もあります。この変数は、loggingパッケージ内からのみアクセス可能です。関数Debugと変数debugは同じスペルですが、関数は大文字で始まり、変数は小文字で始まります。これにより、異なるスコープを持つ異なる宣言となります。

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

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

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

  1. cd ..
  2. mkdir cmd
  3. cd cmd

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

  1. nano main.go

次のコードを追加できます:

cmd/main.go
package main

import "github.com/gopherguides/logging"

func main() {
	logging.Debug(true)

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

これで、プログラム全体が書き終わりました。しかし、このプログラムを実行する前に、コードが正しく動作するための設定ファイルをいくつか作成する必要があります。GoはGo Modulesを使用して、リソースのインポートのためのパッケージ依存関係を設定します。Goモジュールは、コンパイラにパッケージのインポート先を指示するパッケージディレクトリに配置される設定ファイルです。モジュールについて学ぶことはこの記事の範囲を超えていますが、この例をローカルで動作させるためには、設定を数行書くだけで済みます。

以下のgo.modファイルをcmdディレクトリで開いてください:

  1. nano go.mod

そして、ファイルに以下の内容を配置します:

go.mod
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ファイルを作成しましょう:

  1. cd ../logging
  2. nano go.mod

ファイルに以下の内容を追加します:

go.mod
module github.com/gopherguides/logging

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

cmd/main.go
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プログラムを以下のコマンドで実行できます:

  1. cd ../cmd
  2. go run main.go

次のような出力が得られます:

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

プログラムは、RFC 3339形式の現在時刻を出力し、その後にロガーに送信したステートメントを出力します。RFC 3339は、インターネット上で時間を表すために設計された時間形式であり、ログファイルで一般的に使用されています。

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

main.goに以下の強調表示された行を追加します:

cmd/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パッケージを以下のように変更します。

logging/logging.go
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構造体を作成しました。この構造体は、出力する時間のフォーマットやdebug変数の設定(trueまたはfalse)など、非公開の状態を保持します。New関数は、ロガーを作成するための初期状態(時間のフォーマットやデバッグ状態)を設定し、与えられた値を非公開変数timeFormatdebugに内部で保存します。また、Logger型にLogというメソッドを作成し、出力したいステートメントを受け取ります。Logメソッド内では、ローカルのメソッド変数lを参照して、l.timeFormatl.debugなどの内部フィールドにアクセスします。

このアプローチにより、多くの異なるパッケージでLoggerを作成し、他のパッケージの使用方法に依存せずに独立して使用できます。

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

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...")
}

このプログラムを実行すると、以下の出力が得られます:

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

このコードでは、公開された関数Newを呼び出してロガーのインスタンスを作成し、その参照をlogger変数に保存しました。これにより、logging.Logを呼び出してステートメントを出力できます。

もしLoggerの非公開フィールド(例えばtimeFormatフィールド)を参照しようとすると、コンパイル時エラーが発生します。以下の強調表示された行を追加してcmd/main.goを実行してみてください:

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 startedEmail sentのような、ユーザーにアクションを通知する情報タイプのイベントを表します。これらは、プログラムの期待される動作が行われているかどうかをデバッグおよび追跡するのに役立ちます。

  • warningレベルは、Email failed to send, retryingのような、エラーではない予期しない事象を特定するイベントタイプです。これらは、プログラムの一部が期待通りにスムーズに進んでいないことを確認するのに役立ちます。

  • errorレベル。これは、プログラムがファイルが見つかりませんのような問題に遭遇したことを意味します。これにより、プログラムの動作が失敗することが多いです。

プログラムが期待通りに動作していない場合、特にデバッグを行いたい場合には、特定のロギングレベルをオンまたはオフにしたいことがあります。この機能を追加するために、プログラムを変更して、debugtrueに設定されている場合はすべてのレベルのメッセージを出力し、そうでない場合はエラーメッセージのみを出力するようにします。

logging/logging.goに以下の変更を加えることでレベル付きロギングを追加します:

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メソッドに新しい引数を導入しました。これにより、ログメッセージのlevelを渡すことができます。Logメソッドは、そのメッセージがどのレベルなのかを判断します。infoまたはwarningメッセージで、debugフィールドがtrueの場合、メッセージを書き出します。それ以外の場合はメッセージを無視します。他のレベル、例えばerrorの場合、メッセージを書き出します。

メッセージが出力されるかどうかを決定するロジックの大部分は、Logメソッドに存在しています。また、非公開メソッドとしてwriteというメソッドを導入しました。writeメソッドは、実際にログメッセージを出力するものです。

このレベル別ロギングを他のパッケージで使用するために、cmd/main.goを以下のように変更します。

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")

}

これを実行すると、以下の結果が得られます。

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メソッドを正常に使用しました。

これで、debugfalseに切り替えることで、各メッセージのlevelを渡すことができます。

main.go
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レベルのメッセージのみが出力されることがわかります。

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

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

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")

	logger.write("error", "log this message...")
}
Output
cmd/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