記事
· 2021年4月22日 11m read

ObjectScript の信頼性の高いエラー処理機能とクリーンアップ機能

はじめに (および本記事を書いた動機)

ObjectScript コードのユニット (ClassMethod など) を実行する場合、そのスコープ外にあるシステムの諸部分と対話するときに適切なクリーンアップを行えないことが原因で、様々な予期せぬ副作用が発生することがあります。 以下にその一部を紹介します。

  • トランザクション
  • ロック
  • I/O デバイス
  • SQL のカーソル
  • システムフラグと設定
  • $Namespace
  • 一時ファイル

ObjectScript のこういった重要な機能を、クリーンアップのコーディングや防御的なコーディングを適切に行わずに使用すると、普段は正常に動作しても、予期せぬかたちで、またデバッグが困難なかたちで失敗し得るアプリケーションができてしまう可能性があります。 想定できるすべてのエラーケースにおいてクリーンアップコードが正常に動作することは、極めて重要です。表面的なテストではエラーを見落とす可能性が高いことを考えるとなおさらです。 この記事では、既知の落とし穴をいくつかご紹介し、信頼性の高いエラー処理とクリーンアップを実現するための 2 種類の対処法について説明いたします。

確実にすべてのエッジケースをテストしたい方は、 私が Open Exchange に掲載している Test Coverage Tool をご覧ください!

注記: この記事は私が元々 2018年 6月 に InterSystems 社内で掲載したものです。 開発者コミュニティに投稿しようと思って To-Do リストに加えてから、もう 1 年半になります。 ありきたりな言い訳で恐縮です。。。

避けたい落とし穴

トランザクション

トランザクションを自然にかつシンプルに処理する方法として、以下のように try/catch ブロックを使い、catch の中で TRollback を使います。

Try {
    TSTART
    // ... データを処理するコード ...
    TCOMMIT
} Catch e {
    TROLLBACK
    // e.AsStatus(), e.Log(), etc.
}

ここだけを見た場合、エラーが発生したときに TStart と TCommit の間のコードが早々と Quit を出すのではなく、エラーを投げてくれることを考えると、このコードに問題はありません。 しかし、このコードにはリスクがあります。理由は以下の 2 つです。

  • もし他のデベロッパーが try ブロックに「Quit」を追加した場合、このトランザクションは開いた状態で放置される。 そのような変更内容は、コードレビューの際につい見落としてしまうことがあるでしょう。現在のコンテキストにトランザクションが含まれていることが明らかでない場合は一層見落としやすくなります。
  • このブロックのメソッドが外部のトランザクションの中から呼び出されることがあれば、トランザクションのすべてのレベルが TRollback によりロールバックされてしまう。

より好適なアプローチとしては、トランザクションのレベルをメソッドの初めから追跡し、最後にトランザクションのそのレベルにロールバックします。 下の例をご覧ください。

Set tInitTLevel = $TLevel
Try {
    TSTART
    // ... データを処理するコード...
    // tStatus は例外として投げる必要がないので、次のコードはこれで問題ありません。
    If $$$ISERR(tStatus) {
        Quit
    }
    // ... データを処理する他のコード...
    TCOMMIT
} Catch e {
    // e.AsStatus(), e.Log(), etc.
}
While $TLevel > tInitTLevel {
    // トランザクションのレベルを一度に一つずつロールバックします。
    TROLLBACK 1
}

ロック

インクリメントロックを使用するコードでは、ロックが必要でなくなったら、クリーンアップコードの実行時にロックをデクリメントする必要があります。これをしないと、そのようなロックはプロセスが終了するまで保持されることになります。 ロックがメソッドの外にリークすることはありませんが、そのようなロックを取得することがメソッドの副作用として確認されている場合は除きます。

I/O デバイス {#RobustErrorHandlingandCleanupinObjectScript-I/ODevices}

同じように、現在の I/O デバイス (特殊変数 $io) の変更もメソッドの外にリークすることはありませんが、メソッドが現在のデバイスを変更することを目的としている場合は除きます (I/O リダイレクトを有効にするなど)。 ファイルを操作するときは、OPEN / USE / READ / CLOSE を使うシーケンシャルファイルのダイレクト I/O よりも、%Stream パッケージを使用することをおすすめします。 これ以外の場合で、I / O デバイスを使用する必要があるときは、メソッドの終わりにデバイスを元のデバイスに戻すよう注意が必要です。 例えば、次のコードにはリスクがあります。

Method ReadFromDevice(pSomeOtherDevice As %String)
{
    Open pSomeOtherDevice:10
    Use pSomeOtherDevice
    Read x
    // ... x を使って複雑なことを実行します...
    Close pSomeOtherDevice
}

pSomeOtherDevice がクローズする前に例外が投げられることがあれば、$io は pSomeOtherDevice のままとなります。これにより、カスケードエラーが発生する可能性があります。 また、デバイスがクローズされると、$io はプロセスのデフォルトデバイスにリセットされますが、メソッドが呼び出された前の同じデバイスにはリセットされない場合があります。

SQL のカーソル

カーソルベースの SQL を使用する場合にエラーが発生したら、カーソルをクローズする必要があります。 カーソルをクローズしなければ、リソースがリークする可能性があります (ドキュメントにその旨の記載あり)。 また、コードを再度実行して、カーソルをオープンしようとすると、「already open」(既にオープン状態) エラー (SQLCODE -101) が発生する場合もあります。

システムフラグと設定

アプリケーションコードがプロセスレベルのフラグやシステムレベルのフラグを変更する必要があるということは滅多にありません。例えば、これらの多くは %SYSTEM.Process と %SYSTEM.SQL に定義されています。 その必要があるという場合は、初期値を保存してから、メソッドの終わりに再度保存しなおす必要があるので注意が必要です。

$Namespace {#RobustErrorHandlingandCleanupinObjectScript-$Namespace}

ネームスペースの変更がメソッドのスコープ外にリークするのを防ぐために、ネームスペースを変更するコードは、常に新しい $Namespace を最初に記述する必要があります。

一時ファイル

%Library.File:TempFilename などを使って一時ファイルを作成するアプリケーションコードでは、その一時ファイルが不要になれば、その時点で削除するよう心掛ける必要があります (ちなみに、%Library.File:TempFilename0> を使うと、主に InterSystems IRIS では、実際にファイルが作成されます)。

お薦めの対処法: Try-Catch (-Finally) {#RobustErrorHandlingandCleanupinObjectScript-RecommendedPattern:Try-Catch(-Finally)}

多くのプログラミング言語には、try/catch 構造に「finally」ブロックを追加できるという機能が備わっています。「finally」ブロックは、try/catch 文が完了した後に、例外が発生したかどうかに関係なく実行されます。 ObjectScript にこの機能はありませんが、似たようなことができます。 その一般的なパターンは、以下のとおりです。上述した潜在的な多くの問題をご確認いただけます。

ClassMethod MyRobustMethod(pFile As %String = "C:\foo\bar.txt") As %Status
{
    Set tSC = $$$OK
    Set tInitialTLevel = $TLevel
    Set tMyGlobalLocked = 0
    Set tDevice = $io
    Set tFileOpen = 0
    Set tCursorOpen = 0
    Set tOldSystemFlagValue = ""
 
    Try {
        // グローバルをロックする。但し、5 秒以内にロックを取得できることが条件。
        Lock +^MyGlobal(42):5
        If '$Test {
            $$$ThrowStatus($$$ERROR($$$GeneralError,"Couldn't lock ^MyGlobal(42)."))
        }
        Set tMyGlobalLocked = 1
         
        // ファイルを開く
        Open pFile:"WNS":10
        If '$Test {
            $$$ThrowStatus($$$ERROR($$$GeneralError,"Couldn't open file "_pFile))
        }
        Set tFileOpen = 1
         
        // [ カーソル MyCursor を宣言 ]
        &;SQL(OPEN MyCursor)
        Set tCursorOpen = 1
         
        // このプロセスにシステムフラグを設定する。
        Set tOldSystemFlagValue = $System.Process.SetZEOF(1)
         
        // 重要なアクションを実行...
        Use tFile
        
        TSTART
        
        // [ ... ここでデータを変更する重要で複雑なコードをたくさん実行... ]
         
        // すべて完了!
         
        TCOMMIT
    } Catch e {
        Set tSC = e.AsStatus()
    }
     
    // Finally {
 
    // クリーンアップ: システムフラグ
    If (tOldSystemFlagValue '= "") {
        Do $System.Process.SetZEOF(tOldSystemFlagValue)
    }
     
    // クリーンアップ: デバイス
    If tFileOpen {
        Close pFile
        // pFile が現在のデバイスなら、CLOSE コマンドは $io をプロセスのデフォルトのデバイスに戻す
        // メソッドが呼び出されたときの $io の値とは違う可能性あり。
        // 念のために:
        Use tDevice
    }
     
    // クリーンアップ: ロック
    If tMyGlobalLocked {
        Lock -^MyGlobal(42)
    }
     
    // クリーンアップ: トランザクション
    // トランザクションのレベルを最初のレベルまで一つずつロールバックする。
    While $TLevel > tInitialTLevel {
        TROLLBACK 1
    }
     
    // } // "finally" 終了
    Quit tSC
}

注意: このアプローチでは、try ... ブロックで「Return」の変わりに「Quit」を使用することが極めて重要です。「Return」だとクリーンアップをバイパスしてしまいます。

お薦めの対処法: Registered Objects および Destructors

クリーンアップコードは複雑になる場合があります。 そのような場合は、クリーンアップコードを Registered Object の中でカプセル化し、その再利用を促進することが理に適っているかもしれません。 システムの状態は、オブジェクトが初期化されたときや、システムの状態を変化させる (オブジェクトの) メソッドが呼び出された時点から追跡され、そのオブジェクトがスコープから外れたときに、元の値に戻されます。 では、以下のシンプルな例について考えます。ここでは、トランザクション、現在のネームスペース、$System.Process.SetZEOF の状態が管理されています。

/// このクラスのインスタンスがスコープから外れると、そのインスタンスが作成された時点で存在していたネームスペース、トランザクションのレベル、および $System.Process.SetZEOF() の値が復元されます。
Class DC.Demo.ScopeManager Extends %RegisteredObject
{
 
Property InitialNamespace As %String [ InitialExpression = {$Namespace} ];
 
Property InitialTransactionLevel As %String [ InitialExpression = {$TLevel} ];
 
 
Property ZEOFSetting As %Boolean [ InitialExpression = {$System.Process.SetZEOF()} ];
 
 
Method SetZEOF(pValue As %Boolean)
{
    Set ..ZEOFSetting = $System.Process.SetZEOF(.pValue)
}
 
Method %OnClose() As %Status [ Private, ServerOnly = 1 ]
{
    Set tSC = $$$OK
     
    Try {
        Set $Namespace = ..InitialNamespace
    } Catch e {
        Set tSC = $$$ADDSC(tSC,e.AsStatus())
    }
     
    Try {
        Do $System.Process.SetZEOF(..ZEOFSetting)
    } Catch e {
        Set tSC = $$$ADDSC(tSC,e.AsStatus())
    }
     
    Try {
        While $TLevel > ..InitialTransactionLevel {
            TROLLBACK 1
        }
    } Catch e {
        Set tSC = $$$ADDSC(tSC,e.AsStatus())
    }
     
    Quit tSC
}
 
}

以下のクラスは、今お見せした登録クラスを使って、メソッドの最後で実行するクリーンアップを簡素化する方法を示すものです。

Class DC.Demo.Driver
{
 
ClassMethod Run()
{
    For tArgument = "good","bad" {
        Do ..LogState(tArgument,"before")
        Do ..DemoRobustMethod(tArgument)
        Do ..LogState(tArgument,"after")
    }
}
 
ClassMethod LogState(pArgument As %String, pWhen As %String)
{
    Write !,pWhen," calling DemoRobustMethod("_$$$QUOTE(pArgument)_"):"
    Write !,$c(9),"$Namespace=",$Namespace
    Write !,$c(9),"$TLevel=",$TLevel
    Write !,$c(9),"$System.Process.SetZEOF()=",$System.Process.SetZEOF()
}
 
ClassMethod DemoRobustMethod(pArgument As %String)
{
    Set tScopeManager = ##class(DC.Demo.ScopeManager).%New()
     
    Set $Namespace = "%SYS"
    TSTART
    Do tScopeManager.SetZEOF(1)
    If (pArgument = "bad") {
        // 通常ならこれは大問題となるところですが、 tScopeManager があるので大丈夫です。
        Quit
    }
    TCOMMIT
}

}
ディスカッション (0)2
続けるにはログインするか新規登録を行ってください