目次

  1. はじめに

    1. 概要

    2. シナリオ説明

  2. ハンズオン

    1. プロジェクト用意

      1. プロジェクト生成

      2. SFSF接続設定

    2. データモデル定義

    3. サービスモデル定義

    4. ビジネスロジック拡張

      1. 読み込み系処理

      2. 書き込み系処理

    5. ローカル実行

    6. 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環境へのデプロイ時に使用するMulti Target Applicationのディスクリプタファイルや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として取り扱うため、メタデータファイルはコピーされるだけでなく.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配下に記述します。この配下に記述するdbECPersonalInformationがCAPの中で取り扱われる接続先名です。接続先名の配下にその接続先の設定情報を記述します。たとえばHANA Cloudに接続する場合はkind: hana-cloudとのみ記述します。ODataサービス(プロトコルはOData v2)と接続する場合はkind: odata-v2と記述し、そのODataサービスのメタデータの配置場所もmodelとして記述します。接続先の一つ上の階層に[production] と[development]と記述することでデプロイ先の環境で使用する設定とローカル環境で使用する設定を書き分けることが可能です。

ここでは特別に記載をしていませんが、このdevelopmentプロファイルを参照してアプリケーションを起動した際にはFiori Previewおよび認証認可のストラテジーがそれぞれon、dummyで設定されています。またproductionプロファイルを参照してアプリケーションを起動した際にはそれぞれoff、jwtで設定されます。これらの挙動をpackage.json上で上書きしたい場合には、例えばdbECPersonalInformationと同じ深さに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つのプロパティだけでなく、mangedaddtionalNoteの中でも定義されているプロパティも持ちます。このデータモデルは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)
)

2.3. サービスモデル定義

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用ハンドラーをオーバーライドします。この読み込み処理では主に以下のような内容を処理しています。

  1. CAPのデータモデルであるPersonJobsよりデータを取得

  2. 取得したデータのキーを使ってSFSFのODataサービスであるPerPersonal 用のwhere句を組み立てなおす

  3. 組み立てたwhere句を使ってPerPersonalにクエリ実行

  4. PersonJobsPerPersonalの実行結果を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.jsonECPersonalInformationの接続先としてdestinationを指定しています。この設定を使用するとCAPはBTP環境のDestinationサービスの使用を試みます。このサービス使用のために、下記yamlファイルのresoucesservice: 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開発ライフを!

Sara Sampaio

Sara Sampaio

Author Since: March 10, 2022

0 0 votes
Article Rating
Subscribe
Notify of
0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x