Microsoft Office

OpenXML SDK を使って Windows Workflow Foundation を統合する

Rick Spiewak

コード サンプルのダウンロード

ワークフローが関係するビジネス プロセスでは、多くの場合、各プロセスに対応するドキュメントの作成や処理が必要になります。たとえば、ローン、保険証券、株式の償還などの適用を、ワークフローのプロセス中に承認または拒否するときに必要になります。これはプログラムから自動で、または保険業者の手によって手動で行われることもあります。その場合、手紙や収支を示すワークシートの作成が必要になります。

MSDN マガジンの 2008 年 6 月号の記事で、Microsoft Office アプリケーション オブジェクト モデルを使用してこれを行う方法を紹介しました (msdn.microsoft.com/magazine/cc534981)。上記の記事を基にして、今回は Office アプリケーションを直接操作することなく Microsoft Office と互換性のあるドキュメントを Windows Workflow Foundation (WF) と統合する方法を説明します。この統合を実現するため、OpenXML SDK 2.0 を使用してワープロ文書とワークシート ドキュメントを操作します。対応する Office アプリケーションは、当然 Word と Excel です。それぞれが独自の OpenXML ドキュメント モデルを持つ一方で、一連のインターフェイス クラスを使用してワークフローを統合できるという十分な共通点があり、ほとんど違いはありません。

Microsoft .NET Framework 4 Client Profile には WF が組み込まれているため、.NET Framework 4 をインストールすると必ず WF ライブラリもインストールされます。また、.NET Framework 4 では .NET Framework 3.0 と比べて WF ライブラリの使用が大幅に簡素化されたため、ワークフロー機能を必要とするアプリケーションは、基本的なものであっても、カスタム コードを記述する代わりに、このライブラリの使用を検討すべきです。このことは、組み込みのアクティビティをカスタム アクティビティで補完する必要があるとしても当てはまります。

今回は、プロトタイプとして機能する Office ドキュメントに基づくデータ入力を、カスタム アクティビティを使って提供する方法を説明します。このデータ入力のアクティビティは、ドキュメントの種類ごとに用意したアクティビティにデータを渡し、データを受け取った各アクティビティはデータ フィールドを使用して対象となる Office ドキュメントにデータを格納します。そのため、Visual Studio 2010 を使用して、名前付きフィールドの列挙、コンテンツの抽出、プロトタイプのドキュメントへのデータの格納などの操作をサポートするクラスを開発します。これらのクラスはすべて Office アプリケーション オブジェクト モデルを直接操作するのではなく、OpenXML SDK を使用します。今回は、データ入力のアクティビティと、ワープロ文書やワークシート ドキュメントへのデータの格納をサポートするアクティビティを作成しました。完成したドキュメントは、そのドキュメントの種類の既定のアプリケーションを呼び出すだけで表示されます。コードは Visual Basic で記述しました。

全体のデザイン

ワークフローと Office との統合の設計には、全般的な要件が 3 つあります。1 つは、ワークフローへのデータの取り込み、1 つは、Office ドキュメントを作成または更新するためのデータ処理、最後に、出力ドキュメントへのデータの格納または引き渡しです。これらのニーズをサポートするため、OpenXML SDK を使用して、基盤となる Office ドキュメント形式に統一インターフェイスを提供する一連のクラスを作成しました。これらのクラスは、次のメソッドを提供します。

  • ドキュメントでターゲットになり得るフィールドの名前を取得するメソッド。ここではこのターゲットになり得るフィールドを単に "フィールド" と呼びます。このフィールドは、Word ではブックマーク、Excel では名前付き範囲です。ほとんどの場合、ブックマークや名前付き範囲は非常に複雑になりますが、ここではブックマークの場所が 1 か所で、名前付き範囲が単一のセルという単純な場合を想定します。ブックマークには必ずテキストが含まれますが、ワークシートのセルにはテキスト、数値、または日付が含まれる可能性があります。
  • 入力データを受け取り、データをドキュメントに対応させて、ドキュメントのターゲット フィールドに格納するメソッド。

Office ドキュメントにデータを格納するアクティビティを実装するため、WF 4 CodeActivity モデルを使用します。このモデルは WF 3.0 から大幅に簡素化され、実装が非常にわかりやすくなりました。たとえば、依存関係プロパティを明示的に宣言する必要はなくなりました。

サポート クラス

ワークフローの背後には、OpenXML SDK の関数をサポートするよう設計した一連のクラスがあります。これらのクラスはプロトタイプのドキュメントをメモリ ストリームに読み込み、フィールド (ブックマークか名前付き範囲) を見つけてデータを格納し、結果の出力ドキュメントを保存する主要関数を実行します。メモリ ストリームの読み込みや保存などの共通関数は基本クラスに集めています。ここでは簡潔にするためエラー チェックを省略していますが、付属のコード ダウンロードには含めています。図 1 は OpenXML ドキュメントの読み込みと保存の方法を示しています。

図 1 OpenXML ドキュメントの読み込みと保存

Public Sub LoadDocumentToMemoryStream(ByVal documentPath As String)
  Dim Bytes() As Byte = File.ReadAllBytes(documentPath)
  DocumentStream = New MemoryStream()
  DocumentStream.Write(Bytes, 0, Bytes.Length)
End Sub
Public Sub SaveDocument()
  Dim Directory As String = Path.GetDirectoryName(Me.SaveAs)
  Using OutputStream As New FileStream(Me.SaveAs, FileMode.Create)
    DocumentStream.WriteTo(OutputStream)
    OutputStream.Close()
    DocumentStream.Close()
  End Using
End Sub

ワークフローにデータを取り込む

Office ドキュメントをワークフローに統合する際に最初に直面した課題の 1 つが、フィールド用のデータをドキュメントに渡す方法です。標準ワークフローの構造は、アクティビティに関連付けられる変数の名前を事前に把握していることを前提にしています。変数はさまざまなスコープで定義し、そのスコープによってワークフローや他のアクティビティからその変数にアクセスできるかどうかを決めます。このモデルを直接当てはめると、ワークフロー全体のデザインをドキュメント フィールドに緊密に結び付けることになるため、柔軟性に欠けると判断しました。Office の統合の場合、ワークフロー アクティビティは Office ドキュメント用のプロキシとして機能します。ドキュメントのフィールドの名前を事前に決定しておくことは、ドキュメントの種類ごとにカスタム アクティビティを対応させる必要があるため、現実的ではありません。

アクティビティに引数を渡す方法を見ると、Dictionary(Of String, Object) として渡していることがわかります。Office ドキュメントのフィールドにデータを格納するには、2 つの情報が必要です。1 つはフィールドの名前、もう 1 つは挿入する値です。以前、ワークフロー製品と Office を使用するアプリケーションを開発し、そこで採用した一般的な方針は問題なく機能しました。つまり、ドキュメントの名前付きフィールドを列挙し、名前によって入力パラメーターと対応付ける方法です。ドキュメント フィールドが基本入力パラメーター ディクショナリ (String, Object) のパターンと一致する場合は、直接渡されます。ただし、この方法はワークフローとドキュメントを緊密に結び付けます。

ドキュメントのフィールドと対応するように変数に名前を付ける代わりに、汎用の Dictionary(Of String, String) を使用してフィールドの名前を渡すことにしました。このパラメーターに Fields という名前を付け、各アクティビティで使用します。このようなディクショナリの各エントリは KeyValuePair(Of String, String) 型です。このキーをフィールドの名前の対応付けに使用し、値をフィールド コンテンツの格納に使用します。Fields ディクショナリはワークフロー内のパラメーターの 1 つです。

シンプルな Windows Presentation Foundation (WPF) ウィンドウの数行のコードだけでワークフローを開始でき、既存のアプリケーションに追加される場合はより少ないコードで済みます。

Imports OfficeWorkflow
Imports System.Activities
Class MainWindow
  Public Sub New()
    InitializeComponent()
    WorkflowInvoker.Invoke(New Workflow2)
    MessageBox.Show("Workflow Completed")
    Me.Close()
  End Sub
End Class

アクティビティを一般に使いやすく、複数の方法で入力ドキュメントを提供できるようにすることを考えました。これを可能にするため、アクティビティは InputDocument という名前の共通のパラメーターを受け取ります。次にアクティビティは、ワークフローでの必要性に応じて他のアクティビティの出力に接続された変数にアタッチされます。パラメーターはプロトタイプとして使用される入力ドキュメントのパスを保持します。ただし、コードでは InputDocument という Field パラメーターを使用することもできます。その場合、InputDocument には対象となる Office アプリケーションに適したドキュメントの種類へのパスを保持します。追加パラメーターは、目的のアクティビティが TargetActivity という名前の入力フィールドで名前を付けられるようにします。これにより、たとえばシーケンス内の複数のアクティビティに元の入力ドキュメントのフィールドを適用できるか評価できます。

実際のワークフローには必ず、入力データのソースが存在します。ここでは説明のために、DataEntry アクティビティを使用しました。このアクティビティは、サポートされている任意の種類の Office ドキュメントから入力 (フィールドおよび既定値) を抽出できます。このアクティビティは、DataGrid と、ドキュメントを選択してデータ フィールドを保存するボタンを含むダイアログ ボックスを開きます。ユーザーがドキュメントをプロトタイプとして選択した後、DataGrid には、ドキュメントの使用可能なフィールドに加え、InputDocument フィールド、OutputDocument フィールド、および TargetActivity フィールドによりデータが格納されます (図 2 参照)。(ちなみに、Julie Lerman の 2011 年 4 月のデータ ポイントのコラム「WPF の DataGrid 列のテンプレートを構成してユーザー エクスペリエンスを向上する」(msdn.microsoft.com/magazine/gg983481 参照) で、FocusManager を使用して図 2 のようにグリッドをクリック 1 回で編集できるようにする重要なヒントが提供されています。)

The Data Entry Activity Interface
図 2 データ入力アクティビティ インターフェイス

ドキュメント用にデータを処理する

前述のとおり、それぞれの Office ドキュメントの種類には名前付きフィールドのコレクションを提供する独自の方法があります。それぞれのアクティビティは特定のドキュメントの種類をサポートするように記述していますが、各種の Office ドキュメントをサポートするアクティビティはすべて同じパターンに従います。InputDocument プロパティがある場合は、これをドキュメントのパスとして使用します。InputDocument プロパティが null の場合、アクティビティは Fields プロパティを調べて、InputDocument の値を探します。見つかった場合は、アクティビティで処理するドキュメントの種類と一致するサフィックスを持つパスが含まれているかどうかを確認します。また、アクティビティは適したサフィックスを付加してドキュメントを見つけようとします。これらの条件が一致した場合には、InputDocument プロパティをこの値に設定し、処理を進めます。

Fields コレクションの対応するエントリをそれぞれ使用して、出力ドキュメント内の対応するフィールドに値を格納します。これは、対応するワークフロー変数 (OutputDocument) として渡されるか、OutputDocument KeyValuePair エントリとして Fields コレクション内にあります。どちらの場合も、出力ドキュメントにサフィックスがなければ、適切な既定のサフィックスを付加します。これにより、同じ値を使用して異なる種類のドキュメントを作成したり、異なる種類の複数のドキュメントを作成できるようになります。

出力ドキュメントは、指定されたパスに格納されます。ほとんどの実際のワークフロー環境では、ネットワーク共有または SharePoint フォルダーが格納先です。ここでは単純にするために、コードでローカル パスを使用しています。また、Word アクティビティと Excel アクティビティ用のコードは、対応する種類の入力ドキュメントをチェックします。ワークフローのデモで、入力ドキュメントは既定で Fields の基礎として選択されたプロトタイプになります。これはユーザーが変更でき、Word 文書に由来する Fields を Excel ドキュメント用の入力を定義するのに使用でき、逆も同じです。図 3 は、Word 文書にデータを格納するコードを示しています。

図 3 アクティビティにおけるワープロ文書へのデータの格納

Protected Overrides Sub Execute(ByVal context As CodeActivityContext)
  InputFields = Fields.Get(Of Dictionary(Of String, String))(context)
  InputDocumentName = InputDocument.Get(Of String)(context)
  If String.IsNullOrEmpty(InputDocumentName) Then InputDocumentName = _
    InputFields("InputDocument")
  OutputDocumentName = OutputDocument.Get(Of String)(context)
  If String.IsNullOrEmpty(OutputDocumentName) Then OutputDocumentName = _
    InputFields("OutputDocument")
  ' Test to see if this is the right activity to process the input document
  InputFields.TryGetValue(("TargetActivity"), TargetActivityName)
  ' If there is no explicit target, see if the document is the right type
  If String.IsNullOrEmpty(TargetActivityName) Then
    If Not (InputDocumentName.EndsWith(".docx") _
    Or InputDocumentName.EndsWith(".docm")) _
    Then Exit Sub
    'If this is the Target Activity, fix the document name as needed
  ElseIf TargetActivityName = Me.DisplayName Then
    If Not (InputDocumentName.EndsWith(".docx") _
    Or InputDocumentName.EndsWith(".docm")) _
      Then InputDocumentName &= ".docx"
    End If
  Else
    Exit Sub
  End If
  ' This is the target activity, or there is no explicit target and
  ' the input document is a Word document
  Dim InputWordInterface = New WordInterface(InputDocumentName, InputFields)
  If Not (OutputDocumentName.EndsWith(".docx") _
  Or OutputDocumentName.EndsWith(".docm")) _
    Then OutputDocumentName &= ".docx"
  InputWordInterface.SaveAs = OutputDocumentName
  Dim Result As Boolean = InputWordInterface.FillInDocument()
  ' Display the resulting document
  System.Diagnostics.Process.Start(OutputDocumentName)
End Sub

図 3 では、特に次の行に注目してください。

Dim InputWordInterface = _
  New WordInterface(InputDocumentName, InputFields))

この行で WordInterface クラスのインスタンスを構築します。プロトタイプとして使用するためドキュメントにパスとフィールド データを渡します。これらは単に、クラスのメソッドで使用する対応するプロパティに格納します。

WordInterface クラスは、ターゲット ドキュメントにデータを格納する機能を提供します。入力ドキュメントをプロトタイプとして使用し、そこから基盤となる OpenXML ドキュメントのメモリ内のコピーを作成します。これは重要な手順で、メモリ内のコピーにデータを格納し、出力ファイルとして保存します。メモリ内のコピーの作成は、それぞれのドキュメントの種類に共通で、OfficeInterface 基本クラスで処理します。ただし、出力ファイルの保存は種類ごとに異なります。図 4 は、WordInterface クラスによって Word 文書にデータを格納する方法を示しています。

図 4 WordInterface クラスを使用した Word 文書へのデータの格納

Public Overrides Function FillInDocument() As Boolean
  Dim Status As Boolean = False
  ' Assign a reference to the existing document body.
  Dim DocumentBody As Body = WordDocument.MainDocumentPart.Document.Body
  GetBuiltInDocumentProperties()
  Dim BookMarks As Dictionary(Of String, String) = Me.GetFieldNames(DocumentBody)
  ' Determine dictionary variables to use -
    based on bookmarks in the document matching Fields entries
  If BookMarks.Count > 0 Then
    For Each item As KeyValuePair(Of String, String) In BookMarks
      Dim BookMarkName As String = item.Key
      If Me.Fields.ContainsKey(BookMarkName) Then
        SetBookMarkValueByElement(DocumentBody, BookMarkName, Fields(BookMarkName))
      Else
        ' Handle special case(s)
        Select Case item.Key
          Case "FullName"
            SetBookMarkValueByElement(DocumentBody, _
            BookMarkName, GetFullName(Fields))
        End Select
      End If
    Next
    Status = True
  Else
    Status = False
  End If
  If String.IsNullOrEmpty(Me.SaveAs) Then Return Status
  Me.SaveDocument(WordDocument)
  Return Status
End Function

FullName という特殊なケースのフィールド名を追加しました。ドキュメントにこの名前のフィールドが含まれる場合、Title、FirstName、および LastName という入力フィールドを連結してデータを格納します。このロジックは GetFullName という関数に含めています。すべての種類の Office ドキュメンに必要なため、この関数は、他のいくつかの共通プロパティと共に、OfficeInterface 基本クラスに含めます。Select Case ステートメントを使用してこの関数を拡張ポイントにします。たとえば、FullAddress フィールドを追加して Address1、Address2、City、State、ZipCode、および Country という入力フィールドを連結してもかまいません。

出力ドキュメントを保存する

各アクティビティ クラスには OutputDocument プロパティがあり、いくつかの方法で設定できます。デザイナー内では、プロパティをワークフロー レベルのパラメーターまたは定数値にバインドできます。実行時には、各アクティビティがその OutputDocument プロパティを参照して、ドキュメントを保存するパスを確認します。このプロパティが設定されていない場合は、Fields コレクションから OutputDocument という名前のキーを探します。値に適切なサフィックスが付いていれば、それをそのまま使用します。適切なサフィックスが付いていない場合は、サフィックスを付加します。その後、アクティビティから出力ドキュメントを保存します。これにより、出力ドキュメントへのパスをどこに配置するかを非常に柔軟に決定できます。サフィックスが省略されるため、どちらの種類のアクティビティも同じ値を使用します。次に、Word 文書を保存する方法を示します。まず、メモリ ストリームが更新されていることを確認し、基本クラスのメソッドを使用します。

Public Sub SaveDocument(ByVal document As WordprocessingDocument)
  document.MainDocumentPart.Document.Save()
  MyBase.SaveDocument()
End Sub

サンプル ワークフロー

図 5 に、統合のしくみがわかるシンプルなワークフローを示します。2 つ目の例ではフローチャートを使用します (図 6 参照)。それぞれの種類のアクティビティとその機能を順に説明しますが、まず各ワークフローの流れを確認します。

Simple Workflow with Office Integration
図 5 Office を統合するシンプルなワークフロー

Flowchart Workflow with Office Integration
図 6 Office を統合するフローチャート ワークフロー

ワークフローは、それぞれの種類のアクティビティを順に呼び出すシンプルなシーケンスで構成されます。Word アクティビティと Excel アクティビティは入力ドキュメントの種類を確認するため、誤った種類の処理を進めることはありません。

図 6 のフローチャート ワークフローは、Switch アクティビティを使用してどちらの Office アクティビティを呼び出すべきかを判断します。この判断には、次のような単純な式を使用します。

Right(Fields("InputDocument"), 4)

docm と docx の場合は Word アクティビティを呼び出し、xlsx と xlsm の場合は Excel アクティビティを呼び出します。

各アクティビティのアクションは図 5 で示しますが、図 6 のアクションも同じです。

データ入力

図 5 の上部に、DataEntryActivity クラスのインスタンスがあります。DataEntryActivity は、DataGrid コントロールを含む WPF ウィンドウを表示します。このコントロールには、InputDocument から抽出した名前付きフィールドによってデータが格納され、この場合はユーザーが選択します。このコントロールによりユーザーは、どちらが最初に提供されたかにかかわらず、ドキュメントを選択できます。次にユーザーはフィールドで値の入力や編集ができます。ObservableCollection クラスを別に用意しており、DataGrid への必要な TwoWay データ バインドを可能にします (図 7 参照)。

図 7 フィールドを表示する ObservableCollection

Imports System.Collections.ObjectModel
' ObservableCollection of Fields for display in WPF
Public Class WorkflowFields
  Inherits ObservableCollection(Of WorkFlowField)
  'Create the ObservableCollection from an input Dictionary
  Public Sub New(ByVal data As Dictionary(Of String, String))
    For Each Item As KeyValuePair(Of String, String) In data
      Me.Add(New WorkFlowField(Item))
    Next
  End Sub
  Public Function ContainsKey(ByVal key As String) As Boolean
    For Each item As WorkFlowField In Me.Items
      If item.Key = key Then Return True
    Next
    Return False
  End Function
  Public Shadows Function Item(ByVal key As String) As WorkFlowField
    For Each Field As WorkFlowField In Me.Items
      If Field.Key = key Then Return Field
    Next
    Return Nothing
  End Function
End Class
Public Class WorkFlowField
  Public Sub New(ByVal item As KeyValuePair(Of String, String))
    Me.Key = item.Key
    Me.Value = item.Value
  End Sub
  Property Key As String
  Property Value As String
End Class

InputDocument は Word (.docx または .docm) か Excel (.xlsx または .xlsm) というサポート対象の Office ドキュメントの種類のどちらかになります。ドキュメントからフィールドを抽出するために、適切な OfficeInterface クラスを呼び出します。このクラスは、ターゲット ドキュメントを OpenXML オブジェクトとして読み込み、フィールド (および存在する場合はその内容) を列挙します。マクロを含むドキュメント形式をサポートしており、マクロは出力ドキュメントで実行されます。

DataEntryActivity によって提供されるフィールドの 1 つに、TargetActivity フィールドがあります。これは、入力ドキュメントから収集したフィールドによって Fields プロパティにデータを格納する、対象アクティビティの名前です。TargetActivity フィールドは、データ フィールドを処理するかどうかを判断するために他のアクティビティでも使用します。

WordFormActivity

WordFormActivity は、ワープロ (Word) 文書を処理します。Fields エントリを Word 文書内の同じ名前のブックマークに対応させます。次に、フィールドの値を Word ブックマークに挿入します。このアクティビティは、マクロを含むドキュメント (.docm) とマクロを含まないドキュメント (.docx) を処理します。

ExcelFormActivity

ExcelFormActivity は、ワークシート (Excel) ドキュメントを処理します。Fields エントリを Excel ドキュメント内の同じ名前を持つシンプルな名前付き範囲と対応させます。次に、フィールドの値を Excel の名前付き範囲に挿入します。このアクティビティは、マクロを含む Excel ドキュメント (.xlsm) とマクロを含まない Excel ドキュメント (.xlsx) を処理します。

Excel ドキュメントにはいくつかの追加の特徴があり、格納されたデータを正確に設定および処理するために特殊な処理が必要です。こうした特徴の 1 つが、さまざまな暗黙の日付と時刻の形式です。さいわい、これについては詳細なドキュメントがあります (ECMA-376 Part 1、18.8.30 参照、bit.ly/fUUJ、英語)。ECMA 形式を検出したら、対応する .NET 形式に変換します。たとえば、ECMA 形式の mm-dd-yy は M/dd/yyy になります。

さらに、ワークシートには共有文字列の概念があり、共有文字列を格納するセルに値を挿入する際に特殊な処理が必要です。ここで使用した InsertSharedStringItem メソッドは、OpenXML SDK に含まれたメソッドを基にしています。

If TargetCell.DataType.HasValue Then
  Select Case TargetCell.DataType.Value
    Case CellValues.SharedString    ' Handle case of a shared string data type
      ' Insert the text into the SharedStringTablePart.
      Dim index As Integer = InsertSharedStringItem(NewValue, SpreadSheetDocument)
      TargetCell.CellValue.Text = index.ToString
      Status = True
    End Select
End If

最後のしあげ

ワークフローの例は、完了したことをシンプルに宣言します。ドキュメントの種類または TargetActivity フィールドによって選択されたアクティビティは、指定された場所に出力ドキュメントを作成します。ここから、他のアクティビティがそのドキュメントを使用して追加の処理を行うことができます。説明のため、ここでは各アクティビティは出力ドキュメントを起動することで終了し、Windows が適切なアプリケーションで開きます。

System.Diagnostics.Process.Start(OutputDocumentName)

印刷を使用する場合は、以下のようになります。

Dim StartInfo As New System.Diagnostics.ProcessStartInfo( _
  OutputDocumentName) StartInfo.Verb = "print"
System.Diagnostics.Process.Start(StartInfo)

ドキュメント形式にしか取り組んでいないため、インストール済みの Office のバージョンをワークフロー アプリケーションが把握するようにする必要はありません。

実際の運用環境では、追加の作業が必要になる場合があります。たとえば、データベース エントリを作成したり、最終的にドキュメントを電子メールでルーティングするか印刷して顧客に送信するような作業です。運用ワークフロー アプリケーションでは、永続化や追跡などの他のサービスを活用することもあります。

まとめ

今回は、OpenXML SDK を使用して Window Workflow Foundation 4 を Office ドキュメントと結び付ける基本的な設計方法の概要を説明しました。サンプル ワークフローはこのアプローチを説明し、これを実装してワークフローのデータを使用し Office ドキュメントをカスタマイズする方法を示しています。このソリューションを組み込んだクラスは、さまざまな類似ニーズを満たすために簡単に修正して拡張できます。ワークフロー アクティビティは WF 4 と .NET Framework 4 を活用するように作成しましたが、Office インターフェイス ライブラリは .NET Framework 3.5 とも互換性があります。

Rick Spiewak は、The MITRE Corporation 社でリード ソフトウェア システム エンジニアを務めています。現在、米空軍電子システム センターでミッション プランニング業務に従事しています。1993 年から Visual Basic の開発作業、2002 年から Microsoft .NET Framework の開発作業に携わり、Visual Studio .NET 2003 のベータ テスターも務めました。Office アプリケーションをさまざまなワークフロー ツールと統合することに関して長い経験があります。

この記事のレビューに協力してくれた技術スタッフの Andrew Coates に心より感謝いたします。