C#とPowerShellで色々なDBを操作してみる

前書き

C#とPowerShellで色々なDBを操作してみます。
環境は以下の通りです。

クライアントの環境

  • PowerShell 5.1(32bit)
  • VisualStudio 2019 .NET Framework 4.7.2(32bit)

操作対象のDatabase

  • SQLite3
  • MDB
  • Ver 15.1 Distrib 5.5.60-MariaDB
  • PostgreSQL 9.2.24
  • Oracle12
  • SQLServer2017

操作対象のテーブル

CREATE TABLE test_tbl
(
  user_name varchar(50),
  age integer
)

PowerShellのusingについて

PowerShellでのusingを用いたリソースの解放処理が標準で存在していなかったので下記を利用する。

Dave Wyatt's Blog Using-Object: PowerShell version of C#’s “using” statement.
https://davewyatt.wordpress.com/2014/04/11/using-object-powershell-version-of-cs-using-statement/

function Using-Object
{
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowEmptyCollection()]
        [AllowNull()]
        [Object]
        $InputObject,

        [Parameter(Mandatory = $true)]
        [scriptblock]
        $ScriptBlock
    )

    try
    {
        . $ScriptBlock
    }
    finally
    {
        if ($null -ne $InputObject -and $InputObject -is [System.IDisposable])
        {
            $InputObject.Dispose()
        }
    }
}

SQLite3

外部ライブラリのSystem.Data.SQLiteを利用してデータベースの操作を行います。

事前準備

NuGetで下記をインストールする

  • System.Data.SQLite.Core

C#

using System;
using System.Data.SQLite;

namespace sqliteSample
{
    class Program
    {
        // https://www.ivankristianto.com/howto-make-user-defined-function-in-sqlite-ado-net-with-csharp/
        [SQLiteFunction(Name = "ToUpper", Arguments = 1, FuncType = FunctionType.Scalar)]
        public class ToUpper : SQLiteFunction
        {
            public override object Invoke(object[] args)
            {
                return args[0].ToString().ToUpper();
            }
        }

        static void Main(string[] args)
        {
            if (System.IO.File.Exists("database.db"))
            {
                System.IO.File.Delete("database.db");
            }
            using (var conn = new SQLiteConnection("Data Source=database.db; Version = 3; New = True; Compress = True; "))
            {
                conn.Open();

                using (var cmd = new SQLiteCommand("CREATE TABLE test_tbl(user_name varchar(50), age integer)", conn))
                {
                    cmd.ExecuteNonQuery();
                }

                using (var cmd = new SQLiteCommand())
                {
                    cmd.Connection = conn;
                    cmd.CommandText = "INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)";
                    cmd.Parameters.Add(new SQLiteParameter("@user", "aa明日のジョー"));
                    cmd.Parameters.Add(new SQLiteParameter("@age", 17));
                    cmd.ExecuteNonQuery();
                }
                using (var cmd = new SQLiteCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }
                // トランザクション
                Console.WriteLine("=================================================");
                Console.WriteLine("トランザクション(ロールバック)");
                using (var tran = conn.BeginTransaction())
                {
                    using (var cmd = new SQLiteCommand())
                    {
                        cmd.Connection = conn;
                        cmd.CommandText = "INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)";
                        cmd.Parameters.Add(new SQLiteParameter("@user", "bb丹下さくら"));
                        cmd.Parameters.Add(new SQLiteParameter("@age", 43));
                        cmd.ExecuteNonQuery();
                    }
                    tran.Rollback();

                }
                using (var cmd = new SQLiteCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }
                Console.WriteLine("=================================================");
                Console.WriteLine("トランザクション(コミット)");
                using (var tran = conn.BeginTransaction())
                {
                    using (var cmd = new SQLiteCommand())
                    {
                        cmd.Connection = conn;
                        cmd.CommandText = "INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)";
                        cmd.Parameters.Add(new SQLiteParameter("@user", "bb丹下さくら"));
                        cmd.Parameters.Add(new SQLiteParameter("@age", 43));
                        cmd.ExecuteNonQuery();
                    }
                    tran.Commit();

                }
                using (var cmd = new SQLiteCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }

                Console.WriteLine("=================================================");
                Console.WriteLine("ユーザ定義関数");
                using (var cmd = new SQLiteCommand("SELECT ToUpper(user_name) FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0}", reader.GetString(0));
                    }
                }
                conn.Close();
            }
            Console.ReadLine();
        }
    }
}

PowerShell

C#と異なりユーザ定義関数の実行がうまく行きません。(コメント欄参考に実現しました)
また、今回は調査外としましたがPSSQLiteというライブラリがあります。
System.Data.SQLite.dllをラップして使い易くしているようです。

using namespace System.Data.SQLite
Add-Type -Path 'System.Data.SQLite.dll'

$source = @"
using System.Data.SQLite;
[SQLiteFunction(Name = "ToUpper", Arguments = 1, FuncType = FunctionType.Scalar)]
public class ToUpper : SQLiteFunction
{
    public override object Invoke(object[] args)
    {
        return args[0].ToString().ToUpper();
    }
}

"@

Add-Type -TypeDefinition $source -ReferencedAssemblies ("C:\dev\ps\database\System.Data.SQLite.dll")

if (Test-Path C:\dev\ps\database\database.db) {
    Remove-Item C:\dev\ps\database\database.db
}
Using-Object ($conn = New-Object SQLiteConnection('Data Source=C:\dev\ps\database\database.db; Version = 3; New = True; Compress = True; ')) {
    $conn.Open()
    Using-Object ($cmd = New-Object SQLiteCommand('CREATE TABLE test_tbl(user_name varchar(50), age integer)', $conn)) {
        $cmd.ExecuteNonQuery() | Out-Null
    }
    Using-Object ($cmd = New-Object SQLiteCommand('INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)', $conn)) {
        $param = New-Object SQLiteParameter("@user", "aa明日のジョー")
        $cmd.Parameters.Add( $param ) | Out-Null
        $param = New-Object SQLiteParameter("@age", 17)
        $cmd.Parameters.Add( $param ) | Out-Null
        $cmd.ExecuteNonQuery() | Out-Null
    }
    Using-Object ($cmd = New-Object SQLiteCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    Write-Host "================================================="
    Write-Host "トランザクション(ロールバック)"
    Using-Object ($tran = $conn.BeginTransaction()) {
        Using-Object ($cmd = New-Object SQLiteCommand('INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)', $conn)) {
            $param = New-Object SQLiteParameter("@user", "bb丹下さくら")
            $cmd.Parameters.Add( $param ) | Out-Null
            $param = New-Object SQLiteParameter("@age", 43)
            $cmd.Parameters.Add( $param ) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }
        $tran.Rollback()
    }
    Using-Object ($cmd = New-Object SQLiteCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    Write-Host "================================================="
    Write-Host "トランザクション(コミット)"
    Using-Object ($tran = $conn.BeginTransaction()) {
        Using-Object ($cmd = New-Object SQLiteCommand('INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)', $conn)) {
            $param = New-Object SQLiteParameter("@user", "bb丹下さくら")
            $cmd.Parameters.Add( $param ) | Out-Null
            $param = New-Object SQLiteParameter("@age", 43)
            $cmd.Parameters.Add( $param ) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }
        $tran.Commit()
    }
    Using-Object ($cmd = New-Object SQLiteCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    [SQLiteFunction]::RegisterFunction([ToUpper]) 
    Write-Host "================================================="
    Write-Host "ユーザ定義"
    Using-Object ($cmd = New-Object SQLiteCommand("SELECT ToUpper(user_name) FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0) 
            }
        }
    }

    $conn.Close()
}

MDB

古いアクセスの拡張子であるMDBは実は32bitに限り、標準で操作可能です。
COMオブジェクトのMicrosoft ADO Ext(ADOX)を利用してデータベースの作成とテーブルの作成を行います。
.NETのSystem.Data.OleDbを利用してテーブルの操作を行います。

事前準備

C#の場合は、COMとして「Microsoft ADO Ext」を参照します。

C#

System.Data.OleDbはバインド変数に名前を付けることができないようです。

What's wrong with these parameters?
https://stackoverflow.com/questions/1216271/whats-wrong-with-these-parameters

using System;
using System.Data.OleDb;
using System.Runtime.InteropServices;

namespace mdbSample
{
    class Program
    {
        static void Main(string[] args)
        {
            string path = @"C:\dev\ps\database\test.mdb";
            string cnnStr = @"Provider=Microsoft.Jet.OLEDB.4.0; Data Source=" + path;
            if (System.IO.File.Exists(path))
            {
                System.IO.File.Delete(path);
            }
            // 32bitで動かす必要がある
            // https://www.c-sharpcorner.com/uploadfile/mahesh/using-adox-with-ado-net/
            // Microsoft ADO Ext 6.0
            var ct = new ADOX.Catalog();
            var createdObj = ct.Create(cnnStr);
            ADOX.Table tbl = new ADOX.Table();
            tbl.Name = "test_tbl";
            tbl.Columns.Append("user_name", ADOX.DataTypeEnum.adVarWChar, 50);
            tbl.Columns.Append("age", ADOX.DataTypeEnum.adInteger);
            ct.Tables.Append(tbl);
            createdObj.Close();
            Marshal.ReleaseComObject(createdObj);
            Marshal.ReleaseComObject(tbl);
            Marshal.ReleaseComObject(ct);

            using (var conn = new OleDbConnection(cnnStr))
            {
                conn.Open();
                using (var cmd = new OleDbCommand("INSERT INTO test_tbl (user_name, age) VALUES (?, ?)", conn))
                {
                    cmd.Parameters.AddWithValue("@user", "明日のジョー");
                    cmd.Parameters.AddWithValue("@age", 17);
                    cmd.ExecuteNonQuery();

                }
                using (var cmd = new OleDbCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }

                // トランザクション
                Console.WriteLine("=================================================");
                Console.WriteLine("トランザクション(ロールバック)");
                using (var tran = conn.BeginTransaction())
                {
                    using (var cmd = new OleDbCommand("INSERT INTO test_tbl (user_name, age) VALUES (?, ?)", conn, tran))
                    {
                        cmd.Parameters.AddWithValue("@user", "丹下さくら");
                        cmd.Parameters.AddWithValue("@age", 43);
                        cmd.ExecuteNonQuery();
                    }
                    tran.Rollback();
                }
                using (var cmd = new OleDbCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }
                Console.WriteLine("=================================================");
                Console.WriteLine("トランザクション(コミット)");
                using (var tran = conn.BeginTransaction())
                {
                    using (var cmd = new OleDbCommand("INSERT INTO test_tbl (user_name, age) VALUES (?, ?)", conn, tran))
                    {
                        cmd.Parameters.AddWithValue("@user", "丹下さくら");
                        cmd.Parameters.AddWithValue("@age", 43);
                        cmd.ExecuteNonQuery();
                    }
                    tran.Commit();
                }
                using (var cmd = new OleDbCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }
            }
            Console.ReadLine();
        }
    }
}

PowerShell

    using namespace System.Data.OleDb
    using namespace System.Runtime.InteropServices

    $path = "C:\dev\ps\database\test.mdb"
    if (Test-Path $path) {
        Remove-Item $path
    }
    $cnnStr = "Provider=Microsoft.Jet.OLEDB.4.0; Data Source=$path"
    $ct = New-Object -ComObject "ADOX.Catalog"
    $createdObj = $ct.Create($cnnStr)
    $tbl = New-Object -ComObject "ADOX.Table"
    $tbl.Name = "test_tbl"
    $tbl.Columns.Append("user_name", 202, 50)
    $tbl.Columns.Append("age", 3)
    $ct.Tables.Append($tbl)
    $createdObj.Close()
    [Marshal]::ReleaseComObject($createdObj) | Out-Null
    [Marshal]::ReleaseComObject($tbl) | Out-Null
    [Marshal]::ReleaseComObject($ct) | Out-Null

    Using-Object ($conn = New-Object OleDbConnection($cnnStr)) {
        $conn.Open()

        Using-Object ($cmd = New-Object OleDbCommand('INSERT INTO test_tbl (user_name, age) VALUES (?, ?)', $conn)) {
            $cmd.Parameters.AddWithValue("@user", "明日のジョー") | Out-Null
            $cmd.Parameters.AddWithValue("@age", 17) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }

        Using-Object ($cmd = New-Object OleDbCommand("SELECT user_name, age FROM test_tbl", $conn)) {
            Using-Object ($reader = $cmd.ExecuteReader()){
                while ($reader.Read())
                {
                    Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
                }
            }
        }
        Write-Host "================================================="
        Write-Host "トランザクション(ロールバック)"
        Using-Object ($tran = $conn.BeginTransaction()) {
            Using-Object ($cmd = New-Object OleDbCommand("INSERT INTO test_tbl (user_name, age) VALUES (?, ?)", $conn, $tran)) {
                $cmd.Parameters.AddWithValue("@user", "丹下さくら") | Out-Null
                $cmd.Parameters.AddWithValue("@age", 43) | Out-Null
                $cmd.ExecuteNonQuery() | Out-Null
            }
            $tran.Rollback()
        }
        Using-Object ($cmd = New-Object OleDbCommand("SELECT user_name, age FROM test_tbl", $conn)) {
            Using-Object ($reader = $cmd.ExecuteReader()){
                while ($reader.Read())
                {
                    Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
                }
            }
        }

        Write-Host "================================================="
        Write-Host "トランザクション(コミット)"
        Using-Object ($tran = $conn.BeginTransaction()) {
            Using-Object ($cmd = New-Object OleDbCommand("INSERT INTO test_tbl (user_name, age) VALUES (?, ?)", $conn, $tran)) {
                $cmd.Parameters.AddWithValue("@user", "丹下さくら") | Out-Null
                $cmd.Parameters.AddWithValue("@age", 43) | Out-Null
                $cmd.ExecuteNonQuery() | Out-Null
            }
            $tran.Commit()
        }
        Using-Object ($cmd = New-Object OleDbCommand("SELECT user_name, age FROM test_tbl", $conn)) {
            Using-Object ($reader = $cmd.ExecuteReader()){
                while ($reader.Read())
                {
                    Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
                }
            }
        }
    }

MariaDB/MySQL

MySQLのインストーラについてくるMySql.Data.dllを利用して操作を行います。
今回はMariaDBを対象としましたがMySQLも同様に動作すると思います。

事前準備

下記からMySQLのインストーラを手にいれてMySql.Data.dllを手に入れます。
https://dev.mysql.com/doc/connector-net/en/connector-net-installation-binary-mysql-installer.html

既定では以下にインストールされるので、参照してください。
C:\Program Files (x86)\MySQL\MySQL Installer for Windows\MySql.Data.dll

また下記のストアドプロシージャを作成しておきます。

CREATE PROCEDURE test_sp(IN from_age integer, IN to_age integer)
BEGIN
  SELECT test_tbl.user_name,test_tbl.age FROM test_tbl
                            WHERE test_tbl.age BETWEEN from_age AND to_age;

END

C#

// 以下を参照
//C:\Program Files (x86)\MySQL\MySQL Installer for Windows\MySql.Data.dll
using MySql.Data.MySqlClient;
using System;

namespace mysqlSample
{
    class Program
    {
        static void Main(string[] args)
        {
            // https://dev.mysql.com/doc/connector-net/en/connector-net-installation-binary-mysql-installer.html
            // https://dev.mysql.com/doc/connector-net/en/connector-net-tutorials-connection.html
            string connStr = "server=192.168.80.131;user=root;database=test;port=3306;password=root";
            using (var conn = new MySqlConnection(connStr))
            {
                try
                {
                    Console.WriteLine("Connecting to MySQL...");
                    conn.Open();

                    using (var cmd = new MySqlCommand("truncate table test_tbl", conn))
                    {
                        cmd.ExecuteNonQuery();
                    }

                    using (var cmd = new MySqlCommand("INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)", conn))
                    {
                        cmd.Parameters.AddWithValue("@user", "明日のジョー");
                        cmd.Parameters.AddWithValue("@age", 17);
                        cmd.ExecuteNonQuery();

                    }
                    using (var cmd = new MySqlCommand("SELECT user_name, age FROM test_tbl", conn))
                    using (var reader = cmd.ExecuteReader())
                    {
                        while (reader.Read())
                        {
                            Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                        }
                    }

                    // トランザクション
                    Console.WriteLine("=================================================");
                    Console.WriteLine("トランザクション(ロールバック)");
                    using (var tran = conn.BeginTransaction())
                    {
                        // Perform database operations
                        using (var cmd = new MySqlCommand("INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)", conn))
                        {
                            cmd.Parameters.AddWithValue("@user", "丹下さくら");
                            cmd.Parameters.AddWithValue("@age", 43);
                            cmd.ExecuteNonQuery();
                        }
                        tran.Rollback();
                    }
                    using (var cmd = new MySqlCommand("SELECT user_name, age FROM test_tbl", conn))
                    using (var reader = cmd.ExecuteReader())
                    {
                        while (reader.Read())
                        {
                            Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                        }
                    }
                    Console.WriteLine("=================================================");
                    Console.WriteLine("トランザクション(コミット)");
                    using (var tran = conn.BeginTransaction())
                    {
                        // Perform database operations
                        using (var cmd = new MySqlCommand("INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)", conn))
                        {
                            cmd.Parameters.AddWithValue("@user", "丹下さくら");
                            cmd.Parameters.AddWithValue("@age", 43);
                            cmd.ExecuteNonQuery();
                        }
                        tran.Commit();
                    }
                    using (var cmd = new MySqlCommand("SELECT user_name, age FROM test_tbl", conn))
                    using (var reader = cmd.ExecuteReader())
                    {
                        while (reader.Read())
                        {
                            Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                        }
                    }

                    Console.WriteLine("=================================================");
                    Console.WriteLine("ストアド");
                    using (var cmd = new MySqlCommand("call test_sp(@from, @to)", conn))
                    {
                        cmd.Parameters.AddWithValue("@from", 10);
                        cmd.Parameters.AddWithValue("@to", 19);
                        using (var reader = cmd.ExecuteReader())
                        {
                            while (reader.Read())
                            {
                                Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                            }
                        }
                    }
                    conn.Close();
                }
                catch (Exception ex)
                {
                    Console.WriteLine(ex.ToString());
                }
            }
            Console.WriteLine("Done.");
            Console.ReadLine();
        }
    }
}

PowerShell

using namespace MySql.Data.MySqlClient
Add-Type -Path 'MySql.Data.dll'

Using-Object ($conn = New-Object MySqlConnection('server=192.168.80.131;user=root;database=test;port=3306;password=root')) {
    $conn.Open()
    Using-Object ($cmd = New-Object MySqlCommand('truncate table test_tbl', $conn)) {
        $cmd.ExecuteNonQuery() | Out-Null
    }

    Using-Object ($cmd = New-Object MySqlCommand('INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)', $conn)) {
        $cmd.Parameters.AddWithValue("@user", "明日のジョー") | Out-Null
        $cmd.Parameters.AddWithValue("@age", 17) | Out-Null
        $cmd.ExecuteNonQuery() | Out-Null
    }

    Using-Object ($cmd = New-Object MySqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    Write-Host "================================================="
    Write-Host "トランザクション(ロールバック)"
    Using-Object ($tran = $conn.BeginTransaction()) {
        Using-Object ($cmd = New-Object MySqlCommand('INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)', $conn)) {
            $cmd.Parameters.AddWithValue("@user", "丹下さくら") | Out-Null
            $cmd.Parameters.AddWithValue("@age", 43) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }
        $tran.Rollback()
    }
    Using-Object ($cmd = New-Object MySqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }
    Write-Host "================================================="
    Write-Host "トランザクション(コミット)"
    Using-Object ($tran = $conn.BeginTransaction()) {
        Using-Object ($cmd = New-Object MySqlCommand('INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)', $conn)) {
            $cmd.Parameters.AddWithValue("@user", "丹下さくら") | Out-Null
            $cmd.Parameters.AddWithValue("@age", 43) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }
        $tran.Commit()
    }
    Using-Object ($cmd = New-Object MySqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    Write-Host "================================================="
    Write-Host "ストアド"
    Using-Object ($cmd = New-Object MySqlCommand('call test_sp(@from, @to)', $conn)) {
        $cmd.Parameters.AddWithValue("@from", 10) | Out-Null
        $cmd.Parameters.AddWithValue("@to", 19) | Out-Null
        $cmd.ExecuteNonQuery() | Out-Null
    }
    Using-Object ($cmd = New-Object MySqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }
    $conn.Close()
}

PostgreSQL

外部ライブラリのNpgsqlを利用して操作を行います。

事前準備

NugetでNpgsqlをインストールします。
下記のストアドプロシージャを作成します。

CREATE OR REPLACE FUNCTION test_sp(IN from_age integer, IN to_age integer)
  RETURNS TABLE(user_name varchar(50), age integer) AS
$$
DECLARE
BEGIN
    RETURN QUERY SELECT test_tbl.user_name,test_tbl.age FROM test_tbl
            WHERE test_tbl.age BETWEEN from_age AND to_age;
END;
$$ LANGUAGE plpgsql;

C#


using Npgsql;
using System;

namespace DbSample
{
    class Program
    {
        static void Main(string[] args)
        {
            var connString = "Host=192.168.80.131;Username=postgres;Password=postgres;Database=test";

            using (var conn = new NpgsqlConnection(connString))
            {
                conn.Open();

                //
                using (var cmd = new NpgsqlCommand())
                {
                    cmd.Connection = conn;
                    cmd.CommandText = "truncate table test_tbl";
                    cmd.ExecuteNonQuery();
                }

                // Insert some data
                using (var cmd = new NpgsqlCommand())
                {
                    cmd.Connection = conn;
                    cmd.CommandText = "INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)";
                    cmd.Parameters.AddWithValue("user", "明日のジョー");
                    cmd.Parameters.AddWithValue("age", 17);
                    cmd.ExecuteNonQuery();
                }

                // Retrieve all rows
                using (var cmd = new NpgsqlCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }
                // トランザクション
                Console.WriteLine("=================================================");
                Console.WriteLine("トランザクション(ロールバック)");
                using (var tran = conn.BeginTransaction())
                {
                    using (var cmd = new NpgsqlCommand())
                    {
                        cmd.Connection = conn;
                        cmd.CommandText = "INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)";
                        cmd.Parameters.AddWithValue("user", "丹下サクラ");
                        cmd.Parameters.AddWithValue("age", 43);
                        cmd.ExecuteNonQuery();
                    }
                    tran.Rollback();
                }
                using (var cmd = new NpgsqlCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }
                // 
                // トランザクション
                Console.WriteLine("=================================================");
                Console.WriteLine("トランザクション(コミット)");
                using (var tran = conn.BeginTransaction())
                {
                    using (var cmd = new NpgsqlCommand())
                    {
                        cmd.Connection = conn;
                        cmd.CommandText = "INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)";
                        cmd.Parameters.AddWithValue("user", "丹下サクラ");
                        cmd.Parameters.AddWithValue("age", 43);
                        cmd.ExecuteNonQuery();
                    }
                    tran.Commit();
                }
                using (var cmd = new NpgsqlCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }
                // ストアドの試験
                Console.WriteLine("=================================================");
                Console.WriteLine("ストアドファンクション");
                using (var cmd = new NpgsqlCommand("SELECT user_name, age FROM test_sp(@fromage,@toage)", conn))
                {
                    cmd.Parameters.AddWithValue("fromage", 10);
                    cmd.Parameters.AddWithValue("toage", 19);

                    using (var reader = cmd.ExecuteReader())
                    {
                        while (reader.Read())
                        {
                            Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                        }
                    }
                }
            }
        }
    }
}

PowerShell

using namespace Npgsql
try {
    Add-Type -Path 'System.Runtime.CompilerServices.Unsafe.dll'
    Add-Type -Path 'System.Threading.Tasks.Extensions.dll'
    Add-Type -Path 'System.Memory.dll'
    Add-Type -Path 'Npgsql.dll'
} catch [System.Reflection.ReflectionTypeLoadException] {
    $_.Exception.LoaderExceptions
}

Using-Object ($conn = New-Object NpgsqlConnection('Host=192.168.80.131;Username=postgres;Password=postgres;Database=test')) {
    $conn.Open()
    Using-Object ($cmd = New-Object NpgsqlCommand('truncate table test_tbl', $conn)) {
        $cmd.ExecuteNonQuery() | Out-Null
    }

    Using-Object ($cmd = New-Object NpgsqlCommand('INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)', $conn)) {
        $cmd.Parameters.AddWithValue("user", "明日のジョー") | Out-Null
        $cmd.Parameters.AddWithValue("age", 17) | Out-Null
        $cmd.ExecuteNonQuery() | Out-Null
    }

    Using-Object ($cmd = New-Object NpgsqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    Write-Host "================================================="
    Write-Host "トランザクション(ロールバック)"
    Using-Object ($tran = $conn.BeginTransaction()) {
        Using-Object ($cmd = New-Object NpgsqlCommand('INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)', $conn)) {
            $cmd.Parameters.AddWithValue("user", "丹下さくら") | Out-Null
            $cmd.Parameters.AddWithValue("age", 43) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }
        $tran.Rollback()
    }
    Using-Object ($cmd = New-Object NpgsqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }
    Write-Host "================================================="
    Write-Host "トランザクション(コミット)"
    Using-Object ($tran = $conn.BeginTransaction()) {
        Using-Object ($cmd = New-Object NpgsqlCommand('INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)', $conn)) {
            $cmd.Parameters.AddWithValue("user", "丹下さくら") | Out-Null
            $cmd.Parameters.AddWithValue("age", 43) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }
        $tran.Commit()
    }
    Using-Object ($cmd = New-Object NpgsqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    Write-Host "================================================="
    Write-Host "ストアド"
    Using-Object ($cmd = New-Object NpgsqlCommand('SELECT user_name, age FROM test_sp(@fromage,@toage)', $conn)) {
        $cmd.Parameters.AddWithValue("@fromage", 10) | Out-Null
        $cmd.Parameters.AddWithValue("@toage", 19) | Out-Null
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)
            }
        }
    }
    $conn.Close()
}

Oracle12

外部ライブラリのOracle.ManagedDataAccessを利用して操作をします。

事前準備

NugetでOracle.ManagedDataAccessをインストールします。

C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Oracle.ManagedDataAccess.Client;

namespace OracleSample
{
    class Program
    {
        static void Main(string[] args)
        {
            string cnnStr = "user id=system;password=oracle;data source=" +
                             "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)" +
                             "(HOST=192.168.99.102)(PORT=1521))(CONNECT_DATA=" +
                             "(SERVICE_NAME=orcl)))";
            using (var conn = new OracleConnection(cnnStr))
            {
                conn.Open();
                using (var cmd = new OracleCommand("truncate table test_tbl", conn))
                {
                    cmd.ExecuteNonQuery();
                }
                using (var cmd = new OracleCommand("INSERT INTO test_tbl (user_name, age) VALUES (:userName, :age)", conn))
                {
                    cmd.Parameters.Add( new OracleParameter("userName", "明日のジョー"));
                    cmd.Parameters.Add( new OracleParameter("age", 17));
                    cmd.ExecuteNonQuery();

                }
                using (var cmd = new OracleCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }

                // トランザクション
                Console.WriteLine("=================================================");
                Console.WriteLine("トランザクション(ロールバック)");
                using (var tran = conn.BeginTransaction())
                {
                    // Perform database operations
                    using (var cmd = new OracleCommand("INSERT INTO test_tbl (user_name, age) VALUES (:userName, :age)", conn))
                    {
                        cmd.Parameters.Add(new OracleParameter("userName", "丹下さくら"));
                        cmd.Parameters.Add(new OracleParameter("age", 43));
                        cmd.ExecuteNonQuery();
                    }
                    tran.Rollback();
                }
                using (var cmd = new OracleCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }

                Console.WriteLine("=================================================");
                Console.WriteLine("トランザクション(コミット)");
                using (var tran = conn.BeginTransaction())
                {
                    // Perform database operations
                    using (var cmd = new OracleCommand("INSERT INTO test_tbl (user_name, age) VALUES (:userName, :age)", conn))
                    {
                        cmd.Parameters.Add(new OracleParameter("userName", "丹下さくら"));
                        cmd.Parameters.Add(new OracleParameter("age", 43));
                        cmd.ExecuteNonQuery();
                    }
                    tran.Commit();
                }
                using (var cmd = new OracleCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }
                conn.Close();
            }
            Console.ReadLine();
        }
    }
}

PowerShell

using namespace Oracle.ManagedDataAccess.Client
Add-Type -Path 'Oracle.ManagedDataAccess.dll'

Using-Object ($conn = New-Object OracleConnection('user id=system;password=oracle;data source=(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=192.168.99.102)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=orcl)))')) {
    $conn.Open()
    Using-Object ($cmd = New-Object OracleCommand('truncate table test_tbl', $conn)) {
        $cmd.ExecuteNonQuery() | Out-Null
    }

    Using-Object ($cmd = New-Object OracleCommand('INSERT INTO test_tbl (user_name, age) VALUES (:userName, :age)', $conn)) {
        $param = New-Object OracleParameter("userName", "明日のジョー")
        $cmd.Parameters.Add( $param ) | Out-Null
        $param = New-Object OracleParameter("age", 17)
        $cmd.Parameters.Add( $param ) | Out-Null
        $cmd.ExecuteNonQuery() | Out-Null
    }

    Using-Object ($cmd = New-Object OracleCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    Write-Host "================================================="
    Write-Host "トランザクション(ロールバック)"
    Using-Object ($tran = $conn.BeginTransaction()) {
        Using-Object ($cmd = New-Object OracleCommand('INSERT INTO test_tbl (user_name, age) VALUES (:userName, :age)', $conn)) {
            $param = New-Object OracleParameter("userName", "丹下さくら")
            $cmd.Parameters.Add( $param ) | Out-Null
            $param = New-Object OracleParameter("age", 43)
            $cmd.Parameters.Add( $param ) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }
        $tran.Rollback()
    }
    Using-Object ($cmd = New-Object OracleCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }
    Write-Host "================================================="
    Write-Host "トランザクション(コミット)"
    Using-Object ($tran = $conn.BeginTransaction()) {
        Using-Object ($cmd = New-Object OracleCommand('INSERT INTO test_tbl (user_name, age) VALUES (:userName, :age)', $conn)) {
            $param = New-Object OracleParameter("userName", "丹下さくら")
            $cmd.Parameters.Add( $param ) | Out-Null
            $param = New-Object OracleParameter("age", 43)
            $cmd.Parameters.Add( $param ) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }
        $tran.Commit()
    }
    Using-Object ($cmd = New-Object OracleCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }
    $conn.Close()
}

SQLServer

標準についているSystem.Data.SqlClientを使用します。

事前準備

事前に下記のストアドプロシージャを作成します。

CREATE PROCEDURE test_sp(@from int, @to int)
AS
BEGIN
    SET NOCOUNT ON;
    SELECT user_name, age FROM test_tbl
        WHERE age BETWEEN @from  AND  @to;
END
GO

C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Data;
using System.Data.SqlClient;

namespace mssqlSample
{
    class Program
    {
        static void Main(string[] args)
        {
            string cnnString = @"Data Source=.\SQLEXPRESS;Initial Catalog=test;User ID=sa;Password=sa";
            using (var conn = new SqlConnection(cnnString))
            {
                conn.Open();
                using (var cmd = new SqlCommand("truncate table test_tbl", conn))
                {
                    cmd.ExecuteNonQuery();
                }
                using (var cmd = new SqlCommand("INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)", conn))
                {
                    cmd.Parameters.AddWithValue("@user", "明日のジョー");
                    cmd.Parameters.AddWithValue("@age", 17);
                    cmd.ExecuteNonQuery();

                }
                using (var cmd = new SqlCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }

                // トランザクション
                Console.WriteLine("=================================================");
                Console.WriteLine("トランザクション(ロールバック)");
                using (var tran = conn.BeginTransaction())
                {
                    // Perform database operations
                    using (var cmd = new SqlCommand("INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)", conn, tran))
                    {
                        cmd.Parameters.AddWithValue("@user", "丹下さくら");
                        cmd.Parameters.AddWithValue("@age", 43);
                        cmd.ExecuteNonQuery();
                    }
                    tran.Rollback();
                }
                using (var cmd = new SqlCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }
                Console.WriteLine("=================================================");
                Console.WriteLine("トランザクション(コミット)");
                using (var tran = conn.BeginTransaction())
                {
                    // Perform database operations
                    using (var cmd = new SqlCommand("INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)", conn, tran))
                    {
                        cmd.Parameters.AddWithValue("@user", "丹下さくら");
                        cmd.Parameters.AddWithValue("@age", 43);
                        cmd.ExecuteNonQuery();
                    }
                    tran.Commit();
                }
                using (var cmd = new SqlCommand("SELECT user_name, age FROM test_tbl", conn))
                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                    }
                }

                Console.WriteLine("=================================================");
                Console.WriteLine("ストアド");
                using (var cmd = new SqlCommand("EXEC test_sp @from, @to", conn))
                {
                    cmd.Parameters.AddWithValue("@from", 10);
                    cmd.Parameters.AddWithValue("@to", 19);
                    using (var reader = cmd.ExecuteReader())
                    {
                        while (reader.Read())
                        {
                            Console.WriteLine("{0} {1}", reader.GetString(0), reader.GetInt32(1).ToString());
                        }
                    }

                }
                conn.Close();
            }
            Console.ReadLine();
        }
    }
}

PowerShell

using namespace System.Data.SqlClient

Using-Object ($conn = New-Object SqlConnection('Data Source=.\SQLEXPRESS;Initial Catalog=test;User ID=sa;Password=sa')) {
    $conn.Open()
    Using-Object ($cmd = New-Object SqlCommand('truncate table test_tbl', $conn)) {
        $cmd.ExecuteNonQuery() | Out-Null
    }

    Using-Object ($cmd = New-Object SqlCommand('INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)', $conn)) {
        $cmd.Parameters.AddWithValue("@user", "明日のジョー") | Out-Null
        $cmd.Parameters.AddWithValue("@age", 17) | Out-Null
        $cmd.ExecuteNonQuery() | Out-Null
    }

    Using-Object ($cmd = New-Object SqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    Write-Host "================================================="
    Write-Host "トランザクション(ロールバック)"
    Using-Object ($tran = $conn.BeginTransaction()) {
        Using-Object ($cmd = New-Object SqlCommand("INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)", $conn, $tran)) {
            $cmd.Parameters.AddWithValue("@user", "丹下さくら") | Out-Null
            $cmd.Parameters.AddWithValue("@age", 43) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }
        $tran.Rollback()
    }
    Using-Object ($cmd = New-Object SqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    Write-Host "================================================="
    Write-Host "トランザクション(コミット)"
    Using-Object ($tran = $conn.BeginTransaction()) {
        Using-Object ($cmd = New-Object SqlCommand("INSERT INTO test_tbl (user_name, age) VALUES (@user, @age)", $conn, $tran)) {
            $cmd.Parameters.AddWithValue("@user", "丹下さくら") | Out-Null
            $cmd.Parameters.AddWithValue("@age", 43) | Out-Null
            $cmd.ExecuteNonQuery() | Out-Null
        }
        $tran.Commit()
    }
    Using-Object ($cmd = New-Object SqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }

    Write-Host "================================================="
    Write-Host "ストアド"
    Using-Object ($cmd = New-Object SqlCommand('EXEC test_sp @from, @to', $conn)) {
        $cmd.Parameters.AddWithValue("@from", 10) | Out-Null
        $cmd.Parameters.AddWithValue("@to", 19) | Out-Null
        $cmd.ExecuteNonQuery() | Out-Null
    }

    Using-Object ($cmd = New-Object SqlCommand("SELECT user_name, age FROM test_tbl", $conn)) {
        Using-Object ($reader = $cmd.ExecuteReader()){
            while ($reader.Read())
            {
                Write-Host $reader.GetString(0)  $reader.GetInt32(1).ToString()
            }
        }
    }
    $conn.Close()
}

まとめ

今回はC#やPowerShellでの各種データベースの取り扱いをまとめました。
基本的な操作は全てのDBで同じようなインターフェイスで行われていることがわかります。

共有フォルダ教が支配する世界に転生した場合の対応案

前書き

この世界ではバージョン管理ソフトが世の中に出回り始めて20年以上、フリーでかつ信頼性も高くなったツールが流行りだして10年以上はたっています。

さすがにソースコードはバージョン管理をするところが多くなりましたが、ドキュメントや、その他重要なデータはいまだバージョン管理をしない世界をよく見かけます。

バージョン管理ツールを縛り共有フォルダでの管理を支持する、いや、もはや信仰している方々は、提案されるバージョン管理ツールの導入案を却下あるいは先延ばしにし続けてきました。
その結果として作業者がフォルダを誤操作で消したり、古いファイルを上書きしたりするという必然の結果(彼らにとっては想定外な結果)に遭遇し、時間とリソースを無駄にドブに捨てるという贅沢をし続けています。

今回は、不幸にも、そのような世界に転生してしまった場合に、どうすべきかを考えてみます。

なお、この記事はバージョン管理ソフトの使い方を広めて、みんなで協力して導入していく、模範的な話ではなく、ベストではないが、最悪が回避できたらラッキー程度の話になります。

バージョン管理ツールが流行り、10年以上たって導入できないのは導入できないなりの事情が存在するからです。

なぜバージョン管理ツールは導入されないか

なぜバージョン管理ツールが導入されないかは幾つかのパターンがあります。

  • バージョン管理ツールを使用する必要がないあるいは、使用できない
  • バージョン管理ツールを知らない
  • バージョン管理ツールは知っていても、使える人間が少ない
  • 『高度なIT管理』を実現している組織の場合
  • コミット履歴を残したくないケース

バージョン管理ツールを使用する必要がないあるいは、使用できない

状況としては2つ考えられます。
1つは管理対象の資源の重要性が低い場合です。
たとえば、一時的なファイルや、消えても大きな影響がない資産の場合です。

もう一つはバージョン管理をするのが難しい資産です。
たとえば、動画ファイルとか仮想マシンのイメージとかはファイルのサイズが大きすぎる上、簡単に差分を確認する方法がないため、バージョン管理の対象にむいてません。

こういった場合は、バージョン管理ツールを入れる必要はありません。

バージョン管理ツールを知らない

バージョン管理ツール自体を知らない人は少なくなってきてますが、バージョン管理でなにができるかを理解している人はいまだ少数です。

たとえば、昔からの人間の場合、バージョン管理ツールはソースコードや設定ファイルといったテキストしか管理できないと思っている人がいます。このため、ExcelやWordといったOffice文章等は差分の確認もできないし、バージョン管理をする意味がないと思い込んでいます。

これは、ひと昔前は事実でしたが、今現在は、ただの思い込みです。
TotoiseHGやTotoiseSVNなどに付属するDiffツールは標準でOffice文章の差分をとることができますし、WinMergeなどのDiffツールは様々なプラグインがサポートされており画像だろうが差分を見ることが可能になっています。

「知らないで導入しない」パターンは、他のパターンに比べて模範的な方向にもっていく可能性が微粒子レベルで存在します。

バージョン管理ツールは知っていても、使える人間が少ない

管理者レベルでバージョン管理ツールのメリットを知っていても、末端の作業者がバージョン管理ツールを使えないなら、結局は導入はできません。

バージョン管理を使えない人間が少数、または教育期間が十二分に取れる場合、練習用のリポジトリを用意してバージョン管理ツールの教育を行うのは有効です。このリポジトリはプロダクトコードと完全に切り離して、最悪、破壊させていい状況にしておいたほうが教育効果が高いです。

…とはいえ、バージョン管理の使い方を学んでこなかった人間を大量に雇用せざる得ない状況は、たいてい学徒動員的な末期戦になっていることが多いので、教育とかいう贅沢は許されない状況である場合が多いでしょう。

『高度なIT管理』を実現している組織の場合

いわゆる『高度なIT管理』を実現している組織の場合、あまりに『高度』すぎてインターネットの接続を遮断したり、サーバー管理を『完全で完璧な高度な専門集団』にしかやらせないという方針の場合があります。

この場合、サーバーを自由に立ててバージョン管理を行うということはできません。

そして『完全で完璧な高度な専門集団』を説得する、あるいは動いていただくために掛ける労力は、しばしば、数度の共有フォルダの破壊のリスクを許容するに値する労力になる場合があります。

コミット履歴を残したくないケース

過剰な管理ごっこをしている世界においては、その防衛反応として事実を隠す反応が発生します。

そういった防衛反応として、表ざたにできないバグをこっそり直したり、試験の不具合件数をいじったりするダークサイドに堕ちた中間管理職が生まれたりします。彼らにとって闇の技法を暴いてしまうバージョン管理システムというのは相性が悪いでしょう。

共有フォルダの破壊にそなえる

すべてを捨てて旅にでるのも一つの選択肢ですが、無駄な足掻きはいくつか考えました。
外部からの支援がほぼない状況で、共有フォルダの致命的な破壊に備える程度の案になります。

案1:自分でバックアップを取る

もっとも単純な案は1日に1回、夜中にでも共有フォルダの内容を自分のPCにコピーしておくことです。
この際、フォルダ名にタイムスタンプでもつけておいて、履歴としてつかえばよいでしょう。
Copyと同時に履歴を削除しておけば、バックアップフォルダの増加も抑えられると思います。

PowerShellで実現する場合は以下のようになります。

$server = '\\IEWIN7\share\test'
$user = 'IEWIN7\IEUser'
$password = ConvertTo-SecureString 'Passw0rd!' -AsPlainText -Force
$backup_root = 'C:\dev\ps\share\bk'

# 残す履歴の数
$backup_cnt = 10

if ((Test-Path $server) -eq $True) {
    Write-Host '接続....'
    $cred = New-Object System.Management.Automation.PSCredential($user, $password)
    $drive = New-PSDrive -Name "Z" -PSProvider FileSystem -Root $server -Credential $cred -ea Stop
    if ((Test-Path $server) -eq $False) {
        Write-Error "$server に接続できません"
        exit 1
    }
}

$time = Get-Date -Format "yyyyMMddHHmmss"
$backup_path = Join-Path $backup_root $time
Write-Host "BACKUP.... $backup_path "
Copy-Item -Literal $server -Destination $backup_path -Recurse

# 古いフォルダの削除
Get-ChildItem -Literal $backup_root | 
    Sort-Object LastWriteTime -Descending  | 
    Select-Object -Skip $backup_cnt | 
    foreach { 
        Write-Host "Delete..." $_.FullName
        $rmpath = $_.FullName
        $cmd = "rmdir /s/q `"$rmpath`""
        cmd /c $cmd
    }

この案の素晴らしいところは、『高度なIT管理』下でも、やれるということです。
問題点としては、すべてを単純にバックアップするため履歴を追うのは困難ですし、ディスク容量を無駄に消費します。

案2:分散型バージョン管理システムを共有フォルダに対して使用する

image.png

①と②で共有フォルダ中のファイルを分散バージョン管理システムの管理下に入れます。
③と④で共有フォルダのリポジトリをローカルにクローンすることでバックアップとします。

定期的に②のadd&commitと④のpullを実行することに共有フォルダで変更された内容が履歴管理され、その内容はバックアップフォルダに反映されます。
定期的に実行されるaddとcommitは共有フォルダに変化がなければ履歴が作成されません。

これにより更新されたファイル内容だけが履歴管理されるようになります。バージョン管理ツールがサポートする変更履歴の確認や、特定バージョンの差し戻しなどが容易に利用できるようになります。

この構成のデメリットは以下の通りです。

  • 誰が更新したかわからない
  • 共有フォルダ中に管理ファイルが置かれるためサイズが増える。
    • これは歴がすすむにつれサイズが増大していく。
      • どこかのタイミングで履歴の削除が必要になる
      • 共有サーバーのディスクサイズが制限されている環境では使えない

Gitでの実現例

Gitで上記の構成を実現するためのPowerShellのスクリプト例を以下に記載します。
なおバージョンは「git version 2.23.0.windows.1」となります。

$server = '\\IEWIN7\share\testgit'
$user = 'IEWIN7\IEUser'
$password = ConvertTo-SecureString 'Passw0rd!' -AsPlainText -Force
$backup_root = 'C:\dev\ps\share\gitbk'

if ((Test-Path $server) -eq $False) {
    Write-Host '接続....'
    $cred = New-Object System.Management.Automation.PSCredential($user, $password)
    $drive = New-PSDrive -Name "Z" -PSProvider FileSystem -Root $server -Credential $cred
    if ((Test-Path $server) -eq $False) {
        Write-Error "$server に接続できません"
        exit 1
    }
}

Push-Location $server

if ((Test-Path '.git') -eq $False) {
    # Gitフォルダではない
    Write-Host "Init.........."
    git init
}
git add --all
git commit -a -m "auto commit"
git gc
Pop-Location

# ローカルにコピー
Write-Host "BACKUP... $backup_root"
mkdir $backup_root -Force
Push-Location $backup_root
if ((Test-Path '.git') -eq $False) {
    # Gitフォルダではない
    $clone_remote = $server -replace '\\', '/'
    git clone $clone_remote .
}
git pull origin master

Pop-Location

容量の節約と効率化のため、Gitはときどき、緩いフォーマットのオブジェクトの中の幾つかを1つのバイナリファイルにパックします。緩いフォーマットのオブジェクトの場合、ちょっとだけ変更したファイルであってもそれぞれオブジェクトとして存在します。
しかし、パックをすることによりそれらをまとめて変更点だけを保持するようにします。

この処理は「git gc」コマンドを実行することで手動でも実現できます。
https://git-scm.com/book/ja/v2/Git%E3%81%AE%E5%86%85%E5%81%B4-Packfile

この処理を行わないと共有サーバーのファイルサイズが更新のたびに想定以上に増大していくことになります。

共有フォルダでGit管理外の名前の変更が発生した場合、AddとCommit時に変更前のオブジェクトと履歴を紐づけることができるようです。

Mercurialの実装例

Gitで上記の構成を実現するためのPowerShellのスクリプト例を以下に記載します。
なおバージョンは「Mercurial - 分散構成管理ツール(バージョン 4.9.1)」となります。

$server = '\\IEWIN7\share\testhg'
$user = 'IEWIN7\IEUser'
$password = ConvertTo-SecureString 'Passw0rd!' -AsPlainText -Force
$backup_root = 'C:\dev\ps\share\hgbk'

if ((Test-Path $server) -eq $False) {
    Write-Host '接続....'
    $cred = New-Object System.Management.Automation.PSCredential($user, $password)
    $drive = New-PSDrive -Name "Z" -PSProvider FileSystem -Root $server -Credential $cred
    if ((Test-Path $server) -eq $False) {
        Write-Error "$server に接続できません"
        exit 1
    }
}

Push-Location $server

if ((Test-Path '.hg') -eq $False) {
    # HGフォルダではない
    Write-Host "Init.........."
    hg init
}
hg add
hg commit -A -m "auto commit"

Pop-Location

# ローカルにコピー
Write-Host "BACKUP... $backup_root"
mkdir $backup_root -Force
Push-Location $backup_root
if ((Test-Path '.hg') -eq $False) {
    # HGフォルダではない
    $clone_remote = $server -replace '\\', '/'
    hg clone $clone_remote .
}
hg pull default
hg update

Pop-Location

Gitと違い、MercurialはGCの必要がありません。
共有フォルダでMercurial管理外の名前の変更が発生した場合、Gitとことなり変更前のオブジェクトと履歴を紐づけはできないようです。

容量増加の検証

どちらのバージョン管理ツールを使用しても共有フォルダ内に履歴の管理情報を保持する必要があります。
ここで発生する懸念としては、共有フォルダのサイズがどの程度増量するかです。

今回は以下の条件で検証しました。

(1)下記のスクリプトを用いて任意のフォルダに1000行x251列のエクセルファイル(1MB前後)を100個作成する。


    Param(
        [String]$target_dir,
        [Int]$count,
        [Int]$rowcnt,
        [Int]$colcnt
    )

    function create_xls($app, [String]$path, [String[]]$ary, $rowcnt) {
        Write-Host $path
        $books = $app.Workbooks
        $book = $books.Add()
        $sheets = $book.Sheets
        $sheet = $sheets["Sheet1"]
        $cells = $sheet.Cells

        $fromCell = $cells[1,1]
        $toCell = $cells[$rowcnt,$ary.Length]
        $rng = $sheet.Range($fromCell, $toCell)
        $rng.Value = $ary
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($fromCell) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($toCell) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($rng) | Out-Null

        $book.SaveAs($path)
        $book.Close()

        [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 = New-Object -ComObject Excel.Application
    $app.DisplayAlerts = $False
    for ($i = 0; $i -lt $count; $i++) {
        $name = "test" + $i + ".xlsx"
        $path = Join-Path $target_dir $name
        $t = Get-Date -Format "yyyyMMddHHmmss"
        $ary = @($t)
        for ($j = 0; $j -lt $colcnt; $j++) {
            $ary += Get-Random 10000
        }
        create_xls $app $path $ary $rowcnt
    }
    $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()

(2)下記のスクリプトを用いて1列目の全行を全て変更して保存します。

    Param(
        [String]$target_dir,
        [Int]$count,
        [Int]$rowcnt
    )

    function update_xls($app, [String]$path, [String]$time, $rowcnt) {
        Write-Host $path
        $books = $app.Workbooks
        $book = $books.Open($path)
        $sheets = $book.Sheets
        $sheet = $sheets["Sheet1"]
        $cells = $sheet.Cells

        $fromCell = $cells[1,1]
        $toCell = $cells[$rowcnt,1]
        $rng = $sheet.Range($fromCell, $toCell)
        $rng.Value = $time
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($fromCell) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($toCell) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($rng) | Out-Null

        $book.SaveAs($path)
        $book.Close()

        [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 = New-Object -ComObject Excel.Application
    $app.DisplayAlerts = $False
    for ($i = 0; $i -lt $count; $i++) {
        $name = "test" + $i + ".xlsx"
        $path = Join-Path $target_dir $name
        $time = Get-Date -Format "yyyyMMddHHmmss"
        update_xls $app $path $time $rowcnt
    }
    $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()

(3)(2)の実行後、コミットし、コミット後のフォルダのサイズを調べます。これを10回繰り返します

Gitの場合

$src_dir = "C:\dev\ps\share\kensyou\test_dir"
$target_dir = "C:\dev\ps\share\kensyou\git"
$script_dir = Split-Path $MyInvocation.MyCommand.Path

function gettime() {
    return Get-Date -Format "yyyyMMddHHmmss"
}

mkdir $target_dir -force | Out-Null
Copy-Item $($src_dir+"\*") $target_dir

push-location $target_dir
$time = gettime
Write-Host "$time  構成管理前" (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum

git init | Out-Null
$time = gettime
Write-Host "$time  init後" (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum

for ($i = 1; $i -le 10; $i++) {
    if ($i -ne 1) {
        $script = Join-Path $script_dir "update_data.ps1"
        powershell -file $script  $target_dir 100 1000 250  | Out-Null
    }
    git add --all | Out-Null
    git commit -a -m "auto commit" | Out-Null

    $time = gettime
    Write-Host "$time  $i 回目(commit後)"    (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum
    git gc | Out-Null

    $time = gettime
    Write-Host "$time  $i 回目(gc後)"    (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum
}
pop-location

Mercurialの場合

$src_dir = "C:\dev\ps\share\kensyou\test_dir"
$target_dir = "C:\dev\ps\share\kensyou\hg"
$script_dir = Split-Path $MyInvocation.MyCommand.Path

function gettime() {
    return Get-Date -Format "yyyyMMddHHmmss"
}

mkdir $target_dir -force | Out-Null
Copy-Item $($src_dir+"\*") $target_dir

push-location $target_dir
$time = gettime
Write-Host "$time  構成管理前" (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum

hg init | Out-Null
$time = gettime
Write-Host "$time  init後" (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum

for ($i = 1; $i -le 10; $i++) {
    if ($i -ne 1) {
        $script = Join-Path $script_dir "update_data.ps1"
        powershell -file $script  $target_dir 100 1000 250  | Out-Null
    }
    hg add | Out-Null
    hg commit -A -m "auto commit" | Out-Null

    $time = gettime
    Write-Host "$time  $i 回目" (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum
}
pop-location

計測結果

Gitの結果

時刻 経過時間 説明 バイト数 変化バイト数
12:40:42 構成管理前 108,224,999
12:40:42 0:00:00 init後 108,240,786 15,787
12:40:46 0:00:04 1回目(commit後) 167,167,147 58,926,361
12:41:03 0:00:17 1回目(gc後) 160,253,884 -6,913,263
12:42:38 0:01:35 2回目(commit後) 223,790,889 63,537,005
12:43:00 0:00:22 2回目(gc後) 217,598,218 -6,192,671
12:44:38 0:01:38 3回目(commit後) 279,699,191 62,100,973
12:45:13 0:00:35 3回目(gc後) 272,927,795 -6,771,396
12:46:55 0:01:42 4回目(commit後) 335,808,640 62,880,845
12:47:22 0:00:27 4回目(gc後) 329,101,286 -6,707,354
12:49:06 0:01:44 5回目(commit後) 391,427,905 62,326,619
12:49:44 0:00:38 5回目(gc後) 384,702,914 -6,724,991
12:51:50 0:02:06 6回目(commit後) 447,154,138 62,451,224
12:52:57 0:01:07 6回目(gc後) 440,272,643 -6,881,495
12:54:51 0:01:54 7回目(commit後) 502,722,692 62,450,049
12:55:25 0:00:34 7回目(gc後) 495,815,728 -6,906,964
12:57:07 0:01:42 8回目(commit後) 558,031,925 62,216,197
12:57:42 0:00:35 8回目(gc後) 551,140,571 -6,891,354
12:59:24 0:01:42 9回目(commit後) 613,900,051 62,759,480
13:00:01 0:00:37 9回目(gc後) 607,291,311 -6,608,740
13:01:35 0:01:34 10回目(commit後) 669,533,780 62,242,469
13:02:13 0:00:38 10回目(gc後) 662,895,263 -6,638,517

Mercurialの結果

時刻 経過時間 説明 バイト数 変化バイト数
13:06:28 構成管理前 108,224,999
13:06:30 0:00:02 init後 108,225,115 116
13:06:40 0:00:10 1回目 160,990,957 52,765,842
13:08:27 0:01:47 2回目 217,741,339 56,750,382
13:10:11 0:01:44 3回目 274,194,265 56,452,926
13:12:06 0:01:55 4回目 330,463,591 56,269,326
13:14:03 0:01:57 5回目 386,622,952 56,159,361
13:16:20 0:02:17 6回目 442,364,652 55,741,700
13:18:28 0:02:08 7回目 498,348,170 55,983,518
13:20:14 0:01:46 8回目 554,076,263 55,728,093
13:22:02 0:01:48 9回目 609,921,087 55,844,824
13:24:05 0:02:03 10回目 666,193,613 56,272,526

ファイルのサイズとしては「GC後のGitのサイズ<Mercurialサイズ<GC前のGitのサイズ」となります。
10回程度のコミットで6倍のサイズになっています。

ファイルの更新~コミット&GCの時間はGitとMercurialに大きな差はないように見えます。
※時間の注意としては、100ファイルに対して列を書き換える時間も含まれていることに注意してください。

どう実行すべきか

add & commitのタイミングはとりあえず1日単位でおこなって共有フォルダのサイズの増加量を監視して調整したほうがよさそうです。

また、更新頻度にもよりますが、バージョン管理の情報が肥大化することが予想されます。
そのため、どのタイミングで履歴を捨てるかを検討した方がいいです。
(たとえばリリースが行われた時点で開発中の履歴を捨てる等)

案3 ローカルにコピーしたバックアップフォルダに対して分散バージョン管理を使用する

案1のやりかたでローカルに共有フォルダの内容を定期的にコピーします。
ただし、フォルダ毎にわけるのでなく、上書き保存として、保存後、コミットをします。

このメリットは共有フォルダのサイズの肥大化を防げることです。
デメリットとしては、その履歴の活用が個人でしか行えないことです。

参考

Powershellで共有フォルダにアクセス
https://qiita.com/mugippoi/items/608255dee3fc9d3b35dc

Gitの内側
https://git-scm.com/book/ja/v2/Git%E3%81%AE%E5%86%85%E5%81%B4-%E9%85%8D%E7%AE%A1%EF%BC%88Plumbing%EF%BC%89%E3%81%A8%E7%A3%81%E5%99%A8%EF%BC%88Porcelain%EF%BC%89

まとめ

今回は共有フォルダ教が支配する世界に転生した場合の対応案を考えてみました。

おそらくは最悪の状況を回避するという手としては使えないこともないでしょうが、転生しなおした方が楽だと思います。

(補足)
なお、まじめにまっとうなバージョン管理の導入を考えるなら以下で紹介している「パターンによるソフトウェア構成管理」を基に現場にあった論を組み立てた方がいいと思います。

古典を読む~パターンによるソフトウェア構成管理
https://needtec.sakura.ne.jp/wod07672/?p=9172

PowerShellによるレジストリの操作例

目的

PowerShellを使用したレジストリの操作方法についてまとめます。
レジストリについては下記を参考にしてください。

https://needtec.sakura.ne.jp/wod07672/?p=9279

操作例

以下の例ではHKCUを利用していますが、HKLMのレジストリを更新する場合は管理者権限が必要になります。

また今回のサンプル例はWindows10+PowerShell5.1(64bit)となります。

キーの内容を列挙

# 指定のキー以下を調べる
Get-ChildItem -LiteralPath 'HKCU:\TestKey'

# 指定のキー以下を再帰的に調べる
Get-ChildItem -LiteralPath 'HKCU:\TestKey' -Recurse

# ワイルドカードを用いて「Su*」で始まるものを列挙
Get-ChildItem -Path 'HKCU:\TestKey\Su*'

# TestKey以下のキーでTestで開始するもの(大文字小文字区別せず)
Get-ChildItem -Path 'HKCU:\TestKey\' -Include "Test*"  -Recurse

Get-ChildItemを用いることでレジストキーの子要素を調べることができます。

-Pathオプションを用いてワイルドカードの指定も可能です。
-Recurseオプションを用いた場合、再帰的に要素を列挙します。この際、-Includeオプションや-Excludeオプションで取得内容を取捨選択できます。
ファイルシステムと異なり-Filterオプションによるフィルタリングは行えません。

キーの追加

# キーの追加
New-Item 'HKCU:\TestKey\x' 
New-Item 'HKCU:\TestKey\y','HKCU:\TestKey\z' 

# 「HKCU:\TestKey\a」が存在しない場合、エラーになる
New-Item 'HKCU:\TestKey\a\b' 

# Forceをつけることで以下のようになる。
# ・存在しない階層構造の場合は構造を作りながらキーを作成する
# ・すでに存在する場合はエントリーを削除して作りなおす
New-Item 'HKCU:\TestKey\a\b' -Force

New-Itemを用いることでキーの追加が行えます。
追加するキーをコンマ区切りで指定することで、一度に複数のキーを追加することが可能です。

-Forceオプションを付けると以下のような挙動になります。

  • 存在していないキーを親として指定した場合は強制的にキーを作成します。
  • 既に存在しているキーがある場合、強制的にキーを作りなおします。すでに存在したキーを削除してから作りなおします。

エントリーの追加

# エントリー作成
New-ItemProperty -LiteralPath 'HKCU:TestKey\a\b' -Name 's1' -PropertyType 'String' -Value 'test'
New-ItemProperty -LiteralPath 'HKCU:TestKey\a\b' -Name 's2' -PropertyType 'MultiString' -Value ('test', 'bbbb', 'ccc')
New-ItemProperty -LiteralPath 'HKCU:TestKey\a\b' -Name 's3' -PropertyType 'ExpandString' -Value 'Neko;%PATH%'

$b = @()
$b += [byte]0x00
$b += [byte]0xFF
New-ItemProperty -LiteralPath 'HKCU:TestKey\a\b' -Name 'b1' -PropertyType 'Binary' -Value $b

New-ItemProperty -LiteralPath 'HKCU:TestKey\a\b' -Name 'v1' -PropertyType 'DWord' -Value 1
New-ItemProperty -LiteralPath 'HKCU:TestKey\a\b' -Name 'v2' -PropertyType 'QWord' -Value 0xffffffff1

New-ItemProperty -LiteralPath 'HKCU:TestKey\a\b','HKCU:TestKey\a\c' -Name 's99' -PropertyType 'String' -Value 'test'

# ワイルドカードが効く
# HKCU:TestKey\a\b と HKCU:TestKey\a\cに作成される
New-ItemProperty -Path 'HKCU:TestKey\a\*' -Name 'x2' -PropertyType 'QWord' -Value 0xffffffff1

New-ItemPropertyを使用することでString,MultiString,ExpandString,Binary,DWord,QWordといった値をもつエントリを作成できます。
レジストリキーのパスを指定する際に、コンマ区切りで複数を指定することにより、複数のキーに対して同じエントリーを追加できます。

-Pathオプションを使用してワイルドカードを使用した場合、複数のキーにエントリが登録される可能性があります。

作成されたエントリは以下のようになります。

image.png
image.png
ワイルドカードを用いて追加をしたx2についてはHKCU:TestKey\a\bとHKCU:TestKey\a\cの両方に作成されていることが確認できます。

キーの取得

# キーをとる
$item = Get-ItemProperty -LiteralPath 'HKCU:TestKey\a\b'
Write-Host $item.v1

# 複数同時に取得も可能
$items = Get-ItemProperty -Path 'HKCU:TestKey\a\b','HKCU:TestKey\a\c'
Write-Host $item[0].PSPath $items[0].s99
Write-Host $item[1].PSPath $items[1].s99

# ワイルドカードを指定すると複数くる可能性もある
$items = Get-ItemProperty -Path 'HKCU:TestKey\a\*'
Write-Host $item[0].PSPath $items[0].x2
Write-Host $item[1].PSPath $items[1].x2

# 存在チェックは以下のように-ErrorAction指定して取得する
$item = Get-ItemProperty -LiteralPath "HKCU:TestKey\a\xxxx" -ea SilentlyContinue
$item -eq $null

Get-Itemを使用してキーを取得できます。
キーのプロパティとしてエントリ名を指定するとレジストリの値が取得できます。

レジストリキーのパスを指定する際に、複数のキーを指定することで、一度に複数のキーを取得が可能です。
-Pathオプションを用いてワイルドカードを使用した場合は複数のキーが返却される場合があります。

レジストリの値を取得

# キーの値をとる
Get-ItemPropertyValue -LiteralPath 'HKCU:TestKey\a\b' -Name s3
Get-ItemPropertyValue -LiteralPath 'HKCU:TestKey\a\b' -Name v1,v2
Get-ItemPropertyValue -LiteralPath 'HKCU:TestKey\a\b','HKCU:TestKey\a\c' -Name s99

# これもワイルドカードを指定すると複数取得される
Get-ItemPropertyValue -Path 'HKCU:TestKey\a\*' -Name x2

Get-ItemPropertyを使用してレジストリの値を取得できます。
-Nameオプションに複数のエントリ名を指定すると同時に複数の値を取得できます。
また、-Pathまたは-LiteralPathオプションに複数のレジストリキーへのパスを指定して同時に複数の値を取得することもできます。

-Pathオプションを用いてワイルドカードを使用した場合は複数の値が返却される場合があります。

なお、レジストリの値の型がExpandStringの場合は、展開されて取得されます。
たとえば「Neko;%PAHT%」という値が入っている場合、以下のように環境変数PATHが展開された文字が返却されます。

Neko;C:\Perl64\site\bin;C:\Perl64\bin;略

キーの存在チェックとエントリの存在チェック

# 存在するキーの場合は$Trueとなる
Test-Path -LiteralPath "HKLM:\Software\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell"

# 存在しないキーの場合は$Falseとなる
Test-Path -LiteralPath "HKLM:\Software\MicrosoftXXX\PowerShell\1\ShellIds\Microsoft.PowerShell"

# 存在するキーの場合は$Trueとなる
$ret = Test-Path -LiteralPath "HKLM:\Software\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell","HKLM:\Software\Microsoft\PowerShell\1\ShellIds\ScriptedDiagnostics"
Write-Host $ret[0]
Write-Host $ret[1]

# ワイルドカードも使用可能
Test-Path -Path "HKLM:\Software\Microsoft\PowerShell\1\ShellIds\*.PowerShell"

# エントリーをふくめてはいけない。以下はエントリが存在してもFalseになる
Test-Path -Path "HKLM:\Software\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell\ExecutionPolicy"

キーの存在チェックはTest-Pathを用いて行えます。

-LiteralPath または-Pathオプションにレジストリキーを指定して、そのキーが存在する場合はTrue、存在しない場合はFalseを返します。

-LiteralPath または-Pathオプションにレジストリキーを複数指定した場合は、結果は配列として複数返却されます。

-Pathオプションにワイルドカードを指定して存在チェックが可能です。

-LiteralPath または-Pathオプションに指定するパスはあくまでキーまでであり、エントリを含めた場合、正しく動作しません。

エントリーの存在チェックを行うには下記のように一度、キーの取得を行うといいと思います。

> $item = Get-ItemProperty -Path 'HKLM:\Software\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell'
> $item.ExecutionPolicy -ne $null
True
> $item.ExecutionPolicyNai -ne $null
False

レジストリの値を更新

Set-ItemProperty -LiteralPath  'HKCU:TestKey\a\b' -Name 's2' -Value @"
teststests
testsetst
testest
"@

Set-ItemProperty -LiteralPath  'HKCU:TestKey\a\b','HKCU:TestKey\a\c' -Name s99 -Value xxx123

Set-ItemProperty -Path 'HKCU:TestKey\a\*' -Name x2 -Value 123

Set-ItemPropertyを使用してレジストリの値を更新できます。

-Pathまたは-LiteralPathオプションに複数のレジストリキーへのパスを指定して同時に複数の値を更新することもできます。
Get-ItemPropertyと異なり、Nameに複数の値を指定することはできません。

-Pathオプションにワイルドカードを用いた場合、複数の値が更新される可能性があります。

エントリ名の改名

# エントリーの改名
Rename-ItemProperty -LiteralPath 'HKCU:TestKey\a\c' -Name x2 -NewName newX2

Rename-ItemPropertyを使用してエントリ名を改名できます。

-Pathオプションにワイルドカードを用いた場合、複数のエントリ名が更新される可能性があります。

キー名の改名

# キーの改名
Rename-Item -LiteralPath 'HKCU:TestKey\a\c'  -NewName newC

Rename-Itemを用いることでキー名を改名できます。

-Pathオプションにワイルドカードを用いた場合、複数のキー名が更新される可能性があります。

エントリーのコピー

Copy-ItemProperty -LiteralPath 'HKCU:TestKey\a\b' -Name v1 -Destination 'HKCU:TestKey\a'

Copy-ItemPropertyを用いてエントリのコピーを行います。

上記の例は「HKCU:TestKey\a\b」キーのv1エントリが「HKCU:TestKey\a」にコピーされます。

エントリの移動

Move-ItemProperty -LiteralPath 'HKCU:TestKey\a\b' -Name v2 -Destination 'HKCU:TestKey\a'

Move-ItemProperty -Path 'HKCU:TestKey\a\*' -Name s99 -Destination 'HKCU:TestKey\a'

Move-ItemPropertyを利用してエントリの移動を行います。

-Pathオプションを指定してワイルドカードを使用した場合、複数のエントリが移動元となり削除される場合があります。

キーのコピー

# aのもつサブキーはコピーされない
Copy-Item -LiteralPath 'HKCU:TestKey\a' -Destination 'HKCU:TestKey\Other'

# サブキーもコピーされる
Copy-Item -LiteralPath 'HKCU:TestKey\a' -Destination 'HKCU:TestKey\Other' -Recurse

Copy-Itemを用いてキーのコピーを行います。
-Recurseオプションを付与しない場合は、コピー元が保持する子要素のキーはコピーされません。
-Recurseオプションを付与した場合、コピー元が保持する子要素のキーもコピーされます。

コピー前の状態
image.png

-Recurseオプションを付与しない場合:子要素のキーはコピーされない
image.png

-Recurseを付与した場合:子要素のキーもコピーされる
image.png

キーの移動

Move-Item -LiteralPath 'HKCU:TestKey\a\newC'  -Destination 'HKCU:TestKey\Other'

Move-Itemを用いてキーを移動します。
移動元の子要素のキー含めて全て移動します。

エントリーの削除

# エントリ削除
Remove-ItemProperty -LiteralPath 'HKCU:TestKey\a\b' -Name v2
Remove-ItemProperty -LiteralPath 'HKCU:TestKey\a\b','HKCU:TestKey\Other' -Name avalue

Remove-ItemPropertyを用いてエントリの削除を行います。

Nameオプションに複数のエントリ名を指定して同時に複数のエントリを削除可能です。
また、PathまたはLiteralPathオプションに複数のレジストリキーへのパスを指定することでも複数のエントリを削除可能です。

-Pathオプションにワイルドカードを指定した場合は複数のエントリが削除される可能性があります。

キーの削除

# 子の要素がある場合は確認メッセージが表示される
Remove-Item -LiteralPath 'HKCU:TestKey\Other'

# 子の要素があっても無条件で削除
Remove-Item -LiteralPath 'HKCU:TestKey\a' -Recurse

Remove-Itemを用いてキーの削除を行います。

-Recurseオプションを付与しない場合かつ、削除対象キーに子要素のキーがある場合、下記のような確認メッセージが表示されます。

image.png

-Recurseオプションを付与した場合は確認なしで削除します。

PathまたはLiteralPathオプションに複数のレジストリキーへのパスを指定して同時に複数のキーを削除可能です。

-Pathオプションにワイルドカードを指定したばあいは複数のエントリが削除される可能性があります。

トランザクション処理

レジストリ操作はトランザクション処理が可能です

下記の例では、トランザクションを用いてキーとエントリーを追加する例です

Start-Transaction
New-Item  'HKCU:\TestKey\xxxx' -UseTransaction
New-ItemProperty  "HKCU:\TestKey\xxxx" -Name "MyKey" -Value 123 -UseTransaction
Undo-Transaction
# Complete-Transaction

まずStart-Transactionを実行します。
その後、New-Itemなどのレジストリ操作を実行しますが、この際、-UseTransactionオプションを付与します。
トランザクション内で-UseTransactionオプションを付与して操作した操作はComplete-Transactionを行うまでレジストリに反映されません。

もし処理を差し戻す場合はUndo-Transactionを使用します。

64bitOSで32bitのプロセスから動かした場合

64bitOSにて32bitプロセスのPowerShellからレジストリの登録処理を行った場合、レジストリエディターで閲覧すると期待した場所に登録されていません。

具体的に以下の例を見てみましょう。

32ビットプロセスのPowerShellから下記を実行する

New-Item 'HKLM:\SOFTWARE\TestSoft\1' -Force
New-Item 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\TestSoft\2' -Force

64ビットプロセスのPowerShellから下記を実行する

New-Item 'HKLM:\SOFTWARE\TestSoft\3' -Force
New-Item 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\TestSoft\4' -Force

プログラム的には同じ「HKEY_LOCAL_MACHINE\SOFTWARE」の配下にキーを作成しています。
しかし、実際作成される箇所はことなります。

コンピューター\HKEY_LOCAL_MACHINE\SOFTWARE\TestSoft\
image.png

コンピューター\HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\TestSoft

image.png

32ビットプロセスで動かした場合、「HKLM\SOFTWARE\Wow6432Node」の配下に登録されていることがわかります。
64ビットOSで32ビットプロセスを動作させてレジストリを操作した場合、このようなリダイレクトが発生します。

この挙動に関しては下記を参照してください。
https://www.atmarkit.co.jp/ait/articles/1502/19/news120.html
https://docs.microsoft.com/ja-jp/windows/win32/winprog64/registry-redirector

まとめ

PowerShellによるレジストリの操作についてまとめました。
特にトランザクション周りは強力だと思いますので、従来のRegeditやコマンドラインから実行するよりは楽だと思います。

たとえ途中で再起動と管理者権限が必要でも、自動化をあきらめたらそこで試合終了ですよ…?

前書き

Windowsで自動化していると面倒なケースに遭遇します。

それは再起動を挟みかつ管理者権限が必要なケースです。
たとえば、以下のようなケースになります。

・Aというアプリをインストールしたあとに再起動をしてBというアプリをインストールする
・WindowsUpdateでインストールしたあとに再起動をしてコントロールパネルの設定を実施する

今回は、Windowsにおいて再起動を挟んで管理者権限で実行するスクリプトの書き方について検討してみます。

ログインの自動化

再起動後に自動的にログインを行う方法はマイクロソフトのサポート情報として紹介されています。

How to turn on automatic logon in Windows
https://support.microsoft.com/en-us/help/324737/how-to-turn-on-automatic-logon-in-windows

これをPowerShellで記載すると下記のようになります。

ログインの自動化を有効

$RegPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
$DefaultUsername = "IEUser"
$DefaultPassword = "Passw0rd!"
Set-ItemProperty -LiteralPath $RegPath 'AutoAdminLogon' -Value "1" -type String 
Set-ItemProperty -LiteralPath $RegPath 'DefaultUsername' -Value "$DefaultUsername" -type String 
Set-ItemProperty -LiteralPath $RegPath 'DefaultPassword' -Value "$DefaultPassword" -type String

ログインの自動化を無効

$RegPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
Set-ItemProperty -LiteralPath $RegPath "AutoAdminLogon" -Value "0" -type String

ログイン後のスクリプトの実行方法

案1:Run,RunOnceレジストリを利用する

ログイン後のスクリプトの実行を行うために、Run/RunOnceというレジストリキーが存在しています。

Run and RunOnce Registry Keys
https://docs.microsoft.com/en-us/windows/win32/setupapi/run-and-runonce-registry-keys

下記のレジストリキーに文字列のエントリを追加することで、そのレジストリの値で指定されたパスのスクリプトが実行されます。

  • HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Run
  • HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run
  • HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\RunOnce
  • HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\RunOnce

HKEY_LOCAL_MACHINEとHKEY_CURRENT_USERの違いは全てのユーザで行うか、特定のユーザで行うかの違いです。

Runに登録されたスクリプトはログインのたびに実行されるようになります。
RunOnceに登録されたスクリプトは1度実行したあとに削除されます。

ただし、標準ユーザでログインした場合や、セーフモードでログインした場合は実行されません。
セーフモードでも実行したい場合はエントリ名の先頭に「*」を付与します。

RunOnceのエントリはスクリプト実行前に削除されます。これをスクリプト実行後に変更したい場合はエントリ名の先頭に「!」を指定します。

これらのキーのいずれかから実行されるプログラムは、キーの下に登録されている他のプログラムの実行を妨げるため、実行中にキーに書き込むべきではありません。
また、RunOnce下でエントリを継続的に再作成すべきではないです。

これらのスクリプトから実行される実行の権限ですが、HKEY_CURRENT_USERのRunOnceを除きすべて管理者権限がありません。
つまり、HKEY_LOCAL_MACHINEのレジストリの変更や、インストールなどの権限の厳しい作業を行うことはできません。

これらをまとめると以下のようになります。

キー 実行時のエントリ 実行時の管理者権限
HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Run 残る なし
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run 残る なし
HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\RunOnce 消える あり
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\RunOnce 消える なし

実行権限の確認方法と使用例

実行権限を確認するには以下のようなスクリプトを、Run,RunOnceで実行して検証します。

chkfunc.ps1

Param(
    [String]$path
)
# https://serverfault.com/questions/95431/in-a-powershell-script-how-can-i-check-if-im-running-with-administrator-privil
$cur = [Security.Principal.WindowsIdentity]::GetCurrent()
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal($cur)
$isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
$tm = Get-Date -Format "yyyy/MM/dd HH:mm:ss"

$txt = $tm + " " + $cur.Name + " " + $isAdmin

Add-Content -LiteralPath $path -Value $txt

このスクリプトではパラメータで与えられたファイルに実行時の時刻と、実行ユーザ、管理者権限の有無を登録します。
このスクリプトをRunOnce,Runを使用して実行するには以下のようにレジストリに登録します。

$v = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell -ExecutionPolicy RemoteSigned -File c:\share\chkfunc.ps1 c:\share\hkcu_run.txt'
New-ItemProperty -LiteralPath 'HKCU:Software\Microsoft\Windows\CurrentVersion\Run' -Name 'test1' -PropertyType 'String' -Value $v -force

$v = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell -ExecutionPolicy RemoteSigned -File c:\share\chkfunc.ps1 c:\share\hkcu_runonce.txt'
New-ItemProperty -LiteralPath 'HKCU:Software\Microsoft\Windows\CurrentVersion\RunOnce' -Name 'test2' -PropertyType 'String' -Value $v -force

$v = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell -ExecutionPolicy RemoteSigned -File c:\share\chkfunc.ps1 c:\share\hklm_run.txt'
New-ItemProperty -LiteralPath 'HKLM:Software\Microsoft\Windows\CurrentVersion\Run' -Name 'test3' -PropertyType 'String' -Value $v -force

$v = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell -ExecutionPolicy RemoteSigned -File c:\share\chkfunc.ps1 c:\share\hklm_runonce.txt'
New-ItemProperty -LiteralPath 'HKLM:Software\Microsoft\Windows\CurrentVersion\RunOnce' -Name 'test4' -PropertyType 'String' -Value $v -force

Windows7の環境で2回再起動を繰り返した結果は以下のようになります。

hkcu_run.txt

2019/09/04 13:44:56 IEWIN7\IEUser False
2019/09/04 13:48:32 IEWIN7\IEUser False

hkcu_runonce.txt

2019/09/04 13:44:55 IEWIN7\IEUser False

hklm_run.txt

2019/09/04 13:44:55 IEWIN7\IEUser False
2019/09/04 13:48:32 IEWIN7\IEUser False

hklm_runonce.txt

2019/09/04 13:44:53 IEWIN7\IEUser True

runonceの場合、1度のみ実行されていることと、hklm以下のRunOnceのみが管理者権限で実行できることが確認できます。

なお、この結果はWindows10でも同じでした。

使いどころ

再起動後、一度だけ実行すればよいという状況では有効かもしれません。
もし、複数の再起動がある場合は後述のタスクスケジューラを利用したほうがいいでしょう。

案2:タスクスケジューラを利用する

Windowsが標準で搭載しているタスクスケジューラを利用することで、「ログイン後」「最上位の特権」でスクリプトを自動で実行することが可能です。

タスクスケジューラは管理者権限のあるプロセスからであれば以下のコマンドで利用できます。

schtasks

ログイン後に実行するタスクを登録するスクリプトは以下になります

schtasks /create /tn タスク名 /tr スクリプトのパス /sc onlogon /rl highest /F

/sc オプションでどのタイミングで実行するかを指定します。今回はログイン後なのでonlogonとなります。
/rl hightが最上位の特権をあらわしており、/Fで同名のタスクがあった場合に上書きを行います。

また、、タスクを削除するには以下のようになります。

schtasks /Delete /tn "MyTask" /F

サンプル

今回は再起動後にTortoiseSVNをインストールしてみる例を考えてみます。
一般的なインストーラの自動化方法についてはココに記載しています。

再起動前に実行するスクリプト settask.ps1

settask.ps1

# 管理者権限で実行

# 再起動後に実行するタスクを登録
# 最近のバージョンはRegister-ScheduledTaskが使えそう.
$run = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell -ExecutionPolicy RemoteSigned -File C:\share\after_restart.ps1'
schtasks /create /tn "MyTask" /tr $run /sc onlogon /rl highest /F
if ($lastexitcode -ne 0) {
    exit
}

# 検証用のイベントログを登録できるようにSource「test」を追加しておく
New-EventLog -LogName Application -Source test -ea SilentlyContinue

# ログインの自動化
# http://get-cmd.com/?p=4679
$RegPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
$DefaultUsername = "IEUser"
$DefaultPassword = "Passw0rd!"
Set-ItemProperty -LiteralPath $RegPath 'AutoAdminLogon' -Value "1" -type String 
Set-ItemProperty -LiteralPath $RegPath 'DefaultUsername' -Value "$DefaultUsername" -type String 
Set-ItemProperty -LiteralPath $RegPath 'DefaultPassword' -Value "$DefaultPassword" -type String

Restart-Computer -Force

再起動後に実行するスクリプト after_restart.ps1

after_restart.ps1

# なんか処理をする
Start-Process msiexec.exe -Wait -NoNewWindow -ArgumentList '/i c:\share\TortoiseSVN-1.12.2.28653-x64-svn-1.12.2.msi /passive /qn /norestart ADDLOCAL=F_OVL,DefaultFeature,MoreIcons,CLI,CrashReporter,UDiffAssoc,DictionaryENGB,DictionaryENUS'

# ログインの自動化を解除
$RegPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
Set-ItemProperty -LiteralPath $RegPath "AutoAdminLogon" -Value "0" -type String

# タスクを消す
schtasks /Delete /tn "MyTask" /F

# イベントログに記録
Write-EventLog -LogName Application -Source test -EventID 2 -EntryType Information -Message "再起動後のスクリプトの実行しました"

再起動前に実行するスクリプトではタスクスケジューラにタスクを登録して、自動ログインを有効にしたのち、再起動をおこなっています。

再起動後に実行するスクリプトではインストーラを実行したのち、タスクを削除して自動ログインを無効にしています。

今回は、古い環境を考慮してschtasks コマンドを利用しましたが新しいPowerShellが使える環境ではRegister-ScheduledTaskの使用も検討可能です。

リモートからの実行

リモートから再起動を制御できるようになると、同時に複数の端末に対しての自動化が可能になります。
今回は下記の記事の「方法4:schtasks (タスクスケジューラ)」と「方法5:PowerShell (WinRM)」を利用しまして再起動後にTortoiseSVNをリモートからインストールする例を考えてみました。

別端末(Windows)のプログラムを標準機能でリモート起動する方法まとめ
https://qiita.com/0829/items/5518256b348521ac358c

$password = ConvertTo-SecureString "Passw0rd!" -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential("WORKGROUP\IEUser", $password)
$computerName = "IEWin7"

# 再起動前管理者権限がいらないリモート側の処理
Invoke-Command -ComputerName $computerName -Credential $cred -ScriptBlock {
    Get-ChildItem -LiteralPath "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall","HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" | %{$_.GetValue("DisplayName")}

    # ここで管理者権限で実行させたいスクリプトを書いてしまうのも手
    Set-Content -Path c:\share\autologin.ps1 -Value @"
Set-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' 'AutoAdminLogon' -Value 1 -type String 
Set-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' 'DefaultUsername' -Value IEUser -type String 
Set-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' 'DefaultPassword' -Value Passw0rd! -type String
"@

}

# リモート側で再起動前に実行させたい管理者権限のあるタスクを登録して実行
$run = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell -ExecutionPolicy RemoteSigned -File c:\share\autologin.ps1'
schtasks /create  /S IEWIN7 /U IEUser /P Passw0rd!  /TN MyTaskInit /sc ONCE /sd 1900/01/01 /st 00:00 /tr $run /rl highest
schtasks /run /S IEWIN7 /U IEUser /P Passw0rd! /TN MyTaskInit
schtasks /delete /S IEWIN7 /U IEUser /P Passw0rd! /TN MyTaskInit /F

# リモート側で再起動後にさせたいタスクを登録
$run = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell -ExecutionPolicy RemoteSigned -File C:\share\after_restart.ps1'
schtasks /create /tn "MyTask"  /S IEWIN7 /U IEUser /P Passw0rd!  /tr $run /sc onlogon /rl highest /F

# 再起動(応答まで、すごく時間がかかる)
Restart-Computer -ComputerName IEWin7 -Credential  $cred -Wait -Force

# 再起動後管理者権限がいらないリモート側の処理
Invoke-Command -ComputerName $computerName -Credential  $cred -ScriptBlock {
    # 管理者権限がいらないリモート側の処理
    Get-ChildItem -LiteralPath "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall","HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" | %{$_.GetValue("DisplayName")}
}

(1)まず、WinRM経由で管理者権限のいらない再起動前の処理を行います。
このときに、管理者権限が必要な処理用のスクリプトを操作対象の端末に作成してます。

(2)操作対象の端末のタスクスケジューラに管理者権限の必要なスクリプトを実行するタスクを追加して実行します。

(3)再起動後に行うスクリプトをタスクスケジューラに登録します。

(4)再起動を実施します。

(5)再起動後、管理者権限の必要のない処理をWinRM経由で行います。

まとめ

今回は自動化を行う上で、再起動があって、管理者権限も必要であるというケースに対応する方法を検討してみました。

昔、偉い人が「あきらめたらそこで試合終了ですよ…?」といいましたが、一見無理っぽいケースにおいても結構やれる方法はあったりします。

参考

How to turn on automatic logon in Windows
https://support.microsoft.com/en-us/help/324737/how-to-turn-on-automatic-logon-in-windows

Automatic Logon in Windows 10 using PowerShell
http://get-cmd.com/?p=4679

Run and RunOnce Registry Keys
https://docs.microsoft.com/en-us/windows/win32/setupapi/run-and-runonce-registry-keys

Reboot and Resume PowerShell Script
https://www.codeproject.com/Articles/223002/Reboot-and-Resume-PowerShell-Script

Windows で任意のコマンド(タスク)を自動実行する (schtasks)
https://maku77.github.io/windows/admin/schtasks.html

Continue Your Automation To Run Once After Restarting a Headless Windows System
https://cloudywindows.io/post/continue-your-automation-to-run-once-after-restarting-a-headless-windows-system/

In a PowerShell script, how can I check if I'm running with administrator privileges?
https://serverfault.com/questions/95431/in-a-powershell-script-how-can-i-check-if-im-running-with-administrator-privil

別端末(Windows)のプログラムを標準機能でリモート起動する方法まとめ
https://qiita.com/0829/items/5518256b348521ac358c

Powershellによるファイル操作のまとめ

目的

今回はPowerShellのファイル操作をまとめてみます。

検証環境

OS PSVersion
Windows10 5.1
CentOs7 7.0.0-preview.3

※基本的にWindows10+PowerShell5.1で確認していますが、シンボリックリンク、ハードリンク等の挙動が変わりそうな箇所はCentOS+PowerShell7.0でも確認してます。

やりたい事リスト

やりたい事 Windows10 コマンドプロンプト CentOS7 シェル Powershell
カレントディレクトリの取得 echo %CD% pwd Get-Location
カレントディレクトリの設定 cd/pushd/popd cd Set-Location/Push-Location/Pop-Location
空ファイルの作成 copy nul test.txt touch New-Item
フォルダの作成 mkdir mkdir New-Item -Type Directory/Mkdir(Windowsのみ)
シンボリックリンクの作成 mklink or mklink /d ln -s New-Item
ジャンクションの作成 mklink /j - New-Item
ハードリンクの作成 mklink /h ln New-Item
ファイルの一覧表示 dir ls Get-ChildItem
ファイルの検索 where /R c:\dev\ps\file\ *.txt find Get-ChildItem
ファイルの削除 del rm Remove-Item
フォルダの削除 rmdir rmdir/rm -rf Remove-Item
ファイルのコピー copy cp Copy-Item
フォルダのコピー xcopy cp -a Copy-Item
ファイル/フォルダの移動 move mv Move-Item
ファイル/フォルダの改名 ren mv Rename-Item
ファイルの内容表示 type cat Get-Content
ファイルの更新 echo xxxx>test.txt echo xxx > test.txt Set-Content/Out-File
ファイルの追記 echo xxxx>>test.txt echo xxx >>test.txt Add-Content/Out-File -Append
ファイルの追記の監視 - tail -f Get-Content -Wait
ファイルを空にする copy nul test.txt cp /dev/null access_log Clear-Content
ファイルの属性変更 attrib chmod Set-ItemProperty
ファイルの所有者変更 icacls b.txt /setowner Administrators chown Set-Acl
ファイル中の文字検索 findstr grep Select-String
フォルダのサイズ取得 - - Get-ChildItem + Measure-Object
ファイルの存在チェック - - Test-Item
パスの結合 - - Join-Path
相対パスから絶対パスの変換 - - Resolve-Path

カレントディレクトリの取得

従来の方法

Windows10のコマンドプロンプトの例

echo %CD%

CentOs7のシェルの例

pwd

PowerShellの例

PS C:\dev\ps\file> Get-Location

Path
----
C:\dev\ps\file

Get-Locationを使用することで現在作業しているカレントディレクトリを取得できます。
ファイルシステムで実行した場合、返却される値はSystem.Management.Automation.PathInfoになっています。

カレントディレクトリの設定

従来の方法

Windows10のコマンドプロンプトの例

cd c:\dev\ps\file

CentOs7のシェルの例

cd ~/test

PowerShellの例

Set-Locationの例

例1Set-Locationを使用する場合

Set-Location -Literal c:\dev\
Set-Location -Path c:\d?v
Set-Location c:\d?v

Set-Locationを使用してカレントディレクトリを変更可能です。

この際、-Pathまたは-Pathと-LiteralPathを省略をした場合はワイルドカードの指定を受け付けることができます。
もし、これにより複数のパスが帰った場合は以下のエラーが発生します。

> Set-Location -Path c:\d*
Set-Location : パス 'c:\d*' が複数のコンテナーに解決されるため、場所を設定できません。場所を設定できるのは、同時に 1 つ
のコンテナーのみです。
発生場所 行:1 文字:1
+ Set-Location -Path c:\d*
+ ~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidArgument: (:) [Set-Location]、PSArgumentException
    + FullyQualifiedErrorId : Argument,Microsoft.PowerShell.Commands.SetLocationCommand

スタックを利用したカレントディレクトリの設定例

PS C:\dev\ps> Set-Location c:\dev
PS C:\dev> Push-Location -LiteralPath c:\dev\ps
PS C:\dev\ps> Push-Location -Path c:\d?v\ps\file
PS C:\dev\ps\file> Push-Location  c:\d?v\ps\right
PS C:\dev\ps\right> Get-Location -Stack

Path
----
C:\dev\ps\file
C:\dev\ps
C:\dev

PS C:\dev\ps\right> Pop-Location
PS C:\dev\ps\file> Pop-Location
PS C:\dev\ps> Pop-Location
PS C:\dev>

Push-Locationはカレントディレクトリをスタックの一番上に積んだのちに、-Path,-Literalで指定したパスをカレントディレクトリとします。
-Pathを指定した場合、ワイルドカードによる指定が可能になりますが、対象が複数になった場合、エラーとなります。
-Path,-LiteralPathを省略してパスを指定した場合、-Pathが指定されたものとしてワイルドカードを受け付けます。

Get-Locationの-Stackオプションを付与することで現在のスタックの状況を表示することが可能です。

Pop-Loacationを使用するとカレントディレクトリは最後にプッシュされた場所に変更します。また、スタックの一番上は削除されます。

スタックを利用することで関数内でカレントディレクトリを変更する処理を行っても呼出し元の処理に影響を与えないようにすることが可能になります。

function test_fun() {
    Push-Location c:\dev\
    Write-Host "なんかの処理:"  (Get-Location).Path
    Pop-Location
}

Set-Location c:\
test_fun
Write-Host "カレントディレクトリがc:\であることの確認:"  (Get-Location).Path

スタックには名前を付けて積むことも可能です。

C:\> Push-Location -StackName n1 c:\dev
C:\dev> Push-Location -StackName n1 c:\dev\ps
C:\dev\ps> Push-Location -StackName n2 c:\share
C:\share> Get-Location -StackName n1

Path
----
C:\dev
C:\

C:\share> Get-Location -StackName n2

Path
----
C:\dev\ps

C:\share> Pop-Location -StackName n1
C:\dev> Pop-Location -StackName n2
C:\dev\ps> Get-Location -StackName n1

Path
----
C:\

空ファイルの作成

従来の方法

Windows10のコマンドプロンプトの例

echo. 2>test.txt
copy nul test.txt

CentOs7のシェルの例

touch test.txt

PowerShellの例

New-Item ./abc[1].txt -Type File
New-Item -Path ./abc[2].txt -Type File
New-Item . -Name abc[3].txt -Type File
New-Item ./あいうえおおおおお.txt
New-Item -Path ./xxx[1].txt,./xxx[2].txt -Type File

# ワイルドカードを指定して作成
New-Item -Path ./???/ -Name abc1.txt -Type File
New-Item ./???/ -Name abc2.txt -Type File

# 既存のファイルを上書きする
New-Item ./abc[1].txt -Type File -Force

空ファイルを作成する場合はNew-ItemのTypeオプションを省略して実行するか、TypeオプションにFileを指定して実行します。

パスの指定の方法としては-Pathオプションにファイル名までのパスを入れるか、-Pathオプションに親フォルダのパスを指定して、-Nameオプションにファイル名を指定します。

-Pathは省略可能となっており、その場合、一番目のパラメータがPathとみなされます。

-Pathオプションは「,」区切りでパスを指定できます。

-Pathにはワイルドカードを受け付けることが可能です。
また、New-ItemコマンドレットにはLiteralPathが存在しません。
そのため、以下のような挙動を行っているようです。

  • -Nameオプションを使用する場合はワイルドカードを受け付ける。
  • -Nameオプションを使用しない場合はワイルドカードを受け付けない

新規作成先にすでにファイルが存在する場合、以下のエラーが発生します

New-Item : ファイル 'C:\dev\ps\file\abc[1].txt' は既に存在します。
発生場所 行:1 文字:1
+ New-Item ./abc[1].txt -Type File
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : WriteError: (C:\dev\ps\file\abc[1].txt:String) [New-Item], IOException
    + FullyQualifiedErrorId : NewItemIOError,Microsoft.PowerShell.Commands.NewItemCommand

もし強制的に上書きをする場合は「-Force」オプションを付与します。

フォルダの作成

従来の方法

Windows10のコマンドプロンプトの例

mkdir xyz

CentOs7のシェルの例

mkdir xyz

PowerShellの例

New-Item ./xyz -Type Directory
New-Item -Path ./xyz[2] -Type Directory
New-Item -Path . -Name xyz[3] -Type Directory
New-Item ./xyz1[z],./xyz1[x] -Type Directory
New-Item ./xyz/abc -Type Directory
New-Item ./sonzaisinai/1/2/3/abc -Type Directory

# ワイルドカードの指定
New-Item -Path ./??? -Name yyy -Type Directory

# 存在していてもエラーとしない
New-Item ./xyz -Type Directory -Force

# CentOSでは不可
mkdir -Path xyz[4] -Name xxx

フォルダを作成する場合はNew-ItemのTypeオプションにDirectoryを指定して実行します。
パスに存在しないフォルダが混ざっていても、途中の存在しないフォルダを作成しながらフォルダを作成していきます。

基本的なオプションの使い方は空ファイルの作成で説明したものと同じです。

またWindowsにかぎり、mkdirで同様の処理が行えます。CentOS7ではシェルのmkdirが優先されるため動作しません。

シンボリックリンクの作成

Windowsの場合、シンボリックリンクの作成には管理者権限が必要です。

従来の方法

Windows10のコマンドプロンプトの例

mklink slink_a.txt c:\dev\ps\file\link\a.txt
mklink sdir c:\dev\ps\file\link\sub2 /D

CentOs7のシェルの例

ln ../a.txt ./slink_a.txt -s
ln ../sub2 ./slink_dir -s

PowerShellの例

New-Item -Value '../a.txt' -Path './slink.txt'  -ItemType SymbolicLink
New-Item -Value '../sub2' -Path './slink_dir'  -ItemType SymbolicLink

# SymbolicLinkの場合LinkTypeはSymbolicLinkとなる
(Get-Item ./slink.txt).LinkType
(Get-Item ./slink_dir).LinkType

# リンク先を表示
(Get-Item ./slink.txt).Target
(Get-Item ./slink_dir).Target

-Valueオプションにリンク先を入力し、-ItemTypeオプションにSymbolicLinkを設定します。

指定のファイルがシンボリックリンクであるか確認するには、Get-Itemを使用してSystem.IO.DirectoryInfoまたはSystem.IO.FileInfoのLinkTypeプロパティとTargetプロパティを確認します。

ジャンクションの作成

従来の方法

Windows10のコマンドプロンプトの例

mklink /j junction_dir c:\dev\ps\file\link\sub1

PowerShellの例

New-Item -Value '../sub1' -Path './junction_dir'  -ItemType Junction

# Junctionの場合LinkTypeはJunctionとなる
(Get-Item ./junction_dir).LinkType

# リンク先を表示
(Get-Item ./junction_dir).Target

-Valueオプションにリンク先を入力し、-ItemTypeオプションにJunctionを設定します。
もし-Valueにファイルを指定した場合、下記のエラーが発生します。

New-Item : この操作にはディレクトリが必要です。項目 'C:\dev\ps\file\link\a.txt' はディレクトリではありません。
発生場所 行:1 文字:1
+ New-Item -Value '../a.txt' -Path './junction_txt'  -ItemType Junction
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (C:\dev\ps\file\link\a.txt:String) [New-Item]、InvalidOperationException
    + FullyQualifiedErrorId : ItemNotDirectory,Microsoft.PowerShell.Commands.NewItemCommand

CentOS7+PowerShell7で実行した場合、エラーが表示されずにNew-Itemが終了しますが実際にジャンクションは作成されません。

ハードリンクの作成

従来の方法

Windows10のコマンドプロンプトの例

mklink /H hardlink_a.txt c:\dev\ps\file\link\a.txt

CentOs7のシェルの例

ln ../a.txt ./link_a.txt

PowerShellの例

# Windows10 + PowerShell5.1
New-Item -Value '../a.txt' -Path './hard_link_a.txt'  -ItemType HardLink

# CentOS7+PowerShell7の場合-Valueにフルパスを入れないとエラーになった
New-Item -Value /home/username/test/link/a.txt -Path './hard_link_a.txt'  -ItemType HardLink

# HardLinkの場合LinkTypeはHardLinkとなる
(Get-Item ./hard_link_a.txt).LinkType

# Targetでリンク先のパスを表示する。複数ある場合は複数表示。
# ※CentOS7+PowerShell7だと取得できなかった
(Get-Item ./hard_link_a.txt).Target

-Valueオプションにリンク先を入力し、-ItemTypeオプションにHardLinkを設定します。
もし-Valueにフォルダを指定した場合、下記のエラーが発生します。

New-Item : この操作にはファイルが必要です。項目 'C:\dev\ps\file\link\sub1' はファイルではありません。
発生場所 行:1 文字:1
+ New-Item -Value '../sub1' -Path './hard_link_test.txt'  -ItemType Har ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (C:\dev\ps\file\link\sub1:String) [New-Item]、InvalidOperationException
    + FullyQualifiedErrorId : ItemNotFile,Microsoft.PowerShell.Commands.NewItemCommand

ファイルの一覧表示

従来の方法

Windows10のコマンドプロンプトの例

dir c:\dev\

# サブディレクトリを再帰的に表示する例
dir c:\dev\ /s 

CentOs7のシェルの例

ls ~/test

# サブディレクトリを再帰的に表示する例
ls ~/test -R

PowerShellの例

単純な例

# カレントディレクトリの一覧
Get-ChildItem

# パスを指定して検索
Get-ChildItem -LiteralPath ./lstestdir
Get-ChildItem -LiteralPath ./lstestdir,./testdir

# ワイルドカードを指定する
Get-ChildItem -Path ./t*,r*
Get-ChildItem ./t*,r*

# サブフォルダを再帰的に呼び出す
Get-ChildItem -LiteralPath ./lstestdir -Recurse
Get-ChildItem -LiteralPath ./lstestdir -r

Get-ChildItemを使用することで指定のパス配下のファイルとディレクトリの一覧を取得できます。

Get-ChildItemはSystem.IO.FileInfoの配列を返します。

結果をそのままコンソールに出力するとWindows10 + PowerShell5.1では以下のような形式で表示されます。

Get-ChildItem -Path C:\Test

Directory: C:\Test

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d-----        2/15/2019     08:29                Logs
-a----        2/13/2019     08:55             26 anotherfile.txt
-a----        2/12/2019     15:40         118014 Command.txt
-a----         2/1/2019     08:43            183 CreateTestFile.ps1
-ar---        2/12/2019     14:31             27 ReadOnlyFile.txt

Modeの意味合いは以下のようになります。

  • l (link)
  • d (directory)
  • a (archive)
  • r (read-only)
  • h (hidden)
  • s (system).

パスを省略して実行した場合はカレントディレクトリの一覧を表示します。

パスの指定は-LiteralPathまたは-Pathを指定して行います。この際、「,」で複数のパスを指定することで一覧表示の対象パスを複数指定することが可能です。

-LiteralPathは-Literalまたは-PsPathと記述することも可能のようです。

-Pathを指定した場合はワイルドカードを受け付けます。
-LiteralPathも-Pathも省略した場合に指定されたパスは-Pathと同様の動作をします。

サブフォルダの内容も取得したい場合は-Recurseを付与します。

デフォルトの検索オプションでは隠しファイルが表示されないことに気をつけてください。
以下のように-Forceオプションを付与することで隠しファイルも表示されるようになります。

Get-ChildItem -Literal ./lstestdir/test  -Force

属性を指定した一覧取得

PowerShell3.0以降であれば、ファイルの属性を指定して一覧の取得が可能です。

# 隠しファイルのみ表示する
Get-ChildItem -Hidden

# システムファイルのみ表示する
Get-ChildItem -System

# 読み取り専用のみ表示
Get-ChildItem -ReadOnly

# ディレクトリのみ表示
Get-ChildItem -Directory

# ファイルのみ表示
Get-ChildItem -File

# ファイルでかつ隠しファイルの場合表示
Get-ChildItem -File -Hidden

# 複雑な組み合わせで表示→(読み取り専用 and ディレクトリ以外) or (ディレクトリ以外 and 隠しファイル)
Get-ChildItem -Attributes ReadOnly+!Directory, !Directory+Hidden

-AttributesオプションはFileAttributes Enumを下記の演算子で組み合わせて使用します。

  • ! (NOT)
    • (AND)
  • , (OR)

Windows7に初期インストールされているPowerShell2.0ではこれらのオプションは無効なので注意してください。

表示内容を絞りこむ

Get-ChildItemには表示内容を絞り込むオプションがいくつかあります。

# 再帰の深さを制限する
Get-ChildItem -LiteralPath c:\dev\ps\file\testdir -Depth 1

# フィルタを指定して検索
Get-ChildItem -LiteralPath c:\dev\ps\file -Filter *.txt
Get-ChildItem -LiteralPath c:\dev\ps\file -Filter *.txt
Get-ChildItem -LiteralPath c:\dev\ps\file -Filter ????.txt
Get-ChildItem c:\dev\ps\file ????.txt

# Excludeオプションを指定して検索 tで始まるファイルは除く
Get-ChildItem c:\dev\ps\file -Exclude t*
# Excludeオプションを指定して検索 tで始まるファイルと ps1で終わるファイルを除く
Get-ChildItem c:\dev\ps\file -Exclude t*,*.ps1

# Includeオプションを指定して検索 txtで終わる文字
Get-ChildItem c:\dev\ps\file -Recurse -Include *.txt
# aまたはbで始まる文字
Get-ChildItem c:\dev\ps\file -Recurse -Include a*,b*

-depthオプション

-depthオプションは再帰の深さを指定して検索することができます。
たとえば以下のような構造のフォルダが存在するとします。

C:\DEV\PS\FILE\TESTDIR
├─src1
├─src2
└─test
    ├─a
    │  ├─junction_src3
    │  ├─junction_src4
    │  └─junction_src5
    ├─b
    │  ├─junction_src6
    │  └─junction_src7
    ├─junction_src2
    └─symbolic_src1

「-depth 1」で表示対象となるフォルダはsrc1,src2,testまでになります。
「-depth 0」を指定した場合は再帰を行いません。
なお、-depthオプションはPowerShell2.0では使用できません。

-Filterオプション

-Filterオプションは「*」と「!」のワイルドカードを使用してフィルタを行います。
このワイルドカードはいわゆるPowerShellのワイルドカードとは異なります。

では実際に以下のフォルダで-Filterと-Pathで与えられるワイルドカードのふるまいについて検証してみます。

C:\DEV\PS\FILE\LSFILTERTEST
    abcd.log
    abcd.txt
    b.log
    b.txt
    bbb.log
    bbb.txt
    c.log
    c.txt
    cc.log
    cc.txt
    d.log
    d.txt
    [bc].log
    [bc].txt

実行例

PS C:\dev\ps\file\lsfiltertest> Get-ChildItem -Path ./*.txt

    ディレクトリ: C:\dev\ps\file\lsfiltertest

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2019/09/06     15:43              0 abcd.txt
-a----       2019/09/06     15:43              0 b.txt
-a----       2019/09/06     15:43              0 bbb.txt
-a----       2019/09/06     15:43              0 c.txt
-a----       2019/09/06     15:43              0 cc.txt
-a----       2019/09/06     15:39              0 d.txt
-a----       2019/09/06     15:43              0 [bc].txt

PS C:\dev\ps\file\lsfiltertest> Get-ChildItem -Literal ./ -Filter *.txt

    ディレクトリ: C:\dev\ps\file\lsfiltertest

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2019/09/06     15:43              0 abcd.txt
-a----       2019/09/06     15:43              0 b.txt
-a----       2019/09/06     15:43              0 bbb.txt
-a----       2019/09/06     15:43              0 c.txt
-a----       2019/09/06     15:43              0 cc.txt
-a----       2019/09/06     15:39              0 d.txt
-a----       2019/09/06     15:43              0 [bc].txt

PS C:\dev\ps\file\lsfiltertest>
PS C:\dev\ps\file\lsfiltertest> Get-ChildItem -Path ./????.txt

    ディレクトリ: C:\dev\ps\file\lsfiltertest

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2019/09/06     15:43              0 abcd.txt
-a----       2019/09/06     15:43              0 [bc].txt

PS C:\dev\ps\file\lsfiltertest> Get-ChildItem -Literal ./ -Filter ????.txt

    ディレクトリ: C:\dev\ps\file\lsfiltertest

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2019/09/06     15:43              0 abcd.txt
-a----       2019/09/06     15:43              0 b.txt
-a----       2019/09/06     15:43              0 bbb.txt
-a----       2019/09/06     15:43              0 c.txt
-a----       2019/09/06     15:43              0 cc.txt
-a----       2019/09/06     15:39              0 d.txt
-a----       2019/09/06     15:43              0 [bc].txt

PS C:\dev\ps\file\lsfiltertest>
PS C:\dev\ps\file\lsfiltertest> Get-ChildItem -Path ./[bc].txt

    ディレクトリ: C:\dev\ps\file\lsfiltertest

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2019/09/06     15:43              0 b.txt
-a----       2019/09/06     15:43              0 c.txt

PS C:\dev\ps\file\lsfiltertest> Get-ChildItem -Literal ./ -Filter [bc].txt

    ディレクトリ: C:\dev\ps\file\lsfiltertest

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2019/09/06     15:43              0 [bc].txt

この結果から以下のことがわかります。

  • *については挙動に差はない
  • ?については-Filterについては任意の1文字を表しますが、それは空文字も含みます。この挙動はcmd.exeのdirのワイルドカードと同じ挙動です。
  • []については-Filterはサポートしません。

また、-Filterオプションは他のパラメーターよりも効率的に動きます。このことに関する、実際の速度の比較についての情報は以下のブログに記載があります。

Get-ChildItem and the–Include and –Filter parameters
https://tfl09.blogspot.com/2012/02/get-childitem-and-theinclude-and-filter.html

-Excludeオプション

-Excludeオプションで指定した文字のパターンを除外します。
-LiteralPathオプションと組み合わせた場合、期待通り動作しません。

このオプションはワイルドカードを受け付けることが可能です。
「,」区切りで複数のパターンを指定できますが、この場合は指定したパターン全てを除外します。

-Includeオプション

-Pathオプションで指定されたフォルダから特定のアイテムを検索します。
-LiteralPathオプションを使用した場合、期待通り動作しません。

このオプションは-Recurseと共に使用します。

このオプションはワイルドカードを受け付けることが可能です。
「,」区切りで複数のパターンを指定できますが、この場合は指定したパターン全てを受け付けます。

シンボリックリンク、ジャンクションを含む場合

シンボリックリンク、ジャンクションを含むフォルダで下記のコマンドを実行したとします。

Get-ChildItem . -Recurse

この結果は環境によってことなります。

Windows10+Powershell5.1の場合

   ディレクトリ: C:\dev\ps\file\link\ps

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d----l       2019/09/08      1:44                junction_dir
d----l       2019/09/08      1:59                slink_dir
-a----       2019/09/08      1:04             12 hard_link_a.txt
-a---l       2019/09/08      1:59              0 slink.txt

    ディレクトリ: C:\dev\ps\file\link\ps\junction_dir

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2019/09/08      0:54              9 s1.txt
-a----       2019/09/08      0:54              9 s2.txt

    ディレクトリ: C:\dev\ps\file\link\ps\slink_dir

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2019/09/08      0:55              9 s2_1.txt
-a----       2019/09/08      0:55              9 s2_2.txt

シンボリックリンク、ジャンクション、ハードリンクについて、Modeに「l」が付与されていることが確認できます。
また、シンボリックリンク、ジャンクションのフォルダを再帰的に探査していきます。

CentOS7+Powershell7の場合

    Directory: /home/xxxxxxxx/test/link/ps

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
l----          2019/09/08     2:03                slink_dir -> /home/xxxxxxxx/t
                                                  est/link/sub2
-----          2019/09/08     0:58              7 hard_link_a.txt
l----          2019/09/08     1:55              8 slink_a.txt -> /home/xxxxxxxx
                                                  /test/link/a.txt
l----          2019/09/08     2:01              8 slink.txt -> /home/xxxxxxxx/t
                                                  est/link/a.txt

シンボリックリンク、ハードリンクについて、Modeに「l」が付与されていることが確認できます。
そして、シンボリックリンクの再帰的な探査は行われません。

もしシンボリックリンク内も探査したい場合は以下のように-FollowSymlinkをつけます。

Get-ChildItem . -Recurse -FollowSymlink

出力書式の変更

パイプを使って書式整形用のコマンドレットに渡すことで様々な書式で表示することが可能です。

Format-Wide

Format-Wideは「dir /w」のような幅広いテーブルで結果を返します。

Get-ChildItem | Format-Wide
    ディレクトリ: C:\dev\ps\file\lsfiltertest

abcd.log                                                                 abcd.txt
b.log                                                                    b.txt
bbb.log                                                                  bbb.txt
c.log                                                                    c.txt
cc.log                                                                   cc.txt
d.log                                                                    d.txt
[bc].log                                                                 [bc].txt

Out-GridView

Out-GridViewは結果をGUIで表示します。

Get-ChildItem | Out-GridView

image.png

LinuxやMacなどのWindows以外のプラットフォームにおいて標準では使用できません。
しかし、Microsoft.PowerShell.GraphicalToolsモジュールをインストールすると使用できるようです(未検証)

[PowerShell] 帰ってきたOut-GridView
https://dev.classmethod.jp/server-side/out-gridview-returns-powershell-core/

ファイルの検索

従来の方法

Windows10のコマンドプロンプトの例

where /R c:\dev\ps\file\ *.txt

CentOs7のシェルの例

find ~/test *.txt

PowerShellの例

Get-ChildItem -LiteralPath c:\dev\ps\file\lstestdir -Filter *.txt -Recurse -Force

Get-ChildItem -Path c:\dev\ps\file\lstestdir -Include *.txt -Recurse -Force

# 100バイトより大きいtxtファイルを取得
Get-ChildItem . -Filter *.txt -r | Where-Object { $_.Length -gt 100 }

表示内容を絞りこむで紹介したようにGet-ChildItemの-Filterまたは-Inputオプションを用います。

オプションだけで対応できない場合はWhere-Objectを用いてプロパティを使用して条件を抽出します。

ファイルの削除

従来の方法

Windows10のコマンドプロンプトの例

del test.txt

CentOs7のシェルの例

rm test.txt

PowerShellの例

Remove-Item -LiteralPath test.txt
Remove-Item -LiteralPath test.txt,test2.txt
Remove-Item -Path *.txt
Remove-Item *.txt

# 読み取り専用ファイルを削除する
Remove-Item -LiteralPath readonly.txt -Force

# 何が削除されるか事前に確認する
Remove-Item -Path *.txt -WhatIf

# 削除の確認メッセージを表示する
Remove-Item -Path *.txt -Confirm

# カレントディレクトリのtxt拡張子のファイルをすべて消す
Remove-Item -Path * -Filter *.txt -WhatIf
Remove-Item -Path * -Include *.txt -WhatIf

# カレントディレクトリ以下の全てのフォルダのtxt拡張子ファイルを削除する
Get-ChildItem . -Filter *.txt -Recurse | Remove-Item -WhatIf

ファイルの削除はRemove-Itemで行います。

-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。
-LiteralPathと-Pathオプションを省略した場合、-Pathと同じ動作をします。

-LiteralPathと-Pathオプションには削除対象のパスをコンマ区切りで複数指定可能です。

読み取り専用のファイルを削除する場合、以下のようなエラーとなります。
image.png
この場合は-Forceオプションを付与することで削除がおこなえます。

-WhatIfオプションを付与して実行することで、削除を行わず、削除対象のみを表示します。これはワイルドカードを使用した際に事前に影響を調べるのに有効です。
image.png

-Confirmオプションを付与して実行することで各ファイルの削除確認を行いながら削除を進めることが可能になります。
image.png

ファイルの一覧表示のGet-ChildItemで出てきた-Filterオプションと-Includeオプションを利用して削除するファイルを絞りこむこともできます。

またGet-ChildItemでファイルを絞り込んだ後にパイプを使ってRemove-Itemに渡すことで削除も可能です。
この際は、予期せぬファイルが消える可能性があるのでWhat-Ifを使用して事前に削除されるファイルを確認したのち削除した方が安全です。

また、Get-ChildItemでファイルを絞る際、シンボリックリンクやジャンクションを使用している場合は、リンク先も削除される可能性があるので慎重に削除しましょう。

フォルダの削除

従来の方法

Windows10のコマンドプロンプトの例

rmdir testdir
rmdir /Q /S lsfiltertest

CentOs7のシェルの例

rmdir testdir
rm -rf y

PowerShellの例

Remove-Item lsfiltertest
Remove-Item lsfiltertest -Recurse

ファイルの削除と同じようにRemove-Itemを使用してファルダを削除します。

削除するフォルダにファイルが存在する場合、下記の確認メッセージが表示されます。

image.png

この確認メッセージを出さないで削除するには-Recurseオプションを利用します。

ジャンクション、シンボリックリンクを含むフォルダが存在する場合は、リンク先を破壊する可能性があります。
これらを安全に削除する方法は下記を参考にしてください。

.NETでディレクトリを消すのがこんなに面倒なわけがない
https://needtec.sakura.ne.jp/wod07672/?p=9186

ファイルのコピー

従来の方法

Windows10のコマンドプロンプトの例

copy x.txt xxxx

CentOs7のシェルの例

cp x.txt xxxx

PowerShellの例

Copy-Item -LiteralPath ./x.txt -Destination xxxx
Copy-Item -LiteralPath a.txt,b.txt -Destination xxxx
Copy-Item -LiteralPath ./x.txt -Destination y.txt
Copy-Item -Path x*.txt -Destination xxxx
Copy-Item y*.txt xxxx

# 読み取り専用がコピー先の場合
Copy-Item b.txt readonly.txt -Force

Copy-Item y*.txt xxxx -WhatIf
Copy-Item y*.txt xxxx -Confirm

# カレントディレクトリのすべてのファイルとフォルダのうちtで始まるものをコピーする
Copy-Item * xxxx -Filter t* -WhatIf

# カレントディレクトリのすべてのファイルとフォルダのうちtで始まるもの以外をコピーする
Copy-Item * xxxx -Exclude t* -WhatIf

Copy-Itemを使用してファイルのコピーが可能です。

-LiteralPathまた-Pathオプションにコピー元を指定します。

-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。
-LiteralPathと-Pathオプションを省略した場合、第一引数をコピー元のパスとして取り扱います。この際、-Pathと同じ動作をします。

-LiteralPathと-Pathオプションにはコピー元のパスをコンマ区切りで複数していできます。

-Destinationにはコピー先のフォルダまたは、コピー先のファイル名を指定します。

コピー先のファイルが読み取り専用の場合はエラーとなります。
この場合、-Forceオプションを付与してコピーします。

-WhatIfオプションを使用することで事前に変更されるパスの確認も可能です。
image.png

-Confirmオプションで確認メッセージが表示されます。
image.png

ファイルの一覧表示のGet-ChildItemで出てきた-Filter,-Include-Excludeオプションを利用して削除するファイルを絞りこむこともできます。

フォルダのコピー

従来の方法

Windows10のコマンドプロンプトの例

xcopy /e /s /h ./testdir ./xxxx

以下のようになる

xxxx
  testdirの子要素1
    孫要素
  testdirの子要素2

CentOs7のシェルの例

cp ./testdir ./xxxx -R

以下のようになる

xxxx
  testdir
    testdirの子要素1
      孫要素
    testdirの子要素2

PowerShellの例

Copy-Item -Literal ./testdir -Destination xxxx -Recurse

ファイルのコピーと同様にCopy-Itemを使用してフォルダのコピーを行います。
コピー先に指定したフォルダの配下にコピー先のディレクトリが作成されます。

この挙動はCentOS7側のフォルダコピー処理と同じです。

ファイル/フォルダの移動

従来の方法

Windows10のコマンドプロンプトの例

# ファイルの移動
move a.txt b.txt
move b.txt folder

# フォルダの移動
move folder folder2

CentOs7のシェルの例

# ファイルの移動
mv a.txt b.txt
mv b.txt folder

# フォルダの移動
mv folder folder2

PowerShellの例

Move-Item -LiteralPath a.txt -Destination b.txt
Move-Item -LiteralPath b.txt -Destination ./folder
Move-Item -LiteralPath ./folder -Destination ./folder2

# ワイルドカードを指定する例
Move-Item -Path a*.txt -Destination ./folder2
Move-Item b*.txt ./folder2

ファイルとフォルダの移動はMove-Itemを用いて行います。

-LiteralPathまたは-Pathには移動元のパスを指定します。
-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。
-LiteralPathと-Pathオプションを省略した場合、第一引数を移動元のパスとして取り扱います。この際、-Pathと同じ動作をします。

-Destinationオプションには移動先のパスを指定します。-Destinationオプションを省略した場合は第二引数を移動先のパスとします。

-WhatIfオプションを使用することで事前に変更されるパスの確認も可能です。

-Confirmオプションで確認メッセージが表示されます。

ファイル/フォルダの改名

従来の方法

Windows10のコマンドプロンプトの例

ren x.txt xrenamed.txt

CentOs7のシェルの例

mv x.txt xrenamed.txt

PowerShellの例

Rename-Item -Path test.txt -NewName test2.txt
Rename-Item test2.txt test3.txt

# カレントディレクトリの全てのファイルの拡張子をtxt->logに変更
Get-ChildItem *.txt | Rename-Item -NewName { $_.name -Replace '\.txt$','.log' }

ファイルまたはフォルダの改名にはRename-Itemを使用します。

複数のファイルの改名を同時に行う場合はGet-ChildItemでファイルを取得してパイプを用いてRename-Itemに渡します。

-WhatIfオプションを使用することで事前に変更されるパスの確認も可能です。

-Confirmオプションで確認メッセージが表示されます。

ファイルの内容表示

従来の方法

Windows10のコマンドプロンプトの例

type x.txt

CentOs7のシェルの例

cat x.txt

PowerShellの例

Get-Content

Get-Content -Literal ../x.txt
Get-Content -Literal x.txt,y.txt
Get-Content -Path *.txt
Get-Content  *.txt

# 最初の10行取得
Get-Content lines.txt -TotalCount 10

# 最後の10行取得
Get-Content lines.txt -Tail 10

# エンコーディングを指定して表示
Get-Content utf8.txt -Encoding Utf8

# バイト配列の取得
Get-Content -Path C:\temp\test.txt -Encoding Byte -Raw

ファイルの内容表示を行うにはGet-Contentを使用します。

-LiteralPath,-Pathオプションには表示対象のファイルパスを指定します。
「,」区切りで複数指定した場合、複数のファイルの内容を表示します。

-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。

-LiteralPath,-Pathオプションを省略した場合は、指定したパスを-Pathオプションと同じ方法で開きます。

-TotalCountオプションを指定した場合、先頭から指定した行数のみ表示します。
-Tailオプションを指定した場合、末尾から指定した行数のみ表示します。

-TotalCount,-Tailオプションは対象のファイルの改行コードに影響せずに行数を取得してくれるようです。

-Encodingを指定して文字コードを指定したファイルの表示や、バイト配列の取得が可能です。
PowerShell6.0より前は-Encodingはコードページの指定ができず、EUC-JPなどは表示できません。また、UTF8もBOM付きしかサポートしていません。
省略した場合の挙動は「Default」となり、システムの規定のコードページになります。日本語OSの場合はCP932です。

PowerShell5.1+Win10(日本語版) でのEncodingの試験

各種文字コードのファイルがどのEncodingの指定で表示されるかを検証した結果が以下のようになります。

Default Unicode UTF8
EUC-JAのファイル × × ×
SJISのファイル × ×
UNICODEのファイル
UTF8(BOMなし)のファイル × ×
UTF8(BOMあり)のファイル

System.IO.Fileを利用する方法

PowerShell5.1以前のGet-Contentはいくつかのエンコーディングしかサポートしていません。
そのため、EUCなどのファイルを読み込む際は.NETのクラスを直接操作する必要があります。

    $enc = [System.Text.Encoding]::GetEncoding(20932)
    [System.IO.File]::ReadAllLines("c:\dev\ps\file\contenttest\euc.txt", $enc)

ファイルの更新

従来の方法

Windows10のコマンドプロンプトの例

echo xxxx>test.txt

CentOs7のシェルの例

echo xxxx>test.txt

PowerShellの例

Set-Contentを使用する例

Set-Content -LiteralPath .\a.txt -Value 'Hello, World'
Set-Content -LiteralPath a1.txt,a2.txt -Value 'Hello, World'
Set-Content -Path .\b*.txt -Value 'Hello, World'
Set-Content .\c*.txt -Value 'Hello, World'

'Hello, World' | Set-Content -Path .\b*.txt

# UTF8(BOMあり)として更新
Set-Content ./d.txt -Value 'わたしはカモメ' -Encoding Utf8

# バイナリファイルとして更新
Set-Content ./byte.txt -Encoding Byte -Value @([byte]0x30,[byte]0x31,[byte]0x32)

# 事前の確認
Set-Content -Path .\b*.txt -Value 'Hello, World' -WhatIf

Set-Contentを用いることでファイルに更新が行えます。

-Literal,-Pathオプションには更新対象のファイルパスを指定します。
「,」区切りで複数指定した場合、複数のファイルを更新します。

-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。

ワイルドカードを使用していない場合、指定のファイルが存在しなければ新規にファイルを作成します。

-Valueオプションに書き込む内容を設定します。パイプラインから値を受け付けることもできます。

-Encodingを指定して文字コードを指定したファイルの表示や、バイト配列の取得が可能です。
PowerShell6.0より前は-Encodingはコードページの指定ができず、EUC-JPなどは表示できません。また、UTF8もBOM付きしかサポートしていません。
省略した場合の挙動は「Default」となり、システムの規定のコードページになります。日本語OSの場合はCP932です。

読み取り専用ファイルを書きこんだ場合、規定ではエラーとなります。この場合は-Forceオプションを付与することで上書きが可能になります。

-WhatIfオプションを使用することで事前に更新されるファイルの確認も可能です。

-Filter ,-Include, -Exclude オプションで更新対象のファイルを制御できます。

使用中は Read Lock/Write Lock がかかるので長時間実行されているコマンドのログに使用するのは避けたほうがいいです。

Out-Fileを使用する例

Out-File -LiteralPath .\a.txt -InputObject 'The end of the world'
Out-File -FilePath .\b.txt -InputObject 'The end of the world'
Out-File  .\c.txt -InputObject 'The end of the world'

Get-Process | Out-File -FilePath .\Process.txt 

Out-File c.txt -InputObject '私はかもめ' -Encoding Utf8

Out-Fileを使用してファイルを更新可能です。

-LiteralPath,-FilePathオプションには更新対象のファイルパスを指定します。
-FilePathにはワイルドカードによる指定が可能ですが、複数更新対象がある場合以下のエラーとなります。
image.png
また、ドキュメントにも「Accept wildcard characters: False」とあるので指定できてもやめときましょう。

-InputObjectはファイルに書き込むオブジェクトを指定します。
パイプラインから入力を受け付けることも可能です。

-Encodingを指定して文字コードを指定したファイルの表示や、バイト配列の取得が可能です。
PowerShell6.0より前は-Encodingはコードページの指定ができず、EUC-JPなどは表示できません。また、UTF8もBOM付きしかサポートしていません。
省略した場合の挙動は「UNICODE」となります。

読み取り専用ファイルを書きこんだ場合、規定ではエラーとなります。この場合は-Forceオプションを付与することで上書きが可能になります。

-WhatIfオプションを使用することで事前に更新されるファイルの確認も可能です。

-Confirmオプションを使用することで書き込み前に確認メッセージが表示されます。

Set-ContentとOut-Fileの比較については下記を参考にしてください。

PowerShellの Out-File と Set-Content あるいは Out-File -Append と Add-Content の違い
https://tech.guitarrapc.com/entry/2014/02/11/061627

PowerShell Set-Content and Out-File - what is the difference?
https://stackoverflow.com/questions/10655788/powershell-set-content-and-out-file-what-is-the-difference

System.IO.Fileを利用する方法

PowerShell5.1以前のSet-Content/Out-FileのEncodingはいくつかのエンコーディングしかサポートしていません。
そのため、EUCやUTF8のBOMなしを書き込むさいは.NETのクラスを直接操作する必要があります。

    $enc = [System.Text.Encoding]::GetEncoding(20932)
    [System.IO.File]::WriteAllLines("c:\dev\ps\file\contenttest\euc_out.txt", "ねこ", $enc)

    $enc = New-Object System.Text.UTF8Encoding($False)
    [System.IO.File]::WriteAllLines("c:\dev\ps\file\contenttest\utf8_cout.txt", "いぬ", $enc)

ファイルの追記

従来の方法

Windows10のコマンドプロンプトの例

echo xxxx>>test.txt

CentOs7のシェルの例

echo xxx >>test.txt

PowerShellの例

Add-Contentを使用する例

Add-Content -LiteralPath .\a.txt -Value 'end of file'
Add-Content -LiteralPath a1.txt,a2.txt -Value 'end of file'
Add-Content -Path .\b*.txt -Value 'end of file'
Add-Content .\c*.txt -Value 'end of file'

# パイプラインの例。a1の内容をa2に追記
Get-Content a1.txt | Add-Content a2.txt

# UTF8(BOMあり)に追記
Add-Content ./d.txt -Value 'あのこは石ころ' -Encoding Utf8

# バイナリファイルに追記
Add-Content ./byte.txt -Encoding Byte -Value @([byte]0x30,[byte]0x31,[byte]0x32)

Add-Contentを用いることでファイルに更新が行えます。

-LiteralPath,-Pathオプションには更新対象のファイルパスを指定します。
「,」区切りで複数指定した場合、複数のファイルを更新します。

-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。

ワイルドカードを使用していない場合、指定のファイルが存在しなければ新規にファイルを作成します。

-Valueオプションに書き込む内容を設定します。パイプラインから値を受け付けることもできます。

-Encodingを指定して文字コードを指定したファイルの表示や、バイト配列の取得が可能です。
PowerShell6.0より前は-Encodingはコードページの指定ができず、EUC-JPなどは表示できません。また、UTF8もBOM付きしかサポートしていません。
省略した場合の挙動は「Default」となり、システムの規定のコードページになります。日本語OSの場合はCP932です。

読み取り専用ファイルを書きこんだ場合、規定ではエラーとなります。この場合は-Forceオプションを付与することで上書きが可能になります。

-WhatIfオプションを使用することで事前に更新されるファイルの確認も可能です。

-Filter ,-Include, -Exclude オプションで更新対象のファイルを制御できます。

使用中は Read Lock/Write Lock がかかるので長時間実行されているコマンドのログに使用するのは避けたほうがいいです。

Out-Fileに-Appendオプションを付与して使用する例

Out-File -LiteralPath .\abc.txt -InputObject 'The end of the world' -Append

Out-Fileに-Appendオプションを付与して追記を行います。

それ以外はファイルの更新時にOut-Fileを使用する例と同じです。

System.IO.Fileを利用する方法

PowerShell5.1以前のSet-Content/Out-FileのEncodingはいくつかのエンコーディングしかサポートしていません。
そのため、EUCやUTF8のBOMなしを追記する際は.NETのクラスを直接操作する必要があります。

    $enc = [System.Text.Encoding]::GetEncoding(20932)
    [System.IO.File]::AppendAllText("c:\dev\ps\file\contenttest\euc_out.txt", "わっふる", $enc)

    $enc = New-Object System.Text.UTF8Encoding($False)
    [System.IO.File]::AppendAllText("c:\dev\ps\file\contenttest\utf8_cout.txt", "わっしょい", $enc)

ファイルの追記の監視

従来の方法

CentOs7のシェルの例

tail -f log.txt

PowerShellの例

Get-Content -Literal log.txt -Wait

Get-ContentのWaitオプションで類似のことが行えます。

ただし、UNIXのtailコマンドは複数のファイルを受け付けてファイルの更新を監視しますが、Get-Contentの場合、単一のファイルの監視しかできないようです。

ファイルを空にする

従来の方法

Windows10のコマンドプロンプトの例

copy nul test.txt

CentOs7のシェルの例

cp /dev/null test.txt

PowerShellの例

Clear-Content -LiteralPath a.txt
Clear-Content -LiteralPath a1.txt,a2.txt
Clear-Content -Path b*.txt
Clear-Content c*.txt -Force

Get-ChildItem * -File | Clear-Content

ファイルを削除しないが、内容を空にするにはClear-Contentを使用します。

-LiteralPath,-Pathオプションには表示対象のファイルパスを指定します。
「,」区切りで複数指定した場合、複数のファイルの内容を表示します。

-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。

操作対象のパスはパイプラインの入力から指定も可能です。

読み取り専用ファイルを対象とした場合、規定ではエラーとなります。この場合は-Forceオプションを付与することで上書きが可能になります。

-WhatIfオプションを使用することで事前に更新されるファイルの確認も可能です。

-Confirmオプションを使用することで書き込み前に確認メッセージが表示されます。

-Filter,-Exclude,-Include オプションで対象のフィルタリングが行えます。

ファイルの属性変更

従来の方法

Windows10のコマンドプロンプトの例

attrib y.txt +R 

CentOs7のシェルの例

chmod 444 y.txt

PowerShellの例

# 読み取り専用にする
Set-ItemProperty  -LiteralPath ./y.txt -Name IsReadOnly -Value $True

# 読み取り専用を解除
Set-ItemProperty -LiteralPath ./y.txt -Name IsReadOnly -Value $False

# 読み取り専用+システムファイル+隠しファイルとする
Set-ItemProperty -LiteralPath ./y.txt -Name Attributes -Value 'Hidden,System,ReadOnly'

# アーカイブ属性を付与
Set-ItemProperty -LiteralPath ./y.txt -Name Attributes -Value 'Archive'

# 全ての属性を外す
Set-ItemProperty -LiteralPath ./y.txt -Name Attributes -Value 'Normal'

# ファイルの作成日,更新日,最終アクセス日を変更する
Set-ItemProperty -LiteralPath ./y.txt -Name CreationTime -Value  (Get-Date -Format "yyyy/MM/dd HH:mm
:ss") -Force
Set-ItemProperty -LiteralPath ./y.txt -Name LastWriteTime -Value  (Get-Date -Format "yyyy/MM/dd HH:mm
:ss") -Force
Set-ItemProperty -LiteralPath ./y.txt -Name LastAccessTime -Value  (Get-Date -Format "yyyy/MM/dd HH:mm
:ss") -Force

# 複数ファイルを対象に操作
Set-ItemProperty  -LiteralPath x.txt,y.txt -Name IsReadOnly -Value $True
Set-ItemProperty  -Path *.txt -Name IsReadOnly -Value $True -WhatIf
Set-ItemProperty  -Path *.txt -Name IsReadOnly -Value $True -Confirm

Set-ItemPropertyを使用してファイルの属性と更新日、作成日、最終更新日を変更可能です。

-LiteralPath,-Pathオプションには表示対象のファイルパスを指定します。
「,」区切りで複数指定した場合、複数のファイルの内容を表示します。

-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。

操作対象のパスはパイプラインの入力から指定も可能です。

-Nameオプションに与える文字はFileSystemInfo Classのプロパティとなります。
今回使用するものは以下になります。

  • Attributes
    • Archive、Hidden、 Normal、ReadOnly、または System を設定できます。複数設定数場合はカンマ区切りとします。
  • CreationTime
  • LastWriteTime
  • LastAccessTime

-WhatIfオプションを使用することで事前に更新されるファイルの確認も可能です。

-Confirmオプションを使用することで書き込み前に確認メッセージが表示されます。

-Filter,-Exclude,-Include オプションで対象のフィルタリングが行えます。

ファイルの所有者変更

従来の方法

Windows10のコマンドプロンプトの例

icacls b.txt /setowner NOTE-MAIN\mima

CentOs7のシェルの例

chown test x.txt

PowerShellの例

Hey, Scripting Guy! Windows PowerShell を使用してファイルの所有者を特定する方法はありますかではファイルの所有者を変更するスクリプトを紹介しています。

# 操作対象のファイルのACLを取得して所有者を変更する
$acl = Get-Acl aaa.txt
$user = New-Object System.Security.Principal.NTAccount("NOTE-MAIN", "mima")
$acl.SetOwner($user)
Set-Acl -AclObject $acl -Path aaa.txt

まず所有書を変更したいファイルのアクセス制御リスト(ACL)をGet-Aclで取得します。

Get-Aclで取得できるオブジェクトはSystem.Security.AccessControl.FileSecurityになり、SetOwnerメソッドで所有者を変更します。

SetOwnerメソッドにはNTAccountを与える必要があるのでドメイン名とアカウント名を引数にオブジェクトを作成します。

その後、Set-Aclを使用して所有者を変更したACLオブジェクトを指定のパスに設定します。

Set-AclのLiteralPath、Pathオプションはコンマ区切りで複数のファイルを指定することが可能です。
また、-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。

ファイル中の文字検索

従来の方法

Windows10のコマンドプロンプトの例

findstr  /S a ./*

CentOs7のシェルの例

grep -r a ./*

PowerShellの例

# カレントディレクトリのtxtファイル中にGetが含まれるものを列挙する
Select-String -Path .\*.txt -Pattern 'Get'

# カレントディレクトリ以下すべてののtxtファイル中にGetが含まれるものを列挙する
Get-ChildItem -LiteralPath . -Filter *.txt -Recurse | Select-String -Pattern 'Get'

# unicode/utf8(BOMなし)/utf(BOMあり)のファイルが検索可能
Select-String -Path .\*.txt -Pattern 'あ'
Select-String -Path .\*.txt -Pattern 'あ' -Encoding UTF8

# unicode/utf(BOMあり)のファイルが検索可能
Select-String -Path .\*.txt -Pattern 'あ' -Encoding Unicode

# cp932/unicode/utf(BOMあり)のファイルが検索可能
Select-String -Path .\*.txt -Pattern 'あ' -Encoding Default

# カレントディレクトリのtxtファイルでファイル名に1を含まないものを対象にGetが含まれるものを列挙する
Select-String -Path .\*.txt -Pattern 'Get' -Exclude *1*

# -NotMatchオプションでPatternに一致しない行を列挙する
Select-String -Path *.txt -Pattern "a" -NotMatch

Select-Stringはファイル中の文字列を検索します。

-LiteralPath,-Pathオプションには表示対象のファイルパスを指定します。
「,」区切りで複数指定した場合、複数のファイルの内容を表示します。

-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。

操作対象のパスはパイプラインの入力から指定も可能です。

-Encodingを指定して文字コードを選択することが可能です。選択したエンコードと実際検索できる内容は[Get-Content時の実験と同じ](#PowerShell5.1+Win10(日本語版) でのEncodingの試験)になります。
PowerShell6.0より前は-Encodingはコードページの指定ができず、EUC-JPなどは表示できません。また、UTF8もBOM付きしかサポートしていません。
省略した場合の挙動はPowerShell5.1では「Utf8」となります。これはオンラインヘルプではDefaultと書いてあるので注意してください。
※実際の挙動とGet-Helpでは「UTF8」です。

-Include,-Excludeオプションでファイルのフィルタリングが行えます

-NotMatchオプションを指定した場合、パターンに一致しない行を列挙します。

出力結果

Select-Stringの出力は規定ではMicrosoft.PowerShell.Commands.MatchInfoのオブジェクトのセットになります。

# 一致した文字のあるファイル名、行番号、行の内容、一致した文字を表示する
Select-String -Path *.txt -Pattern "fa","あ" | % { $_.Filename + " " + $_.LineNumber + " " + $_.Line + " " + $_.Matches }
# euc.txt 2 asdfafaf fa
# sjis.txt 2 asdfafaf fa
# unicode.txt 2 asdfafaf fa
# unicode.txt 3 asfああああ あ

# -Listオプションを付与した場合は、各ファイル1つ見つかったら次行の検査を辞める
Select-String -Path *.txt -Pattern "fa","あ" -List | % { $_.Filename + " " + $_.LineNumber + " " + $_.Line + " " + $_.Matches }
# euc.txt 2 asdfafaf fa
# sjis.txt 2 asdfafaf fa
# unicode.txt 2 asdfafaf fa

Quietパラメーターを使用する場合はSystem.Booleanとなり,見つかったか否かだけを返します。

Select-String -Path *.txt -Pattern "a" -Quiet
# True
>Select-String -Path *.txt -Pattern "ない文字" -Quiet
# False

AllMatchオプション

出力結果に一致したパターンを全て含めるかどうかを制御するオプションです。

諸君 私は戦争が好きだ 諸君 私は戦争が好きだ
諸君 私は戦争が大好きだ 殲滅戦が好きだ
電撃戦が好きだ 打撃戦が好きだ 防衛戦が好きだ 包囲戦が好きだ
突破戦が好きだ 退却戦が好きだ 掃討戦が好きだ 撤退戦が好きだ
# AllMatchオプションを含まない例:
Select-String -LiteralPath sub1/test2.txt -Pattern "諸君","好き" -Encoding Default | % { $_.Filename + " " + $_.LineNumber + " " + $_.Line + " [" + $_.Matches + "]" }
# 出力結果:行あたり一致するパターンを見つけたらその行については検査をやめている。
# test2.txt 1 諸君 私は戦争が好きだ 諸君 私は戦争が好きだ [諸君]
# test2.txt 2 諸君 私は戦争が大好きだ 殲滅戦が好きだ [諸君]
# test2.txt 3 電撃戦が好きだ 打撃戦が好きだ 防衛戦が好きだ 包囲戦が好きだ [好き]
# test2.txt 4 突破戦が好きだ 退却戦が好きだ 掃討戦が好きだ 撤退戦が好きだ [好き]

Select-String -LiteralPath sub1/test2.txt -Pattern "諸君","好き" -Encoding Default -AllMatch | % { $_.Filename + " " + $_.LineNumber + " " + $_.Line + " [" + $_.Matches + "]" }
# 出力結果:行あたり一致するパターンをすべて見つけている。
# test2.txt 1 諸君 私は戦争が好きだ 諸君 私は戦争が好きだ [諸君 諸君]
# test2.txt 2 諸君 私は戦争が大好きだ 殲滅戦が好きだ [諸君]
# test2.txt 3 電撃戦が好きだ 打撃戦が好きだ 防衛戦が好きだ 包囲戦が好きだ [好き 好き 好き 好き]
# test2.txt 4 突破戦が好きだ 退却戦が好きだ 掃討戦が好きだ 撤退戦が好きだ [好き 好き 好き 好き]

Contextオプション

一致したパターンの前後を表示します。
以下の形式で指定して、1番目の数値は一致前の行数、2番目の数値は一致後の行数を表示します。

-Context 2,3

Get-Command | Out-File -FilePath .\Command.txt
Select-String -Path .\Command.txt -Pattern 'Get-Computer' -Context 2, 3

  Command.txt:2680:Cmdlet          Get-CmsMessage                                     3.0.0.0    Microsoft.PowerShell.Security

  Command.txt:2681:Cmdlet          Get-Command                                        3.0.0.0    Microsoft.PowerShell.Core

> Command.txt:2682:Cmdlet          Get-ComputerInfo                                   3.1.0.0    Microsoft.PowerShell.Management

> Command.txt:2683:Cmdlet          Get-ComputerRestorePoint                           3.1.0.0    Microsoft.PowerShell.Management

  Command.txt:2684:Cmdlet          Get-Content                                        3.1.0.0    Microsoft.PowerShell.Management

  Command.txt:2685:Cmdlet          Get-ControlPanelItem                               3.1.0.0    Microsoft.PowerShell.Management

  Command.txt:2686:Cmdlet          Get-Counter                                        3.0.0.0    Microsoft.PowerShell.Diagnostics

Patternオプション

各行で検索するテキストを設定します。
-SimpleMatchオプションを同時に使用した場合は単純な文字列で検索します。
-SimpleMatchオプションを指定しない場合、正規表現となります。

正規表現については以下を参照してください。

また、規定では大文字と小文字は区別されません。
区別を行うには、-CaseSensitiveオプションを付与してください。

実験対象のファイル

aaabb*ccddeeff
bcd
AAABB*CCDDEEFF
BCD
# 正規表現で検索する例
Select-String -LiteralPath test.txt -Pattern "B*C"
# test.txt:1:aaabb*ccddeeff
# test.txt:2:bcd
# test.txt:3:AAABB*CCDDEEFF
# test.txt:4:BCD

# 文字列で検索する例
Select-String -LiteralPath test.txt -Pattern "B*C" -SimpleMatch
# test.txt:1:aaabb*ccddeeff
# test.txt:3:AAABB*CCDDEEFF

# 大文字小文字区別をする例
Select-String -LiteralPath test.txt -Pattern "B*C" -CaseSensitive -SimpleMatch
# test.txt:3:AAABB*CCDDEEFF

フォルダのサイズ取得

PowerShellの例

指定のフォルダ以下のファイルのサイズの合計は以下のようにして取得します。

(Get-ChildItem -LiteralPath . -Recurse -Force | Measure-Object -Sum Length).Sum

ファイルの一覧表示で使用したGet-ChildItemで再帰的にファイルを列挙してMeasure-Objectに渡します。

なお、ジャンクションやシンボリックリンク等を考慮していないので実際のサイズと異なります。

ファイルの存在チェック

PowerShellの例

ni test.txt -Force
Test-Path -LiteralPath test.txt
# True

Test-Path -LiteralPath 存在しない.txt
# False

Test-Path -LiteralPath test.txt,存在しない.txt
# True
# False

# ワイルドカードを使用して特定のフォルダでtxt以外があるか調べる
Test-Path -Path c:\dev\ps\file\grep\* -Exclude *.txt

# 特定の日より新しい
 Test-Path -Path t*.txt -NewerThan "2019/09/07 00:00:00" -OlderThan "2019/09/07 10:00:00"

指定したパスが存在するかをTrueまたはFalseで返します。
パスを複数指定した場合、それぞれに対してTrue,Falseを返します。

-Literal,-Pathオプションには対象のファイルパスを指定します。
「,」区切りで複数指定した場合、複数のファイルの内容を検査します。

-Pathオプションを指定した場合、ワイルドカードによる指定が可能になります。

-Filter,-Include,-Excludeを使用してさらに絞り込むこともできます。

よくわからない話

-IsValid

IsValidオプションは実際にファイルパスの有無をチェックせず構文が正しいかチェックするオプションですが、この動きがよくわからん状態になっています。

test-path -isValidの判定が全然isValidじゃない件
https://plaza.rakuten.co.jp/satocchia/diary/201807100000/

PowerShell の Test-Path -IsValid が謎くて使えない
https://tech.guitarrapc.com/entry/2013/09/18/081112

-NewerThanと-OlderThanオプション

-NewerThanと-OlderThanオプションで日付を指定して存在チェックができますが、ワイルドカードを利用した際の挙動が怪しいです。

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

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d-----       2019/09/07     22:56                sub1
-a----       2019/09/07     23:47        1613066 Command.txt
-a----       2019/08/31     12:01             24 euc.txt
-a----       2019/09/07     11:50             14 euc_out.txt
-a----       2019/08/31      9:56             26 sjis.txt
-a----       2019/09/08      0:07             37 test.txt
-a----       2019/09/07     22:26              8 test1.txt
-a----       2019/09/07     22:49             10 test2.log
-a----       2019/09/07     22:49             10 test2.txt
-a----       2019/08/31     12:01             42 unicode.txt
-a----       2019/08/31     12:00             28 utf8.txt
-a----       2019/08/31     12:01             31 utf8BOM.txt
-a----       2019/09/07     11:50             23 utf8_cout.txt

このフォルダに対して実験をすると以下のようになります。

(Get-Item -LiteralPath test.txt).LastWriteTime
# 2019年9月8日 0:07:02

(Get-Item -LiteralPath test.txt).CreationTime
# 2019年9月8日 0:00:57

Test-Path -Path test.txt -NewerThan "2019/09/08 00:07:00"
# Trueとなる

Test-Path -Path t*.txt -NewerThan "2019/09/08 00:07:00"
# Falseとなる

Test-Path -Path t*.txt -NewerThan "2019/09/08 00:00:58"
# Falseとなる

Test-Path -Path t*.txt -NewerThan "2019/09/08 00:00:57"
# Trueとなる

つまりワイルドカードを指定しない場合は更新日をみて、ワイルドカードを指定した場合は作成日をみている動きをしています。

ヘルプを見る限りこの挙動が意図したものかどうかはわかりませんでした。

パスの結合

PowerShellの例

# 以下のケースはいずれも「c:\dev[1]\work」を作成する
Join-Path c:\dev[1] work
Join-Path -Path c:\dev[1] -ChildPath work
C:\dev\ps\file\grep> Join-Path c:\dev[1]\ \work

# 複数同時に作成も可能
Join-Path c:\dev[1],c:\dev[2] work
# c:\dev[1]\work
# c:\dev[2]\work

# -Resolveで実際あるファイルのみ作成する。この場合、-Pathと-ChildPathはワイルドカードを受け付ける
Join-Path c:\win* system* -Resolve
# C:\Windows\System
# C:\Windows\System32
# C:\Windows\SystemApps
# C:\Windows\SystemResources
# C:\Windows\system.ini

Join-Pathでパスを連結して新しいパスの文字を作成できます。

これによりパスの区切り文字が前後にあるか気にせずパスの作成ができます。

また-Resolveオプションで結合パスの解決を試みます。この際、-Pathと-ChildPathオプションにワイルドカードを指定すると結果が複数になります。

相対パスから絶対パスの変換

PowerShellの例

# 相対パスから絶対パスの変換
Resolve-Path -LiteralPath ../../update
Resolve-Path -Path ../../update
Resolve-Path ../../update
# C:\dev\ps\update

# 絶対パスから相対パスの変換
Resolve-Path -LiteralPath C:\dev\ps\update -Relative
Resolve-Path -Path C:\dev\ps\update -Relative
Resolve-Path C:\dev\ps\update -Relative
# ..\..\update

Resolve-Pathを利用して相対パスから絶対パスの変換ができます。

まとめのまとめ

まとめを作成中に気づいたことや注意した方がいいことを記載しておきます。

  • オンラインヘルプは古いか間違っている可能性があるので挙動が怪しかったらGet-Helpで確認する
    • 例:PowerShell5.1のSelect-Stringの-Encodingのデフォルト値について
  • ワイルドカードを使用する更新処理は想定外のファイルを変更する可能性があるので-WhatIfオプションを使用して事前に確認した方がいい。
  • ワイルドカードを使用する必要がない場合はなるべくLiteralPathを利用する
    • ファイルシステムで使用できる文字がワイルドカードの文字に含まれているのでエスケープ処理が厄介になる
  • シンボリックリンクやジャンクションを含むフォルダの扱いについてはリンク先を変更していいかどうかを考えながら慎重に取り扱う。
  • Encodingをサポートしているコマンドレットは可能な限り明示する。
    • PowerShell5.1以前は省略した場合の動きがコマンドレット毎に異なる
  • PowerShell5.1以前はUTF8はBOMなしであるということに留意する。
    • 特に更新系は下手にクロスプラットフォームで使うファイルを更新するとBOMがついて使えなくなる。.NETのファイル操作の関数を呼び出して更新すること
  • Aliasは環境ごとに違う。Windowsの場合lsはGet-ItemChildの別名であるが、CentOSでは違う
    • 「Get-Alias -Definition Get-ChildItem」というようなコマンドで調査できる。

PowerShellでBEEP音を鳴らす

前書き

PowerShellでBEEP音を鳴らすことができれば、自動処理中の結果を音で知らせることができます。
今回はその方法を調べました。

こんな感じになります。

# Beep音の鳴らし方
WinAPIの[Beep](https://docs.microsoft.com/en-us/windows/win32/api/utilapiset/nf-utilapiset-beep)をC#経由で呼び出します。

```powershell
$source = @"
using System;
using System.Runtime.InteropServices;

public static class WinApi
{
[DllImport("kernel32.dll")]
public static extern bool Beep(int freq,int duration);
}
"@
Add-Type -TypeDefinition $source
```

## 実行例

```powershell
[WinApi]::Beep(440, 2000)
```

第一引数に周波数、第二引数に継続するミリ秒を入力します。

## 追記
ConsoleオブジェクトがBeepサポートしていました(震え)

```powershell
[Console]::Beep(440,2000)
```

# 音楽を鳴らしてみる。
古のテクニックでBEEP音で音楽を鳴らすという技があります。
下記のページに周波数と音階の対応表があるので利用します。

**メロディのプログラム**
http://web.archive.org/web/20190116021421/http://www.geocities.jp/shuinoue/myurobo/prog1.html

```powershell
# http://web.archive.org/web/20190116021421/http://www.geocities.jp/shuinoue/myurobo/prog1.html
function global:Snd([string]$key, [int]$duration=100) {
$KeySignature = @{
"Fa-1" = 294;
"FaS-1" = 311;
"So-1" = 330;
"SoS-1" = 349;
"La-1" = 370;
"LaS-1" = 392;
"Si-1" = 415;
"Do" = 440;
"DoS" = 466;
"Re" = 494;
"ReS" = 523;
"Mi" = 554;
"Fa" = 587;
"FaS" = 622;
"So" = 659;
"SoS" = 699;
"La" = 740;
"LaS" = 784;
"Si" = 831;
"Do+1" = 880;
"DoS+1" = 932;
"Re+1" = 988;
"ReS+1" = 1047;
"Mi+1" = 1109;
"Fa+1" = 1175;
"FaS+1" = 1245;
"So+1" = 1319;
"SoS+1" = 1397;
"La+1" = 1480;
"LaS+1" = 1568;
"Si+1" = 1661;
"Do+2" = 1760;
"DoS+2" = 1865;
"Re+2" = 1976;
"ReS+2" = 2093;
"Mi+2" = 2218;
"Fa+2" = 2349;
"FaS+2" = 2489;
"So+2" = 2637;
"SoS+2" = 2794;
"La+2" = 2960;
"LaS+2" = 3136;
"Si+2" = 3322;
}
#
$freq = $KeySignature[$key]
$ret = [WinApi]::Beep($freq, $duration)
}
```

以下のように鳴らします。

```powershell

Snd So-1 300;Snd Do 300;Snd Mi 300;Snd So 300;Snd Do+1 300;Snd Mi+1 300;Snd So+1 600;Snd Mi+1 600;
Snd SoS-1 300;Snd Do 300;Snd ReS 300;Snd SoS 300;Snd Do+1 300;Snd ReS+1 300;Snd SoS+1 600;Snd ReS+1 600;
Snd LaS-1 300;Snd Re 300;Snd Fa 300;Snd LaS 300;Snd Re+1 300;Snd Fa+1 300;Snd LaS+1 900;
Snd Si+1 300;Snd Si+1 300;Snd Si+1 300;Snd Do+2 1200;
```

# まとめ
今回はBEEP音をならしてみました。
簡単な警告音ならともかく、令和の時代なので音楽は別の方法で鳴らした方がいいと思った(こなみかん)。

古典を読む~プログラミングの心理学またはハイテクノロジーの人間学

「プログラミングの心理学 または、ハイテクノロジーの人間学」の概要

本日、読み進めていきたい本は「プログラミングの心理学 または、ハイテクノロジーの人間学 25周年記念版」です。原題は「The Psychology of Computer Programming. Silver Anniversary Edition (1998)」になっています。

この書籍はプログラミングを人の活動とし、その人間に焦点を当てた初期の本のうちの一つです。

著者は前書きにおいて以下のように語っています。

現在働いているプログラマは何十万人にものぼっている。著者らの経験に少しでも意味があるとするならば、プログラマ自身とその管理者が、彼らを一台の機械としてでなく一人の人間としてみるようになるなら、彼ら一人一人がより効率よく働き、それにより深い満足を覚えるようになる可能性があるのだ

この人間に焦点をあてる思想はXP、アジャイル、スクラムとった現代の開発手法につながりますし、それらの開発手法をとっていない組織であっても、優秀といわれる管理者にとっては意識的、無意識的…いずれにしても心得ている常識になっていると思います。

それらの「人間に焦点をあてる思想」に興味を持っている方にとっては本書は、おそらく自身の活動に有用なヒントを与えてくれると思っています。

なお、日本では現在、入手できるのは以下の訳書が入手できます。
https://www.amazon.co.jp/dp/4839915946
https://www.amazon.co.jp/dp/B00F0FQ8C4

著者 ジェラルド・M.ワインバーグについて

image.pngamazonの画像より)
ジェラルド・M.ワインバーグ(Gerald Marvin Weinberg ('Jerry'),1933年10月27日-2018年8月7日)はコンサルタント、プログラマー、技術リーダー、そして管理者の役割について数々の記事や書籍を出したアメリカのコンピュータ科学者であり、コンピュータソフトウェア開発の心理学と人類学の著者であり教師です。

以下のようなエピソードがあります。

  • 学生のころクローン病にかかり、治療のために処方されたモルヒネで薬物中毒になった。
  • 1956年からIBMで働いている
  • 米国の最初の人類宇宙飛行計画の労働者の一人でオペレーティングシステムの設計と実装をおこなっていました。参考
  • 数々の書籍を出版。日本でも、大きな本屋にいけば、彼の書籍は数冊みつかります。たぶん「ライトついてますか」か「ソフトウェア文化を創る」シリーズのなんか。
  • 幼少のころより、ピンボールを鍛え続け、市のチャンピオンの栄光を勝ち取り、近所の子供らに尊敬されていた。
    • なお、その栄光は12歳のチビに奪われた模様。
    • ビデオゲームは苦手の模様。

その他、著者について深く知りたい場合は、Wikipediaやワインバーグ自身のホームページに記載があるので参照してください。
https://en.wikipedia.org/wiki/Gerald_Weinberg
http://www.geraldmweinberg.com/Site/About_Jerry.html

読み進める上での留意事項

日本の書籍においては、すくなくとも4つの時間軸の人間がかかわっていることを留意して読み進める必要があります。

  • 初版(1971年)
  • 初版の訳(1994年)
  • 25周年版(1998年)
  • 25周年版の訳(2005年)

つまり令和の時代の目線だけで読み進めると、いくらか読み誤ることがあります。
たとえば、プログラミング言語にしても当時はC言語(1972~)の誕生より前に執筆されており、本書で触れられるプログラミング言語はFORTRAN(1956~)であり、COBOL(1959~)であり、PL/I(1964~)です。
Fortran、COBOLあたりは現在でも、プログラミングの環境を作成することは可能かもしれませんが、PL/Iに至っては努力はしましたが無理でした。
※いくつかPL/IをWindowslinuxで動かす方法がありそうでしたが、あきらめました。

そして、現在と違ってパーソナルコンピュータが出たか出ないかの時代なので、プログラミングの実行にある種のコストがかかり、試行錯誤が難しい時代であったことは留意しておく必要があります。

また、技術的な側面だけでなく社会的な側面でも当時の状況を考慮して読まないと読み誤る箇所があります。たとえば1970年ころは女性解放運動の最中で、現代では問題になるような表現があります。「管理職が女性だった時」の話や、技術者の配偶者を「妻」という表現をしているのは、おそらく現代では異なる書き方になっているでしょう。

プログラミングの心理学またはハイテクノロジーの人間学

これより本書の2019年時点の私の解釈を述べていきますが、主観が多分にふくまれており、おそらく、別の人間が読んだ場合と異なります。

第1部 人の活動としてのプログラミング

第一部ではコンピュータプログラミングが人間の活動であることとし、その活動を人間の立場から考えるための機会をあたえるものになっています。

第1章 プログラミングを読む

プロジェクトの途中で参画する場合、我々はプログラミングを読むことがあります。
また、レビューの際にも人のプログラミングを読むでしょう。

この章では、そういった時のためのヒントがかかれています。

たとえば、不可解なコードにあった場合、今時点の自分の視点だけ安易な評価や判断をする場合があります。
これについて「コンピュータ側の制約」、「言語側からの制約」、「プログラマ側からの制約」、「歴史的痕跡」の視点について考慮する必要があることを述べています。

これらの具体例としては以下のようなものになります。

コンピュータ側の制約の例としては大量のメモリを確保できないので、あえて冗長な書き方をしている場合などです。

言語側の制約としてはプログラミング言語で、できなかったことです。たとえば今のJavaScriptではletやconstで宣言すべき箇所が、varで宣言されている場合などがコレに当たります。

プログラマ側からの制約っていうのは、VBAでいうとCollectionなどの存在を知らないため、なんでも配列で処理しようとしているなどの「言語や機能を知らない」ところから発生している問題です。

歴史的痕跡の例としては、長いプロジェクト期間で昔あった機能がなくなったとか、「暫定対応」とか「臨時対応」が必要だったとかがあります。

いずれにせよ、「なんでこのプログラムはこう書いてあるか」という自問が肝要です。

昨今でも、レビューやペアプログラミングで言われているように他人のプログラムを読む重要性が増しています。そして、構成管理や改訂履歴やレビューツールの発展などで、プログラミングを読みとく行為はマシな状況になっているといえるでしょう。
しかし、半世紀前にワインバーグ氏が示したプログラムを読むコツについての提言は、それらのツールよりも重要なものになると思います。

第2章 よいプログラムとは

プログラムのよしあしを語るのは簡単なことではありません。プログラミングは人の複雑な活動だからです。
たとえば、一見エレガントといわれるプログラムであっても、要求仕様を満たしていない場合、それに価値があるのか、例えば必要なスケジュールに間に合わなかったものに価値があるのか、将来の変更に適応できない場合はどうなのか、効率が悪い時はどうなのか、これらは簡単に答えられるものではないです。
すくなくとも本書において、「よいプログラム」とか「よいプログラマ」といった言葉を合意が得られる、得られる可能性がある、それが望ましいという顔で使うのは慎んでいます。

重要なのは、この章の巻末問題で、管理者とプログラマに、先にあげた要求仕様やスケジュール、適応力、効率といった要素をどう考えるか問うているところにあります。

それぞれが、それぞれの立場で自分なりの回答を考えることが重要であり、おそらく、このことは50年近く前から変わっておらず、50年後も変わっていないでしょう。

第3章 プログラミングの研究方法

50年前は人の活動としてのプログラミングの研究は少なく、どのようにすすめていったらいいかという考えが記述されています。
おそらく、現在は当時とことなり、ある種のスマートな答えが用意されているかもしれませんが、ここでのワインバーグ氏の思考は、計測できない複雑なものに対応するための思考方法のヒントとなりえると思っています。

初版から25年後の補足として興味深いのはホーソン効果(観察されているという事実が、しばしば人々の成績向上に向けて動機付けする)という話のなかで出てくる下記の発見です。

こういった経験を通じて、私は管理の心理学に関する一つの基本原理を発見した。「人々に注意を向ける管理者はよい結果を得る」というものだった。

この人々に注意を向けるという考えはトム・デマルコのデッドラインの正しい管理の四つの本質にも通じるものがあると思います。

正しい管理の四つの本質
   ・適切な人材を雇用する
   ・その人材を適所にあてはめる
   ・人びとの士気を保つ
   ・チームの結束を強め、維持する
  (それ以外のことは全部管理ごっこ)

第2部 社会活動としてのプログラミング

この部ではプログラマの集団を3種類にわけて語られています。

  • プログラミンググループ
    • 同じ場所で、おそらくは同じシステムを使っているが、別々のプログラミングを作っている集団
  • プログラミングチーム
    • 一つのプログラムを共同で開発しようとしているプログラマ集団である。
  • プログラミングプロジェクト
    • プログラマの集団と支援部隊を合わせたグループであり、それはしばしば、システム支援、標準化、文章化などのための特別なチームを持っていることもある。

この辺りの分類は歴史的な側面があります。現在はコンピュータを個々人が持つことになっており、計算室というものがあって1つのコンピュータを共有して使用するという姿は見ないものになりました。また、技術的進化でリモートワークなどが可能になり、当時の環境とは大きく異なります。

50年前ではプログラミンググループを「 同じ場所で、おそらくは同じシステムを使っているが、別々のプログラミングを作っている集団」と定義していますが、「同じ場所」という制約はインターネットの発達により解除されたものと考えられます。50年たった今で、ワインバーグ氏が、この部をどう構成しなおすかというのは興味があるところでありますが、それは50年後の読者の宿題となるでしょう。

第4章 プログラミンググループ

プログラミンググループは、プログラミングチームやプログラミングプロジェクトといったものと性質がことなります。プログラミングチームやプログラミングプロジェクトはある種の共通の目的のために公式に作られるものですが、プログラミンググループはある種、非公式の結びつきになります。

非公式な組織

本書で非公式な例で出てくるのは大学の計算機センターの共用スペースの自販機のまわりでぺちゃくちゃやっている連中の例をあげています。外からははた迷惑なおしゃべりにしか見えていなかった集団でしたが、実際のところ、プログラミングの相談をコーヒーを飲みながらおこなっており、学生は問題を解決しあっていたのです。

本書ではいくつかの非公式的組織の事例をあげ次のようにまとめています。

これらの物語の要点はこうだ。非公式的機構は必ず存在する。それを理解せず物事を変更するのは危険だ。でないと、円滑に働いていて、同程度の費用では置き換えが効かないようなシステムを混乱させてしまう恐れがある。

エゴレス方式(エゴレスプログラミング)

プログラマは作ったプログラムに多かれすくなかれ執着します。少なくとも私は、ある程度の執着は持ちます。
本書では、この執着が、が強くなると自身のプログラミングが間違っていることを受け入れなくなっていくということを事例を交え解説しています。

これらのエゴの問題に過去の偉人はどう対応したのでしょうか。
本書ではジョン・フォン・ノイマンの事例が紹介されていました。

エゴの問題を克服したプログラミンググループは実在している。事実それはコンピュータ技術の最初期から存在していた。自分は自分のプログラムを調べる能力がない、ということに気づいた最初のプログラマは、恐らくジョン・フォン・ノイマンその人である。彼の知人たちが伝えるところによれば、彼は絶えず自分がいかに下手くそなプログラマかを力説し、しょっちゅう人にプログラムを押し付けては、読んで間違いや下手のところを見つけてくれ、と頼んだという。

自分自身のプログラミングの間違えは見つけられないと自覚していたのです。凡人たる我々が自信の完璧さを信仰して分の悪い賭けをおこなう必要はないでしょう。本書では、作成者は自身の成果物より一歩さがって他人から改善点を指摘してもらうというエゴレスプログラミングを提唱しています。

これは、後世のピアレビューやXPのペアプログラミング、ひいてはオープンソースの文化につながる考えの原点の一つと考えられます。

なお25年後にワインバーグ氏は「エゴレス方式」について下記のように記述しています。

プログラミングにおける「エゴレス」方式の概念は原著にあらわれたすべての概念のうちで恐らくもっとも多く引用され、誤解され、否定されたものである。私は何度となく、この部分はもっと説得力豊に書けなかったものだろうか、と考えた「less-ego programming」、つまりエゴなしのプログラミングでなく、エゴを少なめにしたプログラミングという名前にしておけば、論争を引き起こさずに済んだかもしれない。または、もっとたくさんの例題、またはもっとよい例題が必要だったかもしれない。または、実験的証拠がもっとたくさん必要だったかもしれない。

第5章 プログラミングチーム

一人の人間では満たしきれない作業要求にこたえて作られるべき、プログラミングチームの理論については半世紀前から、ゆっくりでありますが確実に進歩していると思います。

チームビルディングやファシリテーションについて、この半世紀で様々な書籍や社会的実験結果が積み重ねられてきました。
一部の優れた組織や、管理者はそれらの教訓を生かしていますが、平均人においてそれらが浸透しているとは言い難い状況です。

ワインバーグ氏は25周年版でコメントを残しています。

プログラミングプロジェクトにおける最悪のやり方は(それが今日もっともふつうのやり方になっているのだが)訓練生の大群を雇って圧力をかけ、監督なしではたらかせるというものなのだ。

ここ10年ほどでこの最悪のやり方は消えたように見えますが、二番目に悪いやりかたは依然として、続いていると語っています。

あちこちから契約社員(実は化けの皮をかぶった訓練生かもしれない)の大群を雇ってきて圧力をかけ、監督なしで(または過剰な監督つきで)働かせる

残念ながら、氏の残したコメントは四半世紀後の令和の時代でも依然として続いていることから、いまだこの章は多くの人間にとって有用なヒントを与えるものになると思います。

第6章 プログラミングプロジェクト

2つ以上のチームが目標を達成するために協働するというならば、より複雑な事態になることは簡単に想像できます。
この章に書かれている「変化を通じての安定性におけるキーマンこそ早くおいだせ」や「作業成績の測定におけるチームリーダたちの極端な値を排除して呼び出さるのを回避する心理」など様々な教訓が得られる章ですが、特筆すべきは25周年版のコメントでしょう。

四半世紀にわたってソフトウェアプロジェクトに関わってきた者として、私はオーストリア式軍隊モデルが(少なくともたいていの管理者の頭の中では)いまも主流を占めていると、確信できる。誰が誰に命令するかといった具体的な細部は当時とは違っているようだし、当時のやりかた自体、もとのオーストラリア陸軍での状況とは違っていたに違いないとはいうものの、である。ただし、現在ではほかのモデル、特にプロジェクトの成功のための不可欠の中核部分としてチームワークを強調しているようなモデルについて話したり文章に書いたりすることも許されるようになったのは喜ばしいことだ。

さらに時代の進んだ、令和の今現在も、完全で完璧な状況ではありませんが、チームワークについて論じることがあたりまえになってきたとは思います。
少なくとも、個別の事例はともかく、Twiiterや、はてな匿名ブログで「弊社がオーストリア式軍隊モデルを採用している件」とか書いて投稿すれば、恐らくは炎上するでしょう。

第3部 個人の活動としてのプログラミング

第7章 プログラミング作業の多様化

プログラミングとひとことでいっても、それはしばしば、本書では、もう少し細かいカテゴリーに分類しています。

  • 問題の定義
  • システム分析
  • フローチャートの作成(今の時代なら設計になるでしょう)
  • コーディング
  • テスト
  • ドキュメンテーション

現在では、これらの多様化はより進み、環境の構築や、構成管理、自動ビルドや自動テストのメンテナンスなどの話も追加されるでしょう。

さらにここであげた作業もさらに細かくわけることができます。
たとえば、テストといった場合、「テスト仕様書を作成する」という作業、「誤りを見つける」という作業、「誤りのありかをみつける」という作業、「誤りを直す」という作業があるわけです。

こういった多様な多面にわたった作業に秀でている人間がいないわけでないですが、多くの場合、そういうことは難しいでしょう。
これらの多面性、多様性は、先にあげた「よいプログラム」とはなにかと考えるのが難しいのと同様に、「よいプログラマ」とはなんであるかということを、絶対的尺度で表せないことを示します。

第8章 性格上の要因

この章であげられた性格上の要因が、プログラミング作業に与える影響の例はためにはなりますが、いささか古くもあります。

事実、著者が25年版に述べている通り、もう一度書き直すならこの章が一番変わることになるとあり、後年発売されたワインバーグのシステム行動法ではMBTIモデルをソフトウェア制御の仕事に関連付けた話が記述されています。

第9章 知能ないし、問題解決能力

この章では、問題解決能力の話が出てきますが、これは後年、氏が発表した名著「ライトついてますか—問題発見の人間学」の種になっているでしょう。

プログラミングにおける知能の話も出ていますが、氏は下記のように述べています。

知能は性格、作業習慣、および訓練といった要素と比べて、この問題にとっての重要性が低いように感じられる。そしてそれらの要素は、知能とちがって人生におけるその後の経験によって変わりえるものだ。だから問題はプログラムを選ぶことよりはむしろ育てることにある。

なお、この「育てる」については最良の思考形式を強制することではないと、氏は述べています。そいういった思考スタイルの強制はむしろ、そのプログラマの問題解決能力をそぎます。それよりはわれわれの考えを、それぞれ独自のスタイルを持ったほかの人々に理解可能なように表現するということが重要であると。

第10章 動機づけ、訓練、経験

プログラマの作業成績に影響を及ぼす道は大きく2つあり、「動機付け」と呼ばれる道と「訓練」または「教育」と呼ばれる道です。

そして、「動機づけ」といわれるものは教育や訓練に影響を与えます。
人が動機づけをもっていなければ、学習させることは容易ではないし、動機づけを持っているとすれば学習をやめさせる方法はないからです。

また、この動機づけはある種やっかいな性質があり、なにかをすれば強めることができるものでもないということです。
たとえば、資本主義社会の共通価値である金銭による動機付けが、目標設定への参画や仕事の質への関心といったものほど効果が薄い傾向があります。

この辺りのプログラマの動機付けについて、氏は古い格言を用いて言い表しています。

古い格言にこんなのがある「チェスは、女性や音楽にも劣らぬほど男を幸福にする。」私はこうもいえると思う。「プログラミングは、チェスや女性や音楽にも劣らぬほど男を幸福にする。」

管理者の立場として、こんなもんどうしろと、という話になりますが、後年発売されたワインバーグのシステム行動法では、MBTIモデルの16の性格タイプを4つに分類し、それぞれの気質が評価し賛成する管理行動を纏めています。

第四部 プログラミングの道具

さすがに初版から半世紀もたつと、これをそのまま現在にあてはめるのは難しいものがあります。

さいごに

初版から半世紀前の書籍というものは、今現在の視点から見れば古いものもありますが、同時に半世紀は変化しない共通的な根っこの部分を見出すには役に立つものです。

この機会に半世紀近くにわたりソフトウェア業界について記述し続けたワインバーグ氏の書籍を読んでいただければ幸いです。

サクラエディタの便利そうな機能

まえがき

SIerをディスる記事がバズるたびに流れ弾が飛んでくるサクラエディタですが、この偉大なエディタを使いこなしている人間は、すくないと思います。

今回は便利そうな機能を記録しておきたいとおもいます。

エディタから使える機能

Grep

検索メニューからGrepまたはGrepによる置換が行えます。
image.png

image.png

image.png

GrepやGrepによる置換には正規表現が利用できます。

利用可能な正規表現
https://sakura-editor.github.io/help/HLP000089.html

個人的によく使う正規表現は「\t」でGRAPの結果をタブ区切りに変換してExcelに張り付ける使い道です。

1、Grepの結果がある。
image.png

2、「): 」→「):\t」の置換をやる
image.png

3、Excelにはりつけやすくなる。
image.png

※正規表現使わなくてもタブ文字をコピーアンドペーストで置換後に入力する手もある

変換

変換メニューでは選択された文字に対して以下の変換が可能です。

  • 小文字
  • 大文字
  • 全角→半角
  • 半角+全ひらがな→全角・カタカナ
  • 半角+全カタカナ→全角・ひらがな
  • 全角英数→半角英数
  • 半角英数→全角英数
  • 全角カタカナ→半角カタカナ
  • 半角カタカナ→全角カタカナ
  • 半角カタカナ→全角ひらがな

大文字変換の例:

image.png

image.png

スクリプトを書かないで、お手軽な変換するには便利です。

制御コードの入力

編集→挿入→コントロールコードの入力で様々な制御コードを入力できます。
image.png

image.png

矩形選択

ALTキーを押しながらキーボードのカーソルキーを動かすか、ALTキーを押しながらマウスをドラッグすることで矩形選択がおこなえます。
image.png

矩形選択してその内容を抜き出すだけでなく、先頭に同じ文字を入れたりするときに使ったりします。

ブックマーク

行にブックマークを付けることができます。
これにより、重要そうな行にブックマークを付けておき、後から見直すことができます。
ログ解析等ではよくお世話になります。

機能 ショートカット
ブックマークの設定・解除 CTRL+F2
次のブックマークへ F2
前のブックマークへ SHIFT+F2
全ブックマークの一覧 ALT+F2

image.png

ブックマークを設定すると青マークがつきます。
image.png

ブックマークの一覧を表示して、そこからジャンプすることも可能です。
image.png

対括弧の検索

「CTRL+[」で選択した括弧の対になっている括弧にジャンプします。

image.png

image.png

IDEがない状況で、HTMLやプログラミング言語の括弧の対を見つけるのに便利です。

アウトライン解析

[検索]→[アウトライン解析]で行える機能で、プログラミング言語やHTMLのアウトラインを解析してくれます。

image.png

image.png

解析結果のツリーをクリックすることで、該当の行にジャンプができます。

タグジャンプ

F12キーでタグジャンプを行えます。
たとえば、includeファイルで宣言されているファイルを表示したりできます。

#include "calibCommon.hpp"を選択してF12を押す
image.png

calibCommon.hppが開く
image.png

ウィンドウの分割

[ウィンドウ]→[左右に分割]を行うことで、ウィンドウの分割が可能です。

image.png

image.png

ログファイルとかをみるのに捗りますね。

外部コマンドの出力

ツールの外部コマンドの実行をおこなうことで、外部コマンドの標準出力をエディタに出力することができます。

image.png

image.png

image.png

image.png

CUIの標準出力の結果をごにょごにょするのに使えそうですね。

マクロ

サクラエディタはマクロが色々使えます。
下記に簡単な例を示します。

下記に「test.vbs」という名前でファイルを保存してください。

test.vbs

Editor.InsBoxText("あ" + vbCrLf + "い")
Editor.InsText("あたたたt" + vbCrLf)
Editor.InsText("猫" + vbCrLf)
Editor.AddTail("末尾やで")

ツール→「名前を指定してマクロの実行」を選択します。
image.png

image.png

マクロが実行されてエディタに文字が書かれます。
image.png

どのようなマクロがサポートされているかの一覧としては下記を見るといいでしょう。
https://github.com/sakura-editor/sakura/blob/f6566a024397be8314cc59567eb7903d66a04d27/sakura_core/macro/CSMacroMgr.cpp

各マクロの詳細はサクラエディタのヘルプに乗っています。

WScript オブジェクトは使用できないものの、WSHがそのまま使えるので、かなり強力なことが事ができると思います。

起動時のコマンドラインオプション

起動時のコマンドラインオプションを設定することで色々自動で動かすことが可能になります。

コマンドラインオプション
https://sakura-editor.github.io/help/HLP000109.html

Grepを行った結果をテキストに保存する例:

Set SAKURA="C:\Program Files (x86)\sakura\sakura.exe"
%SAKURA% -GREPMODE -GKEY=日本  -GFILE=*.txt -GFOLDER="C:\dev\sakura\test" -GOPT=SLHU > result.txt

置換を行う例:

Set SAKURA="C:\Program Files (x86)\sakura\sakura.exe"
%SAKURA% -GREPMODE -GKEY=日本 -GREPR=韓国 -GFILE=*.txt -GFOLDER="C:\dev\sakura\test" -GOPT=SLHUO > result2.txt

マクロを実行する例:

Set SAKURA="C:\Program Files (x86)\sakura\sakura.exe"
%SAKURA% -M=C:\dev\sakura\test.vbs

おわりに

このようにサクラエディタは色々便利です。

サクラエディタを開いてGrepして結果を張り付けるとかいう手順や、サクラエディタを開いて文字コードが~であることを確認することという手順で仕事している所はちょっと見直したほうがいいと思います。

なお、SIerは嫌いになってもサクラエディタは嫌いにならないでください。

PowerPointで茜ちゃんに喋ってもらう

前書き

むかしむかし、ゆっくりさんにPowerPointを使って喋らせた動画を作成したことがあります。

今回は、令和の時代になったので、ゆっくりさんには卒業してもらって、VOICEROIDE2の茜ちゃんにやっていただくこととします。

# 仕組み
![image.png](https://needtec.sakura.ne.jp/wod07672/wp-content/uploads/2020/03/a5a83de0-689d-3b1e-f320-dca091c9b4b1.png)
PowerPointの各スライドのページに記載された文字をVOICEROIDE2を用いてWavファイルの作成を行います。その作成したWavファイルをスライドに埋め込みます。

スライドショーを実行することで、スライドを切り替え後、マウスクリックをすることで、茜ちゃんがしゃべってくれます。

# 使い方
**環境:**
Windows10
Office16 PowerPoint(32bit)
VOICEROIDE2

(1)以下から「akanechan.pptm」ファイルをダウンロードするか、任意のpptmファイルのVBAに「src」フォルダのclsとbasファイルをインポートしてください。
https://github.com/mima3/akanechan_powerpoint

(2)スライドのノートに喋らせたい文字を入力します。
![image.png](https://needtec.sakura.ne.jp/wod07672/wp-content/uploads/2020/03/ea401747-fb97-3e57-cc7c-d9134e9939ce.png)

(3) VOICEROID2を起動します。

(4)「AddSoftTalk」マクロを実行します。
![image.png](https://needtec.sakura.ne.jp/wod07672/wp-content/uploads/2020/03/b805e5a0-2753-3f3f-d999-04740d48c326.png)

これにより以下の処理が行われます。

- すべてのスライドのノートに書かれた文字が、改行毎にVOICEROID2におくられてWavファイルを作成します。このファイルはPowerPointと同じフォルダに存在します。
- 作成したWavファイルを各スライドに埋め込みます。

(5)マクロが終了するとすべてのスライドに以下のような音声が埋め込まれていることが確認できます。
![image.png](https://needtec.sakura.ne.jp/wod07672/wp-content/uploads/2020/03/2b92ac88-eaa9-6465-5552-fb0b1ef023c5.png)

(6)スライドショーの記録を行います。この過程で埋め込んだ音声が再生されます。
 記録されたスライドショーはファイルのエクスポートからビデオを作成することができます。

# ソースコードの内容
## VbaUiAuto.cls
UIAutomationを操作する処理をまとめたものになります。

**VbaUiAuto.cls**
```vb:VbaUiAuto.cls
Option Explicit
'* UIAutomationClientを参照設定してください。
Private uia As UIAutomationClient.CUIAutomation
Private Declare Sub Sleep Lib "kernel32" (ByVal dwMilliseconds As Long)

Private Sub Class_Initialize()
Set uia = New UIAutomationClient.CUIAutomation
End Sub
Private Sub Class_Terminate()
Set uia = Nothing
End Sub
Public Sub SleepMilli(ByVal millisec As Long)
Call Sleep(millisec)
End Sub
' ルートのディスクトップ要素を取得
Public Function GetRoot() As IUIAutomationElement
Dim ret As IUIAutomationElement
Set ret = uia.GetRootElement
Set GetRoot = ret
End Function

' 指定の子ウィンドウをタイトルから取得する
Public Function GetMainWindowByTitle(ByRef form As IUIAutomationElement, ByVal name As String) As IUIAutomationElement
Dim cnd As IUIAutomationCondition
Dim ret As IUIAutomationElement
Set cnd = uia.CreatePropertyCondition(UIA_PropertyIds.UIA_NamePropertyId, name)

Set ret = form.FindFirst(TreeScope_Element Or TreeScope_Children, cnd)

Set GetMainWindowByTitle = ret
End Function

' 指定の子ウィンドウをタイトルから取得できまで待機
Public Function WaitMainWindowByTitle(ByRef form As IUIAutomationElement, ByVal name As String, ByVal timeOutSec As Double) As IUIAutomationElement
Dim start As Variant
start = Timer()
Dim ret As IUIAutomationElement

Set ret = GetMainWindowByTitle(form, name)
Do While ret Is Nothing
If Timer() - start > timeOutSec Then
Exit Function
End If
Set ret = GetMainWindowByTitle(form, name)
Call SleepMilli(100)
Loop
Set WaitMainWindowByTitle = ret
End Function

' ボタンを指定Indexを押下する
Public Sub pushButton(ByRef form As IUIAutomationElement, ByVal ix As Long)
Dim cnd As IUIAutomationCondition
Set cnd = uia.CreatePropertyCondition(UIA_PropertyIds.UIA_ClassNamePropertyId, "Button")

Dim list As IUIAutomationElementArray
Set list = form.FindAll(TreeScope_Element Or TreeScope_Descendants, cnd)

Dim ptn As IUIAutomationInvokePattern
Set ptn = list.GetElement(ix).GetCurrentPattern(UIA_PatternIds.UIA_InvokePatternId)
Call ptn.Invoke

End Sub

' 指定のClassNameがTextBoxに値を設定する
Public Sub SetText(ByRef form As IUIAutomationElement, ByVal ix As Long, ByVal text As String)
Dim cnd As IUIAutomationCondition
Set cnd = uia.CreatePropertyCondition(UIA_PropertyIds.UIA_ClassNamePropertyId, "TextBox")

Dim list As IUIAutomationElementArray
Set list = form.FindAll(TreeScope_Element Or TreeScope_Descendants, cnd)

Dim editValue As IUIAutomationValuePattern
Set editValue = list.GetElement(ix).GetCurrentPattern(UIA_PatternIds.UIA_ValuePatternId)
Call editValue.SetValue(text)

End Sub

' 指定のAutomationIdでTextBoxに値を設定する
Public Sub SetTextById(ByRef form As IUIAutomationElement, ByVal id As String, ByVal text As String)
Dim cnd As IUIAutomationCondition
Set cnd = uia.CreatePropertyCondition(UIA_PropertyIds.UIA_AutomationIdPropertyId, id)

Dim list As IUIAutomationElementArray
Set list = form.FindAll(TreeScope_Element Or TreeScope_Descendants, cnd)

Dim editValue As IUIAutomationValuePattern
Set editValue = list.GetElement(0).GetCurrentPattern(UIA_PatternIds.UIA_ValuePatternId)
Call editValue.SetValue(text)

End Sub

' 指定のClassNameがTextBoxの値を取得する
Public Function GetStatusBarItemText(ByRef form As IUIAutomationElement, ByVal ix As Long) As String
Dim cnd As IUIAutomationCondition
Set cnd = uia.CreatePropertyCondition(UIA_PropertyIds.UIA_ClassNamePropertyId, "StatusBarItem")

Dim list As IUIAutomationElementArray
Set list = form.FindAll(TreeScope_Element Or TreeScope_Descendants, cnd)

GetStatusBarItemText = list.GetElement(ix).CurrentName

End Function
```

## VoiceRoid.cls
VOICEROIDE2で音声ファイルを作成するための処理です。
このあたりは[二番煎じ](https://needtec.sakura.ne.jp/wod07672/?p=9204#vba--uiautomation%E3%81%A7%EF%BD%B1%EF%BD%B6%EF%BE%88%EF%BE%81%EF%BD%AC%EF%BE%9D%EF%BD%B6%EF%BE%9C%EF%BD%B2%EF%BD%B2%EF%BE%94%EF%BD%AF%EF%BE%80%EF%BD%B0)になっています。

**VoiceRoid.cls**
```vb:VoiceRoid.cls
Option Explicit
Private vua As VbaUiAuto
Private mainForm As IUIAutomationElement
'*
'* 初期化
'*
Private Sub Class_Initialize()
Set vua = New VbaUiAuto
Set mainForm = vua.GetMainWindowByTitle(vua.GetRoot(), "VOICEROID2")
If (mainForm Is Nothing) Then
Set mainForm = vua.GetMainWindowByTitle(vua.GetRoot(), "VOICEROID2*")
If (mainForm Is Nothing) Then
Err.Raise 999, "VoiceRoid.Init", "VOICEROIDE2が起動していない"
Exit Sub
End If
End If
End Sub

'**
'* VOICEROID2によるWavファイルの作成
'* @param[in] msg しゃべる内容
'* @param[in] wavPath 作成するwavファイルのパス
'**
Public Sub CreateWavFile(ByVal msg As String, ByVal wavPath As String)
' 茜ちゃんのセリフ設定
Call vua.SetText(mainForm, 0, msg)

' 音声保存
Call vua.pushButton(mainForm, 4)

' 5秒以内に音声保存画面が表示されたら保存ボタンを押す
Dim saveWvForm As IUIAutomationElement
Set saveWvForm = vua.WaitMainWindowByTitle(mainForm, "音声保存", 5)
Call vua.pushButton(saveWvForm, 0)

' 名前を付けて保存に日付のファイル名を作る
Dim saveFileForm As IUIAutomationElement
Set saveFileForm = vua.WaitMainWindowByTitle(saveWvForm, "名前を付けて保存", 5)
Call vua.SetTextById(saveFileForm, "1001", wavPath)
SendKeys "{ENTER}"

' 情報ポップアップのOKを押下
Dim infoForm As IUIAutomationElement
Set infoForm = vua.WaitMainWindowByTitle(saveWvForm, "情報", 60)
Call vua.pushButton(infoForm, 0)

End Sub
```

## Main.bas
スライドショーのノート解析~VOICEROIDE2で作成したWavファイルの埋め込みを行っています。

**Main.bas**
```vb:Main.bas
Option Explicit

'*
'* ノートに書いた内容をスライドの切り替え時にsofttalkを用いてしゃべるようにします。
'* 「画像切り替え」タブのサウンドにそのファイルは指定されています。
'* このリストに追加したファイルはPowerPointerを再起動したときに使用されていなければリストから消えます
'*
Public Sub AddSoftTalk()
Dim sld As Slide
Dim note As Slide
Dim msg As String
Dim wavPath As String
' Visit each slide
For Each sld In ActivePresentation.Slides
Call AddSoftTalkToSlide(sld)
Next
End Sub
'*
'* 選択中のスライドに対して音声を追加する
'*
Public Sub AddSoftTalkToSelectedSlide()
Dim sld As Slide
Set sld = ActivePresentation.Slides.Item(ActiveWindow.Selection.SlideRange.SlideIndex)
Call AddSoftTalkToSlide(sld)
End Sub

Private Sub AddSoftTalkToSlide(ByRef sld As Slide)
Dim note As Slide
Dim msg As String
Dim wavPath As String
Dim line As Variant

Dim vr As VoiceRoid
Set vr = New VoiceRoid

Dim i As Long

For Each note In sld.NotesPage
msg = note.Shapes.Item(2).TextEffect.text
Debug.Print msg
If msg <> "" Then
i = 0
line = Split(msg, vbCr)
For i = LBound(line) To UBound(line)
If line(i) <> "" Then
wavPath = ActivePresentation.Path & "\" & sld.name & "_" & i & ".wav"
' Wavファイル作成
Call vr.CreateWavFile(line(i), wavPath)

Call ApeendWavFile(sld, wavPath)
End If
Next i
End If
Next

End Sub

'**
'* スライドにファイルを追加します。
'* この際全てのShapesをチェックしてすでに追加されていないか確認します。
'* @param[in,out] sld 対象のスライド
'* @param[in] wavPath 作成するwavファイルのパス
'**
Private Sub ApeendWavFile(ByRef sld As Slide, ByVal wavPath As String)
' 重複チェック & 削除
Dim shp As Shape
Dim rmIndex As Long
rmIndex = 0
Dim i As Long
i = 1
For Each shp In sld.Shapes
If shp.Type = msoMedia Then
If shp.MediaType = ppMediaTypeSound Then
If Dir(wavPath) = shp.name Then
rmIndex = i
Exit For
End If
End If
End If
i = i + 1
Next
If rmIndex <> 0 Then
sld.Shapes.Item(rmIndex).Delete
End If

Set shp = sld.Shapes.AddMediaObject2(wavPath)
shp.AnimationSettings.PlaySettings.PlayOnEntry = msoTrue
shp.AnimationSettings.PlaySettings.HideWhileNotPlaying = msoTrue

End Sub

```

# あとがき
これで茜ちゃんとしてプレゼンテーションができるようになります。

.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のプロセスが起動してたら終了させるなどの意識の低い対処案が考えられます。

参考