2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【BTP】Node.jsとPostgreSQLでシンプルなアプリケーションを作成(4)UI編

Last updated at Posted at 2021-06-08

##はじめに
この記事は、Node.jsとPostgreSQLでシンプルなアプリケーションを作成するシリーズの4回目です。

##この記事のゴール
以下のようなアプリを作成します。このアプリには前回の記事で作成したApprouter経由でアクセスします。

  • 一覧画面
    • 行をクリックすると詳細画面に遷移
    • +ボタンをクリックすると登録画面(=詳細画面)に遷移
    • 行を選択してDeleteボタンを押すと削除

image.png

  • 詳細画面
    • 新規Productの登録、変更、削除が可能
      image.png

技術的には、以下のようなポイントがあります。

  • SAPUI5からREST APIを呼ぶ
  • バックエンドのサービス、Approuter、UI5モジュールを一つのMTAプロジェクトでデプロイする
  • ローカル環境からCloud Foundryのサービスに接続する

##ステップ

  1. UI用のプロジェクトを生成
  2. Cloud Fondryにデプロイするための設定を追加
  3. ローカル実行用の設定を追加
  4. List Viewを作成
  5. Detail Viewを作成
  6. Cloud Foundryにデプロイ

※以下では主要なステップ、処理について説明します。ソースコードの全体はGitHubをご参照ください。

###1. UI用のプロジェクトを生成
generator-easy-ui5を使用してUI5プロジェクトを作成します。プロジェクトのルートに移動して以下のコマンドを実行します。

yo easy-ui5 project

デプロイ先として、Static webserverを選択します。
image.png
プロジェクトの構成は以下のようになります。

node-postgres-sample
  |- approuter
  |- demo.manageproducts
 |- srv
 └ mta.yaml

###2. Cloud Fondryにデプロイするための設定を追加
####2.1. ui5-task-zipperをインストール
ui5-task-zipperはビルドしたUI5アプリをzipにまとめるためのモジュールです。

npm install ui5-task-zipper --save-dev

package.jsonのui5.dependenciesにも追加します。

demo.manageproducts/package.json
  "ui5": {
    "dependencies": [
      "ui5-middleware-livereload",
      "ui5-task-zipper"
    ]
  }

####2.2. ui5.yamlの設定
builderセクションを追加します。これにより、ビルドの後にzipが行われます。

demo.manageproducts/uimodule/ui5.yaml
builder:
  customTasks:
    -  name: ui5-task-zipper
       afterTask: uglify
       configuration:
         includeDependencies: false
         archiveName: uimodule     

####2.3. xs-app.jsonを追加
webappフォルダ配下にxs-app.jsonを追加します。

demo.manageproducts/uimodule/webapp/xs-app.json
{
    "welcomeFile": "/index.html",
    "routes": [
      {
        "source": "^(.*)",
        "target": "$1",
        "authenticationType": "xsuaa",
        "service": "html5-apps-repo-rt"
      }
    ]
  }

####2.4. ApprouterのwelcomeFileを変更
ApprouterのURLにアクセスした際に、UI5アプリのindex.htmlが開くようにwelcomeFileを変更します。ここに設定するのはUI5アプリのnamespace + プロジェクト名です。

approuter/xs-app.json
{
    "welcomeFile": "/demomanageproducts",

####2.5. deployerフォルダを追加
プロジェクト直下にdeployerフォルダを追加します。ここには、UIモジュールをzipした結果が格納されます。

node-postgres-sample
  |- approuter
  |- demo.manageproducts
  |- deployer
 |- srv
 └ mta.yaml

####2.6. mta.yamlに設定を追加
mta.yamlにUIをデプロイするための設定を追加します。
#####2.6.1 html5_repo_host、html5_repo_runtimeを追加
resourcesセクションに以下を追加します。

  - name: manageproducts_html5_repo_host
    type: org.cloudfoundry.managed-service
    parameters:
      service-plan: app-host
      service: html5-apps-repo
      config:
        sizeLimit: 2
  - name: manageproducts_html5_repo_runtime
    type: org.cloudfoundry.managed-service
    parameters:
      service-plan: app-runtime
      service: html5-apps-repo 

#####2.6.2. deployerモジュールを追加
ここでは、UIモジュール(uimodule)のビルド結果をdeployer/resources/に格納するという設定をしています。

 - name: manageproducts_deployer
   type: com.sap.application.content
   path: deployer
   requires:
     - name: manageproducts_html5_repo_host
       parameters:
         content-target: true
   build-parameters:
     build-result: resources
     requires:
       - name: uimodule
         artifacts:
           - dist/uimodule.zip
         target-path: resources/

#####2.6.3. UIモジュールを追加

 - name: uimodule
   type: html5
   path: demo.manageproducts/uimodule
   build-parameters:
     builder: custom
     commands:
       - npm install
       - npm run build:uimodule --prefix ..
     supported-platforms: []  

#####2.6.4. approuterモジュールにhtml5_repo_runtimeを追加
ApprouterからUI5アプリにアクセスできるように、 requiresセクションにhtml5_repo_runtimeサービスを追加します。

 - name: node-postgres-sample-approuter
   type: approuter.nodejs
   path: approuter
   parameters:
     disk-quota: 512M
     memory: 512M
   requires:
     - name: manageproducts_html5_repo_runtime   #追加
     - name: node-postgres-sample-uaa
     - name: srv-api
       group: destinations
       properties:
         name: srv-api
         url: "~{srv-url}"
         forwardAuthToken: true  

最終的なmta.yamlファイルはこちらをご参照ください。

####2.7. ビルド、デプロイ
ここまでで一旦ビルド、デプロイしてApprouterからアクセスできることを確認します。

mbt build
cf deploy mta_archives/node-postgres-sample_1.0.0.mtar

結果は以下のようになります。
image.png

###3. ローカル実行用の設定を追加
UIを開発するにあたり、ローカル(VS Code)環境からバックエンドのサービスにアクセスできると便利です。バックエンドにアクセスするのに認証が必要な設定にしているため、ローカル実行でもApprouterを経由してUIにアクセスするようにします。

####3.1 Approuterにローカル実行用のルートを追加
Approuterのxs-app.jsonにローカル実行用のルートを追加します。なお、このルートはデプロイ時には削除するようにしてください(削除しないとエラーになります)。

approuter/xs-app.json
        {
            "source": "^/test/",
            "target": "/",
            "authenticationType": "xsuaa",
            "localDir": "../demo.manageproducts/uimodule/webapp"
        } 

####3.2. default-env.jsonを作成
approuter配下にdefault-env.jsonを作成します。

approuter/default-env.json
{
    "destinations": [
        {
            "name": "srv-api",
            "url": "Cloud Foundryに登録されたサービスのURL",
            "forwardAuthToken": true
        }
    ]
}

サービスのURLは、例えばhttps://b736177ctrial-dev-node-postgres-sample-srv.cfapps.eu10.hana.ondemand.comとなります。

####3.3. default-services.jsonを作成
ローカル環境からXSUAAのサービスインスタンスに接続するための認証情報を設定します。
この情報はApprouterのEnvironemnt Variablesから取得できます。xsuaa.credentialsの中身の部分をコピーして貼り付けます。
image.png

approuter/default-service.json
{
    "uaa":  {
        "tenantmode": "dedicated",
        "sburl": "https://internal-xsuaa.authentication.eu10.hana.ondemand.com",
        "subaccountid": "cf4bec0f-ec63-4d37-8efd-ded0e8f33c58",
        "credential-type": "instance-secret",
        "clientid": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "xsappname": "node-postgres-sample!t54386",
        "clientsecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "url": "https://b736177ctrial.authentication.eu10.hana.ondemand.com",
        "uaadomain": "authentication.eu10.hana.ondemand.com",
        "verificationkey": "-----BEGIN PUBLIC KEY-----xxxxxxxxxxxxxxxxxxxxxxxxxxxx-----END PUBLIC KEY-----",
        "apiurl": "https://api.authentication.eu10.hana.ondemand.com",
        "identityzone": "b736177ctrial",
        "identityzoneid": "cf4bec0f-ec63-4d37-8efd-ded0e8f33c58",
        "tenantid": "cf4bec0f-ec63-4d37-8efd-ded0e8f33c58",
        "zoneid": "cf4bec0f-ec63-4d37-8efd-ded0e8f33c58"
    }
}

###4. List Viewを作成
Productの一覧を表示するList Viewを作成します。

####4.1. Viewの作成
以下のコマンドでビューおよびコントローラーを生成します。

yo easy-ui5 project newview

image.png

ビューのソースは以下のようになります。

demo.manageproducts/uimodule/webapp/view/List.view.xml
 <mvc:View controllerName="demo.manageproducts.controller.List"
  displayBlock="true"
  xmlns="sap.m"
  xmlns:mvc="sap.ui.core.mvc">
  <Table id="table" 
    items="{path: '/'}" mode="MultiSelect" selectionChange="onSelectionChange" busy="{viewModel>/busy}">
    <headerToolbar>
      <OverflowToolbar>
        <content>
          <Title text="Products" />
          <ToolbarSpacer/>
          <Button icon="sap-icon://add" press="onAdd" /> 
          <Button text="Delete" press="onDelete" enabled="{viewModel>/deleteEnabled}" />     
        </content>
      </OverflowToolbar>
    </headerToolbar>
    <columns>
      <Column>
        <Text text="Product ID"/>
      </Column>
      <Column>
        <Text text="Product Name"/>
      </Column>   
      <Column>
        <Text text="Price"/>
      </Column>                        
    </columns>
    <items>
        <ColumnListItem type="Navigation" press="onDetailPress">
            <Text text="{id}"/>
            <Text text="{name}"/>
            <Text text="{price}"/>
	</ColumnListItem>    
    </items>  
  </Table>
</mvc:View>

もともとあったMainViewを以下のように変更します。

demo.manageproducts/uimodule/webapp/view/MainView.view.xml
 <mvc:View controllerName="demo.manageproducts.controller.MainView"
  displayBlock="true"
  xmlns="sap.m"
  xmlns:mvc="sap.ui.core.mvc">
  <App id="idAppControl" >
  </App>
</mvc:View>

manifest.jsonのルートを以下のように変更します。

demo.manageproducts/uimodule/webapp/manifest.json
    "routing": {
      "config": {...},
      "routes": [
        {
          "name": "List",
          "pattern": "",
          "target": [
            "TargetList"
          ]
        }
      ],
      "targets": {
        "TargetList": {
          "viewType": "XML",
          "viewId": "List",
          "viewName": "List"
        }
      }
    }
  }

####4.2. リストを表示
今回はバックエンドがODataではなくREST APIなので、XMLHttpRequestを使用してデータを取得後、JSONモデルを作成して画面にバインドします。
このために、バックエンドに接続するための共通処理をBaseControllerに作成します。

demo.manageproducts/uimodule/webapp/controller/BaseController.js
            sendXMLHttpRequest: function (method, url, oData) {
                return new Promise((resolve, reject) => {
                    var xhr = new XMLHttpRequest()
                    xhr.open(method, url)
                    if (method === 'POST' || method === 'PUT') {
                        xhr.setRequestHeader("content-type", "application/json")
                    }
                    if (oData) {
                        xhr.send(JSON.stringify(oData))
                    } else {
                        xhr.send()
                    }                    
                    xhr.onload = function () {
                        if (xhr.status === 200 || xhr.status === 201) {
                            resolve(xhr.response)            
                        } else {
                            reject(xhr.statusText)
                        }                        
                    }
                })          
            }

ListViewのコントローラーの中でバックエンドからデータを取得してJSONモデルを作成し、ビューに設定します。

demo.manageproducts/uimodule/webapp/controller/List.controller.js
        onInit: function () {
            this.getRouter().getRoute("List").attachMatched(this._onRouteMatched, this); 
            var oModel = new JSONModel({
                deleteEnabled: false,
                busy: false
            });
            this.setModel(oModel, "viewModel");         
        },

        _onRouteMatched: function () {
            this._doRefresh()
        }, 
        
        _doRefresh: function () {
            this.setProperty("viewModel", "busy", true);
            //バックエンドからデータを取得   
            this.sendXMLHttpRequest('GET', '/node-pg/products')
            .then((response)=> {
                //JSONモデルを作成してビューにセット
                var products = JSON.parse(response)
                this.getView().setModel(new JSONModel(products))
                //idでソート
                var oSorter = new Sorter("id")
                oSorter.fnCompare = function(value1, value2) {
                    if (value1 < value2) return -1;
                    if (value1 == value2) return 0;
                    if (value1 > value2) return 1;                    
                }
                this.byId("table").getBinding("items").sort(oSorter)
                this.setProperty("viewModel", "busy", false);   
            })
            .catch(error => {
                this.handleError(error)
            })
        }

####4.3. ローカル実行
approuterのフォルダに移動し、npm startでApprouterを起動します。Approuterがポート5000で立ち上がります。
image.png
ブラウザにlocalhost:5000/test/index.htmlと入力します。認証を求める画面のあと、以下の画面が表示されるはずです。(※前提として、Cloud Foundryでバックエンドのサービスが起動していること)
image.png

####4.4. 削除処理
行を選択してDeleteボタンを押したときの処理を実装します。

demo.manageproducts/uimodule/webapp/controller/List.controller.js
        onDelete: function () {
            MessageBox.confirm("Delete selected product(s)?", {
				actions: ["Delete", MessageBox.Action.CLOSE],
				emphasizedAction: "Delete",                
                onClose: this._deleteItems.bind(this)
            });            
        },

        _deleteItems: function (oAction) {
            if (oAction === MessageBox.Action.CLOSE) { 
                return
            }
            var oTable = this.byId("table")
            var aContexts = oTable.getSelectedContexts()
            this.setProperty("viewModel", "busy", true); 
            var aDeletePromises = aContexts.map(oContext => {
                var id = oContext.getProperty("id")
                return this.sendXMLHttpRequest('DELETE', `/node-pg/products/${id}`)
            })

            Promise.all(aDeletePromises)
            .then(() => {
                this._doRefresh();
                MessageToast.show('Selected product(s) have been deleted');                      
            })
            .catch(error => {
                this.handleError(error)
            });                                       
        }

※登録、更新、削除処理を行うためにApprouterのrouteで"csrfProtection": falseの設定を追加しました。この設定がない場合、POST, PUT, DELETEのリクエストの際にCSRFトークンの設定が必要になります。

approuter/xs-app.json
    "routes": [
        {
            "source": "^/node-pg/products(.*)$",
            "target": "/products$1",
            "authenticationType": "xsuaa",
            "destination": "srv-api",
            "scope": {
                "GET": ["$XSAPPNAME.Display", "$XSAPPNAME.Update"],
                "default": "$XSAPPNAME.Update"
            },
            "csrfProtection": false            
        },
        {
            "source": "^/node-pg/",
            "target": "/",
            "authenticationType": "xsuaa",
            "destination": "srv-api",
            "csrfProtection": false     
        },

###5. Detail Viewを作成
行を選択したとき、または登録(+)ボタンを押したときに遷移する先の画面を作成します。

####5.1. Viewの作成
4.1.と同じ要領でDetail Viewを作成します。

demo.manageproducts/uimodule/webapp/view/Detail.view.xml
 <mvc:View controllerName="demo.manageproducts.controller.Detail"
  displayBlock="true"
  xmlns="sap.m"
	xmlns:l="sap.ui.layout"
	xmlns:f="sap.ui.layout.form"   
  xmlns:mvc="sap.ui.core.mvc">
  <Page title="Prodct Detail" floatingFooter="true" showFooter="{viewModel>/editable}" 
    showNavButton="true" navButtonPress="onNavButtonPress">
    <headerContent>
			<OverflowToolbar>
				<ToolbarSpacer/>
				<Button text="Edit" type="Emphasized" press="onEdit" visible="{= !${viewModel>/editable}}"/>
        <Button text="Delete" type="Default" press="onDelete" visible="{= !${viewModel>/editable}}"/>
			</OverflowToolbar>
    </headerContent>
    <VBox class="sapUiSmallMargin">
      <f:SimpleForm id="simpleForm"
        editable="true"
        layout="ResponsiveGridLayout"
        labelSpanXL="3"
        labelSpanL="3"
        labelSpanM="3"
        labelSpanS="12"
        adjustLabelSpan="false"
        emptySpanXL="4"
        emptySpanL="4"
        emptySpanM="4"
        emptySpanS="0"
        columnsXL="1"
        columnsL="1"
        columnsM="1"
        singleContainerFullSize="false" >
        <f:content>
          <Label text="Product Name"/>
          <Input id="productName" value="{name}" editable="{viewModel>/editable}" />
          <Label text="Price" />
          <Input id="price" value="{price}" editable="{viewModel>/editable}"/>					               
        </f:content>
      </f:SimpleForm>
    </VBox>
		<footer>
			<OverflowToolbar>
				<ToolbarSpacer/>
				<Button type="Emphasized" text="Save" press="onSave"/>
			</OverflowToolbar>
		</footer>    
  </Page>
</mvc:View>

manifest.jsonのルートを以下のように設定します。

demo.manageproducts/uimodule/webapp/manifest.json
    "routing": {
      "config": {...},
      "routes": [
        {
          "name": "List",
          "pattern": "",
          "target": [
            "TargetList"
          ]
        },
        {
          "name": "Detail",
          "pattern": "RouteDetail/:id:",
          "target": [
            "TargetDetail"
          ]
        }
      ],
      "targets": {
        "TargetList": {
          "viewType": "XML",
          "viewId": "List",
          "viewName": "List"
        },
        "TargetDetail": {
          "viewType": "XML",
          "viewId": "Detail",
          "viewName": "Detail"
        }
      }
    }

####5.2. List Viewからの遷移
List View側の画面遷移の処理は以下のようになります。

demo.manageproducts/uimodule/webapp/controller/List.controller.js
        //登録ボタンを押したとき
        onAdd: function () {
            this.getRouter().navTo("Detail", {});
        },

        //行選択したとき
        onDetailPress: function (oEvent) {
            var id = oEvent.getSource().getBindingContext().getProperty("id");
            this.getRouter().navTo("Detail", {
                id: id
            });
        },

Detail View側の画面遷移後の処理は以下のようになります。

demo.manageproducts/uimodule/webapp/controller/Detail.controller.js
        onInit: function () {
            this.getRouter().getRoute("Detail").attachMatched(this._onRouteMatched, this);

            var oModel = new JSONModel({
                isStatusVisible: false,
                deleteEnabled: false,
                editable: false,
                busy: false
            });
            this.setModel(oModel, "viewModel");
        },

        _onRouteMatched: function (oEvent) {
            var oArgs = oEvent.getParameter("arguments");
            var id = oArgs.id;
            //登録の場合
            if (id === undefined) {
                this._handleCreate();
            //照会の場合
            } else {
                this._handleDisplay(id);
            }                 
        },
        
        _handleCreate: function () {
            var oProduct = new JSONModel({
                name: "",
                price: 0
            })
            this.getView().setModel(oProduct)
            this.getView().bindObject("/")  
            this._setEditable(true)
        },

        _handleDisplay: function (id) {
            this.setProperty("viewModel", "busy", true); 
            //バックエンドからデータを取得
            this.sendXMLHttpRequest('GET', `/node-pg/products/${id}`)
            .then((response)=> {
                //JSONモデルを作成してビューにセット
                var product = JSON.parse(response)
                this.getView().setModel(new JSONModel(product))
                this.getView().bindObject("/")
                this._setEditable(false)
                this.setProperty("viewModel", "busy", false); 
            })
            .catch(error => {
                this.handleError(error)
            })          
        },

####5.3. 登録・更新処理
Saveボタンを押したときの処理を追加します。
image.png

登録のパターンと更新のパターンがありますが、idの有無で判断しています。(登録時にはidがまだない)

demo.manageproducts/uimodule/webapp/controller/Detail.controller.js
        onSave: function () {
            var oProduct = this.getModel().getData()
            var id = oProduct.id
            var url = id ? `/node-pg/products/${id}`: '/node-pg/products'
            var method = id ? 'PUT': 'POST'
            this.setProperty("viewModel", "busy", true); 
            this.sendXMLHttpRequest(method, url, oProduct)
            .then((response)=> {
                var newId = JSON.parse(response).id
                MessageToast.show(`Product ${newId} has been created/updated`, {
                    closeOnBrowserNavigation: false
                })
                this.setProperty("viewModel", "busy", false); 
                var oRouter = this.getRouter();
                oRouter.navTo("List");  
            })
            .catch(error => {
                this.handleError(error)
            })  
        }

####5.4. 削除処理
照会画面でDeleteボタンを押したときの処理を追加します。
image.png

demo.manageproducts/uimodule/webapp/controller/Detail.controller.js
        onDelete: function () {
            MessageBox.confirm("Delete this product?", {
				actions: ["Delete", MessageBox.Action.CLOSE],
				emphasizedAction: "Delete",                
                onClose: this._deleteItem.bind(this)
            }); 
        },

        _deleteItem: function (oAction) {
            if (oAction === MessageBox.Action.CLOSE) { 
                return
            }            
            var id = this.getView().getBindingContext().getProperty("id")      
            this.setProperty("viewModel", "busy", true); 
            this.sendXMLHttpRequest('DELETE', `/node-pg/products/${id}`)
            .then(()=> {
                MessageToast.show(`Product ${id} has been deleted`, {
                    closeOnBrowserNavigation: false
                })
                this.setProperty("viewModel", "busy", false); 
                var oRouter = this.getRouter();
                oRouter.navTo("List");  
            })
            .catch(error => {
                this.handleError(error)
            })             
        }

###6. Cloud Foundryにデプロイ
最後に、Cloud Foundryにデプロイします。
ビルドの前に、Approuterの以下のルートは削除しておきます。

approuter/xs-app.json
        {
            "source": "^/test/",
            "target": "/",
            "authenticationType": "xsuaa",
            "localDir": "../demo.manageproducts/uimodule/webapp"
        }  

ApprouterのURLにアクセスし、以下の画面が表示されれば成功です。
image.png

##まとめ
今回の記事のポイントは以下でした。

  • SAPUI5からREST APIを呼ぶ
  • バックエンドのサービス、Approuter、UI5モジュールを一つのMTAプロジェクトでデプロイする
  • ローカル環境からCloud Foundryのサービスに接続する

これで、「Node.jsとPostgreSQLでシンプルなアプリケーションを作成」するシリーズは終了です。お読みいただきありがとうございました。

##参考

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?