検索

クリアフィルター
記事
Toshihiko Minamoto · 2022年11月17日

Angular 14 の新機能

こんにちは! Sergei Sakisian と申します。InterSystems で 7 年以上、Angular フロントエンドを作成しています。 Angular は非常に人気のあるフレームワークであるため、開発者、お客様、そしてパートナーの皆さんは、アプリケーションのスタックの 1 つとして Angular を選択することがよくあります。 概念、ハウツー、ベストプラクティス、高度なトピックなど、Angular のさまざまな側面を網羅する記事の連載を始めたいと思います。 この連載は、すでに Angular に精通しており、基本概念の説明がいらない方が対象となります。 連載記事のロードマップを作成しているところであるため、まずは、一番新しい Angular リリースの重要な機能をいくつか紹介することから始めることにします。 ## 厳格な型指定のフォーム これはおそらく、過去 2 年間で最も要望の多かった Angular 機能です。 Angular 14 では、Angular リアクティブフォームを使って、TypeScript のすべての厳格な型チェック機能を使用できるようになりました。 FormControl クラスはジェネリクスになったため、それが保持する値の型を取ることができます。 ```typescript /* Angular 14 より前*/ const untypedControl = new FormControl(true); untypedControl.setValue(100); // 値を設定、エラーなし // 現在 const strictlyTypedControl = new FormControl(true); strictlyTypedControl.setValue(100); // ここで型チェックエラーメッセージが表示されます // Angular 14 const strictlyTypedControl = new FormControl(true); strictlyTypedControl.setValue(100); // ここで型チェックエラーメッセージが表示されます ``` ご覧のとおり、最初の最後の例はほぼ同じですが、結果が異なります。 これは、Angular 14 では、新しい FormControl クラスが、開発者が指定した初期値から型を推論しているためです。 したがって、`true` が指定された場合、Angular はこの FormControl の型を `boolean | null` に設定します。 `.reset()` メソッドには、値が指定されていない場合に値を null にする Nullable 値が必要です。 以前の型なしの FormControl クラスは、`UntypedFormControl` に変換されています(`UntypedFormGroup`、`UntypedFormArray`、および `UntypedFormBuilder` についても同様)が、実質的に `FormControl` のエイリアスです。 以前のバージョンの Angular からアップグレードしている場合、`FormControl` クラスのすべてのメンションは、Angular CLI によって `UntypedFormControl` クラスに置き換えられます。 Untyped* のクラスは、以下のような特定の目的に使用されます。 1. アプリを、以前のバージョンから移行される前とまったく同じように動作させる(新しい FormControl は、初期値から型を推論することに注意してください)。 2. すべての `FormControl` を意図的に使用する。 そのため、すべての UntypedFormControl を手動で `FormControl` に変更する必要があります。 3. 開発者にもっと自由度を与える(これについては、後の方で説明します)。 初期値が `null` である場合、FormControl の型を明示的に指定する必要があることに注意してください。 また、TypeScript には、初期値が `false` の場合に同じことを行う必要のあるバグが存在します。 フォームの Group については、インターフェースを定義することも可能です。このインターフェースを FormGroup の型として渡すだけです。 この場合、TypeScript は FormGroup 内のすべての型を推論します。 ```typescript interface LoginForm { email: FormControl; password?: FormControl; } const login = new FormGroup({ email: new FormControl('', {nonNullable: true}), password: new FormControl('', {nonNullable: true}), }); ``` 手動で FormGroup を作成した上記の例のように、FormBuilder の `.group()` メソッドに、事前に定義されたインターフェースを受け入れられるジェネリクス属性が追加されました。 ```typescript interface LoginForm { email: FormControl; password?: FormControl; } const fb = new FormBuilder(); const login = fb.group({ email: '', password: '', }); ``` このインターフェースにはプリミティブな非 nullable 型しかないため、新しい `nonNullable` FormBuilder プロパティ(`NonNullableFormBuilder` クラスインスタンスを含み、直接作成することも可能)を使って以下のように単純化できます。 ```typescript const fb = new FormBuilder(); const login = fb.nonNullable.group({ email: '', password: '', }); ``` ❗ 非 nullable 型の FormBuilder を使用する場合、または FormControl に非 nullable 型のオプションを設定する場合、`.reset()` メソッドを呼び出す際に、リセット値として初期の FormControl 値が使用されることに注意してください。 また、`this.form.value` のすべてのプロパティがオプションとしてマークされることに注意することも非常に重要です。 以下に例を示します。 ```typescript const fb = new FormBuilder(); const login = fb.nonNullable.group({ email: '', password: '', }); // login.value // { // email?: string; // password?: string; // } ``` これは、FormGroup 内のいずれかの FormControl を無効にする際に、この FormControl の値が `form.value` から削除されるために発生します。 ```typescript const fb = new FormBuilder(); const login = fb.nonNullable.group({ email: '', password: '', }); login.get('email').disable(); console.log(login.value); // { // password: '' // } ``` フォームオブジェクト全体を取得するには、`.getRawValue()` メソッドを使用する必要があります。 ```typescript const fb = new FormBuilder(); const login = fb.nonNullable.group({ email: '', password: '', }); login.get('email').disable(); console.log(login.getRawValue()); // { // email: '', // password: '' // } ``` 厳格に型付けされたフォームのメリット: 1. FormControl / FormGroup の値を返すすべてのプロパティとメソッドが厳格に型付けされるようになった。 例: `value`、`getRawValue()`、`valueChanges` 2. FormControl 値を変更するすべてのメソッドが型安全になった。`setValue()`、`patchValue()`、`updateValue()` 3. FormControl が厳格に型付けされた。 このことは、FormGroup の `.get()` メソッドにも適用されます。 これにより、コンパイル時に存在しない FormControl へのアクセスも防止されます。 ### 新しい FormRecord クラス 新しい `FormGroup` クラスの欠点は、その動的な性質が失われたことです。 一度定義されると、オンザフライで FormControl を追加または削除することはできません。 この問題を解決するために、Angular は新たに `FormRecord` クラスを追加しました。 `FormRecord` は実質的に `FormGroup` と同じですが、動的であり、そのすべての FormControl に同じ型が使用されます。 ```typescript folders: new FormRecord({ home: new FormControl(true, { nonNullable: true }), music: new FormControl(false, { nonNullable: true }) }); // グループに新しい FormContol を追加する this.foldersForm.get('folders').addControl('videos', new FormControl(false, { nonNullable: true })); // コントロールの型が異なるため、これにより、コンパイルエラーが発生する this.foldersForm.get('folders').addControl('books', new FormControl('Some string', { nonNullable: true })); ``` ご覧のとおり、これには別の制限があります。すべての FormControl は同じ型でなければなりません。 動的と異種の両方を兼ね備えた FormGroup がどうしても必要な場合は、`UntypedFormGroup` クラスを使用してフォームを定義することをお勧めします。 ## モジュールレス(スタンドアロン)コンポーネント これは未だ実験的とされている機能ではありますが、興味深い機能です。 コンポーネント、ディレクティブ、およびパイプをモジュールに含めることなく、これらを定義することができます。 この概念はまだ完全に練られてはいませんが、すでに ngModule を使用せずにアプリケーションをビルドすることができるようになっています。 スタンドアロンコンポーネントを定義するには、Component/Pipe/Directive デコレーターで新しい `standalone` プロパティを使用する必要があります。 ```typescript @Component({ selector: 'app-table', standalone: true, templateUrl: './table.component.html' }) export class TableComponent { } ``` この場合、このコンポーネントはどの ngModule にも宣言されませんが、 ngModule やその他のスタンドアロンコンポーネントにインポートすることは可能です。 各スタンドアロンコンポーネント/パイプ/ディレクティブには、その依存関係を直接デコレーターにインポートするメカニズムが備えられています。 ```typescript @Component({ standalone: true, selector: 'photo-gallery', // 既存のモジュールは直接スタンドアロンコンポーネントにインポートされる // CommonModuleは、*ngIf などの標準の Angular ディレクティブを使用するために直接インポートされる // 上記に宣言されるスタンドアロンコンポーネントも直接インポートされる imports: [CommonModule, MatButtonModule, TableComponent], template: ` ... Next Page `, }) export class PhotoGalleryComponent { } ``` 前述のとおり、スタンドアロンコンポーネントは、既存の ngModule にインポート可能です。 sharedModule 全体をインポートする必要がなく、本当に必要な物だけをインポートできます。 新しいスタンドアロンコンポーネントを使用し始めるのに適したストラテジーでもあります。 ```typescript @NgModule({ declarations: [AppComponent], imports: [BrowserModule, HttpClientModule, TableComponent], // import our standalone TableComponent bootstrap: [AppComponent] }) export class AppModule {} ``` スタンドアロンコンポーネントは、Angular CLI を使って以下を入力すると作成できます。 ```bash ng g component --standalone user ``` ### モジュールレスアプリケーションをブートストラップ アプリケーションにあるすべての ngModule を排除する場合は、別の方法でアプリをブートストラップする必要があります。 Angular にはこのための新しい関数があり、それを main.ts ファイルで呼び出す必要があります。 ```typescript bootstrapApplication(AppComponent); ``` この関数の 2 つ目のパラメーターを使って、アプリ全体で必要なプロバイダーを定義できます。 通常プロバイダーのほとんどはモジュール内に存在するため、Angular は(現時点では)それに新しい `importProvidersFrom` 抽出関数を使用する必要があります。 ```typescript bootstrapApplication(AppComponent, { providers: [importProvidersFrom(HttpClientModule)] }); ``` ### スタンドアロンコンポーネントの遅延読み込みルート: Angular には、`loadComponent` という新しい遅延読み込みルート関数があります。これは、スタンドアロンコンポーネントを読み込むためだけに存在する関数です。 ```typescript { path: 'home', loadComponent: () => import('./home/home.component').then(m => m.HomeComponent) } ``` `loadChildren` は、ngModule を遅延読み込みできるようにするだけでなく、ルートファイルから直接、子ルートも読み込めるようになっています。 ```typescript { path: 'home', loadChildren: () => import('./home/home.routes').then(c => c.HomeRoutes) } ``` ### 記事の執筆時点におけるいくつかの注意事項 - スタンドアロンコンポーネント機能は、現在も実験的段階にあります。 将来的に、Webpack の代わりに Vite ビルダーに移行し、ツーリングの改善、ビルド時間の高速化、アプリアーキテクチャの強化、テスト方法の改善などを通じて、機能が大幅に改善されるでしょう。 現時点では、こういったものが多数欠けているため、全パッケージを受け取っていません。いずれにせよ、少なくともこの新しい Angular パラダイムを念頭に、アプリを開発し始めることは可能です。 - IDE と Angular ツールはまだ、新しいスタンドアロンエンティティを静的に解析する準備を整えていません。 すべての依存関係を各スタンドアロンエンティティにインポートする必要があるため、何かを見逃した場合、コンパイラーもそれを見逃し、ランタイム時に失敗する可能性があります。 これは今後改善されていきますが、現時点ではインポートの際に開発者側の注意が必要です。 - 現時点では Angular にグローバルインポート機能がないため(Vue などで行われるように)、各スタンドアロンエンティティで、依存関係を確実に 1 つずつインポートする必要があります。 この機能の主な目標は、私が思うところ、ボイラープレートを減らして物事を簡単に実行できるようにすることにあるため、今後のバージョンで解決されることを期待しています。 # 今日は、これで以上です。 それではまた!
記事
Mihoko Iijima · 2023年1月5日

システムモニタを利用して特定の条件に合う時にメッセージログ(コンソールログ)に情報を出力する方法

これは InterSystems FAQ サイトの記事です。 システムモニタの中の「アプリケーションモニタ」を利用することで、ユーザが定義した特定の監視対象に対してチェックを行い特定の条件に合致した場合に通知を行ったり、メッセージログ(コンソールログ)に情報を出力したり、ユーザが定義するアクションを指定できます。 <メモ>アプリケーションモニタはインストールにより準備されますが、ユーザが付属のアプリケーション・モニタ・クラスを有効化するまで動作しないモニタです。付属のアプリケーションモニタには、監査のカウントやイベント詳細を収集するもの、ディスクの容量を監視するものなどが含まれます。 詳細は、以下ドキュメントをご参照ください。【IRIS】アプリケーション・モニタのメトリックアプリケーション・モニタのメトリック 作成手順は以下の通りです。 %SYSネームスペースにアプリケーションモニタ用クラスを作成する 作成した1のクラスを、システムモニタのアプリケーションモニタ有効化メニューで有効化する 収集のインターバルを設定する(秒単位) システムモニタを再起動する 以下例で追加するユーザ定義のアプリケーションモニタクラスは、監査ログに「ログイン失敗」のイベントが記録された場合に、メッセージログ(コンソールログ)にイベント詳細を出力する流れを記述しています。(システムに監査ログに関するアプリケーションモニタクラスが用意されていますが、特定のイベントに対する監視ではないため、ユーザ定義のアプリケーションモニタを用意します。) 具体的な流れは以下の通りです。 1、%SYSネームスペースにアプリケーションモニタ用クラスを作成する %Monitor.Adaptorを継承するクラスを%SYSネームスペースに作成します。継承された GetSample()メソッドが指定の間隔(デフォルト30秒)で動作するため、GetSample()メソッドをオーバーライドし、情報収集と通知の判断、出力の命令を記述します。例の情報収集はSQLを利用していますが、グローバル変数にある情報を利用することもできます。※GetSample()メソッドは、%Statusを戻り値に設定していますので、必ず戻り値に%Statusの情報を返してください。 例では、監査イベントが記録される %SYS.Audit テーブルを利用して、ログイン失敗のイベント(Event='LoginFailure')を抽出しています。(前回調査した時刻よりも新しい時刻で記録されているものを抽出しています) Class MyMetric.CheckTable Extends %Monitor.Adaptor { Property AuditEventTC As %String; Method GetSample() As %Status { #dim ex As %Exception.AbstractException set status=$$$OK try { //前回の検索時刻より後の物だけ(分単位に検査する) /* select EventData,UserName ,{fn TIMESTAMPADD(SQL_TSI_HOUR,9,UTCTimeStamp)} from %SYS.Audit where Event='LoginFailure' And ({fn TIMESTAMPDIFF('SQL_TSI_MINUTE',{fn TIMESTAMPADD(SQL_TSI_HOUR,9,UTCTimeStamp)},?)}<0) */ set sql="select EventData,UserName ,{fn TIMESTAMPADD(SQL_TSI_HOUR,9,UTCTimeStamp)} from %SYS.Audit" set sql=sql_" where Event='LoginFailure' And ({fn TIMESTAMPDIFF('SQL_TSI_MINUTE',{fn TIMESTAMPADD(SQL_TSI_HOUR,9,UTCTimeStamp)},?)}<=0)" //set ^isjdebug("sql")=sql_"-"_..AuditEventTC set stmt=##class(%SQL.Statement).%New() set st=stmt.%Prepare(sql) set rset=stmt.%Execute(..AuditEventTC) //現在のタイムスタンプに再セット set ..AuditEventTC=$ZDATETIME($system.Util.UTCtoLocalWithZTIMEZONE($ZTIMESTAMP),3) //set ^isjdebug("TC")=..AuditEventTC while rset.%Next() { set message=rset.%Get("Username")_"のアクセスでエラーが発生"_$C(13,10) set message=message_rset.%Get("EventData") //set ^isjdebug("msg",$H)=message $$$THROWONERROR(ex,##class(%SYS.System).WriteToConsoleLog(message,1,2)) } } catch ex { set status=ex.AsStatus() } return status } /// 必要に応じてオーバーライドしてください。 /// このメソッドはモニタクラスの初期化処理として実行されます。 /// 例のように、情報の抽出に必要な初期データがある場合この中でセットできます。 /// このメソッドの戻り値も%Statusが定義されています。$$$OKを戻すとモニタクラスが起動し、$$$OK以外を戻すとモニタクラスは動作しません Method Initialize() As %Status { //現在のタイムスタンプを取得($ZTIMESTAMPはUTCタイム) if ..AuditEventTC="" { set ..AuditEventTC=$ZDATETIME($system.Util.UTCtoLocalWithZTIMEZONE($ZTIMESTAMP),3) } Quit $$$OK } } 例の中では、メッセージログ(コンソールログ)に出力するため %SYS.SystemクラスのWriteToConsoleLog()メソッドを使用しています。 <注意> Caché/Ensemble の場合、日本語が含まれるメッセージを出力すると文字化けしますので、英数字のみのメッセージを出力するようにしてください。 IRIS/IRIS for Healthでは、日本語が含まれるメッセージをメッセージログ(messages.log)に出力できますが、メッセージログの中身を参照できる管理ポータルのメニュー(管理ポータル > システムオペレーション > システムログ > メッセージログ)では、化けて見えます。 メッセージログファイル(messages.log)を「BOM付きUTF8」で保存し直すことで、管理ポータルから日本語を参照できるようになります。 また、アプリケーションモニタクラスの中では、定期的に動作するGetSample()メソッドの中で定期実行時に使用する情報をプロパティとして記録しておくことができます。例では、プロパティ AuditEventTC を作成し、GetSample()を実行した時のタイムスタンプを記録しています。(次回起動時に前回のタイムスタンプより新しい情報を抽出するために使用しています。) AuditEventTCプロパティの初期値は、モニタクラスが開始するときに実行される Initialize()メソッドに記載しています。 2、作成した1のクラスを、システムモニタのアプリケーションモニタ有効化メニューで有効化する %SYSネームスペースに接続したターミナルを用意し、%SYSMONMGRルーチンを起動し、1で作成したクラス(MyMetric.CheckTable)を有効化します。 %SYS>do ^%SYSMONMGR  // ルーチンを実行します。 1) Start/Stop System Monitor 2) Set System Monitor Options 3) Configure System Monitor Classes 4) View System Monitor State 5) Manage Application Monitor 6) Manage Health Monitor 7) View System Data 8) Exit Option? 5 // 5を入力 1) Set Sample Interval 2) Manage Monitor Classes 3) Change Default Notification Method 4) Manage Email Options 5) Manage Alerts 6) Exit Option? 2 //2を入力 1) Activate/Deactivate Monitor Class 2) List Monitor Classes 3) Register Monitor System Classes 4) Remove/Purge Monitor Class 5) Set Class Sample Interval 6) Debug Monitor Classes 7) Exit Option? 1 //1を入力 Class? ? // ?を入力 Num MetricsClassName Activated 1) %Monitor.System.HistoryMemory N 2) %Monitor.System.HistoryPerf N 3) %Monitor.System.HistorySys N 4) %Monitor.System.HistoryUser N 5) %Monitor.System.AuditCount N 6) %Monitor.System.AuditEvents N 7) %Monitor.System.Clients N 8) %Monitor.System.Diskspace N 9) %Monitor.System.Freespace N 10) %Monitor.System.Globals N 11) %Monitor.System.Journals N 12) %Monitor.System.License N 13) %Monitor.System.LockTable N 14) %Monitor.System.Processes N 15) %Monitor.System.Routines N 16) %Monitor.System.Servers N 17) %Monitor.System.CSPGateway N 18) MyMetric.CheckTable N Class? 18 MyMetric.CheckTable   //18を入力 Activate class? Yes => Yes   // EnterまたはYesを入力 1) Activate/Deactivate Monitor Class 2) List Monitor Classes 3) Register Monitor System Classes 4) Remove/Purge Monitor Class 5) Set Class Sample Interval 6) Debug Monitor Classes 7) Exit Option? ターミナルはこのままの状態にします。 3、収集のインターバルを設定する(秒単位)(先ほどの手順の続き) 1) Activate/Deactivate Monitor Class 2) List Monitor Classes 3) Register Monitor System Classes 4) Remove/Purge Monitor Class 5) Set Class Sample Interval 6) Debug Monitor Classes 7) Exit Option? 5 // 5を入力 Class? ? Num MetricsClassName Activated 1) %Monitor.System.HistoryMemory N 2) %Monitor.System.HistoryPerf N 3) %Monitor.System.HistorySys N 4) %Monitor.System.HistoryUser N 5) %Monitor.System.AuditCount N 6) %Monitor.System.AuditEvents N 7) %Monitor.System.Clients N 8) %Monitor.System.Diskspace N 9) %Monitor.System.Freespace N 10) %Monitor.System.Globals N 11) %Monitor.System.Journals N 12) %Monitor.System.License N 13) %Monitor.System.LockTable N 14) %Monitor.System.Processes N 15) %Monitor.System.Routines N 16) %Monitor.System.Servers N 17) %Monitor.System.CSPGateway N 18) MyMetric.CheckTable Y Class? 18 MyMetric.CheckTable  // 18を入力 Class Sample Interval? 0 (Enter '-' to reset) => 10  // 10を入力(=10秒間隔で収集します) 1) Activate/Deactivate Monitor Class 2) List Monitor Classes 3) Register Monitor System Classes 4) Remove/Purge Monitor Class 5) Set Class Sample Interval 6) Debug Monitor Classes 7) Exit Option? // Enterを入力 1) Set Sample Interval 2) Manage Monitor Classes 3) Change Default Notification Method 4) Manage Email Options 5) Manage Alerts 6) Exit Option?  // Enterを入力 1) Start/Stop System Monitor 2) Set System Monitor Options 3) Configure System Monitor Classes 4) View System Monitor State 5) Manage Application Monitor 6) Manage Health Monitor 7) View System Data 8) Exit Option? 4、システムモニタを再起動する (先ほどの手順の続き) 1) Start/Stop System Monitor 2) Set System Monitor Options 3) Configure System Monitor Classes 4) View System Monitor State 5) Manage Application Monitor 6) Manage Health Monitor 7) View System Data 8) Exit Option? 1 // 1を入力 1) Start System Monitor 2) Stop System Monitor 3) Exit Option? 2  // 2を入力 Stopping System Monitor... System Monitor stopped 1) Start System Monitor 2) Stop System Monitor 3) Exit Option? 1 // 1を入力 Starting System Monitor... System Monitor started 1) Start System Monitor 2) Stop System Monitor 3) Exit Option?   // Enterを入力 1) Start/Stop System Monitor 2) Set System Monitor Options 3) Configure System Monitor Classes 4) View System Monitor State 5) Manage Application Monitor 6) Manage Health Monitor 7) View System Data 8) Exit Option?   // Enterを入力 %SYS> 以上で手続き終了です。 テストとして、管理ポータルやターミナルにログインする際のユーザ名、パスワードを不正な状態で入力し、ログインを失敗させます(※1)。しばらくたった後で、メッセージログ(コンソールログ)にログイン失敗時のメッセージが記録されていれば、成功です。  メッセージログ(IRIS)は <インストールディレクトリ>\mgr\messages.log コンソールログ(Caché/Ensemble)は <インストールディレクトリ>\mgr\cconsole.log をご参照ください。 メッセージ例 07/13/22-12:11:26:480 (1528) 2 [Utility.Event] abcdのアクセスでエラーが発生 エラーメッセージ: エラー #798: パスワード 認証が失敗しました エラー #838: ユーザ abcd が存在しません CSPアプリケーション /csp/sys $I: |TCP|51774|1912 $P: |TCP|51774|1912 アプリケーションモニタを無効化する場合も、%SYSMONMGRルーチンを使用します(2の手続きとほぼ同様の手順です)。モニタの状態を変えた場合は、必ずシステムモニタを再起動し、設定を反映させてください。 無効化手続きは以下の通りです。(MyMetric.CheckTableクラスを無効化する手続き) %SYS>do ^%SYSMONMGR    // ルーチンを実行します。 1) Start/Stop System Monitor 2) Set System Monitor Options 3) Configure System Monitor Classes 4) View System Monitor State 5) Manage Application Monitor 6) Manage Health Monitor 7) View System Data 8) Exit Option? 5 // 5を入力 1) Set Sample Interval 2) Manage Monitor Classes 3) Change Default Notification Method 4) Manage Email Options 5) Manage Alerts 6) Exit Option? 2 //2を入力 1) Activate/Deactivate Monitor Class 2) List Monitor Classes 3) Register Monitor System Classes 4) Remove/Purge Monitor Class 5) Set Class Sample Interval 6) Debug Monitor Classes 7) Exit Option? 1 //1を入力 Class? ? // ?を入力 Num MetricsClassName Activated 1) %Monitor.System.HistoryMemory N 2) %Monitor.System.HistoryPerf N 3) %Monitor.System.HistorySys N 4) %Monitor.System.HistoryUser N 5) %Monitor.System.AuditCount N 6) %Monitor.System.AuditEvents N 7) %Monitor.System.Clients N 8) %Monitor.System.Diskspace N 9) %Monitor.System.Freespace N 10) %Monitor.System.Globals N 11) %Monitor.System.Journals N 12) %Monitor.System.License N 13) %Monitor.System.LockTable N 14) %Monitor.System.Processes N 15) %Monitor.System.Routines N 16) %Monitor.System.Servers N 17) %Monitor.System.CSPGateway N 18) MyMetric.CheckTable Y Class? 18 MyMetric.CheckTable   //18を入力 Deactivate class? Yes => Yes   // EnterまたはYesを入力 1) Activate/Deactivate Monitor Class 2) List Monitor Classes 3) Register Monitor System Classes 4) Remove/Purge Monitor Class 5) Set Class Sample Interval 6) Debug Monitor Classes 7) Exit Option?   // Enterを入力 1) Set Sample Interval 2) Manage Monitor Classes 3) Change Default Notification Method 4) Manage Email Options 5) Manage Alerts 6) Exit Option?   // Enterを入力 1) Start/Stop System Monitor 2) Set System Monitor Options 3) Configure System Monitor Classes 4) View System Monitor State 5) Manage Application Monitor 6) Manage Health Monitor 7) View System Data 8) Exit Option? 1   //1を入力 1) Start System Monitor 2) Stop System Monitor 3) Exit Option? 2   //2を入力 Stopping System Monitor... System Monitor stopped 1) Start System Monitor 2) Stop System Monitor 3) Exit Option? 1   //1を入力 Starting System Monitor... System Monitor started 1) Start System Monitor 2) Stop System Monitor 3) Exit Option?   // Enterを入力 1) Start/Stop System Monitor 2) Set System Monitor Options 3) Configure System Monitor Classes 4) View System Monitor State 5) Manage Application Monitor 6) Manage Health Monitor 7) View System Data 8) Exit Option?   // Enterを入力 %SYS> 以上で無効化手続きは終了です。 ※1 インストール時に初期セキュリティの設定を「最小」とした場合、管理ポータル/ターミナル/スタジオ起動時、認証なしアクセスでログインします。  テストのため、ターミナルをパスワード認証に変更する方法をご説明します。 管理ポータル > システム管理 > セキュリティ > サービス を開き、Windowsの場合は %Service_Consoleサービス、Windows以外の場合は %Service_Terminal を開きます。 「許可された認証方法」以下にあるチェックボックスの中から、「パスワード」にチェックを追加し、保存ボタンをクリックすると、新しいプロセスからパスワード認証が有効になります。 テストを終え、元に戻す場合は、対象サービス名の設定から「パスワード」のチェックを外し、保存ボタンをクリックするだけで元に戻ります。
記事
Toshihiko Minamoto · 2022年4月14日

CircleCI を使用して IRIS アプリケーションを Azure にデプロイする

IRIS ベースのアプリケーションを GCP Kubernetes で実行する方法については、すでに「InterSystems IRIS ソリューションを CircleCI を使用して GCP Kubernetes Cluster GKE へデプロイする」で検討しました。 また、IRIS ベースのアプリケーションを AWS Kubernetes で実行する方法については、「Amazon EKS を使用したシンプルな IRIS ベースの Web アプリケーションのデプロイ」で確認しました。 そこで今回は、アプリケーションを Azure Kubernetes Service(AKS)にデプロイする方法を説明することにします。 Azure この記事では、Azure の無料サブスクリプションを使用します。 価格の詳細については、Azure の価格表ページをご覧ください。  登録が完了すると、Microsoft Azure ポータルが表示されます。 便利なポータルではありますが、この記事では使用しません。 代わりに、Azure コマンドラインインターフェースをインストールしましょう。 執筆時点での最新バージョンは 2.30.0 です。 $ az version { "azure-cli": "2.30.0", "azure-cli-core": "2.30.0", "azure-cli-telemetry": "1.0.6", "extensions": {} } それでは、Azure にログインしましょう。 $ az login CircleCI パイプライン 強力な CircleCI の CI/CD を使用して、AKS のセットアップと IRIS アプリケーションのインストールを行います。 つまり、GitHub ベースのプロジェクトを使って、コードとしてのインフラストラクチャと共にいくつかのパイプラインファイルを追加し、GitHub に変更をプッシュし直して、その結果を使いやすい CircleCI UI で確認します。 GitHub アカウントを使えば、簡単に CircleCI との統合を作成できます。 詳細については、「Seamless integration with GitHub」の記事をご覧ください。 では、「Amazon EKS を使用したシンプルな IRIS ベースの Web アプリケーションのデプロイ 」で使用したプロジェクトの更新バージョンを使用しましょう。 つまり、secured-rest-api です。 それを開き、Use this Template をクリックして、新しいリポジトリ内にバージョンを作成します。 この記事では、そこに含まれるコードサンプルを参照します。 ローカルにリポジトリを Clone し、以下に示すファイルを含む .circleci/ ディレクトリを作成します。 $ tree .circleci/ .circleci/ ├── config.yml └── continue.yml [ダイナミックコンフィグ](https://circleci.com/docs/ja/2.0/configuration-cookbook/?section=examples-and-guides#dynamic-configuration)と[パスのフィルタリング](https://circleci.com/developer/ja/orbs/orb/circleci/path-filtering)を使用して、変更のあるファイルに応じて全体または一部を実行するようにパイプラインを設定します。 この例では、Terraform コードに変更がある場合にのみ Terraform ジョブを実行します。 最初の [config.yml](https://github.com/myardyas/secured-rest-api/blob/master/.circleci/config.yml) ファイルは単純です。 2 つ目の .circleci/continue.yml を呼び出し、Terraform コードが最新である場合に特定のブール値パラメーターを渡します。   $ cat config.yml version: 2.1 # Enable CircleCI's dynamic configuration feature setup: true # Enable path-based pipeline orbs: path-filtering: circleci/path-filtering@0.1.0 workflows: Generate dynamic configuration: jobs: - path-filtering/filter: name: Check updated files config-path: .circleci/continue.yml base-revision: master mapping: | terraform/.* terraform-job true 2 つ目の [continue.yml](https://github.com/myardyas/secured-rest-api/blob/master/.circleci/continue.yml) ファイルを説明する前に、この secured-rest-app プロジェクトを CircleCI に追加して、.circleci/config.yml の変更を GitHub にプッシュしましょう。 $ git add .circleci/config.yml $ git commit -m "Add circleci config.yml" $ git push そして、[CircleCI Projects ページ](https://app.circleci.com/projects)を開いて、プロジェクトを選択し、Set Up Project をクリックします。 ![](/sites/default/files/inline/images/circleci-add.png)     提示される推奨事項に従って、Setup Workflow を有効にします(詳細は、「[CircleCI のダイナミック コンフィグの使用を開始する](https://circleci.com/docs/ja/2.0/dynamic-config/#getting-started-with-dynamic-config-in-circleci)」をご覧ください。 ![](/sites/default/files/inline/images/circleci-setup.png)   これで、2 つ目の continue.yml ファイルに進む準備が整いました。 このファイルの構造は次のようになっています。 Version: CircleCI パイプラインのバージョンです。 Parameters: Terraform が実行中であるかどうかを決定する変数です。 Orbs: 他の人が作成した再利用可能な構成の一部です。 Executors: 一部のジョブに使用する Asure コマンドラインを含む docker イメージです。 Jobs: 実際のデプロイステップです。 Workflows: Terraform の有無に関係なくパイプラインを実行するためのロジックです。 Jobs セクションには、次のジョブが含まれます。 Build and push Docker image to ACR: このジョブは、az コマンドラインツールがインストールされた docker イメージ内で実行します。 Azure にログインし、イメージをビルドして Azure Container Registry(ACR)にプッシュします。 Terraform: このジョブは、Terraform orb を使用してインフラストラクチャを作成します。 詳細は、以下の Terraform に関するセクションをご覧ください。 Setup packages: このジョブは、IRIS アプリケーションといくつかのサービスアプリケーションをインストールします。 詳細は、以下の「パッケージのセットアップ」セクションをご覧ください。 Terraform インフラストラクチャの作成には、infrastructure as code アプローチを使用して、Terraform の力を利用します。 Terraform は Azure プラグインを使って AKS と対話します。 ラッパーの役割を果たし、リソース作成を単純化する AKS Terraform モジュール を使用すると便利です。 Terraform を使用して AKS リソースを使用する例は、「Creating a Kubernetes Cluster with AKS and Terraform」にあります。 ここでは、Terraform がデモと単純化の目的でですべてのリソースを管理するように、Owner ロールを割り当てます。 アプリケーションとしての Terraform は Service Principal を使用して Azure に接続します。 厳密には、「Create an Azure service principal with the Azure CLI」に説明されているとおりに、Owner ロールを Service Principal に割り当てます。  ローカルマシンでコマンドをいくつか実行してみましょう。 Azure サブスクリプション ID を環境変数に保存します。 $ export AZ_SUBSCRIPTION_ID=$(az account show --query id --output tsv) $ az ad sp create-for-rbac -n "Terraform" --role="Owner" --scopes="/subscriptions/${AZ_SUBSCRIPTION_ID}" … { "appId": "<appId>", "displayName": "<displayName>", "name": "<name>", "password": "<password>", "tenant": "<tenant>" } 後で、Service Principals をリスト表示して Terraform という表示名を探すと、appId と tenantId を見つけ出すことができます。 $ az ad sp list --display-name "Terraform" | jq '.[] | "AppId: \(.appId), TenantId: \(.appOwnerTenantId)"' ただし、この方法ではパスワードが表示されません。 パスワードを忘れた場合には、資格情報をリセットするしかありません。 パイプラインでは、AKS の作成には、一般に公開されている Azure Terraform モジュールと Terraform バージョン 1.0.11 を使用します。 取得した、Terraform が Azure への接続に使用する資格情報を使用して、CircleCI project 設定に環境変数を設定します。 また、DOMAIN_NAME 環境変数も設定します。 このチュートリアルは demo-iris.myardyas.club ドメイン名を使用していますが、実際にはユーザーが登録したドメイン名を使用します。 パイプラインでこの変数を使用して、IRIS アプリケーションへの外部アクセスを有効にします。 CircleCI 変数と az create-for-rbac コマンドのマッピングは次のとおりです。 ARM_CLIENT_ID: appId ARM_CLIENT_SECRET: password ARM_TENANT_ID: tenant ARM_SUBSCRIPTION_ID: Value of environment variable AZ_SUBSCRIPTION_ID DOMAIN_NAME: your domain name ![](/sites/default/files/inline/images/circleci-vars_0.png) [Terraform Remote の状態](https://www.terraform.io/docs/language/state/remote.html)を有効にするには、[Azure Storage の Terraform の状態](https://docs.microsoft.com/ja-jp/azure/developer/terraform/store-state-in-azure-storage?tabs=azure-cli)を使用します。 これを実現するために、ローカルマシンで次のコマンドを実行してみましょう。  $ export RESOURCE_GROUP_NAME=tfstate $ export STORAGE_ACCOUNT_NAME=tfstate14112021 # Must be between 3 and 24 characters in length and use numbers and lower-case letters only $ export CONTAINER_NAME=tfstate   # Create resource group $ az group create --name ${RESOURCE_GROUP_NAME} --location eastus # Create storage account $ az storage account create --resource-group ${RESOURCE_GROUP_NAME} --name ${STORAGE_ACCOUNT_NAME} --sku Standard_LRS --encryption-services blob # Enable versioning. Read more at https://docs.microsoft.com/ja-jp/azure/storage/blobs/versioning-overview $ az storage account blob-service-properties update --account-name ${STORAGE_ACCOUNT_NAME} --enable-versioning true # Create blob container $ az storage container create --name ${CONTAINER_NAME} --account-name ${STORAGE_ACCOUNT_NAME} Terraform ディレクトリに配置した Terraform コードです。 これは、次の 3 つのファイルに分割されています。 provider.tf: Azure プラグインのバージョンと、Terraform 状態を保存するリモートストレージへのパスを設定します。 variables.tf: Terraform モジュールの入力データです。 main.tf: 実際のリソースの作成です。  Azure リソースグループ、パブリック IP、Azure コンテナレジストリなどを作成します。 ネットワーキングと Azure Kubernetes サービスについては、一般に公開されている Terraform モジュールを活用します。 パッケージのセットアップ 新たに作成された AKS クラスタにインストールするものは、[helm](https://github.com/myardyas/secured-rest-api/tree/master/helm) ディレクトリにあります。 説明的な [Helmfile](https://github.com/roboll/helmfile) アプローチを使用することで、アプリケーションとその設定を [helmfile.yaml](https://github.com/myardyas/secured-rest-api/blob/master/helm/helmfile.yaml) ファイルに定義することができます。 セットアップは、単一の helmfile sync コマンドで実行します。 このコマンドによって、IRIS アプリケーションと 2 つの追加アプリケーション、cert-manager、および ingress-nginx がインストールされ、外部からアプリケーションを呼び出せるようになります。 詳細については、GitHub の「releases」セクションをご覧ください。 IRIS アプリケーションは、「CircleCI ビルドで GKE の作成を自動化する」の説明と同じ Helm チャートを使用してインストールします。 単純化するために、deployment を使用します。 つまり、ポッドの再起動時に、データは保持されません。 永続させる場合は、Statefulset またはより優れた Kubernetes IRIS Operator(IKO)を使用することをお勧めします。 IKO デプロイメントの例は、iris-k8s-monitoring リポジトリにあります。 パイプラインの実行 .circleci/、terraform/、および helm/ ディレクトリを追加したら、それらを GitHub にプッシュします。 $ git add . $ git commit -m "Setup everything" $ git push すべて問題がなければ、以下のような CircleCI UI 画面が表示されます。 ![](/sites/default/files/inline/images/circleci-success.png)   ドメインレジストラでの A レコードの設定 後もう一つ残っているのは、Terraform が Azure で作成したパブリック IP とDomain Registrar 近ソースのドメイン名の A レコードでバインディングを作成することです。 クラスタに接続しましょう。 $ az aks get-credentials --resource-group demo --name demo ingress-nginx で公開されるパブリック IP アドレスを定義します。 $ kubectl -n ingress-nginx get service ingress-nginx-controller -ojsonpath='{.spec.loadBalancerIP}' x.x.x.x 次のようにして、この IP をドメインレジストラ([GoDaddy](https://godaddy.com/)、[Route53](https://en.wikipedia.org/wiki/Amazon_Route_53)、[GoogleDomains](https://domains.google/) など)に設定します。 YOUR_DOMAIN_NAME = x.x.x.x ここで、DNS の変更が世界中に伝搬されるまでしばらく待ってから、結果を確認します。 $ dig +short YOUR_DOMAIN_NAME 応答は x.x.x.x となります。 テスト ドメイン名を demo-iris.myardyas.club と仮定して、手動テストを実施します。 [Let's Encrypt のステージング用](https://letsencrypt.org/docs/staging-environment/)証明書を使用しているため、ここでは、証明書の確認は省略しましょう。 本番環境では、[lets-encrypt-production](https://github.com/myardyas/secured-rest-api/blob/master/helm/cert-manager/lets-encrypt-production.yaml) [(こちら)](https://github.com/myardyas/secured-rest-api/blob/master/helm/iris/values.yaml#L30)に置き換える必要があります。 また、メールアドレス([こちら](https://github.com/myardyas/secured-rest-api/blob/master/helm/cert-manager/lets-encrypt-production.yaml#L7))を example@gmail.com ではないものに設定することもお勧めします。 $ curl -sku Bill:ChangeMe https://demo-iris.myardyas.club/crudall/_spec | jq . … 人(ユーザー)を作成する: $ curl -ku John:ChangeMe -XPOST -H "Content-Type: application/json" https://demo-iris.myardyas.club/crud/persons/ -d '{"Name":"John Doe"}' 人が作成されたかどうかを確認する: $ curl -sku Bill:ChangeMe https://demo-iris.myardyas.club/crud/persons/all | jq . [ { "Name": "John Doe" } ... まとめ これで以上です! Terraform と CircleCI ワークフローを使用して、Azure クラウドに Kubernetes クラスタを作成する方法を確認しました。 IRIS インストールでは、最も簡単な Helm チャートを使用しました。 本番環境では、このチャートを拡張し、少なくともデプロイを Statefulset に置き換えるか、IKO を使用することをお勧めします。 作成したリソースが不要になったら、忘れずに削除しましょう。 Azure には、無料利用枠が用意されており、AKS は無料ですが、AKS クラスタの実行用に設計されたリソースは有料です。
記事
Hisa Unoura · 2025年9月4日

ベクトルであそぼう! - マルチモーダルAIモデルとモダリティギャップ

開発者の皆様こんにちは。先日のWebinar「ベクトルであそぼう!」では、以下の内容でデータをベクトル化することの可能性をご紹介しました。 写真から魚の名前をあててみる マルチモーダルモデル CLIP を利用して画像によるテキストの検索 ベクトルを「見える化」する ベクトルを次元削減して 3 次元ベクトルに変換し、可視化 データの集まりを見る K-Means によるデータのクラスタリング 変なデータ (=アノマリ) を見つける K-Means による教師なしアノマリ検知や半教師ありアノマリ検知 一番お伝えしたかったのは 「データをベクトルに変換することで、データ利活用の幅が大きく広がる」 ということです。本記事ではマルチモーダルAIおよびCLIPについておさらいし、Webinarでは時間の都合で触れきれなかったTips - モダリティギャップというマルチモーダルモデル特有の現象についてお伝えします。 なお筆者は AI/機械学習の専門家ではありませんが、機械学習を利用したプロダクト・プロジェクトに携わり親しんでまいりました。ご質問・ご指摘などありましたらお気軽にコメント欄からお願いします。 マルチモーダル AI 近年、AI 分野では マルチモーダル AI が大きな注目を集めています。「モーダル」とはデータの種類のことを指します。 文章や会話などのテキスト 写真やイラストなどの画像 音声や BGM などの音 動画やセンサーデータ 従来は、AI モデルは 単一モーダル(例:画像分類モデルは画像だけ、翻訳モデルはテキストだけ)を対象にしてきました。一方、マルチモーダル AI は 異なるモーダルを同時に理解・処理できる のが特徴です。 例えば: 画像を入力して説明文を生成する(画像 → テキスト) 音声を文字に起こす(音声 → テキスト) テキストで検索して関連する画像を見つける(テキスト → 画像) このように複数のモーダルをまたいで処理できる能力は、検索・レコメンド・対話 AI・医療解析など幅広い応用につながります。また、近年では生成AI(ChatGPT や画像生成モデルなど)もマルチモーダル化が進んでおり、テキストだけでなく画像や音声を統合的に扱えるようになってきました。その背景には、大規模言語モデル(LLM) が持つ強力なテキスト理解・知識表現の能力があり、マルチモーダルAIの発展を支える基盤技術となっています。 マルチモーダルAIモデル - CLIP ウェビナー『IRISのベクトル検索を使ってテキストから画像を検索してみよう』では、マルチモーダル AI モデル CLIP を利用して画像をテキストで検索できること、ウェビナー『ベクトルであそぼう!』では、同じく CLIP を利用し、逆にテキストを画像で検索することにより、魚の画像から魚の名前をあてるデモンストレーションをしました。 IRIS にベクトルを格納することで、大規模なデータセットでも高速にベクトル検索が可能となる実験構成で行いました。 あらためてCLIPについておさらいしますと、CLIP(Contrastive Language–Image Pretraining) は OpenAI が 2021 年に発表した画像と自然言語のマルチモーダルモデルです。画像とテキストのペアを学習することで、画像からテキストを予測したり、画像をテキストで検索するといったことが可能となっています。 CLIPの学習には、インターネットから収集した約 4億組の画像+テキストペア が利用されました。 学習には入力データをベクトルに変換するモデル(エンコーダ)が必要となりますが、CLIPには画像とテキストでそれぞれ異なるエンコーダが利用されます。画像のエンコーダには、CNNやViTといった画像処理のモデルが利用され、テキストのエンコーダにはTransformerが利用されています。TransformerはChatGPTのベースになっているので最近よく耳にする、という方も多いのではないでしょうか。学習方法として「画像とそのキャプションをペアで入力」し、対応するペアの埋め込みを近づけ、他のペアは遠ざけるというコントラスト学習 (Contrastive Learning)が行われました。これにより、画像とテキストの類似度が判断できるようになるというわけです。 [出典:Radford et al., Learning Transferable Visual Models From Natural Language Supervision, ICML 2021] ゼロショット性能 ゼロショット分類とは、機械学習モデルが訓練時に一度も見たことのないクラス(カテゴリ)を分類する能力のことです。この技術は、事前のラベル付けされたデータがなくても、意味的な知識(例えば、視覚的な特徴とラベルの関係)や、事前学習済みモデルの持つ能力を活用して、未知のデータを予測します。マルチモーダルモデルは、従来の単一モーダルのモデルよりもゼロショット分類の性能が高くなっています。画像だけで特徴を得るより、「テキスト」という汎用的な知識表現を組み合わせていることで性能が高くなっているのは興味深いですね。 転移学習性能 「転移学習(Transfer Learning)」とは、あるタスクで学習した知識を別のタスクに再利用する手法です。ゼロからすべてのモデルを学習させるのではなく、大規模データで事前に学習済みのモデルを出発点として活用し、少量のデータだけ追加学習(ファインチューニング)して目的のタスクに適応させます。CLIP はゼロショットだけでなく 転移学習にも優れています。4億組の画像+テキストペアを学習しているため、画像の種類や文脈が非常に幅広いのが特徴です。そのため CLIP の埋め込み(ベクトル表現)を特徴量として利用し、少量のデータでファインチューニングするだけで、従来の 事前学習モデルよりも高い性能を発揮するケースが多く報告されています。こうした性能により、医療、製造、EC、セキュリティなど専門的な分野でも応用が期待されます。 ベクトルの可視化とモダリティギャップ ウェビナーでは、約100種の魚名テキストをCLIPによりベクトル化し、UMAPで次元削減して3次元空間に描画し可視化しました。 クロマグロ、ブリ、カツオといった大きめの回遊魚が近くに配置されたり、ヒラメ、カレイといった似ている魚も近くにいます。淡水魚が集まっていたり、エビ、イカタコはそれぞれ近くに配置されクラスターのようになっています。このように、可視化をすることでモデルがデータをどう解釈しているかを把握することができます。ウェビナーではここまででしたが、同様に画像の特徴ベクトルも可視化していきます。こちらは、先程の魚名テキストと同様、20種ほどの魚の画像をCLIPによりベクトルを取得し、UMAPにより次元削減をし、3次元空間に描画したものです。画像ベクトルを可視化した場合も、テキストと同様に、類似しているものは近く、そうでないものは遠く配置されています。(画像の背景等の影響を受けるため、直感的な想定とは若干異なった配置となっている可能性はあります) では、CLIPが画像とテキストのベクトルを同時に扱えることから、画像の特徴ベクトルとテキストの特徴ベクトルを同じ空間に可視化するとどうなるでしょうか。例えば、サンマの画像はサンマのテキスト、ボタンエビの画像はボタンエビのテキストに近いところに配置されるでしょうか?結果はこのようになりました。 左の方に魚名テキストのベクトルが集まり、右の方に魚の画像ベクトルが集まっているようにみえます。サンマのテキストははサンマの画像に近くに配置されるのでは?と予想したのですが、その通りになりませんね。これは、モダリティギャップといわれ、マルチモーダルを扱う際に汎用的にみられる現象です。同じモーダル同士 - 画像と画像、テキストとテキストの類似度は高く出やすく、異なるモーダル間 - 画像とテキストでは、同じ意味を持っていても数値的な類似度が低めに出ます。これは、画像とテキストの特徴表現がもともと性質の異なる情報であることに起因します。画像はピクセルパターンから抽出された視覚的特徴であり、テキストは単語や文脈から抽出された意味的特徴となります。両者を同じ空間に揃えるよう学習しても、完全には一致せず「ズレ」が残ってしまいます。これがモダリティギャップです。このギャップを補正するような研究がされていて、今後さらに高精度なマルチモーダルモデルが期待されます。 [出典:Zhao et al., Bridging the Modality Gap in Multimodal Contrastive Learning, NeurIPS 2022.] マルチモーダルAIとデータプラットフォーム マルチモーダルAIの活用にはモデルだけでなく、データをどう蓄積・検索・活用するかという基盤が不可欠です。例えば、CLIPのようなモデルは入力をベクトル化して処理しますが、実際にビジネスで利用する場合には: 大規模なベクトルデータを効率的に保存できること 高速なベクトル検索や類似度計算が可能であること 他システムやアプリケーションと柔軟に連携できること といった条件が求められます。 この観点から、IRIS のようなデータプラットフォームを利用することで: テーブルにベクトルを格納し、高速な近似近傍探索(ANN検索)を行える 相互運用性により様々なデータソースを統合しパイプラインを構築できる といった利点があります。つまり、マルチモーダルAIモデルの可能性を現実のプロジェクトに落とし込むには、高性能かつ堅牢なデータプラットフォームが必要不可欠です。といったところで本記事を締めたいと思います。 ご案内 ベクトルであそぼう!は 3回連続シリーズのAI関連セミナーの第2回であり、次回(最終回)のテーマは『RAG+生成AIであそぼう!』 (9月9日開催)です。ぜひご登録ください! 参考文献 Radford et al., Learning Transferable Visual Models From Natural Language Supervision, ICML 2021.https://arxiv.org/pdf/2103.00020 Zhao et al., Bridging the Modality Gap in Multimodal Contrastive Learning, NeurIPS 2022.https://papers.neurips.cc/paper_files/paper/2022/hash/702f4db7543a7432431df588d57bc7c9-Abstract-Conference.html 関連リンク 2025/7/29開催 開発者向けセミナー「ベクトルであそぼう!」 サンプルコード👉https://github.com/Intersystems-jp/Vector-Asobo 資料PDF 2025/6/10開催 開発者向けセミナー「IRISのベクトル検索を使って テキストから画像を検索してみよう」
記事
Minoru Horita · 2020年6月3日

グローバルはデータを保存するための魔法の剣です パート3 - 疎な配列 

前のパート(1、2)では、ツリーとしてのグローバルを話題に取り上げました。 この記事では、それらを疎な配列と見なします。 疎な配列は、ほとんどの値が同一であると想定される配列の種類です。 疎な配列は実際には非常に大きいため、同一の要素でメモリを占有することには意味がありません。 したがって、疎な配列を整理し、重複した値の格納にメモリが浪費されないようにすることには意味があります。 疎な配列は、J、MATLABなど一部のプログラミング言語では言語の一部になっています。 他の言語では、疎な配列を使用できるようにする特別なライブラリが存在します。 C++の場合は、Eigenなどがあります。 次の理由により、グローバルは疎な配列を実装するのに適した候補であると言えます。   特定のノード値のみを保存し、未定義のノード値を保存しないこと。 ノード値のアクセスインターフェースが、多くのプログラミング言語が多次元配列の要素にアクセスするために提供しているものとよく似ていること。 Set ^a(1, 2, 3)=5 Write ^a(1, 2, 3) グローバルはデータを格納するためにかなり低レベルの構造を採用しているため、優れたパフォーマンス特性を備えていること(ハードウェアによっては毎秒数十万から数千万のトランザクションを処理可能、1をご覧ください)。   グローバルは永続的な構造であるため、グローバル用に十分なメモリを確保できることが事前に分かっている場合のみ、グローバルに基づいて疎な配列を作成する意味があること。     疎な配列の実装には、未定義の要素を処理する場合にデフォルトで特定の値を返すようにするという意味合いもあること。 これは、COSの$GET関数を使用して実装できます。 この例では、次のような3次元配列を見てみましょう。   SET a = $GET(^a(x,y,z), defValue) では、どのような種類のタスクで疎な配列が必要になり、どのようにグローバルは役立つのでしょうか? 隣接行列 このような行列はグラフを表すために使用されています。   グラフが大きいほど、行列に含まれるゼロが多くなることは明らかです。 例えば社会的ネットワークのグラフをこの種の行列で表すと、ほとんどがゼロで構成されることになります。つまり、疎な配列になります。   Set ^m(id1, id2) = 1 Set ^m(id1, id3) = 1 Set ^m(id1, id4) = 1 Set ^m(id1) = 3 Set ^m(id2, id4) = 1 Set ^m(id2, id5) = 1 Set ^m(id2) = 2 .... この例では隣接行列と各ノードのエッジ数(誰とつながっているか、およびつながりの数)を ^m グローバルに保存します。   グラフの要素数が2,900万を超えない場合(この数は、8 * 最大文字列長で計算されます)、このような行列を格納するには、ビット文字列を使うのがより経済的です。ビット文字列は大きなギャップを特別な方法で最適化するからです。 ビット文字列の操作は、$BIT 関数を使用して実行されます。   ; ビットの設定 SET $BIT(rowID, positionID) = 1 ; ビットの取得 Write $BIT(rowID, positionID)   FSMスイッチのテーブル FSMスイッチのグラフは通常のグラフであるため、FSMスイッチのテーブルは基本的に上述したのと同じ隣接行列です。   セル・オートマトン   最も有名なセル・オートマトンである「ライフ」ゲームでは、そのルール(セルに多数の隣接セルがある場合、セルが死ぬ)によって本質的に疎な配列が形成されます。 スティーブン・ウルフラム氏は、セル・オートマトンを新しい科学分野であると考えています。 スティーブン氏は2002年に「新しい種類の科学」と呼ばれる1,280ページの本を出版しました。同著には、セル・オートマトンの分野での成果は分離されていないものの、非常に安定しており、すべての科学分野にとって重要であることが記されています。 コンピューターで処理できるアルゴリズムがセル・オートマトンを使用して実装できることも証明されています。 セル・オートマトンは動的な環境やシステムのシミュレーション、アルゴリズムの問題の解決、その他の目的に使用されています。 巨大なフィールドがあり、セル・オートマトンのすべての中間状態を登録する必要がある場合、グローバルの使用は合理的であると言えます。   地図の作成 疎な配列の使用に関して最初に思い浮かぶのは、地図の作成です。   一般的に、地図には何も無い空間がたくさんあります。 世界地図が大きなピクセルで構成されていると想定した場合、地球上の全ピクセルの71%が海を表す疎な配列で占められていることになります。 また、地図に人工的な構造物を追加するだけの場合、95%超は何も無い空間になります。 もちろん、地図をビットマップ配列として保存する人などいません。誰もが代わりにベクトル表現を使用しています。[文字列の折り返しの区切り]しかし、ベクトル地図とは何でしょうか? これは、ポリラインとポリゴンを備えたある種の構造物です。[文字列の折り返しの区切り]本質的には、ポイントとこれらの関係を記録したデータベースです。 地図作成で最も困難な作業の1つには、ガイア宇宙望遠鏡が実行している銀河地図の作成が挙げられます。 例えて言えば、銀河は1つの巨大な疎な配列になっています。 その99,999999.......%は完全に空の空間です。 銀河地図の保存には、グローバルに基づくデータベースであるCacheが選ばれました。 このプロジェクトにおけるグローバルの正確な構造は分かりませんが、おそらく次のようなものであると想定できます。   Set ^galaxy(b, l, d) = 1; 星のカタログ番号(存在する場合) Set ^galaxy(b, l, d, "name") = "太陽" Set ^galaxy(b, l, d, "type") = "普通" ; 他のオプションには、ブラックホール、クエーサー、赤色矮星などがあります。 Set ^galaxy(b, l, d, "weight") = 14E50 Set ^galaxy(b, l, d, "planetes") = 7 Set ^galaxy(b, l, d, "planetes", 1) = "水星" Set ^galaxy(b, l, d, "planetes", 1, weight) = 1E20 ... b、l、dは銀河座標であり、それぞれ緯度、経度、太陽からの距離を表しています。   グローバルベースのデータベースはスキーマレスであるため、グローバルの柔軟な構造を活かして星と惑星の特性を保存することができます。 Cacheは柔軟性に優れているというだけでなく、一連のデータを素早く保存しながら同時にインデックスグローバルを作成できるため、高速な検索を実行できるということが理由で選ばれました。 地球に話を戻すと、グローバルはこのような地図に特化したプロジェクトであるOpenStreetMap XAPI やOpenStreetMapのフォークであるFOSMで使用されていました。 つい最近のCachéハッカソンでは、ある開発者のグループがこの技術を使用して地理空間インデックスを実装していました。 詳細については、こちらの記事をご覧ください。   OpenStreetMap XAPIでグローバルを使用した地理空間インデックスの実装 イラストはこちらのプレゼン資料から引用しています。   地球全体を正方形に分割し、それをさらに小さな正方形に再帰的に分割していきます。 最終的には、グローバルを作成するための階層構造を取得します。 これで、いつでも素早く任意の正方形を要求したり、空にしたりできます。その場合はその正方形の子孫となるすべての正方形も返されるか、空になります。 グローバルに基づく詳細なスキームは、以下のようにいくつかの方法で実装できます。 方法1: Set ^m(a, b, a, c, d, a, b,c, d, a, b, a, c, d, a, b,c, d, a, 1) = idPointOne Set ^m(a, b, a, c, d, a, b,c, d, a, b, a, c, d, a, b,c, d, a, 2) = idPointTwo ... 方法2:   Set ^m('abacdabcdabacdabcda', 1) = idPointOne Set ^m('abacdabcdabacdabcda', 2) = idPointTwo ... どちらの場合も、COS/Mで任意のレベルの正方形にあるポイントを要求するのはそれほど面倒ではありません。 最初の方法では、任意のレベルで正方形の空間断片を消去するのが多少簡単になりますが、この処理が必要になることはほとんどありません。   下位レベルの正方形の例: そして、こちらはXAPIプロジェクトで使用されているグローバルの例です。グローバルに基づいてインデックスが表現されています。 ^way グローバルは、ポリライン(道路、小川など)やポリゴン(建物や森林などの閉じられた空間)の頂点を格納するために使用されています。   グローバルでの疎な配列の使用方法の大まかな分類   一部のオブジェクトの座標とその状態(地図作成、セル・オートマトン)を格納します。 疎行列を格納します。 方法2)では特定の座標が要求され、要素に値が割り当てられていない場合、疎な配列の要素のデフォルト値を取得する必要があります。     グローバルに多次元行列を格納するメリット 文字列、面、立方体などの複数の空間断片を素早く削除または選択できます。整数インデックスの場合、文字列、面、立方体などの複数の空間断片を素早く削除または選択できると便利です。 Killコマンドは、単独の要素、文字列、さらには面全体を削除できます。 グローバルにはプロパティがあるため、要素ごとに削除するよりも1000倍高速に削除できます。 この図は、グローバル ^a の3次元配列とさまざまな削除処理を表現しています。 既知のインデックスで空間断片を選択するには、Merge コマンドを使用できます。 行列の列をColumn変数に抜き出します。   ; 3x3x3の疎な3次元配列を定義します Set ^a(0,0,0)=1,^a(2,2,0)=1,^a(2,0,1)=1,^a(0,2,1)=1,^a(2,2,2)=1,^a(2,1,2)=1 Merge Column = ^a(2,2) ; Column変数を出力します Zwrite Column 出力: Column(0)=1 Column(2)=1 興味深いことに、$GET 経由でアドレスを指定できるColumn変数に疎な配列が含まれています。これは、デフォルト値が格納されていないためです。   $Order 関数を使用する小さなプログラムを使用して空間断片を選択することもできます。 これは、量子化されていないインデックスがある空間で特に便利です(地図の作成)。   まとめ 今日の現実は、新しい課題を提起しています。 グラフは数十億の頂点で構成でき、地図は数十億のポイントで構成できます。セル・オートマトン(1、2)に基づいて独自の世界を作りたいと考えている人もいるかもしれません。   疎な配列のデータ量はRAMに収まる大きさには圧縮できませんが、それでも疎な配列を処理する必要がある場合は、グローバルとCOSを使用してそのようなプロジェクトを実装することを検討する必要があります。 最後までお読みいただき、ありがとうございました! コメント欄で質問やリクエストをお待ちしています。 免責事項:この記事と記事に対する筆者(英語原文はSergey Kamenev氏によるものです)のコメントは単なる筆者の私見であり、InterSystemsの公式見解とは関係ありません。 また、前のパート「グローバルはデータを保存するための魔法の剣です パート2 - ツリー」も確認してください。
記事
Mihoko Iijima · 2024年3月4日

オブジェクト同時(並行)処理オプションについて

これは InterSystems FAQ サイトの記事です。 永続クラス定義(またはテーブル定義)に対してオブジェクト操作でデータの参照・更新を行うとき、オブジェクトオープンで使用する%OpenId()、オブジェクトの削除に使用する%DeleteId()の第2引数を使用して並行処理の制御方法を選択できます。 ご参考:オブジェクト同時処理のオプション 既定値は1です。(永続クラスのDEFAULTCONCURRENCYクラスパラメータでデフォルト値を指定できます。特に変更していない場合は 1を使用します) 並行処理の基本事項は以下の通りです。 アトミックな書き込みを保証したい場合は並行処理の値は0より大きい値を指定する必要があります。 並行処理の値が0より大きい場合、オブジェクトの保存や削除処理中は、ロックの取得と解放を実施します。 並行処理の値別の動作の違いは以下の通りです。(ドキュメントの「並行処理の値」に表がありますので併せてご覧ください) 値1の場合は、新規にオブジェクト生成したときにはロックを取得しません。既存オブジェクトを開く際に複数グローバルノードがある場合のみ共有ロックを取得し、開くのが終わったら開放します。(複数グローバルノードを持たないオブジェクトを開く場合はロックしません。)オブジェクトの保存時はロックを取得して保存処理が完了するとロックを解放します。 値2の場合は、既存オブジェクトを開くときに常に共有ロックを取得しますが開いた後はロックを開放します。残りは1と同様です。 値3の場合は、既存オブジェクトを開くとき、開いた後、保存処理が完了した後、共有ロックを取得します。新規にオブジェクトを作成するときはロックを取得しません。 値4の場合は、既存オブジェクトを開くとき、開いた後、新規オブジェクトを初めて保存するときに排他ロックを取得します。 値0の場合は、ロックを使用しないオプションです。 (※アトミックな書き込みを保証できないオプションです。このオプションを使用する場合は注意が必要です。) 以下の2クラスを使用して、下記2項目を解説します。 【1】並行処理オプションの値4でオープンしたインスタンスがある中でデフォルトオプションでオープンした場合のロック取得について 【2】トランザクション中のロックの開放について 例の2クラスの定義は以下の通りです。 Test.Person Class Test.Person Extends %Persistent { Property Name As %String; Property Tel As %String; } Test.Personを継承したTest.Employeeクラス Class Test.Employee Extends Test.Person { Property EmpID As %String; } データは、Test.Personクラスで1件、Test.Employeeクラスで1件用意するとします。 //Test.Person set p=##class(Test.Person).%New() set p.Name="Personです" set p.Tel="" set p.Tel="03-5321-6200" write p.%Save() write p.%Id() //割り当てられたID番号が返ります。 //Test.Employee set e=##class(Test.Employee).%New() set e.Name="従業員1" set e.Tel="03-5321-6200" set e.EmpID="EMP001" write e.%Save() write e.%Id() //ID番号を調査 この時のグローバル変数は以下の通りです。 Test.Employeeのデータは複数のグローバルノードを持つデータであることを確認できます。(サブクラスに用意したプロパティのデータが第2ノード以下に格納されています。) 以降の説明では、Test.PersonのIDは 1、Test.EmployeeのIDは 2 として以下解説します。 【1】並行処理オプションの値4でオープンしたインスタンスがある中でデフォルトオプションでオープンした場合のロック取得について 《Test.Personでの例》 1) 1枚目のターミナルで任意の永続オブジェクトを並行処理の値 4でオープンします。 write $JOB // 実行中ターミナルのプロセスIDを取得できます set p=##class(Test.Person).%OpenId(1,4) 2) 2枚目のターミナルで1枚目のターミナル同じ永続オブジェクトを並行処理の値 4 でオープンします。 write $JOB // 実行中ターミナルのプロセスIDを取得できます set p=##class(Test.Person).%OpenId(1,4,.status) 2枚目のターミナルでは、(デフォルトで)10秒待たされます。待機中に管理ポータルの以下メニューにアクセスすると、ロックの状況を確認できます。 管理ポータル > [システムオペレーション] > [ロックの表示] 第3引数で指定した変数を使用して、オープン後のステータスを確認します。 >write $system.Status.GetErrorText(status) エラー #5803: インスタンス 'Test.Person' の排他ロックの取得ができません 排他ロックの取得待ちがタイムアウトしてオープンに失敗したことがわかります。 3) 2枚目のターミナルで今度は%OpenId()の第2引数を指定しない(並行処理のデフォルト)でオープンします。 set p=##class(Test.Person).%OpenId(1) オープンできます。 Test.Personのデータは^Test.PersonD(ID)以下にデータが格納され、複数のグローバルノードを持たないデータであるためオープン時にロック取得を行っていないことがわかります。 では、ここでプロパティを変更し保存してみます。 set p.Name="あ" set status=p.%Save() すぐにプロンプトが戻らないことを確認できます。(ロック表示は1つ前の図解と同じです。) ステータスを確認します。 >write $system.Status.GetErrorText(status) エラー #5803: インスタンス 'Test.Person' の排他ロックの取得ができません この結果から、並行処理のデフォルトオプション(値 1)でオープンしたインスタンスであっても、保存時には排他ロックを取得しようしていることがわかります。 次の確認に進むため、それぞれのターミナルでオープンしていたオブジェクトを削除します。 《Test.Employeeでの例》 次に、複数のグローバルノードをデータに持つTest.Employeeのデータを使用して、オープン時のロック取得方法を確認します。(ID=2のデータが存在していることとします) 1) Test.Employeeのの永続オブジェクトを並行処理の値 4でオープンします。 set e=##class(Test.Employee).%OpenId(2,4) 2) 2枚目のターミナルで1枚目と同じ永続オブジェクトを並行処理のデフォルトオプション(値1)でオープンします。 write $JOB // 実行中ターミナルのプロセスIDを取得できます set e=##class(Test.Employee).%OpenId(2,,.status) (10秒待たされます。) ロックの状況を確認すると、共有ロックを取得しようとしていることを確認できます。 >write $system.Status.GetErrorText(status) エラー #5804: インスタンス 'Test.Employee' の読み込みロックの取得ができません Test.Employeeのデータが複数のグローバルノードを持つデータであるため、並行処理の値1を利用したオープン時、共有ロックを取得しようとしていたことを確認できます。 並行処理の値4(排他ロック)でオープンしたインスタンスがある中で他プロセスからデフォルトオプション(値1)でオープンする場合、そのインスタンスが複数のグローバルノード持つ/持たないによってロック取得方法が変わることを確認できました。 次の確認を行う場合は、すべてのインスタンスを削除(または開放)してください。 【2】トランザクション中のロックの開放について 続いて、トランザクション中のロックの取得と開放について確認します。ここでも、Test.PersonとTest.Employeeを使用して解説します。 《Test.Personでの例》 1) 1枚目のターミナルでTest.Person(ID=1)をデフォルトの並行処理の値でオープンし、プロパティを更新後、TSTARTコマンドを実行し、%Save()を実行します。その後でインスタンスがセットされた変数を消去します。 set p=##class(Test.Person).%OpenId(1) set p.Name="テスト" tstart set st=p.%Save() kill p ここで管理ポータルのロック表示を確認します。 デフォルトの並行処理の値を使用した場合、保存完了後はロックを保持しませんが、トランザクション中は排他処理を保持していることがわかります。 トランザクションを開始することで、一瞬で解放されるロックもトランザクションのコミットやロールバックが来るまで開放が待たされていることがわかります。またインスタンスを開放していたとしても、トランザクションが確定されるまでロックの開放が待たされていることも確認できます。 2) 2枚目のターミナルで1枚目と同じオブジェクトを並行処理の値4でオープンします。 write $JOB set p=##class(Test.Person).%OpenId(1,4,.status) 10秒のタイムアウト後にプロンプトが戻ります。タイムアウト前のロックの状態は下図の通りです。 エラーメッセージを確認します。 >write $system.Status.GetErrorText(status) エラー #5803: インスタンス 'Test.Person' の排他ロックの取得ができません 1枚目のターミナルでトランザクションが確定するまでロック解放は待たされます。 そのため2枚目のターミナルで行ったオブジェクトのオープンは排他ロック取得待ち状態となるため、ロックが取得できずエラーが返ります。 1枚目のターミナルでトランザクションを確定させてから(tcommit または trollback)、再度2枚目のターミナルで同じオブジェクトを並行処理の値4でオープンしてみるとオープンできることを確認できます。 この結果から、並行処理の値がデフォルトであってもトランザクション実行中の%Save()の実行により取得した排他ロックが継続します。 また、トランザクションが終了するまでロック解放は待たされます。(オブジェクトを破棄しても待たされます。) 次の確認を行うため、トランザクションはロールバックし、インスタンスを消去します。 《Test.Employeeでの例》 次に、複数のグローバルノードを持つTest.Employeeで同様のテストを行います。 1) 1枚目のターミナルでTest.Employee(ID=2)をデフォルトの並行処理の値でオープンし、プロパティを更新後、TSTARTコマンドを実行し、%Save()を実行します。 set e=##class(Test.Employee).%OpenId(2) set e.EmpID="EMP999" tstart set status=e.%Save() kill e この時のロックはTest.Personの時と同様に、^Test.Person(2)で排他ロックを取得しています。(インスタンスを設定した変数を削除してもロックを取得したままの状態です。) 2) 2枚目のターミナルでTest.Employee(ID=2)をデフォルトの並行処理の値でオープンしてみます。 set e=##class(Test.Employee).%OpenId(2,,.status) 共有ロック待ちであることを確認できます。 >write $system.Status.GetErrorText(status) エラー #5804: インスタンス 'Test.Employee' の読み込みロックの取得ができません Test.Employeeのように複数のグローバルノードを持つ永続オブジェクトの場合、オープン時に共有ロックを取得しようとするため、1枚目のターミナルのロックが開放されるまで(トランザクションが終了しインスタンスが破棄されるまで)2枚目のターミナルでインスタンスをオープンすることができません。 以上の結果から、トランザクション中に多くのオブジェクトを保存する場合は、トランザクションが終了するまでオブジェクトが破棄されていても、ロック開放が待たされることにご注意ください。 (テストを終了するため、トランザクションはロールバック、全インスタンスを消去してください。)
記事
Shintaro Kaminaka · 2021年11月3日

SUSHIを使ってFHIRプロファイルを作成しようパート2

開発者の皆さん、こんにちは。 この記事は、FHIRの関連技術として、FHIRプロファイル作成ツールであるSUSHIの使い方を紹介するシリーズの第2弾です。パート2である今回まで半年の期間が経ってしまいました。 前回の[パート1](https://jp.community.intersystems.com/node/493351)では、FHIRとは?FHIRプロファイルとは?FHIR Shorthandとは?そしてSUSHIとはどのようなツールなのか?どのような物を作成できるのか?について、サンプルの成果物のスクリーンショットを交えながら説明しました。 今回の記事では、SUSHIで作成したプロファイルの実際の活用例として、SUSHIを使ってPatientリソースに **Extension** を追加し、さらにそのExtensionの項目に対する新しい **SearchParameter** を定義し、IRIS for HealthのFHIR Repositoyで新しいSearchParameterが使えるようになるまで、をご紹介します。 ## SUSHIのアップグレード いきなり本筋とそれて恐縮ですが、SUSHIを久しぶりに触る私のような方はSUSHIのアップグレードを行いましょう。 この半年の間もSUSHIは精力的な機能Enhancementが行われており、8月にはversion 2.0.0がリリースされています。この記事の執筆段階の最新バージョンは [SUSHI 2.1.1](https://github.com/FHIR/sushi/releases) でした。 このリンク先でも紹介されている通り、アップグレードはインストール同様以下のコマンドです。 ```Bash $ npm install -g fsh-sushi ``` sushi -versionを実行すればバージョンが確認できます。 同様に、SUSHIで生成されたProfileをベースに、実装ガイドのHTMLファイル群を作成してくれるIG Publisherツールも、**_updatePublisher** コマンドを実行してアップグレードすることができます。 ## FISHファイルの作成 まずは、前回同様 ```sushi --init``` コマンドを使って、プロジェクトを作成します。この記事ではテンプレートで生成される、**patient.fsh** ファイルを修正していきます。 今回は、出身都道府県を表現するstring型の **birthPlace** のExtensionを追加し、さらにそのbirthPlaceに対するSearchParameterも定義することで、その患者の出身都道府県で検索できるような拡張を行います! # Extensionを追加する まず、Extensionを追加するために以下の定義を追加します。 US CoreやJP Coreのように、通常はAddress型を使うことが多いですが、ここでは単純にstring型にしています。 ``` Extension: BirthPlace Id: birthPlace Title: "出身地" Description: "生まれた場所をstring型で表現する" * ^url = "http://isc-demo/fhir/StructureDefinition/patient-birthPlace" * value[x] only string ``` 各項目は以下のようにExtensionのStructureDefinitionに対応しています。項目によっては複数の箇所に設定されます。ベースとなるfhirのバージョンや、このExtension自体のバージョンなどの情報は、`sushi-config.yml`ファイルから取得されているものもあります。 | SUSHIの項目 | 対応するStructureDefinitionの項目 | |:----------|:------------| | Extensions | name | | Id | id | | Title | title/differencial.element[id=Extension].short | | Desctiption | description/differencial.element[id=Extension].definition | | ^url | url//differencial.element[id=Extension.url].fixedUri | | value[x] | differencial.element[id=Extension.value[x]].type.code | 実際に生成されたExtensionのStructureDefinitionです。 手書きでこれを1から作るのは大変ですが、SUSHIを使えば比較的簡単です。 ```json { "resourceType": "StructureDefinition", "id": "birthPlace", "url": "http://isc-demo/fhir/StructureDefinition/patient-birthPlace", "version": "0.1.0", "name": "BirthPlace", "title": "出身地", "status": "active", "description": "生まれた場所をstring型で表現する", "fhirVersion": "4.0.1", "mapping": [ { "identity": "rim", "uri": "http://hl7.org/v3", "name": "RIM Mapping" } ], "kind": "complex-type", "abstract": false, "context": [ { "type": "element", "expression": "Element" } ], "type": "Extension", "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Extension", "derivation": "constraint", "differential": { "element": [ { "id": "Extension", "path": "Extension", "short": "出身地", "definition": "生まれた場所をstring型で表現する" }, { "id": "Extension.extension", "path": "Extension.extension", "max": "0" }, { "id": "Extension.url", "path": "Extension.url", "fixedUri": "http://isc-demo/fhir/StructureDefinition/patient-birthPlace" }, { "id": "Extension.value[x]", "path": "Extension.value[x]", "type": [ { "code": "string" } ] } ] } } ``` このExtensionで追加したPatientリソースへのExtensionデータは、実際はこのようなデータになります。 ```json "extension": [ { "url": "http://isc-demo/fhir/StructureDefinition/patient-birthPlace", "valueString": "鹿児島" } ], ``` # SearchParamterを追加する 次は、先ほど追加したExtensionの項目をキーにして、リソースを検索できるように、**SearchParamter** を追加します。FHIRの場合、各リソースには構造化された要素(エレメント)が定義されていますが、 **そのすべての要素で検索ができるわけではなく、SearchParamterに定義された項目(≒要素)でのみ検索することができます** 。ここがSQLのテーブルとは少し異なる点ですね。 SearchParamter名は要素名とは別に定義されており、Patientリソースで言えば、genderのように要素名=SearchParameter名で一致するものもあれば、要素名=address.country -> SearchParamter名=address-country のように構造化された要素では一致しないものもあります。 Extensionに追加される項目は当然ながら(何がはいってくるかわからないので)デフォルトではSearchParameterにはならないわけですが、あえてExtensionを定義して格納する方針を定めるようなExtensionは重要な項目であることも多いですよね。 以下のようなSearchParameter定義を生成するための内容をpatient.fshファイルに追加します。 ``` Instance: BirthPlaceSearchParameter InstanceOf: SearchParameter Usage: #definition * url = "http://isc-demo/fhir/SearchParameter/patient-birthPlace" * version = "0.0.1" * name = "birthPlace" * status = #active * description = "出身地検索のパラメータ" * code = #birthPlace * base = #Patient * type = #string * expression = "Patient.extension.where(url='http://isc-demo/fhir/StructureDefinition/patient-birthPlace').value" * comparator = #eq ``` SearchParameterで生成されるStructureDefinitionはこちらです。 比較的シンプルな定義なので、上記SUSHIの情報とのマッピングは理解しやすいと思います。 ```json { "resourceType": "SearchParameter", "id": "BirthPlaceSearchParameter", "url": "http://isc-demo/fhir/SearchParameter/patient-birthPlace", "version": "0.0.1", "name": "birthPlace", "status": "active", "description": "出身地検索のパラメータ", "code": "birthPlace", "base": [ "Patient" ], "type": "string", "expression": "Patient.extension.where(url='http://isc-demo/fhir/StructureDefinition/patient-birthPlace').value", "comparator": [ "eq" ] } ``` 特にSearchParameterの定義として、重要になるのは **expression** の項目と **comparator** になります。 expressionには対象となるSearchParameterへの **FHIRPath** 式を記述します。FHIRPathも詳しく説明すると長くなるので興味のある方は[こちらの公式ページ](http://hl7.org/fhir/R4/fhirpath.html)をご覧ください。 今回の定義で使っている `Patient.extension.where(url='http://isc-demo/fhir/StructureDefinition/patient-birthPlace').value"` こちらの式は、PatientリソースのJson構造に従って、階層順にPatient.extensionと指定し、複数存在する可能性があるExtensionの中から、url=(省略) である今回のExtensionを絞り込み、そのvalueを指定しています。 comparatorはどのような比較式が使えるかを指定します。詳細は[こちら](https://www.hl7.org/fhir/valueset-search-comparator.html)をご覧下さい。 # Patientに作成したExtension定義を追加する もう一つ大事な変更があります。Patientリソースでこの作成した BirthPlace Extensionを追加することです。元々自動生成されたPatientリソースのProfile定義MyProfileを以下のように変更します。name要素のCardinalityの変更はコメントアウトしました。 ``` Profile: MyPatient Parent: Patient Description: "An example profile of the Patient resource." //* name 1..* MS * extension contains BirthPlace named birthPlace 0..1 ``` 先ほど追加した"BirthPlace"という名前のExtensionを、Patientリソース内にbirthPlaceという名前でCardinality 0..1 で追加しています。 # ついでにテスト用リソースを作成 SUSHIでは、例示用などの目的で使用できるリソースのInstanceを作成することもできます。テストのためにこちらも利用しておきましょう。今定義したExtensionも含めることができます。 ``` Instance: KamiExample InstanceOf: MyPatient Description: "Patientリソースのサンプル" * name.family = "山田" * extension[BirthPlace].valueString = "鹿児島" ``` どんなデータができたかは最後のテストでご覧いただきたいと思います。 ## Let's SUSHI! FSHファイルの用意ができました!それでは SUSHIコマンドで、fshファイルから各定義ファイルを生成しましょう! **sushi** コマンドを実行し、以下のように2つのProfile(拡張されたPatientとExtension)、二つのInstance(SearchParameterとサンプルリソース)が生成されたら成功です。 ```PowerShell C:\Users\kaminaka\Documents\Work\FHIR\SUSHI\TestProject\MyProfileProject>sushi . info Running SUSHI v2.1.1 (implements FHIR Shorthand specification v1.2.0) info Arguments: info C:\Users\kaminaka\Documents\Work\FHIR\SUSHI\TestProject\MyProfileProject info No output path specified. Output to . info Using configuration file: C:\Users\kaminaka\Documents\Work\FHIR\SUSHI\TestProject\MyProfileProject\sushi-config.yaml info Importing FSH text... info Preprocessed 1 documents with 0 aliases. info Imported 2 definitions and 2 instances. info Checking local cache for hl7.fhir.r4.core#4.0.1... info Found hl7.fhir.r4.core#4.0.1 in local cache. info Loaded package hl7.fhir.r4.core#4.0.1 (node:27132) Warning: Accessing non-existent property 'INVALID_ALT_NUMBER' of module exports inside circular dependency (Use `node --trace-warnings ...` to show where the warning was created) (node:27132) Warning: Accessing non-existent property 'INVALID_ALT_NUMBER' of module exports inside circular dependency info Converting FSH to FHIR resources... info Converted 2 FHIR StructureDefinitions. info Converted 2 FHIR instances. info Exporting FHIR resources as JSON... info Exported 4 FHIR resources as JSON. info Assembling Implementation Guide sources... info Generated ImplementationGuide-myprofileproject.json info Assembled Implementation Guide sources; ready for IG Publisher. ╔════════════════════════ SUSHI RESULTS ══════════════════════════╗ ║ ╭───────────────┬──────────────┬──────────────┬───────────────╮ ║ ║ │ Profiles │ Extensions │ Logicals │ Resources │ ║ ║ ├───────────────┼──────────────┼──────────────┼───────────────┤ ║ ║ │ 1 │ 1 │ 0 │ 0 │ ║ ║ ╰───────────────┴──────────────┴──────────────┴───────────────╯ ║ ║ ╭────────────────────┬───────────────────┬────────────────────╮ ║ ║ │ ValueSets │ CodeSystems │ Instances │ ║ ║ ├────────────────────┼───────────────────┼────────────────────┤ ║ ║ │ 0 │ 0 │ 2 │ ║ ║ ╰────────────────────┴───────────────────┴────────────────────╯ ║ ║ ║ ╠═════════════════════════════════════════════════════════════════╣ ║ FSHing for compliments? Super job! 0 Errors 0 Warnings ║ ╚═════════════════════════════════════════════════════════════════╝ C:\Users\kaminaka\Documents\Work\FHIR\SUSHI\TestProject\MyProfileProject> ``` `fsh-generated\resource` フォルダには以下のような成果物が作成されました。 | ファイル名 | 内容 | |:----------|:------------| | ImplementationGuide-myprofileproject.json | 今回の全ての内容を取りまとめたImplemamtionGuide | | StructureDefinition-MyPatient.json | PatientにExtensionを追加したStructureDefinition | | StructureDefinition-birthPlace.json | Extension birthPlaceの定義を含むStructureDefinition | | SearchParameter-BirthPlaceSearchParameter.json | birthPlace SearchParameterの定義ファイル | | Patient-KamiExample.json | Patientのサンプルインスタンス | ## IRIS for HealthにFHIR Profileをインポートしてテストしてみる # IRIS for Health のFHIRリポジトリへの適用 前回の記事ではこの後、_updatePublisherを実行してIGファイル群を生成しましたが、今回は、このStuructureDefinitino/SearchParameterファイルをIRIS for HealthのFHIRリポジトリに取り込んで、新しいSearchParameterで検索できるようになるところを見ていきましょう。 FHIR Profileのインポート等について詳細は、こちらの[開発者コミュニティ記事 FHIRプロファイル](https://jp.community.intersystems.com/node/495321)をご覧章ください。 FHIRリポジトリの構築方法などは、[こちらの記事](https://jp.community.intersystems.com/node/480231)も参考になると思います。 インポートの対象となるのは、先ほど生成された5つのファイルのうち、 - StructureDefinition-MyPatient.json - StructureDefinition-birthPlace.json - SearchParameter-BirthPlaceSearchParameter.json の3つです。これを別のフォルダにコピーし、さらにパッケージ全体の情報を管理するための `package.json`ファイルを用意します。 **package.json** ```json { "name": "SUSHI Demo", "title": "SUSHI Demo", "version": "0.0.1", "author": { "name": "ISC" }, "fhirVersions": [ "4.0.1" ], "bundleDependencies": false, "date": "20201208205547", "dependencies": { "hl7.fhir.r4.core": "4.0.1" }, "deprecated": false } ``` nameやtitle,author,dateなどの項目は適宜変更して問題ありません。 (注意)各プロファイルを変更してIRISに再インポートする場合は、versionを適切に変更していく(上げていく)必要があります。 (現在のバージョン2021.1ではFHIRリポジトリには、プロファイルを削除する機能がないため、テスト環境で適切に動作確認した上で、本番環境への適用は最小の回数に抑えるなど、本番環境等でプロファイルが増えすぎないように注意をする必要があります。) IRISの管理ポータルからHealth -> FHIR Configuration -> Package Configurationと進み、Import Packageから上記4ファイルを含むフォルダを選ぶと以下のような画面になります。 ![image](/sites/default/files/inline/images/sushi_part2_ss1.jpg) Importをクリックして、IRISへのインポートを完了します。 次に Server Configuration画面で、新規FHIRリポジトリを作成します。 (既存のFHIRリポジトリへ追加することも可能です。) ![image](/sites/default/files/inline/images/sushi_part2_ss2.jpg) ## POSTMANからテストする 先ほどSUSHIで生成された、テスト用リソースをPOSTします。検証のためには他の値のbirthPlaceを含むデータや、そもそもbirthPlaceを含まないPatientリソースなども生成するほうが良いかもしれません。 ![image](/sites/default/files/inline/images/sushi_part2_ss3.jpg) FHIRリポジトリのSearchParameter に正しく birthPlaceが追加されていれば、以下のGETリクエストでこの患者情報を取得できるはずです! ```http GET http://localhost:52785/csp/healthshare/sushi/fhir/r4/Patient?birthPlace=鹿児島 ``` 正しく結果を取得できるようになったでしょうか? 新しいSearchParameterである birthPlaceが正しく追加されていない場合は、GETリクエストの応答の最初に以下の「birthPlaceというパラメータが認識されていません」というエラー情報をが記述されたOperationOutcomeリソースの情報が含まれています。このメッセージがでていないか応答メッセージを確認してみてください。 ```json { "resource": { "resourceType": "OperationOutcome", "issue": [ { "severity": "error", "code": "invalid", "diagnostics": "ParameterNotSupported", "details": { "text": "Unrecognized parameter 'birthPlace'. 鹿児島" } } ] }, "search": { "mode": "outcome" } }, ``` # まとめ SUSHIを使ってFHIRのProfile(StructureDefinition/SearchParameter)を作成し、IRIS for HealthのFHIRリポジトリにインポートして機能を拡張する流れをみていただきました。 今回は、Extensionに追加した項目をSearchParameterに追加しましたが、FHIR標準仕様で存在するが、SearchParameterにはなっていない要素(エレメント)に対して、SearchParameterを追加するということも可能です。 自由度の高いFHIRの開発では、このように機能を拡張することが可能になっていますが、一方ではInteroperabilityを担保するためにどのような拡張を行ったかという情報の共有、つまりImplemantationGuide等の作成も重要になってきます。 このシリーズのPart1,2で見てきたようにSUSHIはその両面をカバーすることができる非常にユニークで強力なオープンソースのツールです。 このようなツールとIRIS for Healthを組み合わせて、新しいFHIRソリューションが構築されることを期待しています。 今回の記事で使用したSUSHIのfshファイルおよび、生成されたStructureDefinition/SearchParameterのサンプルファイルは[こちら](https://github.com/Intersystems-jp/FHIR_SUSHI)からダウンロードすることができます。
記事
Toshihiko Minamoto · 2021年9月9日

RDBにおけるEntity-Attribute-Value(EAV)モデル。 グローバル変数はテーブルでエミュレートする必要がありますか? パート1.

## はじめに この連載の最初の記事では、リレーショナルデータベースの[EAV(Entity–Attribute–Value)モデル](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model)を見て、それがどのように使用されて、何に役立つのかを確認しましょう。 その上で、EAVモデルの概念とグローバル変数と比較します。 原則として検索する必要のある、フィールド数、または階層的にネストされたフィールドの数が不明なオブジェクトがある場合があります。 たとえば、多様な商品群を扱うオンラインストアを考えてみましょう。 商品群ごとに固有の一意のプロパティセットがあり、共通のプロパティもあります。 たとえば、SSDとHDDドライブには共通の「capacity」プロパティがありますが、SSDには「Endurance, TBW」、HDDには「average head positioning time」という一意のプロパティもあります。 場合によっては、同じ商品でも別のメーカーが製造した場合には、それぞれに一意のプロパティが存在します。 では、50種の商品群を販売するオンラインストアがあるとしましょう。 各商品群には、数値またはテキストの固有のプロパティが5つあります。 実際に使用するのは5個だけであっても、各商品に250個のプロパティがあるテーブルを作成するのであれば、ディスク容量の要件が大幅に増える(50倍!)だけでなく、有用性のない空のプロパティによってキャッシュが詰まってしまうため、データベースの速度特性が大幅に減少してしまいます。 さらに、それだけではありません。 固有のプロパティを持つ新しい商品群を追加するたびに、**ALTER TABLE**コマンドを使用してテーブルの構造を変更する必要があります。 大規模なテーブルであれば、この操作には数時間、さらには数日間かかる可能性もあり、ビジネスでは許容しかねます。 これを注意深く読んでいる方は「商品群ごとに異なるテーブルを用意しては?」と言うでしょう。 もちろんその通りではありますが、このアプローチを使用すると、大型ストアの場合には、数万個ものテーブルでデータベースを作成することになり、管理が困難になります。 さらに、サポートする必要のあるコードがますます複雑化してしまいます。 一方、新しい商品群を追加するときに、データベースの構造を変更する必要はありません。 新しい商品群向けの新しいテーブルを追加すればよいだけだからです。 いずれにせよユーザーは、ストア内の商品を簡単に検索できること、現在のプロパティを示す便利な表形式で商品を表示できること、そして商品を比較できることが必要です。 ご想像のとおり、商品群の5個のプロパティのみが必要であるにもかかわらず、商品テーブルにはさまざまなプロパティを示す250個のフィールドがあれば不便であるのと同様に、250個のフィールドを使った検索フォームは、ユーザーにとって非常に不便です。 これは商品の比較にも当てはまります。 マーケティングデータベースも別の有用な例と言えるでしょう。 それに格納されている人ごとに、絶えず追加、変更、または削除される可能性のある多数のプロパティが必要です(多くの場合はネストされています)。 過去にある商品を特定の数量で購入した、特定の商品群を購入した、何かに参加した、どこかで勤務した、親戚がいる、この都市に住む、特定の社会階級に属する、などのプロパティがあります。 フィールド数は数千個にもなり、変化も絶えないでしょう。 マーケターは常に、さまざまな顧客グループを区別して魅力的な特別オファーを提供する方法を考えています。 これらの問題を解決すると同時に、明確で確定的なデータベース構造を得るために、Entity-Attribute-Valueアプローチが編み出されました。 ## EAVアプローチ EAVアプローチの本質は、エンティティ、属性、および属性値を個別に保存することにあります。 一般的に、EAVアプローチを説明するために、Entity、Attribute、およびValueという3つのテーブルのみが使用されます。 ![](/sites/default/files/inline/images/images/simpleeav.png) 保存するデモデータの構造。 ![](/sites/default/files/inline/images/images/data_structure1.png) ## テーブルを使用したEAVアプローチの実装 5つ(最後の2つのテーブルを1つに統合することにした場合は4つ)のテーブルを使用したより複雑な例を考察してみましょう。 ![](/sites/default/files/inline/images/images/db_struct.png) 最初のテーブルは`Сatalog`です。 CREATE TABLE Catalog ( id INT, name VARCHAR (128), parent INT ); このテーブルは実際、EAVアプローチのエンティティに対応しています。 階層的な商品カタログのセクションを保存します。 2つ目のテーブルは ****`Field`です。 CREATE TABLE Field ( id INT, name VARCHAR (128), typeOf INT, searchable INT, catalog_id INT, table_view INT, sort INT ); このテーブルでは、属性の名前、型、および属性が検索可能であるかどうかを指定します。 また、プロパティが属する商品を保持しているカタログのセクションも指定します。 catalog_id以下のカタログセクションにあるすべての商品には、このテーブルに保存されているさまざまなプロパティがある場合があります。 3つ目のテーブルは`Good`です。 商品を、商品の価格、商品の合計数量、商品の予約数量、および商品名とともに保存するように設計されています。 厳密にはこのテーブルは必要ではありませんが、個人的には、商品用に別のテーブルを用意しておくと便利だと思います。 CREATE TABLE Good ( id INT, name VARCHAR (128), price FLOAT, item_count INT, reserved_count, catalog_id INT ); 4つ目のテーブル(`TextValues`)と5つ目のテーブル(`NumberValues`)は、商品のテキストの値と数値属性を保存するように設計されており、構造も似ています。 CREATE TABLE TextValues ​​( good_id INT, field_id INT, fValue TEXT ); CREATE TABLE NumberValues ​​( good_id INT, field_id INT, fValue INT ); テキスト値と数値に個別のテーブルを使用する代わりに、次の構造で単一のCustomeValuesテーブルを使用することもできます。 CREATE TABLE CustomValues ​​( good_id INT, field_id INT, text_value TEXT, number_value INT ); データ型ごとに個別に保存しておけば、速度が向上し、容量を節約できるため、私は別々に保存する方を好んでいます。 ## EAVアプローチを使用したデータへのアクセス SQLを使用して、カタログ構造マッピングを表示してみましょう。 SELECT * FROM Catalog ORDER BY id; これらの値からツリーを作成するには、個別のコードが必要となります。 PHPでは、次のようになります。 $stmt = $ pdo-> query ('SELECT * FROM Catalog ORDER BY id'); $aTree = []; $idRoot = NULL; while ($row = $ stmt->fetch()) {     $aTree [$row ['id']] = ['name' => $ row ['name']];     if (! $row['parent'])       $idRoot = $row ['id'];     else       $aTree [$row['parent']] ['sub'] [] = $row['id']; } 将来的には、ルートノードの $aTree[$ idRoot] から始めると、ツリーを簡単に描画できるようになります。 では、特定の商品のプロパティを取得しましょう。  まず、この商品に固有のプロパティのリストを取得し、その後で、それらのプロパティとデータベースにあるプロパティを接続します。 実際には、示されるすべてのプロパティが入力されているわけではないため、LEFT JOINを使用する必要があります。 SELECT * FROM ( SELECT g. *, F.name, f.type_of, val.fValue, f.sort FROM Good as g INNER JOIN Field as f ON f.catalog_id = g.catalog_id LEFT JOIN TextValues ​​as val ON tv.good = g.id AND f.id = val.field_id WHERE g.id = $ nGood AND f.type_of = 'text' UNION SELECT g. *, F.name, f.type_of, val.fValue, f.sort FROM Good as g INNER JOIN Field as f ON f.catalog_id = g.catalog_id LEFT JOIN NumberValues ​​as val ON val.good = g.id AND f.id = val.field_id WHERE g.id = $nGood AND f.type_of = 'number' ) t ORDER BY t.sort; 数値とテキスト値の両方を保存するために1つのテーブルのみを使用すると、クエリを大幅に簡略化できます。 SELECT g. *, F.name, f.type_of, val.text_value, val.number_value, f.sort FROM Good as g INNER JOIN Field as f ON f.catalog = g.catalog LEFT JOIN CustomValues ​​as val ON tv.good = g.id AND f.id = val.field_id WHERE g.id = $nGood ORDER BY f.sort; では、$nCatalogカタログセクションに含まれる商品を表形式で取得します。 まず、カタログのこのセクションのテーブルビューに反映する必要があるプロパティのリストを取得します。 SELECT f.id, f.name, f.type_of FROM Catalog as c INNER JOIN Field as f ON f.catalog_id = c.id WHERE c.id = $nCatalog AND f.table_view = 1 ORDER BY f.sort; 次に、テーブルを作成するクエリを構築します。 表形式ビューには、3つの追加プロパティ(Goodテーブルのプロパティのほかに)が必要だとします。 クエリを単純化するために、次を前提としています。 SELECT g.if, g.name, g.price,             f1.fValue as f1_val,             f2.fValue as f2_val,             f3.fValue as f3_val, FROM Good LEFT JOIN TextValue as f1 ON f1.good_id = g.id LEFT JOIN NumberValue as f2 ON f2.good_id = g.id LEFT JOIN NumberValue as f3 ON f3.good_id = g.id WHERE g.catalog_id = $nCatalog; ## EAVアプローチの長所と短所 EAVアプローチは明らかに柔軟性のメリットがあります。 テーブルなどの固定されたデータ構造を使用すると、オブジェクトの広範なプロパティセットを保存することが可能になります。 また、データベースのスキーマを変更せずに、別のデータ構造を保存することができます。  また、非常に多くの開発者に馴染みのあるSQLも使用することができます。  最も明白なデメリットは、データの論理構造と物理ストレージの不一致であり、これによって様々な問題が引き起こされます。  さらに、プログラミングには、非常に複雑なSQLクエリが伴うこともよくあります。 EAVデータの表示には標準的に使用されていないツールの作成が必要となるため、デバッグが困難になることがあります。 また、LEFT JOINクエリを使用する必要がある場合があるため、データベースの速度が低下してしまいます。 ## グローバル変数: EAVの代替 私はSQLの世界とグローバル変数の世界の両方に精通しているため、EAVアプローチが解決するタスクにグローバルを使用する方がはるかに魅力的になるのではないかと考えました。 グローバル変数はまばらで階層的な情報を保存できるデータ構造です。 グローバル変数は階層情報を保存するために慎重に最適化されているというのが非常に重要なポイントです。 グローバル変数自体はテーブルよりも低レベルの構造であるため、テーブルよりもはるかに素早く動作します。 同時に、グローバル構造自体をデータ構造に従って選択できるため、コードを非常に単純で明確にすることができます。 ## デモデータを保存するためのグローバル構造 グローバル変数はデータを保存する上で非常に柔軟でエレガントな構造であるため、1つのグローバル変数を管理するだけでカタログセクション、プロパティ、および商品などのデータを保存することができます。 ![](/sites/default/files/inline/images/images/1global.png) グローバル構造がデータ構造にどれほど似ているのかに注目してください。 このコンプライアンスによって、コーディングとデバッグが大幅に簡略化されます。 実際には、全ての情報を1つのグローバルに保存したい気持ちが非常に強くても、複数のグローバルを使用することをお勧めします。 インデックス用に別のグローバルを作成することが合理的です。 また、ディレクトリのパーティション構造のストレージを商品から分離することもできます。 ## この続きは? この連載の2つ目の記事では、EAVモデルに従う代わりに、InterSystems Irisのグローバルにデータを保存する方法の詳細とメリットについて説明します。
記事
Kawasaki Kazuhito · 2024年10月14日

SourceControlを用いた自動ソースチェックツールについて

開発者の皆様はじめまして。 私からはIRISのソースコントロール機能を用いたソースの自動チェック機能のご紹介をしたいと思います。 チーム開発では、ソースの可読性や実装方法等がある程度統一されるようにコーディング規約を作成すると思います。 しかし、メンバーの入れ替わりでコーディング規約の説明をしていても徹底されないことが起こることも少なくありません。 なので、ソースコントロールを使用してコンパイル時に自動的にチェックするようにしました。 IRIS内で完結させるメリットとして、エラーチェックだけでなくチェック後にエラーがなければコンパイルまで自動で行えること、 %Dictionary.ClassDefinition(クラス定義)を使用できるので、チェッククラスを作成しやすいこと等があげられます。 目次 1. ソースコントロールについて 2. 今回用意したチェック用クラスの紹介 3. ソースコントロールへの設定 4. 実際の動作 5. 感想 **1.ソースコントロールについて** まず、ソースコントロールについて簡単に記載します。 ソースコントロールとは、一般的にコードに対する変更を追跡し管理することを表します。 IRISのソースコントロール機能には様々なメソッドが用意されています。 今回はそれを使用することでソースの自動チェック機能を実現していきます。 参考リンク:[InterSystems IRIS とソース・コントロール・システムの統合](https://docs.intersystems.com/supplychain20241/csp/docbookj/DocBook.UI.Page.cls?KEY=ASC)、 [ソース・コントロール設定の構成](https://docs.intersystems.com/supplychain20241/csp/docbookj/Doc.View.cls?KEY=ECONFIG_other_source_control) **2.今回用意したチェック用クラスの紹介** 今回作成したチェック用クラスには基底クラスとして、「%Studio.SourceControl.Base」と「%Studio.Extension.Base」(%Studio.SourceControl.Baseの基底クラス)を使用しています。 上記のクラスにはログイン時のイベントやロード前イベントなどが定義されており、今回は「OnBeforeCompile」(コンパイル前イベント)を使用しました。 ![image](/sites/default/files/inline/images/hua_xiang_1.png) では、実際のチェック用クラスの内容をご紹介します。 今回は以下をコーディング規則として実装をしています。 ①クラスの命名チェック - 「XXXX」始まりのクラス名であること ②インデントチェック - インデントは4の倍数の半角空白で埋めること ③変数の利用チェック - 定義した変数は利用すること ④引数の利用チェック - パラメータとして受け取った引数は利用すること Class User.CompileChk Extends %Studio.SourceControl.Base { ///  処理概要 :コンパイル前チェック ///  IN :InternalName : コンパイル対象クラス ///  OUT :%Status ///  処理詳細:規約に則さない実装がされている場合、コンパイルエラーにする。 Method OnBeforeCompile(InternalName As %String, ByRef qstruct As %String) As %Status {     Set InternalName = $REPLACE(InternalName, ".CLS", "")     Set clsDef = ##Class(%Dictionary.ClassDefinition).%OpenId(InternalName)     Set SKIPuFLG = $$$NO     Write !,"****************コンパイルチェック開始****************",!     Write "TARGET : "_InternalName,!     Set hasErr = 0          #; クラス名チェック     Set hasErr = hasErr + '##class(User.Chk.ClassNamingChecker).%New().IsCorrectDefine(clsDef)     #; インデントチェック     Set hasErr = hasErr + '##class(User.Chk.IndentChecker).%New().UseWrongIndent(clsDef)     #; 変数の利用チェック     Set hasErr = hasErr + '##class(User.Chk.UseValChecker).%New().UseVal(clsDef)     #; 引数の利用チェック     Set hasErr = hasErr + '##class(User.Chk.UseArgsChecker).%New().UseArgs(clsDef)          Write "****************コンパイルチェック完了****************",!     If (hasErr > 0) {         Return $$$ERROR($$$GeneralError, "コンパイルエラーがあります。")     } Else {         Return $$$OK     } } } ①クラスの命名チェッククラス - User.Chk.ClassNamingChecker Class User.Chk.ClassNamingChecker Extends %RegisteredObject { ///  処理概要 :クラス定義の命名規約違反チェック ///  IN :clsDef : クラス定義 ///  OUT :%Boolean ///  処理詳細:クラスの命名がコーディング規約に従っているかどうかをチェックする。 Method IsCorrectDefine(clsDef As %Dictionary.ClassDefinition) As %Boolean {     Write "*クラス名チェック",!     Set ret = $$$YES     If (clsDef '= "") {         Set clsName = clsDef.Name         Set ret = ..WriteStartWithStrErr(clsName)     }     Return ret } ///  処理概要 :命名先頭不正のエラー表示 ///  IN :clsName クラス名/ keyword キーワード ///  OUT :%Boolean ///  処理詳細:クラス名の先頭がキーワードで開始していなければエラーを表示する。 Method WriteStartWithStrErr(clsName As %String) As %Boolean [ Private ] {     If ($FIND(clsName, ".XXXX") > 0) {         #; OK         Return $$$YES     } Else {         Write "E: クラス名はXXXXという単語で始まる必要があります。",!         Return $$$NO     } } } クラス内で行っていること 引数として%Dictionary.ClassDefinition(クラス定義)を受け取り、クラス定義内のプロパティであるNameを使用することで クラス名を取得。取得したクラス名に対して、「.XXXX」を$FINDで検索することでクラスの先頭が「XXXX」であるかチェックを行います。 ②インデントチェック - User.Chk.IndentChecker Class User.Chk.IndentChecker Extends %RegisteredObject { ///  処理概要 :インデント不正チェック ///  IN :clsDef : クラス定義 ///  OUT :%Boolean ///  処理詳細:インデントが4の倍数になっているかをチェックをする。 Method UseWrongIndent(clsDef As %Dictionary.ClassDefinition) As %Boolean {     Write "*インデント不正チェック",!     Set isCorrect = $$$YES     Set count = clsDef.Methods.Count()     For i = 1: 1: count {         Set cnt = 0         Set method = clsDef.Methods.GetAt(i)         Do method.Implementation.Rewind()         While ('method.Implementation.AtEnd) {             Set cnt = cnt + 1             Set line = method.Implementation.ReadLine()             If (line = "") {                 Continue             }             If ('$MATCH(line, "^( {4}){1,}[^ ].*")) {                 Set isCorrect = $$$NO                 Write "E: インデントが4の倍数になっていません。: "_method.Name_"+"_cnt_line,!             }         }     }     Return isCorrect } } クラス内で行っていること 引数として%Dictionary.ClassDefinition(クラス定義)を受け取り、クラス定義内のプロパティであるMethods.Count()でメソッドの数を取得。 メソッドごとに1行ずつチェックを行います。チェックは正規表現を使用(^( {4}){1,}[^ ].*の部分)し、半角スペースが4の倍数になっているかチェックを行います。 ③変数の利用チェック - User.Chk.UseValChecker Class User.Chk.UseValChecker Extends %RegisteredObject { ///  処理概要 :変数の利用チェック ///  IN :clsDef : クラス定義 ///  OUT :%Boolean ///  処理詳細:定義された変数が利用されているかをチェックをする。 Method UseVal(clsDef As %Dictionary.ClassDefinition) As %Boolean {     Set isCorrect = $$$YES     Set count = clsDef.Methods.Count()     For i = 1: 1: count {         Set cnt = 0         Set method = clsDef.Methods.GetAt(i)         Do method.Implementation.Rewind()         Set args = method.FormalSpec         Set argList = {}         For j = 1: 1: $LENGTH(args, ",") {             Set arg = $REPLACE($REPLACE($REPLACE($PIECE($PIECE(args, ",", j), ":", 1), "&", ""), "*", ""), "...", "")             If (arg = "") {                 Continue             }             Do argList.%Set(arg, "")         }         Set valList = {}         While ('method.Implementation.AtEnd) {             Set cnt = cnt + 1             Set line = method.Implementation.ReadLine()             If (line = "") {                 Continue             }             If (..HasComment(line)) {                 Continue             }             If ($FIND(line, "Set ") > 0) {                 Set valNm = $PIECE($REPLACE($PIECE($PIECE(line, "Set ", 2), "="), " ", ""), "(")                 #; オブジェクトへの参照は対象外。                 If (($FIND(valNm, ".") = 0) && ($FIND(valNm, "$") = 0)) {                     #; 変数の定義があればObjectに登録。                     If (('valList.%IsDefined(valNm)) && 'argList.%IsDefined(valNm)) {                         Do valList.%Set(valNm, $$$NO)                     }                 }             }             Set iter = valList.%GetIterator()             While iter.%GetNext(.key, .value) {                 If ($FIND($REPLACE(line, "Set "_key_" ", ""), key) > 0) {                     Do valList.%Set(key, $$$YES)                 }             }         }         Set iter = valList.%GetIterator()         While iter.%GetNext(.key, .value) {             If ('value) {                 Write "E: "_method.Name_"() にて変数"_key_"は定義されていますが、利用されていない可能性があります。",!                 Set isCorrect = $$$NO             }         }     }     Return isCorrect } Method HasComment(line As %String) As %Boolean [ Private ] {     Return ($MATCH(line, "^( )*#;.*") > 0) || ($MATCH(line, "^( )*//.*") > 0) } } クラス内で行っていること ②で行ったようにメソッド単位でチェックを行います。method.FormalSpec でメソッドの引数のリストを含む文字列を取得します。 上記で取得した文字列から、引数のみを抽出して引数リストを作ります。ここまで来たら、メソッドを1行ずつチェックしていきます。 HasCommentメソッドでコメントの場合は読み飛ばすようにしています。まず、「Set」の使用をチェックします。(If ($FIND(line, "Set ") > 0) {) 使用されている場合でもオブジェクトへの参照は対象外とするため、「.」や「$」が使用されている場合は読み飛ばします。(If (($FIND(valNm, ".") = 0) && ($FIND(valNm, "$") = 0)) {) 「.」や「$」が使用されていないかつ、引数のリストに存在しない場合は後のチェックのためにリストに追加します。(value側は$$$NOで登録しておきます) チェックリストを順番にリストのキー項目が定義箇所以外で使用されているかチェックしていきます。(L50~L54) 使用されている場合、該当キー項目のvalueを$$$YESに更新しておきます。 チェック処理としては上記で完了です。あとはvalueが$$$NOの項目を洗い出して、チェック完了となります。 ④引数の利用チェック - User.Chk.UseArgsChecker Class User.Chk.UseArgsChecker Extends %RegisteredObject { ///  処理概要 :引数の利用チェック ///  IN :clsDef : クラス定義 ///  OUT :%Boolean ///  処理詳細:引数が利用されているかをチェックする。 Method UseArgs(clsDef As %Dictionary.ClassDefinition) As %Boolean { Write "*引数の利用チェック",!     Set ngKeyword = $LISTBUILD(")", """", "}")     Set isCorrect = $$$YES     Set count = clsDef.Methods.Count()     For i = 1: 1: count {         Set method = clsDef.Methods.GetAt(i)         Set args = method.FormalSpec         Do method.Implementation.Rewind()         Set str = method.Implementation.Read()         For j = 1: 1: $LENGTH(args, ",") {             Set arg = $REPLACE($REPLACE($REPLACE($PIECE($PIECE(args, ",", j), ":", 1), "&", ""), "*", ""), "...", "")             If (arg = "") {                 Continue             }             Set isIgnore = $$$NO             Set ptr = 0             While $LISTNEXT(ngKeyword, ptr, value) {                 #; NGリストの内容が含まれていると、切り出し対象外。                 If ($FIND(arg, value) > 0) {                     Set isIgnore = $$$YES                     Quit                 }             }             If (isIgnore) {                 Continue             }             If ($FIND(str, arg) = 0) {                 Write "E: 引数が利用されていません。: "_method.Name_"/"_arg,!                 Set isCorrect = $$$NO             }         }     }     Return isCorrect } } クラス内で行っていること まず、コメントなどを引っ掛けないためにチェックしない文字を定義します。(L10) ③と同様にメソッド単位でチェックをするようにします。(L13~) メソッドの引数を取得します。(L16) メソッドの最初の行からチェックするようにポインタをストリームの先頭にしてメソッドの読込を行います。(L18、L19) 引数の数分チェックをまわしていきます。(L20) 引数の中にチェックしない文字が入っているかチェックします。(L28~L34) チェックしない文字が入っていない場合は、読み込んだメソッドの中で引数が使用されているかチェックを行います。(L39~L42) **3.ソースコントロールへの設定** 管理ポータルにて、[システム管理]⇒[構成]⇒[追加の設定]⇒[ソースコントロール] を開くと ソースコントロールの設定画面となります。 ![image](/sites/default/files/inline/images/hua_xiang_2.png) ![image](/sites/default/files/inline/images/hua_xiang_3.png) ソースコントロールクラス名の一覧には「%Studio.SourceControl.Base」クラスを継承したクラスが表示されます。 ソースコントロールを行いたいネームスペースを選択し、使用したいソースコントロールクラスを選択⇒保存します。 今回はUSERのネームスペースにチェック用クラス用意していますが、%SYSにソースコントロールクラスを作成することで全てのネームスペースに対して使用することができます。 **4.実際の動作** 今回テスト用に用意したクラスが以下のクラスです。 Class User.Test.NewClass1 Extends %RegisteredObject { ///  処理概要 :テストメソッド ///  IN :Str1 : テスト用文字列1 ///  IN :Str2 : テスト用文字列2 ///  OUT :%Boolean Method TestMethod(Str1 As %String, Str2 As %String) As %Boolean {    Set test1 = Str1     Set test2 = "TEST"          Set ^TESTG(test1,"abc") = "hugehuge"          Return $$$OK } } クラス名が「XXXX」始まりでないこと。 - クラスの命名チェック インデントが4の倍数個の半角スペースになっていないこと。(L10) - インデントチェック 定義した変数test2が使用されていないこと。 - 変数の使用チェック TestMethodの引数として用意したStr2が使用されていないこと。 - 引数の使用チェック 実際にコンパイルした結果がこちらです。 ![image](/sites/default/files/inline/images/hua_xiang_4.png) すべてチェックに引っかかっており、コンパイルもされないようになっています。 では、チェッククラスに指摘された部分を修正したものをコンパイルしてみます。 Class User.Test.XXXXNewClass1 Extends %RegisteredObject { ///  処理概要 :テストメソッド ///  IN :Str1 : テスト用文字列1 ///  IN :Str2 : テスト用文字列2 ///  OUT :%Boolean Method TestMethod(Str1 As %String, Str2 As %String) As %Boolean {     Set test1 = Str1     Set test2 = "TEST"     Set ^TESTG(test1,test2) = Str2     Return $$$OK } } ![image](/sites/default/files/inline/images/hua_xiang_5.png) 見事にコンパイルが成功しました。 **5.感想** 今回参考例として4つのチェックを行いましたが、工夫や組み込み方次第では色々なチェックを組み込めると感じました。 Ex.) 変数がキャメルケースになっているか、利用してほしくないプロパティ等が使用されているか etc… また、他のライブラリでチェックツールは色々とあるかと思いますが、今回はIRISの中だけで完結させており、 チェックだけでなくエラーが出なかった時はコンパイルまで通るところがやはり良い部分に感じました。 今回使用したクラスは[Github](https://github.com/KK288650/IRISSourceControlSample)にアップしておりますので、興味のある方はご確認いただければと思います。 *追記)インデントチェッククラスをEmbedded Pythonで記載してみました。(Githubにもアップしております)* Class User.Chk.IndentCheckerP Extends %RegisteredObject { /// 処理概要 :インデント不正チェック /// IN :clsDef : クラス定義 /// OUT :%Boolean /// 処理詳細:インデントが4の倍数になっているかをチェックをする。 ClassMethod UseWrongIndentP(clsDef As %Dictionary.ClassDefinition) As %Boolean [ Language = python ] { import iris import re print("*インデント不正チェック\n") isCorrect = 1 count = clsDef.Methods.Count() for i in range(count): cnt = 0 method = clsDef.Methods.GetAt(i + 1) while not method.Implementation.AtEnd: cnt += 1 line = method.Implementation.ReadLine() if line == '': continue if len(re.compile("^( {4}){1,}[^ ].*").findall(line)) == 0: isCorrect = 0 print("E: インデントが4の倍数になっていません。: " + method.Name + str(cnt) + str(line) + "\n") return isCorrect } } 以上になります。ご一読いただき、ありがとうございました。
記事
Toshihiko Minamoto · 2021年11月3日

REST経由でファイル転送しプロパティに格納する - パート3

この連載の最初の記事では、大きなチャンクのデータをHTTP POSTメソッドのRaw本体から読み取って、それをクラスのストリームクラスとしてデータベースに格納する方法を説明しました。 2つ目の記事では、ファイルとファイル名をJSON形式にラップして送信する方法を説明しました。  それでは、大きなファイルを分割してサーバーに送るという構想を詳しく見ていきましょう。 これを行うために使用できるアプローチにはいくつかあるのですが、 この記事では、Transfer-Encodingヘッダーを使用してチャンク転送を指示する方法を説明します。 Transfer-EncodingヘッダーはHTTP/1.1仕様で導入されたものです。RFC 7230第4.1項では説明されているものの、HTTP/2仕様からはその説明が無くなっています。  Transfer-Encoding(転送符号法)ヘッダー  Transfer-Encodingヘッダーは、ペイロード本体をユーザーに安全に転送するために使用されるエンコードの形式を指定することを目的としています。 主に、動的に生成されたペイロードを正確に区切るため、そして選択されたリソースの特性から、転送効率のためのペイロードエンコードであるのか、セキュリティのためのペイロードエンコードであるのかを区別するために使用します。   このヘッダーでは次の値を使用できます。   Chunked   Compress   Deflate   gzip  Transfer-EncodingがChunkedである場合   Transfer-EncodingをChunkedに設定した場合、メッセージの本文は不特定の数の通常のチャンク、終了チャンク、トレーラー、および最後の行頭復帰・改行(CRLF)シーケンスで構成されます。   各部分は、16進数で表現されるチャンクサイズで始まり、オプションの拡張とCRLFが続きます。 その後には、チャンクの本体と最後にCRLFが続きます。 拡張にはチャンクのメタデータが含まれます。 たとえば、メタデータには署名、ハッシュ、メッセージの中途を制御する情報などが含まれることがあります。 終了チャンクは長さがゼロの通常のチャンクです。 (おそらく空の)ヘッダーフィールドで構成されるトレーラーは、終了チャンクの後に続きます。   想像しやすくするために、以下に「Transfer-Encoding = chunked」を使ってメッセージの構造を示します。  簡単なチャンク化メッセージの例は次のようになります。  13\r\n Transferring Files \r\n 4\r\n on\r\n 1A\r\n community.intersystems.com 0\r\n \r\n このメッセージ本文は、3つの有意義なチャンクで構成されています。 最初のチャンクの長さは19オクテット、2つ目は4オクテット、そして3つ目は26オクテットです。 チャンクの終わりを示す末尾のCRLFはこのチャンクサイズに含まれないことがわかるでしょう。 ただし、CRLFを行末(EOL)マーカーとして使用する場合は、そのCRLFはメッセージの一部として考慮され、2オクテットとなります。 デコードされたメッセージは次のようになります。  Transferring Files on community.intersystems.com IRISでのチャンク化メッセージの作成  このチュートリアルでは、最初の記事で作成したサーバーのメソッドを使用します。 つまり、ファイルのコンテンツを直接POSTメソッドの本体に送信することになります。 ファイルのコンテンツを本体で送信するため、POSTをhttp://webserver/RestTransfer/fileに送信します。  では、IRISでチャンク化メッセージを作成する方法を見てみましょう。 HTTP/1.1を使用しているのであれば、「チャンク化リクエストの送信」のセクションの「HTTPリクエストの送信」で説明されるとおり、HTTPリクエストをチャンクで送信することができます。 このプロセスの最も良いところは、%Net.HttpRequestがメッセージ本文全体のコンテンツの長さをサーバー側で自動的に計算するため、サーバー側で何かを変更する必要がまったくないことです。 したがって、チャンク化されたリクエストを送信するには、クライアントでのみ次の手順に従う必要があります。  最初のステップは、%Net.ChunkedWriterのサブクラスを作成してOutputStreamメソッドを実装することです。 このメソッドはデータのストリームを取得し、それを調べて、分割するかどうかと分割の方法を決定し、継承されたクラスのメソッドを呼び出して出力に書き込みます。 この場合、クラスRestTransfer.ChunkedWriterを呼び出します。  次に、データの送信を行うクライアント側のメソッド(ここでは「SendFileChunked」と呼びます)で、RestTransfer.ChunkedWriterクラスのインスタンスを作成して、送信するリクエストデータを入力する必要があります。 ファイルを送信しようとしているので、面倒な作業はすべてRestTransfer.ChunkedWriterクラスで行うようにします。 Filename As %Stringというプロパティと「MAXSIZEOFCHUNK = 10000」というパラメーターを追加します。 もちろん、チャンクの最大許容サイズをプロパティとして設定し、ファイルまたはメッセージごとに設定することもできます。  最後に、%Net.HttpRequestのEntityBodyプロパティが作成したRestTransfer.ChunkedWriterクラスのインスタンスと等しくなるように設定すれば、準備完了です。  これらの手順は、ファイルをサーバーに送信する既存のメソッドに書き込んだり置換したりする必要のある新しいコードにすぎません。  このメソッドは次のようになります。  ClassMethod SendFileChunked(aFileName) As %Status { Set sc = $$$OK Set request = ..GetLink() set cw = ##class(RestTransfer.ChunkedWriter).%New() set cw.Filename = aFileName set request.EntityBody = cw set sc = request.Post("/RestTransfer/file") Quit:$System.Status.IsError(sc) sc Set response=request.HttpResponse do response.OutputToDevice() Quit sc } %Net.ChunkedWriterクラスは、インターフェースを提供し、いくつかの実装済みメソッドとプロパティを持つ抽象ストリームクラスです。 ここでは、次のプロパティとメソッドを使用します。  プロパティTranslateTable as %Stringは、チャンクを出力ストリーム(EntityBody)に書き込むときに、チャンクの自動変換を強制します。 Rawデータを受け取ることを期待しているため、TranslateTableを “RAW” に設定する必要があります。  メソッドOutputStreamは、すべてのチャンク化操作を行うために、サブクラスによってオーバーライドされる抽象メソッドです。  メソッドWriteSingleChunk(buffer As %String)は、Content-Length HTTPヘッダーとそれに続くエンティティ本体を単一のチャンクとして書き込みます。 ファイルのサイズがMAXSIZEOFCHUNKメソッドよりも小さいかどうかを確認し、小さい場合には、このメソッドを使用します。  メソッドWriteFirstChunk(buffer As %String)は、Transfer-Encodingヘッダーとそれに続く最初のチャンクを書き込みます。 必ず存在する必要があります。 この後にさらにチャンクを書き込むため、0回以上の呼び出しが行われる可能性がありますが、その後、空の文字列を持つ最後のチャンクを書き込む強制的な呼び出しが行われます。 ファイルの長さがMAXSIZEOFCHUNKメソッドを超えることを確認したら、このメソッドを呼び出します。  メソッドWriteChunk(buffer As %String)は結果として得たチャンクを書き込みます。 最初のチャンクの後の残りのファイルが依然としてMAXSIZEOFCHUNKを上回るかを確認してから、このメソッドを使用してデータを送信します。 ファイルの最後の部分のサイズがMAXSIZEOFCHUNKよりも小さくなるまで、この作業を繰り返します。   メソッドWriteLastChunk(buffer As %String)は、最後のチャンクと、それに続く長さゼロのチャンクを書き込み、データの終わりをマークします。   上記のすべてを基にすると、クラスRestTransfer.ChunkedWriterは次のようになります。  Class RestTransfer.ChunkedWriter Extends %Net.ChunkedWriter { Parameter MAXSIZEOFCHUNK = 10000; Property Filename As %String; Method OutputStream() { set ..TranslateTable = "RAW" set cTime = $zdatetime($Now(), 8, 1) set fStream = ##class(%Stream.FileBinary).%New() set fStream.Filename = ..Filename set size = fStream.Size if size < ..#MAXSIZEOFCHUNK { set buf = fStream.Read(.size, .st) if $$$ISERR(st) { THROW st } else { set ^log(cTime, ..Filename) = size do ..WriteSingleChunk(buf) } } else { set ^log(cTime, ..Filename, 0) = size set len = ..#MAXSIZEOFCHUNK set buf = fStream.Read(.len, .st) if $$$ISERR(st) { THROW st } else { set ^log(cTime, ..Filename, 1) = len do ..WriteFirstChunk(buf) } set i = 2 While 'fStream.AtEnd { set len = ..#MAXSIZEOFCHUNK set temp = fStream.Read(.len, .sc) if len<..#MAXSIZEOFCHUNK { do ..WriteLastChunk(temp) } else { do ..WriteChunk(temp) } set ^log(cTime, ..Filename, i) = len set i = $increment(i) } } } } これらのメソッドがファイルをどのように分割しているかを確認するために、次の構造でグローバル^logを追加します。  //単一のチャンクで転送する場合 ^log(time, filename) = size_of_the_file //複数のチャンクで転送する場合 ^log(time, filename, 0) = size_of_the_file ^log(time, filename, idx) = size_of_the_idx’s_chunk プログラミングが完了したので、これら3つのアプローチがさまざまなファイルでどのように機能するのかを見てみましょう。 サーバーを呼び出すための単純なクラスメソッドを記述します。  ClassMethod Run() { // まず、グローバルを削除します。 kill ^RestTransfer.FileDescD kill ^RestTransfer.FileDescS // 次に、送信するファイルのリストを作成します for filename = "D:\Downloads\wiresharkOutput.txt", // 856 バイト "D:\Downloads\wiresharkOutput.pdf", // 60 134 バイト "D:\Downloads\Wireshark-win64-3.4.7.exe", // 71 354 272 バイト "D:\Downloads\IRIS_Community-2021.1.0.215.0-win_x64.exe" //542 370 224 bytes { write !, !, filename, !, ! // そしてデータをサーバー側に送信する3つのメソッドをすべて呼び出します。 set resp1=##class(RestTransfer.Client).SendFileChunked(filename) if $$$ISERR(resp1) do $System.OBJ.DisplayError(resp1) set resp1=##class(RestTransfer.Client).SendFile(filename) if $$$ISERR(resp1) do $System.OBJ.DisplayError(resp1) set resp1=##class(RestTransfer.Client).SendFileDirect(filename) if $$$ISERR(resp1) do $System.OBJ.DisplayError(resp1) } } クラスメソッドRunを実行した後、最初の3つのファイルの出力では、ステータスは正常となりました。 しかし、最後のファイルでは、最初と最後の呼び出しは動作するにもかかわらず、真ん中の呼び出しはエラー: 5922を返しました。これは応答待ちのタイムアウトです。 globalsメソッドを見ると、コードが11番目のファイルを保存しなかったことがわかります。 つまり、##class(RestTransfer.Client).SendFile(filename)が失敗しています。正確に言えば、JSONからデータを取り出すメソッドが成功しなかったということです。   ここで、ストリームを見ると、正常に保存されたファイルのサイズがすべて正しいことがわかります。  ^logグローバルを見ると、各ファイルに対してコードが作成したチャンク数がわかります。  おそらく、実際のメッセージの本文を確認したいところでしょう。 Eduard Lebedyukは、「Webをデバッグする」という記事の中で、CSP ゲートウェイロギングとトレーシングを使用できると提案しています。  イベントログで2つ目のチャンクファイルを見ると、Transfer-Encodingヘッダーの値が実際に「chunked」となっていることがわかります。 残念ながら、サーバーはすでにメッセージを接合してしまっているため、実際のチャンクを確認することはできません。  トレース機能を使用しても、それ以上の情報はあまり表示されませんが、最後から2番目と最後のリクエストの間にギャップがあることが明らかになります。  メッセージの実際の部分を確認するには、クライアントを別のコンピューターにコピーして、ネットワークスニファーを使用します。 ここでは、Wiresharkを選択しました。これは無料のツールで、必要な機能が揃っているためです。 コードがファイルをチャンクに分割する方法をわかりやすく示すには、MAXSIZEOFCHUNKの値を100に変更して、小さなファイルを送信することができます。 すると、次のような結果が表示されます。  最後の2つのチャンクを除くすべてのチャンクの長さがHEXの64(DECの100)に等しく、データのある最後のチャンクは21 DEC(HEXでは15)であることがわかります。また、最後のチャンクのサイズがゼロであることを確認できます。 すべては正常にみえるため、仕様に合致しています。 ファイルの全長さは421(4x100+1x21)に等しく、これをグロバールで確認することもできます。  まとめ  総合的に、このアプローチは動作し、大きなファイルを問題なくサーバーに送信できることがわかります。 さらに、大量のデータをクライアントに送信する場合は、Web ゲートウェイの動作と構成の「アプリケーション・パスの構成パラメータ」セクションにあるパラメーター「応答サイズの通知」をよく読むことをお勧めします。 これは、使用するHTTPのバージョンに応じて、大量のデータを送信する際のWebゲートウェイの動作を指定するパラメーターです。  このアプローチのコードは、GitHubとInterSystems Open Exchangeにある、この例の前のバージョンに追加されています。  ファイルをチャンクで送信するというトピックでは、Transfer-Encodingヘッダーの有無に関わらずContent-Rangeヘッダーを使用して、データのどの部分が転送されているのかを示すことも可能です。 さらに、HTTP/2仕様で利用できる、まったく新しいストリームの概念を使用することができます。  いつものように、質問や提案があれば、お気軽にコメントセクションに書き込んでください。
記事
Toshihiko Minamoto · 2021年5月12日

インデックスを理解する

これは、SQLインデックスに関する2部構成の記事の前半です。 第1部 - インデックスを理解する   インデックスとは?   最後に図書館に行った時のことを思い出してください。 通常そこには、分野別(そして作者順と題名順)に整理された本が並び、それぞれの棚には、本の分野を説明したコードが記載された本立てがあります。 特定の分野の本を収集する場合、すべての通路を歩いて一冊ずつ本の表紙を読む代わりに、目的の分野の本棚に直接向かって選ぶことができるでしょう。 SQLインデックスにもこれと同じ機能があります。テーブルの各行にフィールドの値へのクイック参照を提供することで、パフォーマンスを向上させています。 インデックスの設定は、最適なSQLパフォーマンスを得られるようにクラスを準備する際の主なステップの1つです。 この記事では、次のことについて説明します。 1. インデックスとは何か。いつ、なぜそれを使用するか。 2. どのようなインデックスが存在するか、どのようなシナリオに適しているのか。 3. インデックスの例 4. 作成方法 インデックスが存在する場合、どのように扱うのか。 この記事では、Sampleスキーマのクラスを参照します。 このスキーマは以下に示すGitHubリポジトリにあります。また、CachéとEnsembleでインストールされるSamplesネームスペースでも提供されています。 https://github.com/intersystems/Samples-Data   基本   永続プロパティと、永続データから確実に計算されるプロパティにインデックスを作成できます。 Sample.CompanyのTaxIDプロパティにインデックスを作成するとしましょう。 StudioまたはAtelierで、以下のコードをクラス定義に追加します。                   Index TaxIDIdx On TaxID;   これに相当するDDL SQLステートメントは、次のようになります。                   CREATE INDEX TaxIDIdx ON Sample.Company (TaxID); デフォルトのグローバルインデックス構造は、次のようになります。                   ^Sample.CompanyI("TaxIDIdx ",<TaxIDValueAtRowID>,<RowID>) = ""   通常のデータグローバルのフィールドより、読み取るサブスクリプトが少ないところに注目してください。   「SELECT Name,TaxID FROM Sample.Company WHERE TaxID = 'J7349'」というクエリを見てみましょう。 論理的に単純なクエリです。このクエリを実行するためのクエリプランは、これを反映しています。     このプランは基本的に、指定されたTaxID値を持つ行のインデックスグローバルをチェックし、データグローバル(「マスターマップ」)を参照して一致する行を取得するように指定しています。 ここで、同じクエリを、TaxIDXにインデックスを使わずに考察してみましょう。 クエリプランの効率は、予想どおり、低下します。     インデックスがない場合、IRISの基盤のクエリ実行は、メモリを読み取って、テーブルの各行にWHERE句の条件を適用します。論理的に言って、TaxIDを共有する会社はないと思うため、この作業をたった1行のためだけに行っているのです! もちろん、インデックスを使用するということは、インデックスと行データがディスクにあるということですので、 条件の内容とテーブルに含まれるデータの量によっては、インデックスを作成してデータを入力する際に、それ固有の問題が生じる可能性もあります。 では、プロパティにはいつインデックスを追加すればよいのでしょうか。 とされることの多いエクステントビットマップは、クラスのIDにおけるビットマップインデックスです。IRISはこれを使って行が存在するのかをすばやく検出し、COUNTクエリまたはサブクラスのクエリに役立てています。 これらのインデックスは、ビットマップインデックスがクラスに追加される際に自動的に生成されますが、次のように、クラス定義にビットマップエクステントインデックスを手動で作成することも可能です。 Index Company [ Extent, SqlName = "$Company", Type = bitmap ];   DDLのBITMAPEXTENTキーワードを使うこともできます。   CREATE BITMAPEXTENT INDEX "$Company" ON TABLE Sample.Company   複合 - 2つ以上のプロパティに基づくインデックス   Index OfficeAddrIDX On (Office.City, Office.State);   複合インデックスは通常、2つ以上のプロパティを条件とするクエリが頻繁に発生する場合に使用できます。 インデックスはグローバルレベルで格納されるため、複合インデックスでは、プロパティの順序が重要になります。 インデックスグローバルの最初のディスク読み取りは保存されるため、選択する頻度の高いプロパティを最初に指定すると、高いパフォーマンス効率を得ることができます。この例では、米国の州の数より都市の数の方が多いため、Office.Cityが最初に指定されています。 あまり選択しないプロパティを最初に指定すると、スペースの効率性が高くなります。 グローバル構造に焦点を当てれば、Stateを最初に指定すると、インデックスツリーのバランスがより高まります。 考えてみれば、各州には多数の市がありますが、1つの州にしか存在しない市もあるのです。 また、いずれかのプロパティのみを条件としたクエリを頻繁に実行するのかどうかを検討することもお勧めします。別のインデックスを定義する手間を省けるからです。 複合インデックスのグローバル構造の例を以下に示します。   ^Sample.PersonI("OfficeAddrIDX"," BOSTON"," MA",100115)="~Sample.Employee~"   余談: 複合インデックスかビットマップインデックスか 複数のプロパティで条件付けするクエリの場合、個別のビットマップインデックスを使った方が1つの複合インデックスよりも効果的かどうかを検討することもできます。 ビットマップインデックスが各プロパティに適切に適合するのであれば、2つの異なるインデックスに対してビット演算した方が効率的になる可能性があります。 複合ビットマップインデックスを作成することもできます。これらはユニーク値が、インデックスを作成している複数のプロパティの共通した値となるビットマップインデックスです。 前のセクションで示したテーブルを考察してみましょう。ただし、州の代わりに、州と市のすべての可能な組み合わせ(マサチューセッツ州ボストン、マサチューセッツ州ケンブリッジ、マサチューセッツ州ロサンゼルスなど)を用いたテーブルです。両方の値に適合する行のセルは1となります。   コレクション - コレクションプロパティに基づくインデックス   次のように定義されたFavoriteColorsプロパティがあります。 Property FavoriteColors As list Of %String; 実演の目的で、インデックスは次のように定義されています。 Index fcIDX1 On FavoriteColors(ELEMENTS);Index fcIDX2 On FavoriteColors(KEYS); ここでは、複数の値を含む単一セルのプロパティをより広く参照するために、「コレクション」を使用しています。 ここでは、List OfとArray Ofプロパティが重要で、必要に応じて区切り付きの文字列も指定できます。 コレクションプロパティは自動的に解析され、インデックスが構築されます。 電話番号などの区切り付きのプロパティでは、このメソッドを明示的に <PropertyName>BuildValueArray(value, .valueArray) と定義する必要があります。 上記のFavoriteColorsの例で考えると、お気に入りの色が青と白であるPersonのfcIDX1は、次のようになります。   ^Sample.PersonI("fcIDX1"," BLUE",100115)="~Sample.Employee~" (…) ^Sample.PersonI("fcIDX1"," WHITE",100115)="~Sample.Employee~"   そしてfcIDX2は次のようになります。            ^Sample.PersonI("fcIDX2",1,100115)="~Sample.Employee~"       ^Sample.PersonI("fcIDX2",2,100115)="~Sample.Employee~"   この場合、FavoriteCoslorsはListコレクションであるため、キーに基づくインデックスの有用性は、要素に基づくインデックスよりも低くなります。 コレクションプロパティのインデックスの作成と管理に関するより詳しい考慮事項については、ドキュメントをご覧ください。 https://docs.intersystems.com/latest/csp/docbook/DocBook.UI.Page.cls?KEY=GSQLOPT_indices#GSQLOPT_indices_collections   ビットスライス - 数値データのビット文字列表現のビットマップ表現   Index SalaryIDX On Salary [ Type = bitslice ]; //In Sample.Employee   ビットスライスインデックスは、どの行に特定の値が含まれるかを表すフラグを含むビットマップインデックスとは異なり、最初に数値を10進数から2進数に変換し、その後で2進数値の各桁にビットマップを作成します。 上記の例を見てみましょう。現実的に考えられるよう、Salaryを$1000単位として単純化します。つまり、従業員の給与が65であれば、65,000ドルということになります。 ID 1のEmployeeのSalaryを15、ID 2のSalaryを40、ID 3のSalaryを64、ID 4のSalaryを130とします。 この場合、対応するビット値は次のようになります。   15 0 0 0 0 1 1 1 1 40 0 0 1 0 1 0 0 0 64 0 1 0 0 0 0 0 0 130 1 0 0 0 0 0 1 0   ビット文字列は8桁を超えています。 対応するビットマップ表現(ビットスライスインデックス値)は、基本的に次ように格納されます。   ^Sample.PersonI("SalaryIDX",1,1) = "1000" ; Row 1 has value in 1’s place ^Sample.PersonI("SalaryIDX",2,1) = "1001" ; Rows 1 and 4 have values in 2’s place ^Sample.PersonI("SalaryIDX",3,1) = "1000" ; Row 1 has value in 4’s place ^Sample.PersonI("SalaryIDX",4,1) = "1100" ; Rows 1 and 2 have values in 8’s place ^Sample.PersonI("SalaryIDX",5,1) = "0000" ; etc… ^Sample.PersonI("SalaryIDX",6,1) = "0100" ^Sample.PersonI("SalaryIDX",7,1) = "0010" ^Sample.PersonI("SalaryIDX",8,1) = "0001"   Sample.Employeeまたはその行の給与を変更する演算(INSERT、UPDATES、DELETE)では、これらの各グローバルノードまたはビットスライスを更新する必要があることに注意してください。 ビットスライスインデックスをテーブルの複数のプロパティまたは頻繁に変更されるプロパティに追加すると、パフォーマンスにリスクが生じる可能性があります。 一般的に、ビットスライスインデックスの管理には、標準またはビットマップインデックスの管理よりもコストがかかります。 ビットスライスインデックスは非常に特殊であるため、ユースケースも特殊であり、SUM、COUTN、またはAVGなどの集計計算を実行する必要のあるクエリで使用します。 さらに、数値に対してのみ効果を発揮するため、文字列は2進数の0に変換されます。 クエリの条件をチェックするために、インデックスではなくデータテーブルを読み取る必要がある場合、クエリの実行にビットスライスインデックスは選択されません。 Sample.PersonのNameにインデックスがないとします。 Smithという姓の従業員の平均給与を計算する場合(SELECT AVG(Salary) FROM Sample.Employee WHERE Name %STARTSWITH 'Smith,' )、WHERE条件を適用するためにデータ行を読み取る必要があるため、ビットスライスは実際には使用されません。 行が頻繁に作成または削除されるテーブルのビットスライスとビットマップインデックスについても、同様のストレージに関する懸念があります。   データ - グローバルノードに格納されているデータのインデックス。   Index QuickSearchIDX On Name [ Data = (SSN, DOB, Name) ];   前のいくつのかの例で、「~Sample.Employee~」という文字列がノード自体に値として格納されていることに気づいたかもしれません。 Sample.Employeeは、Sample.Personからインデックスを継承していることを思い出してください。 特にEmployeesをクエリする場合、プロパティ条件に一致するインデックスノードの値を読み取り、そのPersonがEmployeeでもあることを確認します。 また、格納する値を明示的に定義することもできます。 インデックスグローバルノードでデータを定義すると、データグローバルの読み取りも保存できます。頻繁な選択クエリや順序付けクエリに役立ちます。 上記のインデックスを例とすると、 名前の全部または一部を指定された人物に関する識別情報を取得する場合(フロントデスクアプリケーションでクライアントの情報を検索する場合など)、「SELECT SSN, Name, DOB FROM Sample.Person WHERE Name %STARTSWITH 'Smith,J' ORDER BY Name」というクエリを実行できます。 Nameをクエリ条件としており、取得しようとしている値はすべてQuickSearchIDXグローバルノード内に格納されているため、このクエリを実行するには、グローバルのみを読み取る必要があります。 データ値は、ビットマップまたはビットスライスインデックスと保存できないことに注意してください。 ^Sample.PersonI("QuickSearchIDX"," LARSON,KIRSTEN A.",100115)=$lb("~Sample.Employee~","555-55-5555",51274,"Larson,Kirsten A.")   iFindインデックス   このようなインデックスを聞いたことがあるでしょうか? 私にもありません。iFindインデックスは、ストリームプロパティで使用されますが、これを使用するには、クエリにキーワードで名前を指定する必要があります。 もう少し説明することもできますが、このことについては、Kyle Baxterがすでに有用な記事を執筆しています。 フリーテキスト検索:SQL開発者が秘密にしているテキストフィールドの検索方法      [最終更新日: 2020年4月16日 - 可読性を調整。]      
記事
Tomohiro Iwamoto · 2023年4月3日

IRISだけでoAuth2/OpenID ConnectのSSO/SLO環境を実現する

本記事は、あくまで執筆者の見解であり、インターシステムズの公式なドキュメントではありません。 IRISのoAuth2機能関連の情報発信は既に多数ありますが、本稿では - 手順(ほぼ)ゼロでひとまず動作させてみる - 設定の見通しを良くするために、役割ごとにサーバを分ける - 目に見えない動作を確認する - クライアント実装(PythonやAngular,CSPアプリケーション等)と合わせて理解する - シングルサインオン/シングルログアウトを実現する ということを主眼においています。 コミュニティ版で動作しますので、「とりあえず動かす」の手順に従って、どなたでもお試しいただけます。 > 現状、使用IRISバージョンはIRIS 2023.1のプレビュー版になっていますが、[ソースコード](https://github.com/IRISMeister/iris-oauth2)は適宜変更します。 手順に沿ってコンテナを起動すると下記の環境が用意されます。この環境を使用して動作を確認します。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/oauth-demo-env.png) ユーザエージェント(ブラウザ)やPython/curlからのアクセスは、全てApache (https://webgw.localdomain/) 経由になります。青枠の中のirisclient等の文字はコンテナ名(ホスト名)です。 例えば、irisclientホストの/csp/user/MyApp.Login.clsにアクセスする場合、URLとして ``` https://webgw.localdomain/irisclient/csp/user/MyApp.Login.cls ``` と指定します。 > つまり、各エンドポイントは同一のorigin (https://webgw.localdomain) を持ちます。そのため、クロスサイト固有の課題は存在しません(カバーされません)が、仮に各サーバが別のドメインに存在しても基本的には動作するはずです。 oAuth2/OIDC(OpenID Connect)の利用シーンは多種多様です。 本例は、認証・認可サーバ,クライアントアプリケーション,リソースサーバの全てがIRISで実行されるクローズドな環境(社内や組織内での使用)を想定して、認可コードフロー(Authorization Code Flow)を実現します。分かりやすい解説が、ネットにたくさんありますので、コードフロー自身の説明は本稿では行いません。 >認証・認可サーバの候補はIRIS, WindowsAD, Azure AD, AWS Cognito, Google Workspace, keycloak, OpenAMなどがあり得ます。個別に動作検証が必要です。 クライアントアプリケーション(RP)は、昨今はSPAが第一候補となると思いますが、利用環境によっては、SPA固有のセキュリティ課題に直面します。 IRISには、Confidential Clientである、従来型のWebアプリケーション(フォームをSubmitして、画面を都度再描画するタイプのWebアプリケーション)用のoAuth2関連のAPI群が用意されています。 そこで、Webアプリケーション(CSP)を選択することも考えられますが、クライアント編では、よりセキュアとされるSPA+BFF(Backend For Frontend)の構成を実現するにあたり、Webアプリケーション用APIをそのまま活用する方法をご紹介する予定です。 > 以下、サーバ編の動作確認には、CSPアプリケーションを使用しています。これは、新規開発にCSP(サーバページ)を使用しましょう、という事ではなく、BFF実現のために必要となる機能を理解するためです。BFFについては、クライアント編で触れます。BFFについては、[こちら](https://dev.to/damikun/web-app-security-understanding-the-meaning-of-the-bff-pattern-i85)の説明がわかりやすかったです。 リソースサーバの役割はデータプラットフォームであるIRISは最適な選択肢です。医療系用のサーバ機能ですがFHIRリポジトリはその良い例です。本例では、至極簡単な情報を返すAPIを使用しています。 > 少しの努力でFHIRリポジトリを組み込むことも可能です。 サーバ編とクライアント編に分けて記載します。今回はサーバ編です。 > とはいえ、クライアントとサーバが協調動作する仕組みですので、境界は少しあいまいです --------- # 使用環境 - Windows10 ブラウザ(Chrome使用)、curl及びpythonサンプルコードを実行する環境です。 - Liunx (Ubuntu) IRIS, WebGateway(Apache)を実行する環境です。Windows10上のwsl2、仮想マシンあるいはクラウドで動作させる事を想定しています。 参考までに私の環境は以下の通りです。 --------- |用途|O/S|ホストタイプ| |:--|:--|:--| |クライアントPC|Windows10 Pro|物理ホスト| |Linux環境|ubuntu 22.04.1 LTS|上記Windows10上のwsl2| --------- Linux環境はVMでも動作します。VMのubuntuは、[ubuntu-22.04-live-server-amd64.iso](https://releases.ubuntu.com/22.04/ubuntu-22.04.1-live-server-amd64.iso )等を使用して、最低限のサーバ機能のみをインストールしてあれば十分です。 # Linux上に必要なソフトウェア 実行にはjq,openssl,dockerが必要です。 私の環境は以下の通りです。 ``` $ jq --version jq-1.6 $ openssl version OpenSSL 3.0.2 15 Mar 2022 (Library: OpenSSL 3.0.2 15 Mar 2022) $ docker version Client: Docker Engine - Community Version: 23.0.1 ``` # とりあえず動かす 下記手順でとりあえず動かしてみることが出来ます。 - 以下は、Linuxで実行します。 ```bash git clone https://github.com/IRISMeister/iris-oauth2.git --recursive cd iris-oauth2 ./first-run.sh ``` この時点で下記をLinuxで実行し、OpenIDプロバイダーのメタデータを取得できる事を確認してください。[こちら](https://github.com/IRISMeister/iris-oauth2/blob/main/docs/openid-configuration.json)のような出力が得られるはずです。 ```bash curl http://localhost/irisauth/authserver/oauth2/.well-known/openid-configuration ``` - 以下はWindowsで実行します。 クライアントPC(Windows)にホスト名(webgw.localdomain)を認識させるために、%SystemRoot%\system32\drivers\etc\hostsに下記を追加します。 wsl2使用でかつlocalhostForwarding=Trueに設定してある場合は下記のように設定します。 ``` 127.0.0.1 webgw.localdomain ``` VM使用時は、LinuxのIPを指定します。 ``` 192.168.11.48 webgw.localdomain ``` 次に、httpsの設定が正しく機能しているか確認します。作成された証明書チェーンをWindows側のc:\tempにコピーします。 ``` cp ssl/web/all.crt /mnt/c/temp ``` > VMの場合は、scp等を使用してssl/web/all.crtを c:\temp\all.crtにコピーしてください。以後、WSL2のコマンドのみを例示します。 PCからcurlでリソースサーバの認証なしのRESTエンドポイントにアクセスします。ユーザ指定(-u指定)していないことに注目してください。 ```DOS curl --cacert c:\temp\all.crt --ssl-no-revoke -X POST https://webgw.localdomain/irisrsc/csp/myrsc/public {"HostName":"irisrsc","UserName":"UnknownUser","sub":"","aud":"","Status":"OK","TimeStamp":"03/28/2023 17:39:17","exp":"(1970-01-01 09:00:00)","debug":{}} ``` 認証なしのRESTサービスですので成功するはずです。次にアクセストークン/IDトークンによる認証・認可チェック処理を施したエンドポイントにアクセスします。 ```DOS curl --cacert c:\temp\all.crt --ssl-no-revoke -X POST https://webgw.localdomain/irisrsc/csp/myrsc/private { "errors":[ { "code":5035, "domain":"%ObjectErrors", "error":"エラー #5035: 一般例外 名前 'NoAccessToken' コード '5001' データ ''", "id":"GeneralException", "params":["NoAccessToken",5001,"" ] } ], "summary":"エラー #5035: 一般例外 名前 'NoAccessToken' コード '5001' データ ''" } ``` こちらは、期待通りエラーで終了します。 次に、ブラウザで[CSPベースのWEBクライアントアプリケーション](https://webgw.localdomain/irisclient/csp/user/MyApp.Login.cls)を開きます。 > プライベート認証局発行のサーバ証明書を使用しているため、初回はブラウザで「この接続ではプライバシーが保護されません」といったセキュリティ警告が出ます。アクセスを許可してください。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/login.png) 「oAuth2認証を行う」ボタンを押した際に、ユーザ名、パスワードを求められますので、ここではtest/testを使用してください。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/user-pass.png) 権限の要求画面で「許可」を押すと各種情報が表示されます。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/authorize.png) ページ先頭に「ログアウト(SSO)」というリンクがありますので、クリックしてください。最初のページに戻ります。 IRISコミュニティエディションで、接続数上限に達してしまうと、それ以後は[Service Unavailable]になったり、認証後のページ遷移が失敗したりしますので、ご注意ください。その場合、下記のような警告メッセージがログされます。 ``` docker compose logs irisclient iris-oauth2-irisclient-1 | 03/24/23-17:14:34:429 (1201) 2 [Generic.Event] License limit exceeded 1 times since instance start. ``` しばらく(10分ほど)待つか、終了・起動をしてください。 - 以下は、Linuxで実行します。 終了させるには下記を実行します。 ```bash ./down.sh ``` # 主要エンドポイント一覧 下図は、コード認可フローを例にした、各要素の役割になります。用語としてはoAuth2を採用しています。 ![](https://community.intersystems.com/sites/default/files/inline/images/images/image-20200703154452-1.png) OIDCはoAuth2の仕組みに認証機能を載せたものなので、各要素は重複しますが異なる名称(Authorization serverはOIDC用語ではOP)で呼ばれています。 > CLIENT SERVERという表現は「何どっち?」と思われる方もおられると思いますが、Client's backend serverの事で、サーバサイドに配置されるロジック処理機能を備えたWebサーバの事です。描画を担うJavaScriptなどで記述されたClient's frontendと合わせて単にClientと呼ぶこともあります。 ----- |要素|サービス名|OIDC用語|oAuth2用語|エンドポイント| |:--|:--|:--|:--|:--| |ユーザエージェント|N/A|User Agent|User Agent|N/A| |Web Gateway|webgw|N/A|N/A|[/csp/bin/Systems/Module.cxw](http://webgw.localdomain/csp/bin/Systems/Module.cxw)| |認可サーバの管理|irisauth|N/A|N/A|[/irisauth/csp/sys/%25CSP.Portal.Home.zen](http://webgw.localdomain/irisauth/csp/sys/%25CSP.Portal.Home.zen)| |リソースサーバ#1の管理|irisrsc|N/A|N/A|[irisrsc/csp/sys/%25CSP.Portal.Home.zen](https://webgw.localdomain/irisrsc/csp/sys/%25CSP.Portal.Home.zen)| |リソースサーバ#1|irisrsc|N/A|Resource server|[/irisrsc/csp/myrsc/private](https://webgw.localdomain/irisrsc/csp/myrsc/private)| |リソースサーバ#2の管理|irisrsc2|N/A|N/A|[/irisrsc2/csp/sys/%25CSP.Portal.Home.zen](https://webgw.localdomain/irisrsc2/csp/sys/%25CSP.Portal.Home.zen)| |リソースサーバ#2|irisrsc2|N/A|Resource server|[/irisrsc2/csp/myrsc/private](https://webgw.localdomain/irisrsc2/csp/myrsc/private)| |WebApp 1a,1bの管理|irisclient|N/A|N/A|[/irisclient/csp/sys/%25CSP.Portal.Home.zen](http://webgw.localdomain/irisclient/csp/sys/%25CSP.Portal.Home.zen)| |WebApp 1a|irisclient|RP|Client server|[/irisclient/csp/user/MyApp.Login.cls](https://webgw.localdomain/irisclient/csp/user/MyApp.Login.cls)| |WebApp 1b|irisclient|RP|Client server|[/irisclient2/csp/user/MyApp.AppMain.cls](https://webgw.localdomain/irisclient/csp/user2/MyApp.Login.cls)| |WebApp 2の管理|irisclient2|N/A|N/A|[/irisclient2/csp/sys/%25CSP.Portal.Home.zen](http://webgw.localdomain/irisclient2/csp/sys/%25CSP.Portal.Home.zen)| |WebApp 2|irisclient2|RP|Client server|[/irisclient2/csp/user/MyApp.AppMain.cls](https://webgw.localdomain/irisclient2/csp/user/MyApp.AppMain.cls)| > エンドポイントのオリジン(https://webgw.localdomain)は省略しています ----- 組み込みのIRISユーザ(SuperUser,_SYSTEM等)のパスワードは、[merge1.cpf](https://github.com/IRISMeister/iris-oauth2/blob/master/cpf/merge1.cpf)のPasswordHashで一括で"SYS"に設定しています。管理ポータルへのログイン時に使用します。 # 導入手順の解説 first-run.shは、2~5を行っています。 1. ソースコード入手 ```bash git clone https://github.com/IRISMeister/iris-oauth2.git --recursive ``` 2. SSL証明書を作成 ``` ./create_cert_keys.sh ``` [apache-ssl](https://github.com/IRISMeister/apache-ssl.git)に同梱のsetup.shを使って、鍵ペアを作成し、出来たsslフォルダの中身を丸ごと、ssl/web下等にコピーしています。コピー先と用途は以下の通りです。 |コピー先|使用場所|用途| |:--|:--|:--| |ssl/web/| ApacheのSSL設定およびクライアントアプリ(python)| Apacheとのhttps通信用| |irisauth/ssl/auth/|認可サーバ| 認可サーバのクライアント証明書| |irisclient/ssl/client/|CSPアプリケーション#1a,1b| IRIS(CSP)がクライアントアプリになる際のクライアント証明書| |irisclient2/ssl/client/|CSPアプリケーション#2| IRIS(CSP)がクライアントアプリになる際のクライアント証明書| |irisrsc/ssl/resserver/|リソースサーバ| リソースサーバのクライアント証明書| |irisrsc2/ssl/resserver/|リソースサーバ#2| リソースサーバのクライアント証明書| 3. PCにクライアント用の証明書チェーンをコピー all.crtには、サーバ証明書、中間認証局、ルート認証局の情報が含まれています。curlやpythonなどを使用する場合、これらを指定しないとSSL/TLSサーバ証明書の検証に失敗します。 ```bash cp ssl/web/all.crt /mnt/c/temp ``` >備忘録 >下記のコマンドで内容を確認できます。 >```bash >openssl crl2pkcs7 -nocrl -certfile ssl/web/all.crt | openssl pkcs7 -print_certs -text -noout >``` 4. Web Gatewayの構成ファイルを上書きコピー ```bash cp webgateway* iris-webgateway-example/ ``` 5. コンテナイメージをビルドする ```bash ./build.sh ``` >各種セットアップは、各サービス用のDockerfile以下に全てスクリプト化されています。iris関連のサービスは、原則、##class(MyApps.Installer).setup()で設定を行い、必要に応じてアプリケーションコードをインポートするという動作を踏襲しています。例えば、認可サーバの設定はこちらの[Dockefile](https://github.com/IRISMeister/iris-oauth2/blob/master/irisauth/Dockerfile)と、インストーラ用のクラスである[MyApps.Installer](https://github.com/IRISMeister/iris-oauth2/blob/master/irisauth/src/MyApps/Installer.cls)(内容は後述します)を使用しています。 6. ブラウザ、つまりクライアントPC(Windows)にホスト名webgw.localdomainを認識させる 上述の通りです。 # 起動方法 ``` ./up.sh ``` up時に表示される下記のようなjsonは、後々、pythonなどの非IRISベースのクライアントからのアクセス時に使用する事を想定しています。各々client/下に保存されます。 ```json { "client_id": "trwAtbo5DKYBqpjwaBu9NnkQeP4PiNUgnbWU4YUVg_c", "client_secret": "PeDUMmFKq3WoCfNfi50J6DnKH9KlTM6kHizLj1uAPqDzh5iPItU342wPvUbXp2tOwhrTCKolpg2u1IarEVFImw", "issuer_uri": "https://webgw.localdomain/irisauth/authserver/oauth2" } ``` コンテナ起動後、ブラウザで下記(CSPアプリケーション)を開く。 https://webgw.localdomain/irisclient/csp/user/MyApp.Login.cls # 停止方法 ```bash ./down.sh ``` # 認可サーバの設定について ## カスタマイズ内容 多様なユースケースに対応するために、[認可サーバの動作をカスタマイズする機能](https://docs.intersystems.com/irislatestj/csp/docbook/DocBook.UI.Page.cls?KEY=GOAUTH_authz#GOAUTH_authz_code)を提供しています。 特に、%OAuth2.Server.Authenticateはプロダクションには適さない可能性が高いのでなんらかのカスタマイズを行うように[注記](https://docs.intersystems.com/irislatestj/csp/docbook/DocBook.UI.Page.cls?KEY=GOAUTH_authz#GOAUTH_authz_oauth2serverauthenticate)されていますのでご注意ください。 本例では、認証関連で下記の独自クラスを採用しています。 - 認証クラス [%ZOAuth2.Server.MyAuthenticate.cls](https://github.com/IRISMeister/iris-oauth2/blob/master/irisauth/src/%25ZOAuth2/Server/MyAuthenticate.cls) 下記を実装しています。 BeforeAuthenticate() — 必要に応じてこのメソッドを実装し、認証の前にカスタム処理を実行します。 ドキュメントに下記の記載があります。本例ではscope2が要求された場合には、応答に必ずscope99も含める処理を行っています。 >通常、このメソッドを実装する必要はありません。ただし、このメソッドの使用事例の1つとして、FHIR® で使用される launch と launch/patient のスコープを実装するのに利用するというようなものがあります。この事例では、特定の患者を含めるようにスコープを調整する必要があります。 AfterAuthenticate() — 必要に応じてこのメソッドを実装し、認証の後にカスタム処理を実行します。 ドキュメントに下記の記載があります。本例ではトークンエンドポイントからの応答にaccountno=12345というプロパティを付与する処理を行っています。 >通常、このメソッドを実装する必要はありません。ただし、このメソッドの使用事例の1つとして、FHIR® で使用される launch と launch/patient のスコープを実装するのに利用するというようなものがあります。この事例では、特定の患者を含めるようにスコープを調整する必要があります。 トークンエンドポイントからの応答はリダイレクトの関係でブラウザのDevToolでは確認できません。[pythonクライアント](https://github.com/IRISMeister/python-oauth2-client)で表示出来ます。 ``` { 'access_token': '...........', 'accountno': '12345', 'expires_at': 1680157346.845698, 'expires_in': 3600, 'id_token': '...........', 'refresh_token': '..........', 'scope': ['openid', 'profile', 'scope1', 'scope2', 'scope99'], 'token_type': 'bearer' } ``` - ユーザクラスを検証(ユーザの検証を行うクラス) [%ZOAuth2.Server.MyValidate.cls](https://github.com/IRISMeister/iris-oauth2/blob/master/irisauth/src/%25ZOAuth2/Server/MyValidate.cls) 下記を実装しています。 ValidateUser() — (クライアント資格情報を除くすべての付与タイプで使用) ここでは、トークンに含まれる"aud"クレームのデフォルト値を変更したり、カスタムクレーム(customer_id)を含める処理を行っています。 ``` { "jti":"https://webgw.localdomain/irisauth/authserver/oauth2.UQK89uY7wBdysNvG-fFh44AxFu8", "iss":"https://webgw.localdomain/irisauth/authserver/oauth2", "sub":"test", "exp":1680156948, "aud":[ "https://webgw.localdomain/irisrsc/csp/myrsc", "https://webgw.localdomain/irisrsc2/csp/myrsc", "pZXxYLRaP8vAOjmMetLe1jBIKl0wu4ehCIA8sN7Wr-Q" ], "scope":"openid profile scope1", "iat":1680153348, "customer_id":"RSC-00001", "email":"test@examples.com", "phone_number":"01234567" } ``` これらの独自クラスは、下記で設定しています。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/oauth-server-customize.png) ## リフレッシュトークン 「パブリッククライアント更新を許可」をオンにしています。 この設定をオンにすると、client_secretを含まない(つまりpublic clientの要件を満たすクライアント)からのリフレッシュトークンフローを受け付けます。そもそもPublic Clientにはリフレッシュトークンを発行しない、という選択もありますが、ここでは許可しています。 また、「リフレッシュ・トークンを返す」項目で「常にリフレッシュトークンを返す」を設定しています。 > 「scopeに"offline_access"が含まれている場合のみ」のように、より強めの制約を課すことも可能ですが、今回は無条件に返しています ## ユーザセッションをサポート 認可サーバの"ユーザセッションをサポート"を有効に設定しています。この機能により、シングルサインオン(SSO)、シングルログアウト(SLO)が実現します。 > ユーザセッションをユーザエージェントとRP間のセッション維持に使用する"セッション"と混同しないよう この設定を有効にすると、認可時に使用したユーザエージェントは、以後、ユーザ名・パスワードの再入力を求めることなくユーザを認証します。以下のように動作を確認できます。 1. [CSPベースのアプリケーション#1a](https://webgw.localdomain/irisclient/csp/user/MyApp.Login.cls)をブラウザで開きます。ユーザ名・パスワードを入力し、認証を行います。 2. 同じブラウザの別タブで、異なるclient_idを持つ[CSPベースのアプリケーション#1b](https://webgw.localdomain/irisclient/csp/user2/MyApp.Login.cls)を開きます。本来であれば、ユーザ名・パスワード入力を求められますが、今回はその工程はスキップされます。 3. 上記はほぼ同じ表示内容ですが$NAMESPACE(つまり実行されているアプリケーション)が異なります。 > アプリケーションが最初に認可されたスコープと異なるスコープを要求した場合、以下のようなスコープ確認画面だけが表示されます。 > ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/authorize-scope.png) この時点で認可サーバで下記を実行すると、現在1個のセッションに属する(同じGroupIdを持つ)トークンが2個存在することが確認できます。 ```bash $ docker compose exec irisauth iris session iris -U%SYS "##class(%SYSTEM.SQL).Shell()" [SQL]%SYS>>SELECT * FROM OAuth2_Server.Session ID AuthTime Cookie Expires Scope Username 6Xks9UD1fm8HU6u6FYf5eRtlyv8IU44LM4vGEkqbI60 1679909215 6Xks9UD1fm8HU6u6FYf5eRtlyv8IU44LM4vGEkqbI60 1679995615 openid profile scope1 test [SQL]%SYS>>SELECT ClientId, GroupId, Scope, Username FROM OAuth2_Server.AccessToken ClientId GroupId Scope Username qCIoFRl1jtO0KpLlCrfYb8TelYcy_G1sXW_vav_osYU 6Xks9UD1fm8HU6u6FYf5eRtlyv8IU44LM4vGEkqbI60 openid profile scope1 test vBv3V0_tS3XEO5O15BLGOgORwk-xYlEGQA-48Do9JB8 6Xks9UD1fm8HU6u6FYf5eRtlyv8IU44LM4vGEkqbI60 openid profile scope1 test ``` 4. 両方のタブでF5を何度か押して、%session.Data("COUNTER")の値が増えて行くことを確認します。 > セッションを持つアプリケーションの動作という見立てです。 5. 1個目のタブ(CSPベースのアプリケーション#1a)でログアウト(SSO)をクリックします。ログアウトが実行され、最初のページに戻ります。 6. 2個目のタブ(CSPベースのアプリケーション#1b)でF5を押します。「認証されていません! 認証を行う」と表示されます。 これで、1度のログアウト操作で、全てのアプリケーションからログアウトするSLOが動作したことがが確認できました。 同様に、[サンプルのpythonコード](https://github.com/IRISMeister/python-oauth2-client)も、一度認証を行うと、それ以降、何度実行してもユーザ名・パスワード入力を求めることはありません。これはpythonが利用するブラウザに"ユーザセッション"が記録されるためです。 ``` redirect ブラウザ --> 認可サーバ | (ユーザセッション) +--> リソースサーバ ``` この設定が有効の場合、認可サーバはユーザエージェントに対してCSPOAuth2Sessionという名称のクッキーをhttpOnly, Secure設定で送信します。以後、同ユーザエージェントが認可リクエストを行う際には、このクッキーが使用され、(認可サーバでのチェックを経て)ユーザを認証済みとします。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/CSPOAuth2Session.png) CSPOAuth2Sessionの値は、発行されるIDトークンの"sid"クレームに含まれます。 ``` { "iss":"https://webgw.localdomain/irisauth/authserver/oauth2", "sub":"test", "exp":1679629322, "auth_time":1679625721, "iat":1679625722, "nonce":"M79MJF6HqHHDKFpK4ZZJkaD3moE", "at_hash":"AFeWfbXALP78Y9KEhlKnp_5LJmEjthJQlJDGXh_eLPc", "aud":[ "https://webgw.localdomain/irisrsc/csp/myrsc", "https://webgw.localdomain/irisrsc2/csp/myrsc", "SrGSiVPB8qWvQng-N7HV9lYUi5WWW_iscvCvGwXWGJM" ], "azp":"SrGSiVPB8qWvQng-N7HV9lYUi5WWW_iscvCvGwXWGJM", "sid":"yxGBivVOuMZGr2m3Z5AkScNueppl8Js_5cz2KvVt6dU" } ``` 詳細は[こちら](https://docs.intersystems.com/irislatest/csp/docbookj/DocBook.UI.Page.cls?KEY=GOAUTH_authz#GOAUTH_authz_config_ui_server) の「ユーザ・セッションのサポート」の項目を参照ください。 ## PKCE 認可コード横取り攻撃への対策である、[PKCE](https://www.rfc-editor.org/rfc/rfc7636)(ピクシーと発音するそうです)関連の設定を行っています。そのため、PublicクライアントはPKCEを実装する必要があります。 - 公開クライアントにコード交換用 Proof Key (PKCE) を適用する: 有効 - 機密クライアントにコード交換用 Proof Key (PKCE) を適用する: 無効 ## ログアウト機能 OpenID Connectのログアウト機能について再確認しておきます。 実に、様々なログアウト方法が提案されています。メカニズムとして、postMessage-Based Logout,HTTP-Based Logoutがあり、ログアウト実行の起点によりRP-Initiated, OP-Initiatedがあり、さらにHTTP-Based LogoutはFront-Channel, Back-Channelがありと、利用環境に応じて様々な方法が存在します。 > postMessageとはクロスドメインのiframe間でデータ交換する仕組みです 目的は同じでシングルログアウト(SLO)、つまり、シングルサインオンの逆で、OP,RP双方からログアウトする機能を実現することです。 ### 本例での設定 HTTP-Basedを使用したほうがクライアント実装が簡単になる事、バックチャネルログアウトは現在IRISでは未対応であることから、本例では、フロントチャネルログアウトをRP-Initiatedで実行しています。 ユーザセッションが有効なクライアント(irisclient)のログアウト用のリンクをクリックすると下記のようなJavaScriptを含むページが描画されます。 ``` ・ ・ function check(start) { 個々のiframeの実行完了待ち if (完了) doRedirect() } function doRedirect() { post_logout_redirect_uriへのリダイレクト処理 } ``` > 表示がおかしくなってしまうので、scriptをscr1ptに変更しています。インジェクション攻撃扱いされています...? JavaScriptが行っていることは、iframe hiddenで指定された各RPログアウト用のエンドポイント(複数のRPにログインしている場合、iframeも複数出来ます)を全て呼び出して、成功したら、doRedirect()で、post_logout_redirect_urisで指定されたURLにリダイレクトする、という処理です。これにより、一度の操作で全RPからのログアウトとOPからのログアウト、ログアウト後の指定したページ(本例では最初のページ)への遷移が実現します。 > 内容を確認したい場合、ログアウトする前に、ログアウト用のリンクのURLをcurlで実行してみてください。 > >``` >curl -L --insecure "https://webgw.localdomain/irisclient/csp/sys/oauth2/OAuth2.PostLogoutRedirect.cls?register=R3_wD-F5..." >``` 一方、ユーザセッションが無効の場合は、ログアウトを実行したクライアントのみがfrontchannel_logout対象となります。 > つまり、ユーザセッションを使用して、2回目以降にユーザ名・パスワードの入力なしで、認証されたアプリケーション群が、SLOでログアウトされる対象となります。 フロントチャネルログアウト実現のために、認可サーバの設定で、下記のログアウト関連の設定を行っています。 - HTTPベースのフロントチャネルログアウトをサポート:有効 - フロントチャネルログアウトURLとともに sid (セッションID) クレームの送信をサポート:有効 また、認可サーバ(irisauth)に以下のcookie関連の設定を行っています。 [ドキュメント](https://docs.intersystems.com/iris20223/csp/docbook/Doc.View.cls?KEY=GOAUTH_authz)に従って、irisauthの/oauth2のUser Cookie Scopeをlaxとしています。 >Note: For an InterSystems IRIS authorization server to support front channel logout, the User Cookie Scope for the /oauth2 web application must be set to Lax. For details on configuring application settings, see Create and Edit Applications. ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/cookie-user-session.png) > 本例は同じオリジンで完結している(Chromeであれば、ログアウト実行時に関わるhttpアクセスのRequest Headersに含まれるsec-fetch-site値がsame-originになっていることで確認できます)ので、この設定は不要ですが、備忘目的で設定しています。 また、クライアント(irisclient)に以下の設定を行っています。 1. Session Cookie Scopeの設定 [ドキュメント](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=GOAUTH_client)に従って、irisclientの/csp/userのSession Cookie Scopeをnoneとしています。 >Note: For an InterSystems IRIS client to support front channel logout, the Session Cookie Scope of the client application to None. For details on configuring application settings, see Create and Edit Applications. ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/cookie-session.png) > 本例は同じオリジンで完結している(Chromeであれば、ログアウト実行時に関わるhttpアクセスのRequest Headersに含まれるsec-fetch-site値がsame-originになっていることで確認できます)ので、この設定は不要ですが、備忘目的で設定しています。 2. "frontchannel_logout_session_required"をTrueに設定しています。 3. "frontchannel_logout_uri"に"https://webgw.localdomain/irisclient/csp/user/MyApp.Logout.cls"を設定しています。 管理ポータル上には下記のように表示されています。このURLに遷移する際は、IRISLogout=endが自動付与されます。 >If the front channel logout URL is empty, the client won't support front channel logout. 'IRISLogout=end' will always be appended to any provided URL. IRISドキュメントに記述はありませんが、Cache'の同等機能の記述は[こちら](https://docs.intersystems.com/latest/csp/docbook/DocBook.UI.Page.cls?KEY=GCSP_sessions)です。IRISLogout=endは、CSPセッション情報の破棄を確実なものとするためと理解しておけば良いでしょう。 > 一般論として、ログアウト時のRP側での処理は認可サーバ側では制御不可能です。本当にセッションやトークンを破棄しているか知るすべがありません。IRISLogout=endはRPがCSPベースである場合に限り、それら(cspセッションとそれに紐づくセッションデータ)の破棄を強制するものです。非CSPベースのRPにとっては意味を持ちませんので、無視してください。 # 各サーバの設定について 各サーバの設定内容とサーバ環境を自動作成する際に使用した各種インストールスクリプトに関する内容です。 ## 認可サーバ 認可サーバ上の、oAUth2/OIDC関連の設定は、[MyApps.Installer](https://github.com/IRISMeister/iris-oauth2/blob/master/irisauth/src/MyApps/Installer.cls)にスクリプト化してあります。 下記の箇所で、「OAuth 2.0 認可サーバ構成」を行っています。 ``` Set cnf=##class(OAuth2.Server.Configuration).%New() ・ ・ Set tSC=cnf.%Save() ``` これらの設定は、[認可サーバ](http://webgw.localdomain/irisauth/csp/sys/sec/%25CSP.UI.Portal.OAuth2.Server.Configuration.zen#0)で確認できます。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/oauth-server-config.png) ## 認可サーバ上のクライアントデスクリプション 下記のような箇所が3か所あります。これらは「 OAuth 2.0 サーバ クライアントデスクリプション」で定義されている、python, curl, angularのエントリに相当します。 ``` Set c=##class(OAuth2.Server.Client).%New() Set c.Name = "python" ・ ・ Set tSC=c.%Save() ``` > これらに続くファイル操作は、利便性のためにclient_idなどをファイル出力しているだけで、本来は不要な処理です。 > これらはコンテナイメージのビルド時に実行されます。 これらの設定は、[認可サーバ](http://webgw.localdomain/irisauth/csp/sys/sec/%25CSP.UI.Portal.OAuth2.Server.ClientList.zen?)で確認できます。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/oauth-serverside-client-desc.png) ## CSPベースのWebアプリケーション 実行内容の説明は、クライアント編で行います。 CSPベースのWebアプリケーションの設定は、[MyApps.Installer](https://github.com/IRISMeister/iris-oauth2/blob/master/irisclient/src/MyApps/Installer.cls)にスクリプト化してあります。 oAUth2/OIDC関連の設定(クライアントの動的登録)は、irisclient用の[RegisterAll.mac](https://github.com/IRISMeister/iris-oauth2/blob/master/irisclient/src/MyApp/RegisterAll.mac)、およびirisclient2用の[RegisterAll.mac](https://github.com/IRISMeister/iris-oauth2/blob/master/irisclient2/src/MyApp/RegisterAll.mac)にスクリプト化してあります。 > これらは[register_oauth2_client.sh](https://github.com/IRISMeister/iris-oauth2/blob/master/register_oauth2_client.sh)により、コンテナ起動後に実行されます。 これらの設定は、[クライアント用サーバ](http://webgw.localdomain/irisclient/csp/sys/sec/%25CSP.UI.Portal.OAuth2.Client.Configuration.zen?PID=USER_CLIENT_APP&IssuerEndpointID=1&IssuerEndpoint=https%3A%2F%2Fwebgw.localdomain%2Firisauth%2Fauthserver%2Foauth2#0)で確認できます。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/client-config.png) 動的登録を行った時点で、これらの内容が認可サーバに渡されて、認可サーバ上に保管されます。その内容は、認可サーバで確認できます。 > ビルド時に生成されるclient_idがURLに含まれるため、リンクを用意できません。画像イメージのみです。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/serverside-client-config.png) ## リソースサーバ リソースサーバの設定は、[MyApps.Installer](https://github.com/IRISMeister/iris-oauth2/blob/master/irisrsc/src/MyApps/Installer.cls)にスクリプト化してあります。 リソースサーバのRESTサービスは、IRISユーザUnknownUserで動作しています。 リソースサーバは、受信したトークンのバリデーションをするために、[REST APIの実装](https://github.com/IRISMeister/iris-oauth2/blob/master/irisrsc/src/API/REST.cls)で、下記のAPIを使用しています。 アクセストークンをhttp requestから取得します。 ```objectscript set accessToken=##class(%SYS.OAuth2.AccessToken).GetAccessTokenFromRequest(.tSC) ``` アクセストークンのバリデーションを実行します。この際、..#AUDがアクセストークンのaudクレームに含まれていることをチェックしています。 ```objectscript if '(##class(%SYS.OAuth2.Validation).ValidateJWT($$$APP,accessToken,,..#AUD,.jsonObjectJWT,.securityParameters,.tSC)) { ``` 署名の有無の確認をしています。 ```objectscript Set sigalg=$G(securityParameters("sigalg")) if sigalg="" { set reason=..#HTTP401UNAUTHORIZED $$$ThrowOnError(tSC) } ``` (べた書きしていますが)受信したアクセストークンのSCOPEクレーム値がscope1を含まない場合、http 404エラーを返しています。 ```objectscript if '(jsonObjectJWT.scope_" "["scope1 ") { set reason=..#HTTP404NOTFOUND throw } ``` oAUth2/OIDC関連の設定(クライアントの動的登録)は、[Register.mac](https://github.com/IRISMeister/iris-oauth2/blob/master/irisrsc/src/API/Register.mac)にスクリプト化してあります。 > これらは[register_oauth2_client.sh](https://github.com/IRISMeister/iris-oauth2/blob/master/register_oauth2_client.sh)により、コンテナ起動後に実行されます。 これらの設定は、[リソースサーバ](http://webgw.localdomain/irisrsc/csp/sys/sec/%25CSP.UI.Portal.OAuth2.Client.Configuration.zen?PID=RESSERVER_APP&IssuerEndpointID=1&IssuerEndpoint=https%3A%2F%2Fwebgw.localdomain%2Firisauth%2Fauthserver%2Foauth2#0)で確認できます。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/rsc-config.png) 動的登録を行った時点で、これらの内容が認可サーバに渡されて、認可サーバ上に保管されます。その内容は、認可サーバで確認できます。 > ビルド時に生成されるclient_idがURLに含まれるため、リンクを用意できません。画像イメージのみです。 ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/serverside-rsc-config.png) ## 署名(JWK) 認可サーバをセットアップすると、一連の暗号鍵ペアが作成されます。これらはJWTで表現されたアクセストークンやIDトークンを署名する(JWS)ために使用されます。 鍵情報は認可サーバのデータべースに保存されています。参照するにはirisauthで下記SQLを実行します。 ``` $ docker compose exec irisauth iris session iris -U%SYS "##class(%SYSTEM.SQL).Shell()" SELECT PrivateJWKS,PublicJWKS FROM OAuth2_Server.Configuration ``` PrivateJWKSの内容だけを見やすいように整形すると[こちら](https://github.com/IRISMeister/iris-oauth2/blob/main/docs/PrivateJWKS.json)のようになります。 実際にアクセストークンを https://jwt.io/ で確認してみます。ヘッダにはkidというクレームが含まれます。これはトークンの署名に使用されたキーのIDです。 ``` { "typ": "JWT", "alg": "RS512", "kid": "3" } ``` これで、このトークンはkid:3で署名されていることがわかります。 この時点で、Signature Verifiedと表示されていますが、これはkid:3の公開鍵を使用して署名の確認がとれたことを示しています。 > 公開鍵は[公開エンドポイント](https://webgw.localdomain/irisauth/authserver/oauth2/jwks)から取得されています 次に、エンコード処理(データへのJWSの付与)を確認するために、ペーストしたトークンの水色の部分(直前のピリオドも)をカットします。Invalid Signatureに変わります。 さきほどSQLで表示したPrivateJWKSの内容のkid:3の部分だけ(下記のような内容)を抜き出して下のBOXにペーストします。 ``` { "kty": "RSA", "n": "....", "e": "....", "d": "....", ・ ・ ・ "alg": "RS512", "kid": "3" } ``` ![](https://raw.githubusercontent.com/IRISMeister/iris-oauth2/main/docs/images/jwtio.png) 水色部分が復元され、再度、Signature Verifiedと表示されるはずです。また、水色部分は元々ペーストしたアクセストークンのものと一致しているはずです。 > 本当に大切な秘密鍵はこういう外部サイトには張り付けないほうが無難かも、です # ログ取得方法 各所でのログの取得方法です。 ## 認可サーバ(IRIS) 認可サーバ上の実行ログを取得、参照出来ます。クライアントの要求が失敗した際、多くの場合、クライアントが知りえるのはhttpのステータスコードのみで、その理由は明示されません。認可サーバ(RPがIRISベースの場合は、クライアントサーバでも)でログを取得すれば、予期せぬ動作が発生した際に、原因のヒントを得ることができます。 - ログ取得開始 ```bash ./log_start.sh ``` これ以降、発生した操作に対するログが保存されます。ログは^ISCLOGグローバルに保存されます。 - ログを出力 ログは非常に多くなるので、いったんファイルに出力してIDE等で参照するのが良いです。 ```bash ./log_display.sh ``` [Webアプリケーション1a](https://webgw.localdomain/irisclient/csp/user/MyApp.Login.cls)をユーザエージェント(ブラウザ)からアクセスした際のログファイルの出力例は[こちら](https://github.com/IRISMeister/iris-oauth2/blob/master/docs/logging.txt)です。 - ログを削除 ログを削除します。ログ取得は継続します。 ```bash ./log_clear.sh ``` - ログ取得停止 ログ取得を停止します。ログ(^ISCLOGグローバル)は削除されません。 ```bash ./log_end.sh ``` ## IRISサーバのログ確認 IRISサーバが稼働しているサービス名(認可サーバならirisauth)を指定します。 IRISコミュニティエディション使用時に、接続数オーバ等を発見できます。 ``` docker compose logs -f irisauth ``` ## WebGWのログ確認 WebGWコンテナ内で稼働するapacheのログを確認できます。 全体の流れを追ったり、エラー箇所を発見するのに役立ちます。 ``` docker compose logs -f webgw ```
記事
Toshihiko Minamoto · 2021年3月31日

Caché のメソッドジェネレータを使ったコード生成の検証

デベロッパーの方なら、反復的なコードを書いた経験があると思います。 プログラムを使ってコードを生成できたら楽なのに、と考えたことがあるかもしれません。 まさに自分のことだと思った方、ぜひこの記事をお読みください! まずは例をお見せします。 注意: 次の例で使用する `%DynamicObject` インターフェースは Caché 2016.2 以上のバージョンが必要です。 このクラスに馴染みのない方は、[Using JSON in Caché](http://docs.intersystems.com/latest/csp/docbook/DocBook.UI.Page.cls?KEY=GJSON_intro) と題したドキュメンテーションをお読みください。 とても重宝すると思います! ## 例 データを保管するために使う `%Persistent` というクラスがあります。 `%DynamicObject` インターフェースを使い、データを JSON 形式で取り込むとしましょう。 どうすれば `%DynamicObject` 構造をクラスにマッピングできると思いますか? ソリューションの 1 つに、値を直接コピーするコードを書くという方法があります。 Class Test.Generator Extends %Persistent { Property SomeProperty As %String; Property OtherProperty As %String; ClassMethod FromDynamicObject(dynobj As %DynamicObject) As Test.Generator { set obj = ..%New() set obj.SomeProperty = dynobj.SomeProperty set obj.OtherProperty = dynobj.OtherProperty quit obj } } しかし、プロパティの数が多かったり、このパターンを複数のクラスに使ったりすると、少し面倒なことになります (もちろん管理も大変です)。 それを解決するのがメソッドジェネレータです! 簡単に言うと、メソッドジェネレータを使うときは、特定のメソッドのコードを書く代わりに、クラスのコンパイラが実行するコードを書き、それによりメソッドのコードを生成します。 少しややこしいでしょうか? いたって単純なんですよ。 では、例を一つお見せしましょう。 Class Test.Generator Extends %Persistent { ClassMethod Test() As %String [ CodeMode = objectgenerator ] { do %code.WriteLine(" write ""This is a method Generator!"",!") do %code.WriteLine(" quit ""Done!""") quit $$$OK } } `CodeMode = objectgenerator` というパラメーターを使い、現在のメソッドはメソッドジェネレータであり、普通のメソッドではないことを示しています。 このメソッドの働きですが、 メソッドジェネレータをデバッグするには、クラスの生成されたコードを見ると便利です。 今回の例で言うと、Test.Generator.1.INT と名付けた INT ルーチンがそれに当たります。 これを開くには、Studio で「Ctrl+Shift+V」と入力してもいいですし、Studio の「Open」ダイアログまたは Atelier から開くこともできます。 INT コードを見ると、このメソッドが実装されているのが分かります。 zTest() public { write "This is a method Generator!",! quit "Done!" } 見てお分かりの通り、この実装は `%code` オブジェクトに書き込まれるテキストを含む単純なものです。 `%code` は、特殊なタイプのストリームオブジェクトです(`%Stream.MethodGenerator`)。 このストリームに書き込まれるコードには、マクロやプリプロセッサディレクティブ、埋め込まれた SQL など、MAC ルーチンで有効なコードであれば、何でも含めることができます。 メソッドジェネレータを使用するにあたり、いくつか頭に入れておきたいことがあります。 * メソッドシグネチャは、生成するターゲットメソッドに適用される。 ジェネレータのコードは、常に成功またはエラー状況を示すステータスコードを返すものである。 * %code に書き込まれるコードは有効な ObjectScript でなければいけない (他の言語モードを持つメソッドジェネレータは本記事の範囲外です)。 つまり、特に重要なこととして、コマンドを含む行はスペースから始めなければいけません。 例にある `WriteLine()` の呼び出しは、2 つともスペースで始まっています。 `%code` の変数 (生成されたメソッド) 以外にも、コンパイラは現在のクラスのメタデータを以下の変数が使用できます。 * `%class` * `%method` * `%compiledclass` * `%compiledmethod` * `%parameter` 最初の 4 つは、それぞれ `%Dictionary.ClassDefinition`、`%Dictionary.MethodDefinition`、`%Dictionary.CompiledClass` `%Dictionary.CompiledMethod` のインスタンスです。 `%parameter` は、クラスで定義されたパラメータ名とキーで構成される添え字付き配列です。 (今回の目的において) `%class` と `%compiledclass` の主な違いは、`%class` には現在のクラスで定義されているクラスメンバー (プロパティやメソッドなど) のメタデータだけが含まれている点です。 一方の `%compiledclass` には、これらのメンバー以外にも、継承されたすべてのメンバーのメタデータが含まれます。 また、`%class` から参照される型の情報は、クラスコードで指定されている通りに表示される一方で、`%compiledclass` (および `%compiledmethod`) の型は完全なクラス名に展開されます。 例えば、`%String` は `%Library.String` に展開され、パッケージが指定されていないクラス名は `Package.Class` のように完全なクラス名に展開されます。 詳細は、これらのクラスのクラスリファレンスをご覧ください。 この情報を使えば、`%DynamicObject` 用にメソッドジェネレータを構築することができます。 ClassMethod FromDynamicObject(dynobj As %DynamicObject) As Test.Generator [ CodeMode = objectgenerator ] { do %code.WriteLine(" set obj = ..%New()") for i=1:1:%class.Properties.Count() { set prop = %class.Properties.GetAt(i) do %code.WriteLine(" if dynobj.%IsDefined("""_prop.Name_""") {") do %code.WriteLine(" set obj."_prop.Name_" = dynobj."_prop.Name) do %code.WriteLine(" }") } do %code.WriteLine(" quit obj") quit $$$OK } これにより、以下のコードが生成されます。 zFromDynamicObject(dynobj) public { set obj = ..%New() if dynobj.%IsDefined("OtherProperty") { set obj.OtherProperty = dynobj.OtherProperty } if dynobj.%IsDefined("SomeProperty") { set obj.SomeProperty = dynobj.SomeProperty } quit obj } ご覧のとおり、このクラスで定義されている各プロパティを set するコードが生成されます。 この実装では、継承されたプロパティを除外していますが、`%class.Properties` の代わりに `%compiledclass.Properties` を使えば簡単に含めることができます。 また、プロパティを set しようと試みる前に、`%DynamicObject` にプロパティが存在するかどうかをチェックするコードも追加しました。 存在しないプロパティを `%DynamicObject` から参照してもエラーは出ないので絶対に必要な訳ではありませんが、クラス内のプロパティのいずれかがデフォルト値を定義している場合は便利です。 このチェックを行わなければ、デフォルト値はいつもこのメソッドによって上書きされます。 メソッドジェネレータは継承と組み合わせて使うと大きな威力を発揮します。 FromDynamicObject() メソッドジェネレータは、抽象クラスに置くことができます。 なお、`%DynamicObject` から逆シリアル化できる新しいクラスを作成するのであれば、このクラスを拡張してこの機能を有効化するだけで OK です。 クラスのコンパイラは、各サブクラスをコンパイルするときに、メソッドジェネレータのコードを実行し、そのクラスの実装をカスタマイズします。 ## メソッドジェネレータのデバッグ ### 基本的なデバッグ作業 メソッドジェネレータを使用すると、プログラミングの間接参照のレベルが増えてしまいます。 これにより、ジェネレータのコードをデバッグする際に問題が起こる場合があります。 それでは、1 つ例を見てみましょう。 次のメソッドをご覧ください。 Method PrintObject() As %Status [ CodeMode = objectgenerator ] { if (%class.Properties.Count()=0)&&($get(%parameter("DISPLAYEMPTY"),0)) { do %code.WriteLine(" write ""{}"",!") } elseif %class.Properties.Count()=1 { set pname = %class.Properties.GetAt(1).Name do %code.WriteLine(" write ""{ "_pname_": ""_.."_pname_"_""}"",!") } elseif %class.Properties.Count()>1 { do %code.WriteLine(" write ""{"",!") for i=1:1:%class.Properties.Count() { set pname = %class.Properties.GetAt(i).Name do %code.WriteLine(" write """_pname_": ""_.."_pname_",!") } do %code.WriteLine(" write ""}""") } do %code.WriteLine(" quit $$$OK") quit $$$OK } これは、オブジェクトの中身を出力するだけの単純なメソッドです。 オブジェクトは、プロパティの数によって異なる形式で出力されます。具体的には、複数のプロパティを持つオブジェクトは複数の行に渡って出力され、プロパティを持たない、または 1 つしか持たないオブジェクトは 1 つの行に出力されます。 また、オブジェクトは DISPLAYEMTPY というパラメーターを導入しています。これは、プロパティを持たないオブジェクトの出力を抑制するかしないかを制御するものです。 しかし、このコードには問題点があります。 プロパティを持たないクラスでは、オブジェクトが正しく出力されていません。 TEST>set obj=##class(Test.Generator).%New() TEST>do obj.PrintObject() TEST> ここでは、何も出力されないのではなく、空のオブジェクト "{}" が出力されるはずなのです。 これをデバッグするに、INT コードの中身を確認します。 ところが、INT コードを開いてみると、なんと zPrintObject() の定義が見当たらないのです! 私の言うことを鵜呑みにせず、コードをコンパイルしてご自身の目でお確かめください。 どうぞ... 終わるまでお待ちします。 はい、 終わりましたでしょうか? 何か分かりましたか? 鋭い方なら、1 つ目の問題の原因が分かったのではないでしょうか。そうです、IF 文の最初の節に入力ミスがあります。DISPLAYEMPTY パラメーターのデフォルト値は 0 ではなく、1 でなければいけません。 正しくは、`$get(%parameter("DISPLAYEMPTY"),1)`。`$get(%parameter("DISPLAYEMPTY"),0)` は間違いです。 これで原因がはっきりしましたね。 でも、どうして INT コードにメソッドがなかったのでしょう? でも、実行はできましたよね。 `` エラーは出なかったし。メソッドは全く何もしなかったのです。 ミスが解明したところで、このメソッドが INT コードにあればどうようなコードに_なっていたか_を見てみましょう。 if ... else if ... コンストラクトの条件を1つも満たしていないので、コードは単純に以下のようなります。 zPrintObject() public { quit 1 } このコードは、リテラル値を返す以外には、何もしないことに注目してください。 Caché のクラスのコンパイラは非常に賢いことが分かりました。 特定の状況では、メソッドのコードを実行する必要がないことに気付き、INT コードをメソッドに合わせて最適化できるのです。 これは紛れもなく素晴らしい最適化機能です。なぜなら、主にシンプルなメソッドの場合は、カーネルから INT コードにディスパッチすると膨大なオーバーヘッドが生じるからです。 この動作は、メソッドジェネレータ固有のものではないことに注意してください。 次のメソッドをコンパイルしてから、INT コードの中で探してみてください。 ClassMethod OptimizationTest() As %Integer { quit 10 } メソッドジェネレータのコードをデバッグするときは、INT コードを確認すると非常に便利です。 ジェネレータによって実際に作成されたものを確認できます。 但し、生成されたコードが INT コードに表示されない場合があるので、注意が必要です。 そういった予想外の事象が発生する場合は、ジェネレータのコードにバグがあり、ジェネレータが有意義なコードを生成できない原因となっていることが考えられます。 ### デバッガーの使用について 先ほど説明しましたが、生成されたコードに問題がある場合は、INT コードを見れば確認できます。 また、ZBREAK や Studio のデバッガーを使って、メソッドをデバッグすることもできます。 メソッドジェネレータのコードそのものをデバッグする方法はないだろうか、と気になっている方もいるのではないでしょうか。 もちろん、いつでもメソッドジェネレータに「write」式を追加したり、caveman のようなデバッググローバルを設定したりできます。 でも、もっといい方法があるはずだと思いませんか? そうです、あるんです。しかし、その方法を理解するには、まずクラスのコンパイラーが機能する仕組みを理解する必要があります。 大まかに説明すると、クラスのコンパイラーは、クラスをコンパイルするとき、まず最初にクラスの定義を解析して、そのクラス用にメタデータを生成します。 基本的には、先ほど説明した `%class` 変数と `%compiledclass` 変数用にデータを生成していることになります。 次に、すべてのメソッドに対し INT コードを生成します。 この段階で、すべてのメソッドジェネレータの生成コードを格納する個別のルーチンを作成します。 このルーチンは、`.G1.INT` と呼ばれています。 そして、*.G1 ルーチンのコードを実行してメソッドのコードを生成し、そのコードをクラスの残りのメソッドと一緒に `.1.INT` ルーチンに保管します。 そして、このルーチンをコンパイルすると、 コンパイルされたクラスが作成されます! もちろん、これは非常に複雑なソフトウェアを極端に単純化したものですが、この記事の目的を果たすには十分です。 この *.G1 ルーチンは面白そうですね。 ではその中身を見てみましょう! ;Test.Generator3.G1 ;(C)InterSystems, method generator for class Test.Generator3. Do NOT edit. Quit ; FromDynamicObject(%class,%code,%method,%compiledclass,%compiledmethod,%parameter) public { do %code.WriteLine(" set obj = ..%New()") for i=1:1:%class.Properties.Count() { set prop = %class.Properties.GetAt(i) do %code.WriteLine(" if dynobj.%IsDefined("""_prop.Name_""") {") do %code.WriteLine(" set obj."_prop.Name_" = dynobj."_prop.Name) do %code.WriteLine(" }") } do %code.WriteLine(" quit obj") quit 1 Quit 1 } クラスの INT コードを編集して、デバッグコードを追加するということに慣れている方もいるのではないでしょうか。 少しやり方が粗いですが、通常ならそれでも構いません。 しかし、この場合はそれだとうまく行きません。 このコードを実行するには、クラスをコンパイルし直す必要があります。 (結局はクラスのコンパイラに呼び出されます。) しかし、クラスをまたコンパイルすると、このルーチンが再生成されるので、加えた変更がすべて消去されてしまいます。 幸い、ZBreak か Studio のデバッガーを使えば、このコードを細かく確認できます。 ルーチン名が分かっているので、ZBreak の使い方はいたって単純です。 TEST>zbreak FromDynamicObject^Test.Generator.G1 TEST>do $system.OBJ.Compile("Test.Generator","ck") Compilation started on 11/14/2016 17:13:59 with qualifiers 'ck' Compiling class Test.Generator FromDynamicObject(%class,%code,%method,%compiledclass,%compiledmethod,%parameter) publ ^ ic { FromDynamicObject^Test.Generator.G1 TEST 21e1>write %class.Name Test.Generator TEST 21e1> Studio のデバッガーの使い方も簡単です。 *.G1.MAC ルーチンにブレークポイントを設定し、$System.OBJ.Compile() をクラスに対して呼び出すようにデバッグターゲットを設定できます。 $System.OBJ.Compile("Test.Generator","ck") これでデバッグ作業が開始されます。 # 結論 この記事では、メソッドジェネレータについて簡単にまとめました。 詳細にご興味のある方は、以下のドキュメンテーションをお読みください。 * [メソッドジェネレータとトリガージェネレータの定義](http://docs.intersystems.com/latestj/csp/docbook/DocBook.UI.Page.cls?KEY=GOBJ_generators#GOBJ_C2395) * `%class` オブジェクトと `%compiledclass` オブジェクトの詳細は、以下をご覧ください。 * [%Dictionary クラスの使用について](http://docs.intersystems.com/latestj/csp/docbook/DocBook.UI.Page.cls?KEY=GOBJ_dictionary) * [%Dictionary.ClassDefinition のクラスリファレンス](http://docs.intersystems.com/latestj/csp/documatic/%25CSP.Documatic.cls?PAGE=CLASS&LIBRARY=%25SYS&CLASSNAME=%25Dictionary.ClassDefinition) * [%Dictionary.CompiledClass のクラスリファレンス](http://docs.intersystems.com/latestj/csp/documatic/%25CSP.Documatic.cls?PAGE=CLASS&LIBRARY=%25SYS&CLASSNAME=%25Dictionary.CompiledClass)
記事
Toshihiko Minamoto · 2021年12月9日

MLとIntegratedMLでCovid-19のICU入室予測を実行する(パート2)

キーワード: IRIS、IntegratedML、機械学習、Covid-19、Kaggle  [前のパート1](https://jp.community.intersystems.com/node/507001)の続き... パート1では、Kaggleに掲載されているこのCovid-19データセットにおける従来型MLのアプローチを説明しました。  今回のパート2では、IRISのIntegratedMLを使用して、可能な限り単純な形態で同じデータとタスクを実行しましょう。IntegratedMLは、バックエンドAutoMLオプション用に洗練された優れたSQLインターフェースです。 同じ環境を使用します。    ## IntegratedMLアプローチとは ### **IRISにデータを読み込む方法** [integredML-demo-template](https://openexchange.intersystems.com/package/integratedml-demo-template)には、IRISにデータを読み込む様々な方法が定義されています。 たとえば、このCSV形式のxlsファイルに固有のカスタムIRISクラスを定義し、それをIRISテーブルに読み込むことができます。 大量のデータをより適切に制御することができます。  ただし、この記事では、単純化された怠惰な方法を使用します。[データフレーム全体を私が作成したカスタムPython関数で読み込む](https://community.intersystems.com/post/save-pandas-dataframe-iris-quick-note)方法です。  そうすることで、生のデータフレームや処理されたデータフレームのさまざまなステージをいつでもIRISに保存し、前のMLアプローチを使用して、類似性比較を行えます。 def to_sql_iris(cursor, dataFrame, tableName, schemaName='SQLUser', drop_table=False ): """" Dynamically insert dataframe into an IRIS table via SQL by "excutemany" Inputs: cursor: Python JDBC or PyODBC cursor from a valid and establised DB connection dataFrame: Pandas dataframe tablename: IRIS SQL table to be created, inserted or apended schemaName: IRIS schemaName, default to "SQLUser" drop_table: If the table already exsits, drop it and re-create it if True; othrewise keep it and appen Output: True is successful; False if there is any exception. """ if drop_table: try: curs.execute("DROP TABLE %s.%s" %(schemaName, tableName)) except Exception: pass try: dataFrame.columns = dataFrame.columns.str.replace("[() -]", "_") curs.execute(pd.io.sql.get_schema(dataFrame, tableName)) except Exception: pass curs.fast_executemany = True cols = ", ".join([str(i) for i in dataFrame.columns.tolist()]) wildc =''.join('?, ' * len(dataFrame.columns)) wildc = '(' + wildc[:-2] + ')' sql = "INSERT INTO " + tableName + " ( " + cols.replace('-', '_') + " ) VALUES" + wildc #print(sql) curs.executemany(sql, list(dataFrame.itertuples(index=False, name=None)) ) return True ### **Python JDBC接続のセットアップ** import numpy as np import pandas as pd from sklearn.impute import SimpleImputer import matplotlib.pyplot as plt from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, roc_auc_score, roc_curve import seaborn as sns sns.set(style="whitegrid") import jaydebeapi url = "jdbc:IRIS://irisimlsvr:51773/USER" driver = 'com.intersystems.jdbc.IRISDriver' user = "SUPERUSER" password = "SYS" jarfile = "./intersystems-jdbc-3.1.0.jar" conn = jaydebeapi.connect(driver, url, [user, password], jarfile) curs = conn.cursor()   ### **開始データポイントをセットアップする** 類似性比較を行うために、前の記事の特徴量選択(「特徴量の選択 - 最終的な選択」セクション)の後のデータフレームから始めました。「DataS」はここで実際に開始するデータフレームです。 data = dataS data = pd.get_dummies(data) data.AGE_ABOVE65 = data.AGE_ABOVE65.astype(int) data.ICU = data.ICU.astype(int) data_new = data data_new   AGE_ABOVE65 GENDER HTN OTHER CALCIUM_MEDIAN CALCIUM_MIN CALCIUM_MAX CREATININ_MEDIAN CREATININ_MEAN CREATININ_MIN ... HEART_RATE_DIFF_REL RESPIRATORY_RATE_DIFF_REL TEMPERATURE_DIFF_REL OXYGEN_SATURATION_DIFF_REL ICU WINDOW_0-2 WINDOW_2-4 WINDOW_4-6 WINDOW_6-12 WINDOW_ABOVE_12 1 0.0 0.0 1.0 0.330359 0.330359 0.330359 -0.891078 -0.891078 -0.891078 ... -1.000000 -1.000000 -1.000000 -1.000000 1 1 1 0.0 0.0 1.0 0.330359 0.330359 0.330359 -0.891078 -0.891078 -0.891078 ... -1.000000 -1.000000 -1.000000 -1.000000 1 2 1 0.0 0.0 1.0 0.183673 0.183673 0.183673 -0.868365 -0.868365 -0.868365 ... -0.817800 -0.719147 -0.771327 -0.886982 1 3 1 0.0 0.0 1.0 0.330359 0.330359 0.330359 -0.891078 -0.891078 -0.891078 ... -0.817800 -0.719147 -1.000000 -1.000000 1 4 1 0.0 0.0 1.0 0.326531 0.326531 0.326531 -0.926398 -0.926398 -0.926398 ... -0.230462 0.096774 -0.242282 -0.814433 1 1 ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... 1920 1.0 0.0 1.0 0.330359 0.330359 0.330359 -0.891078 -0.891078 -0.891078 ... -1.000000 -1.000000 -1.000000 -1.000000 1 1921 1.0 0.0 1.0 0.244898 0.244898 0.244898 -0.934890 -0.934890 -0.934890 ... -1.000000 -1.000000 -1.000000 -1.000000 1 1922 1.0 0.0 1.0 0.330359 0.330359 0.330359 -0.891078 -0.891078 -0.891078 ... -1.000000 -1.000000 -1.000000 -1.000000 1 1923 1.0 0.0 1.0 0.330359 0.330359 0.330359 -0.891078 -0.891078 -0.891078 ... -1.000000 -1.000000 -1.000000 -1.000000 1 1924 1.0 0.0 1.0 0.306122 0.306122 0.306122 -0.944798 -0.944798 -0.944798 ... -0.763868 -0.612903 -0.551337 -0.835052 1 1925 rows × 62 columns 上記には、選択された58個の特徴量と、前の非数値列(WINDOW)から変換された4つの特徴量があることを示します。     ### **IRISテーブルにデータを保存する** 上記の**to\_sql\_iris**関数を使用して、IRISテーブル「CovidPPP62」にデータを保存します。 iris_schema = 'SQLUser' iris_table = 'CovidPPP62' to_sql_iris(curs, data_new, iris_table, iris_schema, drop_table=True) df2 = pd.read_sql("SELECT COUNT(*) from %s.%s" %(iris_schema, iris_table),conn) display(df2)   Aggregate_1 1925 次に、トレーニングビュー名、モデル名、およびトレーニングターゲット列(この場合は「ICU」)を定義します。   dataTable = iris_table dataTableViewTrain = dataTable + 'Train1' dataTablePredict = dataTable + 'Predict1' dataColumn = 'ICU' dataColumnPredict = 'ICUPredicted' modelName = "ICUP621" #名前を選択 - サーバー側で一意である必要があります すると、このデータをトレーニングビュー(1700行)とテストビュー(225行)に分割できます。 IntegratedMLではこれを行う必要はありませんが、前の記事との比較目的で行っています。 curs.execute("CREATE VIEW %s AS SELECT * FROM %s WHERE ID<=1700" % (dataTableViewTrain, dataTable)) df62 = pd.read_sql("SELECT * from %s" % dataTableViewTrain, conn) display(df62) print(dataTableViewTrain, modelName, dataColumn) CovidPPP62Train1 ICUP621 ICU   ### **IntegratedMLのデフォルトのAutoMLでモデルをトレーニングする** curs.execute("CREATE MODEL %s PREDICTING (%s) FROM %s" % (modelName, dataColumn, dataTableViewTrain)) curs.execute("TRAIN MODEL %s FROM %s" % (modelName, dataTableViewTrain)) df3 = pd.read_sql("SELECT * FROM INFORMATION_SCHEMA.ML_TRAINED_MODELS", conn) display(df3)   MODEL_NAME TRAINED_MODEL_NAME PROVIDER TRAINED_TIMESTAMP MODEL_TYPE MODEL_INFO 9 ICUP621 ICUP6212 AutoML 2020-07-22 19:28:16.174000 classification ModelType:Random Forest, Package:sklearn, Prob... したがって、IntegratedMLは「ModelType」を自動的に「Random Forrest」(ランダムフォレスト)として選択し、問題を「Classification」(分類)タスクとして扱っているという結果がわかります。  前の記事では、箱ひげ図を使った長々としたモデル比較と選択、およびグリッド検索による長々としたモデルパラメーターのチューニングなど、これとまったく同じことを達成しましたよね。 **注意**: 上記はIntergratedML構文による最低限のSQLです。  トレーニングアプローチやモデルの選択を指定していませんし、バックエンドMLプラットフォームも設定していません。 すべてはIMLの決定に委ねられており、IMLは内部トレーニングストラテジーをある程度達成して、適切な最終結果を備えた合理的なモデルに落ち着いています。 わずかながら、私の期待を超えたと言ってよいでしょう。    では、予約しておいたテストセットに対し、現在トレーニングされているモデルの簡単な類似性テストランを実行してみましょう。   ### **テストデータの結果を予測する** トレーニングには1700行を使用しました。 以下では、残りの225行を使用してテストデータのビューを作成し、これらのレコードにSELECT PREDICTを実行します。 その予測結果を「`dataTablePredict`」に保存して、データフレームとして「df62」に読み込みます。 dataTableViewTest = "SQLUSER.DTT621" curs.execute("CREATE VIEW %s AS SELECT * FROM %s WHERE ID > 1700" % (dataTableViewTest, dataTable)) curs.execute("DROP TABLE %s" % dataTablePredict ) curs.execute("Create Table %s (%s VARCHAR(100), %s VARCHAR(100))" % (dataTablePredict, dataColumnPredict, dataColumn)) curs.execute("INSERT INTO %s SELECT PREDICT(%s) AS %s, %s FROM %s" % (dataTablePredict, modelName, dataColumnPredict, dataColumn, dataTableViewTest)) df62 = pd.read_sql("SELECT * from %s ORDER BY ID" % dataTablePredict, conn) display(df62) その混同行列を手動で計算します。これを行う必要はありません。 これは比較のみを目的としています。 TP = df62[(df62['ICUPredicted'] == '1') & (df62['ICU']=='1')].count()['ICU'] TN = df62[(df62['ICUPredicted'] == '0') & (df62['ICU']=='0')].count()["ICU"] FN = df62[(df62['ICU'] == '1') & (df62['ICUPredicted']=='0')].count()["ICU"] FP = df62[(df62['ICUPredicted'] == '1') & (df62['ICU']=='0')].count()["ICU"] print(TP, FN, '\n', FP, TN) precision = (TP)/(TP+FP) recall = (TP)/(TP+FN) f1 = ((precision*recall)/(precision+recall))*2 accuracy = (TP+TN) / (TP+TN+FP+FN) print("Precision: ", precision, " Recall: ", recall, " F1: ", f1, " Accuracy: ", accuracy) 34 20 8 163 Precision: 0.8095238095238095 Recall: 0.6296296296296297 F1: 0.7083333333333334 Accuracy: 0.8755555555555555 または、IntegratedMLの組み込みの混同行列を取得する構文を使用することができます。 # テストデータを検証する curs.execute("VALIDATE MODEL %s FROM %s" % (modelName, dataTableViewTest) ) df5 = pd.read_sql("SELECT * FROM INFORMATION_SCHEMA.ML_VALIDATION_METRICS", conn) df6 = df5.pivot(index='VALIDATION_RUN_NAME', columns='METRIC_NAME', values='METRIC_VALUE') display(df6) METRIC_NAME Accuracy F-Measure Precision Recall VALIDATION_RUN_NAME         ICUP62121 0.88 0.71 0.81 0.63 ... ... ... ... ... パート1の「基本的なLRトレーニングを実行する」セクションにあった「元の結果」と比較すると、Recall は57%に対して63%、Accuracyは85%に対して88%という結果になっています。  したがって、IntegratedMLではより良い結果が得られています。   ### **SMOTEを介して再調整されたトレーニングデータでIntegratedMLを再トレーニングする** 上記のテストは、ICU入室と非入室の比率が1:3という不均衡なデータで行われました。  そこで、前の記事と同様に、SMOTEを適用してデータを均衡化し、その上で上記のIMLパイプラインを再実行することにしましょう。 「X\_train\_res' and 'y\_train\_res」は、前のパート1の「基本的なLRトレーニングを実行する」セクションにあったSMOTE後のデータフレームです。  df_x_train = pd.DataFrame(X_train_res) df_y_train = pd.DataFrame(y_train_res) df_y_train.columns=['ICU'] df_smote = pd.concat([df_x_train, df_y_train], 1) display(df_smote) iris_schema = 'SQLUser' iris_table = 'CovidSmote' to_sql_iris(curs, df_smote, iris_table, iris_schema, drop_table=True) # save it into a new IRIS table of specified name df2 = pd.read_sql("SELECT COUNT(*) from %s.%s" %(iris_schema, iris_table),conn) display(df2)   Aggregate_1 2490 SMOTEによって、ICU=1のレコードが増やされたため、データセットの行数は1700ではなく2490になりました。 dataTable = iris_table dataTableViewTrain = dataTable + 'TrainSmote' dataTablePredict = dataTable + 'PredictSmote' dataColumn = 'ICU' dataColumnPredict = 'ICUPredictedSmote' modelName = "ICUSmote1" #名前を選択 - サーバー側で一意である必要があります end curs.execute("CREATE VIEW %s AS SELECT * FROM %s" % (dataTableViewTrain, dataTable)) df_smote = pd.read_sql("SELECT * from %s" % dataTableViewTrain, conn) display(df_smote) print(dataTableViewTrain, modelName, dataColumn) CovidSmoteTrainSmote ICUSmote1 ICU curs.execute("CREATE MODEL %s PREDICTING (%s)  FROM %s" % (modelName, dataColumn, dataTableViewTrain)) curs.execute("TRAIN MODEL %s FROM %s" % (modelName, dataTableViewTrain)) df3 = pd.read_sql("SELECT * FROM INFORMATION_SCHEMA.ML_TRAINED_MODELS", conn) display(df3)   MODEL_NAME TRAINED_MODEL_NAME PROVIDER TRAINED_TIMESTAMP MODEL_TYPE MODEL_INFO 9 ICUP621 ICUP6212 AutoML 2020-07-22 19:28:16.174000 classification ModelType:Random Forest, Package:sklearn, Prob... 12 ICUSmote1 ICUSmote12 AutoML 2020-07-22 20:49:13.980000 classification ModelType:Random Forest, Package:sklearn, Prob... 次に、予約済みの225件のテストデータ行を再準備し、それに対してSMOTE再トレーニング済みモデルを実行します。 df_x_test = pd.DataFrame(X3_test) df_y_test = pd.DataFrame(y3_test) df_y_test.columns=['ICU'] df_test_smote = pd.concat([df_x_test, df_y_test], 1) display(df_test_smote) iris_schema = 'SQLUser' iris_table = 'CovidTestSmote' to_sql_iris(curs, df_test_smote, iris_table, iris_schema, drop_table=True) dataTableViewTest = "SQLUSER.DTestSmote225" curs.execute("CREATE VIEW %s AS SELECT * FROM %s" % (dataTableViewTest, iris_table)) curs.execute("Create Table %s (%s VARCHAR(100), %s VARCHAR(100))" % (dataTablePredict, dataColumnPredict, dataColumn)) curs.execute("INSERT INTO %s SELECT PREDICT(%s) AS %s, %s FROM %s" % (dataTablePredict, modelName, dataColumnPredict, dataColumn, dataTableViewTest)) df62 = pd.read_sql("SELECT * from %s ORDER BY ID" % dataTablePredict, conn) display(df62) TP = df62[(df62['ICUPredictedSmote'] == '1') & (df62['ICU']=='1')].count()['ICU'] TN = df62[(df62['ICUPredictedSmote'] == '0') & (df62['ICU']=='0')].count()["ICU"] FN = df62[(df62['ICU'] == '1') & (df62['ICUPredictedSmote']=='0')].count()["ICU"] FP = df62[(df62['ICUPredictedSmote'] == '1') & (df62['ICU']=='0')].count()["ICU"] print(TP, FN, '\n', FP, TN) precision = (TP)/(TP+FP) recall = (TP)/(TP+FN) f1 = ((precision*recall)/(precision+recall))*2 accuracy = (TP+TN) / (TP+TN+FP+FN) print("Precision: ", precision, " Recall: ", recall, " F1: ", f1, " Accuracy: ", accuracy) 45 15 9 156 Precision: 0.8333333333333334 Recall: 0.75 F1: 0.7894736842105262 Accuracy: 0.8933333333333333 # SMOTE再トレーニング済みモデルでテストデータを検証する curs.execute("VALIDATE MODEL %s FROM %s" % (modelName, dataTableViewTest) ) #Covid19aTest500, Covid19aTrain1000 df5 = pd.read_sql("SELECT * FROM INFORMATION_SCHEMA.ML_VALIDATION_METRICS", conn) df6 = df5.pivot(index='VALIDATION_RUN_NAME', columns='METRIC_NAME', values='METRIC_VALUE') display(df6) METRIC_NAME Accuracy F-Measure Precision Recall VALIDATION_RUN_NAME         ICUP62121 0.88 0.71 0.81 0.63 ICUSmote122 0.89 0.79 0.83 0.75 前の63%に比べてはるかに優れた75%のRecallと、わずかに優れたAccuracyとF1スコアが得られました。  さらに**注目すべきこと**は、この結果が、前の記事の「「グリッド検索によるパラメーターチューニング」をさらに行って、選択されたモデルを実行する」セクションに記録されたとおりに、「モデルの選択」と「グリッド検索によるパラメーターのチューニング」を集中的に行った「従来型MLアプローチ」と一致しているということです。  したがって、IMLの結果は全く悪くないということです。   ### **IntegratedMLのH2Oプロバイダーに変更する ** IMLのAutoMLプロバイダーを1行で変更してから、前のステップで行った通りにモデルを再トレーニングできます。    curs.execute("SET ML CONFIGURATION %H2O; ") modelName = 'ICUSmoteH2O' print(dataTableViewTrain) curs.execute("CREATE MODEL %s PREDICTING (%s) FROM %s" % (modelName, dataColumn, dataTableViewTrain)) curs.execute("TRAIN MODEL %s FROM %s" % (modelName, dataTableViewTrain)) df3 = pd.read_sql("SELECT * FROM INFORMATION_SCHEMA.ML_TRAINED_MODELS", conn) display(df3)   MODEL_NAME TRAINED_MODEL_NAME PROVIDER TRAINED_TIMESTAMP MODEL_TYPE MODEL_INFO 12 ICUSmote1 ICUSmote12 AutoML 2020-07-22 20:49:13.980000 classification ModelType:Random Forest, Package:sklearn, Prob... 13 ICUPPP62 ICUPPP622 AutoML 2020-07-22 17:48:10.964000 classification ModelType:Random Forest, Package:sklearn, Prob... 14 ICUSmoteH2O ICUSmoteH2O2 H2O 2020-07-22 21:17:06.990000 classification None # テストデータを検証する curs.execute("VALIDATE MODEL %s FROM %s" % (modelName, dataTableViewTest) ) #Covid19aTest500, Covid19aTrain1000 df5 = pd.read_sql("SELECT * FROM INFORMATION_SCHEMA.ML_VALIDATION_METRICS", conn) df6 = df5.pivot(index='VALIDATION_RUN_NAME', columns='METRIC_NAME', values='METRIC_VALUE') display(df6) METRIC_NAME Accuracy F-Measure Precision Recall VALIDATION_RUN_NAME         ICUP62121 0.88 0.71 0.81 0.63 ICUSmote122 0.89 0.79 0.83 0.75 ICUSmoteH2O21 0.90 0.79 0.86 0.73 H2O AutoMLでは、F1は同じですが、Accuracyがわずかに高く、Recallがわずかに減少していることがわかります。  ただし、このCovid19 ICUタスクの主な目的は、可能であれば偽陰性を最小限に抑えることであるため、  プロバイダーをH2Oに変更しても、ターゲットパフォーマンスは向上しなかったようです。 もちろん、IntegratedMLのDataRobotプロバイダーもテストしたいのですが、残念ながらDataRobotのAPIキーは持っていないため、ここでストップとします。    ## まとめ: 1. **パフォーマンス**: この特定のCovid-19 ICUタスクでは、比較テストによって、IRIS IntegratedMLのパフォーマンスは従来型MLの類似性の結果に少なくとも同等か類似していることが示されています。 この特定のケースでは、IntegratedMLは内部トレーニングストラテジーを自動的に正しく選択することができ、適切なモデルに落ち着いて、期待される結果を出したように見えました。 2. **単純さ**: IntegratedMLのプロセスは、従来型MLのパイプラインよりもはるかに単純です。 上記に示されるとおり、モデルの選択やパラメーターのチューニングなどの通常のデータサイエンティスト作業を行わずに、同等のパフォーマンスを達成することができました。  比較の為でなければ、実際には特徴量の選択の不要です。 また、Integrated-demo-templateデモノートブックに示されているIntegratedMLの最低限の構文しか使用していません。 もちろん、従来型パイプラインで使用できる一般的なデータサイエンスツールのカスタマイズ性とファインチューニング機能は失われるという欠点はありますが、これは他のAutoMLプラットフォームにも多かれ少なかれ当てはまることです。  3. **データ前処理は依然として重要**: 残念ながら特効薬はありません。または、特効薬には時間が必要でしょう。 このCovid-19 ICUタスクに限定して言えば、上記のテストでは、データが現在のIntegratedMLにとって依然として重要であることが示されています。生のデータ、欠落したデータを代入して選択された特徴量、および基本的なSMOTEオーバーサンプリングによる再調整データはすべて大幅に異なるパフォーマンスを見せました。 これは、IMLのデフォルトAutoMLとそのH2Oプロバイダーの両方に当てはまります。 DataRobotはわずかに優れたパフォーマンスを主張するかもしれませんが、IntegratedMLのSQLラッパーでさらにテストされると思います。 **要するに、データ正規化は、IntegratedMLでも依然として重要であるということです。** 4. **デプロイ可能性**: デプロイ可能性、API管理、モニタリング、および非関数サービス可能性などについてはまだ比較していません。次の記事で行えるでしょう。   ## 今後の内容 1. **モデルのデプロイ**: これまで、Covid-19のX線画像に対するデモAIと、バイタルサインおよび観測に対するCovid-19 ICU予測を実行しました。 これらをFlask/FastAPIおよびIRISサービススタックにデプロイし、REST/JSON APIを介してデモML/DL機能を開会できるでしょうか?  もちろん、次の記事でそのようなことを試すことはできます。 その後で、NLP APIなどを含むさらに多くのデモAI機能を徐々に追加していくことができます。  2. **FHIRラップAPIの相互運用性**: この開発者コミュニティには、FHIRテンプレートやIRISネイティブAPIなどもあります。 デモAIサービスをFHIRアプリでSMARTに、または対応する標準に従ってFHIRラップAIサービスに変換することはできるでしょうか?  IRIS製品ラインには、AIデモスタックで利用できるAPIゲートウェイ、Kubernetesサポート付きのICM、SAMなどがあることも忘れないでください。 3. **HealthShare Clinical ViewerやTrakなどとのデモ統合は?** [サードパーティAIベンダーのPACS Viewer(Covid-19 CT用)とHealthShare Clinical Viewerのデモ統合](https://jp.community.intersystems.com/node/506991)は簡単に説明しました。したがって、おそらくいずれは、さまざまな専門分野での独自のAIデモサービスを最後まで説明することはできるでしょう。    
記事
Toshihiko Minamoto · 2021年11月25日

HealthShareにバインディングしたPython 3を使用したディープラーニングデモを実行する(パート2)  

**キーワード**:   Jupyterノートブック、TensorFlow GPU、Keras、ディープラーニング、MLP、HealthShare       ## 1. 目的 前回の[「パート1」では、ディープラーニングデモ環境をセットアップ](https://jp.community.intersystems.com/node/505841)しました。今回「パート2」では、それを使ってできることをテストします。 私と同年代の人の中には、古典的なMLP(多層パーセプトロン)モデルから始めた人がたくさんいます。 直感的であるため、概念的に取り組みやすいからです。 それでは、AI/NNコミュニティの誰もが使用してきた標準的なデモデータを使って、Kerasの「ディープラーニングMLP」を試してみましょう。 いわゆる「教師あり学習」の一種です。 これを実行するのがどんなに簡単かをKerasレベルで見ることにします。 後で、その歴史と、なぜ「ディープラーニング」と呼ばれているのかについて触れることができます。流行語ともいえるこの分野は、実際に最近20年間で進化してきたものです。  HealthShareにも関連しているため、最終的には、少々実現的なユースケースを想像または予測できるようになることを願っています。 ## 2. 範囲と免責事項 次のことを行います。  * tensorflow-gpu環境用に新しいJupyterカーネルをセットアップします。 * ANNコミュニティで一般的な標準のMNISTサンプルを使って、Keras MLPモデルを定義、トレーニング、および検証(テスト)します。 * 重要なパラメーターのほんの一部の非常に単純なものを簡単に説明します。 * デモデータを簡単に調べます。データを理解することは、あらゆる実験において常に重要なことです。  * データサンプルをCache/HealthShareに保存し、予測(分類)と推論を行うために読み取り直す作業がどれほど簡単であるかを実演します。 その後で、テストサンプルを少し回転させ、トレーニング済みのモデルをどれくらい混乱させられるかを確認し、それによって明確な制限を理解します。 学術的・数学的な部分は省略しますが、仕組みについて簡単に説明するところもあります。 免責事項: [MNISTデータサンプル](http://yann.lecun.com/exdb/mnist/)は、このデモの目的で公開されています。 ほとんどのデモコードは最小限に縮減されており、エラー処理の含まれないベアなコードでした。 Kerasコードのソースは「謝辞」に記載されています。  この内容は、必要に応じていつでも変更されます。   ## 3. 前提条件 [前の「パート1」の記事](https://community.intersystems.com/post/deep-learning-demo-kit-python3-binding-healthshare-part-i)に記載されているとおりにデモ環境をセットアップする以外で、以下の実験のための前提条件はありません。    ## 4. Jupyterノートブックのセットアップ 前回インストールした「tensorflow-gpu」環境で次のコマンドを実行しました。  (tensorflow-gpu) C:\>conda install ipykernelSolving environment: done ... ... (tensorflow-gpu) C:\>python -m ipykernel install --user --name tensorflow-gpu --display-name "Tensorflow-GPU"Installed kernelspec tensorflow-gpu in C:\Users\zhongli\AppData\Roaming\jupyter\kernels\tensorflow-gpu こうすることで、「Tensorflow-GPU」などと呼ばれる新しいJupyterカーネルを作成しました。 これで、Anaconda Promptから次のようにしてJupyterノートブックを起動できるようになりました。 (tensorflow-gpu) C:\anaconda3\keras\Zhong>jupyter notebook[... ... [I 10:58:12.728 NotebookApp] The Jupyter Notebook is running at:[I 10:58:12.729 NotebookApp] http://localhost:8889/?token=6b40f6e6749e88b80a338eec3330d06c181ead9b644cffe1[I 10:58:12.734 NotebookApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).[C 10:58:12.835 NotebookApp] ... ...  ブラウザのUIが以下のように起動していることを確認できます。 [New]をクリックすると、新しい「Tensorflow_GPU」タブが開きます。 ![](/sites/default/files/inline/images/images/image(63).png) ## 5. ディープラーニングMLPモデルのトレーニング 標準のディープラーニングMLPモデルデモを試してみましょう。 ###  5.1 環境のテスト Jupyterタブを「MLP\_Demo\_ HS」のような名前に変更し、そのセル [1] で以下の行を実行して「**Run**」をクリックします。 print('Hello World!') Hello World!   ### 5.2 PythonからHealthShareへの接続のテスト  セル[2]でPythonサンプルを実行し、まだ[「パート1」の記事](https://community.intersystems.com/post/deep-learning-demo-kit-python3-binding-healthshare-part-i)に記載されたとおりにHealthShareデータベースインスタンスに接続できるかをテストします。 import codecs, sysimport intersys.pythonbind3 try:    print ("Simple Python binding sample")     port = input("Cache server port (default 56778)? ")    port = port.rstrip()    if (port == ""):        port = "56778"     url = "localhost["+port+"]:SAMPLES"    print ("Connection string: " + url)     print ("Connecting to Cache server")    conn = intersys.pythonbind3.connection( )    conn.connect_now(url, "_SYSTEM", "SYS", None)    print ("Connected successfully")     print ("Creating database")    database = intersys.pythonbind3.database( conn)     print ("Opening Sample.Person instance with ID 1 with default concurrency and timeout")    person = database.openid( "Sample.Person", "1", -1, -1)     print ("Getting the value of the Name property")    name = person.get("Name")    print ("Value: " + name)     print ("Test completed successfully")except intersys.pythonbind3.cache_exception( err):    print ("InterSystems Cache' exception")    print (sys.exc_type)    print (sys.exc_value)    print (sys.exc_traceback)    print (str(err)) Simple Python binding sample Cache server port (default 56778)? Connection string: localhost[56778]:SAMPLES Connecting to Cache server Connected successfully Creating database Opening Sample.Person instance with ID 1 with default concurrency and timeout Getting the value of the Name property Value: Zevon,Mary M. Test completed successfully   ### 5.3 説明 - MLPモデルのトポロジーとMNISTデータセット   MLPネットワークのトポロジーは、以下に示すとおり単純です。 通常、1つの入力と1つの出力のレイヤーがあり、多数の非表示レイヤーがあります。  各レイヤーには多数のニューロン(ノード)があります。 各ニューロンには活性化関数があります。 以下のように、2つの異なるレイヤーのニューロン間に完全にメッシュ化された接続(「密度」モデル)が存在することになります。  ![関連画像](https://www.researchgate.net/profile/Tao_Jiang10/publication/320441691/figure/download/fig2/AS:631629156454418@1527603534354/A-fully-connected-feedforward-NN-architecture-where-all-neurons-between-adjacent-layers.png?_sg=wX1LD_6u7NtX2AqwUk5gG6JE2tffoM7VxlsdZel3Qb3-jXXalMxuNOkne6caL2Plt3UEkt-7eXc)   これに対応し、以下でテストしているKeras MLPモデルには次のものが含まれます。 * **28 x 28ノードの計784ノードの入力レイヤー**(ようするに28x28ピクセルの小さな画像であり、それぞれは「0」から「9」の手書きの数字です。**MNISTデータセットにはこのような画像がトレーニング用に60,000個、テスト用に10,000個含まれています**) * **10ノードの出力レイヤー**(0から9の間の入力画像の分類結果を表します) * **2つの非表示レイヤー**(各レイヤーには512個のノードがあります) これが、このデモモデルの主要トポロジーです。 他の詳細については今のところは省略して、実際に実行することにしましょう。  ![関連画像](https://nextjournal.com/data/1220CC01595BBCB08CCAC75AC0A373519699CFBC6FB7E6118A92DDB89EDB63490CFE?content-type=image%2Fpng&filename=text4298.png)     ### 5.4. GoogleパブリッククラウドからMNISTサンプルデータを読み込む     では、上記のモデルをまったく最初から作成し始めることにしましょう。KerasパッケージとMNISTデータをJupyter Cellにロードして、  メニューの[Run]ボタンをクリックします。 ### Import Keras modules import kerasfrom keras.datasets import mnistfrom keras.models import Sequentialfrom keras.layers import Dense, Dropoutfrom keras.optimizers import RMSprop ### Define key training parameterbatch_size = 128  # weights adjusted in 128 stepsnum_classes = 10  # 10 classification results on the output layerepochs = 20       # run the set of samples 20 times. ###load the data from Google public cloud # load the MNIST sample image  data, split between train and test sets(x_train, y_train), (x_test, y_test) = mnist.load_data() 注意: 問題がある場合は、例外の内容に従うか(ほとんどの場合が、パッケージが見つからないといった例外です)、Googleで答えを探すか(99%の確率で回答を得られます)、以下に質問を投稿してください。 コードの最後の行で、60,000個と10,000個の全データセットが3次元整数のPython配列にロードされました。  トレーニングサンプルの1つをHealthShareデータベースに読み込んでみてみましょう。   ### 5.5 データサンプルをHealthShareのグローバルに読み込む HealthShare -> SAMPLESネームスペース - > Sample.Person.clsで、この最も単純なクラスメソッドをスクラッチします。 ClassMethod SetTrainGlobals(d1 As %Integer = , d2 As %Integer = , value As %String = "", target As %String = "") As %BigInt [ SqlProc ]{Set ^XTrainInput(d1, d2) = valueSet ^YTrainTarget(d1) = targetreturn $$$OK} 1つの入力トレーニングサンプルを文字列としてグローバル^XTrainInputに取り込み、入力トレーニングターゲットを^YTrainTargetに保存します。 リコンパイルし、セクション5.2に従って接続を更新してから、以下のようにPythonのセルから呼び出しを実行します。 result1 = person.run_obj_method("SetTrainGlobals", [0, 2, str(x_train[0]), str(y_train[0])]) HealthShare -> Samplesで、^XTrainGlobal(0, 2) というグローバルが2次元整数の文字列で作成されたことがわかります。 後で、別の単純なメソッドを実行し、データをサンプルとしてPython変数に読み戻すことができます。     5.6 モデルを実行してトレーニング実行する JupyterでのMLPモデルの定義とトレーニングを完了しましょう。 基本的に以下のコードの「reshape」は、それぞれの28 x 28のサンプルを0から255の値の1x 784個の値に変換し、その後で0から1.0の浮動小数点型に正規化します。 Model.SequentialからModel.Summaryまでのコードは、784 x 512 x 512 x 10ノードのMLPを、「relu」活性化関数を使って定義するするものです。  最後に、 model.fitでトレーニングし、model.evaluteでテスト結果を評価します。    x_train = x_train.reshape(60000, 784)x_test = x_test.reshape(10000, 784)x_train = x_train.astype('float32')x_test = x_test.astype('float32')x_train /= 255x_test /= 255 print(x_train.shape[0], 'train samples')print(x_test.shape[0], 'test samples') # convert class vectors to binary class matricesy_train = keras.utils.to_categorical(y_train, num_classes)y_test = keras.utils.to_categorical(y_test, num_classes) model = Sequential()model.add(Dense(512, activation='relu', input_shape=(784,)))model.add(Dropout(0.2))model.add(Dense(512, activation='relu'))model.add(Dropout(0.2))model.add(Dense(num_classes, activation='softmax')) model.summary() model.compile(loss='categorical_crossentropy',              optimizer=RMSprop(),              metrics=['accuracy']) history = model.fit(x_train, y_train,                    batch_size=batch_size,                    epochs=epochs,                    verbose=1,                    validation_data=(x_test, y_test)) score = model.evaluate(x_test, y_test, verbose=1) print('Test loss:', score[0])print('Test accuracy:', score[1]) Using TensorFlow backend. 60000 train samples 10000 test samples _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= dense_1 (Dense) (None, 512) 401920 _________________________________________________________________ dropout_1 (Dropout) (None, 512) 0 _________________________________________________________________ dense_2 (Dense) (None, 512) 262656 _________________________________________________________________ dropout_2 (Dropout) (None, 512) 0 _________________________________________________________________ dense_3 (Dense) (None, 10) 5130 ================================================================= Total params: 669,706 Trainable params: 669,706 Non-trainable params: 0 _________________________________________________________________ Train on 60000 samples, validate on 10000 samples Epoch 1/20 60000/60000 [==============================] - 11s 178us/step - loss: 0.2476 - acc: 0.9243 - val_loss: 0.1057 - val_acc: 0.9672 Epoch 2/20 60000/60000 [==============================] - 6s 101us/step - loss: 0.1023 - acc: 0.9685 - val_loss: 0.0900 - val_acc: 0.9730 Epoch 3/20 60000/60000 [==============================] - 6s 101us/step - loss: 0.0751 - acc: 0.9780 - val_loss: 0.0756 - val_acc: 0.9783 Epoch 4/20 60000/60000 [==============================] - 6s 100us/step - loss: 0.0607 - acc: 0.9816 - val_loss: 0.0771 - val_acc: 0.9801 Epoch 5/20 60000/60000 [==============================] - 6s 101us/step - loss: 0.0512 - acc: 0.9844 - val_loss: 0.0761 - val_acc: 0.9810 Epoch 6/20 60000/60000 [==============================] - 6s 102us/step - loss: 0.0449 - acc: 0.9866 - val_loss: 0.0747 - val_acc: 0.9809 Epoch 7/20 60000/60000 [==============================] - 6s 101us/step - loss: 0.0377 - acc: 0.9885 - val_loss: 0.0765 - val_acc: 0.9811 Epoch 8/20 60000/60000 [==============================] - 6s 101us/step - loss: 0.0334 - acc: 0.9898 - val_loss: 0.0774 - val_acc: 0.9840 Epoch 9/20 60000/60000 [==============================] - 6s 101us/step - loss: 0.0307 - acc: 0.9911 - val_loss: 0.0771 - val_acc: 0.9842 Epoch 10/20 60000/60000 [==============================] - 6s 105us/step - loss: 0.0298 - acc: 0.9911 - val_loss: 0.1015 - val_acc: 0.9813 Epoch 11/20 60000/60000 [==============================] - 6s 102us/step - loss: 0.0273 - acc: 0.9922 - val_loss: 0.0869 - val_acc: 0.9833 Epoch 12/20 60000/60000 [==============================] - 6s 99us/step - loss: 0.0247 - acc: 0.9926 - val_loss: 0.0945 - val_acc: 0.9824 Epoch 13/20 60000/60000 [==============================] - 6s 101us/step - loss: 0.0224 - acc: 0.9935 - val_loss: 0.1040 - val_acc: 0.9823 Epoch 14/20 60000/60000 [==============================] - 6s 100us/step - loss: 0.0219 - acc: 0.9939 - val_loss: 0.1038 - val_acc: 0.9835 Epoch 15/20 60000/60000 [==============================] - 6s 104us/step - loss: 0.0227 - acc: 0.9936 - val_loss: 0.0909 - val_acc: 0.9849 Epoch 16/20 60000/60000 [==============================] - 6s 100us/step - loss: 0.0198 - acc: 0.9944 - val_loss: 0.0998 - val_acc: 0.9826 Epoch 17/20 60000/60000 [==============================] - 6s 101us/step - loss: 0.0182 - acc: 0.9951 - val_loss: 0.0984 - val_acc: 0.9832 Epoch 18/20 60000/60000 [==============================] - 6s 102us/step - loss: 0.0178 - acc: 0.9955 - val_loss: 0.1150 - val_acc: 0.9839 Epoch 19/20 60000/60000 [==============================] - 6s 100us/step - loss: 0.0167 - acc: 0.9954 - val_loss: 0.0975 - val_acc: 0.9847 Epoch 20/20 60000/60000 [==============================] - 6s 102us/step - loss: 0.0169 - acc: 0.9956 - val_loss: 0.1132 - val_acc: 0.9832 10000/10000 [==============================] - 1s 71us/step Test loss: 0.11318948425535869 Test accuracy: 0.9832    以上で、「トレーニング済み」となりました。 ほんの数行のコードで、このKerasディープラーニングMLPは、「tensorflow-gpu」環境でかなり効率的に実行します。 これまでのすべてのキットインストールを検証します。    6 サンプルを使用してモデルをテストする 以下の指定されたサンプルを使用してトレーニング済みのモデルをテストしましょう。 10,000個のx_testセットから特定のサンプルをランダムに選択し、別のHealthShareグローバルに保存してから、そのグローバルからPython配列にデモサンプルとして読み戻します。 それを使用してトレーニング済みのモデルをテストします。 次に、この入力サンプルを90度、180度、および270度に回転させてモデルを再テストし、混乱が生じないかを確認します。    6.1 サンプルをHealthShareに保存する - デモ  サンプルをランダムに選択しましょう。たとえば、10,000個のテストサンプルから12番目のサンプルを選択し、HSグローバルに保存します。 HealthShare -> SAMPLE -> Sample.Personクラスに新しいクラスメソッドを追加します。 ClassMethod SetT2Globals(d1 As %Integer = 0, d2 As %Integer = 0, d3 As %Integer = 0, value As %String = "", target As %String = "") As %BigInt [ SqlProc ]{         Set ^XTestInput(d1, d2, d3) = value         Set ^YTestTarget(d1, d2) = target         return $$$OK} Sample.Person.cls をリコンパイルします。  Jupyterノートブックで、セクション5.2のコードを再実行し、データベースのバインディングを更新します。次に、この行を実行して、28 x 28 個の数字サンプルをグローバル^XTestInputに保存します。 import ren = 12  # randomly choose a sample for i in range(0, len(x_train[n])):     r1 = person.run_obj_method("SetT2Globals", [1, n, i, re.sub('0\s0', '  0   0', str(x_test[n][i])), str(y_test[n])]) これで、2次元配列のサンプルがHS SAMPLEグローバル^XTestInputに保存されていることがわかりました。 それぞれの数字は、0~255のピクセルグレースケールです。 以下のHS管理ポータルから、それが「9」であることが簡単にわかります。     6.2 HealthShareグローバルからサンプルを読み取る - デモ HSデータベースグローバルからサンプルを確実に読み取ることができます。   別のクラスメソッドをSample.Person.clsに追加して、それをリコンパイルします。 ClassMethod GetT2Globals(d1 As %Integer = 0, d2 As %Integer = 0, d3 As %Integer = 0) As %String [ SqlProc ]{     Set value = ^XTestInput(d1, d2, d3)     return value} Jupyterで、前述のようにDBバインディングを更新してから、このPythonコードを実行してHealthShareから文字列としてグローバルを読み取り、1 x 2次元数値配列に変換します。 import re, astsample = ""for i in range(0, len(x_train[n])):   sample += person.run_obj_method("GetT2Globals", [1, n, i])#convert it to numpy ndarrayas1  = np.array(ast.literal_eval(re.sub('\s+', ',', re.sub('0\]', '0', re.sub('\[  ', '', re.sub('\]\[', ' ', sample))))))   Sample12 = as1.reshape(1, 28, 28)print(Sample12)   6.3 トレーニング済みモデルをテストする  これで、Jupyterで、この配列「Sample12」をトレーニング済みのモデルに送信できるようになりました。  model.predictやmodel.predict_classesがこの作業を行います。 Sample12  = Sample12.reshape(1, 784)Sample12f = Sample12.astype('float32')/255  # normalise it to float between [0, 1]Result12f = model.predict(Sample12f)   #test the 1x784 sample, the result is a 1d matrixprint(Result12f) Result12 = model.predict_classes(Sample12f)  #test the sample, the result is a clasified lable.print(Result12) [[2.5672970e-27 1.1168821e-25 1.3736557e-20 6.2964843e-17 7.0107062e-09 6.2905544e-17 1.5294099e-28 7.8019199e-17 3.5748028e-16 1.0000000e+00]] [9] 結果には出力レイヤーのニューロン#9に最大値「1.0」があることが示されているため、分類結果は「9」となります。  これは正しい結果です。 確かに、人工的な28 x 28整数のサンプルをモデルに送信して試してみることができます。   6.4 サンプルを回転させてモデルを再テストする このサンプルを反時計回りに90度回転させて、もう一度試してみることはできるでしょうか?  Sample12 = Sample12.reshape(28, 28)  #reshape to 2D array valuesSample1290 = np.rot90(Sample12)  #rotate in 90 degreeprint(Sample1290) 次に、モデルを再テストします。 Sample12901  = Sample1290.reshape(1, 784)Sample1290f = Sample12901.astype('float32')/255  Result1290f = model.predict(Sample1290f)   print(Result1290f) Result1290 = model.predict_classes(Sample1290f)  print(Result1290) [[2.9022769e-05 1.2192334e-20 1.7143857e-07 3.0004558e-11 2.4583075e-11 6.2443775e-01 2.5749558e-05 3.7550735e-01 2.0722151e-08 5.5368415e-10]] [5] さて、モデルは「5」と認識しました。ニューロン#5が最大出力値でトリガーされています。  どうやら少し混乱しているようです! (確かに縦向きのものが「5」のように見えるので仕方ありませんね)。 では今度はサンプルを180度回転させましょう。モデルはどのように認識するでしょうか?   [[3.3131425e-11 3.0135434e-27 8.7524540e-23 7.1371946e-24 2.4029167e-13 4.2327470e-09 1.0000000e+00 1.7086377e-18 1.3129146e-18 2.8595145e-22]] [6] もちろん、間違いなく「6」と認識しました!  人間も「9」ではなく「6」と認識するでしょう。  では、最後に270度回転させてみましょう。  どうやら、また混乱しているようです。今度は「4」と認識しました。 [[1.6130849e-06 3.0311636e-14 2.1490927e-03 2.7108688e-03 9.9499077e-01 1.4130991e-04 6.2298268e-06 8.6649310e-09 2.9320630e-12 1.5710594e-07]] [4]   6.5 パブリッククラウドツールとの比較 上記の配列をPythonコードの行を介してPNGにエクスポートし、回転して、反転して、画像上にまとめました。 次のようになっています。   次に、それぞれ「Google Vision API」、「Amazon Rekognition」、および「Microsoft Computer Vision API」にアップロードすると、結果はどうなるでしょうか。 この場合、AWSの「数字」のスコアが95%とわずかに最高となっています(これは絶対に代表的な結果ではありません)。  1. Google Vision APIの結果:   2. AWS Rekognitionの結果: 3. Microsoft Computer Visionの結果:    7. 次の内容 次は、以下のような他のいくつかの簡単なポイントについてフォローアップします。  一言で、MLPはどのように機能するのか? 制限と考えられるユースケースは? 現在のスタックで実行可能な最も一般的なML/DL/ANNモデルの簡単なウォークスルー   謝辞