2024年2月1日木曜日

ワークスペース内のアプリケーションをオブジェクト・ストレージに一括でエクポートする

Oracle Cloudの(Always Freeの)Autonomous Database上でAPEXのアプリケーションを、オブジェクト・ストレージに一括でエクスポートするAPEXアプリケーションを作ってみました。オブジェクト・ストレージから一括でインポートする機能も加えています。

エクスポートはそれほど危険ではありませんが、インポートについては既存のアプリケーションが上書きされる可能性があります。あくまで一括でエクスポートやインポートを行うPL/SQLコードのサンプルとして取り扱うようお願いします。

本記事は、Oracle Corporationの公式ブログにOracle APEXのアーキテクトのCarsten Czarskiさんが投稿した、以下の記事を参考にしています。

Building a REST API to Deploy APEX Apps

記事のタイトルの通り、APEXのアプリケーションのインポートとエクスポートを、ORDSのRESTサービスとして実装しています。Oracle APEXにはリモート・デプロイメントが機能として含まれていますが、この機能では、デプロイ先となるAPEXワークスペースでREST対応SQLが有効になっている必要があります。セキュリティ上の理由などでREST対応SQLを有効にできない場合、APEXアプリケーションのインポート/エクスポートを行うORDS RESTサービスを作成することでリモート・デプロイメントを実行できるようになります。

今回の記事で作成するアプリケーションのエクスポートを以下に置いてあります。
https://github.com/ujnak/apexapps/blob/master/exports/bulk-app-exporter.zip

以下、アプリケーションの作り方を紹介します。

アプリケーションの名前Bulk App Exporterとしました。

アプリケーションの作成をクリックし、アプリケーションを作成します。


アプリケーション定義置換に、アプリケーションのエクスポート先となるオブジェクト・ストレージのリージョンネームスペースバケット名Web資格証明を設定します。

置換文字列G_REGIONリージョン(本記事ではus-ashburn-1)、G_NAMESPACEネームスペースG_BUCKETバケット名(同app-exports)、G_CREDENTIALにOCIのAPI呼び出しに使用するWeb資格証明静的ID(同OCI_API_ACCESS)を置換値として設定します。


Oracle Cloudのオブジェクト・ストレージの準備については、少し古いですがこちらの記事が参考になります。

エクスポートするアプリケーションを複数選択し、エクスポートを実行するページを作成します。アプリケーションの一覧表示と選択を行うために、対話グリッドを使用します。

ページの作成を開始し、対話グリッドを選択します。


ページの名前Exportとします。データ・ソースソース・タイプとしてSQL問合せを選択し、SQL SELECT文を入力に以下を記述します。

select
application_id
,'N' as export
,'N' as overwrite
,application_name
,alias
,owner
,application_group
,version
,created_by
,created_on
,last_updated_by
,last_updated_on
,application_comment
,is_working_copy
from apex_applications
where workspace_id = :workspace_id
編集が有効オンにします。

へ進みます。


主キー列1としてAPPLICATION_ID (Number)を選択します。

ページの作成を実行します。


ページが作成されます。

編集可能な対話グリッドのリージョンが作成されますが、実際にはこの対話グリッドのデータ・ソースは更新できません。今回実装するエクスポートの処理が必要としていない、対話グリッドの操作を無効化します。

対話グリッドを選択し、プロパティ・エディタ属性を開きます。

編集実行可能な操作の内、行の追加行の削除チェックを外します。行の更新のみを実行可能な操作にします。

ツールバーコントロールから保存ボタンチェックを外します。対話グリッドの保存ボタンの代わりに、エクスポートを実行するボタンを後ほど作成します。


この後に列自体をコメント・アウトするのですが、対話グリッドの列APEX$ROW_SELECTORを選択し、設定コントロールの非表示オンにし、機能を無効化しておきます。


対話グリッドの列APEX$ROW_SELECTORAPEX$ROW_ACTIONコメント・アウトします。


EXPORTOVERWRITE識別タイプチェックボックスに変更します。EXPORTにチェックが入っているアプリケーションが一括エクスポートの対象となります。また、OVERWRITEにチェックが入っていると、バケット内に同じ名前のファイルがあれば上書きします。今回の実装では、同じ名前のファイルがありOVERWRITEにチェックが入っていない場合は、エラーにせず単にエクスポートをスキップします。


APPLICATION_IDEXPORTOVERWRITEALIAS以外はエクスポート処理には使わないため、ソース問合わせのみオンにします。列の値がサーバーへの送信対象から外されます。


APPLICATION_IDEXPORTOVERWRITE以外の列は変更不可なので、詳細カスタム属性readonlyを設定します。


対話グリッドの静的IDとしてexport-gridを設定します。この後に記述する動的アクションのJavaScriptのコードより対話グリッドを参照するために使用します。


対話グリッドの設定は以上です。

各種処理を行うボタンの作成と、クリックした時に実行される処理を定義します。

最初にボタンを配置する静的コンテンツのリージョンを作成します。識別タイトルButtonsとし、外観テンプレートButton Containerを選択します。


Button Containerのリージョンに、アプリケーションの一括エクスポートを実行するボタンEXPORTを作成します。ラベルはExportとします。動作アクションはデフォルトのページの送信です。


プロセス・ビューを開き、デフォルトで作成されている対話グリッドの行の自動処理を行うプロセスを選択します。

識別名前Export - アプリケーションのアップロードに変更します。設定ターゲット・タイプPL/SQLコードに変更し、挿入/更新/削除するPL/SQLコードとして以下を記述します。

declare
l_app_export apex_t_export_files;
l_zip_export blob;
l_request_url varchar2(4000);
l_response clob;
e_upload_failed exception;
begin
if :APEX$ROW_STATUS <> 'U' then
/* 更新行のみのはずだが念のため他の状態は除外する。 */
return;
end if;
if :EXPORT <> 'Y' then
/* EXPORTがチェックされていないアプリケーションはエクスポートしない。 */
return;
end if;
/* アップロード先となるURLを作成する。 */
l_request_url := apex_string.format('https://objectstorage.%s.oraclecloud.com/n/%s/b/%s/o/%s.zip',
:G_REGION, :G_NAMESPACE, :G_BUCKET, utl_url.escape(lower(:ALIAS), false, 'AL32UTF8')
);
/* OVERWRITEがチェックされていない場合、上書きを回避する。 */
if :OVERWRITE <> 'Y' then
apex_web_service.clear_request_headers();
l_response := apex_web_service.make_rest_request(
p_url => l_request_url
,p_http_method => 'HEAD'
,p_credential_static_id => :G_CREDENTIAL
);
if apex_web_service.g_status_code = 200 then
apex_debug.info('Export exists, %s, id %s', :ALIAS, :APPLICATION_ID);
/* すでに存在するのでアップロードせずに処理を終了する。 */
return;
end if;
end if;
/*
  * ZIP形式でエクスポートする。
* Ref: https://docs.oracle.com/en/database/oracle/apex/23.2/aeapi/GET_APPLICATION_Function.html
*
* 引数の指定によりエクスポートに含まれる内容が変わるため、要件に合わせて変更する。
*/
apex_debug.info('Start export application %s, id %s', :ALIAS, :APPLICATION_ID);
l_app_export := apex_export.get_application(
p_application_id => :APPLICATION_ID
,p_split => TRUE
,p_with_date => FALSE /* エクスポート日を除外 */
,p_with_ir_public_reports => FALSE /* ユーザーが保存したパブリック・レポートを除外 */
,p_with_ir_private_reports => FALSE /* ユーザーのプライベート・レポートも除外 */
,p_with_ir_notifications => FALSE
,p_with_translations => TRUE /* 翻訳は含める */
,p_with_original_ids => TRUE
,p_with_no_subscriptions => TRUE /* サブスクリプションは除外する */
,p_with_comments => TRUE
,p_with_supporting_objects => 'Y'
,p_with_acl_assignments => TRUE
);
/*
* 分割されてエクスポートされたファイルをZIP(BLOB)に固める。
*/
l_zip_export := apex_export.zip(
p_source_files => l_app_export
);
/*
* オブジェクト・ストレージにアップロードする。
*/
apex_debug.info('Upload export to %s, bytes %s', l_request_url, dbms_lob.getlength(l_zip_export));
apex_web_service.clear_request_headers();
apex_web_service.set_request_headers('Content-Type', 'application/zip', p_reset => false);
l_response := apex_web_service.make_rest_request(
p_url => l_request_url
,p_http_method => 'PUT'
,p_body_blob => l_zip_export
,p_credential_static_id => :G_CREDENTIAL
);
if apex_web_service.g_status_code <> 200 then
raise e_upload_failed;
end if;
apex_debug.info('End export application %s', :ALIAS);
end;

データベースへの操作ではないため、失われた更新の防止オフ行のロックいいえを選択します。

サーバー側の条件ボタン押下時EXPORTを設定します。


以上でエクスポート自体は実装できたのですが、実際にアプリケーションがエクスポートされると、以下のようにUnexpected end of JSON  inputのエラーが発生します。


エクスポートの実行が対話グリッドのリフレッシュに何かの影響を与えているようなので、エクスポートが完了した後に明示的にページをリロードさせます。

プロセスの後ブランチを作成します。

識別名前ページのリロードとします。動作タイプとしてページまたはURL(リダイレクト)を選択し、ターゲットタイプこのアプリケーションのページページ番号、つまり同じページを指定します。

サーバー側の条件ボタン押下時EXPORTを指定します。


以上でAPEXアプリケーションをオブジェクト・ストレージにエクスポートする処理が実装できました。

今回のアプリケーションは、アプリケーションを一括でエクスポートすることを目的にしています。その操作を容易にするために、すべての行の列EXPORTにチェックを入れるボタンを作成します。

ページ・プロパティJavaScriptページ・ロード時に実行に以下を記述し、対話グリッドの指定した列のチェックボックスに、すべての行に同じ値を設定するAPEXアクションset-column-value-allを定義します。ただし、JavaScriptのコードからでも画面からでも同じですが、対話グリッドで操作できる行は画面に表示されている行に限られます。APEXアクションset-column-value-allで値が設定される行も、表示中の行に限定されます。

apex.actions.add(
[
{
name: "set-column-value-all",
action: (event, element, args) => {
const grid = apex.region(args.grid).call("getViews","grid");
const model = grid.model;
let rec;
/* **表示されている** すべての行で繰り返す */
model.forEach( (row) => {
/* 先頭列は主キー、つまりAPPLICATION_ID */
rec = model.getRecord(row[0]);
model.setValue(rec, args.column, args.value);
});
}
}
]
);

Button Containerのリージョンに、すべての行のEXPORTをチェックするボタンを作成します。

識別名前CHECK_ALL_EXPORTラベルCheck All Exportとします。レイアウト新規行の開始オフにし、ボタンEXPORTの隣に配置します。

動作アクション動的アクションで定義を選択し、詳細カスタム属性に以下を記述します。

data-action="#action$set-column-value-all?grid=export-grid&column=EXPORT&value=Y"


この他に以下のボタンを作成します。

ボタンUNCKECK_ALL_EXPORT、ラベルはUncheck All Exportカスタム属性は以下。
data-action="#action$set-column-value-all?grid=export-grid&column=EXPORT&value=N"

ボタンCHECK_ALL_OVERWRITE、ラベルはCheck All Overwriteカスタム属性は以下。
data-action="#action$set-column-value-all?grid=export-grid&column=OVERWRITE&value=Y"

ボタンUNCHECK_ALL_OVERWRITE、ラベルはUnheck All Overwriteカスタム属性は以下。
data-action="#action$set-column-value-all?grid=export-grid&column=OVERWRITE&value=N"

以上でアプリケーションを一括エクスポートするページは完成です。


オブジェクト・ストレージのバケットに含まれるZIPファイルを選択してインポートするページを作成します。

バケット内のファイル一覧を取得するRESTデータ・ソースを作成します。

RESTデータ・ソースデータ・プロファイルを自動的に作成するため、一つ以上のアプリケーションのエクスポートを実行し、あらかじめバケット内にファイルを配置しておいてください。

共有コンポーネントRESTデータ・ソースを開きます。


作成をクリックします。


RESTデータ・ソースの作成最初から行います。

へ進みます。


RESTデータ・ソース・タイプOracle Cloud Infrastructure (OCI)を選択します。名前List Exported Applicationsとします。

URLエンドポイントとして以下を指定します。<リージョン>の部分は環境に合わせて置き換えます(本記事はus-ashburn-1)。:namespaceおよび:bucketがパラメータとして認識されます。

https://objectstorage.<リージョン>.oraclecloud.com/n/:namespace/b/:bucket/o?fields=name,size,etag,timeCreated,md5,timeModified

URLパラメータ1namespaceとなるので、ネームスペースを設定します。URLパラメータ2bucketとなるので、バケット名を設定します。

へ進みます。


選択された、または新規作成されたリモート・サーバーを確認します。

へ進みます。


認証が必要ですオンにし、資格証明としてオブジェクト・ストレージのアクセスに使用するWeb資格証明を選択します。

検出をクリックします。


バケット内のファイルがデータとして表示されます。

RESTデータ・ソースの作成をクリックします。


RESTデータ・ソースList Exported Applicationsが作成されます。


このRESTデータ・ソースをデータ・ソースとした対話グリッドのページを作成します。バケット上のファイルの一覧よりインポートするファイルを複数選択するために、対話グリッドを使用します。

Exportのページと同様に、ページの作成を開始し対話グリッドを選択します。

ページ名Importとします。データ・ソースとしてRESTデータ・ソースを選択し、RESTデータ・ソースとして先ほど作成したList Exported Applicationsを選びます。編集が有効オンにします。

へ進みます。


主キー列1は必ず指定が必要なので、列NAMEを除く列から一意であると思われる列を選択します。列NAMEは一意ですが、主キー列として選択するとデフォルトで非表示列になるため、他の列を選択します。今回は一意だと思われるMD5(Varchar2)主キー列1として選択しています。

ページの作成をクリックします。


対話グリッドのページが作成されます。

対話グリッドのリージョンを選択し、ローカル後処理タイプSQL問合せに変更し、SQL問合せとして以下を記述します。インポート対象として選択するための列IMPORTを追加しています。

select MD5,
'N' as IMPORT,
'N' as OVERWRITE,
ETAG,
NAME,
SIZE_,
TIMECREATED,
TIMEMODIFIED
from #APEX$SOURCE_DATA#


これ以降の作業はExportのページとほぼ同じです。

対話グリッドの属性を、Exportと同様に設定します。


行セレクタAPEX$ROW_SELECTORコントロールの非表示オンにします。


APEX$ROW_SELECTORAPEX$ROW_ACTIONコメント・アウトします。


IMPORTOVERWRITEタイプチェックボックスに変更します。


ETAGSIZE_TIMECREATEDTIMEMODIFIEDはインポート処理に使わないため、ソース問合せのみオンにします。


IMPORTOVERWRITE以外の表示列は変更不可なので、詳細カスタム属性readonlyを設定します。


インポートについては、アプリケーションを全て選択して一度にインポートすることはなさそうなので、Exportで行ったJavaScriptに関する設定は省略します。

対話グリッドの設定は以上になります。

Exportのページと同様に、ボタンを配置するButton Containerのリージョンを作成します。

そのリージョンにボタンIMPORTを作成します。


プロセス・ビューを開き、デフォルトで作成されている対話グリッドの行の自動処理を行うプロセスを選択します。

識別名前Import - アプリケーションのインポートに変更します。設定ターゲット・タイプPL/SQLコードに変更し、挿入/更新/削除するPL/SQLコードとして以下を記述します。

/*
* Ref: Building a REST API to Deploy APEX Apps
* https://blogs.oracle.com/apex/post/building-a-rest-api-to-deploy-apex-apps
*/
declare
l_request_url varchar2(400);
l_zip_file blob;
l_app_files apex_zip.t_files;
l_files apex_t_export_files := apex_t_export_files();
l_new_app_id number;
e_download_object_failed exception;
begin
if :APEX$ROW_STATUS <> 'U' then
/* 更新行のみのはずだが念のため他の状態は除外する。 */
return;
end if;
if :IMPORT <> 'Y' then
/* IMPORTがチェックされていないアプリケーションは除外する。 */
return;
end if;
/*
* Object StorageからZIPファイルを取得する。
*/
l_request_url := apex_string.format('https://objectstorage.%s.oraclecloud.com/n/%s/b/%s/o/%s',
:G_REGION, :G_NAMESPACE, :G_BUCKET, utl_url.escape(lower(:NAME), false, 'AL32UTF8')
);
apex_debug.info('Download application export from %s', l_request_url);
apex_web_service.clear_request_headers();
l_zip_file := apex_web_service.make_rest_request_b(
p_url => l_request_url
,p_http_method => 'GET'
,p_credential_static_id => :G_CREDENTIAL
);
if apex_web_service.g_status_code <> 200 then
raise e_download_object_failed;
end if;
apex_debug.info('Download success: %s, size %s', :NAME, dbms_lob.getlength(l_zip_file));
/*
* ZIPファイルを展開する。
*/
l_app_files := apex_zip.get_files(
p_zipped_blob => l_zip_file,
p_only_files => true
);
l_files.extend( l_app_files.count );
for i in 1 .. l_app_files.count
loop
l_files(i) := apex_t_export_file(
l_app_files(i),
apex_util.blob_to_clob(
apex_zip.get_file_content(
p_zipped_blob => l_zip_file,
p_file_name => l_app_files(i)
)
)
);
end loop;
/*
* 未使用のアプリケーションIDを取得する。
*/
select max(application_id) + 1 into l_new_app_id
from apex_applications;
/*
* 予約されている番号であれば、9000にする。
*/
if l_new_app_id between 3000 and 8999 then
l_new_app_id := 9000;
end if;
/*
* アプリケーションをインポートする。
*
* SET_APPLICATION_ALIAS, SET_APPLICATION_NAMEの呼び出しも要検討。
*/
apex_application_install.set_application_id(
p_application_id => l_new_app_id);
apex_application_install.install(
p_source => l_files,
/*
* アプリケーションには必ず空いているアプリケーションIDが割り当たる
* ため、上書きをYにする必要がない。
*/
p_overwrite_existing => (:OVERWRITE = 'Y') );
end;

データベースへの操作ではないため、失われた更新の防止オフ行のロックいいえを選択します。

サーバー側の条件ボタン押下時IMPORTを設定します。


以上でアプリケーションをインポートするページは完成です。

実際はアプリケーションをインポートする際に、アプリケーションIDとして未割り当てのIDを選んで割り当てているため、OVERWRITEにチェックが必要な状況は発生しないはずです。複数の人が同時にインポートを実行するといった場合に、同じアプリケーションIDでアプリケーションをインポートしようとする状況が発生する可能性はあります。

インポート処理については、実際の要件からもっと実装を調整する必要はあるでしょう。


APEXアプリケーションよりインポートを実行するには、アプリケーション定義セキュリティ詳細ランタイムAPIの使用状況に含まれる他のアプリケーションを変更にチェックを入れる必要があります。


データベースにsqlplusやSQLclなどで直接接続してAPEXアプリケーションをインストールする場合は、接続ユーザーにロールAPEX_ADMINISTATOR_ROLEが割り当たっている必要があります。詳しくはOracle APEXの管理ガイドを参照してください。

以下より、アプリケーションを若干調整します。

作成したRESTデータ・ソースのパラメータbucketnamespaceデフォルト値を削除し、必須はいに切り替えます。


Importの対話グリッドのパラメータbucketタイプ静的値に変更し、静的値として&G_BUCKET.を指定します。


パラメータnamespace静的値&G_NAMESPACE.を指定します。


以上でアプリケーション定義G_NAMESPACEG_BUCKETの設定が、Import対象に一覧表示に反映されるようになります。

リージョンはホスト名に含まれるため、パラメータとしては定義できません。ホスト部分の切り替えは、RESTデータ・ソースのリモート・サーバーを変更することで対応します。リモート・サーバーによる切り替えは、こちらの記事「リモート・サーバーを入れ替えてRESTデータ・ソースの実行環境を切り替える」で紹介しています。

今回の記事は以上になります。

Oracle APEXのアプリケーション作成の参考になれば幸いです。