Goでのパッケージの可視性について

前書き

package in Goを作成する場合、最終的な目標は通常、他の開発者が高次のパッケージまたはプログラム全体で使用できるようにパッケージにアクセスできるようにすることです。 importing the packageにより、コードは他のより複雑なツールの構成要素として機能します。 ただし、インポートできるのは特定のパッケージのみです。 これは、パッケージの可視性によって決まります。

このコンテキストでのVisibilityは、パッケージまたはその他の構成を参照できるファイルスペースを意味します。 たとえば、関数で変数を定義する場合、その変数の可視性(スコープ)は、それが定義された関数内にのみ存在します。 同様に、パッケージ内で変数を定義する場合、そのパッケージのみに変数を表示することも、パッケージの外部にも変数を表示することもできます。

人間工学に基づいたコードを記述する場合、特にパッケージに加えたい将来の変更を考慮する場合は、パッケージの可視性を慎重に制御することが重要です。 バグの修正、パフォーマンスの改善、または機能の変更が必要な場合は、パッケージを使用している人のコードを壊さない方法で変更を行う必要があります。 重大な変更を最小限に抑える方法の1つは、パッケージを適切に使用するために必要な部分のみにアクセスを許可することです。 アクセスを制限することにより、他の開発者がパッケージを使用する方法に影響を与える可能性を少なくして、パッケージを内部的に変更できます。

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

前提条件

この記事の例を実行するには、次のものが必要です。

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

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

スコープを指定するためにpublicprivateprotectedなどのaccess modifiersを使用するJavaやPythonなどの他のプログラム言語とは異なり、Goはアイテムが%かどうかを判断します(t5)sおよびunexportedは、それがどのように宣言されているかを示します。 この場合、アイテムをエクスポートすると、現在のパッケージの外にvisibleになります。 エクスポートされていない場合は、定義されたパッケージ内からのみ表示および使用できます。

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

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

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パッケージを作成して、パッケージの外部で表示したいものと表示しないものを念頭に置いてみましょう。 このロギングパッケージは、プログラムメッセージをコンソールに記録する役割を果たします。 また、ログに記録しているlevelも確認します。 レベルはログのタイプを記述し、infowarning、またはerrorの3つのステータスのいずれかになります。

まず、srcディレクトリ内に、loggingというディレクトリを作成して、ログファイルを次の場所に配置します。

mkdir logging

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

cd logging

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

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つのexported関数があります。 これらの関数は、loggingパッケージをインポートする他のパッケージから呼び出すことができます。 debugと呼ばれるプライベート変数もあります。 この変数には、loggingパッケージ内からのみアクセスできます。 関数Debugと変数debugはどちらも同じスペルですが、関数は大文字で、変数は大文字ではないことに注意してください。 これにより、スコープが異なる明確な宣言になります。

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

このパッケージをコードの他の領域で使用するには、import it into a new packageを使用できます。 この新しいパッケージを作成しますが、これらのソースファイルを最初に保存するための新しいディレクトリが必要です。

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

cd ..
mkdir cmd
cd cmd

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

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モジュールは、パッケージディレクトリに配置される構成ファイルであり、パッケージのインポート元をコンパイラに指示します。 モジュールについて学習することはこの記事の範囲を超えていますが、ほんの数行の設定を書いて、この例をローカルで動作させることができます。

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

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ファイルを作成しましょう。

cd ../logging
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プログラムを実行できます。

cd ../cmd
go run main.go

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

Output2019-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

パッケージ内のexportedおよびunexportedアイテムがどのように動作するかを確認したので、次に、fieldsおよびmethodsstructsからエクスポートする方法を見ていきます。

構造内の可視性

前のセクションで作成したロガーの可視性スキームは、単純なプログラムで機能する場合がありますが、複数のパッケージ内から使用するにはあまりにも多くの状態を共有します。 これは、エクスポートされた変数は、変数を矛盾した状態に変更する可能性のある複数のパッケージからアクセスできるためです。 この方法でパッケージの状態を変更できると、プログラムの動作を予測するのが難しくなります。 たとえば、現在の設計では、1つのパッケージで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構造体を作成しました。 この構造体は、出力する時間形式やtrueまたはfalsedebug変数設定など、エクスポートされていない状態を格納します。 New関数は、時間形式やデバッグ状態など、ロガーを作成するための初期状態を設定します。 次に、内部で指定した値をエクスポートされていない変数timeFormatおよびdebugに格納します。 また、出力するステートメントを受け取る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...")
}

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

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

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

timeFormatフィールドなどの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...")

    fmt.Println(logger.timeFormat)
}

これにより、次のエラーが発生します。

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

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

メソッド内の可視性

構造体フィールドと同様に、メソッドもエクスポートまたはアンエクスポートできます。

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

  • infoレベル。これは、Program startedEmail sentなどのアクションをユーザーに通知する情報タイプのイベントを表します。 これらは、プログラムの一部をデバッグおよび追跡して、予想される動作が発生しているかどうかを確認するのに役立ちます。

  • warningレベル。 これらのタイプのイベントは、Email failed to send, retryingのように、エラーではない予期しないことが発生したことを識別します。 彼らは私たちが期待したほどスムーズに進まないプログラムの部分を見るのに役立ちます。

  • errorレベル。これは、プログラムでFile not foundなどの問題が発生したことを意味します。 これにより、多くの場合、プログラムの操作が失敗します。

また、特にプログラムが期待どおりに実行されず、プログラムをデバッグする場合は、特定のレベルのログをオンまたはオフにすることもできます。 プログラムを変更してこの機能を追加し、debugtrueに設定されている場合に、すべてのレベルのメッセージを出力するようにします。 それ以外の場合、falseの場合、エラーメッセージのみが出力されます。

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...")
}
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のパッケージの詳細については、Importing Packages in GoHow To Write Packages in Goの記事を確認するか、How To Code in Go series全体を調べてください。