目次
-
-
概要
-
シナリオ説明
-
-
ハンズオン
-
プロジェクト用意
-
プロジェクト生成
-
SFSF接続設定
-
-
データモデル定義
-
サービスモデル定義
-
ビジネスロジック拡張
-
読み込み系処理
-
書き込み系処理
-
-
ローカル実行
-
CFへのデプロイ
-
1. はじめに
1.1. 概要
本ブログはCloud Application Programming Model(CAP)を用いてSFSFとHANA Cloudのデータのやり取りをするアプリケーションの開発方法を紹介します。主な紹介の範囲はCAP上のSAP SuccessFactors(SFSF)接続用の設定からビジネスロジックの拡張方法までです。
1.2. シナリオ説明
SAP Business Application Studio(BAS)上でSFSFのPerPersonalのODataサービスを利用したCRUD処理を提供するアプリケーションを開発します。開発したアプリケーションはCloud Foundry環境へとデプロイし、HANA Cloudに接続されます。
2. ハンズオン
2.1. プロジェクト用意
2.1.1. プロジェクト生成
BASのTerminal上でcds init <project-name>
を実行し、アプリケーションの雛形を作成します。実際に実行するコマンドは以下のようにaddオプションを使用して--add mta,hana
を付け加えた上でアプリケーション名を入力してください。addオプションを用いることで生成される雛形にいくつかの機能を追加可能です。ここではCloud Foundry環境へのデプロイ時に使用するのディスクリプタファイルやHANA Cloud用デプロイメントアーティファクトを生成するための設定を機能として加えています。他にはサンプルプログラムを埋め込むためのsamples
などがあります。これらの機能はすべて手動で記述することも可能ですが、プロジェクト生成時のオプションとして事前に付け加えることで開発作業のうち煩雑ないくつかの要素をスキップすることができます。
$ cds init --add mta,hana cap-sfsf-handson
Creating new cap project in ./cap-sfsf-handson
Adding feature 'nodejs'...
Adding feature 'mta'...
Adding feature 'hana'...
Done adding features
Continue with 'cd cap-sfsf-handson'
Find samples on https://github.com/SAP-samples/cloud-cap-samples
Learn about next steps at https://cap.cloud.sap
2.1.2. CAP上のSFSF接続設定
使用するSFSFのODataサービスのモデルを取得し、cds import
を実行してプロジェクトに取り込みます。これによってpackage.jsonの設定が更新され、取り込まれたメタデータファイルはsrv/external
配下にコピーされます。CAPはモデル情報を全てとして取り扱うため、メタデータファイルはコピーされるだけでなく.csn
にも変換されて同様の場所に配置されます。CAP実行時にはこちらのcsnファイルが取り込んだモデルとして参照されます。
$ cds import ECPersonalInformation.edmx
[cds] - imported API to srv/external/ECPersonalInformation
> use it in your CDS models through the like of:
using { ECPersonalInformation as external } from './external/ECPersonalInformation'
[cds] - updated ./package.json
さらにpackage.jsonのcds
配下を下記のように書き換えます。
CAPにおける接続先(Clean Architectureなどのに相当する要素)の設定はpackage.jsonのcds.requires
配下に記述します。この配下に記述するdb
やECPersonalInformation
がCAPの中で取り扱われる接続先名です。接続先名の配下にその接続先の設定情報を記述します。たとえばHANA Cloudに接続する場合はkind: hana-cloud
とのみ記述します。ODataサービス(プロトコルはOData v2)と接続する場合はkind: odata-v2
と記述し、そのODataサービスのメタデータの配置場所もmodel
として記述します。接続先の一つ上の階層に[production]
と[development]
と記述することでデプロイ先の環境で使用する設定とローカル環境で使用する設定を書き分けることが可能です。
ここでは特別に記載をしていませんが、このdevelopmentプロファイルを参照してアプリケーションを起動した際にはおよびがそれぞれon、dummyで設定されています。またproductionプロファイルを参照してアプリケーションを起動した際にはそれぞれoff、jwtで設定されます。これらの挙動をpackage.json上で上書きしたい場合には、例えばdb
とECPersonalInformation
と同じ深さにfeatures.fiori_preview: true
のオブジェクトを記述することができます。これによってどちらのプロファイルのデフォルト設定を上書きしてFiori Previewを利用可能です。
"cds": {
"requires": {
"[production]": {
"db": {
"kind": "hana-cloud"
},
"ECPersonalInformation": {
"kind": "odata-v2",
"model": "srv/external/ECPersonalInformation",
"credentials": {
"destination": "sfsf_ext_handson",
"path": "/odata/v2"
}
},
"log": {
"levels": {
"PersonJobService": "info",
"db": "info"
}
}
},
"[development]": {
"db": {
"kind": "sqlite"
},
"ECPersonalInformation": {
"kind": "odata-v2",
"model": "srv/external/ECPersonalInformation",
"credentials": {
"url": "https://<your-api-endpoint>",
"path": "/odata/v2",
"authentication": "BasicAuthentication",
"username": "<your-username>",
"password": "<your-password>"
}
},
"log": {
"levels": {
"PersonJobService": "debug",
"sqlite": "debug"
}
}
}
}
}
2.2. データモデル定義
db/data-model.cds
ファイルを作成し、以下のように記述します。
他のデータモデルでも使う可能性のある補足(remark
)はアスペクト指向のオブジェクトとしてPersonJobsに差し込んでいます。また、@sap/cds/common
はよく使用されるアスペクト指向のオブジェクトをSAPが事前に用意しているパッケージであり、ここではcreatedAt
などの要素をmanaged
より使用しています。これらの結果、ここで定義しているエンティティであるPersonJobsは直接宣言されている3つのプロパティだけでなく、manged
やaddtionalNote
の中でも定義されているプロパティも持ちます。このデータモデルはSFSFのODataサービスであるPerPersonalとjoinして取り扱うことを目指すため、そのための共通項目を定義しています。PersonJobsのkey項目であるpersonIdがそのための項目であり、PerPesonalと同じkeyの型を利用したいためにtype of
でPerPersnalのkeyの型を参照させています。
namespace com.sap.rasai.sample.personjob;
using { managed } from '@sap/cds/common';
using { ECPersonalInformation.PerPersonal as external } from '../srv/external/ECPersonalInformation.csn';
entity PersonJobs: managed, additionalNote {
key personId: type of external: personIdExternal;
jobId: String(32);
jobName: String(128);
}
aspect additionalNote {
remark: String(512);
}
例えばこのデータモデルをもとにHANAにデプロイ用のデザインタイムオブジェクトを生成すると、以下のようなファイルが生成されます。2.1で定義した接続先db
のうち、このPersonJobsをターゲットにCRUD処理をCAPで走らせると下記のテーブルなどのためのSQLが自動で発行されます。
-- generated by cds-compiler version 2.15.2
COLUMN TABLE PersonJobService_PerPersonals (
createdAt TIMESTAMP,
createdBy NVARCHAR(255),
modifiedAt TIMESTAMP,
modifiedBy NVARCHAR(255),
remark NVARCHAR(512),
personId NVARCHAR(100) NOT NULL,
jobId NVARCHAR(32),
jobName NVARCHAR(128),
nationality NVARCHAR(128),
businessFirstName NVARCHAR(128),
businessLastName NVARCHAR(128),
PRIMARY KEY(personId)
)
srv/personjob-service.cds
を作成し、下記のように記述します。SFSFのODataサービスであるPerPersonalをexternalと名前をつけてこのファイル内では取り扱っています。定義されているエンティティPerPersonalsはSFSFのODataサービスであるPerPersonalを主体としながらもCAP内で定義したデータモデルであるPersonJobsもアスペクトとして差し込まれています。したがってCAPから公開されるサービスであるPerPersonalsはこれら2つの要素を同時に取り扱うサービスとして振る舞います。
using com.sap.rasai.sample.personjob as personjob from '../db/data-model';
using { ECPersonalInformation.PerPersonal as external } from '../srv/external/ECPersonalInformation.csn';
@path: 'personjob'
service PersonJobService {
entity PerPersonals: personjob.PersonJobs {
nationality: type of external: nationality;
businessFirstName: type of external: businessFirstName;
businessLastName: type of external: businessLastName;
}
}
2.4. ビジネスロジック拡張
srv/personjob-service.js
を作成し、下記のように記述します。
const cds = require('@sap/cds');
class PersonJobService extends cds.ApplicationService {
async init() {
super.init();
}
}
module.exports = PersonJobService;
2.4.1. 読み込み系処理
srv/personjob-service.js
に下記のようにthis.on('READ', PerPersonals, () => {})
としてREAD用ハンドラーをオーバーライドします。この読み込み処理では主に以下のような内容を処理しています。
-
CAPのデータモデルである
PersonJobs
よりデータを取得 -
取得したデータのキーを使ってSFSFのODataサービスである
PerPersonal
用のwhere句を組み立てなおす -
組み立てたwhere句を使って
PerPersonal
にクエリ実行 -
PersonJobs
とPerPersonal
の実行結果をjoinして詰めたリストをハンドラの返り値とする
const cds = require('@sap/cds');
class PersonJobService extends cds.ApplicationService {
async init() {
const LOG = cds.log('PersonJobService');
//pacage.jsonで定義した接続情報を使ったサービスを取得
const db = await cds.connect.to('db');
const sfsf = await cds.connect.to('ECPersonalInformation');
//各サービスに紐づく定数を用意
const { PersonJobs } = db.entities;
const { PerPersonal } = sfsf.entities;
const { PerPersonals } = this.entities;
const personIdExternal = PerPersonal.elements.personIdExternal;
const nationality = PerPersonals.elements.nationality.name;
const firstName = PerPersonals.elements.businessFirstName.name;
const lastName = PerPersonals.elements.businessLastName.name;
this.on('READ', PerPersonals, async req => {
LOG.info('PerPersonals READ is called.');
let result = [];
const personJobList = await getPersonJobList(req.query);
if (personJobList != null && personJobList.length !== 0) {
const cxnCondition = await buildPerPersonalCxn(personJobList);
const personList = await sfsf.run(
SELECT
.from(PerPersonal)
.columns(
PerPersonal.elements.personIdExternal.name,
PerPersonal.elements.nationality.name,
PerPersonal.elements.firstName.name,
PerPersonal.elements.lastName.name
)
.where(cxnCondition));
//sfsfとdbのデータjoinと返す配列へのpush
for (const i in personJobList) {
const target = personList.filter((data) => data.personIdExternal === personJobList[i].personId);
let targetNationality, targetFirstName, targetLastName = null;
if (target.length === 1) {
targetNationality = target[0].nationality;
targetFirstName = target[0].firstName;
targetLastName = target[0].lastName;
}
result.push({
...personJobList[i],
[nationality]: targetNationality,
[firstName]: targetFirstName,
[lastName]: targetLastName
})
}
}
return result;
//key指定した1行READとそれ以外のREAD処理でqueryの作りが違うため、ifで切り分けてそれぞれdb呼び出し
async function getPersonJobList(query) {
if ('personId' in req.data) {
query.SELECT.from.ref[0].id = PersonJobs.name;
const personJob = await db.run(query);
let personJobList = [];
personJobList.push(personJob);
return personJobList;
} else {
query.SELECT.from.ref[0] = PersonJobs.name;
return await db.run(query);
}
};
//READのwhereでin句を使うための都合良いライブラリがないのでwhere表現を自作
async function buildPerPersonalCxn(personJobList) {
if(personJobList === null || personJobList.length === 0) return null;
const operator = personJobList.length === 1 ? '=' : 'in';
const space = ' ';
let conditionExpression = personIdExternal + space + operator + space;
for (const i in personJobList) {
let prefix = ''';
const suffix = ''';
if (i == 0) {
prefix = '(' + prefix;
} else {
prefix = ',' + prefix;
}
const singleExpression = prefix + personJobList[i].personId + suffix;
conditionExpression = conditionExpression + singleExpression;
}
conditionExpression = conditionExpression + ')';
return cds.parse.expr(conditionExpression);
}
})
super.init();
}
}
module.exports = PersonJobService;
2.4.2. 書き込み系処理
srv/personjob-service.js
に下記のように記述し、各処理をオーバーライドします。
const cds = require('@sap/cds');
class PersonJobService extends cds.ApplicationService {
async init() {
const LOG = cds.log('PersonJobService');
//pacage.jsonで定義した接続情報を使ったサービスを取得
const db = await cds.connect.to('db');
const sfsf = await cds.connect.to('ECPersonalInformation');
//各サービスに紐づく定数を用意
const { PersonJobs } = db.entities;
const { PerPersonal } = sfsf.entities;
const { PerPersonals } = this.entities;
const personIdExternal = PerPersonal.elements.personIdExternal;
const nationality = PerPersonals.elements.nationality.name;
const firstName = PerPersonals.elements.businessFirstName.name;
const lastName = PerPersonals.elements.businessLastName.name;
//sfsf関連のデータを除去してdb(HANA)にINSERT INTO
this.on('CREATE', PerPersonals, async req => {
LOG.info('PerPersonals CREATE is called.');
let { nationality, businessFirstName, businessLastName, ...data } = req.data;
const res = await db.run(INSERT.into(PersonJobs, data));
if (res.affectedRows !== 1) req.error(500, "something happened while inserting...");
return req.data;
})
//sfsf関連のデータを除去してdb(HANA)にUPDATE
this.on('UPDATE', PerPersonals, async req => {
LOG.info('PerPersonals UPDATE is called.');
let { nationality, businessFirstName, businessLastName, ...data } = req.data;
const affectedRows = await db.run(UPDATE.entity(PersonJobs).where('personId = ', req.data.personId).set(data));
if (affectedRows > 0) return data;
return req.error(500, "not changed")
})
this.on('DELETE', PerPersonals, async req => {
LOG.info('PerPersonals DELETE is called.');
const affectedRows = await db.run(DELETE.from(PersonJobs, req.data.personId));
if (affectedRows !== 1) req.error(500, "something happened while deleting...");
return affectedRows;
})
super.init();
}
}
module.exports = PersonJobService;
2.4. ローカル実行
cds watch --in-memory
を使ってアプリケーションを立ち上げ、下記の内容を使って動作を確認します。
###
POST http://localhost:4004/v2/personjob/PerPersonals
Content-Type: application/json
{
"remark": null,
"personId": "test003",
"jobId": "job001",
"jobName": "engi-meow",
"nationality": null,
"businessFirstName": "Jacob",
"businessLastName": "Meowllier"
}
###
GET http://localhost:4004/v2/personjob/PerPersonals?$filter='jobId' ne 'job001'
###
GET http://localhost:4004/v2/personjob/PerPersonals('test001')
###
GET http://localhost:4004/v2/personjob/PerPersonals
###
DELETE http://localhost:4004/v2/personjob/PerPersonals('test002')
###
PUT http://localhost:4004/v2/personjob/PerPersonals('test003')
Content-Type: application/json
{
"remark": null,
"personId": "test003",
"jobId": "job001_updated",
"jobName": "engi-meow",
"nationality": null,
"businessFirstName": "Keith",
"businessLastName": "Emeowson"
}
2.5. CFへのデプロイ
下記のようにmta.yamlを記述し、さらに下記のxs-security.json
を作成します。そしてmbt build
でアプリケーションをビルドしたのちにcf deploy <your-mtar>
でmtarをcf環境へデプロイします。package.json
でECPersonalInformation
の接続先としてdestination
を指定しています。この設定を使用するとCAPはBTP環境のDestinationサービスの使用を試みます。このサービス使用のために、下記yamlファイルのresouces
にservice: destination
としたサービスインスタンスの使用を記述しています。ここで定義したサービスインスタンスをCAP本体にバインドすることで、CAPアプリケーションはDestionationサービスを利用するための情報を吸い上げることができるようになります。しかしDestionationサービスを利用するためには認証認可を取り扱う仕組みも同時に必要であるため、XSUAA用のサービスインスタンスも同時に宣言してバインドしています。
---
_schema-version: '3.2'
ID: cap_simple_crud_w_sfsf
version: 1.0.0
description: "SFSF Extension CAP App for Hands-on"
parameters:
enable-parallel-deployments: true
build-parameters:
before-all:
- builder: custom
commands:
- npx -p @sap/cds-dk cds build --production # build用ファイルではnpxで外部を参照する方が確実
# アプリケーション本体を羅列するmoduleブロック
modules:
# CAPアプリケーション本体
- name: cap_simple_crud_w_sfsf-srv
type: nodejs
path: gen/srv
parameters:
buildpack: nodejs_buildpack
build-parameters:
builder: npm-ci
provides:
- name: srv-api # required by consumers of CAP services (e.g. approuter)
properties:
srv-url: ${default-url}
requires:
- name: cap_simple_crud_w_sfsf-db # HANA Cloud用サービスインスタンスをバインド
- name: cap_simple_crud_w_sfsf-dest # Destinationサービス用サービスインスタンスをバインド
- name: cap_simple_crud_w_sfsf-uaa # XSUAA用サービスインスタンスをバインド
# CAPが生成するHANA Cloud用のデザインタイムオブジェクトのデプロイ用アプリケーション
- name: cap_simple_crud_w_sfsf-db-deployer
type: hdb
path: gen/db
parameters:
buildpack: nodejs_buildpack
requires:
- name: cap_simple_crud_w_sfsf-db
# アプリケーションにバインドするサービスインスタンスを宣言するブロック
resources:
# HANA Cloud用のHDIコンテナーのサービスインスタンス
- name: cap_simple_crud_w_sfsf-db
type: com.sap.xs.hdi-container
parameters:
service: hana # or 'hanatrial' on trial landscapes
service-plan: hdi-shared
# Destinationサービス用のサービスインスタンス
- name: cap_simple_crud_w_sfsf-dest
type: org.cloudfoundry.managed-service
parameters:
service: destination
service-plan: lite
# XSUAA用のサービスインスタンス
- name: cap_simple_crud_w_sfsf-uaa
type: org.cloudfoundry.managed-service
parameters:
service: xsuaa
service-plan: application
path: ./xs-security.json
xs-security.json
の記述は以下の通りです。認可を設定するときはこのファイルを中心に定義します。
{
"xsappname": "cap_w_sfsf",
"scopes": [],
"attributes": [],
"role-templates": []
}
デプロイしたアプリケーションはローカル実行時と同様にhttpリクエストを受け付けることができます。
3. おわりに
以上で本ブログにおける開発方法の紹介はおわりです。
本ブログで紹介しているソースコードは簡潔なファイル構成でその内容を紹介することを目的としているため、あまりきれいなソフトウェアアーキテクチャではありません。また、JavaScriptで記述しているため型安全でもなく、記述すべきテストの量が増えてしまうようなソースコードになってしまっており、加えてそのテストなどの自動化もありません(そもそもテストがありません)。これらに対応するために、本番向けアプリケーションを開発するときにはClean ArchitechtureやTypeScript、CI系サービスなどを利用してより安全で確実な開発プロジェクトを進めることが求められます。多くのご意見をいただければそれらを紹介するブログなどを新たに記述したいと思っています。ぜひコメントなどよりみなさまのご意見をお待ちしています。
よきCAP開発ライフを!