shikoan’s memo

プログラミング初心者のチラ裏

ぷろぐらみんぐ帳

C#で標準(エラー)出力をリダイレクトする

前置き。月額200円ぐらいでWindowsServer2016が使えるVPS環境が見つかったのでお遊び。バックグラウンドで動かすプログラムがあるのだが、たまに落ちてしまうことがあるので、定期的に「実行していなければ動かして、動いていれば何もしない」という操作をした。

タスクスケジューラ

これはLinuxは言う所のcronを使えば簡単に(?)できる。ワンライナーでできるらしい。Linux環境ではPythonが使えれば、モジュールのpsutil使って、プロセスを確かめていって同時起動を防止することもできる(自分はこっちのほうが好き)。Windows環境の場合、cron相当のものとしてタスクスケジューラがあり、ほぼ同様のことができる(cronと違って分単位の細かい設定をするのが難しい)。以下、設定する際のポイント。

タスクスケジューラの作業ディレクトリの設定

cronのときにありがちなミスとして、コマンドラインからは実行できたが、cronから動かすと実行できないというものがある。大方パスがらみで、cronの場合はカレントディレクトリが変わるため、気をつけないと相対パスが処理できない。cronの場合の解決法として、

  1. 絶対パスで記述する → 最も確実だがデバッグ環境でパスを書き換えるのがめんどい
  2. シェルスクリプトを噛ませて、プログラムを実行する前にcdでカレントディレクトリに移動しておく → 結構使える
  3. 例えばPythonの場合はプログラムレベルで設定する → これも好き
import os
os.chdir("カレントディレクトリ")

タスクスケジューラの場合は、設定画面で作業ディレクトリを設定できる。これを忘れていて最初ハマったが、ここで設定したらバッチファイルが通った。

f:id:shikoan:20180408075418p:plain

開始(オプション)に作業ディレクトリを設定する。地味にわかりづらい。

分単位での発動のさせ方

トリガーで編集する。毎日1回としておいて、トリガー→編集として繰り返し間隔で分単位で設定できる。詳細はこれ↓

www.atmarkit.co.jp

ただ、5分、10分、15分、30分、1時間のような既定値でしか設定できないようで、cronのような細かい設定はできない。ここだけはcron記法で記述したかったり。

セキュリティオプションによる違い

タスクスケジューラのセキュリティオプションとして次の2つがある

  • ユーザーがログオンしているときのみ実行する
  • ユーザーがログオンしているかどうかにかかわらず実行する

そりゃcronの代用として使うんだから下一択でしょと思ったら、なんとこのオプションで動作の仕様が変わる。下を選ぶとバックグラウンドプロセスになり、コンソールアプリケーションの場合は黒い画面が表示されない。 なら上を選べばいいか、というと、ログオンの定義が最初「該当のユーザーでログインしていればいい、つまりリモートデスクトップを閉じてしまっても別のユーザーでログインしなければ大丈夫」という意味かと思っていたら、実行されなかった。なのでここでの、ログオンの定義=リモートデスクトップ等で該当ユーザーのセッションが維持されているという意味だと思う。

なので結局下を選ぶしかない。ところが、バックグラウンドプロセスになるので、コンソール画面が表示されず何やっているのかよくわからない。ならどうするか、解決法の1つとして、出力をファイルにリダイレクトしてしまえばいいのです。シェルなんかではおなじみのテクニック。

バッチファイルでもできるけど、せっかくWindowsServer使っているからC#で記述してみた。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.IO;

class Program
{
    static string filePath = null;
    static Encoding utf8 = new UTF8Encoding(false);

    static void Main(string[] args)
    {
        var p = new Process();
        //ログファイルのディレクトリを作成
        var date = DateTime.Now;
        filePath = Path.Combine(Environment.CurrentDirectory, "log", date.ToString("yyyy"),
            date.ToString("MM"), date.ToString("dd"),
            date.ToString("s").Replace(":", "_") + ".txt");
        var dir = Path.GetDirectoryName(filePath);
        if(!Directory.Exists(dir))
        {
            Directory.CreateDirectory(dir);
        }
        //出力をストリームに書き込むようにする
        p.StartInfo.UseShellExecute = false;
        p.StartInfo.RedirectStandardError = true;
        //OutputDataReceivedイベントハンドラを追加
        //p.StartInfo.RedirectStandardOutput = true;
        //p.OutputDataReceived += P_OutputDataReceived;
        p.ErrorDataReceived += P_OutputDataReceived;
        p.StartInfo.RedirectStandardInput = false;
        p.StartInfo.CreateNoWindow = true;

        p.StartInfo.FileName = Path.Combine(Environment.CurrentDirectory, "実行するプログラム");
        p.StartInfo.Arguments = @"引数1 引数2 ……";

        //起動
        p.Start();

        //非同期で出力の読み取りを開始
        p.BeginErrorReadLine();
        //p.BeginOutputReadLine();

        p.WaitForExit();
        p.Close();
    }

    //標準出力と標準エラー出力を同時に読むときは、標準出力読んでいるときにエラー出力が割り込む(あるいはその逆がある)ので
    //ファイル操作部分をロックする。どっちかだけの場合は不要
    private static void P_OutputDataReceived(object sender, DataReceivedEventArgs e)
    {
        if (filePath == null) return;
        //追記してBOMなしUTF8
        using (var sw = new StreamWriter(filePath, true, utf8))
        {
            sw.WriteLine(e.Data);
        }
    }
}

WindowsServerは何もインストールしなくても.NETのプログラムが動くのが素晴らしい(MS製品だからそれはそう)。期待通りの出力になった。実行しているプログラムが、ログを標準出力ではなく標準エラー出力に出すというちょっとよくわからないことをしているので、エラーのみを読んでいる。標準出力のみ読んでいたら何も出てこなくてびっくりした。

エラーと出力を両方読む場合は、必ずファイルをロック(排他制御しよう。usingの部分をlockのステートメントで囲ってしまえばいい。こんなイメージ↓

static _lock = new object();
static void Method()
{
    lock(_lock)
    {
        using(…)     
    }
}

実はこのプログラム、だいたい期待通りに動くんだけど100点ではなくて、親プロセスを終了させたときに、子のプロセスがゾンビプロセスとして生き続ける仕様がある。解決法はJob Objectを使うらしい。

stackoverflow.com

ざっと読んでみたらなかなか闇が深そうなので、スルーしてしまった。自分用だしゾンビプロセスは手動でkillすればいいよね、うん(おわり)。