🐈

[Ruby] SingletonなLoggerクラスを作成する

2021/02/13に公開

アプリケーションのログを生成するクラスにはシングルトン・パターンがぴったりだから、Singletonモジュールを利用してシングルトン・パターンなロギングクラス(Loggerクラス継承)を作ろう、という趣旨の記事です。

この記事の要点

シングルトン・パターンなロギングクラスを作る・使うには、

  1. Loggerクラスを継承したクラスを作成する

    • Singletonモジュールincludeする
    • initializeメソッドを再定義する
      • Logger.newに渡す引数を指定したsuperメソッドを呼ぶ(Logger.newではない!)
    singleton_logger.rb
    require "logger"
    require "singleton"
    
    class SingletonLogger < Logger
      LOGFILE = "my_app.log"
      LOGLEVEL = Logger::Severity::INFO
    
      include Singleton
    
      def initialize
        super(LOGFILE, level: LOGLEVEL)
      end
    end
    
  2. インスタンス生成時は、instanceメソッドを呼ぶ(newではない!)

    main.rb
    require_relative "singleton_logger"
    
    logger = SingletonLogger.instance # これ!
    
    puts "hello"
    logger.info("said hello")
    

シングルトン・パターンとは?

シングルトン・パターンでは、アプリケーション内で、そのクラスのインスタンスを高々ひとつしか生成しないようにします。
既にそのクラスのインスタンスが生成されている場合は、新しいインスタンスを生成するのではなく、既存の(唯一の)インスタンスを渡すようにします。

デザインパターンの一種であり、いわゆるGoF本で定義されています。

Rubyでは、標準ライブラリであるSingletonモジュールを利用することで、簡単に実装できます。

そして、このシングルトン・パターンは、ログを生成するクラスと相性がよいのです。

ロギングクラスをシングルトン・パターンにすると何が嬉しい?

ロギングを考える際、「あのログファイルに記録する」「あのログレベル以上で記録する」は、アプリケーション内で共通・統一的であることが多いのではないでしょうか。

Ruby標準のloggerライブラリおよびLoggerクラスを単純に利用して、「特定のファイル・ログ記録レベル」にてログを出力したいとします。
この場合、正確に同じログファイル名と同じログ記録レベルをひとつひとつ指定して、モジュールやモジュールファイル(.rbファイル)ごとに、newメソッドを繰り返す必要があります。

logger = Logger.new(log_file, level: log_level)

この方法には、以下のようなマイナス点があります:

  • 単純に引数の指定の繰り返しが面倒くさい
  • 引数の指定をミスして、アプリケーション内でログ生成環境が統一されない可能性がある
  • (相対的に)インスタンス生成の数だけ重複して生成コストがかかる
  • 「このログファイル名・ログ記録レベルを指定すること」というルールをどこかで運用・保守する必要がある

ここで、シングルトン・パターンにしたがうようにクラスを作成すると、

  • 引数の指定ミスがなくなる(というか直接newできないので、インスタンス生成に関して余計なことができなくなる)
  • インスタンスの生成コストは初回の一度のみ
  • 「あ、このインスタンス、アプリケーション内で統一なんだ」というのが一目でわかる

という形にすることができます。

シングルトン・パターンはコードデザインのやり方なので、どう実装してもよいのですが、Rubyでは標準ライブラリのSingletonモジュールを使うのが簡単・確実でしょう。

Singletonモジュール

Singletonモジュールは、シングルトン・パターンにしたいクラスの中でincludeして使います。

Singletonモジュールをincludeしたクラスではnewメソッドが隠蔽(privateメソッド化)され、かわりに

  • アプリケーション内でまだインスタンスが存在しない場合、内部的にnewを呼んでインスタンスを生成し、返す
  • インスタンスが既に生成済みの場合、そのインスタンスを返す

という動きをするinstanceメソッドで、インスタンスを呼び出します。

require "singleton"

class X
  include Singleton

  def initialize
    puts "Xのインスタンスを生成しました"
  end
end

# インスタンスの生成は一度きり
a = X.instance
  #=> Xのインスタンスを生成しました
b = X.instance
  #=> (出力なし)
c = X.instance
  #=> (出力なし)

# すべて同一のインスタンス
p [a, b, c]
  #=> [#<X:0x000055f7af906c60>, #<X:0x000055f7af906c60>, #<X:0x000055f7af906c60>]

インスタンスの生成は初回のinstanceでの一度きりで、また2回目以降のinstanceで渡されているのがすべて同一のインスタンスである、ということがわかります。

ラッパーその他でよくない?

単純なnewメソッドの隠蔽ならば、次のようなラッパーを定義すれば事足りるような気もします:

require "logger"

def my_logger
  Logger.new("my_app.log", level: Logger::Severity::INFO)
end

# (使うとき)
logger = my_logger
logger.info("hello")

しかし、この方法では、my_loggerを呼ぶたびに相変わらず重複してインスタンス生成を行ってしまいます(まあ、実際に問題となるほど大したコストではないですが)。また、「絶対唯一のインスタンスを呼んでいる」というのが明示されないのも、ちょっとイケてないと思います。

この他にも、色々な方法が考えられますが、

  • グローバル変数でいいじゃん => 色々と危ない
  • クラス変数を活用すればいいじゃん => 初期化のタイミングがクラス定義時になる(Singletonモジュールのinstanceではインスタンス生成時)、他のクラス変数やらと混ざってややこしくなる

のように、マイナスポイントがあります。詳しくは、例えば大分古いですが
https://www.amazon.co.jp/Design-Patterns-Addison-Wesley-Professional-English-ebook/dp/B004YW6M6G
などを参照してください(私が読んだのがこの本であるというだけで、似たようなことは他の本やサイトでも見つかると思います)。

これらマイナスポイントに加えて、独自実装は保守コストがかかってくるので、素直に信頼性の高いSingletonモジュールを利用するのがよいと思います。

実装例

前置きが長くなりましたが、簡単な実装例に行きましょう。

今回は、以下のようなディレクトリ構成で、lib/singleton_logger.rbに、シングルトン・パターンなロギングクラスであるSingletonLoggerクラスを作成したいと思います。
また、SingletonLoggerインスタンスの呼び出し側として、main.rbとlib/another_file_moduleを作成しています。

.
├── lib
│   ├── singleton_logger.rb      # <- SingletonLoggerクラスを定義
│   └── another_file_module.rb   # <- 呼び出し側
├── log
│   └── my_day.log
└── main.rb                      # <- 呼び出し側

SingletonLoggerクラス

lib/singleton_logger.rb
require "logger"
require "singleton"

class SingletonLogger < Logger
  # ロギングパラメータ
  LOGFILE = "log/my_day.log"
  LOGLEVEL = Logger::Severity::INFO

  include Singleton

  def initialize
    super(LOGFILE, level: LOGLEVEL)
  end
end

単純にLoggerクラスを継承し、Singletonモジュールをincludeしています。

初回のSingletonLogger.instance呼び出し時に、内部的にSingletonLogger.newが呼ばれます。
今回は「ファイル・ログレベルを指定したLoggerインスタンス」を返したいので、superの引数にファイル名とログレベルを指定して、Logger.newが呼ばれるようにしています。

ロギングパラメータをハードコードしているのがちょっと嫌な感じもしますが、シングルトン・パターンの概念上、生成されるインスタンスとクラスは一対一で密な関係であることを思えば、クラス定数ならばいいかなと思います。

呼び出し側

main.rb
require_relative "lib/singleton_logger"

require_relative "lib/another_file_module"

def main
  logger = SingletonLogger.instance # インスタンス呼び出し

  puts "1時間だけゲームすることにします。"
  logger.info("decided to play a game for 1 hour") # -> ログ書き出し

  AnotherFileModule.play_more(hours: 3) # -> メソッド内部でログ書き出し

  puts "きっちり切り上げて、夕飯を作ります!"
  logger.info("decided to prepare dinner") # -> ログ書き出し
end
              
# 実行
main
lib/another_file_module.rb
require_relative "singleton_logger"

module AnotherFileModule
  module_function

  def play_more(hours: 1)
    logger = SingletonLogger.instance # インスタンス呼び出し
        
    puts "やっぱり、あと#{hours}時間だけゲームします。"
    logger.info("decided to play the game #{hours} more hour(s)") # -> ログ書き出し
  end
end

newではなく、instanceでインスタンス呼び出しをしているところがポイントです。

(本題とは関係ありませんが、このmainメソッドを定義するやり方は、伊藤淳一さんの「Rubyスクリプトにもmainメソッドを定義するといいかも、という話」という記事を参考にしています。)

実行してみる

$ ruby main.rb
1時間だけゲームすることにします。
やっぱり、あと3時間だけゲームします。
きっちり切り上げて、夕飯を作ります!

$ cat log/my_day.log
# Logfile created on 2021-02-13 08:30:51 +0900 by logger.rb/v1.4.2
I, [2021-02-13T19:11:10.464850 #2769]  INFO -- : decided to play a game for 1 hour
I, [2021-02-13T19:11:10.464987 #2769]  INFO -- : decided to play the game 3 more hour(s)
I, [2021-02-13T19:11:10.465015 #2769]  INFO -- : decided to prepare dinner

出力に関しては、ふぅん、そうだねという感じですね。

まとめ&なぜこの記事を書いたか

シングルトン・パターンなロギングクラスを作成しよう、というお話でした。

Rubyのシングルトン・パターンで調べると、Singletonモジュールを利用して独自のロギングクラスを作成する例は割と見かけるのですが、標準ライブラリのLoggerクラスを継承しているやり方はパッと見つからなかったので、記事にした次第です。

個人的には、Singletonモジュールを使ってみる過程で、以下の2点に少し悩みました:

  1. Loggerクラスを継承して作成しようとしているロギングクラスのinitialize内で、とりあえずLogging.newと書いてしまい、superを使えばいいことにしばらく思い至らなかった
    (多分、initializeを特殊なものであると考えそうになっていた)
  2. ラッパーでよくない?(1でちょっと詰まりかけた時に)

最終的には、この記事の実装例のような形ですっきりできたかな、と思っています。


デザインパターンやLoggerクラスについては理解が浅いので、「それアンチパターンやぞ」「それ、LoggerクラスのXXがダメになるよ」というのがありましたら、ご指摘いただけると嬉しいです。

Discussion