クリアフィルター
記事
Toshihiko Minamoto · 2021年9月9日
## はじめに
この連載の最初の記事では、リレーショナルデータベースの[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つのテーブルのみが使用されます。

保存するデモデータの構造。

## テーブルを使用したEAVアプローチの実装
5つ(最後の2つのテーブルを1つに統合することにした場合は4つ)のテーブルを使用したより複雑な例を考察してみましょう。

最初のテーブルは`С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つのグローバル変数を管理するだけでカタログセクション、プロパティ、および商品などのデータを保存することができます。

グローバル構造がデータ構造にどれほど似ているのかに注目してください。 このコンプライアンスによって、コーディングとデバッグが大幅に簡略化されます。
実際には、全ての情報を1つのグローバルに保存したい気持ちが非常に強くても、複数のグローバルを使用することをお勧めします。 インデックス用に別のグローバルを作成することが合理的です。 また、ディレクトリのパーティション構造のストレージを商品から分離することもできます。
## この続きは?
この連載の2つ目の記事では、EAVモデルに従う代わりに、InterSystems Irisのグローバルにデータを保存する方法の詳細とメリットについて説明します。
記事
Shintaro Kaminaka · 2021年11月3日
開発者の皆さん、こんにちは。
この記事は、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ファイルを含むフォルダを選ぶと以下のような画面になります。

Importをクリックして、IRISへのインポートを完了します。
次に Server Configuration画面で、新規FHIRリポジトリを作成します。
(既存のFHIRリポジトリへ追加することも可能です。)

## POSTMANからテストする
先ほどSUSHIで生成された、テスト用リソースをPOSTします。検証のためには他の値のbirthPlaceを含むデータや、そもそもbirthPlaceを含まないPatientリソースなども生成するほうが良いかもしれません。

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)からダウンロードすることができます。
記事
Minoru Horita · 2020年6月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 - ツリー」も確認してください。
記事
Toshihiko Minamoto · 2021年11月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機能関連の情報発信は既に多数ありますが、本稿では
- 手順(ほぼ)ゼロでひとまず動作させてみる
- 設定の見通しを良くするために、役割ごとにサーバを分ける
- 目に見えない動作を確認する
- クライアント実装(PythonやAngular,CSPアプリケーション等)と合わせて理解する
- シングルサインオン/シングルログアウトを実現する
ということを主眼においています。
コミュニティ版で動作しますので、「とりあえず動かす」の手順に従って、どなたでもお試しいただけます。
> 現状、使用IRISバージョンはIRIS 2023.1のプレビュー版になっていますが、[ソースコード](https://github.com/IRISMeister/iris-oauth2)は適宜変更します。
手順に沿ってコンテナを起動すると下記の環境が用意されます。この環境を使用して動作を確認します。

ユーザエージェント(ブラウザ)や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)を開きます。
> プライベート認証局発行のサーバ証明書を使用しているため、初回はブラウザで「この接続ではプライバシーが保護されません」といったセキュリティ警告が出ます。アクセスを許可してください。

「oAuth2認証を行う」ボタンを押した際に、ユーザ名、パスワードを求められますので、ここではtest/testを使用してください。

権限の要求画面で「許可」を押すと各種情報が表示されます。

ページ先頭に「ログアウト(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を採用しています。

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"
}
```
これらの独自クラスは、下記で設定しています。

## リフレッシュトークン
「パブリッククライアント更新を許可」をオンにしています。
この設定をオンにすると、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(つまり実行されているアプリケーション)が異なります。
> アプリケーションが最初に認可されたスコープと異なるスコープを要求した場合、以下のようなスコープ確認画面だけが表示されます。
> 
この時点で認可サーバで下記を実行すると、現在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設定で送信します。以後、同ユーザエージェントが認可リクエストを行う際には、このクッキーが使用され、(認可サーバでのチェックを経て)ユーザを認証済みとします。

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.

> 本例は同じオリジンで完結している(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.

> 本例は同じオリジンで完結している(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)で確認できます。

## 認可サーバ上のクライアントデスクリプション
下記のような箇所が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?)で確認できます。

## 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)で確認できます。

動的登録を行った時点で、これらの内容が認可サーバに渡されて、認可サーバ上に保管されます。その内容は、認可サーバで確認できます。
> ビルド時に生成されるclient_idがURLに含まれるため、リンクを用意できません。画像イメージのみです。

## リソースサーバ
リソースサーバの設定は、[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)で確認できます。

動的登録を行った時点で、これらの内容が認可サーバに渡されて、認可サーバ上に保管されます。その内容は、認可サーバで確認できます。
> ビルド時に生成されるclient_idがURLに含まれるため、リンクを用意できません。画像イメージのみです。

## 署名(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"
}
```

水色部分が復元され、再度、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日
デベロッパーの方なら、反復的なコードを書いた経験があると思います。 プログラムを使ってコードを生成できたら楽なのに、と考えたことがあるかもしれません。 まさに自分のことだと思った方、ぜひこの記事をお読みください!
まずは例をお見せします。 注意: 次の例で使用する `%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年11月25日
**キーワード**: 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」タブが開きます。
.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つの異なるレイヤーのニューロン間に完全にメッシュ化された接続(「密度」モデル)が存在することになります。

これに対応し、以下でテストしているKeras MLPモデルには次のものが含まれます。
* **28 x 28ノードの計784ノードの入力レイヤー**(ようするに28x28ピクセルの小さな画像であり、それぞれは「0」から「9」の手書きの数字です。**MNISTデータセットにはこのような画像がトレーニング用に60,000個、テスト用に10,000個含まれています**)
* **10ノードの出力レイヤー**(0から9の間の入力画像の分類結果を表します)
* **2つの非表示レイヤー**(各レイヤーには512個のノードがあります)
これが、このデモモデルの主要トポロジーです。 他の詳細については今のところは省略して、実際に実行することにしましょう。

### 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モデルの簡単なウォークスルー
謝辞
記事
Toshihiko Minamoto · 2021年12月9日
キーワード: 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月30日
**キーワード**: COVID-19、医用画像、ディープラーニング、PACSビューア、HealthShare。
## **目的**
私たちは皆、この前例のないCovid-19パンデミックに悩まされています。 現場のお客様をあらゆる手段でサポートする一方で、今日のAI技術を活用して、Covid-19に立ち向かうさまざまな前線も見てきました。
昨年、私は[ディープラーニングのデモ環境](https://jp.community.intersystems.com/node/506626)について少し触れたことがあります。 この長いイースターの週末中に、実際の画像を扱ってみてはどうでしょうか。Covid-19に感染した胸部X線画像データセットに対して簡単な分類を行うディープラーニングモデルをテスト実行し、迅速な「AIトリアージ」や「放射線科医の支援」の目的で、X線画像やCT用のツールがdockerなどを介してクラウドにどれほど素早くデプロイされるのかを確認してみましょう。
これは、10分程度の簡易メモです。学習過程において、最も単純なアプローチでハンズオン経験を得られることを願っています。
## **範囲**
このデモ環境では、次のコンポーネントが使用されます。 これまで見てきた中で最も単純な形式です。
* 3タイプの小さな**匿名加工オープンデータセット**: Covid-19感染胸部、細菌性肺炎胸部、正常な胸部。
* 1セットの**ディープラーニングモデル**(X線胸部画像分類用のInception V3モデル)
* Jupyterノートブックによる**TensorFlow 1.13.2**コンテナ
* **GPU用のNvidia-Docker2**コンテナ
* Nvidia T4 GPU搭載AWS **Ubuntu 16.04 VM**(事前トレーニング済みモデルの再トレーニングを行わない場合はノートパソコンのGPUで十分です)
および
* 「AI支援CT検出」のデモコンテナ
* サードパーティのOpen PACS Viewerのデモコンテナ
* HealthShare Clinical Viewerのデモインスタンス
以下は、このデモの範囲では説明されていません。
* PyTorchの人気が高まっています(次の機会に利用します)
* デモ環境では、TensorFlow 2.0の実行速度がはるかに低下します(そのためバージョン1.13に戻します)
* AutoMLなどのマルチモデルアンサンブル(実際に人気が高まっていますが、この小さなデータセットでは昔ながらのシングルモデルで十分です)
* 実際のサイトから得るX線およびCTデータ。
## **免責事項**
このデモは、この特定の分野における臨床試験についてではなく、技術的なアプローチに関するものです。 CTおよびX線などの証拠に基づくCovid-19診断は現在ではオンラインで広く入手可能であり、肯定的なレビューもあれば、否定的なレビューもあります。また、このパンデミックにおいて、国や文化における役割が実際に異なります。 また、この記事のコンテンツとレイアウトは、必要に応じて変更される場合があります。 このコンテンツは「開発者」としての純粋な個人的見解です。
## **データ**
このテストの元の画像は、Joseph Paul Cohenによって一般公開されている[Covid-19 Lung X-Ray set](https://github.com/ieee8023/covid-chestxray-dataset)に含まれるものと、オープン形式の[Kaggle Chest X-Ray sets](https://www.kaggle.com/paultimothymooney/chest-xray-pneumonia)からAdrian Yuが[GradientCrescentリポジトリ](http://github.com/EXJUSTICE/GradientCrescent)の小さなテストセットに集めたの正常な胸部画像です。 また、皆さんが使用できるように、[簡単なテスト用のテストデータをこちらにアップロード](https://github.com/zhongli1990/Covid19-X-Rays)しました。 これまでのところ、以下の小さなトレーニングセットが含まれます。
* Covid-19に感染した胸部画像 60点
* 正常な胸部画像 70点
* 細菌性肺炎に感染した胸部画像 70点
## **テスト**
以下のテストでは、独自のフレーバーに合わせてわずかに変更したものを実行しました。
* ベースのInception V3モデルとその上の2つのCNNレイヤー
* 再トレーニング用に基盤のInceptionレイヤーの非凍結重みを使った転移学習(ノートパソコンのGPUでは、事前トレーニング済みのInceptionレイヤーを凍結するだけです)
* これまでに収集した小さなデータセットを補うためのわずかなデータ拡張
* バイナリの代わりに3つのカテゴリを使用: Covid-19、正常、細菌性(またはウイルス性)肺炎(これらの3つのクラスを使用した理由は後で説明します)
* 後のステップで使えるテンプレートとして、基本的な3クラス混同行列を計算
**注意**: ほかの一般的なCNNベースのモデル(VGG16やResNet50など)ではなく、Inception V3を選択した理由は特にありません。 他にもモデルがありますが、最近、骨折データセットのデモ実行に使用したため、ここではそれを再利用しているだけです。 以下のJupyterノートブックスクリプトを実行し直す際には、[お好きなモデル](https://towardsdatascience.com/illustrated-10-cnn-architectures-95d78ace614d)を使用してください。
この記事には、Jupyterノートブックも添付しました。 以下に簡単な説明も示しています。
### 1. 必要なライブラリをインポートする
# import the necessary packages
from tensorflow.keras.layers import AveragePooling2D, Dropout, Flatten, Dense, Input
from tensorflow.keras.models import Model
from tensorflow.keras.optimizers import Adam
from tensorflow.keras.utils import to_categorical
from tensorflow.keras import optimizers, models, layers
from tensorflow.keras.applications.inception_v3 import InceptionV3
from tensorflow.keras.applications.resnet50 import ResNet50
from tensorflow.keras.preprocessing.image import ImageDataGenerator
from sklearn.preprocessing import LabelEncoder, OneHotEncoder
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, confusion_matrix
from imutils import paths
import matplotlib.pyplot as plt
import numpy as np
import cv2
import os
### 2. [ここに提供されているサンプルの画像ファイル](https://github.com/zhongli1990/Covid19-X-Rays)を読み込む
# set learning rate, epochs and batch size
INIT_LR = 1e-5 # この値は選択したモデルに固有の値です: Inception、VGG、ResNetなど。
EPOCHS = 50
BS = 8
print("Loading images...")
imagePath = "./Covid_M/all/train" # 同じ画像のローカルパスに変更してください
imagePaths = list(paths.list_images(imagePath))
data = []
labels = []
# 指定されたパスにあるすべてのX線画像を読み取り、サイズを256x256に変更します
for imagePath in imagePaths:
label = imagePath.split(os.path.sep)[-2]
image = cv2.imread(imagePath)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
image = cv2.resize(image, (256, 256))
data.append(image)
labels.append(label)
#ピクセル値を0.0~1.0の実数に正規化します
data = np.array(data) / 255.0
labels = np.array(labels)
# マルチクラスのラベル付けを行うためにワンホットエンコーディングを実行します
label_encoder = LabelEncoder()
integer_encoded = label_encoder.fit_transform(labels)
labels = to_categorical(integer_encoded)
print("... ... ", len(data), "images loaded in multiple classes:")
print(label_encoder.classes_)
Loading images...
... ... 200 images loaded in 3x classes:
['covid' 'normal' 'pneumonia_bac']
### 3. 基本的なデータ拡張を追加し、モデルを再構成してトレーニングする
# データとトレーニング用と検証用に分割します。
(trainX, testX, trainY, testY) = train_test_split(data, labels, test_size=0.20, stratify=labels, random_state=42)
# 単純な拡張を追加します。 注意: このケースでは、拡張が多すぎるとうまく行きません。テストの過程でわかりました。
trainAug = ImageDataGenerator(rotation_range=15, fill_mode="nearest")
#事前トレーニング済み「ImageNet」の重みの転移学習でInceptionV3モデルを使用します。
#注意: VGG16またはResNetを選択した場合は、最上部で初期の学習率をリセットする必要がある場合があります。
baseModel = InceptionV3(weights="imagenet", include_top=False, input_tensor=Input(shape=(256, 256, 3)))
#baseModel = VGG16(weights="imagenet", include_top=False, input_tensor=Input(shape=(256, 256, 3)))
#baseModel = ResNet50(weights="imagenet", include_top=False, input_tensor=Input(shape=(256, 256, 3)))
#Inception V3モデルの上にカスタムCNNレイヤーを2つ追加します。
headModel = baseModel.output
headModel = AveragePooling2D(pool_size=(4, 4))(headModel)
headModel = Flatten(name="flatten")(headModel)
headModel = Dense(64, activation="relu")(headModel)
headModel = Dropout(0.5)(headModel)
headModel = Dense(3, activation="softmax")(headModel)
# 最終モデルを構成します
model = Model(inputs=baseModel.input, outputs=headModel)
# Navidia T4 GPUを使用しているので、再トレーニングを行うために、事前トレーニング済みのInception「ImageNet」重みの凍結を解除します
#baseModel.layersのレイヤーの場合:
# layer.trainable = False
print("Compiling model...")
opt = Adam(lr=INIT_LR, decay=INIT_LR / EPOCHS)
model.compile(loss="categorical_crossentropy", optimizer=opt, metrics=["accuracy"])
# 上記で事前トレーニング済みの重みの凍結を解除したため、全モデルをトレーニングします。
print("Training the full stack model...")
H = model.fit_generator( trainAug.flow(trainX, trainY, batch_size=BS), steps_per_epoch=len(trainX) // BS,
validation_data=(testX, testY), validation_steps=len(testX) // BS, epochs=EPOCHS)
... ...
Compiling model...
Training the full stack model...
... ...
Use tf.cast instead.
Epoch 1/50
40/40 [==============================] - 1s 33ms/sample - loss: 1.1898 - acc: 0.3000
20/20 [==============================] - 16s 800ms/step - loss: 1.1971 - acc: 0.3812 - val_loss: 1.1898 - val_acc: 0.3000
Epoch 2/50
40/40 [==============================] - 0s 6ms/sample - loss: 1.1483 - acc: 0.3750
20/20 [==============================] - 3s 143ms/step - loss: 1.0693 - acc: 0.4688 - val_loss: 1.1483 - val_acc: 0.3750
Epoch 3/50
... ...
... ...
Epoch 49/50
40/40 [==============================] - 0s 5ms/sample - loss: 0.1020 - acc: 0.9500
20/20 [==============================] - 3s 148ms/step - loss: 0.0680 - acc: 0.9875 - val_loss: 0.1020 - val_acc: 0.9500
Epoch 50/50
40/40 [==============================] - 0s 6ms/sample - loss: 0.0892 - acc: 0.9750
20/20 [==============================] - 3s 148ms/step - loss: 0.0751 - acc: 0.9812 - val_loss: 0.0892 - val_acc: 0.9750
### 4. 検証結果用に混同行列をプロットする
print("Evaluating the trained model ...")
predIdxs = model.predict(testX, batch_size=BS)
predIdxs = np.argmax(predIdxs, axis=1)
print(classification_report(testY.argmax(axis=1), predIdxs, target_names=label_encoder.classes_))
# 基本的な混同行列を計算します
cm = confusion_matrix(testY.argmax(axis=1), predIdxs)
total = sum(sum(cm))
acc = (cm[0, 0] + cm[1, 1] + cm[2, 2]) / total
sensitivity = cm[0, 0] / (cm[0, 0] + cm[0, 1] + cm[0, 2])
specificity = (cm[1, 1] + cm[1, 2] + cm[2, 1] + cm[2, 2]) / (cm[1, 0] + cm[1, 1] + cm[1, 2] + cm[2, 0] + cm[2, 1] + cm[2, 2])
# 混同行列、精度、感度、特異度を示します
print(cm)
print("acc: {:.4f}".format(acc))
print("sensitivity: {:.4f}".format(sensitivity))
print("specificity: {:.4f}".format(specificity))
# トレーニング損失と精度をプロットします
N = EPOCHS
plt.style.use("ggplot")
plt.figure()
plt.plot(np.arange(0, N), H.history["loss"], label="train_loss")
plt.plot(np.arange(0, N), H.history["val_loss"], label="val_loss")
plt.plot(np.arange(0, N), H.history["acc"], label="train_acc")
plt.plot(np.arange(0, N), H.history["val_acc"], label="val_acc")
plt.title("Training Loss and Accuracy on COVID-19 Dataset")
plt.xlabel("Epoch #")
plt.ylabel("Loss/Accuracy")
plt.legend(loc="lower left")
plt.savefig("./Covid19/s-class-plot.png")
上の図から、小さなデータセットと5分未満の簡易トレーニングでも「転移学習」のメリットにより、結果はそれほど悪くないことがわかります。12点すべてのCovid-19に感染した胸部画像は正しく分類されており、誤って「細菌性肺炎」の胸部画像として分類された正常な胸部画像は、合計40点のうち1点のみです。
###
### 5. 実際のX線画像をテストするための混同行列をプロットする
では、さらにもう一歩飛躍してみましょう。簡単にトレーニングされたこの分類器がどれほど効果的であるかをテストするために、実際のX線画像を送信してみます。
上記のトレーニングや検証セットで使用していない27点のX線画像をモデルにアップロードしました。
Covid-19に感染した胸部画像 9点と正常な胸部画像 9点と細菌性肺炎に感染した胸部画像 9点です。 (これらの画像は、この記事にも添付されています。)
ステップ2のコードを1行だけ変更し、テスト画像が確実に異なるパスから読み込まれるようにしました。
...
imagePathTest = "./Covid_M/all/test"
...
次に上記のトレーニング済みのモデルを使用して予測します。
predTest = model.predict(dataTest, batch_size=BS)
print(predTest)
predClasses = predTest.argmax(axis=-1)
print(predClasses)
...
最後に、ステップ5のようにして、混同行列を再計算します。
testX = dataTest
testY = labelsTest
... ...
実際のテスト結果が得られました。
またしても、トレーニング済みモデルは、すべてのCovid-19に感染した胸部画像を正しく分類できるようです。 こんなに小さなデータセットにしては、それほど悪くない出来です。
### 6. さらなる監察結果
このイースターの週末に、さまざまなデータセットを試し、Covid-19に感染した胸部画像には明確な特徴があるように見えました。AI分類器の観点では、通常の細菌性またはウイルス性(インフルエンザ)感染症の影響を受けた胸部画像と区別することは比較的簡単です。
また、いくつかの簡単なテストから、細菌性とウイルス性(一般的なインフルエンザ)の胸部画像を区別するのは非常に困難であることに気づきました。 時間があれば、ほかのKaggle挑戦者がおそらくあの状況で行うように、クラスタアンサンブルを使用して、その違いを追跡しなければならないでしょう。
では、臨床の観点では、上記の結果は本当に正しいのでしょうか? Covid-19に感染した胸部にはX線で本当に明確な特徴が見られるのでしょうか? あまり確信が持てません。 実際の胸部放射線科医に意見を聞かなくてはいけないでしょう。 今のところは、現在のデータセットは結論を出すには小さすぎると思います。
**次**: 実際のサイトのX線画像をもっと集めて、xgsboot、AutoML、または新しいIRIS IntegratedMLワークベンチを使って、深く調べたいと思います。 さらに、医師やA&Eトリアージ向けに、Covid-19に感染した胸部画像を、その深刻度に応じてレベル1、レベル2、およびレベル3のようにさらに分類できるはずです。
とにかく話を戻すと、[データセットと上記のJupyterノートブックを添付しました](https://github.com/zhongli1990/Covid19-X-Rays)。
## **デプロイメント**
上記では、この「医用画像」分野における簡単なセットアップについて、わりと単純な出発点に触れました。 このCovid-19フロントは、実際には、過去1年の週末や長期休暇中に調べたもののうち、3つ目の試行となります。 他には、「AI支援骨折検出」システムや「AI支援眼科網膜診断」システムなどを調べました。
上記のモデルは、今のところは単純すぎて試すほどでもないかもしれませんが、一般的な疑問はすぐにでも避けて通れないでしょう。[ある種の「AIサービス」はどのようにデプロイすればよいのでしょうか? ](https://community.intersystems.com/post/deploy-mldl-models-consolidated-ai-demo-service-stack)
テクノロジースタックとサービスのライフサイクルに関わることですが、どのような問題を解決しようとしているのか、どういった実際の価値が得られるのかといった実際の「ユースケース」によっても異なります。 答えはテクノロジーほど明確ではないこともあります。
イギリスの[RCR(王立放射線学者会)の原案](https://www.rcr.ac.uk/sites/default/files/integrating-ai-with-radiology-reportin%20g-workflow-guidance-covid19.pdf)では、「放射線技師のAI支援」と「A&EまたはプライマリケアにおけるAIトリアージ」という2つの単純なユースケースが提案されました。 正直なところ現時点では2つ目の「AIトリアージ」に同意しており、個人的には、この方がより高い価値をもたらすのではないかと思っています。 また、幸いにも、今日の開発者はクラウド、Docker、AI、そしてもちろんInterSystemsのHealthShareにより、これまで以上の力を使ってこの種のケースを解決することができます。
たとえば、以下のスクリーンキャプチャは実際にAWSにホストされたエンタープライズ級の「AI支援によるCovid-19感染胸部のCT検出」サービスを示しており、デモの目的で、いかにしてこのサービスを直接HealthShare Clinical Viewerに組み込むかを説明しています。 X線撮影と同様に、DICOMに設定されたCTも迅速な「AIトリアージ」のユースケース用に毎日24時間稼働しながら、このオープンPACS Viewerに直接アップロードまたは送信し、「AI診断」をクリックするだけで、トレーニング済みモデルに基づいて定量化されたCovid-19感染確率の判定を提供することができます。 X線画像分類などのモデルは、最前線に立つ医師を支援できるよう、同一患者のコンテキストで既存のPACSビューアの上にデプロイし、同じ方法で呼び出すことができます。

## **謝辞**
繰り返しますが、このテスト画像は、Joseph Paul Cohenによる[ Covid-19 Lung X-Ray set](https://github.com/ieee8023/covid-chestxray-dataset)から得たもので、正常な胸部画像はAndrian Yuが[GradientCrescentリポジトリ](http://github.com/EXJUSTICE/GradientCrescent)に収集しているオープンの[Kaggle Chest X-Ray sets](https://www.kaggle.com/paultimothymooney/chest-xray-pneumonia)に含まれるものです。 また、「テスト」セクションにリストされたとおり、独自に改善したトレーニングを使って[PyImageSearchのAdrian](https://github.com/AleGiovanardi/covidhelper)の構造を再利用しています。 また、テストデータベースを確認するために、X線画像とCT画像用のAIモジュール付き[AWSクラウドベースOpen PACS Viewer](http://ec2-3-8-183-64.eu-west-2.compute.amazonaws.com/doctor/index.html#!/login)を提供していただいた[HYM](http://en.huiyihuiying.com/)にも感謝しています。
**次の内容**
今日、AIは、人間の健康と日常生活のほぼすべての側面を「浸食」しています。 単純化し過ぎた私の見解では、ヘルスケア分野におけるAI技術の応用には、以下のようないくつかの方向性があると考えられます。
* **医用画像**: 胸部、心臓、目、脳などのX線、CT、またはMRIなどの診断画像。
* **NLPによる読解**: 膨大な量のテキスト資産とナレッジベースのマイニング、理解、学習。
* **公衆衛生**: 疫学などの傾向予測、分析、モデリングなど。
* パーソナル化AI: 個人専用の健康アシスタントとして、ともに成長して老化するように特別にトレーニングされたAI/ML/DLモデル。
* その他のAI: AlphaGoや、Covid-19への対抗に活用できる3次元タンパク質構造予測向けのAlphaFoldなど。このような最先端の画期的な技術に非常に感激しています。
学習の過程で、焦点を当てられるものを検討していきたいと思います。 いずれにしても、在宅が長く続かない限りは、取り上げたいリストにしかすぎないかもしれません。
付録 - ファイルアップロードはこちらです。 上記で使用した画像と上記のJupyterノートブックファイルが含まれています。 週末に新規でセットアップして実行するには、数時間かかるかもしれません。
記事
Minoru Horita · 2020年6月2日
最初の記事については、パート1を参照してください。
3. グローバルを使用する場合のさまざまな構造
順序付きツリーなどの構造には、さまざまな特殊ケースがあります。 グローバルを使用する上で実用的な価値があるものを見てみましょう。
3.1 特殊ケース1 - 枝のない1つのノード
グローバルは配列のようにも、通常の変数のようにも使用できます。 例えば、カウンターを作成する場合を考えてみましょう。
Set ^counter = 0 ; カウンターの設定
Set id=$Increment(^counter) ; アトミックなインクリメント操作
また、グローバルには値に加えて枝を持たせることができます。 一方が他方を除外することはありません。
3.2 特殊ケース2 - 1つのノードと複数の枝
実際、これは典型的なキー・バリューベースのデータ構造です。 また、値の代わりに値のタプルを保存すると、主キーを持つ通常のテーブルが得られます。
グローバルに基づくテーブルを実装するには、カラムの値から文字列を作成し、主キー別にそれをグローバルに保存する必要があります。 読み取りの時に文字列をカラムに分割できるようにするため、以下のいずれかを使用することができます。
区切り文字
Set ^t(id1) = "col11/col21/col31"
Set ^t(id2) = "col12/col22/col32"
各フィールドが特定のバイト数を占める、固定のスキーマ。 これは、リレーショナルデータベースで通常行われる方法です。
値から文字列を作る専用の $LB 関数(Cachéで導入されます)。
Set ^t(id1) = $LB("col11", "col21", "col31")
Set ^t(id2) = $LB("col12", "col22", "col32")
興味深いことに、グローバルを使用すればリレーショナルDBの外部キーと同様のことを行うのは難しくありません。 このような構造をインデックスグローバルと呼びましょう。 インデックスグローバルは、メイングローバルの主キーを構成しないフィールドですばやく検索するための補助的なツリーです。 インデックスグローバルにデータを入れて使用するには、追加のコードを記述する必要があります。
最初のカラムに基づいてグローバルインデックスを作成しましょう。
Set ^i("col11", id1) = 1
Set ^i("col12", id2) = 1
最初のカラムですばやく検索するには、^i グローバルを調べ、最初のカラムで必要な値に対応する主キー(id)を見つける必要があります。
値を挿入する際には、必要なフィールドの値とインデックスグローバルの両方を作成できます。 信頼性を確保するため、トランザクションで囲みましょう。
TSTART
Set ^t(id1) = $LB("col11", "col21", "col31")
Set ^i("col11", id1) = 1
TCOMMIT
詳細については、グローバルとセカンダリキーのエミュレーションを使用してMでテーブルを作成する方法をご覧ください。
挿入/更新/削除関数がCOS/Mで記述されてコンパイルされている場合、これらのテーブルは従来のDBと同じくらい高速に(またはさらに高速に)機能します。
単一の2カラム構成のテーブルにINSERTおよびSELECT操作の大部分を適用し、TSTARTおよびTCOMMITコマンド(トランザクション)も併用することにより、このことをを検証しました。 同時アクセスと並列トランザクションが発生する、より複雑なシナリオはテストしていません。 トランザクションを使用しない場合、100万件の値を挿入したときの速度は毎秒778,361レコードでした。 3億件の場合、挿入速度は毎秒422,141レコードでした。トランザクションが使用された場合、挿入速度は5,000万件の値で毎秒572,082レコードでした。 すべての操作はコンパイル済みのMコードから実行されました。 SSDではなく、通常のハードドライブを使用しました。 ライトバックキャッシュ付きのRAID5を構成していました。 すべての処理は、Phenom II 1100T CPUで実行されました。 SQLデータベースに対して同じテストを実行するには、ループで挿入を行うストアドプロシージャを作成する必要があります。同じ方法を使用してMySQL 5.5(InnoDBストレージ)をテストしたときは、1秒あたりの挿入件数が11,000を超えることはありませんでした。
そうです。グローバルを使ったテーブルの実装は、リレーショナルデータベースで同じ実装をするよりも複雑です。 そのため、グローバルに基づく実務用DBはSQLデータにアクセスし、表形式データでの作業を簡略化しているのです。
一般的に、データスキーマが頻繁に変更されない場合、挿入速度は重要ではなく、データベース全体を正規化されたテーブルで簡単に表現することができます。これにより、より高度な抽象化が行われるため、SQLでの作業が容易になります。
このケースでは、グローバルを他の種類のDBを作成するための構成要素として使用できることを示したいと思いました。 これは、他の言語の作成に使用できるアセンブリ言語と同じです。 また、グローバルを使用してキー/値、リスト、セット、表形式、ドキュメント指向のDBに相当するものを作成する例がいくつか存在します。
最小限の労力で標準的ではないDBを作成する必要がある場合は、グローバルの使用を検討すると良いでしょう。
3.3 特殊ケース3 - 第2階層に枝の数が固定されたノードを持つ2階層のツリー
ご想像のとおり、これはグローバルを使用したテーブルの代わりに実装したものです。 以前のものと比較してみましょう。
2階層ツリー内のテーブルと 1階層ツリー内のテーブルの比較。
短所
長所
ノード数とカラム数が等しくなるように設定する必要があるため、挿入が遅くなります。
カラム名を含むグローバルインデックス(配列インデックスなど)がハードドライブの容量を占有し、各レコードで複製されるため、ハードドライブの容量の消費が多くなります。
文字列を解析する必要がないため、特定カラムの値に高速にアクセスできます。 私がテストした結果によれば、2カラムの場合は11.5%高速で、カラム数が増えるとさらに高速になりました。
データスキーマの変更が簡単
コードが読みやすい
結論: 特筆すべきことはありません。 パフォーマンスはグローバルの主なメリットの1つであるため、このアプローチを使用しても実質的には意味がありません。リレーショナルデータベースの通常のテーブルよりも高速に動作することはほとんどないからです。
3.4 一般的なケース - ツリーと順序キー
ツリーとして表現できるすべてのデータ構造は、完全にグローバルに適合します。
3.4.1 サブオブジェクトを持つオブジェクト
これは、グローバルが昔から使用されている分野です。 医療分野には数多くの病気、薬剤、症状、治療法があります。 これらのフィールドの99%は空になるため、患者ごとに100万フィールドのテーブルを作成するのは不合理です。
「患者」〜100,000フィールド、「投薬」100,000フィールド、「治療」100,000フィールド、「合併症」100,000フィールドなどのテーブルで構成されたSQL DBを想像してください。 別の方法として、特定の患者タイプ(これも重複する可能性があります!)、治療、投薬ごとの数千テーブルと、これらのテーブル間のリレーション用の数千テーブルを含むDBを作成できます。
グローバルは手袋のようにヘルスケアに適合します。これは、各患者に対して完全な症例記録、治療法のリスト、投与薬とその効果をすべて、空のカラムに無駄なディスク領域を浪費することなくツリーの形で持たせることができるためです。リレーショナルデータベースではそうはいきません。
グローバルは、個人の詳細情報を含むデータベースに適しています。取引先に関するさまざまな個人データを最大限に蓄積し、システム化することが課題である場合が想定されます。 これは、医療、銀行、マーケティング、アーカイブなどの分野で特に重要です。
当然ながら、SQLを使用すればいくつかのテーブルのみを使用してツリーをエミュレートすることもできますが(EAV、1、2、3、4、5、6、7、8)、その場合はかなり複雑になり、処理が遅くなります。 つまりテーブルに基づいてグローバルを記述し、すべてのテーブル関連ルーチンを抽象化レイヤーの下に隠す必要があるでしょう。 上位の技術(SQL)を利用して下位の技術(グローバル)をエミュレートすることは正しくありません。 それは単純に言って不当です。
巨大なテーブルのデータスキーマを変更する処理(ALTER TABLE)には、かなりの時間がかかる場合があるのは明らかです。 例えば、MySQLの場合はすべてのデータを古いテーブルから新しいテーブルにコピーすることにより、ALTER TABLE ADD|DROP COLUMN操作を実行します(MyISAMとInnoDBでテスト済みです)。 このような処理は、数十億件のレコードを含む本番データベースを数週間とは言わないまでも、数日間ハングアップさせる可能性があります。
グローバルを使用している場合、データ構造の変更にコストはかかりません。いつでも任意の階層の任意のオブジェクトにいくらでも新しいプロパティを追加できます。 枝の名前変更が必要な場合は、DBがバックグラウンドモードで稼働している状態で適用できます。
そのため、グローバルは省略可能なプロパティを多数含むオブジェクトを格納する場合に最適です。
グローバルではすべてのパスがBツリーであるため、プロパティへのアクセスが瞬時に行われることを思い出してください。
一般的なケースでは、グローバルに基づくデータベースは階層情報の格納に対応した一種のドキュメント指向データベースであると言えます。 したがって、ドキュメント指向のデータベースはカルテ保存の分野でグローバルと効果的に競合できます。
しかし、それではまだ不十分です。
MongoDBを例にとってみましょう。この分野では 、以下の理由でグローバルに劣っています。 1. ドキュメントサイズ。保存単位はJSON形式(正確にはBSON形式)のテキストで、最大サイズは約16 MBです。 この制限は、解析中に巨大なJSONドキュメントが保存され、特定フィールド値のアドレスが指定された場合にJSONデータベースが遅くなりすぎないようにするために導入されました。 このドキュメントには、患者に関する完全な情報が含まれていなければなりません。 私たちは皆、カルテがどれほど厚いかを知っています。 カードの最大サイズが16 MBに制限されているのであれば、カードにMRIスキャン画像、X線スキャン画像などの資料が含まれている患者はすぐに除外されてしまうでしょう。 グローバルの単一ブランチには、ギガバイトや数千テラバイトのデータを持たせることができます。 それがすべてを物語っていますが、もう少し話を続けましょう。 2. 患者カードから新しいプロパティを作成/変更/削除するのに要する時間。このようなデータベースでは、カルテ全体(大量のデータ!)をメモリにコピーし、BSONデータを解析し、新しいノードを追加/変更/削除し、インデックスを更新し、すべての情報をBSONに圧縮してディスクに保存する必要があります。 グローバルの場合は必要なプロパティのアドレスを指定し、必要な操作を実行するだけで済みます。 3. 特定プロパティへのアクセス速度。ドキュメントに多くのプロパティと複数階層構造がある場合、各パスがBツリーになっているグローバルのほうが特定プロパティへのアクセスが高速になります。 BSONでは、必要なプロパティを見つけるためにドキュメントを順番に解析する必要があります。
3.3.2 連想配列
連想配列(入れ子になった配列でさえも)は、グローバルで完璧に使用できます。 例えば、次のPHP配列は3.3.1の最初の図のようになります。
$a = array(
"name" => "Vince Medvedev",
"city" => "Moscow",
"threatments" => array(
"surgeries" => array("apedicectomy", "biopsy"),
"radiation" => array("gamma", "x-rays"),
"physiotherapy" => array("knee", "shoulder")
)
);
3.3.3 階層ドキュメント:XML、JSON
これらも簡単にグローバルに格納し、さまざまな方法で分解できます。
XML
XMLをグローバルに分解するには、ノードにタグ属性を格納するのが最も簡単です。 タグの属性に素早くアクセスする必要がある場合は、それらを別々の枝に配置できます。
<note id=5>
<to>Alex</to>
<from>Sveta</from>
<heading>Reminder</heading>
<body>Call me tomorrow!</body>
</note>
COSでは、次のようなコードになります。
Set ^xml("note")="id=5"
Set ^xml("note","to")="Alex"
Set ^xml("note","from")="Sveta"
Set ^xml("note","heading")="Reminder"
Set ^xml("note","body")="Call me tomorrow!"
注意:XML、JSON、および連想配列の場合、それらをグローバルに表示する方法はいくつか見つけることができます。 この特定のケースでは、「note」タグ内で入れ子になったタグの順序は反映されていません。 ^xml グローバルでは、入れ子になったタグはアルファベット順に表示されます。 順序を正確に表すには、次のようなモデルを使用できます。
JSON
このJSONドキュメントの内容は、セクション3.3.1の最初の図に示されています。
var document = {
"name": "Vince Medvedev",
"city": "Moscow",
"threatments": {
"surgeries": ["apedicectomy", "biopsy"],
"radiation": ["gamma", "x-rays"],
"physiotherapy": ["knee", "shoulder"]
},
};
3.3.4 階層関係に制限された同一の構造
例:営業所の構造、ネットワークビジネス構造における人々のポジション。
デビューのデータベース。グローバルのノードインデックスの値として、手の強さを評価に使用できます。 この場合、最高の手を決定するには、重みが最も高い枝を選択する必要があります。 グローバルでは、すべての階層のすべての枝が手の強さでソートされます。
営業所、ネットワークビジネス企業の人々の構造。ノードには、サブツリー全体の特性を反映するいくつかのキャッシュ値を保存できます。 例えば、この特定のサブツリーの売上高です。 いつでもすべての支店の業績について正確な情報を得ることができます。
4. グローバルの使用が役立つ場面
最初の列にはグローバルを使用した場合にパフォーマンス面でかなりのメリットが得られるケースを、2番目の列には開発またはデータモデルを単純化できる状況を一覧で掲載しています。
スピード
データ処理/表現の利便性
挿入(各階層での自動ソートを伴うもの)、(主キーごとにインデックスを作成するもの)
サブツリーの削除
個別にアクセスが必要な入れ子になったプロパティを多数持つオブジェクト
存在しないものも含め、任意の枝から子の枝を探索する可能性のある階層構造
ツリーの深さ方向の探索
不要なプロパティ/インスタンス(入れ子になったものを含む)を大量に含むオブジェクト/インスタンス
スキーマのないデータ。新しいプロパティが頻繁に追加され、古いプロパティが削除される可能性がある場合。
標準的ではないDBを作成する必要がある場合。
パスデータベースとソリューションツリー。 パスをツリーとして適切に表現できる場合。
再帰を使用しない階層構造の削除
続きは「グローバルはデータを保存するための魔法の剣です パート3 - 疎な配列」を読み進めてください。
免責事項:この記事と記事に対する筆者(英語原文はSergey Kamenev氏によるものです)のコメントは、筆者の意見を反映しているにすぎず、InterSystems Corporationの公式見解とは関係ありません。
次のパート「グローバルはデータを保存するための魔法の剣です パート3 - 疎な配列 」を読み進めてください。
また、前のパート「グローバルはデータを管理するための魔法の剣です パート1」も確認してください。
記事
Toshihiko Minamoto · 2021年9月30日
## はじめに
Caché 2016.2のフィールドテストはかなり前から利用可能ですので、このバージョンで新しく追加されたドキュメントデータモデルという重要な機能に焦点を当てたいと思います。 このモデルは、オブジェクト、テーブル、および多次元配列など、データ処理をサポートするさまざまな方法として自然に追加されました。 プラットフォームがより柔軟になるため、さらに多くのユースケースに適したものになります。
いくつかのコンテキストから始めましょう。 NoSQLムーブメントの傘下にあるデータベースシステムを少なくとも1つは知っているかもしれません。 これにはかなりたくさんのデータベースがあり、いくつかのカテゴリにグループ化することができます。 Key/Valueは非常に単純なデータモデルです。 値をデータベースに格納し、それにキーを関連付けることができます。 値を取得する場合は、キーを介してそれにアクセスする必要があります。 適切なキーを選択によってソートが決まり、キーの一部であるものでグループ化する場合に単純な集計に使用できるようになるため、キーの選択が重要な鍵となります。 ただし、値は値にすぎません。 値内の特定のサブ要素にアクセスしたり、それらにインデックスを作成したりすることはできません。 値をさらに活用するには、アプリケーションロジックを書く必要があります。 Key/Valueは、大規模なデータセットと非常に単純な値を操作する必要がある場合に最適ですが、より複雑なレコードを扱う場合には価値が劣ります。
ドキュメントデータモデルはKey/Valueにとてもよく似ていますが、値はより複雑です。 値はキーに関連付けられたままになりますが、さらに、値のサブ要素にアクセスして特定の要素にインデックスを作成することができます。 つまり、いくつかのサブ要素が制限を満たす特定のドキュメントを検索することもできるということです。 明らかに、NoSQLの世界にはGraphのような他のモデルもさらに存在しますが、ここでは、ドキュメントに焦点を置くことにします。
###
## そもそもドキュメントとは?
一部の読者を混乱させる傾向があるため、まず最初に、1つ明確にしておきましょう。この記事で「ドキュメント」と言った場合、PDFファイルやWordドキュメントといった物理的なドキュメントを指してはいません。
この文脈でのドキュメントとは、サブ値を特定のパスと関連付けることのできる構造を指しています。 ドキュメントを記述できるシリアル化形式には、JSONやXMLなどのよく知られたものが様々あります。 通常こういった形式には、共通して次のような構造とデータ型があります。
1. 順序付けされていないKey/Valueペアの構造
2. 順序付けされた値のリスト
3. スカラー値
1つ目は、XMLの属性要素とJSONのオブジェクトにマッピングされます。 2つ目の構造は、XMLのサブ要素とJSONの配列を使ったリストによって導入されています。 3つ目は、単に、文字列、数値、ブール値といったネイティブのデータ型を利用できるようしています。
JSONのようなシリアル化された形式でドキュメントを視覚化することは一般的ですが、これは、ドキュメントを表現できる一方法にすぎないことに注意してください。 この記事では、JSONを主なシリアル化形式として使用することにします。JSONサポートの改善機能をうまく利用できるでしょう。この改善についてまだ読んでいない方は[こちら](https://community.intersystems.com/post/introducing-new-json-capabilities-cach%C3%A9-20161)をご覧ください。
ドキュメントはコレクションにグループ化されます。 セマンティックと潜在的に共通の構造を持つドキュメントは同じコレクションに保存する必要があります。 コレクションはその場で作成できるため、事前にスキーマ情報を用意しておく必要はありません。
コレクションにアクセスするには、データベースハンドルを最初に取得しておく必要があります。 データベースハンドルはサーバーへの接続として機能し、コレクションへの単純なアクセスを提供しますが、分散環境の場合にはさらに複雑なシナリオを処理することもできます。
###
## 基本
まず、Caché Object Scriptで単純なドキュメントを挿入する方法を見てみましょう。
USER>set db = ##class(%DataModel.Document.Database).$getDatabase()
USER>set superheroes = db.$getCollection("superheroes")
USER>set hero1 = {"name":"Superman","specialPower":"laser eyes"}
USER>set hero2 = {"name":"Hulk","specialPower":"super strong"}
USER>do superheroes.$insert(hero1)
USER>do superheroes.$insert(hero2)
USER>write superheroes.$size()
2
上記のコードサンプルでは、まずデータベースハンドルが取得されて、「superheroes」というコレクションが取得されます。 コレクションは明示的に作成されるため、事前に設定する必要はありません。 新しいコレクションにアクセスできるようになったら、ヒーローのSupermanとHulkを表す非常に単純なドキュメントを2つ作成します。 これらは$insert(<document>)への呼び出しでコレクションに保存され、コレクションサイズの最終チェックで、2つのドキュメントを報告します。これは、以前にコレクションが存在していなかったためです。
$insert()呼び出しは、成功すると、挿入されたドキュメントを返します。 このため、ドキュメントの操作を続行する場合に、自動的に割り当てられたIDを取得することができます。 また、チェーンメソッドも可能になります。
USER>set hero3 = {"name":"AntMan","specialPower":"can shrink and become super strong"}
USER>write superheroes.$insert(hero3).$getDocumentID()
3
このコードスニペットは、別のヒーローオブジェクトを作成し、superheroesコレクションに永続させます。 今回は、メソッド呼び出しの$getDocumentID()を$insert()呼び出しに連鎖させ、システムがこのドキュメントに割り当てたIDを取得します。 $insert()は必ず自動的にIDを割り当てます。 独自のIDを割り当てる必要がある場合は、$insertAt(<User-ID>,<document>)呼び出しを利用できます。
特定のIDでドキュメントを取得する場合は、コレクションに対して$get(<ID>)メソッドを呼び出すことができます。
USER>set antMan = superHeroes.$get(3)
USER>write antMan.$toJSON()
{"name":"AntMan","specialPower":"can shrink and become super strong"}
Supermanとそのhometownを表すドキュメントを更新するとしましょう。 この場合、$upsert(<ID>,<document>)呼び出しを使用して、既存のドキュメントを簡単に更新することができます。
USER>set hero1.hometown = "Metropolis"
USER>do superheroes.$upsert(1,hero1)
USER>write superheroes.$get(1).$toJSON()
{"name":"Superman","specialPower":"laser eyes","hometown":"Metropolis"}
$upsert()は、IDがまだほかに使用されていない場合にドキュメントを挿入するか、そうでない場合に既存のドキュメントを更新します。
もちろん、$toJSON()を呼び出すだけで、コレクションの全コンテンツをJSONにシリアル化することも可能です。
USER>write superheroes.$toJSON()
[
{"documentID":1,"documentVersion":4,"content":{"name":"Superman","specialPower":"laser eyes","hometown":"Metropolis"}},
{"documentID":2,"documentVersion":2,"content":{"name":"Hulk","specialPower":"super strong"}},
{"documentID":3,"documentVersion":3,"content":{"name":"AntMan","specialPower":"can shrink and become super strong"}}
]
コレクションがドキュメントの配列として表されていることがわかります。 各ドキュメントは、ドキュメントIDとドキュメントバージョンでラップされており、同時実行を適切に処理するために使用されます。 実際のドキュメントコンテンツは、ラッパーのプロパティコンテンツに格納されます。 これは、コレクションの完全なスナップショットを取得して移動できるようにするために必要な表現です。
また、これはモデルの非常に重要な側面をカバーしており、特殊プロパティを予約してドキュメントデータに挿入することはありません。 ドキュメントを適切に処理するためのエンジンが必要とする情報は、ドキュメントの外部に保存されます。 さらに、ドキュメントはオブジェクトまたは配列のいずれかです。 その他の多くのドキュメントストアは、オブジェクトを最上位の要素としてのみ許可しています。
コレクションでドキュメントを変更するためのAPI呼び出しには他にもたくさんあり、基本的な演算をいくつか見てきました。 ドキュメントの挿入と変更は楽しい作業ですが、実際にデータセットを分析したり、特定の制限を満たすドキュメントを取得したりすると、さらに興味深くなります。
##
## クエリ
すべてのデータモデルが有用とみなされるには、何らかのクエリ機能が必要です。 動的スキーマを使用してドキュメントをクエリできるようにするには、2つの潜在的な方法があります。
1. 動的なドキュメントの性質に対処できる独自のクエリ言語を設計して実装する
2. 定着している構造化されたクエリ言語にクエリを統合する
この記事の後の方で説明するいくつかの理由により、コレクションをSQLエンジンに公開することにしました。 SQLの知識を引き続き活用できるというメリットがあります。また、クエリ方言の別のフレーバーを作成しているところでもあります。 実際、SQL ANSI委員会は、JSONの標準拡張機能を提案しており、それに準拠しています。 まとめれば、これらの拡張機能には、JSON関数の2つのカテゴリが含まれています。
1. リレーションコンテンツからJSONコンテンツに公開するための関数セット
2. 動的JSONコンテンツをクエリするための関数セット
この記事の範囲では、2つ目のカテゴリである動的JSONコンテンツのクエリのみを取り上げ、結果をSQLで処理できるようにテーブルとして利用できるようにします。
動的コンテンツ(関連するスキーマのないコンテンツ)を公開し、事前に定義されたスキーマを使用してデータを操作するSQLで利用できるようにする魔法の関数は、JSON_TABLEです。 一般に、この関数は2つの引数を取ります。
1. JSONデータソース
2. 名前と型で列へのJSONパスのマッピングを指定する定義
骨に肉付けした例を見てみましょう。
SELECT name, power FROM JSON_TABLE(
'superheroes',
'$' COLUMNS(
name VARCHAR(100) PATH '$.name',
power VARCHAR(300) PATH '$.specialPower'
)
)
name power
--------- -------------------------------
Superman laser eyes
Hulk super strong
AntMan can shrink and become super strong
JSON_TABLE関数の最初の引数は、それが作成する仮想テーブルのソースを定義します。 この場合、コレクション「superheroes」をクエリします。 このコレクションのドキュメントごとに行が作られます。
2つ目の引数は、ドキュメントの特定の値をテーブルの列として公開することを忘れないでください。 この引数は2つ部分で構成されています。最初のステップとして、次の式のコンテキストを設定します。 ドル記号'$'には特別な意味があり、ドキュメントのルートを指しています。 それ以降のすべての式はこのコンテキストを基準としています。
後に続くのは、COLUMNS句で、カンマ区切りのCOLUMN式のリストです。 COLUMN式ごとに、仮想テーブルの列が作成されます。 「name」と「power」という2つの列をクエリで公開しています。 列「name」はVARCHAR(100)型で定義されていますが、列「power」は300文字に制限されています。 PATH式は、JPL(JSONパス言語)式を使用してドキュメントの特定の値を列に関連付けています。 キー「name」の値は列「name」に公開されますが、キー「specialPower」の値は列「power」にマッピングされます。 JPL式は非常に表現力が高く強力ですが、これについては別のトピックで説明することにします。 このサンプルで使用した式は非常に基本的な式です。
この構文が初めてであれば、理解するのに少し時間が掛かるかもしれませんが、 JSON_TABLE関数を自然に読み取ると理解しやすいでしょう。 例として、上記のクエリを使います。 ここで表現しているのは、基本的に次のことです。
コレクション「superheroes」をクエリし、各ドキュメントのルートに式のコンテキストを設定します。 次の2つの列を公開します。
1. 列「name」を型VARCHAR(100)で公開し、キー「name」の値を挿入します。
2. 列「power」を型VARCHAR(300)で公開し、キー「specialPower」の値を挿入します。
前に述べた通り、JPL式は複雑になりがちであるか、たくさんの列を公開したいだけの場合もあります。 そのため、型定義を参照できる標準への拡張を組み込みました。これは基本的に、事前定義済みのCOLUMNS句です。 このようにして、上記のCOLUMNS句を登録することができます。
do db.$createType("heropower",{"columns":[{"column":"name","type":"VARCHAR(100)","path":"$.name"},{"column":"power","type":"VARCHAR(300)","path":"$.specialPower"}]})
型情報を登録したら、%TYPE式を使って、JSON_TABLE関数でそれを参照することができます。
SELECT name, power FROM JSON_TABLE(
'superheroes',
'$' %TYPE 'heropower'
)
これは明らかに、SQLクエリにドキュメントの一貫したビューを提供し、クエリそのものを大幅に簡略化する上で役立ちます。
## 高度な内容
ここまで説明したことのほぼすべてについて補足することはたくさんありますが、ここでは最も重要なことに焦点を当てたいと思います。 最後のセクションを読みながら、JSON_TABLE関数を非常に強力なポイントとしている手掛かりに気づいたかもしれません。
1. 仮想テーブルを作成する
2. JSONのようなデータをソースデータとして消費できる
最初の項目はそれだけで重要なポイントです。コレクションを簡単にクエリして、別のJSON_TABLE呼び出しまたは正にテーブルと結合することができるからです。 コレクションをテーブルと結合できることは大きなメリットです。要件に応じて、データに完璧なデータモデルを選択できるのです。
型安全、整合性チェックが必要であるのに、モデルがあまり進化していませんか? リレーショナルを使いましょう。 他のソースのデータを処理してそのデータを消費する必要があり、モデルが必ず急速に変化するか、アプリケーションユーザーの影響を受ける可能性のあるモデルを保存することを検討していますか? ドキュメントデータモデルを選択しましょう。 モデルはSQLで一緒にまとめることができるので安心です。
JSON_TABLE関数の2つ目のメリットは、実のところ基盤のデータモデルとは無関係です。 これまでに、JSON_TABLEでコレクションのクエリを説明してきました。 最初の引数は任意の有効なJSON入力にすることもできます。 次の例を考察しましょう。
SELECT name, power FROM JSON_TABLE(
'[
{"name":"Thor","specialPower":"smashing hammer"},
{"name":"Aquaman","specialPower":"can breathe underwater"}
]',
'$' %TYPE 'heropowers'
)
name power
--------- -------------------------------
Thor smashing hammer
Aquaman can breathe underwater
入力は通常のJSON文字列で、オブジェクトの配列を表します。 構造がsuperheroesコレクションと一致するため、保存された型識別子「heropowers」を再利用することができます。
これにより、強力なユースケースが可能になります。 実際にメモリ内のJSONデータをディスクに永続させずにクエリすることができます。 REST呼び出しでJSONデータをリクエストし、クエリを実行してコレクションまたはテーブルを結合できます。 この機能を使用すると、Twitterタイムライン、GitHubリポジトリの統計、株式情報、または単に天気予報のフィードをクエリできます。 この機能は非常に便利であるため、これについては、後日、専用の記事で取り上げたいと思います。
##
## REST対応
ドキュメントデータモデルでは、初期状態でREST対応のインターフェースが備わっています。 すべてのCRUD(作成、読み取り、更新、削除)とクエリ機能はHTTP経由で利用できます。 完全なAPIについては説明しませんが、ネームスペース「USER」内の「superheroes」コレクションのすべてのドキュメントを取得するサンプルcURLを以下に示します。
curl -X GET -H "Accept: application/json" -H "Cache-Control: no-cache" http://localhost:57774/api/document/v1/user/superheroes
###
## 私のユースケースで使用できますか?
ドキュメントデータモデルは、InterSystemsのプラットフォームへの重要な追加機能です。 これは、オブジェクトとテーブルに続く、まったく新しいモデルです。 SQLとうまく統合できるため、既存のアプリケーションで簡単に利用することができます。 Caché 2016.1で導入された新しいJSON機能によって、CachéでのJSONの処理が楽しく簡単になります。
そうは言っても、これは新しいモデルです。 いつ、そしてなぜそれを使用するのかを理解する必要があります。 常に言っていることですが、特定のタスクにはそれに適したツールを選択してください。
このデータモデルは、動的データを処理する必要がある場合に優れています。 以下に、主な技術的メリットをまとめます。
* 柔軟性と使いやすさ
スキーマを予め定義する必要がないため、データの作業環境を素早くセットアップし、データ構造の変更に簡単に適応させることができます。
* スパース性
テーブルに300列があっても、各行が入力できるのはその内の15列であることを覚えていますか? これはスパースなデータセットであり、リレーショナルシステムではそれらを最適に処理にできません。 ドキュメントは設計上スパースであり、効率的に保存と処理を行えるようになっています。
* 階層
配列やオブジェクトなどの構造化型は、任意の深さでネストすることができます。 つまり、ドキュメント内で関連するデータを保存することができるため、そのレコードにアクセスする必要がある場合の読み取りのI/Oを潜在的に縮小することができます。 データは非正規化して保存できますが、リレーショナルモデルではデータは正規化して保存されます。
* 動的な型
特定のキーには、列のように固定されたデータ型がありません。 名前は、あるドキュメントでは文字列であっても、別のドキュメントでは複雑なオブジェクトである可能性があります。 単純なものは単純にしましょう。 複雑になることはありますが、そうなった場合はもう一度単純化しましょう。
上記の項目はそれぞれ重要であり、適切なユースケースには、少なくとも1つが必要ではありますが、すべての項目が一致することは珍しいことではありません。
モバイルアプリケーションのバックエンドを構築しているとしましょう。 クライアント(エンドユーザー)は自由に更新できるため、同時に複数のバージョンのインターフェースをサポートする必要があります。 WebServicesを使用するなど、契約によって開発すると、データインターフェースを素早く適応させる能力が低下する可能性があります(ただし、安定性が増す可能性はあります)。 ドキュメントデータモデルには柔軟性が備わっているため、スキーマを素早く進化させ、特定のレコード型の複数のバージョンを処理し、それでもクエリで相関させることができます。
###
## その他のリソース
この魅力的な新機能についてさらに詳しく知りたい方は、利用可能なフィールドテストバージョン2016.2または2016.3を入手してください。
『ドキュメントデータモデル学習パス』を必ず確認してください。
今年のグローバルサミットのセッションをお見逃しなく。 「データモデリングリソースガイド」には、関連するすべてのセッションが集められています。
最後に、開発者コミュニティに参加し、質問を投稿しましょう。また、フィードバックもお待ちしています。
記事
Tomohiro Iwamoto · 2020年5月7日
Cachéの優れた可用性とスケーリング機能の1つは、エンタープライズキャッシュプロトコル(ECP)です。 アプリケーション開発中に考慮することにより、ECPを使用した分散処理は、Cachéアプリケーションのスケールアウトアーキテクチャを可能にします。 アプリケーション処理は、アプリケーションを変更することなく、単一のアプリケーションサーバーから最大255台といった非常に高いレートにまで、アプリケーションサーバー処理能力を拡張できます。
ECPは、私が関与していたTrakCareのデプロイメントで長年広く使用されていました。 10年前は、主要ベンダーの1つが提供する「大きな」x86サーバーは、合計で8つのコアしか備えていなかったかもしれません。 大規模なデプロイメントの場合、ECPは、高価な大型コンピュータを使う単一のエンタープライズサーバーではなく、コモディティサーバーでの処理をスケールアウトする方法でした。 コア数の多いエンタープライズサーバーでさえ制限があったため、ECPはそれらのサーバーへのデプロイメントのスケーリングにも使用されました。
現在、ほとんどの新しいTrakCareのデプロイメントや主流ハードウェアへのアップグレードは、ECPでのスケーリングを必要としません。 現行の2ソケットx86プロダクションサーバーは、数十のコアと巨大なメモリを持つことができます。 最近のCachéバージョンでは、TrakCare(および他の多くのCachéアプリケーション)は、単一サーバーにおいて、CPUコア数とメモリの増設により、ユーザーとトランザクションの増加をサポートしうる、予測可能な線形スケーリングを備えています。 現場では、ほとんどの新しいデプロイメントが仮想化されています。その場合でも、VMは、必要に応じてホストサーバーのサイズまで拡張できます。 単一の物理ホストが提供できる以上のリソース要件である場合、ECPを使用してスケールアウトします。
ヒント: 管理とデプロイメントを簡略化するため、ECPをデプロイする前に単一サーバー内でスケーリングを実行します。
この投稿では、アーキテクチャの例とECPの基本的な仕組みを示し、ストレージに重点を置いてパフォーマンスの考慮事項を説明します。
ECPとアプリケーション開発の構成に関する特定の情報は、オンラインの「Caché分散データ管理ガイド」で得ることができ、このコミュニティには ECP学習トラックがあります。
ECPの他の主要な機能の1つは、アプリケーションの可用性の向上です。詳細については、 「Caché高可用性ガイド」のECPセクションを参照してください。
このシリーズの他の記事のリストはこちら
ECPアーキテクチャの基本
ECPのアーキテクチャと働きは、概念としては単純です。ECPは、複数のサーバーシステム間でデータ、ロック、実行可能コードを効率的に共有する方法を提供します。 アプリケーションサーバーから見たデータとコードは、リモートにあるデータサーバー に保存されますが、アプリケーションサーバーのローカルメモリにキャッシュされ、最小限のネットワークトラフィックでアクティブなデータへの効率的なアクセスを提供します。
データサーバーはディスク上の永続ストレージへのデータベースの読み取りと書き込みを管理しますが、複数のアプリケーションサーバーはアプリケーション処理のほとんどを実行するソリューションの主力です。
多層アーキテクチャ
ECPは多層アーキテクチャです。 処理層とそれらの役割を説明するにはさまざまな方法があります。以下は、WebブラウザベースのCachéアプリケーションを説明するときに役立つものであり、私の投稿で使用するモデルと用語です。 各層を分解するためのさまざまな方法があるかもしれませんが、ここでは私の方法を使ってみましょう。:)
たとえば、CachéServer Pages(CSP)を使用するブラウザベースのアプリケーションは、プレゼンテーション、アプリケーション処理、およびデータ管理機能が論理的に分離された多層アーキテクチャを使用します。さまざまな役割を持つ「 論理サーバー」が層を構成します。 論理サーバーを個別の物理ホストまたは仮想サーバーに配置する必要はありません。コスト効率と管理性のために、一部またはすべての論理サーバーを単一のホストまたはオペレーティングシステムインスタンスに配置することもできます。 デプロイメントがスケールアップすると、ECPを使用してサーバーを複数の物理ホストまたは仮想ホストに分割できるため、アプリケーションに変更を加えることなく、必要に応じて処理ワークロードを分散できます。
ホストシステムは、容量と可用性の要件に応じて、物理的なもの、または仮想化されたものである場合があります。 以下の層と論理サーバーがデプロイメントを構成します。
プレゼンテーション層: ブラウザベースのクライアントとアプリケーション層の間のゲートウェイとして機能するWebサーバーが含まれます。
アプリケーション層: これは、ECPアプリケーションサーバーが置かれる層です。 上記のように、これは、アプリケーションサーバーがデータサーバーから分離する必要がない論理モデルであり、通常、非常に大きなサイト以外で必要なものではありません。 この層には、レポートサーバーなど、特殊な処理のための他のサーバーも含まれます。
データ層: これは、データサーバーが配置されている層です。 データサーバーはトランザクション処理を実行し、Cachéデータベースに格納されているアプリケーションコードとデータのリポジトリです。 データサーバーは、永続ディスクストレージの読み取りと書き込みを行います。
論理アーキテクチャ
次の図は、3層アーキテクチャーとしてデプロイされたブラウザーベースのアプリケーションの論理図です。
アーキテクチャは一見複雑に見えるかもしれませんが、単一のサーバーにインストールされたCachéシステムと同じコンポーネントで構成されています。ただし、論理コンポーネントは、複数の物理サーバーまたは仮想サーバーにインストールされています。 サーバー間のすべての通信はTCP/IPを介して行われます。
論理ビューでのECPの働き
上の図は、上から順に、複数の負荷分散されたWebサーバーに安全に接続しているユーザーを示しています。 Webサーバーは、クライアントと任意の処理を実行するアプリケーション層(アプリケーションサーバー)の間でCSP Webページ要求を渡し、コンテンツを動的に作成し、完成したページをWebサーバー経由でクライアントに返します。
この3層モデルでは、アプリケーション処理はECPによって複数のアプリケーションサーバーに分散されています。 アプリケーションは、データ(アプリケーションデータベース)をアプリケーションサーバーに対してローカルであるかのようにシンプルに扱います。
アプリケーションサーバーがデータを要求すると、ローカルキャッシュからの要求を満たそうとしますが、それができない場合は、ECPが自身のキャッシュからの要求を満たすことができるデータサーバーに必要なデータを要求します。また、それができない場合は、ディスクからデータをフェッチします。 データサーバーからアプリケーションサーバーへの応答には、そのデータが格納されたデータベースブロックが含まれます。 これらのブロックが使用され、次にアプリケーションサーバーにキャッシュされます。 ECPは、ネットワーク全体のキャッシュの一貫性を自動的に管理し、変更をデータサーバーに反映します。 クライアントはローカルにキャッシュされたデータを頻繁に使用するため、迅速な応答を得ることができます。
データが既にローカルキャッシュにある可能性があるため、デフォルトでは、Webサーバーは優先アプリケーションサーバーと通信し、同じアプリケーションサーバーが関連データに対する後続の要求にサービスを提供することを保証します。
ヒント: Cachéのドキュメントで詳述されているように、アプリケーションサーバーでのキャッシュの利点に影響を与えるラウンドロビンや負荷分散スキームでユーザーをアプリケーションサーバーに接続することは避けてください。 同じユーザーまたはユーザーグループが同じアプリケーションサーバーに接続したままになることが理想的です。
このソリューションは、ユーザーのダウンタイムなしで、Webサーバーを追加することによってプレゼンテーション層を、また、アプリケーションサーバーを追加することによってアプリケーション層を拡張できます。 データ層は、データサーバーのCPUとメモリを増やすことによって拡張されます。
物理アーキテクチャ
次の図は、3層論理アーキテクチャの例と同じ3層デプロイメントで使用される物理ホストの例を示しています。
物理ホストまたは仮想化ホストは、ホスト障害が発生した場合や定期メンテナンスに備えて、100%容量のn + 1またはn + 2モデルを使用して各層にデプロイされることに注意してください。 ユーザーは複数のWebサーバーとアプリケーションサーバーに分散しているため、単一のサーバーの障害は少数のユーザーに影響を与え、ユーザーは残りのサーバーの1つに自動的に再接続します。
データ管理層は、たとえば1つ以上のストレージアレイに接続されたフェイルオーバークラスター(たとえば、 仮想化HA、InterSystemsデータベースミラーリング、または従来のフェイルオーバークラスタリング)によって高い可用性を持ちます。 ハードウェアまたはサービスに障害が発生した場合、クラスタリングは、クラスター内の残りのノードの1つでサービスを再起動します。 その他の利点として、ECPには復旧力が組み込まれており、データベースノードクラスターのフェールオーバーが発生した場合でもトランザクションの整合性が維持されるので、アプリケーションユーザーは、フェールオーバーと自動復旧が完了するまで処理の一時停止を観察しますが、ユーザーは切断されずにシームレスにトランザクションを再開することができます。
同じアーキテクチャを仮想化サーバーにマッピングすることもできます。たとえば、VMware vSphereを使用してアプリケーションサーバーを仮想化できます。
ECPキャパシティプランニング
上述のように、データサーバーはディスク上の永続ストレージへのデータベースの読み取りと書き込みを管理しますが、複数のアプリケーションサーバーはアプリケーション処理のほとんどを実行するソリューションの主力となるものです。 システムリソースのキャパシティプランニングを実施する際の重要な概念の要約は次のとおりです。
データサーバー (データベースサーバーと呼ばれることもあります)は通常、アプリケーション処理をほとんど実行しないため CPU要件は低くなります。ところが、このサーバーはストレージIOの大部分を実行するため、非常に高いストレージIOPSとなる可能性があります。これらは、 データベースの読み取りと書き込み、およびジャーナルの書き込み(ジャーナルIOについては後で詳しく説明します)です。
アプリケーションサーバーはほとんどのアプリケーション処理を実行するため高いCPU要件がありますが、ストレージIOはほとんどありません。
一般的に、ECPサーバーのCPU、メモリ、およびIOの要件は、高可用性に合わせてN + 1またはN + 2サーバーを考慮しながら、非常に大規模な単一サーバーソリューションのサイズを決定する場合と同じルールを使用して決定します。
基本的なCPUおよびストレージのサイジング:
My_Applicationがアプリケーション処理にピーク72 CPUコアを必要とし(ヘッドルームも考慮に入れてください)、書き込みデーモンサイクル中に2万回の書き込みが必要であり、持続的ピーク1万回のランダムデータベース読み取りが必要であると想像してください。
仮想サーバーまたは物理サーバーの、大まかなサイジングは次のとおりです。
4 x 32 CPUアプリケーションサーバー(3サーバー+ HA用に1)。 低いIOPS要件。
2 x 10 CPUデータサーバー(HA用にミラーリングまたはクラスター化)。 低レイテンシIOPS要件は、 2万回の書き込み、1万回の読み取り、およびWIJとジャーナルです。
データサーバーはほとんど処理を実行していませんが、システムプロセスとCachéプロセスを考慮して、8〜10 CPUのサイズに設定されています。 アプリケーションサーバーのサイズは、物理ホストごとのベストの価格性能比に基づいて、および/または可用性を考慮して設定することができます。 スケールアウトすると効率が多少低下しますが、通常はサーバーブロックに処理を追加して、スループットの線形に近い増加を期待できます。 制限は、ストレージIOで最初に生じる可能性が高くなります。
ヒント: HAで通常するように、ホスト、シャーシ、またはラックの障害の影響を考慮します。 VMWareでアプリケーションサーバーとデータサーバーを仮想化する場合、必ずvSphere DRSとアフィニティルールを適用して処理負荷を分散し、可用性を確保します。
ジャーナル同期IO要件
ECPデプロイメントのキャパシティプランニングに関するその他の考慮事項は、より高いIOが必要であり、ジャーナルの同期(別名 ジャーナル同期)のためより高いIOが必要であり、データサーバー上のジャーナリングのスケーラビリティを維持するため、非常に厳格なストレージ応答時間要件を課することです。 同期要求の発行により、ジャーナルの最後のブロックへの書き込みがトリガーされ、データの耐久性が確保されます。
アプリケーション次第ですが、高いトランザクションレートの一般的な顧客サイトでは、ECP以外の構成でのジャーナル書き込みIOPSが毎秒2桁台で見られることがよくあります。 ビジー状態のシステムでECPを使用すると、ECPによってジャーナル同期が強制されるため、ジャーナルディスク上で100〜1,000の書き込みIOPSを確認できます。
ヒント: mgstatを表示するか、 pButtons でmgstatを確認すると、ストレージIOリソース計画で考慮されるJrnwrts(ジャーナル書き込み)が表示されます。 ECPデータサーバーには、mgstatに表示されないジャーナルディスクへのジャーナル同期書き込みもあります。これらを確認するには、iostatなどを使用して、ジャーナルディスクのオペレーティングシステムメトリックを確認する必要があります。
ジャーナル同期とは何ですか?
ジャーナルの同期は次の場合に必要です。
データサーバーで障害が発生した場合のデータの耐久性と回復性を保証します。
また、アプリケーションサーバー間のキャッシュの一貫性を確保するためのトリガーでもあります。
非ECP構成では、Cachéデータベースへの変更はジャーナルバッファ(128 x 64Kバッファ)に書き込まれ、ジャーナルデーモンによって、バッファが一杯になったら、あるいは2秒ごとに、ディスク上のジャーナルファイルに書き込まれます。 Cachéはバッファ全体に64kを割り当て、これらは破棄されたり再作成される事なく常に再利用され、またCachéは単に終了オフセットを追跡します。 ほとんどの場合(大量の更新が一度に行われない限り)、ジャーナルの書き込みは非常に小さなものです。
ECPシステムでは、ジャーナルの同期も行われます。 ジャーナル同期は、現在のジャーナルバッファの関連部分をディスクに再書き込みして、ジャーナルが常にディスク上で常に最新になるようにすることとして定義できます。 そのため、ジャーナル同期リクエストにより、同じジャーナルブロックの一部(サイズが2kから64kの間)が、複数回、再書き込みされる事があります。
ジャーナル同期要求をトリガーできるECPクライアント上のイベントは、更新(SETまたはKILL)またはLOCKです。 たとえば、SETまたはKILLごとに、現在のジャーナルバッファがディスクに書き込まれます(または書き換えられます)。 非常にビジーなシステムでは、ジャーナル同期を1回の同期操作で複数の同期リクエストにバンドルまたは遅延できます。
ジャーナル同期のキャパシティプランニング
スループット維持のために、ジャーナル同期の平均書き込み応答時間は次のようにする必要があります。
<= 0.5 ms、最大<= 1 ms
詳細については、この投稿のIO要件の表を参照してください。パート6-CachéストレージIOプロファイル。
ヒント: CachéデータベースミラーリングをECPで使用する場合、ジャーナル同期は、プライマリミラーノードとバックアップミラーノード・ジャーナルディスクの両方に適用されます。 ミラー構成のルールは、両方のノードのストレージIOを同等に構成することなので、これは問題ではないでしょう。
お使いのシステムの特定のIOメトリックを検証する必要があります。このセクションの目的は、非常に厳密な応答時間の要件があるということを理解し、メトリックを見つける場所を把握することです。
ヒント: Cachéシステムでは、Red Hat LinuxとSuSE XFSファイルシステムが推奨されます。 ただし、テストでは、ext4はジャーナル同期などの小さな書き込みに対してより高いIOPS機能を提供することが明らかになっています。 ジャーナルディスクにはext4の使用を検討してください。
概要
この投稿はECPと、キャパシティプランニング中に考慮するその他のメトリックに関するオリエンテーションです。 近い将来、非常に大規模なシステムで実施されたCachéとECPに関する最新のベンチマークの結果を皆さんと共有できることを願っています。 いつものように、ご質問やご意見がある場合はどうぞお気軽にご連絡ください。 ツイッター @murray_oldfield