log4javascript でファイルにログを書き込む独自の Appender を作る方法

log4javascript というライブラリがある。log4j に似たような使い方ができ、ログレベルを指定できたり、出力先をコンソールにしたり LocalStorage にしたりといったことができる。

大変便利なライブラリなのだが、log4javascript 標準ではファイルにログを書き出す Appender を提供してくれていない。というのも無理はないか。動作環境によってどのような方法でファイルに書き込みができるか、かなり微妙だからであろう。

今回は独自の Appender を作成するための参考なりそうなリンクをまとめてみようと思う。

File API を使用する方法

この Gist がそのまま動作する Appender 拡張クラスになっている。File API が使えれば File API で、File API が使えず ActiveX が使えれば FileSystemObject でファイルにログを書き込もうとしている。

ActiveX を使用する方法

こちらの StackOverflow の回答も、ActiveX を使用した Appender 拡張クラスのサンプルを紹介している。

Cordova-Plugin-File を使用する方法

こちらはそのものズバリなコードはないのだが、Cordova アプリにおいては Cordova-Plugin-File プラグインを使うことで、アプリ内に作ったローカルファイルにログを追記していくことができる。

プラグインをそのまま使うだけでは「ファイルの末尾に追記」ができないので、以下の記事で紹介されているように追記する場所をシークしてやる必要がある。fileWriter.seek(fileWriter.length) がミソ。

AngularJS 向けに Cordova プラグインのラッパーを提供している ngCordova も、seek() を利用した writeExistingFile() というメソッドを提供している。実コードを以下で確認できる。

AngularJS 向けと Angular4 向けのコードを作ったので以下を参考にされたし。

const log4j = log4javascript;

/**
 * AngularJS 向け・ログファイルを生成し、ログを追記する Appender
 * log4javascript の Appender を拡張しており、cordova-plugin-file プラグイン・$cordovaFile を使用してファイル操作を行う
 */
class LocalFileAppender extends log4j.Appender {
  /**
   * コンストラクタ
   * 
   * @param $cordovaFile ngCordova が提供するファイル操作プラグインの API
   * @param $q 非同期処理を行うためのサービス
   * @param $window File API を呼び出すために使用する
   * @param appenderOptions アペンダオプション
   */
  constructor($cordovaFile, $q, $window, appenderOptions) {
    // DI
    super();
    this._$cordovaFile = $cordovaFile;
    this._$q = $q;
    this._$window = $window;
    
    // 不変プロパティの宣言
    // デフォルトのアペンダオプション
    this.defaultAppenderOptions = {
      fileName: 'MyLogger.log',
      rotateDays: 0,
      layout: '[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5p] %m',
      threshold: log4j.Level.INFO
    };
    // ログ出力先ディレクトリ : グローバル変数 cordova より取得する
    this.logDirectory = this._$window.cordova.file.dataDirectory;
    
    // 可変プロパティの宣言
    // キャッシュしているログを格納する配列。ログ出力が可能になったらこの配列内のログを出力する
    this.cachedLogEventsQueue = [];
    // ログ出力できない問題がある場合は true にするフラグ変数。true の場合はコンソールに出力する
    this.isFatal = false;
    // ログ出力の準備中は true にするフラグ変数。ログファイルの準備ができたら false にする
    this.isGettingReady = true;
    // ローテート作業中は true にするフラグ変数。ログファイルの準備ができたら false にする
    this.isRotating = false;
    
    // 内部ロガーの準備
    this.internalAppender = this._createInternalAppender();
    this.internalLogger = this._createInternalLogger(this.internalAppender);
    
    // このクラス全体で使う Promise チェーンを用意する
    // この Promise チェーンの then() に行いたい処理を設定することで、非同期処理を順番に処理させる
    // 最初は処理がないので then() に繋げさせるため空で Resolve しておく
    this._promiseChain = this._$q.resolve();
    
    // 初期化処理呼び出し
    // アペンダオプションの設定処理
    this.initAppenderOptions(appenderOptions);
    // ログファイルの確認・生成処理
    this.initLogFile();
  }
  
  /**
   * 内部ロガーが使用する Appender を生成する
   * append() メソッドにて、コンソールにログ出力する際にレイアウト設定するためにも使用する
   * 
   * @return {Object} 出力レベル、レイアウトを設定した BrowserConsoleAppender オブジェクト
   */
  _createInternalAppender() {
    const internalAppender = new log4j.BrowserConsoleAppender();
    internalAppender.setThreshold(log4j.Level.WARN);
    internalAppender.setLayout(new log4j.PatternLayout('[%d{yyyy-MM-dd HH:mm:ss.SSS}] [my-logger] [%-5p] %m%n'));
    return internalAppender;
  }
  
  /**
   * 本 Appender クラスの内部で使用する、コンソール出力用のロガーを生成する
   * デバッグログやエラーログの出力の他、ログファイルへの出力が不可能と判断した場合にログをコンソールへ出力するためにも使用する
   *
   * @param {Object} appender 内部ロガーに適用するコンソール出力アペンダ
   * @return {Object} コンソール出力用のロガーオブジェクト
   */
  _createInternalLogger(appender) {
    const internalLogger = log4j.getLogger('my-logger.internal');
    internalLogger.addAppender(appender);
    return internalLogger;
  }
  
  /**
   * 本 Appender クラスの各種設定を行う
   *
   * @param {Ojbect} appenderOptions ユーザ指定のアペンダオプション
   */
  initAppenderOptions(appenderOptions) {
    // デフォルトのオプションに対しユーザ指定のオプションをマージする
    const mergedAppenderOptions = _.merge({}, this.defaultAppenderOptions, appenderOptions);
    
    // ファイル名がない場合、もしくはローテート期間が 0 未満など不正値の場合は NG とする
    if (!mergedAppenderOptions.fileName || mergedAppenderOptions.rotateDays < 0) {
      throw new Error('アペンダオプションの設定が不正です');
    }
    
    // アペンダオプションを設定する
    this.appenderOptions = mergedAppenderOptions;
    
    // ログ出力対象日付 (当日日付) を yyyyMMdd の形式で取得・設定する
    this.logDate = this._getCurrentDate();
    // 当日日付のログファイルの名前 (fileName.log.yyyyMMdd) を設定する
    this.logFileName = `${this.appenderOptions.fileName}.${this.logDate}`;
    // 出力レベル : log4javascript.Level クラスの定数で指定
    this.setThreshold(this.appenderOptions.threshold);
    // レイアウト : 指定のレイアウト文字列の末尾に改行コードを付与する
    this.setLayout(new log4j.PatternLayout(`${this.appenderOptions.layout}%n`));
  }
  
  /**
   * ログを出力するファイルの生成処理を行う
   * 
   * - ログファイルが既に存在する場合は生成処理を行わない
   * - ログファイルの準備が完了次第、キャッシュ配列に格納されているログを出力する
   * - 一連の処理中に問題があった場合はファイル出力を止め、コンソール出力に切り替える
   */
  initLogFile() {
    // ログファイルの存在確認
    this._$cordovaFile.checkFile(this.logDirectory, this.logFileName)
      .then(() => {
        this.internalLogger.debug('ログファイル生成済');
        // ログファイルが既に生成されているので準備中フラグを false にする
        this.isGettingReady = false;
      }, () => {
        this.internalLogger.debug('ログファイル未生成・生成処理開始');
        return this._$cordovaFile.createFile(this.logDirectory, this.logFileName, true)
          .then(() => {
            this.internalLogger.debug('ログファイル生成完了');
            // ログファイルの生成が完了したら準備中フラグを false にする
            this.isGettingReady = false;
          });
      })
      .then(() => {
        this.internalLogger.debug('ログファイル準備完了・キャッシュログの出力開始');
        return this._putCachedLogs();
      })
      .catch((error) => {
        this.internalLogger.warn('ログファイル準備中にエラーが発生しました。コンソール出力に切り替えます', error);
        // ファイル出力不可能のフラグを設定し、コンソール出力させるようにする
        this.isFatal = true;
      });
  }
  
  /**
   * 現在日付を 'yyyyMMdd' の形式で取得し返却する
   * 
   * @return {Object} 'yyyyMMdd' の形式にした現在日付
   */
  _getCurrentDate() {
    return moment().format('YYYYMMDD');
  }
  
  /**
   * log4javascript.Appender より継承するログの追記メソッド
   * 
   * @param {Object} log4javascript.LoggingEvent オブジェクト
   */
  append(loggingEvent) {
    // ログ出力できない場合はキャッシュ配列をコンソールに出力する
    if (this.isFatal) {
      this.cachedLogEventsQueue.length = 0;
      // loggingEvent 内のレベルに応じて出力する
      this.internalLogger.log(loggingEvent.level, this.internalAppender.getLayout().format(loggingEvent));
      return;
    }
    
    // アペンダ準備中・ローテート中の場合はキャッシュ配列に追加する
    if (this.isGettingReady || this.isRotating) {
      this.cachedLogEventsQueue.push(loggingEvent);
      return;
    }
    
    // 現在日付を再取得する
    const currentDate = this._getCurrentDate();
    // ログローテートが必要な場合
    if (this._needsRotate(currentDate)) {
      // ローテート中フラグを設定する
      this.isRotating = true;
      // ログ出力日を現在日付に変更する
      this.logDate = currentDate;
      // ログファイル名を現在日付が末尾に付与されたものに変更する
      this.logFileName = `${this.appenderOptions.fileName}.${this.logDate}`;
      // ログはキャッシュ配列に追加しておく
      this.cachedLogEventsQueue.push(loggingEvent);
      // ローテート処理を呼ぶ
      this._rotateLogs()
        .then(() => {
          this.internalLogger.debug('ローテート処理完了・キャッシュログ出力開始');
          return this._putCachedLogs();
        })
        .then(() => {
          this.internalLogger.debug('キャッシュログ出力完了・ログローテート処理完了');
          // ローテート中フラグを戻す
          this.isRotating = false;
        })
        .catch((error) => {
          this.internalLogger.error('ログローテート処理に失敗しました。コンソール出力に切り替えます', error);
          // ローテート中フラグを戻す
          this.isRotating = false;
          // コンソール出力に切り替えるためのフラグを設定する
          this.isFatal = true;
          // キャッシュログをクリアする
          this.cachedLogEventsQueue.length = 0;
          // loggingEvent 内のレベルに応じてコンソール出力する
          this.internalLogger.log(loggingEvent.level, this.internalAppender.getLayout().format(loggingEvent));
        });
      return;
    }
    
    // 通常のログ出力
    this._putLog(loggingEvent);
  }
  
  /**
   * ログ出力を行う
   * Cordova のネイティブ連携処理 (ファイル操作含む) は非同期で処理されるので、実行順序を保証するため、クラス内に生成した Promise に繋げて処理する
   * 
   * @param {Object} log4javascript.LoggingEvent オブジェクト
   * @return {Promise} ファイル追記が成功した場合に Resolve される
   */
  _putLog(loggingEvent) {
    // 実行順序を把握するためのデバッグログ文字列
    let logStr = '';
    // この非同期処理の結果を返却するための Promise を用意する
    const deferred = this._$q.defer();
    // 設定済みの Promise 処理を実行後、then() 内で引数のログを出力する
    // 処理後の結果を自身に再代入することで直列化する
    this._promiseChain = this._promiseChain
      .then(() => {
        logStr += `ログ書込:[${loggingEvent.messages}]…`;
        return this._$cordovaFile.writeExistingFile(
          this.logDirectory,
          this.logFileName,
          this.getLayout()
          .format(loggingEvent),
          false);
      })
      .then(() => {
        logStr += `書込成功`;
        this.internalLogger.debug(logStr);
        deferred.resolve();
      })
      .catch((error) => {
        logStr += `書込失敗:[${error}]`;
        this.internalLogger.debug(logStr);
        deferred.reject(error);
      });
    return deferred.promise;
  }
  
  /**
   * キャッシュされているログを全て出力する
   *
   * @return {Promise} ログ出力処理を連結した Promise
   */
  _putCachedLogs() {
    let promiseChain = this._$q.promise;
    // キャッシュ配列がなくなるまで繰り返す
    while (this.cachedLogEventsQueue.length > 0) {
      // キャッシュ配列の先頭の要素を取り出す
      const loggingEvent = this.cachedLogEventsQueue.shift();
      promiseChain = promiseChain.then(() => {
        this.internalLogger.debug(`PromiseChain:[${loggingEvent.messages[0]}]`);
        return this._putLog(loggingEvent);
      });
    }
    return promiseChain;
  }
  
  /**
   * ログ出力中の日付より、現在日付の方が新しくなった場合は true を返却しログローテートが必要であることを知らせる
   * 
   * @param {Date} 現在日付
   * @return {Boolean} ログ出力中の日付より現在日付の方が新しい場合は true
   */
  _needsRotate(currentDate) {
    return (this.logDate < currentDate);
  }
  
  /**
   * ログのローテート処理を行う
   * 
   * - 不要になった古いログファイルを削除し、新たな日付のログファイルを生成する
   * - 古いファイルの削除処理に失敗した場合も、新たなログファイルの生成は行う
   * - ログファイルの生成に失敗した場合は、呼び出し元である append() にてコンソール出力に切り替える処理を行う
   * 
   * @return {Promise} ログファイル生成処理の結果を持った Promise
   */
  _rotateLogs() {
    // 古いログファイルを取得する
    return this._resolveLocalFiles()
      .then((removeFiles) => {
        // 削除処理を一括で実行する
        return this._$q.all(removeFiles);
      }, () => {
        // this._resolveLocalFiles() でのエラーを吸収する
        this.internalLogger.warn('古いログファイルの取得処理に失敗');
      })
      .then(() => {
        this.internalLogger.debug('古いログファイルの削除処理終了');
      }, () => {
        // this._$q.all() が失敗してもログファイル生成は行わせる
        this.internalLogger.warn('古いログファイルの削除処理中にエラー');
      })
      .then(() => {
        this.internalLogger.debug('ログファイル生成開始');
        // 当日日付のログファイルを生成する
        return this._$cordovaFile.createFile(this.logDirectory, this.logFileName, true);
      });
    // ログ生成に問題があった場合は呼び出し元の catch() にてコンソール出力に切り替える
  }
  
  /**
   * ログ出力先ディレクトリ配下のファイル一覧を取得し、削除対象ファイルを判定する _getOldFiles() を呼び出す
   *
   * @returns {Promise} 削除対象ファイルの収集処理結果を持った Promise
   */
  _resolveLocalFiles() {
    const deferred = this._$q.defer();
    this._$window.resolveLocalFileSystemURL(this.logDirectory, (fs) => {
      // ディレクトリ配下のファイル一覧を取得する
      const reader = fs.createReader();
      reader.readEntries((entries) => {
        // 削除対象のファイルを収集する
        const removeFiles = this._getOldFiles(entries);
        this.internalLogger.debug(`削除対象ファイル数:${removeFiles.length}`);
        // 削除するファイルの配列を格納して Resolve する
        deferred.resolve(removeFiles);
      }, (error) => {
        this.internalLogger.warn(`ファイル一覧取得に失敗・古いファイルの削除処理を中止:${error}`);
        deferred.reject(`ファイル一覧取得に失敗・古いファイルの削除処理を中止:${error}`);
      });
    }, (error) => {
      this.internalLogger.warn(`ディレクトリ情報取得に失敗・古いファイルの削除処理を中止:${error}`);
      deferred.reject(`ディレクトリ情報取得に失敗・古いファイルの削除処理を中止:${error}`);
    });
    return deferred.promise;
  }
  
  /**
   * 削除対象となる古いログファイルを判別し、そのファイルを削除する処理を配列に詰めて返却する
   * 
   * @param {Object} FileEntry の配列オブジェクト
   * @return {Array<Promise>} 削除対象ファイルの削除処理を格納した配列
   */
  _getOldFiles(entries) {
    // ログ出力日 'yyyyMMdd' から、現在の moment (日付) オブジェクトを作る
    const currentDate = moment(this.logDate, 'YYYYMMDD');
    // 対象のファイルを削除する Promise 処理を格納する配列
    const removeFiles = [];
    // FileEntry : isFile, isDirectory, name
    entries.forEach((entry) => {
      // ロギング中のログファイル名と同じ名前で始まるログファイルのみ対象にする
      if (!entry.isFile || !entry.name.startsWith(this.appenderOptions.fileName)) {
        this.internalLogger.debug(`対象外:[${entry.name}]`);
        return;
      }
      
      // ファイル名末尾の日付 'yyyyMMdd' 部分を取得する
      const fileDateStr = entry.name.slice(-8);
      // 取得した文字列から moment オブジェクトを作る
      const fileDate = moment(fileDateStr, 'YYYYMMDD');
      // 現在日付と比較し、差分の日数を算出する
      const diff = currentDate.diff(fileDate, 'days');
      
      // 指定のローテート期間を過ぎていれば削除対象とする
      if (diff > this.appenderOptions.rotateDays) {
        this.internalLogger.debug(`削除対象:[${entry.name}]`);
        // 指定のファイルを消す処理ごと配列に詰める
        removeFiles.push(this._$cordovaFile.removeFile(this.logDirectory, entry.name));
      } else {
        // ローテート期間内であれば削除しない
        this.internalLogger.debug(`削除対象外:[${entry.name}]`);
      }
    });
    return removeFiles;
  }
  
  /**
   * 本 Appender の文字列表現を返す
   *
   * @return {String} 本 Appender の文字列表現
   */
  toString() {
    return 'LocalFileAppender';
  }
}

/**
 * LocalFileAppender のインスタンスを返却するクラス
 */
class LocalFileAppenderFactory {
  /**
   * LocalFileAppender のインスタンスを返却する
   * インスタンス生成に必要な Angular サービスは、本 Factory を返却する Function より渡される
   * 
   * @param {Object} アペンダオプション
   * @return {Object} LocalFileAppender のインスタンス
   */
  static getInstance(options) {
    // インスタンスが生成済みならそれを返す
    if (LocalFileAppenderFactory.appender) {
      // オプション指定を反映させるため再度初期化処理を呼ぶ
      LocalFileAppenderFactory.appender.initAppenderOptions(options);
      LocalFileAppenderFactory.appender.initLogFile();
      return LocalFileAppenderFactory.appender;
    }
    
    // Function から本 Factory に持たせてあったサービスと共にインスタンス化する
    LocalFileAppenderFactory.appender = new LocalFileAppender(
      LocalFileAppenderFactory.$cordovaFile,
      LocalFileAppenderFactory.$q,
      LocalFileAppenderFactory.$window,
      options);
    return LocalFileAppenderFactory.appender;
  }
}

/**
 * LocalFileAppender のインスタンスを返却する Factory クラスを返却する
 * Angular サービスの DI のため、本 Function を Angular モジュールとして提供する
 * 
 * import localFileAppenderFactoryClassFactory from './localFileAppender';
 * module.factory('LOCAL_FILE_APPENDER', localFileAppenderFactoryClassFactory);
 * 
 * @param {Object} $cordovaFile ngCordova が提供するファイル操作プラグインの API
 * @param {Object} $q 非同期処理を行うためのサービス
 * @param {Object} $window File API を呼び出すために使用する
 */
export default function localFileAppenderFactoryClassFactory($cordovaFile, $q, $window) {
  // Factory に必要なサービスを持たせておく
  LocalFileAppenderFactory.$cordovaFile = $cordovaFile;
  LocalFileAppenderFactory.$q = $q;
  LocalFileAppenderFactory.$window = $window;
  return LocalFileAppenderFactory;
}
localFileAppenderFactoryClassFactory.$inject = ['$cordovaFile', '$q', '$window'];
import { Inject, Injectable } from '@angular/core';
import { File } from '@ionic-native/file';

import * as _ from 'lodash';
import * as log4javascript from 'log4javascript';
import * as moment from 'moment';

/**
 * window オブジェクトを返す関数
 */
function _window(): any {
  return window;
}

/**
 * Window の参照を返すクラス
 */
@Injectable()
export class WindowRefService {
  /**
   * window オブジェクトを返す
   *
   * @return window オブジェクト
   */
  get nativeWindow(): any {
    return _window();
  }
}



/**
 * アペンダオプションを設定するインターフェースクラス
 */
export interface LocalFileAppenderOptions {
  /** ファイル名 : 必須 */
  fileName: string;
  /** ローテート日数 */
  rotateDays?: number;
  /** レイアウト */
  layout?: string;
  /** 最低ログ出力レベル */
  threshold?: log4javascript.Level;
}

/**
 * アペンダオプションのデフォルト設定
 */
export const APPENDER_OPTIONS: LocalFileAppenderOptions = {
  fileName: 'MyLogger.log',
  rotateDays: 0,
  layout: '[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5p] %m',
  threshold: log4javascript.Level.INFO
};



/**
 * Angular4 向け・ログファイルを生成し、ログを追記する Appender
 * log4javascript の Appender を拡張しており、cordova-plugin-file プラグインおよび @ionic-native/file を使用してファイル操作を行う
 */
@Injectable()
export class LocalFileAppenderService extends log4javascript.Appender {
  /** アペンダオプション */
  appenderOptions: LocalFileAppenderOptions;
  /** ログ出力日 ('yyyyMMdd' 形式) */
  logDate: string;
  /** ログファイル名 */
  logFileName: string;
  /** ログ出力先ディレクトリ : @ionic-native/file より取得する */
  logDirectory: string;
  /** Promise チェーン : この Promise チェーンの then() に処理を追加していくことで、非同期処理を順番に処理させる */
  promiseChain: Promise<any>;
  
  /** キャッシュしているログを格納する配列。ログ出力が可能になったらこの配列内のログを出力する */
  cachedLogEventsQueue: Array<log4javascript.LoggingEvent> = [];
  /** ログ出力できない問題がある場合は true にするフラグ変数。true の場合はコンソールに出力する */
  isFatal: boolean = false;
  /** ログ出力の準備中は true にするフラグ変数。ログファイルの準備ができたら false にする */
  isGettingReady: boolean = true;
  /** ローテート作業中は true にするフラグ変数。ログファイルの準備ができたら false にする */
  isRotating: boolean = false;
  
  /** 内部ロガーのアペンダ */
  internalAppender: log4javascript.Appender;
  /** 内部ロガー */
  internalLogger: log4javascript.Logger;
  
  /**
   * コンストラクタ
   * 
   * @param file cordova-plugin-file プラグインをラップする @ionic-native/file クラス
   * @param appenderOptions アペンダオプション
   * @param windowRefSrv window の参照を返すサービス
   */
  constructor(protected file: File, @Inject(APPENDER_OPTIONS) appenderOptions: LocalFileAppenderOptions, protected windowRefSrv: WindowRefService) {
    super();
    
    // ログ出力先ディレクトリ
    this.logDirectory = this.file.dataDirectory;
    
    // 内部ロガーの準備
    this.internalAppender = this.createInternalAppender();
    this.internalLogger = this.createInternalLogger(this.internalAppender);
    
    // このクラス全体で使う Promise チェーンを用意する
    // この Promise チェーンの then() に行いたい処理を設定することで、非同期処理を順番に処理させる
    this.promiseChain = Promise.resolve();
    
    // 初期化処理呼び出し
    // アペンダオプションの設定処理
    this.initAppenderOptions(appenderOptions);
    // ログファイルの確認・生成処理
    this.initLogFile();
  }
  
  /**
   * 内部ロガーが使用する Appender を生成し返却する
   * append() メソッドにて、コンソールにログ出力する際にレイアウト設定するためにも使用する
   * 
   * @return 出力レベル、レイアウトを設定した BrowserConsoleAppender オブジェクト
   */
  createInternalAppender(): log4javascript.Appender {
    const internalAppender = new log4javascript.BrowserConsoleAppender();
    internalAppender.setThreshold(log4javascript.Level.WARN);
    internalAppender.setLayout(new log4javascript.PatternLayout('[%d{yyyy-MM-dd HH:mm:ss.SSS}] [my-logger] [%-5p] %m%n'));
    return internalAppender;
  }
  
  /**
   * 本 Appender クラスの内部で使用する、コンソール出力用のロガーを生成し返却する
   * デバッグログやエラーログの出力の他、ログファイルへの出力が不可能と判断した場合にログをコンソールへ出力するためにも使用する
   * 
   * @param appender 内部ロガーに適用するコンソール出力アペンダ
   * @return コンソール出力用のロガーオブジェクト
   */
  createInternalLogger(appender: log4javascript.Appender): log4javascript.Logger {
    const internalLogger = log4javascript.getLogger('my-logger.internal');
    internalLogger.addAppender(appender);
    return internalLogger;
  }
  
  /**
   * 本 Appender クラスの各種設定を行う
   * 
   * @param appenderOptions ユーザ指定のアペンダオプション
   */
  initAppenderOptions(appenderOptions: LocalFileAppenderOptions): void {
    // デフォルトのオプションに対しユーザ指定のオプションをマージする
    const mergedAppenderOptions = _.merge({}, APPENDER_OPTIONS, appenderOptions);
    
    // ファイル名がない場合、もしくはローテート期間が 0 未満など不正値の場合は NG とする
    if (!mergedAppenderOptions.fileName || mergedAppenderOptions.rotateDays < 0) {
      throw new Error('アペンダオプションの設定が不正です');
    }
    
    // アペンダオプションを設定する
    this.appenderOptions = mergedAppenderOptions;
    
    // ログ出力対象日付 (当日日付) を yyyyMMdd の形式で取得・設定する
    this.logDate = this.getCurrentDate();
    // 当日日付のログファイルの名前 (fileName.log.yyyyMMdd) を設定する
    this.logFileName = `${this.appenderOptions.fileName}.${this.logDate}`;
    // 出力レベル : log4javascript.Level クラスの定数で指定
    this.setThreshold(this.appenderOptions.threshold);
    // レイアウト : 指定のレイアウト文字列の末尾に改行コードを付与する
    this.setLayout(new log4javascript.PatternLayout(`${this.appenderOptions.layout}%n`));
  }
  
  /**
   * ログを出力するファイルの生成処理を行う
   * 
   * - ログファイルが既に存在する場合は生成処理を行わない
   * - ログファイルの準備が完了次第、キャッシュ配列に格納されているログを出力する
   * - 一連の処理中に問題があった場合はファイル出力を止め、コンソール出力に切り替える
   */
  initLogFile(): void {
    // ログファイルの存在確認
    this.file.checkFile(this.logDirectory, this.logFileName)
      .then((/* isExists: boolean */) => {
        this.internalLogger.debug('ログファイル生成済');
        // ログファイルが既に生成されているので準備中フラグを false にする
        this.isGettingReady = false;
      }, () => {
        this.internalLogger.debug('ログファイル未生成・生成処理開始');
        return this.file.createFile(this.logDirectory, this.logFileName, true)
          .then(() => {
            this.internalLogger.debug('ログファイル生成完了');
            // ログファイルの生成が完了したら準備中フラグを false にする
            this.isGettingReady = false;
          });
      })
      .then(() => {
        this.internalLogger.debug('ログファイル準備完了・キャッシュログの出力開始');
        this.putCachedLogs();
      })
      .catch((error) => {
        this.internalLogger.warn('ログファイル準備中にエラーが発生しました。コンソール出力に切り替えます', error);
        // ファイル出力不可能のフラグを設定し、コンソール出力させるようにする
        this.isFatal = true;
      });
  }
  
  /**
   * 現在日付を 'yyyyMMdd' の形式で取得し返却する
   *
   * @return 'yyyyMMdd' の形式にした現在日付
   */
  getCurrentDate(): string {
    return moment().format('YYYYMMDD');
  }
  
  /**
   * log4javascript.Appender より継承するログの追記メソッド
   *
   * @param loggingEvent LoggingEvent オブジェクト
   */
  append(loggingEvent: log4javascript.LoggingEvent): void {
    // ログ出力できない場合はキャッシュ配列をコンソールに出力する
    if (this.isFatal) {
      this.cachedLogEventsQueue.length = 0;
      // loggingEvent 内のレベルに応じて出力する
      this.internalLogger.log(loggingEvent.level, this.internalAppender.getLayout().format(loggingEvent) as any);
      return;
    }
    
    // アペンダ準備中・ローテート中の場合はキャッシュ配列に追加する
    if (this.isGettingReady || this.isRotating) {
      this.cachedLogEventsQueue.push(loggingEvent);
      return;
    }
    
    // 現在日付を再取得する
    const currentDate = this.getCurrentDate();
    // ログローテートが必要な場合
    if (this.needsRotate(currentDate)) {
      // ローテート中フラグを設定する
      this.isRotating = true;
      // ログ出力日を現在日付に変更する
      this.logDate = currentDate;
      // ログファイル名を現在日付が末尾に付与されたものに変更する
      this.logFileName = `${this.appenderOptions.fileName}.${this.logDate}`;
      // ログはキャッシュ配列に追加しておく
      this.cachedLogEventsQueue.push(loggingEvent);
      // ローテート処理を呼ぶ
      this.rotateLogs()
        .then(() => {
          this.internalLogger.debug('ローテート処理完了・キャッシュログ出力開始');

          return this.putCachedLogs();
        })
        .then(() => {
          this.internalLogger.debug('キャッシュログ出力完了・ログローテート処理完了');
          // ローテート中フラグを戻す
          this.isRotating = false;
        })
        .catch((error) => {
          this.internalLogger.error('ログローテート処理に失敗しました。コンソール出力に切り替えます', error);
          // ローテート中フラグを戻す
          this.isRotating = false;
          // コンソール出力に切り替えるためのフラグを設定する
          this.isFatal = true;
          // キャッシュログをクリアする
          this.cachedLogEventsQueue.length = 0;
          // loggingEvent 内のレベルに応じてコンソール出力する
          this.internalLogger.log(loggingEvent.level, this.internalAppender.getLayout().format(loggingEvent) as any);
        });
      return;
    }
    
    // 通常のログ出力
    this.putLog(loggingEvent);
  }
  
  /**
   * ログ出力を行う
   * Cordova のネイティブ連携処理 (ファイル操作含む) は非同期で処理されるので、実行順序を保証するため、クラス内に生成した Promise に繋げて処理する
   *
   * @param loggingEvent LoggingEvent オブジェクト
   * @return ファイル追記が成功した場合に Resolve される
   */
  putLog(loggingEvent: log4javascript.LoggingEvent): Promise<void> {
    // 実行順序を把握するためのデバッグログ文字列
    let logStr = '';
    // この非同期処理の結果を返却するための Promise を用意する
    return new Promise<void>((resolve, reject) => {
      // 設定済みの Promise 処理を実行後、then() 内で引数のログを出力する
      // 処理後の結果を自身に再代入することで直列化する
      this.promiseChain = this.promiseChain
        .then(() => {
          logStr += `ログ書込:[${loggingEvent.messages}]…`;
          return this.file.writeExistingFile(
            this.logDirectory,
            this.logFileName,
            this.getLayout().format(loggingEvent));
        })
        .then(() => {
          logStr += `書込成功`;
          this.internalLogger.debug(logStr);
          resolve();
        })
        .catch((error) => {
          logStr += `書込失敗:[${error}]`;
          this.internalLogger.debug(logStr);
          reject(error);
        });
    });
  }

  /**
   * キャッシュされているログを全て出力する
   *
   * @return ログ出力処理を連結した Promise
   */
  putCachedLogs(): Promise<void> {
    let promiseChain = Promise.resolve();
    // キャッシュ配列がなくなるまで繰り返す
    while (this.cachedLogEventsQueue.length > 0) {
      // キャッシュ配列の先頭の要素を取り出す
      const loggingEvent = this.cachedLogEventsQueue.shift();
      promiseChain = promiseChain.then(() => {
        this.internalLogger.debug(`PromiseChain:[${loggingEvent.messages[0]}]`);
        return this.putLog(loggingEvent);
      });
    }
    return promiseChain;
  }
  
  /**
   * ログ出力中の日付より、現在日付の方が新しくなった場合は true を返却し、ログローテートが必要であることを知らせる
   *
   * @param currentDate 現在日付
   * @return ログ出力中の日付より現在日付の方が新しい場合は true
   */
  needsRotate(currentDate: string): boolean {
    return (this.logDate < currentDate);
  }
  
  /**
   * ログのローテート処理を行う
   * 
   * - 不要になった古いログファイルを削除し、新たな日付のログファイルを生成する
   * - 古いファイルの削除処理に失敗した場合も、新たなログファイルの生成は行う
   * - ログファイルの生成に失敗した場合は、呼び出し元である append() にてコンソール出力に切り替える処理を行う
   * 
   * @return ログファイル生成処理の結果を持った Promise
   */
  rotateLogs(): Promise<any> {
    // 古いログファイルを取得する
    return this.resolveLocalFiles()
      .then((removeFiles) => {
        // 削除処理を一括で実行する
        return Promise.all(removeFiles);
      }, () => {
        // this._resolveLocalFiles() でのエラーを吸収する
        this.internalLogger.warn('古いログファイルの取得処理に失敗');
      })
      .then(() => {
        this.internalLogger.debug('古いログファイルの削除処理終了');
      }, () => {
        // Promise.all() が失敗してもログファイル生成は行わせる
        this.internalLogger.warn('古いログファイルの削除処理中にエラー');
      })
      .then(() => {
        this.internalLogger.debug('ログファイル生成開始');
        // 当日日付のログファイルを生成する
        return this.file.createFile(this.logDirectory, this.logFileName, true);
      });
    // ログ生成に問題があった場合は呼び出し元の catch() にてコンソール出力に切り替える
  }
  
  /**
   * ログ出力先ディレクトリ配下のファイル一覧を取得し、削除対象ファイルを判定する getOldFiles() を呼び出す
   *
   * @return 削除対象ファイルの収集処理結果を持った Promise
   */
  resolveLocalFiles(): Promise<Array<Promise<any>>> {
    return new Promise((resolve, reject) => {
      // window.resolveLocalFileSystemURL の定義がないため回避する
      this.windowRefSrv.nativeWindow.resolveLocalFileSystemURL(this.logDirectory, (fs) => {
        // ディレクトリ配下のファイル一覧を取得する
        const reader = fs.createReader();
        reader.readEntries((entries: Array<any>) => {
          // 削除対象のファイルを収集する
          const removeFiles = this.getOldFiles(entries);
          this.internalLogger.debug(`削除対象ファイル数:${removeFiles.length}`);
          // 削除するファイルの配列を格納して Resolve する
          resolve(removeFiles);
        }, (error) => {
          this.internalLogger.warn(`ファイル一覧取得に失敗・古いファイルの削除処理を中止:${error}`);
          reject(`ファイル一覧取得に失敗・古いファイルの削除処理を中止:${error}`);
        });
      }, (error) => {
        this.internalLogger.warn(`ディレクトリ情報取得に失敗・古いファイルの削除処理を中止:${error}`);
        reject(`ディレクトリ情報取得に失敗・古いファイルの削除処理を中止:${error}`);
      });
    });
  }
  
  /**
   * 削除対象となる古いログファイルを判別し、そのファイルを削除する処理を配列に詰めて返却する
   *
   * @param entries FileEntry の配列オブジェクト
   * @return 削除対象ファイルの削除処理を格納した配列
   */
  getOldFiles(entries: Array<any>): Array<Promise<any>> {
    // ログ出力日 'yyyyMMdd' から、現在の moment (日付) オブジェクトを作る
    const currentDate = moment(this.logDate, 'YYYYMMDD');
    // 対象のファイルを削除する Promise 処理を格納する配列
    const removeFiles = [];
    // FileEntry : isFile, isDirectory, name
    entries.forEach((entry) => {
      // ロギング中のログファイル名と同じ名前で始まるログファイルのみ対象にする
      if (!entry.isFile || !entry.name.startsWith(this.appenderOptions.fileName)) {
        this.internalLogger.debug(`対象外:[${entry.name}]`);
        return;
      }
      
      // ファイル名末尾の日付 'yyyyMMdd' 部分を取得する
      const dateStrSlice = -8;
      const fileDateStr = entry.name.slice(dateStrSlice);
      // 取得した文字列から moment オブジェクトを作る
      const fileDate = moment(fileDateStr, 'YYYYMMDD');
      // 現在日付と比較し、差分の日数を算出する
      const diff = currentDate.diff(fileDate, 'days');
      
      // 指定のローテート期間を過ぎていれば削除対象とする
      if (diff > this.appenderOptions.rotateDays) {
        this.internalLogger.debug(`削除対象:[${entry.name}]`);
        // 指定のファイルを消す処理ごと配列に詰める
        removeFiles.push(this.file.removeFile(this.logDirectory, entry.name));
      } else {
        // ローテート期間内であれば削除しない
        this.internalLogger.debug(`削除対象外:[${entry.name}]`);
      }
    });
    return removeFiles;
  }
  
  /**
   * 本 Appender の文字列表現を返す
   *
   * @return 本 Appender の文字列表現
   */
  toString(): string {
    return 'LocalFileAppender';
  }
}

/**
 * プロバイダのエクスポート設定
 * 
 * @NgModule({
 *   providers: [LOCAL_FILE_APPENDER_PROVIDERS]
 * })
 */
export const LOCAL_FILE_APPENDER_PROVIDERS = [
  {
    provide: LocalFileAppenderService,
    useClass: LocalFileAppenderService
  },
  {
    provide: APPENDER_OPTIONS,
    useValue: APPENDER_OPTIONS
  }
];