MFC/C++アプリにログ機能を実装する方法|spdlogでログローテーション・30日削除・ログレベル管理

MFCで作ったC++デスクトップアプリを運用すると、障害調査やユーザーサポートで必ず必要になるのがログです。ところが単純なファイル追記だけでは、出力先の切り替え、日付付きファイル名、サイズローテーション、30日超の自動削除、ログレベル管理まで一気に難易度が上がります。この記事ではspdlogを軸に、実装と運用のコツを具体例つきでまとめます。

目次

結論:MFC固有ではなく「C++アプリのログ設計」をどう組み込むか

MFCアプリにログ機能を入れたい、と聞くと「MFCのAPIで何とかできるのでは?」と思いがちですが、実態はC++アプリ共通の設計課題です。MFCはUIやメッセージループを提供してくれますが、ログローテーションや保持期間、レベル制御は標準で面倒を見てくれません。

もちろん標準ライブラリ(<fstream>)だけでもログの追記はできます。しかし、今回の要件のように「日付付きファイル名」「30,000MB超でのローテーション」「起動時の30日超削除」「Error/Info/Debugの使い分け」まで含めると、ファイル運用まで含んだ仕組みが必要になります。

要件を先に表に落とし込み、実装の迷いを減らす

ログ実装は、機能追加よりも「運用で困らないこと」が重要です。まずは要件をC++の実装要素に分解しておきます。

要件実装上の論点おすすめの解決策
ログファイルの出力先ディレクトリを設定したい書き込み権限、既定パス、フォルダ作成、設定値の読み込み設定(INI/レジストリ/JSON等)から取得し、std::filesystem::create_directoriesで作成
ファイル名形式:2025-03-06-LogFile.log のように日付付き日付文字列生成、日付が変わったときの扱い起動時に当日名を組み立て、24時間稼働なら日付変更で再生成
ログが30,000MB(約30GB)を超えたら新しいファイルに切り替え閾値(バイト)換算、ローテーション時の命名、保持上限、32bit/64bit差spdlogのサイズローテーションを利用(30GBならx64ビルド推奨)
アプリ起動時に、30日より古いログファイルは削除したいファイル走査、削除対象の判定、削除失敗時の扱い、Windowsのファイルロックstd::filesystemでログフォルダを走査し、更新日時またはファイル名日付で削除
ログレベル(Error/Debug/Info)を使い分けたい普段の出力量、障害時の切り替え、性能への影響、情報漏えいリスクspdlogのレベル機能+設定値で動的変更(必要ならReleaseでDebugを無効化)

自作するか、ライブラリを使うか:判断基準は「運用コスト」

ログは一度入れると、障害対応や監査などで長期にわたって使い続けます。そのため実装の手間よりも運用の安定性と保守性が重要です。

自作でつまずきやすいポイントは次の通りです。

  • マルチスレッド環境での排他制御(ログの行が混ざる、クラッシュする)
  • ログ出力がボトルネックになってUIが固まる(I/O待ち)
  • ローテーション中のファイルハンドル管理(開きっぱなし、ロック競合)
  • ファイル削除・権限・パス文字コードなどWindows特有の罠

今回の要件は「よくあるが罠が多い」タイプなので、実績のあるログライブラリを採用するのが現実的です。代表例がspdlogで、ログレベルやサイズローテーションが標準で整っています。

spdlogで作る全体像:初期化→利用→終了処理を一本化する

ログを安定させるコツは、アプリのあちこちで勝手にファイルを開かないことです。初期化を1か所に集約し、利用側は「書くだけ」にすると、将来の変更(出力先変更、レベル変更、出力形式変更)に強くなります。

アプリ起動(InitInstanceなど)
  ├─ ログディレクトリ決定・作成
  ├─ 古いログ削除(30日超)
  ├─ 当日ファイル名を生成
  └─ spdlogロガー生成(サイズローテーション+レベル設定)
        ↓
各処理から logger->info()/debug()/error() を呼ぶ
        ↓
アプリ終了(ExitInstanceなど)
  └─ flush/shutdown

spdlogのローテーション方式:サイズ/日次をどう使い分けるか

spdlogには主に「サイズで回す」「時刻(日次)で回す」という定番のローテーションが用意されています。要件の組み合わせで迷いやすいので、最初に整理しておくと楽です。

方式向いているケースメリット注意点
サイズローテーションログ量が日によって変動、サイズ上限を絶対に守りたい「何MB超で切り替え」が素直に実現できるローテーション後の命名が世代番号(.1等)になる
日次ローテーション日付単位で管理したい、監査や日報的に見たい毎日決まった時刻で新ファイルになり、運用が分かりやすい「日付プレフィックス」など命名規則を厳密に合わせたいときは工夫が必要

今回の要件は「ファイル名に日付を入れる」かつ「30GB超で切り替える」なので、実装としては日付をファイル名に埋め込んだうえでサイズローテーションが最もシンプルです(後半で日付変更時の切り替え案も紹介します)。

参考:spdlogの日次ローテーション例(命名規則が合う場合)

auto logger = spdlog::daily_logger_mt(
    "daily_logger",
    "logs/daily-log.txt",
    2, 30 // 毎日 2:30 に切り替え
);

spdlogの導入方法(Visual Studio向け)

spdlogは「ヘッダオンリーでも使える」「パッケージ管理で入れやすい」という点が強みです。MFCプロジェクトでも、導入方法を決めておくとチーム開発が安定します。

導入方法メリットデメリットこんなチームにおすすめ
vcpkgで管理再現性が高い、依存関係が楽、CIに乗せやすい開発環境のセットアップが必要複数人で開発、環境差分を潰したい
ソースを同梱(サブモジュール等)外部ツールが不要、プロジェクト単体で完結更新手順が属人化しやすい小規模、固定バージョンで運用したい

vcpkgでの例

vcpkgを使う場合、次のような流れになります(環境に合わせてx86/x64を選んでください)。

vcpkg integrate install
vcpkg install spdlog:x64-windows

導入後は、Visual Studioのインクルード/ライブラリ設定が統合されるため、MFCプロジェクト側は通常通り#include <spdlog/spdlog.h>ができるようになります。

出力先ディレクトリの設計:まず「書ける場所」を選ぶ

Windowsのデスクトップアプリでは、実行ファイルと同じ場所(Program Files配下など)に書けないケースが多いです。運用で困りにくい定番は次の3つです。

候補例向いている用途注意点
ユーザーごとのログ%LOCALAPPDATA%\\Company\\App\\logs一般的な業務アプリ、権限トラブルを避けたいユーザー切り替えでログが分散する
端末共通ログ%PROGRAMDATA%\\Company\\App\\logsサービス連携、端末運用で一括回収したい環境によっては作成時に権限が必要
実行ファイル相対.\\logsポータブル運用、開発中の簡易運用配置場所によっては書けない

MFCアプリなら、設定値の持たせ方は「INI」「レジストリ(MFCのプロファイル機能)」「JSON」など自由です。重要なのは、設定→ディレクトリ作成→ロガー生成の順にすることです。

ディレクトリ作成の例(C++17)

#include <filesystem>
#include <stdexcept>

namespace fs = std::filesystem;

fs::path EnsureLogDir(const fs::path& dir)
{
std::error_code ec;
fs::create_directories(dir, ec);
if (ec)
{
throw std::runtime_error("ログディレクトリを作成できません: " + dir.string());
}
return dir;
}

ファイル名を「YYYY-MM-DD-LogFile.log」にする方法

要件のように日付を先頭に付けたい場合は、起動時に日付文字列を作ってファイル名を組み立てるのが分かりやすいです。日次ローテーションの仕組みを使う手もありますが、命名規則を厳密に合わせたいときは自分で組み立てた方が確実です。

当日の日付文字列を作る例(Windows/C++17)

#include <chrono>
#include <ctime>
#include <string>

std::string TodayYYYYMMDD()
{
using namespace std::chrono;


const auto now = system_clock::now();
const std::time_t t = system_clock::to_time_t(now);

std::tm tm{};
localtime_s(&tm, &t); // Windows
char buf[11]{};
std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm);
return std::string(buf);


}

この関数を使って、次のようにファイル名を組み立てます。

#include <filesystem>

std::filesystem::path BuildLogPath(const std::filesystem::path& logDir)
{
const std::string fileName = TodayYYYYMMDD() + "-LogFile.log";
return logDir / fileName;
}

注意点として、日付をファイル名に埋め込む方式は「起動中に日付が変わる」ケースでは、深夜を跨いでも同じファイルに書き続けます。24時間以上起動しっぱなしのアプリなら、深夜にファイルを切り替える設計も検討します(後述)。

サイズローテーション(30,000MB)をspdlogで実装する

spdlogにはサイズローテーション付きのファイルシンク(sink)が用意されています。ポイントは「MBをバイトに直すこと」と「保持するローテーション数(max_files)を決めること」です。

30,000MBの換算は、仕様の書き方に合わせて次のどちらかを選ぶと混乱しません。

  • 10進(1MB=1,000,000バイト)で厳密に30,000MB → 30,000,000,000バイト
  • 2進(1MiB=1,048,576バイト)で扱う → 30,000×1,048,576バイト

ここでは仕様表記に合わせやすい10進で設定します。

spdlogのサイズローテーション例(ファイル名は当日固定)

#include <cstddef>
#include <cstdint>
#include <memory>
#include <spdlog/spdlog.h>
#include <spdlog/sinks/rotating_file_sink.h>

std::shared_ptr CreateRotatingLogger(const std::filesystem::path& logPath)
{
// 30,000MB = 30,000,000,000 bytes (10進)
constexpr std::uint64_t max_size64 = 30000ULL * 1000ULL * 1000ULL;


// 注意:32bitビルド(size_tが32bit)だと30GBは表現できません
static_assert(sizeof(std::size_t) >= 8, "30GBローテーションにはx64ビルドが必要です");

const std::size_t max_size  = static_cast<std::size_t>(max_size64);
const std::size_t max_files = 5; // 例:当日分のローテーションを最大5世代まで保持

auto sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
    logPath.string(),
    max_size,
    max_files,
    true // rotate_on_open: 起動時にサイズ超過なら即ローテーション
);

auto logger = std::make_shared<spdlog::logger>("app_logger", sink);
spdlog::register_logger(logger);
return logger;


}

ローテーション後のファイル名は、基本的に元のファイル名に世代番号が付く形(例:2025-03-06-LogFile.log.1)になります。要件が「サイズ超で新しいファイルに切り替え」であれば、この挙動で十分なことが多いです。

30GB設定での重要な注意として、昔からのMFCアプリは32bit(x86)で残っていることがあります。spdlogのサイズ指定は内部的にsize_tを使うため、32bitビルドでは4GB超の閾値が扱えません。要件通り30GBでローテーションしたいなら、x64ビルド(64bit)へ移行するのが現実的です。

また、30GBはかなり大きいので、

  • ログ回収(サポートへの送付)が大変
  • ディスク逼迫に気づきにくい
  • バックアップやウイルス対策のスキャン負荷が上がる

といった副作用が出ます。要件として30GBが必須でないなら、数百MB〜数GBに下げて世代数を増やす方が、現場で扱いやすいことが多いです。

起動時に「30日より古いログ」を削除する(std::filesystem)

保持期間で削除する仕組みは、spdlogだけではカバーしにくい領域です。ここは割り切って、アプリ起動時にログフォルダを走査して削除するのが実装も理解もシンプルです。

削除ロジックは「安全に」「失敗してもアプリが落ちない」ことが重要です。おすすめは次の方針です。

  • 対象フォルダはログ専用にする(他ファイルを混ぜない)
  • 削除対象は「このアプリのログ」だけに絞る(ファイル名パターンで判定)
  • 削除できない場合は例外で落とさず、警告ログを残す(次回起動で再挑戦できる)

特に重要なのが「ローテーション後のファイル名」です。サイズローテーションでは...LogFile.log.1のように末尾が変わるため、単純に拡張子が.logのものだけを削除対象にすると、ローテーション世代が残り続ける可能性があります。ここではファイル名に-LogFile.logを含むものを対象にしています。

30日超のログを削除する例(C++17)

#include <filesystem>
#include <chrono>
#include <string>

namespace fs = std::filesystem;

static std::chrono::system_clock::time_point ToSysTime(fs::file_time_type ft)
{
using namespace std::chrono;
const auto now_ft  = fs::file_time_type::clock::now();
const auto now_sys = system_clock::now();
return time_point_cast(ft - now_ft + now_sys);
}

void DeleteLogsOlderThan(const fs::path& logDir, int days)
{
using namespace std::chrono;


if (!fs::exists(logDir)) return;

const auto limit = system_clock::now() - hours(24 * days);

std::error_code ec;
for (const auto& e : fs::directory_iterator(logDir, ec))
{
    if (ec) break;
    if (!e.is_regular_file()) continue;

    const auto& p = e.path();
    const std::string name = p.filename().string();

    // 例:2025-03-06-LogFile.log / 2025-03-06-LogFile.log.1 の両方を対象にする
    if (name.find("-LogFile.log") == std::string::npos) continue;

    std::error_code ec2;
    const auto ft = fs::last_write_time(p, ec2);
    if (ec2) continue;

    const auto st = ToSysTime(ft);
    if (st < limit)
    {
        fs::remove(p, ec2); // 削除失敗は無視(必要なら別途記録)
    }
}


}

この削除処理は、ロガー生成より先に実行するのが基本です。ロガーがファイルを開いた後だと削除できない(Windowsのファイルロック)ケースがあるためです。

より厳密にやるなら「ファイル名の日付」で判定する

更新日時(last_write_time)での判定は実装が簡単ですが、ログファイルを別場所にコピーした場合などに日付がズレることがあります。運用で「ファイル名の先頭の日付が30日より古いものを削除」と決めているなら、YYYY-MM-DDをパースして判定する方が説明がしやすく、結果も安定します。

ログレベル(Error/Info/Debug)を設計して「出しすぎ」を防ぐ

ログは「多ければ安心」ではありません。必要な情報が埋もれるのが最大の敵です。Error/Info/Debugを明確に使い分けるだけで、調査効率が大きく変わります。

レベル主な用途書くべき内容の例書かない方がよい例
Error障害調査の入口例外内容、エラーコード、復旧可否、ユーザー影響頻繁に起きる想定内の分岐
Info運用の流れを追う起動/終了、設定読み込み結果、主要処理の開始/完了ループ内の細かい進捗
Debug再現が難しい不具合の掘り下げ入力値、内部状態、分岐理由個人情報・機密情報、巨大データの丸ごと出力

spdlogでレベルを設定し、出し分ける例

// 例:Info以上のみ出す(Debugは抑制)
logger->set_level(spdlog::level::info);

// 書き分け
logger->info("処理開始: userId={}", userId);
logger->debug("内部状態: x={}, y={}", x, y);
logger->error("失敗しました: code={}, msg={}", code, msg);

実運用では「普段はInfo」「トラブル時だけDebugに上げる」が鉄板です。設定ファイルやレジストリでレベルを変更できるようにしておくと、再ビルドなしで調査できます。

MFCアプリに組み込む具体例:InitInstanceで初期化し、ExitInstanceで終了

MFCアプリなら、ロガーのライフサイクルはCWinAppに寄せるのが分かりやすいです。起動時に初期化し、終了時にフラッシュして閉じます。

Loggerラッパー(例)

#pragma once
#include <memory>
#include <filesystem>
#include <spdlog/logger.h>

class AppLogger
{
public:
static void Init(const std::filesystem::path& logDir);
static std::shared_ptr Get();
static void Shutdown();

private:
static std::shared_ptr s_logger;
};
#include "AppLogger.h"
#include <cstddef>
#include <cstdint>
#include <vector>

#include <spdlog/spdlog.h>
#include <spdlog/sinks/rotating_file_sink.h>
#include <spdlog/sinks/msvc_sink.h>

std::shared_ptr<spdlog::logger> AppLogger::s_logger;

void AppLogger::Init(const std::filesystem::path& logDir)
{
    // 0) ログフォルダがなければ作る
    EnsureLogDir(logDir);

    // 1) 先に古いログ削除(ロガーがファイルを掴む前に)
    DeleteLogsOlderThan(logDir, 30);

    // 2) 当日のログパスを決定(YYYY-MM-DD-LogFile.log)
    const auto logPath = BuildLogPath(logDir);

    // 3) sinkを組み立て(ファイル + Visual Studio出力)
    constexpr std::uint64_t max_size64 = 30000ULL * 1000ULL * 1000ULL;
    static_assert(sizeof(std::size_t) >= 8, "30GBローテーションにはx64ビルドが必要です");

    const std::size_t max_size  = static_cast<std::size_t>(max_size64);
    const std::size_t max_files = 5;

    auto file_sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
        logPath.string(), max_size, max_files, true);

    auto vs_sink = std::make_shared<spdlog::sinks::msvc_sink_mt>();

    std::vector<spdlog::sink_ptr> sinks{ file_sink, vs_sink };
    s_logger = std::make_shared<spdlog::logger>("app_logger", sinks.begin(), sinks.end());

    // 4) 書式・レベル
    s_logger->set_pattern("%Y-%m-%d %H:%M:%S.%e [%l] [tid %t] %v");
    s_logger->set_level(spdlog::level::info);
    s_logger->flush_on(spdlog::level::err);

    spdlog::register_logger(s_logger);

    s_logger->info("ログ初期化完了: path={}", logPath.string());
}

std::shared_ptr<spdlog::logger> AppLogger::Get()
{
    return s_logger;
}

void AppLogger::Shutdown()
{
    if (s_logger)
    {
        s_logger->flush();
    }
    spdlog::shutdown();
    s_logger.reset();
}

InitInstance / ExitInstanceの呼び出しイメージ

BOOL CMyApp::InitInstance()
{
    // ... MFC初期化 ...


const auto logDir = std::filesystem::path(".") / "logs"; // 実運用ではLOCALAPPDATAなど推奨
AppLogger::Init(logDir);

AppLogger::Get()->info("アプリ起動");
return TRUE;


}

int CMyApp::ExitInstance()
{
if (AppLogger::Get())
{
AppLogger::Get()->info("アプリ終了");
}
AppLogger::Shutdown();
return CWinApp::ExitInstance();
}

上の例では相対パス(.\\logs)を使っていますが、実運用では前述の%LOCALAPPDATA%などに切り替えるのがおすすめです。

日付+サイズの「両方」を満たしたい場合の実装パターン

要件が「日付付きファイル名」かつ「サイズ超で新ファイル」だと、日付ローテーションとサイズローテーションを併用したくなります。spdlogは標準で「日次」または「サイズ」のどちらかを選ぶ設計が基本なので、組み合わせる場合は設計を一段だけ工夫します。

現実的で壊れにくいアプローチ

  • ファイル名に日付を埋め込んだうえで、サイズローテーションをかける
    → 当日分は2025-03-06-LogFile.log、溢れたら...log.1のように世代が増える
  • 日付が変わったタイミングでロガーを作り直す
    → 24時間稼働するアプリで有効。タイマーや処理の節目で日付をチェックして切り替える

「日付変更で自動切り替え」を実現する場合、ログ出力のたびに日付をチェックすると無駄が大きいので、次のような設計が扱いやすいです。

  • 最後に作成した日付(YYYY-MM-DD)を保持
  • 1分に1回程度の軽いタイマーで日付だけ確認
  • 変わっていたらロガーを再初期化(古いログ削除もついでに実行)

この方式なら「深夜0時を跨いだら翌日ファイルへ」「30GB超で当日内で世代分割」を両立できます。

パフォーマンスと安全性を上げる運用テクニック

ログはアプリの生命線ですが、出し方を間違えると逆に障害の原因になります。運用で効きやすいポイントをまとめます。

フラッシュ方針を決める

障害時にログが失われるのを避けたいなら、Errorは即フラッシュが無難です。逆にInfoまで毎回フラッシュするとI/Oが増えます。よく使われるのが「Error以上でフラッシュ+定期フラッシュ」です。

logger->flush_on(spdlog::level::err);
spdlog::flush_every(std::chrono::seconds(1));

ログに入れるべき識別子を固定する

調査で効くのは、メッセージ本文よりメタ情報です。最低でも次は入れておくと、現場での再現・切り分けが早くなります。

  • タイムスタンプ(ミリ秒まで)
  • レベル(INFO/ERRORなど)
  • スレッドID(MFCはUIスレッドとワーカースレッドが混在しやすい)
  • 処理単位のID(リクエストID、ジョブID、ファイル名など)

Debugログを「ビルドで無効化」する選択肢も用意する

「普段はDebugを出さない」だけでなく、「ReleaseビルドではDebug呼び出し自体を消したい」場合は、spdlogのアクティブレベルをビルド設定で切り替える運用が効きます。大量ログで性能が落ちるタイプのアプリほど、最初から逃げ道を作っておくと安心です。

個人情報・機密情報をログに出さないルールを作る

ログは便利ですが、出し方を誤ると情報漏えいリスクになります。特に業務アプリでは、氏名・住所・メール・トークンなどをそのまま出力しないルールを決め、必要ならマスク(例:末尾4桁だけ)します。

よくあるトラブルと対処

症状原因の候補対処
ログが出ないディレクトリ権限、パスが存在しない、レベルが高すぎる初期化直後にテスト出力し、ディレクトリ作成エラーは例外やメッセージで可視化
ファイル削除に失敗するロガーが先にファイルを開いている、他プロセスが掴んでいる削除はロガー生成前に実行。失敗は警告として残し、次回起動で再挑戦
UIが重くなる大量ログでI/O待ち、頻繁なフラッシュInfo以下はフラッシュしない、Debugを普段は無効、必要なら非同期ロガーを検討
日本語パスで失敗する文字コード、ANSI/Unicodeの不整合まずASCIIのみのパスを選ぶ。必要ならワイド文字対応の設定やパス変換を検討

まとめ:要件が多いほど「設計の型」を作ると楽になる

  • MFCの問題というより、C++アプリのログ運用設計の問題として捉える
  • サイズローテーションとログレベルはspdlogで素直に実装できる(30GBならx64ビルドが前提)
  • 30日超削除はstd::filesystemで起動時に実行するのが分かりやすい(ローテーション世代も対象に含める)
  • 初期化を1か所に集約し、利用側は「書くだけ」にして保守性を上げる

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次