.NETを使ったOfficeの自動化が面倒なはずがない―そう考えていた時期が俺にもありました。

前書き

.NETを使ったOfficeの自動化が面倒なはずがない

―そう考えていた時期が俺にもありました。

以下のPowerShellのコードを見てみましょう。

    $app = New-Object -ComObject Excel.Application
    $books = $app.Workbooks
    $book = $books.Open("test.xlsx")
    Write-Host $book.Sheets["Sheet1"].Cells[1,1].Text

    $book.Close()
    $app.Quit();

Excelを立ち上げて、シートのセルを表示し、Excelを終了するコードです。
本来であれば、なんの問題もありません。

しかしながら、起動したExcelのプロセスは終了せずタスクマネージャーに残り続けます。

今回はExcelで説明しましたが、これはWordでもOutlookでも同様です。また、PowerShellでなくC#で同様の実装をしても、この問題は発生します。

何が問題なのか?

Office オートメーションで割り当てたオブジェクトは、自分で解放する必要があります。
解放処理を適切に行わないことで、予期せぬ動作や、メモリの圧迫を引き起こします。
解放処理のベストプラクティスについてはマイクロソフトの中の人が以下のような記事をあげています。

Office オートメーションで割り当てたオブジェクトを解放する – Part1
Office オートメーションで割り当てたオブジェクトを解放する – Part2

.NETでOfficeの操作をする場合は必ず一読しておくことをお勧めします。
上記、記事のポイントとしては以下の通りです。

・作成されたRCWはReleaseComObjectで解放を行う
・Officeのアプリケーションの終了前後にガベージコレクトを実行する

作成されたReleaseComObjectで解放処理を行う。

New 演算子で Excel.Application クラス等を生成すると、RCW (ランタイム呼び出し可能ラッパー) も生成され COM オブジェクトのインスタンスを管理することになります。
ここで作成したRCWはReleaseComObjctを実行して参照カウントを減算します。これを行わないとオブジェクトが解放されずに予期せぬ動作を引き起こします。

    # ここでRCWが作成される。
    $app = New-Object -ComObject Excel.Application
    $app.Quit()

    # 作成したオブジェクトはReleaseComObjectを用いて解放する。
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app)

COMオブジェクトは暗黙的に作成されることがあり、以下のケースがそれにあたります。

    # ここでRCWが作成される。
    $app = New-Object -ComObject Excel.Application

    # 暗黙的に「Microsoft.Office.Interop.Excel.Workbooks」オブジェクトが作成される。
    Write-Host $app.WorkBooks.Count

    $app.Quit()

    # 作成したオブジェクトはReleaseComObjectを用いて解放する。
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app)

上記の暗黙的に作成されるCOMオブジェクトも解放処理を記載する必要があります。
つまり、以下のように実装する必要があります。

    # ここでRCWが作成される。
    $app = New-Object -ComObject Excel.Application

    $books = $app.WorkBooks
    Write-Host $books.Count

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($books)
    $app.Quit()

    # 作成したオブジェクトはReleaseComObjectを用いて解放する。
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app)

How to properly release Excel COM objectsで述べているように「1 dot good, 2 dots bad」と覚えると楽だと思います。

Officeのアプリケーションの終了前後にガベージコレクトを実行する

Office 開発系サポートの提唱するいわゆる「ベストプラクティス」ではOfficeのアプリケーションの終了前後に明示的にガベージコレクトを実行することを薦めています。

アプリケーションの終了時には、Excel からの COM オブジェクトの参照解放処理が行われます。このタイミングまでに適切に COM オブジェクトが解放されていないと、ガベージコレクトのタイミングによっては予期せぬエラーが生じる場合があります。このため、アプリケーションの終了前にまずガベージコレクトを実行します。

さらに、Application インスタンスを解放する際なのですが、Marshal.ReleaseComObject メソッドを使用して参照カウンタをデクリメントしただけでは、プロセスが終了することを保証できませんので、GC.Collect メソッドでガベージコレクトを強制してオブジェクトを解放しています。

つまりいわゆる「ベストプラクティス」では以下のようになります。

    # ここでRCWが作成される。
    $app = New-Object -ComObject Excel.Application

    $books = $app.WorkBooks
    Write-Host $books.Count

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($books)

    # GCの対象になるようにNULLをいれて変数の参照を切る
    $books = $null
    Remove-Variable books -ErrorAction SilentlyContinue

    # アプリケーション終了前のGC
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit()

    # 作成したオブジェクトはReleaseComObjectを用いて解放する。
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app)

    # GCの対象になるようにNULLをいれて変数の参照を切る
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    # アプリケーション終了前のGC
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

また、以下のようにReleaseComObjectなしのガベージコレクトだけでExcelのプロセスは終了して一見正しく動いているように見えます。

    # ここでRCWが作成される。
    $app = New-Object -ComObject Excel.Application

    $books = $app.WorkBooks
    Write-Host $books.Count

    # GCの対象になるようにNULLをいれて変数の参照を切る
    $books = $null
    Remove-Variable books -ErrorAction SilentlyContinue

    # アプリケーション終了前のGC
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit()

    # GCの対象になるようにNULLをいれて変数の参照を切る
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    # アプリケーション終了前のGC
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

Office オートメーションで割り当てたオブジェクトを解放する – Part2」のTipsではこの危険性を訴えています。

Null 代入の後、GC.Collect メソッドを実行するだけで良いように見える場合があります (例. EXCEL.EXE プロセスが終了するなど) が、これは非常に危険な方法です。
RCW への参照を残しているままの状態では、COM オブジェクト生成や参照等を繰り返した際に、例えば開発者が解放済みと考えている COM オブジェクトに対して接続しに行くなど、正しい COM オブジェクトに対する接続が実施できる保障ができなくなります。

なお、このベストプラクティスはあくまでOfficeアプリケーションのCOM解放処理のベストプラクティスであって、全てのベストプラクティスではありません。
サーバーサイドでガベージコレクトを明示的に実行することは、ベストではありません。

ベストプラクティスとその問題点

前書きに以下のようなコードを記載しました。

    $app = New-Object -ComObject Excel.Application
    $books = $app.Workbooks
    $book = $books.Open("test.xlsx")
    Write-Host $book.Sheets["Sheet1"].Cells[1,1].Text

    $book.Close()
    $app.Quit();

このベストプラクティスは以下のようになります。

    $app = New-Object -ComObject Excel.Application
    $books = $app.Workbooks
    $book = $books.Open("test.xlsx")
    $sheets = $book.Sheets
    $sheet = $sheets["Sheet1"]
    $cells = $sheet.Cells
    $cell = $cells[1,1]
    Write-Host $cell.Text

    $book.Close()

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($cell) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($cells) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheet) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheets) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($book) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($books) | Out-Null

    $cell = $null
    Remove-Variable cell -ErrorAction SilentlyContinue
    $cells = $null
    Remove-Variable cells -ErrorAction SilentlyContinue
    $sheet = $null
    Remove-Variable sheet -ErrorAction SilentlyContinue
    $sheets = $null
    Remove-Variable sheets -ErrorAction SilentlyContinue
    $book = $null
    Remove-Variable book -ErrorAction SilentlyContinue
    $books = $null
    Remove-Variable books -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit();

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

C#の例

using Excel = Microsoft.Office.Interop.Excel;

// 略
        static void test1()
        {
            {
                Excel.Application app = new Excel.Application();
                {
                    Excel.Workbooks books = app.Workbooks;
                    Excel.Workbook book = books.Open(@"test.xlsx");
                    Excel.Sheets sheets = book.Sheets;
                    Excel.Worksheet sheet = sheets["Sheet1"];
                    Excel.Range cells = sheet.Cells;
                    Excel.Range cell = cells[1, 1];
                    Console.WriteLine(cell.Value);

                    book.Close(Type.Missing, Type.Missing, Type.Missing);

                    Marshal.ReleaseComObject(cell);
                    Marshal.ReleaseComObject(cells);
                    Marshal.ReleaseComObject(sheet);
                    Marshal.ReleaseComObject(sheets);
                    Marshal.ReleaseComObject(book);
                    Marshal.ReleaseComObject(books);

                }
                GC.Collect();
                GC.WaitForPendingFinalizers();
                GC.Collect();

                app.Quit();
                Marshal.ReleaseComObject(app);
            }

            // Application オブジェクトのガベージ コレクトを強制します。
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }

いわゆるベストプラクティスではコードのステップ数が数倍になっていることがわかります。

また、ReleaseComObjectによるオブジェクトの解放漏れを検知することが非常に厄介です。
上記のコードで仮にReleaseComObjectをいくつか漏らしても、それを検知することは困難です。
先に説明したようにEXCELのプロセスが終了するなどの事象で解放漏れが起きているかどうかを検知することは難しいです。

また、暗黙的に作成されるCOMオブジェクトは、プログラマが意図せぬタイミングでおこなわれます。
たとえば以下のコードを10分ほど読んで見て解放漏れを考えてみてください。

                Excel.Application app = new Excel.Application();
                {
                    Excel.Workbooks books = app.Workbooks;
                    Excel.Workbook book = books.Open(@"test.xlsx");
                    Excel.Sheets sheets = book.Sheets;

                    foreach(Excel.Worksheet sheet in sheets)
                    {
                        Console.WriteLine(sheet.Name);
                        Marshal.ReleaseComObject(sheet);
                        Console.ReadLine();
                    }

                    Console.WriteLine("Excel起動済み");
                    Console.ReadLine();

                    book.Close(Type.Missing, Type.Missing, Type.Missing);

                    Marshal.ReleaseComObject(sheets);
                    Marshal.ReleaseComObject(book);
                    Marshal.ReleaseComObject(books);

                }
                GC.Collect();
                GC.WaitForPendingFinalizers();
                GC.Collect();

                app.Quit();
                Marshal.ReleaseComObject(app);
                Console.WriteLine("ReleaseComObject");
                Console.ReadLine();
            }

            // Application オブジェクトのガベージ コレクトを強制します。
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
            Console.WriteLine("ガベージコレクション済み");
            Console.ReadLine();

実は下記のコードのforeach句で「System.Runtime.InteropServices.CustomMarshalers.EnumeratorViewOfEnumVariant」からCOMオブジェクトが作成されてしまいます。

                    foreach(Excel.Worksheet sheet in sheets)
                    {
                        Console.WriteLine(sheet.Name);
                        Marshal.ReleaseComObject(sheet);
                        Console.ReadLine();
                    }

そのため、以下のようにforeachを使わずにCOMオブジェクトを操作した方が安全です。

                    for (int i = 1; i <= sheets.Count; ++i)
                    {
                        Excel.Worksheet sheet = book.Sheets[i];
                        Console.WriteLine(sheet.Name);
                        Marshal.ReleaseComObject(sheet);
                        Console.ReadLine();
                    }

このように、挙動を理解した上でソースコードを読み込まないとベストプラクティスのメモリ解放を実現することができないのです。

いわゆる「ベストプラクティス」の問題点の対応策

ソースコードを読まないと適切にメモリ解放をしているかわからない、かつ、タスクマネージャーだけではメモリが正しく解放されたかどうかを検知するのが困難であることがわかりました。
さすがに令和の時代にメモリ解放を気にして実装するのは辛いものがあるので、この状況を改善する方法について検討してみます。

NetOfficeを使用してCOMオブジェクトの解放を任せる

NetOfficeというMIT Licenseのオープンソースのライブラリが存在します。
https://github.com/NetOfficeFw/NetOffice

Officeの各オブジェクトをラッパーしてReleaseComObjectを行わなくてもすむようになっています。


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Excel = NetOffice.ExcelApi;

namespace NetOfficeSample
{
    class Program
    {
        static void Main(string[] args)
        {
            using (Excel.Application app = new Excel.Application()) {
                Excel.Workbook book = app.Workbooks.Open(@"test.xlsx");
                Excel.Worksheet workSheet = (Excel.Worksheet)book.Worksheets[1];
                Console.WriteLine(workSheet.Cells[1, 1].Value);

                Console.WriteLine("起動済み");
                Console.ReadLine();
                book.Close();
                app.Quit();

            }
            Console.WriteLine("Application終了");
            Console.ReadLine();
        }

    }
}

ただし、NetOfficeのコードをみるかぎりガベージコレクトを明示的にやっていないようにみえるので、いわゆるベストプラクティスとは違う部分があります。

RCWのオブジェクトの状況を監視する。

RCW (ランタイム呼び出し可能ラッパー) のオブジェクトの解放状況を監視しながら実装する方法もあります。
そのためには、マネージドヒープを監視するためのライブラリ、Microsoft.Diagnostics.Runtime.dll (nicknamed "CLR MD")を利用します。

https://github.com/microsoft/clrmd

なお、ウィルスバスターなどのウィルス対策ソフトは、このライブラリを使用して別プロセスにアタッチしてマネージドヒープを調べる操作を「怪しい操作」とみなすので、除外リストに入れて対応してください。

以下で説明するCOMの解放漏れをチェックするツールについては下記にソースコードとバイナリがあるので必要に応じて使用してください。
https://github.com/mima3/MemoryCheck

RCWのオブジェクトを列挙するサンプル

マネージドヒープ中のRCWのオブジェクトを列挙するサンプルを以下に記載します。

using Microsoft.Diagnostics.Runtime;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace EnumRcw
{
    class Program
    {
        static void Main(string[] args)
        {
            foreach (var process in System.Diagnostics.Process.GetProcessesByName(args[0]))
            {
                int pid = process.Id;
                Console.WriteLine("{0} {1} =======================================================", args[0], pid);
                using (var dataTarget = DataTarget.AttachToProcess(pid, 1000))
                {
                    Console.WriteLine(dataTarget.Architecture);
                    var clrVersion = dataTarget.ClrVersions.First();
                    var dacInfo = clrVersion.DacInfo;
                    ClrRuntime runtime = clrVersion.CreateRuntime();
                    foreach (var obj in runtime.Heap.EnumerateObjects())
                    {
                        ClrType type = obj.Type;
                        ulong size = obj.Size;
                        if (type.IsRCW(obj))
                        {
                            RcwData rcw = type.GetRCWData(obj);
                            if (rcw != null)
                            {
                                string ifname = "";
                                foreach (var i in rcw.Interfaces)
                                {
                                    ifname += i.Type.Name + ",";
                                }
                                Console.WriteLine("{0,16:X} {1,12:n0} {2} {3} {4} {5}", obj.Address, size, type.Name, rcw.RefCount, rcw.Disconnected, ifname);

                            }
                            else
                            {
                                Console.WriteLine("{0,16:X} {1,12:n0} {2} (GetRCWDataに失敗)", obj.Address, size, type.Name);

                            }
                        }
                    }
                }
            }
        }
    }
}

このプログラムは指定のプロセスにアタッチして、マネージドヒープ中のRCWオブジェクトを列挙します。
この際、アタッチ対象のプロセスと同じになるように、プラットフォームのターゲットをX86/X64を明示してください。

image.png

コマンドラインからプロセス名🅂を指定することで、現在のヒープ中のRCWのオブジェクトを列挙します。
以下のサンプルは「foreach(Excel.Worksheet sheet in sheets)」のブロック内におけるRCWのオブジェクトを列挙した例になります。

>EnumRcw プロセス名
memorycheck 18688 =======================================================
Amd64
     25601C57CE8           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     25601C57D08           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     25601C58348           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,
     25601C58368           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Sheets,
     25601C58C78           32 System.__ComObject 1 False
     25601C58DA8           32 System.__ComObject (GetRCWDataに失敗)

指定のオブジェクトを参照しているオブジェクトを探す

Microsoft.Diagnostics.Runtime.dll では指定のオブジェクトがGCのルートから、どのパスで参照されているか確認することもできます。
https://blog.maartenballiauw.be/post/2017/01/03/exploring-.net-managed-heap-with-clrmd.html

using Microsoft.Diagnostics.Runtime;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace WalkHeap
{
    class Program
    {
        static void Main(string[] args)
        {
            string name = args[0];
            foreach (var process in System.Diagnostics.Process.GetProcessesByName(args[0]))
            {
                int pid = process.Id;
                Console.WriteLine("{0} {1} =======================================================", args[0], pid);
                ulong taregetPtr = ulong.Parse(args[1], System.Globalization.NumberStyles.HexNumber);

                using (var dataTarget = DataTarget.AttachToProcess(pid, 1000))
                {
                    var clrVersion = dataTarget.ClrVersions.First();
                    var dacInfo = clrVersion.DacInfo;
                    ClrRuntime runtime = clrVersion.CreateRuntime();
                    var stack = new Stack<ulong>();

                    var heap = runtime.Heap;
                    if (heap.CanWalkHeap)
                    {
                        Console.WriteLine("-----");
                        foreach (var ptr in heap.EnumerateObjectAddresses())
                        {
                            var type = heap.GetObjectType(ptr);
                            if (type == null || taregetPtr != ptr)
                            {
                                continue;
                            }

                            Console.WriteLine("find");

                            // todo: retention path
                            Console.WriteLine("roots...");
                            foreach (var root in heap.EnumerateRoots())
                            {
                                stack.Clear();
                                stack.Push(root.Object);

                                if (GetPathToObject(heap, ptr, stack, new HashSet<ulong>()))
                                {
                                    // Print retention path
                                    var depth = 0;
                                    foreach (var address in stack)
                                    {
                                        var t = heap.GetObjectType(address);
                                        if (t == null)
                                        {
                                            Console.WriteLine("{0} {1,16:X} ", new string('+', depth++), address);
                                            continue;
                                        }

                                        Console.WriteLine("{0} {1,16:X} - {2} - {3} bytes", new string('+', depth++), address, t.Name, t.GetSize(address));
                                    }

                                    break;
                                }
                            }

                            break;
                        }
                    }

                }

            }
        }

        // https://blog.maartenballiauw.be/post/2017/01/03/exploring-.net-managed-heap-with-clrmd.html
        private static bool GetPathToObject(ClrHeap heap, ulong objectPointer, Stack<ulong> stack, HashSet<ulong> touchedObjects)
        {
            // Start of the journey - get address of the first objetc on our reference chain
            var currentObject = stack.Peek();

            // Have we checked this object before?
            if (!touchedObjects.Add(currentObject))
            {
                return false;
            }

            // Did we find our object? Then we have the path!
            if (currentObject == objectPointer)
            {
                return true;
            }

            // Enumerate internal references of the object
            var found = false;
            var type = heap.GetObjectType(currentObject);
            if (type != null)
            {
                type.EnumerateRefsOfObject(currentObject, (innerObject, fieldOffset) =>
                {
                    if (innerObject == 0 || touchedObjects.Contains(innerObject))
                    {
                        return;
                    }

                    // Push the object onto our stack
                    stack.Push(innerObject);
                    if (GetPathToObject(heap, objectPointer, stack, touchedObjects))
                    {
                        found = true;
                        return;
                    }

                    // If not found, pop the object from our stack as this is not the tree we're looking for
                    stack.Pop();
                });
            }

            return found;
        }
    }
}

コマンドラインから以下のようにプロセス名とオブジェクトのアドレスを指定して実行することで、指定のプロセスのオブジェクトが、どのように参照されているか表示します。

>WalkHeap.exe memorycheck 25601C58C78
memorycheck 18688 =======================================================
-----
find
roots...
      25601C58C78 - System.__ComObject - 32 bytes
+      25601C58CD0 - System.Runtime.InteropServices.CustomMarshalers.EnumeratorViewOfEnumVariant - 40 bytes

RCWの解放状況の検証

ヒープ上のRCWの状況を検査する方法が分かったので様々なケースで実験してみようと思います。

OS
 Windows10 64bit

Excel
 Office16 Excel32bit

PowerSehll
 5.1.17134.858(64bit)

操作側のアプリ:
 .NET Framework4.5.2
 64bit
 Releaseビルド
debugビルドだと挙動が変わる可能性があります。

実験1 アプリケーションの解放漏れがあるケース
        //using Excel = Microsoft.Office.Interop.Excel;
        static void t1()
        {
            Excel.Application app = new Excel.Application();
            app.Quit();
            Console.WriteLine("ここでヒープのチェックを行う");
            Console.ReadLine();
        }

結果:

memorycheck 20140 =======================================================
Amd64
     19880007BE0           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,

Microsoft.Office.Interop.Excel.ApplicationClassが解放されずに残っていることがわかります。

実験2 アプリケーションの解放をReleaseComObjectで行った場合
        //using Excel = Microsoft.Office.Interop.Excel;
        static void t1_2()
        {
            Excel.Application app = new Excel.Application();
            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う");
            Console.ReadLine();
        }

結果:

memorycheck 16108 =======================================================
-----

Microsoft.Office.Interop.Excel.ApplicationClassが解放されていることが確認できます。

実験3 アプリケーションの解放をガベージコレクションだけで行った場合
        //using Excel = Microsoft.Office.Interop.Excel;
        static void t1_3()
        {
            Excel.Application app = new Excel.Application();
            app.Quit();
            app = null;
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
            Console.WriteLine("ここでヒープのチェックを行う t1_3");
            Console.ReadLine();
        }

結果:

memorycheck 8664 =======================================================
Amd64

Microsoft.Office.Interop.Excel.ApplicationClassが解放されていることが確認できます。
なお、デバッグビルドの場合は以下のようになります。

memorycheck 19908 =======================================================
Amd64
     1B583A77BD8           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,

これはデバッグ実行の場合、オブジェクトの生存期間が異なるためです。

実験4 暗黙の参照が作成されるケース
        //using Excel = Microsoft.Office.Interop.Excel;
        static void t2()
        {
            Excel.Application app = new Excel.Application();
            // Workbooksの暗黙のオブジェクトの作成
            Excel.Workbook book = app.Workbooks.Open(@"test.xlsx");
            Console.WriteLine("ここでヒープのチェックを行う1");
            Console.ReadLine();
            book.Close();
            Marshal.ReleaseComObject(book);

            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う2");
            Console.ReadLine();
        }

結果:

1回目のメモリの内容
memorycheck 21916 =======================================================
Amd64
     1D04E377CE8           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     1D04E377D08           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     1D04E378348           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,

2回目のメモリの内容
memorycheck 21916 =======================================================
Amd64
     1D04E377D08           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     1D04E378348           32 System.__ComObject (GetRCWDataに失敗)

Microsoft.Office.Interop.Excel.Workbooksのオブジェクトが暗黙的に作成されていることがわかります。
この暗黙的なオブジェクトを解放しない場合、「Microsoft.Office.Interop.Excel._Workbook」のオブジェクトが中途半端に残っていることも確認できます。

実験5 暗黙の参照が作成しないように修正したケース
        static void t2_1()
        {
            Excel.Application app = new Excel.Application();
            Excel.Workbooks books = app.Workbooks;
            Excel.Workbook book = books.Open(@"test.xlsx");
            Console.WriteLine("ここでヒープのチェックを行う1 t_1");
            Console.ReadLine();
            book.Close();
            Marshal.ReleaseComObject(book);
            Marshal.ReleaseComObject(books);

            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う2");
            Console.ReadLine();
        }

結果:

1回目のメモリの状態:
memorycheck 22292 =======================================================
Amd64
     12E47377CF0           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     12E47377D10           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     12E47378350           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,

2回目のメモリの状態:
memorycheck 22292 =======================================================
Amd64

作成したオブジェクトが全て削除されていることが確認できます。

実験6 for eachを用いた繰り返しの場合
        static void t3()
        {
            Excel.Application app = new Excel.Application();
            Excel.Workbooks books = app.Workbooks;
            Excel.Workbook book = books.Open(@"test.xlsx");
            foreach (var sheet in book.Sheets)
            {
                Console.WriteLine("ここでヒープのチェックを行う1(ループ内) t3");
                Console.ReadLine();
                Marshal.ReleaseComObject(sheet);
            }
            Console.WriteLine("ここでヒープのチェックを行う2 ループ完了 t3");
            Console.ReadLine();
            book.Close();
            Marshal.ReleaseComObject(book);
            Marshal.ReleaseComObject(books);

            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う3");
            Console.ReadLine();
        }

結果:

1:ループ内のメモリの状態
memorycheck 17784 =======================================================
Amd64
     1C74A3B7D50           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     1C74A3B7D70           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     1C74A3B83B0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,
     1C74A3B83D0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Sheets,
     1C74A3B8CE0           32 System.__ComObject 1 False
     1C74A3B8E10           32 System.__ComObject 1 False

2: 1C74A3B8CE0の参照箇所を調べる
-----
find
roots...
      1C74A3B8CE0 - System.__ComObject - 32 bytes
+      1C74A3B8D38 - System.Runtime.InteropServices.CustomMarshalers.EnumeratorViewOfEnumVariant - 40 bytes

3: 1C74A3B8E10 の参照箇所を調べる
-----
find
roots...
      1C74A3B8E10 - System.__ComObject - 32 bytes

4: ループ外でのメモリの状態
memorycheck 17784 =======================================================
Amd64
     1C74A3B7D50           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     1C74A3B7D70           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     1C74A3B83B0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,
     1C74A3B83D0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Sheets,
     1C74A3B8CE0           32 System.__ComObject 1 False
     1C74A3B8E10           32 System.__ComObject (GetRCWDataに失敗)

5: 1C74A3B8CE0の参照箇所を調べる
-----
find
roots...

6: 1C74A3B8E10の参照箇所を調べる
-----
find
roots...

7: Officeアプリケーション終了後
memorycheck 17784 =======================================================
Amd64

foreachでループを行った場合、1C74A3B8CE0が暗黙的に作成されていることがわかります。このオブジェクトはSystem.Runtime.InteropServices.CustomMarshalers.EnumeratorViewOfEnumVariantから参照されていましたが、ループを抜けると、どこからも参照されずゴミとして残ってしまいます。
※Officeの終了で削除されるが、その間、予期せぬオブジェクトが残り続ける

実験7 for eachを排除した繰り返しの場合
        static void t3_1()
        {
            Excel.Application app = new Excel.Application();
            Excel.Workbooks books = app.Workbooks;
            Excel.Workbook book = books.Open(@"test.xlsx");
            Excel.Sheets sheets = book.Sheets;

            for (int i = 1; i <= sheets.Count; ++i)
            {
                var sheet = sheets[i];
                Console.WriteLine("ここでヒープのチェックを行う1(ループ内) t3-1");
                Console.ReadLine();
                Marshal.ReleaseComObject(sheet);

            }
            Console.WriteLine("ここでヒープのチェックを行う2 ループ完了 t3");
            Console.ReadLine();
            book.Close();

            Marshal.ReleaseComObject(sheets);
            Marshal.ReleaseComObject(book);
            Marshal.ReleaseComObject(books);

            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う3");
            Console.ReadLine();
        }
ループ内のメモリの状態:
memorycheck 17612 =======================================================
Amd64
     29107A77D90           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     29107A77DB0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     29107A783F0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,
     29107A78410           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Sheets,
     29107A78480           32 System.__ComObject 1 False

ループ外のメモリの状態:
memorycheck 17612 =======================================================
Amd64
     29107A77D90           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     29107A77DB0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     29107A783F0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,
     29107A78410           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Sheets,
     29107A78480           32 System.__ComObject (GetRCWDataに失敗)

Officeアプリケーション終了後のメモリの状態
memorycheck 17612 =======================================================
-----

foreachで暗黙的に作成されたSystem.Runtime.InteropServices.CustomMarshalers.EnumeratorViewOfEnumVariantからの参照されているSystem.__ComObjectが存在しないことがわかります。

実験8 RCWのオブジェクトを別の変数に格納した場合
        //using Excel = Microsoft.Office.Interop.Excel;
        static void t4()
        {
            Excel.Application app = new Excel.Application();
            Excel.Workbooks books = app.Workbooks;
            Excel.Workbook book = books.Open(@"test.xlsx");
            var book2 = book;
            Console.WriteLine(book.Name);
            Console.WriteLine(book2.Name);

            Console.WriteLine("ここでヒープのチェックを行う1 t_1");
            Console.ReadLine();
            book.Close();
            Marshal.ReleaseComObject(book);
            Marshal.ReleaseComObject(books);

            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う2");
            Console.ReadLine();
        }

結果:

1回目:
MemoryCheck 15648 =======================================================
Amd64
     1FC94A97CF0           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     1FC94A97D10           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     1FC94A98350           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,

2回目:
MemoryCheck 15648 =======================================================
Amd64

1回目のメモリの内容確認でExcel.Workbookをbookとbook2変数に格納していますが、RCWのオブジェクト自体が増加しておらず、かつ、参照カウンタが1のままであることが確認できます。
2回目のbook変数のみにたいしてReleaseComObjectを行うことでRCWオブジェクトが削除されることも確認できます。

実験9:PowerShellで実行した場合

test2.ps1

    Read-Host "初期状態"

    $app = New-Object -ComObject Excel.Application
    $books = $app.Workbooks
    $book = $books.Open("test.xlsx")
    $sheets = $book.Sheets
    $sheet = $sheets["Sheet1"]
    $cells = $sheet.Cells
    $cell = $cells[1,1]
    Write-Host $cell.Text
    #Write-Host $cell.Value # Variant Value (Variant)が表示される
    #$cell.Value = "TEST"
    #Write-Host $cell.Text

    $book.Close()
    Read-Host "①起動済み"

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($cell) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($cells) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheet) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheets) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($book) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($books) | Out-Null
    Read-Host "②ReleaseComObject"

    $cell = $null
    Remove-Variable cell -ErrorAction SilentlyContinue
    $cells = $null
    Remove-Variable cells -ErrorAction SilentlyContinue
    $sheet = $null
    Remove-Variable sheet -ErrorAction SilentlyContinue
    $sheets = $null
    Remove-Variable sheets -ErrorAction SilentlyContinue
    $book = $null
    Remove-Variable book -ErrorAction SilentlyContinue
    $books = $null
    Remove-Variable books -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit();

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()
    Read-Host "すべて終了"

結果:

1: スクリプト実行前
powershell 1656 =======================================================
Amd64
     2873E065E98           32 System.__ComObject 1 False Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics,

2: スクリプト実行して「初期状態」が表示された時点の状態
powershell 1656 =======================================================
Amd64
     2873E065E98           32 System.__ComObject 1 False Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics,
     2873E709130           32 System.__ComObject (GetRCWDataに失敗)

※2873E709130はルートから検索できない

3:「①起動済み:」が表示された時点の状態
powershell 1656 =======================================================
Amd64
     2873E065E98           32 System.__ComObject 1 False Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics,
     2873E7B2618           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Management.Automation.IDispatch,Microsoft.Office.Interop.Excel._Application,
     2873E7BA850           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873E87F860           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,System.Management.Automation.ComInterop.IDispatch,
     2873E891FE0           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873E8DD090           32 System.__ComObject 1 False System.Management.Automation.ComInterop.IDispatch,
     2873E8EF398           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873E93BB10           32 System.__ComObject 1 False System.Management.Automation.ComInterop.IDispatch,
     2873E94AE08           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873E9A66A0           32 System.__ComObject 1 False System.Management.Automation.ComInterop.IDispatch,
     2873E9B7928           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873E9F5C90           32 System.__ComObject 1 False System.Management.Automation.ComInterop.IDispatch,
     2873EA05D48           32 System.__ComObject 2 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873EA6ED78           32 System.__ComObject 1 False System.Management.Automation.ComInterop.IDispatch,

※2873E709130は消えている

4:「②ReleaseComObject:」が表示された時点の状態
powershell 1656 =======================================================
Amd64
     2873E065E98           32 System.__ComObject 1 False Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics,
     2873E6B77C0           32 System.__ComObject (GetRCWDataに失敗)
     2873E6C5288           32 System.__ComObject (GetRCWDataに失敗)
     2873E6DBB58           32 System.__ComObject (GetRCWDataに失敗)
     2873E6E0058           32 System.__ComObject (GetRCWDataに失敗)
     2873E6F11D8           32 System.__ComObject (GetRCWDataに失敗)
     2873E715028           32 System.__ComObject (GetRCWDataに失敗)
     2873E7B2618           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Management.Automation.IDispatch,Microsoft.Office.Interop.Excel._Application,
     2873E7BA850           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,

5:「すべて終了」が表示された時点の状態
powershell 1656 =======================================================
Amd64
     2873E065E98           32 System.__ComObject 1 False Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics,
     2873E7BA850           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,

2873E7BA850の検索結果は以下の通り
      2873E7BA850 - System.__ComObject - 32 bytes
+      2873E7BAEA8 - System.Management.Automation.ComProperty - 64 bytes
++      2873E7E1738 - System.Collections.Generic.Dictionary+Entry<System.String,System.Management.Automation.ComProperty>[] - 10368 bytes
+++      2873E7BA950 - System.Collections.Generic.Dictionary<System.String,System.Management.Automation.ComProperty> - 80 bytes
++++      2873E7BA918 - System.Management.Automation.ComTypeInfo - 56 bytes
+++++      2873E7EC398 - System.Management.Automation.DotNetAdapterWithComTypeName - 32 bytes
++++++      2873E7EC3B8 - System.Management.Automation.PSObject+AdapterSet - 32 bytes
+++++++      2873E7EC3D8 - System.Collections.Concurrent.ConcurrentDictionary+Node<System.Type,System.Management.Automation.PSObject+AdapterSet> - 48 bytes
++++++++      2873E3FD9B8 - System.Collections.Concurrent.ConcurrentDictionary+Node<System.Type,System.Management.Automation.PSObject+AdapterSet>[] - 272 bytes
+++++++++      2873E3FDAC8 - System.Collections.Concurrent.ConcurrentDictionary+Tables<System.Type,System.Management.Automation.PSObject+AdapterSet> - 48 bytes
++++++++++      2873E3FD8B8 - System.Collections.Concurrent.ConcurrentDictionary<System.Type,System.Management.Automation.PSObject+AdapterSet> - 64 bytes

PowerShellでは初期状態で「Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics」のCOMオブジェクトが存在しています。
いわゆるベストプラクティスを守ったスクリプトを実行しても、操作終了後に「System.Runtime.InteropServices.ComTypes.ITypeInfo」が残ります。このオブジェクトはSystem.Collections.Concurrent.ConcurrentDictionary<System.Type,System.Management.Automation.PSObject+AdapterSet>から参照されています。

※以下のs_adapterMappingのように見えるが詳細は不明。
https://github.com/PowerShell/PowerShell/blob/c684902fba5b5e63fa750e66270a25b6889b4ec6/src/System.Management.Automation/engine/MshObject.cs

まとめと意識の低い対応策

.NETでOfficeオートメーションを使用する場合、いわゆるベストベストプラクティスは以下のようになります。

・アプリケーションで作成したRCWオブジェクトはReleaseComObjectで解放します
・RCWオブジェクトは暗黙的に作成される場合があります。
 例:
  foreach句
  「app.WorkBooks.Count」で2ドット含む場合
・Officeオートメーションの解放だけを考えた場合、Officeの終了前後でガベージコレクトを実行する。

正直面倒…さらにいうとサーバー側のプログラムでガベージコレクトを実行するのは、よくないです。

ここでは、意識を低くして対応策を考えてみます。

Office オートメーションで割り当てたオブジェクトを解放する – Part1」によると以下のような記述があります。

なお、コンソール アプリケーション等では、処理が一通り実行された後、プロセスが終了する際に CLR ランタイムが終了するのに合わせて、Application オブジェクトの解放および EXCEL.EXE プロセスの終了が実施されます。そのため、解放漏れがあったとしても、プログラム終了時に解放されるため、ほとんどの場合大きな影響がない傾向があります。

すなわち、 Officeの操作をして、すぐプロセスを終了すると影響が少なくできると考えられます。
なので、別プロセスで起動して単一のOfficeの操作を行い、すぐ終了するという設計にすることで、Officeの解放漏れを抑えられると考えられます。

たとえばPowerShellを実行する場合も以下のように別プロセスで実行してしまいます。

# PowerShellのコマンドプロンプトを落とすまで影響がのこる
./test1.ps1

# 別プロセスで実行することで影響を抑える
powershell ./test1.ps1

あとはわりきって、完璧なリソース解放はあきらめて、操作中はOfficeを使用しないでくださいとか運用でカバーしたり、予期せぬOfficeのプロセスが起動してたら終了させるなどの意識の低い対処案が考えられます。

参考

RedmineをあきらめたオレたちのPowerShellでのOutlookの自動操作

目的

この記事は、IT企業を名乗る名状しがたい企業においてRedmineとかTracを使用した、タスク管理の導入を、あきらめた方が対象です。

今回は、彼らのルールにしたがったOfficeという土俵で多少マシな状況を作るためにPowerShellを用いてOutlookの自動操作を行う方法を調べてみました。

Outlookはメール送るだけでなく、タスクの依頼や会議の設定ができます。
これらの操作はVBAやVBS、そしてPowerShellによって自動化が可能になっています。

すくなくとも一つのExcelを全員で修正したり、タスクの変更は気を付けて確認するという人間の能力を過大評価して現在のIT技術を過少評価した、タスク管理っぽいなにかをしているとこでは、多少はマシになる可能性があると期待しています。

環境:
 Office16 Outlook(32bit)
 Windows 10
 PowerShell 5.1

Outlookオブジェクトの解放処理について冗長な書き方をしていますが、その背景は下記を参照してください。

メールの操作

メールの送信サンプル

指定の画像ファイルを添付ファイルとしてメールを送信する例になります。

    # https://docs.microsoft.com/ja-jp/office/vba/api/overview/outlook
    # https://community.spiceworks.com/how_to/150253-send-mail-from-powershell-using-outlook
    $app = New-Object -ComObject Outlook.Application

    # プロファイルを選択してログオン
    $namespace = $app.GetNamespace("MAPI")
    $namespace.Logon("Outlook")

    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.olitemtype
    # 0:olmailitem
    $mail = $app.CreateItem(0)
    $mail.To = "送信先メールアドレス" 
    $mail.Subject = "タイトルでごわす" 
    $mail.Body = "本文でごわす"
    $attachments = $mail.Attachments
    $addResult = $attachments.Add("C:\dev\ps\mail\test.png")
    $mail.Send()

    # 送受信を行う
    # このメソッドは非同期なので、10秒ほどスリープしている
    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.namespace.sendandreceive
    $session = $app.Session
    $session.SendAndReceive($False)
    Start-Sleep 10

    #
    $namespace.Logoff()

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($namespace) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($addResult) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($attachments) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($Mail) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($namespace) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($session) | Out-Null

    $namespace = $null
    Remove-Variable namespace -ErrorAction SilentlyContinue
    $addResult = $null
    Remove-Variable addResult -ErrorAction SilentlyContinue
    $attachments = $null
    Remove-Variable attachments -ErrorAction SilentlyContinue
    $mail = $null
    Remove-Variable mail -ErrorAction SilentlyContinue
    $namespace = $null
    Remove-Variable namespace -ErrorAction SilentlyContinue
    $session = $null
    Remove-Variable session -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit() 

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

image.png

メールの受信フォルダ中のアイテムの列挙

受信フォルダ内のメールを列挙します。
サブフォルダの中身も列挙します。


    function DumpMailItem($folder, $prefix) {
        Write-Host $prefix "------------------"
        Write-Host $prefix $folder.Name
        $prefix = "  " + $prefix
        $items = $folder.Items

        # メールアイテムの列挙
        for ($i=1; $i -le $items.Count; $i++){
           $item = $items.Item($i)
           $sender = $item.Sender
           if ($sender -eq $null) 
           {
               Write-Host $prefix  $item.SentOn    $item.Subject
           } else {
               Write-Host $prefix  $item.SentOn    $item.SenderName $sender.Address   $item.Subject
               [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sender) | Out-Null
           }
           [System.Runtime.Interopservices.Marshal]::ReleaseComObject($item) | Out-Null
        }

        # フォルダの列挙
        $folders = $folder.Folders
        for ($i=1; $i -le $folders.Count; $i++){
            $item = $folders.Item($i)
            DumpMailItem $item $prefix
            [System.Runtime.Interopservices.Marshal]::ReleaseComObject($item) | Out-Null
        }
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($items) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($folders) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($folder) | Out-Null
    }

    # https://docs.microsoft.com/ja-jp/office/vba/api/overview/outlook
    # https://community.spiceworks.com/how_to/150253-send-mail-from-powershell-using-outlook
    $app = New-Object -ComObject Outlook.Application

    # プロファイルを選択してログオン
    $namespace = $app.GetNamespace("MAPI")
    $namespace.Logon("Outlook")

    # このメソッドは非同期
    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.namespace.sendandreceive
    $session = $app.Session
    $session.SendAndReceive($True)
    Start-Sleep 10

    #https://docs.microsoft.com/ja-jp/office/vba/api/outlook.oldefaultfolders
    # 6: The Inbox folder.
    $inbox = $namespace.GetDefaultFolder(6)

    DumpMailItem $inbox "  "

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($inbox) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($session) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($namespace) | Out-Null

    $inbox = $null
    Remove-Variable inbox -ErrorAction SilentlyContinue
    $session = $null
    Remove-Variable session -ErrorAction SilentlyContinue
    $namespace = $null
    Remove-Variable namespace -ErrorAction SilentlyContinue

    #
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    #
    $app.Quit()

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

フォルダの構成:
image.png

出力例:

   ------------------
   受信トレイ
      Task Declined: タスクだお
      Task Declined: タスクだお
      Task Declined: タスクだお
     2019/08/22 1:24:20 Google Play noreply-developer-googleplay@google.com Google Play Developer Program Policy Update
     ------------------
     フォルダ
       2019/08/20 8:52:28 Facebook noreply@developers.facebook.com 開発者ブログウィークリーダイジェスト
       ------------------
       サブ1
         2019/08/20 14:47:06 test@co.jp test@co.jp subject

予定の操作

予定の追加

    # https://docs.microsoft.com/ja-jp/office/vba/api/overview/outlook
    # https://community.spiceworks.com/how_to/150253-send-mail-from-powershell-using-outlook
    $app = New-Object -ComObject Outlook.Application

    # プロファイルを選択してログオン
    $namespace = $app.GetNamespace("MAPI")
    $namespace.Logon("Outlook")

    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.olitemtype
    # olappointmentItem 1   AppointmentItem オブジェクト。

    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.appointmentitem
    $appointment = $app.CreateItem(1)
    $appointment.Subject = "タイトルだお" 
    $appointment.Body = "私は猫"
    $appointment.Location = "家"
    $appointment.ReminderSet = $True
    $appointment.ReminderMinutesBeforeStart = 30
    $appointment.Start = Get-Date -Date '2019/8/20 18:00:00'
    $appointment.End = Get-Date -Date '2019/8/20 18:30:00'

    $appointment.Save()

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($appointment) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($namespace) | Out-Null
    $appointment = $null
    Remove-Variable appointment -ErrorAction SilentlyContinue
    $namespace = $null
    Remove-Variable namespace -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    # 起動時に全て開いている場合、ここで待機されてしまう。(数十秒後にメールを送信して終了される)
    $app.Quit() 

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

image.png
image.png

会議の予約

    # https://docs.microsoft.com/ja-jp/office/vba/api/overview/outlook
    # https://community.spiceworks.com/how_to/150253-send-mail-from-powershell-using-outlook
    $app = New-Object -ComObject Outlook.Application

    # プロファイルを選択してログオン
    $namespace = $app.GetNamespace("MAPI")
    $namespace.Logon("Outlook")

    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.olitemtype
    # olAppointmentItem 1   An AppointmentItem object.
    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.meetingitem
    $meeting = $app.CreateItem(1)

    #https://docs.microsoft.com/ja-jp/office/vba/api/outlook.appointmentitem.meetingstatus
    #olMeeting  1   The meeting has been scheduled.
    #olMeetingCanceled  5   The scheduled meeting has been cancelled.
    #olMeetingReceived  3   The meeting request has been received.
    #olMeetingReceivedAndCanceled   7   The scheduled meeting has been cancelled but still appears on the user's calendar.
    #olNonMeeting   0   An Appointment item without attendees has been scheduled. This status can be used to set up holidays on a calendar.
    $meeting.MeetingStatus = 1

    $meeting.Subject = "牛丼友会会合" 
    $meeting.Location = "吉野家"
    $meeting.Body = "吉野家"
    $meeting.Start = Get-Date -Date '2019/8/31 18:00:00'
    $meeting.Duration = 90

    $recipients = $meeting.Recipients
    $required = $recipients.Add("メールアドレス1")
    #https://docs.microsoft.com/ja-jp/office/vba/api/outlook.olmeetingrecipienttype
    # olOptional:2 Optional attendee
    $required.Type = 2

    $optional = $recipients.Add("メールアドレス2")
    # olRequired:1 Required attendee
    $optional.Type = 1

    $recipients.ResolveAll()
    $meeting.Send()

    #
    $session = $app.Session
    $session.SendAndReceive($False)
    Start-Sleep 10

    $namespace.Logoff()

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($optional) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($required) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($recipients) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($meeting) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($namespace) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($session) | Out-Null

    $optional = $null
    Remove-Variable optional -ErrorAction SilentlyContinue

    $required = $null
    Remove-Variable required -ErrorAction SilentlyContinue

    $recipients = $null
    Remove-Variable recipients -ErrorAction SilentlyContinue

    $meeting = $null
    Remove-Variable meeting -ErrorAction SilentlyContinue

    $namespace = $null
    Remove-Variable namespace -ErrorAction SilentlyContinue

    $session = $null
    Remove-Variable session -ErrorAction SilentlyContinue
    #
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit() 
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue
    #
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

出席依頼された人のスケジュール:

image.png

image.png

予定の取得


    # https://docs.microsoft.com/ja-jp/office/vba/api/overview/outlook
    # https://community.spiceworks.com/how_to/150253-send-mail-from-powershell-using-outlook
    $app = New-Object -ComObject Outlook.Application

    # プロファイルを選択してログオン
    $namespace = $app.GetNamespace("MAPI")
    $namespace.Logon("Outlook")

    #https://docs.microsoft.com/ja-jp/office/vba/api/outlook.oldefaultfolders
    # 9: olFolderCalendar The Calendar folder.
    $calendar = $namespace.GetDefaultFolder(9)
    $items = $calendar.Items
    $items.Sort("[Start]")
    $items.IncludeRecurrences = $True
    $today = Get-Date -Format "yyyy/MM/dd"
    $filter = "[Start] >= '" + $today + "'"
    $item = $items.Find($filter)
    while ($item -ne $null) {
        Write-Host $item.Subject $item.Location $item.Start $item.End
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($item) | Out-Null
        $item = $items.FindNext()
    }

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($items) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($calendar) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($namespace) | Out-Null

    $items = $null
    Remove-Variable items -ErrorAction SilentlyContinue
    $calendar = $null
    Remove-Variable calendar -ErrorAction SilentlyContinue
    $namespace = $null
    Remove-Variable namespace -ErrorAction SilentlyContinue

    #
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    #
    $app.Quit()

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

出力例:

定期予定  2019/08/28 8:00:00 2019/08/28 8:30:00
定期予定  2019/08/29 8:00:00 2019/08/29 8:30:00
タイトルだお 家 2019/08/29 18:00:00 2019/08/29 18:30:00
定期予定  2019/09/04 8:00:00 2019/09/04 8:30:00
定期予定  2019/09/05 8:00:00 2019/09/05 8:30:00
定期予定  2019/09/11 8:00:00 2019/09/11 8:30:00

タスクの操作

自分のタスクを追加する場合

    # https://docs.microsoft.com/ja-jp/office/vba/api/overview/outlook
    # https://community.spiceworks.com/how_to/150253-send-mail-from-powershell-using-outlook
    $app = New-Object -ComObject Outlook.Application

    # プロファイルを選択してログオン
    $namespace = $app.GetNamespace("MAPI")
    $namespace.Logon("Outlook")

    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.olitemtype
    # olTaskItem    3   A TaskItem object.
    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.taskitem
    $task = $app.CreateItem(3)
    $task.Subject = "タスクだお" 
    $task.Body = "私は猫"
    $task.StartDate = Get-Date -Date '2019/8/30 18:00:00'
    $task.DueDate  = Get-Date -Date '2019/8/31 18:30:00'

    # 優先度 0:低 1:中 2:高
    $task.Importance = 0
    $task.Save()

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($namespace) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($task) | Out-Null

    $namespace = $null
    Remove-Variable namespace -ErrorAction SilentlyContinue

    $task = $null
    Remove-Variable task -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit() 

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

image.png

image.png

タスクを依頼する場合

    # https://docs.microsoft.com/ja-jp/office/vba/api/overview/outlook
    # https://community.spiceworks.com/how_to/150253-send-mail-from-powershell-using-outlook
    $app = New-Object -ComObject Outlook.Application

    # プロファイルを選択してログオン
    $namespace = $app.GetNamespace("MAPI")
    $namespace.Logon("Outlook")

    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.olitemtype
    # olTaskItem    3   A TaskItem object.
    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.taskitem
    $task = $app.CreateItem(3)

    $task.Subject = "タスクの依頼" 
    $task.Body = "私はカモメ"
    $task.StartDate = Get-Date -Date '2019/8/30 18:00:00'
    $task.DueDate  = Get-Date -Date '2019/8/31 18:30:00'

    # 優先度 0:低 1:中 2:高
    $task.Importance = 0

    $assignResult = $task.Assign()
    $recipients = $task.Recipients
    $addResult = $recipients.Add("送信先メール")

    $recipients.ResolveAll()

    $task.Send()
    #$task.Save()
    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.olinspectorclose
    # 

    #
    $session = $app.Session
    $session.SendAndReceive($False)
    $namespace.Logoff()

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($recipients) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($addResult) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($assignResult) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($task) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($namespace) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($session) | Out-Null

    $recipients = $null
    Remove-Variable recipients -ErrorAction SilentlyContinue

    $addResult = $null
    Remove-Variable addResult -ErrorAction SilentlyContinue

    $assignResult = $null
    Remove-Variable assignResult -ErrorAction SilentlyContinue

    $task = $null
    Remove-Variable task -ErrorAction SilentlyContinue

    $namespace = $null
    Remove-Variable namespace -ErrorAction SilentlyContinue

    $session = $null
    Remove-Variable session -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit() 

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null

    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

依頼先のタスク
image.png

承認を押下することで進捗率等が変更できるようになる。
image.png

進捗レポートの送信をすることで、依頼者のタスクが更新される。
image.png

タスクの列挙

    function DumpMailItem($folder, $prefix) {
        Write-Host $prefix "------------------"
        Write-Host $prefix $folder.Name
        $prefix = "  " + $prefix
        $items = $folder.Items

        # タスクの列挙
        for ($i=1; $i -le $items.Count; $i++){
            $item = $items.Item($i)
            #OlTaskStatus:https://docs.microsoft.com/ja-jp/office/vba/api/outlook.oltaskstatus
            #Name   Value   Description
            #olTaskComplete 2   The task is complete.
            #olTaskDeferred 4   The task is deferred.
            #olTaskInProgress   1   The task is in progress.
            #olTaskNotStarted   0   The task has not yet started.
            #olTaskWaiting  3   The task is waiting on someone else.    
            Write-Host $prefix  $item.Subject $item.Owner $item.DueDate $item.Complete $item.Status $item.PercentComplete
            [System.Runtime.Interopservices.Marshal]::ReleaseComObject($item) | Out-Null
        }

        # フォルダの列挙
        $folders = $folder.Folders
        for ($i=1; $i -le $folders.Count; $i++){
            $item = $folders.Item($i)
            DumpMailItem $item $prefix
            [System.Runtime.Interopservices.Marshal]::ReleaseComObject($item) | Out-Null
        }
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($items) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($folders) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($folder) | Out-Null
    }

    # https://docs.microsoft.com/ja-jp/office/vba/api/overview/outlook
    # https://community.spiceworks.com/how_to/150253-send-mail-from-powershell-using-outlook
    $app = New-Object -ComObject Outlook.Application

    # プロファイルを選択してログオン
    $namespace = $app.GetNamespace("MAPI")
    $namespace.Logon("Outlook")

    # このメソッドは非同期
    # https://docs.microsoft.com/ja-jp/office/vba/api/outlook.namespace.sendandreceive
    $session = $app.Session
    $session.SendAndReceive($False)
    Start-Sleep 10

    #https://docs.microsoft.com/ja-jp/office/vba/api/outlook.oldefaultfolders
    # olFolderTasks 13  The Tasks folder.
    $taskfolder = $namespace.GetDefaultFolder(13)

    DumpMailItem $taskfolder "  "

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($taskfolder) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($session) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($namespace) | Out-Null

    $taskfolder = $null
    Remove-Variable taskfolder -ErrorAction SilentlyContinue
    $session = $null
    Remove-Variable session -ErrorAction SilentlyContinue
    $namespace = $null
    Remove-Variable namespace -ErrorAction SilentlyContinue

    #
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    #
    $app.Quit()

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

出力例

   ------------------
   タスク
     タスクの依頼 ZZZZZ@YYYYY.ne.jp 2019/08/31 0:00:00 False 1 50
     ------------------
     サブタスク
       タスクだお XXXX@YYYYY.ne.jp 2019/08/31 0:00:00 False 0 0
       ------------------
       さらにサブタスク
         owatta xxx@YYYYY.ne.jp 4501/01/01 0:00:00 True 2 100

まとめ

Outlookをつかってメール送信やタスク管理の自動化が行えることはわかりました。
おそらく、Excelでタスク管理をしているとこでも変更したタスクのメールの自動送信から初めて、Outlookのタスク機能を使う方向に徐々に倒していけば、マシになるかもしれません。

PowerShellのメモ~PathとLiteralPathパラメータについて

目的

PowerShellのGet-ChildItemCopy-ItemなどのいくつかのコマンドレットはPathとLiteralPathパラメータを持っています。

今回は、この違いと使い所さんについて考えてみます。

パラメータの説明

これらはいずれもロケーションを指定できるものですが、ワイルドカードを受け付けることができるかどうかが違います。

  • -Path ワイルドカードを受け付けることができる。
  • -LiteralPath 記述されたとおりに使用されます。ワイルドカードとして解釈される文字はありません。パスにエスケープ文字が含まれる場合は、単一引用符で囲みます。

パラメータのワイルドカード

Pathなどのパラメータではワイルドカード文字を使用して、コマンドで単語パターンを作成できます。

たとえば以下のコマンドでは末尾が「.txt」のファイルを列挙します。

Get-ChildItem -Path *.txt

コマンドレットのパラメータがワイルドカードを認めるかどうかは各コマンドレットのパラメータの説明で「Accept wildcard characters」の項目をみれば確認できます。

下記はGet-ChildItemのPathパラメータのドキュメントになります。

image.png

では次にPowerShellで使用できるワイルドカードパターンの特殊文字を確認してみます。

ワイルドカード 説明 一致する 一致しない
* 文字列の0個以上の文字に一致する su* sub1,sub1 a,sab1
? 任意の1文字に一致する あい??お.txt あいうえお.txt,あいabお.txt あいうお.txt
[\<char>-\<char>*] 連続する範囲の文字に一致する a[b-c]c.txt abc.txt,acc.txt aac.txt
[\<char>\<char>*] 文字セットの任意の一文字にセットする a[いa]b.txt aab.txt,aいb.txt acb.txt

windowsのcmd.exeで使用できるワイルドカードの特殊文字と異なることに注意してください。cmd.exeの場合、ワイルドカードとして使える文字は「*」と「?」でした。そして、この文字はWindowsのファイルとして使えない文字です。

PowerShellのワイルドカードの特殊文字「[」、「]」はファイルとして使える文字になっています。また、Windows環境以外で動作することを考える場合、全てのワイルドカードの特殊文字がファイル名として存在する可能性があります
これが「LiteralPath」というワイルドカードを解釈しないパラメータが必要となる理由になります。

もし、指定するワイルドカードパターンにワイルドカード文字として解釈されるべきではないリテラル文字が含まれている場合はエスケープ文字としてバッククォート(`)を用います。

たとえば以下のようなファイルを検索する例を考えてみましょう。この文字のパターンは「test[」+任意の文字+「].txt」とします。

  • test[1].txt
  • test[2].txt
  • test[3].txt
  • test[4].txt

「[」と「]」のリテラルを含むワイルドカードのパターンのサンプルは以下のいずれかになります。

Get-ChildItem  -Path test````[*````].txt
Get-ChildItem  -Path "test````[*````].txt"
Get-ChildItem  -Path 'test``[*``].txt'

まとめ

意識的にワイルドカードを使用しない場合は、LiteralPathを用いたほうが安全です。多くの場合、Pathが既定のパラメータになっているので注意してください。

参考:

よくわからない話(´・ω・`)

「[」と「]」のリテラルを含むワイルドカードのパターンのサンプルは以下のいずれかになります。

Get-ChildItem  -Path test````[*````].txt
Get-ChildItem  -Path "test````[*````].txt"
Get-ChildItem  -Path 'test``[*``].txt'

とかいいましたが、実際のところ、この挙動がマイクロソフトのドキュメントのどこで保障されたものかが今一わかりませんでした。

バッククォートでエスケープを行うことはSupporting Wildcard Characters in Cmdlet Parametersで記載されています。問題はそのバッククォートの数の妥当性です。

PowerShell インアクションでは以下のような解説をしています。

これは1番目のバッククォートがインタープリタによって削除され、2組目のバッククォートがプロバイダによって削除されるためです(2つ目のバッククォートの削除は、リテラル引用符が含まれたファイル名をあらわすために、エスケープを使用できるようにするためのものです)。パターンを単一引用符で囲むとインタープリタが文字列でエスケープ処理をしなくなるため、必要なバッククォートの数が2つに減ります。

正直なところチンときません。しかも最後に身も蓋もないことを言っています。

この例は、メタ文字の一部をリテラル文字として扱い、残りの部分でパターン照合を実行するため、きわめて複雑です。これをきちんと理解するには、試行錯誤を繰り返すしかありません。

.NETでディレクトリを消すのがこんなに面倒なわけがない

目的

PowerShellでファイルシステムのディレクトリを削除する方法について、よくある例と、それを行うとうまくいかない、場合によっては破壊的な動作をする例を説明します。

なお、最終的に.NETの問題にぶつかるので、PowerShellからC#使えばいいじゃない教の人もハマります。

よくある例

以下のようなコマンドでファイルシステムにテストデータを作成します。

New-Item -Path ./testdir  -Type Directory -Force
New-Item -Path ./testdir/test -Type Directory -Force
New-Item -Path ./testdir/test -Name 1.txt -Type File -Value 'Test1'
New-Item -Path ./testdir/test -Name 2.txt -Type File -Value 'Test2'
New-Item -Path ./testdir/test/sub1 -Type Directory -Force
New-Item -Path ./testdir/test/sub1 -Name s1.txt -Type File -Value 'Sub-Test1'

以下のようなディレクトリが作成されます。

TESTDIR
└─test
    │  1.txt
    │  2.txt
    │
    └─sub1
            s1.txt

この時にtestフォルダを削除するにはどうすればいいでしょうか?
よくある説明では下記のコマンドを入力すると行えるとあります。

Remove-Item -LiteralPath ./testdir/test -Recurse -Force

破壊的に動作する例

先の例は、多くの場合、正しく動きますが、特定の条件下でユーザが意図しないような破壊的動作をします。

まずファイルシステムの場合、シンボリックリンクやジャンクションという種類が存在しています。

Windowsのシンボリックリンクとジャンクションとハードリンクの違い
https://www.atmarkit.co.jp/ait/articles/1306/07/news111.html

これらの機能は簡単にいうとディレクトリへのリンクを作成することができる機能になっています。
これらを実際に作成するには下記のスクリプトを管理者権限で実行してください。

New-Item -Path ./testdir  -Type Directory -Force

New-Item -Path ./testdir/src1 -Type Directory -Force
New-Item -Path ./testdir/src1 -Name src1.txt -Type File -Value 'src1'

New-Item -Path ./testdir/src2 -Type Directory -Force
New-Item -Path ./testdir/src2 -Name src2.txt -Type File -Value 'src2'

# シンボリックリンクの作成には管理者権限が必要
New-Item -Path ./testdir/test -Type Directory -Force
New-Item -Path ./testdir/test -Name 1.txt -Type File -Value 'Test1'
New-Item -Value './testdir/src1' -Path './testdir/test/symbolic_src1'  -ItemType SymbolicLink
New-Item -Value './testdir/src2' -Path './testdir/test/junction_src2'  -ItemType Junction

# PowerShellのNew-Itemでサポートしていないバージョンの場合
# cmd /c "mklink /d ./testdir/test/symbolic_src1 c:\share\testdir\src1"
# cmd /c "mklink /j ./testdir/test/junction_src2 ./testdir/src2"

以下のようなディレクトリになります。

C:\DEV\PS\FILE\TESTDIR
├─src1
│      src1.txt
│
├─src2
│      src2.txt
│
└─test
    │  1.txt
    │
    ├─junction_src2
    │      src2.txt
    │
    └─symbolic_src1
            src1.txt

ジャンクションやシンボリックリンクを用いることでディレクトリへのリンクが作成されていることが確認できます。

ではsrc1フォルダとsrc2フォルダを残してtestフォルダのみを削除します。

Remove-Item -LiteralPath ./testdir/test -Recurse -Force

この結果はPowerShellのバージョンによって異なります。

PowerShell 2.0の場合

C:\SHARE\TESTDIR
├─src1
└─src2

testディレクトリは削除されましたが、リンク先のファイルが全て削除されてしまいます。
これがユーザーの意図しない破壊的結果になります。

この挙動は以下で修正されています。
https://github.com/PowerShell/PowerShell/issues/621

Window10+PowerShell 5.1の場合

Windows10+PowerShell 5.1の場合以下のようになります。

Remove-Item : 要求で指定したタグと再解析ポイントにあるタグが一致しません。
発生場所 行:1 文字:1
+ Remove-Item  -LiteralPath ./testdir/test -Recurse -Force
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Remove-Item], Win32Exception
    + FullyQualifiedErrorId : System.ComponentModel.Win32Exception,Microsoft.PowerShell.Commands.RemoveItemCommand

フォルダの内容:
C:\DEV\PS\FILE\TESTDIR
├─src1
│      src1.txt
│
├─src2
│      src2.txt
│
└─test
    │  1.txt
    │
    └─symbolic_src1
            src1.txt

ジャンクションは正しく削除されましたが、シンボリックリンクは残ってしまいます。
シンボリックリンクが消えない事象は、以下のコメントと同様の事象と思われます。
https://github.com/PowerShell/PowerShell/issues/621#issuecomment-515729308

プランBの削除方法

よく上記の話が出た際にプランBとして紹介される削除方法は以下になります。

(Get-Item -LiteralPath "./testdir/test").Delete($True)

[System.IO.Directory]::Delete("c:\dev\ps\file\testdir\test", $True)

これは2つの問題を引き起こします。
1つ目は読み取り専用のファイルが混在していた場合に削除できません。
2つ目はジャンクションを含むサブディレクトリを削除した場合、エラーになります。

先に挙げたジャンクションを含むディレクトリを削除した場合、以下のようになります。

PS C:\dev\ps\file> (gi "./testdir/test").Delete($true)
"1" 個の引数を指定して "Delete" を呼び出し中に例外が発生しました: "パラメーターが間違っています。
"
発生場所 行:1 文字:1
+ (Get-Item -LiteralPath "./testdir/test").Delete($True)
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], MethodInvocationException
    + FullyQualifiedErrorId : IOException
PS C:\dev\ps\file> [System.IO.Directory]::Delete("c:\dev\ps\file\testdir\test", $True)
"2" 個の引数を指定して "Delete" を呼び出し中に例外が発生しました: "パラメーターが間違っています。
"
発生場所 行:1 文字:1
+ [System.IO.Directory]::Delete("c:\dev\ps\file\testdir\test", $True)
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], MethodInvocationException
    + FullyQualifiedErrorId : IOException

このときのフォルダ構成は以下のようになっており、ジャンクションを含むディレクトリ以外は削除されています。

C:\DEV\PS\FILE\TESTDIR
├─src1
│      src1.txt
│
├─src2
│      src2.txt
│
└─test

このため、もう一度、再実行すると正常に削除されます。

この事象は、PowerShell固有の問題でなく、C#で実装した場合も発生します。
例えば、C#で以下のような実装で削除した場合も同様の事象になります。

System.IO.Directory.Delete(@"c:\dev\ps\file\testdir\test", true);
{"パス 'junction_src2' へのアクセスが拒否されました。"} System.UnauthorizedAccessException

VisualStudio2019の下記にて検証
・.NET Framework 4.7.2 + Win10
・.NET Core 2.1 + Win10.
※TODO:Linuxで動かした場合はそのうち。

なぜこの事象が発生するかの理由は、わかりませんでしたが、おそらく、以下と類似の問題だと思います。

Cannot delete directory with Directory.Delete(path, true)
https://stackoverflow.com/questions/329355/cannot-delete-directory-with-directory-deletepath-true

ジャンクションポイントが削除されきっていない状態で親フォルダを削除しようとしてエラーになっているのではないかと思いますが、正直わかりません。

どうやって消すべきか

PowerShellや.NETにはたよらない。

昔ながらのcmd.exeにやらせる。

PowerShellの例

cmd /c "rmdir /s/q `"c:\dev\ps\file\testdir\test`""

C#の実装例

Process.Start("cmd.exe", "/c " + @"rmdir /s/q c:\dev\ps\file\testdir\test");

2回やってしまう

PowerShellの場合、以下のようにSystem.IO.DirectoryInfo.Deleteで消せなかった残りの項目をRemove-Itemを強制+再帰的に呼び出してしまう方法もあります。

try { (Get-Item -LiteralPath "./testdir/test").Delete($True) } catch { Remove-Item ./testdir/test -Recurse -Force  }

再帰呼び出しをしてうまいことやる

FileAttributes.ReparsePointを判定してシンボリックリンクやジャンクションの場合は再帰を行わず、該当のディレクトリだけを削除します。
それ以外の場合は読み取り属性を外してファイルを削除後、各ディレクトリについて再起呼び出しをします。
※よくあるサンプルだとFileAttributes.ReparsePointの判定をしていないので、リンク先の属性を変更してしまっているサンプルがあります。

        // using System.IO;
        // Delete
        public static void DeleteDirectoryNest(string target_dir)
        {
            DirectoryInfo di = new DirectoryInfo(target_dir);
            if ((di.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
            {
                Directory.Delete(target_dir, false);
                return;
            }

            string[] files = Directory.GetFiles(target_dir);
            string[] dirs = Directory.GetDirectories(target_dir);

            foreach (string file in files)
            {
                File.SetAttributes(file, FileAttributes.Normal);
                File.Delete(file);
            }

            foreach (string dir in dirs)
            {
                DeleteDirectoryNest(dir);
            }

            Directory.Delete(target_dir, false);
        }

SHFileOperationを利用する

SHFileOperationを利用してエクスプローラーから削除したものと同じ動作を行う方法もあります。
この場合、削除中の進捗プログレスなどが表示されます。
ファイルロック中等でエラーなどが表示された場合に以下のダイアログが表示されて処理が止まるのでバッチ処理には向いていません

image.png

        // TODO :using System.Runtime.InteropServices;
        private const int FO_DELETE = 0x0003;
        private const int FOF_ALLOWUNDO = 0x0040;           // Preserve undo information, if possible. 
        private const int FOF_NOCONFIRMATION = 0x0010;      // Show no confirmation dialog box to the user

        // Struct which contains information that the SHFileOperation function uses to perform file operations. 
        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
        public struct SHFILEOPSTRUCT
        {
            public IntPtr hwnd;
            [MarshalAs(UnmanagedType.U4)]
            public int wFunc;
            public string pFrom;
            public string pTo;
            public short fFlags;
            [MarshalAs(UnmanagedType.Bool)]
            public bool fAnyOperationsAborted;
            public IntPtr hNameMappings;
            public string lpszProgressTitle;
        }

        [DllImport("shell32.dll", CharSet = CharSet.Auto)]
        static extern int SHFileOperation(ref SHFILEOPSTRUCT FileOp);

        public static void DeleteFileOrFolder(string path)
        {
            SHFILEOPSTRUCT fileop = new SHFILEOPSTRUCT();
            fileop.wFunc = FO_DELETE;
            fileop.pFrom = path + '\0' + '\0';
            //fileop.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION;
            fileop.fFlags =  FOF_NOCONFIRMATION;
            SHFileOperation(ref fileop);
        }

これと同様のことはMicrosoft.VisualBasicを参照後以下のコードでも実現可能です。

            Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(
                @"c:\dev\ps\file\testdir\test",
                Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs,
                Microsoft.VisualBasic.FileIO.RecycleOption.DeletePermanently);

まとめ

シンボリックリンクやジャンクションを含むディレクトリの削除について記載しました。
C#のディレクトリ削除やPowerShellのディレクトリ削除でグーグル検索した場合の上位に出てくるものは、この辺りを考慮していないので注意してください。

参考:

http://m-hiyama.hatenablog.com/entry/20150914/1442195413
https://kristofmattei.be/2012/12/15/powershell-remove-item-and-symbolic-links/
https://stackoverflow.com/questions/329355/cannot-delete-directory-with-directory-deletepath-true
https://www.fluxbytes.com/csharp/delete-files-or-folders-to-recycle-bin-in-c/

Windowsのアプリケーションのインストールは自動でやりたい

目的

よくプロジェクトの新規メンバーが入った時、各種アプリを手でポチポチインストールすることがあります。
そして、どんなに丁寧に手順書を書いても、設定や順番をミスる人はミスって、何回か重大な局面で環境を色々見直すはめになることが稀によくあります。

今回は、Windowsのインストールを自動化できるかどうか検討してみます。

なお、この記事でコマンドをいくつか実行していますが、すべて管理者権限のあるコマンドプロンプトより実行しています。

インストーラについて

Windowsのインストーラは大きくわけて2種類あります。
Setup.exeのように拡張子がexeのものと、拡張子がmsiのものです。

msiはユーザー応答の有無などの基本的なオプションについて共通的になっています。
exeの場合、それぞれが異なる可能性もありますし、また、ユーザー操作なしでインストールが行えない場合もあります。

拡張子がexeのインストーラの操作例

まず、GUIなしでインストールできるオプションがあるかどうかを調べるためにセットアップのヘルプを見ます。
たとえば、WinMergeの場合は以下のようになります。

WinMerge-2.1.6.4-Setup.exe /?

image.png

これを読めば、/SILENT、または/VERYSILENTを付与して実行すればGUIの操作なしでインストールできると考えられます。
では実行してみましょう。

WinMerge-2.1.6.4-Setup.exe /VERYSILENT

GUIなしでWinMergeがインストールできることが確認できます。
image.png

拡張子がmsiのインストーラの操作例

msiexecでMSIファイルの操作が行えます。このコマンドについては/?でヘルプを確認するか、公式のヘルプを参考にしてください。

Windows R インストーラー. V 5.0.7601.23593 

msiexec /Option <必須パラメーター> [省略可能なパラメーター]

インストール オプション
    </package | /i> <Product.msi>
        製品をインストールまたは構成します。
    /a <Product.msi>
        管理用ツール - ネットワーク上の製品をインストールします。
    /j<u|m> <Product.msi> [/t <変換一覧>] [/g <言語 ID>]
        製品をアドバタイズします - すべてのユーザーには m、現在の
        ユーザーには u を指定します。
    </uninstall | /x> <Product.msi | 製品コード>
        製品をアンインストールします。
表示オプション
    /quiet
        Quiet モード - ユーザーの操作なし
    /passive
        無人モード - 進行状況バーのみ
    /q[n|b|r|f]
        ユーザー インターフェイスのレベルを設定します。
        n - なし
        b - 基本
        r - 簡易
        f - 完全 (既定)
    /help
        ヘルプ情報
再起動オプション
    /norestart
        インストール完了後に再起動しません。
    /promptrestart
        再起動が必要な場合は、ユーザーに再起動を要求します。
    /forcerestart
        常に、インストール後コンピューターを再起動します。
ログ オプション
    /l[i|w|e|a|r|u|c|m|o|p|v|x|+|!|*] <LogFile>
        i - 状態メッセージ
        w - 致命的ではない警告
        e - すべてのエラー メッセージ
        a - 操作のスタートアップ 
        r - 特定の操作の記録
        u - ユーザーの要求
        c - UI パラメーターの初期値
        m - メモリ不足または致命的な終了に関する情報
        o - ディスク領域不足メッセージ
        p - ターミナルのプロパティ
        v - 詳細出力
        x - 詳細デバッグ情報
        + - 既存のログ ファイルに追加
        ! - 各行をログにフラッシュ
        * - v オプションと x オプションを除くすべての情報をログに記録します。
    /log <ログ ファイル>
        /l* <ログ ファイル> と指定したときと同じ情報がログに記録されます。

更新オプション
    /update <Update1.msp>[;Update2.msp]
        更新を適用します。
    /uninstall <修正プログラム コード GUID>[;Update2.msp] 
    /package <Product.msi | 製品コード>
        製品の更新を削除します。
修復オプション
    /f[p|e|c|m|s|o|d|a|u|v] <Product.msi | 製品コード>
        製品を修復します。
        p - ファイルが見つからない場合のみ
        o - ファイルが見つからない、または古いバージョンが
            インストールされている場合 (既定)
        e - ファイルが見つからない、同じバージョンまたは古い
            バージョンがインストールされている場合
        d - ファイルが見つからない、または違うバージョンが
            インストールされている場合
        c - ファイルが見つからない、またはチェックサムと計算
            された値が一致しない場合
        a - すべてのファイルをインストールする
        u - すべてのユーザー固有の必須レジストリ エントリ (既定)
        m - すべてコンピューター固有の必須レジストリ エントリ (既定)
        s - すべての既存のショートカット (既定)
        v - ソースから実行して、パッケージをローカルに再キャッシュする
パブリック プロパティの設定
    [PROPERTY=プロパティ値]

コマンド ラインの構文の詳細については、Windows (R) インストーラー SDK を参照してください。

Copyright (C) Microsoft Corporation. All rights reserved.
Portions of this software are based in part on the work of the Independent JPEG Group.

https://docs.microsoft.com/ja-jp/windows/win32/msi/standard-installer-command-line-options
https://docs.microsoft.com/ja-jp/windows/win32/msi/command-line-options

このことより以下のコマンドを使えばいいとわかります。

/i でmsiファイルを指定する。
/passive で「無人モード - 進行状況バーのみ」の表示になります。
/qn でユーザー インターフェイスをなしにできます。
/norestartで再起動をおこないません。

つまり以下のような実行を行うことで自動でインストールが行えます。
今回はTortoiseSVN-1.12.2.28653-x64-svn-1.12.2で実験します。

msiexec /i TortoiseSVN-1.12.2.28653-x64-svn-1.12.2.msi /passive /qn /norestart

image.png

インストール時のオプションを変更したい

上記のインストールを行った場合、コマンドラインからSVNコマンドが実行できません。
これは、デフォルトのインストールの動作が以下のように「command line client tools」が除外されているためです。

image.png

このオプションを変更するには各アプリケーションが持っている特定のプロパティを指定する必要があります。
これを調べる方法については以下にヒントがあります。

https://serverfault.com/questions/384904/retrieving-public-properties-from-an-msi-file
YenForYang氏の回答がベターです。

氏はインストール時のログを以下のコマンドで取得し、それを参考にプロパティを探すべきと言っています。

msiexec /lp! <msi_property_logfile> /i <msi_name>

たとえばTortoiseSVNの場合は以下のように行います。

(1)下記のコマンドを実行する。

msiexec /lp! log_default.txt /i TortoiseSVN-1.12.2.28653-x64-svn-1.12.2.msi

(2)インストーラーが起動するので、何も変更せずにインストールを行う。

(3)アインインストールする。

(4)下記のコマンドを実行する。

msiexec /lp! log_cli.txt /i TortoiseSVN-1.12.2.28653-x64-svn-1.12.2.msi

(5)Command Line Toolを設定した状態でインストールをする。

(6)作成されたログファイルを比較する
image.png

比較をしているとCommand Line Toolを有効にした場合は、ADDLOCALにCLIが追加されていることがわかります。

すなわち以下のようなコマンドでCommand Line Toolを有効にしたインストールが行えます。

msiexec /i TortoiseSVN-1.12.2.28653-x64-svn-1.12.2.msi /passive /qn /norestart ADDLOCAL=F_OVL,DefaultFeature,MoreIcons,CLI,CrashReporter,UDiffAssoc,DictionaryENGB,DictionaryENUS

あとは必要に応じて端末の再起動を行えばSVNのコマンドラインツールが使えるようになります。

まとめ

インストールをGUIなしで自動化できるヒントについてまとめました。
すべてのアプリケーションで有効ではないですが、特定の条件下においては利用できると思います。

カウボーイには嫌がられるPythonの話

はじめに

コードの荒野にはカウボーイといわれる人種がいます。
Code Craftではカウボーイを次のように評価しています。

カウボーイは性急にコーディングに取りかかり、目の前の問題を最小限の労力で解決することを目指します。優れた解決策かどうかは気にしません。コードの構造が崩れようとも、今後の要件にそぐわない点があろうとも、お構いなしです。
カウボーイは1つの作業を済ませて次の作業に移ることに多大な意欲を注ぎます。プロセスについて少し学んだことのあるカウボーイなら、これはアジャイルプログラミングなのだと言うことでしょう。しかし実際は単に怠けているだけです。

本記事で述べる内容は、カウボーイには嫌がられる内容です。
それはコーディングスタイルの話であり、ドキュメンテーションの話であり、テストの話です。
これらの話をEffective Pythonを元に、Pythonではどう進めるかを考えます。

なお、この記事で使用するPythonのバージョンはPython 3.7.4のWindows版になります。

コードのスタイル

Effective Pythonの項目2「PEP8 スタイルガイドに従う」があります。一貫したスタイルに従うことでコードが扱いやすく、読みやすくなり、協同作業が捗ります。

実際、どこぞの役所がPEP8に従わないサンプルコードを出したため、サンドバックになっていたことも記憶に新しいかと思います。
正直、機械的にチェックできることで、時間を使うのは無駄なので静的解析ツールでチェックできる方法を調べます。

なお、5年ほどまえ、こんな記事を書きましたが、さすがに古いです。

pep8を用いてpythonのコードのスタイルをチェックする
https://needtec.sakura.ne.jp/wod07672/?p=9305

pylintでPEP8をチェックする

Effective Pythonでよく使われるといわれていたツールです。

公式
https://www.pylint.org/

インストール方法

pip install pylint

pylintを使用するにはWindowsの場合は以下のようにパスを通す必要があります。

set PATH=C:\Users\[ユーザ名]\AppData\Roaming\Python\Python37\Scripts;C:\Users\[ユーザ名]\AppData\Local\Programs\Python\Python37-32\Scripts;C:\Users\[ユーザ名]\AppData\Local\Programs\Python\Python37-32\;%PATH%

Jenkinsでの集計方法

pylintの結果をJenkinsで集計するためにはViolationsプラグインを使います。
https://wiki.jenkins.io/display/JENKINS/Violations

(1)プラグインマネージャー画面でViolationsをインストールします。
image.png

(2)ジョブを追加してビルドに以下のようなスクリプトを追加する。

Windowsのバッチの例

 del %WORKSPACE%\test.out
 for %%f in (%WORKSPACE%\*.py) do (
   pylint  -f parseable -r y  %%f  >> %WORKSPACE%\test.out
 ) 
 exit 0

(3)ビルド後の処理に「Report Violations」を追加し、「pylint」に出力ファイルを指定します
image.png

(4)ビルド後にレポートが作成されます。
image.png
image.png
image.png

flake8でPEP8を含めたエラーや複雑度をチェックする

pylintと同様によく使われる静的解析ツールにflax8があります。
flax8は下記のツールのラッパーになっています。

https://pypi.org/project/flake8/

インストール方法

pip install flake8

Jenkinsでの使用方法

flake8 コマンド実行時の--formatオプションで「pylint」を指定することでpylintと同じ形式で出力できます。

flake8 --format=pylint --output-file=%WORKSPACE%\test.txt .

pylintとflake8を同時に使用する場合は、Jenkinsの「Report Violations」のpylintの項目に出力ファイルをコンマ区切りで指定することで、複数のレポートを使用できます。

参考
https://github.com/jenkinsci/violations-plugin/issues/51

pylintに対するアドバンテージ

掲示板でpylintとflake8のどちらをつかえばいいかというのが話題になっていました。
「両方つかって、矛盾する警告は好みに合わせて無効にする」という意見が支持を得ているようです。

Any advantages of Flake8 over PyLint?
https://www.reddit.com/r/Python/comments/82hgzm/any_advantages_of_flake8_over_pylint/

Banditでセキュリティの問題をチェックする

Pythonの一般的なセキュリティ問題を見つけるためのツールです。前述の掲示板でお勧めされていたツールです。
https://github.com/PyCQA/bandit

実際にエラーが発生するコードのサンプルは以下を参照してください。
https://github.com/PyCQA/bandit/tree/master/examples

インストール

pip install bandit

Jenkinsでの使用方法

(1)-fオプションを使用してJUnitのテスト結果と同じXML形式を出力します。

bandit -f xml  -r . -o test.xml

(2)JUnitテスト結果の集計で、作成したXMLを指定します。
image.png

(3)banditで出力されたセキュリティの問題はJUnitのテストで失敗したものと同様に出力されます。
image.png

参考:
https://vdwaa.nl/openstack-bandit-jenkins-integration.html

ドキュメンテーション

Effective Pythonの項目49「すべての関数、クラス、モジュールについてドキュメンテーション文字列を書く」とあります。
適切なドキュメンテーションは協同作業を進めるうえで有用です。

ドキュメント文字列のガイドラインとしてはPEP257があります。 
https://www.python.org/dev/peps/pep-0257/

それをベースとした上で細かい流派がいくつかあります。
Google docstrings
https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings

NumPy/SciPy docstrings
https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard

サンプル

test.py

"""testパッケージでござる、一行目の最後はピリオドである必要があります.

複数行書く場合は最初の行の後に、空行をいれる必要があります。
なんか説明
"""

def func(arg_value1, arg_value2):
    """NumPyスタイルのドキュメント.

    Parameters
    ----------
    arg_value1 : int
        引数1の解説.
    arg_value2 : int
        引数2の解説

    Returns
    -------
    int
        戻り値の説明.

    """
    return arg_value1 + arg_value2 + 1

class MyClass:
    """クラスの説明."""

    myclass_value = 0

    def add(self, add_value):
        """Googleスタイルのコメント.

        Args:
            add_value (int): 増加分

        Returns:
            int: 増加後の値

        """
        self.myclass_value = self.myclass_value + add_value
        return self.myclass_value

    def minus(self, minus_value):
        """関数の説明2."""
        self.myclass_value = self.myclass_value + minus_value
        return self.myclass_value

    def error_func(self):
        raise ValueError

    def get_value(self):
        """xxx."""
        return self.myclass_value

ここで記載したドキュメントは__doc__オブジェクト経由で取得できます。

 >>> test.func.__doc__
 '関数の説明.\n\n    ----------\n    arg_value1 : int\n        引数1の解説.\n    arg_value1\n        引数2の解説\n\n    Returns\n    -------\n    int\n        戻り値の説明.\n\n 

VSCodeなどを使用している場合、このドキュメント文字が表示されるようになります。
以下のように、関数をマウスオーバーすることで表示されます。

image.png

ドキュメント文字列のチェック

ドキュメント文字列が適切に記載されているかチェックをする方法はいくつかあります。

pylintのプラグインを使用したチェック

pylintのプラグインであるDocstyle checkerParameter Documentation checkerを使用することでドキュメントストリングの内容をチェックできます。

pylint --load-plugins=pylint.extensions.docparams,pylint.extensions.docstyle test.py

pydocstyleを用いたチェック

PEP257のガイドラインに適応しているかどうかをチェックするには「pydocstyle」を使用します。
https://pypi.org/project/pydocstyle/

また、flake8ではpydocstyleを利用したプラグインが存在するので、それを利用すると簡単にJenkinsと連携できるようになります。

インストール方法:

pip install pydocstyle=="3.0.0"
pip install flake8-docstrings

※pydocstyleは2019/8/1時点で4.0.0がリリースされていますが、それはflaske8-docstringsと整合性がないため、バージョンを落としてインストールしています。
https://gitlab.com/pycqa/flake8-docstrings/issues/36

使用例

flake8を通常どおり実行するだけでドキュメントのチェックが行われます。

C:\dev\python3\doctest>flake8 test.py
test.py:50:1: D102 Missing docstring in public method

HTMLドキュメントの作成

Pythonのドキュメント文字列をもとにDoxygenのようにHTML文章を作成することが可能です。
作成にはsphinxを使用します。
https://sphinx-users.jp/

インストール方法

pip install sphinx

使用例

以下のようなファイル構成があるとします。

C:\DEV\PYTHON3\DOCTEST
    main.py
    test.py
    unittest_example.py
    unittest_example2.py

(1)sphinx-quickstartを実行してドキュメント用のフォルダを作成します。

cd doctest
sphinx-quickstart docs

sphinx-quickstartコマンドにより、対話式で質問が始まります。
「Project name」と「Author name(s)」に任意の値を入力して、あとはデフォルトとしています。

C:\dev\python3\doctest>sphinx-quickstart docs
Welcome to the Sphinx 2.1.2 quickstart utility.

Please enter values for the following settings (just press Enter to
accept a default value, if one is given in brackets).

Selected root path: docs

You have two options for placing the build directory for Sphinx output.
Either, you use a directory "_build" within the root path, or you separate
"source" and "build" directories within the root path.
> Separate source and build directories (y/n) [n]:

The project name will occur in several places in the built documentation.
> Project name: projectName
> Author name(s): author
> Project release []:

If the documents are to be written in a language other than English,
you can select a language here by its language code. Sphinx will then
translate text that it generates into that language.

For a list of supported codes, see
https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-language.
> Project language [en]:

Creating file docs\conf.py.
Creating file docs\index.rst.
Creating file docs\Makefile.
Creating file docs\make.bat.

Finished: An initial directory structure has been created.

You should now populate your master file docs\index.rst and create other documentation
source files. Use the Makefile to build the docs, like so:
   make builder
where "builder" is one of the supported builders, e.g. html, latex or linkcheck.

この時点のファイルの構成は以下の通りです。

C:\DEV\PYTHON3\DOCTEST
│  main.py
│  test.py
│  unittest_example.py
│  unittest_example2.py
│
└─docs
    │  conf.py
    │  index.rst
    │  make.bat
    │  Makefile
    │
    ├─_build
    ├─_static
    └─_templates

(2)conf.pyを修正します。
docsフォルダに作成された「conf.py」を以下のように修正します。

conf.py


# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('../')) # ソースコードのある場所

# 略
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions =  ['sphinx.ext.autodoc','sphinx.ext.viewcode']

(3)sphinx-apidocコマンドを用いてrstファイルを生成します。

sphinx-apidoc -f  -o ./docs .

pythonファイルに対応したrstファイルが作成されます。
この時点のファイルの構成は以下の通りです。

C:\DEV\PYTHON3\DOCTEST
│  main.py
│  test.py
│  unittest_example.py
│  unittest_example2.py
│
└─docs
    │  conf.py
    │  index.rst
    │  main.rst
    │  make.bat
    │  Makefile
    │  modules.rst
    │  test.rst
    │  unittest_example.rst
    │  unittest_example2.rst
    │
    ├─_build
    ├─_static
    └─_templates

(4)「sphinx-build」コマンドを使用してHTMLドキュメントを作成します。

sphinx-build -b html ./docs ./docs/_build

これにより「docs/_build」中にHTMLドキュメントが作成されます。
image.png

image.png

image.png

ユニットテスト

Effective Python の項目56に「unittestですべてをテストする」とあります。
ここでは次のようにかかれています。

Pythonプログラムで確信が持てる唯一の方法はテストを書くことだ

あやしげな実装を後工程に全てをぶん投げて、やり逃げダイナミックをかますビジネスモデルもあることはありますが、保安官に吊るされないためにも、ユニットテストを書いて動作確認を行うことは有効だと思います。

標準のunittest用のフレームワーク

Pythonでは標準でユニットテスト用のフレームワークが用意してあります。
https://docs.python.org/ja/3/library/unittest.html

テストケースの例

unittest_example.py

import unittest
import test

class SampleTestCase(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        # 開始時に1回呼ばれる
        print("setUpClass")

    @classmethod
    def tearDownClass(cls):
        # 終了時に1回呼ばれる
        print("tearDownClass")

    def setUp(self):
        # テスト開始毎に呼ばれる
        print("setup")

    def tearDown(self):
        # テスト毎に呼ばれる
        print("tearDown")

    def test_1(self):
        # テストが成功する例
        self.assertEqual(test.func(5, 3), 9, 'test.func(5, 3)の試験')

    def test_2(self):
        # テストが失敗する例
        self.assertEqual(test.func(5, 3), 8, 'test.func(5, 3)の試験')

    @unittest.skip("demonstrating skipping")
    def test_3(self):
        # スキップの例
        self.assertEqual(test.func(5, 3), 8, 'test.func(5, 3)の試験')

    def test_4(self):
        # 例外の発生を確認する場合で例外がでる場合
        m = test.MyClass()
        with self.assertRaises(ValueError):
            m.error_func()

    def test_5(self):
        # 例外の発生を確認する場合で例外がでない場合
        m = test.MyClass()
        with self.assertRaises(ValueError):
            m.add(1)

if __name__ == '__main__':
    unittest.main()

テスト実行例

# unittest.main()を実装していない場合
# python -m unittest unittest_example.py
#
# unittest.main()を実装している場合
# python unittest_example.py

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

C:\dev\python3\doctest>python -m unittest unittest_example.py
setUpClass
setup
tearDown
.setup
tearDown
Fssetup
tearDown
.setup
tearDown
FtearDownClass

======================================================================
FAIL: test_2 (unittest_example.SampleTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\dev\python3\doctest\unittest_example.py", line 30, in test_2
    self.assertEqual(test.func(5, 3), 8, 'test.func(5, 3)の試験')
AssertionError: 9 != 8 : test.func(5, 3)の試験

======================================================================
FAIL: test_5 (unittest_example.SampleTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\dev\python3\doctest\unittest_example.py", line 47, in test_5
    m.add(1)
AssertionError: ValueError not raised

----------------------------------------------------------------------
Ran 5 tests in 0.007s

FAILED (failures=2, skipped=1)

モックの利用

unittest.mockを使用することでテスト対象が依存している関数をテスト用に置き換えることが可能です。
https://docs.python.org/ja/3/library/unittest.mock.html

例えばテスト対象の以下の関数が存在したとします。

main.py

import test

def test1():
    m = test.MyClass()
    m.add(10)
    return m.get_value()

MyClassの各メソッドを置き換えるためにpatchを利用します。


import unittest
import test
import main
from unittest.mock import patch

class SampleTestCase2(unittest.TestCase):
    @patch('test.MyClass.minus')     # minus_object
    @patch('test.MyClass.add')       # add_object
    @patch('test.MyClass.get_value') # get_value_object
    def test_1(self, get_value_object, add_object, minus_object):
        # 戻り値の偽装
        get_value_object.return_value = 3

        # テスト対象の関数を実行
        ret = main.test1()

        print(ret)

        # 当該関数が実行されているかの確認
        self.assertTrue(add_object.called, 'addが実行されている')

        # 当該関数が実行される場合の引数の確認。複数ある場合は(1,2,3)
        add_object.assert_called_with(10)

        # 当該関数が実行されているかの確認
        self.assertFalse(minus_object.called, 'minusが実行されていない')

        # 当該関数が実行されているかの確認
        self.assertTrue(get_value_object.called, 'get_value_objectが実行されている')

        self.assertEqual(ret, 3, 'モックテスト')

Jenkinsでの集計方法

Jenkinsで集計するためにはXMLで出力する必要があるのでxmlrunnerを利用します。

インストール方法

pip install xmlrunner

XML出力用のコード例

import unittest
import xmlrunner

test_suite = unittest.TestSuite()
all_test_cases = unittest.defaultTestLoader.discover('.','*.py')
# Loop the found test cases and add them into test suite.
for test_case in all_test_cases:
    test_suite.addTests(test_case)

test_runner = xmlrunner.XMLTestRunner(output="./result")
test_runner.run(test_suite)    

参考:
https://www.dev2qa.com/python-3-unittest-html-and-xml-report-example/

Jenkinsでの使用例

(1)ビルド時のコードに以下を追加します。

rmdir /q /s result
coverage run unittest_runner.py

(2)ビルド後の処理に「JUnitテスト結果の集計」を追加します。
image.png

(3)ビルド後にテスト結果としてレポートされます。
image.png

image.png

カバレッジの集計

Coverage.pyを使用することでカバレッジを計測できます。
https://coverage.readthedocs.io/en/v4.5.x/index.html

インストール

pip insatll coverage

使用方法:

# コードを指定してカバレッジの集計
coverage run unittest_example.py

# カバレッジの出力
coverage report -m
Name                  Stmts   Miss  Cover   Missing
---------------------------------------------------
test.py                  14      3    79%   47-48, 55
unittest_example.py      27      1    96%   35
---------------------------------------------------
TOTAL                    41      4    90%

# カバレッジの集計データを削除
coverage erase

カバレッジの出力時に以下のコマンドを実行することでHTMLで出力も可能です。

coverage html

カレントディレクトリにhtmlcovフォルダが作成されてカバレッジのレポートが作成されます。
image.png

image.png

Jenkinsでの集計方法

(1)Cobertura PluginをJenkinsのプラグインマネージャでインストールします。
https://wiki.jenkins.io/display/JENKINS/Cobertura+Plugin

(2)以下のコマンドを記載することで、coverage.xmlが作成されます。

coverage erase
coverage run unittest_runner.py
coverage xml
coverage erase

(3)ビルド後の処理として「Coberturaカバレッジレポートの集計」を追加します。
image.png

(4)ビルド後にコードカバレッジのレポートが作成されます。
image.png

まとめ

Effective Pythonを読んで気になったコードスタイル、ドキュメンテーション、テストの分野を調べてみました。
ドキュメントやテストなどの地味で退屈といわれている分野なので、コードの荒野にいるカウボーイに嫌われますが、ギャレットに射殺されないためには覚えておいて損はないと思います。

Windowsで「そのファイル操作、やっぱなしで」ができる TransactionalFileMgr

はじめに

データベースの場合は、挿入、更新、削除をやったあとで取り消したければロールバックできます。
地上の哀れな羊は、データベースと同様にファイルにおいてもコピー、移動、削除、変更を行ったあとで、やっぱり取り消しが行えるよう神たるマイクロソフトに祈りました。

偉大なるマイクロソフトはVista開発時に、Transactional NTFS(TxF)を遣わし、哀れな羊を救いを与えました。

しかし、この救いはWindows8の頃から非推奨となり、「Transaction NTFSの代替を考えておくよう」、ファイルだけでなく自分自身をロールバックするというアメリカンジョークをなさいました。

神に見捨てられた哀れな羊たちはStackOverflowに集い、代替方法を考えることになりました。

Alternatives to using Transactional NTFS
https://stackoverflow.com/questions/13420643/alternatives-to-using-transactional-ntfs

この際、NuGetより救いの手を差し伸べたのが.NET Transactional File Managerなのです。

TransactionalFileMgr .NET Transactional File Manager
https://archive.codeplex.com/?p=transactionalfilemgr

インストール方法

NuGetでTxFileMangerをインストールしてください。
image.png

1.3と2.0があります。2.0は2019年の4月ころに追加されたもので、いくつかの機能が追加されているようですが、今回は1.3を使用することにします。

サンプル

using ChinhDo.Transactions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Transactions; // System.Transactions.dll

namespace FileTest
{
    class Program
    {
        static void Main(string[] args)
        {
            TxFileManager fileMgr = new TxFileManager();
            using (TransactionScope scope1 = new TransactionScope())
            {
                try
                {
                    // 「newfolder」ディレクトリを作成
                    fileMgr.CreateDirectory(@"test\a\newfolder");

                    // 「newfolder\aaa.txt」ファイルを作成
                    fileMgr.WriteAllText(@"test\a\newfolder\aaa.txt", "あああああ");

                    // 「newfolder\aaa.txt」ファイルに追記
                    fileMgr.AppendAllText(@"test\a\newfolder\aaa.txt", "いいいい");

                    // test.txt→test_renamed.txt に変更
                    fileMgr.Move(@"test\a\test.txt", @"test\a\test_renamed.txt");

                    // WriteAllTextはBOMなしのUTF8になるので、エンコードを変えたかったりバイナリを操作したい場合はSnapshotを行う
                    fileMgr.Snapshot(@"test\a\test_renamed.txt");
                    System.IO.StreamWriter sw = new System.IO.StreamWriter(@"test\a\test_renamed.txt", false, System.Text.Encoding.Unicode);
                    sw.Write("ああああああ");
                    sw.Close();

                    String ret;
                    Console.WriteLine("Completeを実行する場合は[Y]を入力してください");
                    ret = Console.ReadLine();
                    if (ret.Equals("Y"))
                    {
                        scope1.Complete();
                        Console.WriteLine("ファイル操作完了");
                    }
                    else
                    {
                        Console.WriteLine("ロールバック");
                    }

                }
                catch (Exception ex)
                {
                    Console.WriteLine(ex);
                }
            }
            Console.ReadLine();
        }
    }
}

サンプルの実行結果

(1)実行前
image.png

(2)Complete実行前
image.png

(3-1)Completeした場合
image.png
・・・(2)より変化なし

(3-2)Completeしない場合
image.png
・・・(1)と同じになる

サンプルの解説

・TransactionScope 内のTxFileManager経由のファイル操作についてトランザクション処理を行います。

・scope1.Complete()を行った場合は、TxFileManager経由で行ったファイル操作は、そのままになります。
・Complete()を行わないでスコープを抜けた場合は元に戻ります。
※ただし、バックアップから地道に差し戻しているだけなので、ファイルがロックされていたり、アプリケーション自体が強制終了された場合は元に戻りません

サポートしている機能について

説明の詳細についてはTxFileManager.csと各操作の~Operation.csを見て記載しています。

AppendAllText(string path, string contents)

指定のファイルに追記を行う。

トランザクションの外では「File.AppendAllText」を実行するのみ

トランザクション内では操作対象のファイルが存在する場合はバックアップとしてコピーしてから「File.AppendAllText」を実行する。
ロールバック時にバックアップから復元される。仮に今回のトランザクションで新規で作成したファイルだった場合は、ロールバック時に削除される。

Copy(string sourceFileName, string destFileName, bool overwrite)

コピー元のパスからコピー先のパスにファイルのコピーを行う

トランザクションの外では「File.Copy」を実行するのみ

トランザクション内ではコピー先のファイルが存在する場合はバックアップとしてコピーしてから「File.Copy」を実行する。
ロールバック時にバックアップから復元される。

CreateDirectory(string path)

ディレクトリの作成を行う。

トランザクション外では「Directory.CreateDirectory」を実行するのみ
トランザクション内ではすでに存在しているディレクトリか記憶してから「Directory.CreateDirectory」を実行する。
ロールバック時はすでに存在していた場合はなにもしないが、存在しておらずトランザクションで作成されたディレクトリの場合は削除をする。

DeleteDirectory(string path)

ディレクトリの削除を行う。

トランザクション外では「Directory.Delete」を実行するのみ。
トランザクション内では削除対象のディレクトリをバックアップに移動する。
ロールバック時には、バックアップから、移動したディレクトリを元の位置に戻す。

Delete(string path)

ファイルの削除を行う。

トランザクション外では「File.Delete(path)」を実行する。
トランザクション内ではバックアップに削除対象のファイルをコピーしてから「File.Delete(path)」を実行する。
ロールバック時にバックアップから復元する。

Move(string srcFileName, string destFileName)

ファイルの移動を行う。
トランザクション外では「File.Move」を実行する。
トランザクション内では移動元と移動先を記憶してから「File.Move」を実行する。
ロールバック時には移動先から移動元にファイルを移動することで元に戻す。

Snapshot(string fileName)

ファイルのスナップショットを保存しておく。
これにより、ロールバック時にファイルを元に戻すことができる。

トランザクション外では何もしない

WriteAllText(string path, string contents)

指定のファイルの作成または上書きをする。

トランザクションの外では「File.WriteAllText」を実行するのみ

トランザクション内では操作対象のファイルが存在する場合はバックアップとしてコピーしてから「File.WriteAllText」を実行する。
ロールバック時にバックアップから復元される。仮に今回のトランザクションで新規で作成したファイルだった場合は、ロールバック時に削除される。

まとめ

アプリケーションの強制終了や、別プロセスの操作などを考えた場合、完璧ではないとはいえませんが、簡単なファイル操作であればTransactionalFileMgrでロールバックが行えることがわかりました。

また、ソースコードがダウンロードできるので、機能拡張もわりと容易に行えるかと思います。

JavaScriptのカバレッジ計測ツール, JsCover

JsCoverの概要

JSCoverは、JavaScriptプログラムのコードカバレッジを測定するツールです。
Webブラウザで実行される前に、JavaScriptコードに計測用のコードを追加することによって機能します。

JsCoverをHTTPサーバーとして起動させて、QUnitのようにHTMLにテストコードを記載し、JavaScriptのカバレッジを計測することができます。

image.png

この際、サーバーと通信する箇所はsinonなどのモックを利用して、ダミーコードを用意するとテストが容易になります。

また下記のようにプロキシサーバーとしてJsCoverを起動することも可能です。
image.png

詳細は下記を参照してください。
https://tntim96.github.io/JSCover/manual/manual.xml

チュートリアル

導入方法

Java1.6以上を取得後、下記からダウンロードを行います。
https://tntim96.github.io/JSCover/

HTTPサーバーでの実行例

解凍後の「JSCover-X.X.X\target」中のJSCover-all.jarを下記のようなコマンドで実行することでJsCoverはHTTPサーバーとして起動します。

java -jar target/dist/JSCover-all.jar -ws --document-root=doc/example --report-dir=target

--document-rootにはhtmlやjsファイルが存在するディレクトリのルートパスを指定して、--report-dirにはカバレッジの計測結果が格納されるディレクトリを指定します。

次に、ブラウザを用いて「http://localhost:8080/jscoverage.html」にアクセスしてJSCover用の画面を表示します。

image.png

URLの入力ボックスに「http://localhost:8080/index.html」を入力して「Open in frame」ボタンを押下します。

image.png

これにより、フレーム内にテスト対象のページが表示されました。
では、「Two」オプションを選択してみましょう。

image.png

JavaScriptが実行されて画面が更新されます。
この状態で、カバレッジを確認するには、「Summary」タブをクリックします。

image.png

「Summary」画面では、File、Coverage、Branch、またはFunctionの列をクリックしてソート順を変更できます。
カバーされていない行を確認するには「Show missing statements column」チェックボックスにチェックを付けます。
image.png
Missingという列が追加されて、カバーされていない行が表示されます。

ソースコードを見るにはFile列のファイル名をクリックするか、Missing列の行番号をクリックします。その結果は以下のようになります。
image.png

カバーされた行は緑となり、カバーされていない行は赤になります。
今回は「Two」オプションをクリックした場合の分岐のみが実行されていることがわかります。

また、分岐においてどの条件が実行されたかを確認するには「info」ボタンを押下します。
9行目をクリックすると以下のようなメッセージボックスが表示されます。
image.png

element.id=='radio1'がtrueになる条件をみたしていないと通知されます。

カバレッジの計測結果を外部ファイルに保存するには「Store」タブを押下後、「Store Report」ボタンを押下することで「jscoverage.json」というファイル名で起動時に指定したレポート出力フォルダに出力されます。
image.png

URLを再読み込みしても、JsCoverのサーバーが起動中はカバレッジは累積して計測されます。
1から計測しなおしたい場合はJsCoverを再起動してください。

プロキシサーバーでの実行例

HTTPサーバーで実行する場合、HTTPサーバーはJsCoverが提供したHTTPサーバになります。
このためWebServerとのやりとりのコードをスタブに置き換えたりする必要があったり、JSPやASPXをHTMLに置き換えて実行する必要があります。

なるべくテスト用のコードを記載せず、現在、動いている環境を利用して計測するにはJsCoverをプロキシサーバーとして起動します。

java -jar C:\tool\JSCover-2.0.8\target\dist\JSCover-all.jar -ws --proxy --port=3128   --report-dir=C:\tool\JSCover-2.0.8\myfolder

--proxyを付与することで、プロキシサーバーとして起動します。
あとはブラウザのプロキシに「127.0.0.1:3128」を指定して手動で動かすか、下記のようにSeleniumで動作させることで、指定のウェブページをJsCoverのプロキシサーバー経由で計測することによりカバレッジが計測できるようになります。

C#のSeleniumによるカバレッジの取得例

まずSelenium用のライブラリを取得します。
image.png

以下の例は、指定のURLをプロキシ経由で開いてradio2をクリックしたのちに、カバレッジを取得する例です。

using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;

namespace selenium_test
{
    class Program
    {
        static void Main(string[] args)
        {
            // Chrome
            var option = new ChromeOptions();
            option.Proxy = new Proxy();
            option.Proxy.Kind = ProxyKind.Manual;
            option.Proxy.IsAutoDetect = false;
            option.Proxy.HttpProxy = "127.0.0.1:3128";
            //option.Proxy.SslProxy = "127.0.0.1:3128";

            using (var chrome = new ChromeDriver(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), option))
            {
                Test(chrome);
                chrome.ExecuteScript("jscoverage_report();");
            }
        }

        static void Test(IWebDriver _webDriver)
        {
            // https://github.com/tntim96/JSCover-samples/blob/master/src/test/java/jscover/webdriver/proxy/WebDriverGeneralProxyTest.java
            _webDriver.Url = "http://tntim96.github.io/JSCover/example/";
            IWebElement element = _webDriver.FindElement(By.Id("radio2"));
            element.Click();
        }
    }
}

Seleniumをプロキシ経由で動作させて、テスト用の操作終了後、JavaScriptの「jscoverage_report();」を実行することで、レポート用のディレクトリにJSONファイルが吐き出されます。

Jenkinsでのカバレッジの表示方法

JSCoverで計測したレポートはJenkinsに表示させることも可能です。
その手順は以下のようになります。

(1)JenkinsのプラグインでCoberturaをインストールします。
image.png

(2)JSCoverのコマンドを用いて「jscoverage.json」から「cobertura-coverage.xml」を生成します。

java -cp JSCover-all.jar jscover.report.Main --format=COBERTURAXML jscoverage.jsonのあるフォルダ JavaScriptのソースのあるフォルダ

Jenkinsで使用する場合、「cobertura-coverage.xml」とJavaScriptのあるフォルダはワークスペース上に配置するようにした方がいいでしょう。

(3)Jenkinsのジョブのビルド後イベントで「Cobertura カバレッジ・レポートの集計」を追加する
image.png

(4)Jenkinsでビルドが成功するとカバレッジのレポートが作成されます。
image.png

image.png

image.png

LocalStorageモード

JsCover起動時に「--local-storage」を付与することで、ブラウザのローカルストレージにカバレッジ情報を記録することができます。これにより、JSCoverを再起動しても継続してカバレッジを収取しつづけることが期待できます。

具体的にはページの切り替わりのタイミングでローカルストレージの「jscover」キーにカバレッジの情報を記録するような動きになります。

詳細は下記を参考にしてください。
https://tntim96.github.io/JSCover/manual/manual.xml#localStorage

まとめ

JSCoverを使用することでJavaScriptのカバレッジの計測が行えるようになりました。
また、プロキシモードを用いることで既存のコードをテストコードを追加せずに、そのまま計測することも可能です。
ただし、内部的には計測用にJavaScriptを置き換えているため、プロダクトコードをそのまま確認したいテストの場合に使用は控えたほうがよいです。(例:速度計測とかする場合)

SikuliXによるスクリーンショット(苦行)の効率化について

目的

SikuliX1.1.4ではスクリーンを監視して変更が生じたらスクリーンキャプチャをとることが容易に可能である。
これにより、スクリーンキャプチャの苦行の労力を減らせるかを検討する。

Sikulix1.1.4については下記参照
https://needtec.sakura.ne.jp/wod07672/?p=9202

コード

from datetime import datetime
import threading
import time
import sys
# 保存先のフォルダ
captureFolder = "C:\\tool\\sikulix\\log\\"

# 50ピクセル変更したらchangedを呼び出す。ここで調整する
changePixel = 50

threadWaitStopFunc = None
stopFlg = False

# 停止用のスレッド
def waitStopFunc():
    global stopFlg
    popup(u"キャプチャを停止する場合は [OK]を押下してください")
    stopFlg = True

# 範囲内の変更発生時のイベント
def changed(event):
    global stopFlg
    global threadWaitStopFunc
    print ('changed' + datetime.now().strftime('%Y%m%d_%H%M%S%f')[:-3])
    try:
        capture(selRegion, captureFolder, datetime.now().strftime('%Y%m%d_%H%M%S%f')[:-3])
    except:    
        print(sys.exc_info())
        stopFlg = True

# 範囲選択が表示されてRegionを選択する
selRegion = selectRegion(u"select caputure region.")
if selRegion is None:
    exit(1)

selRegion.onChange(changePixel, changed)

# 監視 alt-shift-c
selRegion.observeInBackground(FOREVER); 

threadWaitStopFunc = threading.Thread(target=waitStopFunc)
threadWaitStopFunc.start()

while True:
    if stopFlg:
        break
    wait(1)

popup(u'キャプチャが停止しました')

selRegion.stopObserver()

※画像ファイルの作成に失敗した場合は「キャプチャが停止しました」というウィンドウが表示されるので、その場合は保存フォルダを見直す。

使ってみる

  1. 実行すると矩形が選択できるので、監視したい範囲を選択する。
    image.png

  2. 以下のポップアップが表示されるのでOKを押さないで邪魔にならない位置にどかしておく
    image.png
    監視を終了したくなったらOKをおして処理を停止する。

  3. 監視対象の画面を操作する。
    今回はコマンドプロンプトにて以下の操作を行った
    ・dirコマンド実行
    ・プロパティを表示

  4. 3の操作後に作成された画像ファイル
    20190610_172512624.png
    20190610_172513288.png
    20190610_172513620.png
    20190610_172514295.png
    20190610_172519636.png
    20190610_172551705.png
    20190610_172552031.png
    20190610_172552351.png
    20190610_172553022.png
    20190610_172553355.png
    20190610_172554021.png

使用感

・一文字づつのキー押下や、ウィンドウが表示される過程の半透明のものもキャプチャされてしまう
・とはいえ、一枚一枚心を込めてスクリーンショットを撮る苦行よりはまし。
 (あとで間引くか、大した量でないならそのまま納品でいいはず)
・同様のことは動画でもできる。
 ただ、画像で保存するアドバンテージとしては吹き出しつけたりの編集の容易性。

感動的なツールだな、だが、無意味だ。
※SikuliX1.1.4は64bitのJavaが必要なので、こういうのが本当に必要な職場はどうせ、32ビットマシンしか支給されていないので使えない。仕方ないね。

客先常駐の仕事で1%も出てこないけど知っとくとそれなりに役に立つ話をいくつか

はじめに

以下のような記事があって、概ね真実であったりします。
客先常駐の仕事で99%出てくる頻出ソフトウェア・ツール三種の神器
https://qiita.com/neet_se/items/ea147c83100a21fc1985

99%の場合、もうこれで十分ですし、事実、多くのことに目を瞑ればそれなりに幸せにすごせます。
しかし、世の中にはそういう環境に反逆して、どんな状況でも俺の正義を見せてやるというドン・キホーテがいますので、そういう人向けの記事です。

Excel

IT技術を自称する会社のなかにはフリーソフトやOSSを制限し、独力で世界と戦うという縛りプレーをしている会社があります。
しかし、そこにおいてもたいてい、Officeは入っています。
そうです、VBAつかって自動化しましょう。

例:

RPA九人衆による「アカネチャンカワイイヤッタ」の自動化
https://needtec.sakura.ne.jp/wod07672/?p=9204#vba%E3%81%AB%E3%82%88%E3%82%8B%E5%AE%9F%E8%A3%85

VBAでInternetExplore上のJavaScriptを無理やり動かすよ!
https://needtec.sakura.ne.jp/wod07672/?p=9210

この辺を応用するとExcelでSeleniumっぽいなにかができる謎ツールだって自作できます。
慣れないうちは時間がかかるかもしれませんが、3、4回、そういうとこでやると、片手間の数日で自作できるようになります。
※なお、基本持ち出しはできないので、どっかに公開しておいてそれを利用するっていうのが楽だとおもいます(3敗)

ChromeとFireFoxの自動化ができない?
どうせ、そういう環境だと、そんなもんインストールしてないから平気です。

PowerShellかVBS

ExcelVBAだとExcelを開くの面倒という兄貴、姉貴もいると思いますが、そういうときはPowerShellとVBSです。
コマンドプロンプトから実行できるので、タスクスケジューラとか利用して定期処理がはかどります。

例:
茜ちゃんをPowerShellで弄る
https://needtec.sakura.ne.jp/wod07672/?p=9207

PowerShellのUIAutomationは複雑怪奇なり
https://needtec.sakura.ne.jp/wod07672/?p=9206

レガシー環境のためのWindows Script Host(WSH)の解説
https://needtec.sakura.ne.jp/wod07672/?p=9288

ExcelとPowerShell、VBSの使い分けは、慣れているほうでいいと思います。PowerShellのPS1ファイルはバッチファイル経由なら権限気にしないですみます。

ただし、PowerShellなら、がんばってWindowsAPIを叩けるうえ、.NETのすべてが利用できるので、今から覚えるならPowerShellを覚えるのがいいでしょう。
あと、PowerShellを覚えるのがつらい兄貴、姉貴はPowerShellの文法を最低だけおさえて、PowerShellからC#を使うという方法で学習コストを減らすこともできると思います。

だいたいどこの現場でも入っているツール

だいたいどこの現場でも入っているツールがいくつかあります。これらのツールはSIer批判の文脈で登場すると流れ弾が飛んできますが、実際、それらを完全に使いこなせている人は少ないです。

TeraTermのマクロ

だいたいどこでも入っているTeraTermですが、マクロが組めます。
https://mag.osdn.jp/10/01/08/0825239

覚えておくと、たまにやくに立ちます。

Sakuraエディタ

SIerご用達のエディタですが、コマンドラインからGrepしたり、マクロ機能があったりして以外と自動化に便利です

サクラエディタの便利そうな機能
https://needtec.sakura.ne.jp/wod07672/?p=9179

WinSCP

サーバーにファイルをアップロードする際にWinSCPを使っている会社はたくさんありますが、実はコマンドラインから操作したり、PowerShellでDLLを参照して自動化できたりします。

WinSCPを自動化しても別にかまわんのだろ
https://needtec.sakura.ne.jp/wod07672/?p=9178

二人で目視確認しながら慎重にアップロードするとかいう、アレな作業手順が改善できると思います。

WinMerge

WinMergeも以外と隠しコマンドラインオプションがあったりして、レポートの自動作成とかができます。

WinMergeのコマンドラインオプション
https://needtec.sakura.ne.jp/wod07672/?p=9176

あと、VBSで色々なプラグインが簡単に書けます。

WinMergeでSQLiteの比較を行う方法
https://needtec.sakura.ne.jp/wod07672/?p=9274

HTTPSの閲覧制限を突破する

HTTPS閲覧制限をしている環境でgithubやqiitaを見る方法はいくつかあります。
まず、レンタルサーバーを借りるか、自社のサーバーを借ります。
そのうえで、上記のサーバー経由で目的のサイトをPDF化して取得します。
お手軽にできるPHPでやるならtcpdfでPDF化するといいでしょう。
https://github.com/tecnickcom/tcpdf

こんな感じで使います。

<?php
if(!isset($_GET['target']))
{
  echo "No URL data.";
  exit();
}
$url = $_GET['target'];
require_once("./TCPDF/tcpdf.php");

// ここで警告がでるなら以下をためす
// https://stackoverflow.com/questions/55526568/failed-loading-cafile-stream-in-file-get-contents
$html = file_get_contents($url);

error_reporting(0);
$tcpdf=new TCPDF('Portrait');
$tcpdf->AddPage();
$tcpdf->SetFont("kozminproregular", "", 10);
$tcpdf->WriteHTML($html);
ob_end_clean();
$tcpdf->Output();

なお、客先からアクセスできるサーバーがあると色々便利ですが、アップロード系はヤバイのでやめましょう。
あと、インターネット自体制限してたらあきらめましょう。しかたないね。

PCの管理者権限を得ずにインストールせずにmsiの中身をとりだす。

Windowsで.MSIファイルを解凍して内部のファイルを取り出す(msiexec編)
https://www.atmarkit.co.jp/ait/articles/0703/02/news130.html

msiexecを使えば任意のフォルダに展開できますね。

ただし、無断でソフトウェアを使うってのは避けた方がいいです。
レジストリや特殊フォルダとか書き込む系統のやつだと、管理ツールによってはバレます。
プロセスモニターあたりで、レジストリや特殊フォルダを監視して対象のアプリの動きを観察してからやる方法もありますが、そんな手間とリスクをとってやる価値は色々な意味でないです。

あくまで許可されているものが、PCの管理者権限がなくてインストールできない場合に使いましょう。
よくつかうのはtortoise svnからコマンドラインのSVNを抜きだすのに使います。この際は、システムディレクトリに書き込むdllはsvn.exeと同一フォルダに配置しとけば動きます。

ExcelでSVNのコミット履歴を管理して云々とかいう苦行をやっている場合は、VBAからSVNコマンド叩いて動かすようにしましょう。
これも同じようなツールを2,3回つくったので、たぶんよく使うんだと思います。

Javaな環境ならPHP

みんな大好きJavaで開発している環境では、pleiadesあたりをつかっていると思います。
つまり、xamppがあるのでPHPで動くものなら、Webアプリが作れますね。

wikiつくったり、Jenkinsっぽいなにかを作ったり色々できますので、PHPを覚えておくと色々便利です。
ただし、PCの管理者権限がない環境だと、ファイアウォールの設定で、つみそうなので、そこではあきらめましょう。

こういう環境で避けたほうがいい技術

交渉でツールやプログラミング言語をインストールできる環境であっても、この手の職場で避けた方がいい技術があります。
Pythonやnode.jsのようにpipやnpmでパッケージを取得するのは、そのたびに申請がいるので辞めといた方が無難です。
パッケージが依存しているパッケージを調べて申請云々とかは非常にめんどくさいです。

あと、この手の環境でPythonやnode.jsを知っている人間は絶滅危惧種より少ないので、保守性にも問題がでてきます。

おわりに

いかがでしたでしょうか?
特に、これからSIerやSESで働きだす人はご参考にならなかったと思いますが、今、SIerやSESで働いていてソウルジェムが濁ってきた人には、ご参考になったのではないでしょうか?

色々と悩みや課題はあると思います。
だけど、あきらめたらそこで試合終了なので色々やってみると色々突破口があると思います。













なお、私はあきらめました。