Share via


Excel、Exchange、および C#

Eric Gunnerson
Microsoft Corporation

April 21, 2003

概要 : この資料では、Outlook、Excel、および C# を使用して、短期間のプロジェクトと長期間のプロジェクトを、見やすいレイアウトで提供するカスタム予定表を作成する方法を示します。

サンプル ファイル (csharp05152003_sample.exe) のダウンロード

もはや 1 月ではありませんが、遅ればせながら新年の決意を立てることにしました。 私は次回のコラムについて、何もお話しないことを決意しました。 それは、トピックについてここで意見を述べると、 お話しないことがわかってしまうように思えるためです。 そこで、今月は DirectX についてはお話しません。 (次回、そのことについてお話することを約束していないことは明らかであることに注意してください。)

お話を始める前に、 先月のコラムについて簡単な説明を追加しておきます。 私は私の単体テストの必要性に応じて NUnit を使用しましたが、 単体テストの必要性によっては csunit や .NETUnit を使用することもできます。 詳細については、C# のツールのページ (英語) を参照してください。

C# プログラミング管理チームは、 自分達の活動のスケジュールを設定するために、 最近、Microsoft Outlook® の予定表を使い始めました。 この予定表を使えば、 次回のチャットがいつに予定されているか、チームのメンバがいつ休暇をとるか、 今後カンファレンスがいつごろ予定されているかなどをチームの全員が知ることができるようになります。 この予定表は短期間の予定を表示する場合はかなりうまく機能しますが、 今後数か月に何が起こるかを見ようとするときは、物足りません。 そのようなビューを提供するユーティリティを探しましたが、 何も見つかりませんでした。

そこで、MSDN のほこりを払い、再び使用することになりました。 私はかつて電子メールにアクセスするコードを作成したことがあったので、 これはかなり簡単に作成できるように思えました。 しかし、予定表のグリッドを作成する方法と、それを印刷する方法が必要になりました。 グリッドを表示するのは簡単ですが、 複数ページに渡って印刷しようとすることはあまり面白いことのようには思えませんでした。 そこで、四角形のグリッドを印刷でき、 複数のページにまたがって印刷する方法がわかっているものを探し始めました。 私には Microsoft Excel が適切であるように思えました。

C# から Outlook や Excel にアクセスするには、COM 相互運用機能を使用する必要があります。 COM 相互運用機能を使用するには、 C# 側から参照するために相互運用アセンブリが必要になります。 C# プロジェクトから適切な COM コンポーネントを参照するか、 Microsoft Office のすべての製品向けの相互運用アセンブリをダウンロードすることによって、 自分自身で生成できます。

アセンブリを GAC にインストールする必要がある場合は、 未署名のアセンブリを参照できないので、 作成するアセンブリが Office を使用する場合は、 署名付きアセンブリをダウンロード (英語) する必要があります。 アセンブリをダウンロードした後は、 署名付きアセンブリへの参照を追加する必要があります。 では、まず Excel でワークシートを作成し、セルを設定することから始めましょう。

Excel との対話

プロジェクトを作成後、 プロジェクトの [参照] ノードに移動し、PIA が存在するディレクトリを参照して、 Excel 用に参照を追加します。

これで Excel を使って作業を開始する準備ができましたが、 作業を行うために、Excel オブジェクト モデルを理解することにします。 残念ながら、適切な情報を見つけることは困難なので、 2 つの技法を使うことにしました。

最初の技法は、 相互運用アセンブリ内でどのオブジェクトを利用できるかを調べるために、 オブジェクト ブラウザを使用することです。 オブジェクト ブラウザは、どのメソッドやプロパティを使用できるかについて適切な考え方を提供してくれます。

2 つ目の技法は、 行いたいことを Excel でマクロに記録することです。 その後、この VBA コードを作成する C# コードの参考にします。 一般的にこれを行うことは容易ですが、 ここで説明しようとしていることについては、 C# と VBA ではやや見え方に違いがあります。

Excel マクロ

Excel で行いたいことを実行する方法を調べるための "調査用" アプリケーションを作成します。 はじめに、Excel を起動して表示し、新しいワークシートを作成します。 セルの 1 つに値を格納し、そのセルの背景色を設定します。

作業を行う前に、Excel のオブジェクト モデルについて少し説明しておく必要があります。 Excel はオブジェクト モデルを公開した最初の Microsoft アプリケーションでした。 その後、現在私たちが受け入れる必要のあるいくつかの選択が行われました。 つまり、場合によっては使用がやや困難になることがあります。 ここでは、必要に応じてその点を指摘していくことにしましょう。

Excel を開始することは、それを表示することと同様に簡単です。


using Microsoft.Office.Interop.Excel;
using ExcelApplication = Microsoft.Office.Interop.Excel.Application;

ExcelApplication excel = new ExcelApplication();
excel.Visible = true;

最初の using ステートメントは Excel オブジェクトとメソッドを参照しますが、 Windows フォーム アプリケーション内でこれを使用するときに、 Excel と Windows フォームの両方が Application オブジェクトを持つことがわかりました。 そこで、ここでは完全修飾名を使用するのではなく、 Excel の Application オブジェクトのエイリアスを定義しました。 2 番目の using ステートメントで、 ExcelApplication と Excel の Application オブジェクトを等価なものにしているので、 その後は完全修飾名の代わりにこれを使用できます。

行いたいことを Excel マクロに記録しました。以下に記録したマクロを示します。


    Workbooks.Add
    Range("C6").Select
    ActiveCell.FormulaR1C1 = "Hello"
    Range("C6").Select
    With Selection.Interior
        .ColorIndex = 6
        .Pattern = xlSolid
    End With

これは、C# とはまったく似ていません。 Excel マクロでは、ある想定される値や結果が存在するので、 いくつか変換を行う必要があります。 たとえば、次のコードは、


    Workbooks.Add

次のように変換します。


    Workbook workbook = excel.Workbooks.Add(Missing.Value);

では、どのようにしてこの変換を考え出したのでしょう。 まず、Application オブジェクトを調べることから始めました。 このオブジェクトには Workbooks という名前のプロパティがあることがわかりました。 (驚くことではありませんが) このプロパティは Workbooks オブジェクトを返します。 したがって、VBA コードでは "excel." が想定されていることになります。 ここで、"Workbooks.Add(" と入力してみると、 IntelliSense® が Add メソッドが 1 つのパラメータとして名前付きテンプレートを受け取ることを教えてくれました。

しかし、VBA コードにはパラメータがありません。 したがって、このパラメータが省略可能であることを適切に示しています。 使用しているラッパー クラスでは、この関数の 1 つのバージョンしか定義していないので、 "既定値を使用する" ことを意味する値を渡す必要があります。 その値が "Missing.Value" で、System.Reflection 名前空間にあります。

次の手順は、セル "C6" に値を設定する処理を扱います。 VBA コードの "Workbooks" 部分は、 C# コードでは "excel.Workbooks" を意味するので、 範囲を取得するために "excel.Range" を試すことができます。 残念ながら、この試みは失敗します。

結局、Excel VBA プログラミングでは、記述している内容によって、複数の想定済みプレフィックスがあることがわかりました。 "Range" を使用する場合は、 "excel.ActiveSheet.Range" と等価なものを使用していることになります。 そこで次のように記述します。


excel.ActiveSheet.Range("C4").Select();

少なくとも、ここではこのような記述を試みましたが、コンパイルされないことがわかりました。 結局、excel.ActiveSheet が object 型であることがわかりました。 なぜ、そうなっているのか、はっきりとはわかりません。 それがワークシートまたはその他の何らかのオブジェクトになり得るからかもしれません。 あるいは、元々そのように型が設定されていただけかもしれません。

そこで、以下のようにしました。


((Worksheet) excel.ActiveSheet).Range("C4").Select();

前よりはよくなりました。 しかし、Worksheet クラスには Range 関数がありません。 VBA 環境では Range はプロパティです。 しかし、C# では単に 2 つのパラメータを受け取る 1 つのメソッドです。 最終的には、次のようになりました。


((Worksheet) excel.ActiveSheet).get_Range("C4", Missing.Value).Select();
excel.ActiveCell.Value2 = "Hello";

なぜ、"Value2" で、"FormulaR1C1" ではないのでしょう ? ここでは、このことについて考えませんでした。

これを仕上げるには 2 つの方法があります。 1 つは、worksheet オブジェクトを変数に格納することです。 その結果、キャストを避けることができます。 もう 1 つは、セルを選択して、アクティブ セルを使用することではなく、 Range オブジェクトを操作することです。

最後の手順では、ワークシートを保存します。 これを行うには Worksheet.SaveAs() を呼び出します。 Worksheet.SaveAs() は 10 個のパラメータを受け取りますが、 残りのパラメータにはすべて Missing.Value を渡します。 最終的なコードは次のようになります。


    ExcelApplication excel = new ExcelApplication();
    excel.Visible = true;

    excel.Workbooks.Add(Missing.Value);
    Worksheet worksheet = (Worksheet) excel.ActiveSheet;

    Range r = worksheet.get_Range("C6", Missing.Value);
    r.Value2 = "Hello";
    r.Interior.ColorIndex = 6;

    worksheet.SaveAs(@"c:\ExcelExample.xls", 
            Missing.Value, Missing.Value, Missing.Value, Missing.Value, 
            Missing.Value, Missing.Value, Missing.Value, Missing.Value, 
            Missing.Value); 
    excel.Quit();

9 行のコードで、 ワークシートを作成し、何らかの値を設定後、 それを保存して、終了しています。 これで適切に機能します。 このコードは ExcelExample プロジェクトにあります。

電子メールとの対話

Exchange 電子メールにアクセスするには、 Outlook オブジェクト モデル、または CDO (Collaboration Data Objects、以前の MAPI) モデルのいずれかを使用します。 グラフィカルな表示には関心がないので、ここでは CDO を使用します。 CDO は Office の一部ではないので、PIA は存在しません。

新しいプロジェクトを作成し、 COM オブジェクト "Microsoft CDO 1.21 Library" への参照を追加します。 その後、受信トレイ内のメッセージ数を取得するために、以下のようなコードを記述しました。


using MAPI;
using System.Reflection;

         Session session = new Session();
         session.Logon("Default Outlook Profile", 
            Missing.Value,
            Missing.Value,
            Missing.Value,
            Missing.Value,
            Missing.Value,
            Missing.Value
            );

         Folder folder = (Folder) session.Inbox;

         Messages messages = (Messages) folder.Messages;

         int messageCount = (int) messages.Count;

Excel オブジェクト モデルと同様に、MAPI/CDO オブジェクト モデルもやや古いものです。 オブジェクト モデル内のあらゆるものが Object 型に設定されています。 フォルダ内のメッセージ数のようなものまでです。 すべてをキャストしないで使用できるように、 いつものように、MAPI オブジェクトのラッパー オブジェクトを記述しました。 foreach を使って繰り返し処理できるように、 フォルダと Messages コレクションに 2 つのラッパーを作成しました。

これらを適切に行い、受信トレイ内のすべてのメッセージを見るためのコードを以下のように作成できます。


         MapiFolder inbox = new MapiFolder(session.Inbox);

         int size = 0;
         int count = 0;
         foreach (MAPI.Message message in inbox.Messages)
         {
            size += (int) message.Size;
            count++;
         }

これを実行すると、私の Exchange 受信トレイには 2982 通のメッセージがあり、 33 MB を少し超える領域を使用していることがわかります。

すべてのフォルダの内容を見る場合は、次のように再帰関数を作成できます。


      public int TraverseFolder(MapiFolder folder)
      {
         int size = 0;

         foreach (MapiFolder subFolder in folder)
         {
            size += TraverseFolder(subFolder);
         }

         foreach (MAPI.Message message in folder.Messages)
         {
            size += (int) message.Size;
         }
         return size;
      }

これを実行すると、1 分ちょっとで、受信トレイ ツリー全体で約 88 MB の領域を占有していることを知らせてくれました。 何らかの整理が必要だと思いました。

予定

本来、MAPI は単にメッセージを扱います。 他の種類のアイテムが追加されているときは、問題が生じます。 Message アイテムを取得するために使用していたコードを、 まったく突然に Appointment アイテムを取得するようにすると、 コードは機能しなくなりました。 そこで、メールボックスまで移動し、[予定表] サブフォルダが見つかれば、 メッセージだけでなく、予定も含まれるフォルダを取得することになるでしょう。 予定の件名の取得を考えればこれで大丈夫ですが、 開始日付や終了日付を取得する場合は、うまくいきません。

これを回避するために、 MAPI には GetDefaultFolder() という名前の新しい関数が追加されました。 この関数を呼び出して、実際には Messages のコレクションではなく、 AppointmentItems のコレクションが必要であることを指定できます。 したがって、以下のように記述できます。


      public void TraverseCalendar(Session session)
      {
         Folder calendar =
            (Folder) session.GetDefaultFolder(
ActMsgDefaultFolderTypes.ActMsgDefaultFolderCalendar);

         Messages messages = (Messages)
            calendar.Messages;
         
         AppointmentItem message = 
  (AppointmentItem) messages.GetFirst(Missing.Value);
         while (message != null)
         {
            string subject = (string) message.Subject;
            message = (AppointmentItem) messages.GetNext();
         }
      } 

Appointments のコレクション用のラッパーは作成しなかったので、 ここではラッパーを使用しないで記述しています。

このコードは 1 つの欠点を除けば、まったく適切に機能します。 このコードでは、私のメールボックスの既定のフォルダだけを取得でき、 他人のメールボックスの既定のフォルダは取得できません。 思い出してください。 このコラムの目的は他人のメールボックスの予定を見ることでした。 これを回避しては役に立ちません。

そこで、Google に戻ってさらに調査を進めることにしました。 結局、メッセージ内の特定のアイテム以外に、 この種のフィールドをすべて含み、数値で格納された Fields アイテムがあることがわかりました。 したがって、適切な数値がわかれば、特定のフィールドの値を取得できます。

最終的に作成したコードは以下のようになります。


         InfoStore infoStore =
            FindInfoStore(session, mailbox);

         MapiFolder rootFolder = 
            new MapiFolder((Folder) infoStore.RootFolder);
         MapiFolder calendar = rootFolder.FindSubFolder("Calendar");

         DateTime graphEndDate = 
            graphStartDate + new TimeSpan(days, 0, 0, 0);
         foreach (MAPI.Message message in calendar.Messages)
         {
            DateTime startDate = (DateTime) 
               GetFieldValue(message, 6291520);
            DateTime endDate = (DateTime)
               GetFieldValue(message, 6357056);

            if (endDate < graphStartDate)
               continue;

            if (startDate > graphEndDate)
               continue;

            if (startDate < graphStartDate)
            {
               startDate = graphStartDate;
            }

            if (endDate > graphEndDate)
            {
               endDate = graphEndDate;
            }

            int labelIndex = 0;
            try
            {
               labelIndex = (int) GetFieldValue(message, -2093678589);
            }
            catch (Exception e)
            {
               string s = e.Message;
            }

            Appointment appointment = 
               new Appointment((string) message.Subject, 
               labelIndex,
               startDate,
               endDate);
            appointments.Add(appointment);
         }

GetFieldValue() は、メッセージのすべてのフィールドを調べ、 特定の番号が付けられたフィールドを検索します。 これらの定数を適切な名前を持つ静的な定数に取り出すことをお勧めします。

少し見苦しいですが、機能します。 残念ながら、繰り返しの予定を処理する方法はまだ考えていません。 これには 2 つの可能性のある選択肢があります。

  1. ここで使用したのと同じアプローチを試み、 繰り返しを格納したオブジェクトをデコードします。
  2. CDO の使用を止め、WebDAV など、Exchange と対話するためのその他のアプローチの 1 つを使用します。

すべてをまとめる

Excel と Exchange の両方と対話できるようになった後に、 実際のアプリケーションの作成を開始できます。 興味深い部分は、グリッドに予定をレイアウトする方法を考え出すことでした。 これはかなり複雑なコードになりました。 そこで、ガイドになるように単体テストも作成しました。

単体テストを作成するには、 テストする対象が必要でした。 実際に使用している予定表をテストすることは、 予定が変化するので、あまり適切ではありません。 そこで、ICalendar インターフェイスで予定表の操作を抽象化し、 そのインターフェイスを実装する 2 つのクラスを作成しました。 最初の 1 つは、CDO を使用する実際のクラスで、 2 つ目は、単なるテストの目的でオブジェクトを作成できる模擬バージョンです。

これにより、レイアウトを行うコードをテストする単体テストを作成でき、 さらに、Excel 内部で実際のレイアウトを実行できます。

また、Excel オブジェクトに対して同様のインターフェイスと模擬オブジェクトを作成しましたが、 Excel で適切な結果が作成されたことは "手作業で確認する" ことを選択しました。

Eric Gunnerson は Visual C# チームのプログラム マネージャで、以前は C# 言語デザイン チームのメンバーでした。 そして『A Programmer's Introduction to C#, 2nd Edition』の著者でもあります。 彼は、8 インチのフロッピー ディスクが何であるかを知っているぐらい昔からプログラミングを行っています。 さらに、かつては簡単にテープを装着できました。 暇な時間は、積荷の無い燕の対気速度にの研究に取り組んでいます。