20200824のNode.jsに関する記事は15件です。

Asciidoctor.jsでプレビューしながら編集する

AsciiDoc の処理系といえば、Ruby の Asciidoctor1が有名です。しかし、JavaScript な Asciidoctor.js2もあります。本記事は後者を使ってみた記録です。

関連

バージョン

$ asciidoctor -v
Asciidoctor 2.0.10 [https://asciidoctor.org]
Runtime Environment (ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-darwin19]) (lc:UTF-8 fs:UTF-8 in:UTF-8 ex:UTF-8)

$ npm -v
6.14.8

きっかけ

Ruby 版の Asciidoctor には監視機能がありません。つまり、AsciiDoc ファイルに変更があった際、自動的に変換処理が行われるようにするオプションを、asciidoctorコマンドに指定することはできません。

$ asciidoctor --watch main.adoc # こうできたらいいなぁ……

監視機能を進めると、プレビュー機能になります。ここでのプレビュー機能とは、生成結果が変更されたタイミングでその表示に反映されることです。HTML ファイルの場合、ブラウザで再読込する手間が省けます。

今回は HTML ファイルを生成し、プレビューすることを考えます。

Ruby 版 Asciidoctor で

Editing AsciiDoc with Live Preview3に紹介されていますが、Firefox 79.0 (64bit) では拡張機能が使えなくなっているようです。ただ、guardという gem4 は AsciiDoc から HTML への変換過程で使えそう。

asciidoctor, guard, guard-shell をインストールします。

$ bundle init
$ bundle add asciidoctor guard guard-shell

main.adocという AsciiDoc ファイルを監視します。

Guardfile
require 'asciidoctor'

guard :shell do
  watch('main.adoc') { |m| Asciidoctor.convert_file m[0] }
end

Guard を起動します。

$ guard

AsciiDoc ファイルに変更があるたびに、HTML ファイルが更新されるようになりました。

live-server登場

npm にlive-server5というパッケージがあり、これでプレビューを実現できます。使い方は簡単。

$ live-server

guard周りでもできそうですが、これが明快かと思います。

Asciidoctor.js で

live-serverで視点が変わりました。監視はnpm-watch6に任せましょう。

$ npm i -D asciidoctor live-server npm-watch

package.json に npm scripts7 を書きます。

package.json
{
  "watch": {
    "convert": "main.adoc"
  },
  "scripts": {
    "start": "live-server",
    "watch": "npm-watch",
    "convert": "asciidoctor main.adoc"
  },
  "devDependencies": {
    "asciidoctor": "^2.2.0",
    "live-server": "^1.2.1",
    "npm-watch": "^0.7.0"
  }
}

注意:$ npm run convertで起動される Asciidoctor は Asciidoctor.js の方です。実際、$ npx asciidoctor --helpで Asciidoctor.js のヘルプが表示されます。

AsciiDoc ファイルも準備しましょう。

main.adoc
= Hello

* ワン
* ツー
* スリー

用意するファイルはこれだけ:

$ ls -1
main.adoc
package.json

監視、プレビュー……

$ npm run watch # terminal 1
$ npm run start # terminal 2
$ vim main.adoc # terminal 3

before

そして編集……

main.adoc
= Hello

_Happy AsciiDocing!_

after

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Expressとpassportで簡単にOpenID ConnectのRPを作成してみた!

目的と前提

認証/認可について少しづつですが備忘録としてまとめようと思います。
今回は、Nodejsを使ったRPの作成[1]です。
OpenID Connectのアクセストークン取得まで実装しています。
(UserInfoを取得するところは実装していません)

IdPの作成にはオープンソースソフトのOpenAM[2]を使用しています。

認証/認可、基礎的なOpenID Connectの知識があることを前提としています。

環境

macOS Catalina v10.15.5
OpenAM 14.5.1 Build d8b8db3cac (2020-March-11 23:25)
node v13.13.0

利用モジュール
"dependencies": {
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"express": "~4.16.1",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"morgan": "~1.9.1",
"passport-openidconnect": "0.0.2"
}

OpenAMの起動と初期設定

IdPはDockerで提供されているOpenAMを利用して作成します。
OpenAMのイメージはDockerHubよりゲットできます。

$ docker pull openidentityplatform/openam
$ docker run -h openam-01.domain.com -p 8080:8080 --name openam-01 openidentityplatform/openam

これでOpenAMが起動したはずです。

念のため、起動しているか確認してみます。
下記のような表示が出れば、問題なく起動できています。

$ docker container ls
CONTAINER ID        IMAGE                         COMMAND                  CREATED             STATUS              PORTS                    NAMES
91d60b3e3538        openidentityplatform/openam   "/usr/local/tomcat/b…"   2 hours ago         Up 2 hours          0.0.0.0:8080->8080/tcp   openam-01

それではOpenAMにアクセスしてみましょう。
http://localhost:8080/openam

初回起動では設定事項がいろいろあるので、私は、OpenAMコンソーシアムの資料を参考に設定しました。
※設定オプションは、カスタム設定ではなく、デフォルト設定を選択しました。
https://www.openam.jp/wp-content/uploads/techtips_vol1.pdf

IdPの作成

まずOpenAMにamAdminでログインします。
スクリーンショット 2020-08-24 18.51.20.png
その後、Top Level Realm(トップレベルレルム)にアクセス後、Configure OAuth Providerを選択します。

スクリーンショット 2020-08-24 19.14.17.png

Configure OpenID Connectを選択します。
スクリーンショット 2020-08-24 19.15.36.png
認可コードやアクセストークンの有効期限をチェックして、問題なければ作成を押します。
リフレッシュトークンを発行させたい場合は、リフレッシュトークンの発行にチェックを入れてください。
スクリーンショット 2020-08-24 19.19.30.png

これでIdPが作成できました〜
下記URLにアクセスしてIdPができていることを確認します。
http://localhost:8080/openam/oauth2/.well-known/openid-configuration

{"response_types_supported":["code token id_token","code","code id_token","id_token","code token","token","token id_token"],"claims_parameter_supported":false,"end_session_endpoint":"http://localhost:8080/openam/oauth2/connect/endSession","version":"3.0","check_session_iframe":"http://localhost:8080/openam/oauth2/connect/checkSession","scopes_supported":["address","phone","openid","profile","email"],"issuer":"http://localhost:8080/openam/oauth2","id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"acr_values_supported":[],"authorization_endpoint":"http://localhost:8080/openam/oauth2/authorize","userinfo_endpoint":"http://localhost:8080/openam/oauth2/userinfo","device_authorization_endpoint":"http://localhost:8080/openam/oauth2/device/code","claims_supported":["zoneinfo","address","profile","name","phone_number","given_name","locale","family_name","email"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","A128KW","RSA1_5","A256KW","dir","A192KW"],"jwks_uri":"http://localhost:8080/openam/oauth2/connect/jwk_uri","subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES384","HS256","HS512","ES256","RS256","HS384","ES512"],"registration_endpoint":"http://localhost:8080/openam/oauth2/connect/register","token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],"token_endpoint":"http://localhost:8080/openam/oauth2/access_token"}

こんな風に表示されればOK!

RPの作成

初期設定

express公式サイトのGetting startedに従って、まずサンプルのWebアプリケーションを作成します。

$ mkdir myapp
$ npx express-generator

こんな感じのディレクトリ構成になっているはず

$ ls
app.js                  node_modules            package.json            routes
bin                     package-lock.json       public                  views

実装

実装はForgeRockのOpenIDConnectのサンプルRP[3]を参考にしながら作成していきます。(非常に分かり易かったのでオススメ!)

SSO連携というリンクをクリックすると、
OpenIDConnectのフローが開始されるようにしていきます。
見た目はこんな感じ
スクリーンショット 2020-08-24 20.08.01.png

viewsにリンクのボタンを加えます。

views/index.jade
extends layout

block content
  h1= title
  p Welcome to #{title}

  hoge-button
    a(href="http://localhost:3000/auth/openidconnect", target="_blank") SSO連携

routes/index.js
var express = require("express");
var router = express.Router();

/* GET home page. */
router.get("/", function (req, res, next) {
  res.render("index", { title: "Express" });
});

module.exports = router;

コントローラー(app.js)にロジックを直接追加しちゃいます。
気になる方は分けていただいても問題ないです。

app.js
// 参考:https://github.com/ForgeRock/exampleOAuth2Clients/tree/master/node-passport-openidconnect

// 各モジュールをインポート
var createError = require("http-errors");
var express = require("express");
var path = require("path");
// sessionを使うのに求められる
var cookieParser = require("cookie-parser");
var logger = require("morgan");

// pathを定義
// indexにログインボタンを設置
// ログイン失敗時 → loginfail
// ログイン成功時 → login
// に遷移するようにする
var indexRouter = require("./routes/index");
var loginFRouter = require("./routes/loginfail");
var loginRouter = require("./routes/login");

var app = express();

//session有効
var session = require("express-session");
app.use(
  session({
    //クッキー改ざん検証用ID
    secret: "YOUR_PASSWORD",
    //未初期化のセッションを保存するか
    saveUninitialized: false,
    //他にもsessionの寿命とか、httpsならsecureも設定できる
  })
);

// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");

app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));

app.use("/", indexRouter);

//追記ここから
app.use("/loginfail", loginFRouter);
app.use("/login", loginRouter);

//認証セクション
var passport = require("passport");
const { token } = require("morgan");
var OpenidConnectStrategy = require("passport-openidconnect").Strategy;

app.use(passport.initialize());
app.use(passport.session());

passport.use(
  new OpenidConnectStrategy(
    {
      issuer: "http://localhost:8080/openam/oauth2",
      authorizationURL: "http://localhost:8080/openam/oauth2/authorize",
      tokenURL: "http://localhost:8080/openam/oauth2/access_token",
      userInfoURL: "http://localhost:8080/openam/oauth2/userinfo",
      clientID: "sampleRP",
      clientSecret: "RP_PASSWORD",
      callbackURL: "http://localhost:3000/oauth2callback",
      scope: ["openid", "email", "profile"],
    },
    function (
      issuer,
      sub,
      profile,
      jwtClaims,
      accessToken,
      refreshToken,
      tokenResponse,
      done
    ) {
      //認証成功したらこの関数が実行される
      //ここでID tokenの検証を行う
      console.log("issuer: ", issuer);
      console.log("sub: ", sub);
      console.log("profile: ", profile);
      console.log("jwtClaims: ", jwtClaims);
      console.log("accessToken: ", accessToken);
      console.log("refreshToken: ", refreshToken);
      console.log("tokenResponse: ", tokenResponse);

      return done(null, {
        profile: profile,
        accessToken: {
          token: accessToken,
          scope: tokenResponse.scope,
          token_type: tokenResponse.token_type,
          expires_in: tokenResponse.expires_in,
        },
        idToken: {
          token: tokenResponse.id_token,
          claims: jwtClaims,
        },
      });
    }
  )
);

passport.serializeUser(function (user, done) {
  //userにはprofileが入る
  done(null, user);
});

passport.deserializeUser(function (obj, done) {
  done(null, obj);
});

app.get("/auth/openidconnect", passport.authenticate("openidconnect"));

app.get(
  "/oauth2callback",
  passport.authenticate("openidconnect", {
    failureRedirect: "/loginfail",
  }),
  function (req, res) {
    // Successful authentication, redirect home.
    console.log("認可コード:" + req.query.code);
    req.session.user = req.session.passport.user.displayName;
    res.redirect("/login");
  }
);
//ここまで

// catch 404 and forward to error handler
app.use(function (req, res, next) {
  next(createError(404));
});

// error handler
app.use(function (err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render("error");
});
module.exports = app;

login成功後は、/loginというページに遷移させる予定なので、
views/login.jade
routes/login.js
をそれぞれ追加します。

views/login.jade
extends layout

block content
  h1= title
  p Welcome to #{title}
  p login成功!
routes/login.js
var express = require("express");
var router = express.Router();

/* GET home page. */
router.get("/", function (req, res, next) {
  res.render("login", { title: "ログイン" });
});

module.exports = router;

login失敗時のページも作っておきます。
views/loginfail.jade
routes/loginfail.js

views/loginfail.jade
extends layout

block loginfail
  block content
  h1= title
  p Welcome to #{title}
  p Login失敗
routes/loginfail.js
var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  res.render('loginfail', { title: 'ログインできなかったよ' });
});

module.exports = router;

これでRPの作成は完了です。
最終的にはこんな感じのディレクトリ構成になりました。


$ ls
app.js                  node_modules            package.json            routes
bin                     package-lock.json       public                  views
$ ls views 
error.jade      index.jade      layout.jade     login.jade      loginfail.jade
$ ls routes 
index.js        login.js        loginfail.js

RPの登録

OpenAMにRPを登録します。
OpenAMにAdminでログイン後、Top Level Realm(トップレベルレルム)のApplications>OAuth2.0を選択します。
スクリーンショット 2020-08-24 21.03.48.png

エージェントの新規をクリック
スクリーンショット 2020-08-24 21.07.28.png

エージェントの名前とパスワードの入力を求められるので、
今回は下記のように入力し、作成を押します。

名前:sampleRP
パスワード:password

このパスワードは、先ほど作成したapp.js内のRP_PASSWORDにあたります。
作成を押した後、メインページに戻るので、エージェントから、先ほど作成したエージェントを選択し、設定を追加していきます。

項目 設定内容
リダイレクトURI http://localhost:3000/oauth2callback
スコープ openid, email, profole
Token Endpoint Authentication Method client_secret_post

そのほかの設定は、デフォルトのまま。

スクリーンショット 2020-08-24 21.33.22.png

スクリーンショット 2020-08-24 21.34.39.png

設定追加後、保存を押して登録完了です。

動作確認

早速RPを動かしてみます。

$ npm start

RPにアクセス!
http://localhost:3000/

SSO連携のリンクを押してみるとOpenAMのログイン画面に遷移します。
スクリーンショット 2020-08-24 20.26.52.png

初期設定で作成したアカウントのID/PWを入れてログイン!
アカウントを作成した記憶がない方はデフォルトの下記アカウントでもログインできるはずです。
ID: demo
password: changeit
ログイン後、個人情報提供の同意画面に遷移するのでAllowを選択します。

スクリーンショット 2020-08-24 20.29.13.png

ログイン成功画面に遷移しました。
スクリーンショット 2020-08-24 21.38.26.png

ターミナルにこんな感じに出力されていれば認証成功です。

スクリーンショット 2020-08-24 20.34.39.png
これでアクセストークン、IDトークンが取得できているはずなので、
この後、ユーザーの情報を取得したい場合は、OpenAMのユーザー情報エンドポイントにアクセストークンを GETで渡せば大丈夫なはずです。
参考:
https://backstage.forgerock.com/docs/am/5/oauth2-guide/#oauth2-byo-client

以上になります。

お疲れ様でした!

参考

[1] 株式会社オージス総研 テミストラクトソリューション部 氏縄 武尊."第三回 Relying Party の実装例 ~passport~".オブジェクトの広場.2016-03-10,(参照2020-08-24)
[2] Open Source Solution Technology Corporation.学認Shibboleth ShibbolethとOpenAMを連携させて学外と学内をシングルサインオン.2011
[3] ForgeRock."exampleOAuth2Clients/node-passport-openidconnect".Github.2020-3-25,(参照2020-08-24)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Expressとpassportで簡単にOpenID ConnectのRPを作成してみた

目的と前提

認証/認可について少しづつですが備忘録としてまとめようと思います。
今回は、Nodejsを使ったRPの作成[1]です。
OpenID Connectのアクセストークン取得まで実装しています。
(UserInfoを取得するところは実装していません)

IdPの作成にはオープンソースソフトのOpenAM[2]を使用しています。

認証/認可、基礎的なOpenID Connectの知識があることを前提としています。

環境

macOS Catalina v10.15.5
OpenAM 14.5.1 Build d8b8db3cac (2020-March-11 23:25)
node v13.13.0

利用モジュール
"dependencies": {
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"express": "~4.16.1",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"morgan": "~1.9.1",
"passport-openidconnect": "0.0.2"
}

OpenAMの起動と初期設定

IdPはDockerで提供されているOpenAMを利用して作成します。
OpenAMのイメージはDockerHubよりゲットできます。

$ docker pull openidentityplatform/openam
$ docker run -h openam-01.domain.com -p 8080:8080 --name openam-01 openidentityplatform/openam

これでOpenAMが起動したはずです。

念のため、起動しているか確認してみます。
下記のような表示が出れば、問題なく起動できています。

$ docker container ls
CONTAINER ID        IMAGE                         COMMAND                  CREATED             STATUS              PORTS                    NAMES
91d60b3e3538        openidentityplatform/openam   "/usr/local/tomcat/b…"   2 hours ago         Up 2 hours          0.0.0.0:8080->8080/tcp   openam-01

それではOpenAMにアクセスしてみましょう。
http://localhost:8080/openam

初回起動では設定事項がいろいろあるので、私は、OpenAMコンソーシアムの資料を参考に設定しました。
※設定オプションは、カスタム設定ではなく、デフォルト設定を選択しました。
https://www.openam.jp/wp-content/uploads/techtips_vol1.pdf

IdPの作成

まずOpenAMにamAdminでログインします。
スクリーンショット 2020-08-24 18.51.20.png
その後、Top Level Realm(トップレベルレルム)にアクセス後、Configure OAuth Providerを選択します。

スクリーンショット 2020-08-24 19.14.17.png

Configure OpenID Connectを選択します。
スクリーンショット 2020-08-24 19.15.36.png
認可コードやアクセストークンの有効期限をチェックして、問題なければ作成を押します。
リフレッシュトークンを発行させたい場合は、リフレッシュトークンの発行にチェックを入れてください。
スクリーンショット 2020-08-24 19.19.30.png

これでIdPが作成できました〜
下記URLにアクセスしてIdPができていることを確認します。
http://localhost:8080/openam/oauth2/.well-known/openid-configuration

{"response_types_supported":["code token id_token","code","code id_token","id_token","code token","token","token id_token"],"claims_parameter_supported":false,"end_session_endpoint":"http://localhost:8080/openam/oauth2/connect/endSession","version":"3.0","check_session_iframe":"http://localhost:8080/openam/oauth2/connect/checkSession","scopes_supported":["address","phone","openid","profile","email"],"issuer":"http://localhost:8080/openam/oauth2","id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"acr_values_supported":[],"authorization_endpoint":"http://localhost:8080/openam/oauth2/authorize","userinfo_endpoint":"http://localhost:8080/openam/oauth2/userinfo","device_authorization_endpoint":"http://localhost:8080/openam/oauth2/device/code","claims_supported":["zoneinfo","address","profile","name","phone_number","given_name","locale","family_name","email"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","A128KW","RSA1_5","A256KW","dir","A192KW"],"jwks_uri":"http://localhost:8080/openam/oauth2/connect/jwk_uri","subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES384","HS256","HS512","ES256","RS256","HS384","ES512"],"registration_endpoint":"http://localhost:8080/openam/oauth2/connect/register","token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],"token_endpoint":"http://localhost:8080/openam/oauth2/access_token"}

こんな風に表示されればOK!

RPの作成

初期設定

express公式サイトのGetting startedに従って、まずサンプルのWebアプリケーションを作成します。

$ mkdir myapp
$ npx express-generator

こんな感じのディレクトリ構成になっているはず

$ ls
app.js                  node_modules            package.json            routes
bin                     package-lock.json       public                  views

実装

実装はForgeRockのOpenIDConnectのサンプルRP[3]を参考にしながら作成していきます。(非常に分かり易かったのでオススメ!)

SSO連携というリンクをクリックすると、
OpenIDConnectのフローが開始されるようにしていきます。
見た目はこんな感じ
スクリーンショット 2020-08-24 20.08.01.png

viewsにリンクのボタンを加えます。

views/index.jade
extends layout

block content
  h1= title
  p Welcome to #{title}

  hoge-button
    a(href="http://localhost:3000/auth/openidconnect", target="_blank") SSO連携

routes/index.js
var express = require("express");
var router = express.Router();

/* GET home page. */
router.get("/", function (req, res, next) {
  res.render("index", { title: "Express" });
});

module.exports = router;

コントローラー(app.js)にロジックを直接追加しちゃいます。
気になる方は分けていただいても問題ないです。

app.js
// 参考:https://github.com/ForgeRock/exampleOAuth2Clients/tree/master/node-passport-openidconnect

// 各モジュールをインポート
var createError = require("http-errors");
var express = require("express");
var path = require("path");
// sessionを使うのに求められる
var cookieParser = require("cookie-parser");
var logger = require("morgan");

// pathを定義
// indexにログインボタンを設置
// ログイン失敗時 → loginfail
// ログイン成功時 → login
// に遷移するようにする
var indexRouter = require("./routes/index");
var loginFRouter = require("./routes/loginfail");
var loginRouter = require("./routes/login");

var app = express();

//session有効
var session = require("express-session");
app.use(
  session({
    //クッキー改ざん検証用ID
    secret: "YOUR_PASSWORD",
    //未初期化のセッションを保存するか
    saveUninitialized: false,
    //他にもsessionの寿命とか、httpsならsecureも設定できる
  })
);

// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");

app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));

app.use("/", indexRouter);

//追記ここから
app.use("/loginfail", loginFRouter);
app.use("/login", loginRouter);

//認証セクション
var passport = require("passport");
const { token } = require("morgan");
var OpenidConnectStrategy = require("passport-openidconnect").Strategy;

app.use(passport.initialize());
app.use(passport.session());

passport.use(
  new OpenidConnectStrategy(
    {
      issuer: "http://localhost:8080/openam/oauth2",
      authorizationURL: "http://localhost:8080/openam/oauth2/authorize",
      tokenURL: "http://localhost:8080/openam/oauth2/access_token",
      userInfoURL: "http://localhost:8080/openam/oauth2/userinfo",
      clientID: "sampleRP",
      clientSecret: "RP_PASSWORD",
      callbackURL: "http://localhost:3000/oauth2callback",
      scope: ["openid", "email", "profile"],
    },
    function (
      issuer,
      sub,
      profile,
      jwtClaims,
      accessToken,
      refreshToken,
      tokenResponse,
      done
    ) {
      //認証成功したらこの関数が実行される
      //ここでID tokenの検証を行う
      console.log("issuer: ", issuer);
      console.log("sub: ", sub);
      console.log("profile: ", profile);
      console.log("jwtClaims: ", jwtClaims);
      console.log("accessToken: ", accessToken);
      console.log("refreshToken: ", refreshToken);
      console.log("tokenResponse: ", tokenResponse);

      return done(null, {
        profile: profile,
        accessToken: {
          token: accessToken,
          scope: tokenResponse.scope,
          token_type: tokenResponse.token_type,
          expires_in: tokenResponse.expires_in,
        },
        idToken: {
          token: tokenResponse.id_token,
          claims: jwtClaims,
        },
      });
    }
  )
);

passport.serializeUser(function (user, done) {
  //userにはprofileが入る
  done(null, user);
});

passport.deserializeUser(function (obj, done) {
  done(null, obj);
});

app.get("/auth/openidconnect", passport.authenticate("openidconnect"));

app.get(
  "/oauth2callback",
  passport.authenticate("openidconnect", {
    failureRedirect: "/loginfail",
  }),
  function (req, res) {
    // Successful authentication, redirect home.
    console.log("認可コード:" + req.query.code);
    req.session.user = req.session.passport.user.displayName;
    res.redirect("/login");
  }
);
//ここまで

// catch 404 and forward to error handler
app.use(function (req, res, next) {
  next(createError(404));
});

// error handler
app.use(function (err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render("error");
});
module.exports = app;

login成功後は、/loginというページに遷移させる予定なので、
views/login.jade
routes/login.js
をそれぞれ追加します。

views/login.jade
extends layout

block content
  h1= title
  p Welcome to #{title}
  p login成功!
routes/login.js
var express = require("express");
var router = express.Router();

/* GET home page. */
router.get("/", function (req, res, next) {
  res.render("login", { title: "ログイン" });
});

module.exports = router;

login失敗時のページも作っておきます。
views/loginfail.jade
routes/loginfail.js

views/loginfail.jade
extends layout

block loginfail
  block content
  h1= title
  p Welcome to #{title}
  p Login失敗
routes/loginfail.js
var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  res.render('loginfail', { title: 'ログインできなかったよ' });
});

module.exports = router;

これでRPの作成は完了です。
最終的にはこんな感じのディレクトリ構成になりました。


$ ls
app.js                  node_modules            package.json            routes
bin                     package-lock.json       public                  views
$ ls views 
error.jade      index.jade      layout.jade     login.jade      loginfail.jade
$ ls routes 
index.js        login.js        loginfail.js

RPの登録

OpenAMにRPを登録します。
OpenAMにAdminでログイン後、Top Level Realm(トップレベルレルム)のApplications>OAuth2.0を選択します。
スクリーンショット 2020-08-24 21.03.48.png

エージェントの新規をクリック
スクリーンショット 2020-08-24 21.07.28.png

エージェントの名前とパスワードの入力を求められるので、
今回は下記のように入力し、作成を押します。

名前:sampleRP
パスワード:password

このパスワードは、先ほど作成したapp.js内のRP_PASSWORDにあたります。
作成を押した後、メインページに戻るので、エージェントから、先ほど作成したエージェントを選択し、設定を追加していきます。

項目 設定内容
リダイレクトURI http://localhost:3000/oauth2callback
スコープ openid, email, profole
Token Endpoint Authentication Method client_secret_post

そのほかの設定は、デフォルトのまま。

スクリーンショット 2020-08-24 21.33.22.png

スクリーンショット 2020-08-24 21.34.39.png

設定追加後、保存を押して登録完了です。

動作確認

早速RPを動かしてみます。

$ npm start

RPにアクセス!
http://localhost:3000/

SSO連携のリンクを押してみるとOpenAMのログイン画面に遷移します。
スクリーンショット 2020-08-24 20.26.52.png

初期設定で作成したアカウントのID/PWを入れてログイン!
アカウントを作成した記憶がない方はデフォルトの下記アカウントでもログインできるはずです。
ID: demo
password: changeit
ログイン後、個人情報提供の同意画面に遷移するのでAllowを選択します。

スクリーンショット 2020-08-24 20.29.13.png

ログイン成功画面に遷移しました。
スクリーンショット 2020-08-24 21.38.26.png

ターミナルにこんな感じに出力されていれば認証成功です。

スクリーンショット 2020-08-24 20.34.39.png
これでアクセストークン、IDトークンが取得できているはずなので、
この後、ユーザーの情報を取得したい場合は、OpenAMのユーザー情報エンドポイントにアクセストークンを GETで渡せば大丈夫なはずです。
参考:
https://backstage.forgerock.com/docs/am/5/oauth2-guide/#oauth2-byo-client

以上になります。

お疲れ様でした!

参考

[1] 株式会社オージス総研 テミストラクトソリューション部 氏縄 武尊."第三回 Relying Party の実装例 ~passport~".オブジェクトの広場.2016-03-10,(参照2020-08-24)
[2] Open Source Solution Technology Corporation.学認Shibboleth ShibbolethとOpenAMを連携させて学外と学内をシングルサインオン.2011
[3] ForgeRock."exampleOAuth2Clients/node-passport-openidconnect".Github.2020-3-25,(参照2020-08-24)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Express+Passportで簡単にOpenID ConnectのRPを作成してみた

目的と前提

認証/認可について少しづつですが備忘録としてまとめようシリーズ2つめです。
前回はSAML2.0の仕様についてまとめてみました。
https://qiita.com/yuna-s/items/8aa318ca5426c3d9c7e6

今回は、Nodejsを使ったRPの作成[1]です。
OpenID Connectのアクセストークン取得まで実装しています。
(UserInfoを取得するところは実装していません)

IdPの作成にはオープンソースソフトのOpenAM[2]を使用しています。

認証/認可、基礎的なOpenID Connectの知識があることを前提としています。

環境

macOS Catalina v10.15.5
OpenAM 14.5.1 Build d8b8db3cac (2020-March-11 23:25)
node v13.13.0

利用モジュール
"dependencies": {
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"express": "~4.16.1",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"morgan": "~1.9.1",
"passport-openidconnect": "0.0.2"
}

OpenAMの起動と初期設定

IdPはDockerで提供されているOpenAMを利用して作成します。
OpenAMのイメージはDockerHubよりゲットできます。

$ docker pull openidentityplatform/openam
$ docker run -h openam-01.domain.com -p 8080:8080 --name openam-01 openidentityplatform/openam

これでOpenAMが起動したはずです。

念のため、起動しているか確認してみます。
下記のような表示が出れば、問題なく起動できています。

$ docker container ls
CONTAINER ID        IMAGE                         COMMAND                  CREATED             STATUS              PORTS                    NAMES
91d60b3e3538        openidentityplatform/openam   "/usr/local/tomcat/b…"   2 hours ago         Up 2 hours          0.0.0.0:8080->8080/tcp   openam-01

それではOpenAMにアクセスしてみましょう。
http://localhost:8080/openam

初回起動では設定事項がいろいろあるので、私は、OpenAMコンソーシアムの資料を参考に設定しました。
※設定オプションは、カスタム設定ではなく、デフォルト設定を選択しました。
https://www.openam.jp/wp-content/uploads/techtips_vol1.pdf

IdPの作成

まずOpenAMにamAdminでログインします。
スクリーンショット 2020-08-24 18.51.20.png
その後、Top Level Realm(トップレベルレルム)にアクセス後、Configure OAuth Providerを選択します。

スクリーンショット 2020-08-24 19.14.17.png

Configure OpenID Connectを選択します。
スクリーンショット 2020-08-24 19.15.36.png
認可コードやアクセストークンの有効期限をチェックして、問題なければ作成を押します。
リフレッシュトークンを発行させたい場合は、リフレッシュトークンの発行にチェックを入れてください。
スクリーンショット 2020-08-24 19.19.30.png

これでIdPが作成できました〜
下記URLにアクセスしてIdPができていることを確認します。
http://localhost:8080/openam/oauth2/.well-known/openid-configuration

{"response_types_supported":["code token id_token","code","code id_token","id_token","code token","token","token id_token"],"claims_parameter_supported":false,"end_session_endpoint":"http://localhost:8080/openam/oauth2/connect/endSession","version":"3.0","check_session_iframe":"http://localhost:8080/openam/oauth2/connect/checkSession","scopes_supported":["address","phone","openid","profile","email"],"issuer":"http://localhost:8080/openam/oauth2","id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"acr_values_supported":[],"authorization_endpoint":"http://localhost:8080/openam/oauth2/authorize","userinfo_endpoint":"http://localhost:8080/openam/oauth2/userinfo","device_authorization_endpoint":"http://localhost:8080/openam/oauth2/device/code","claims_supported":["zoneinfo","address","profile","name","phone_number","given_name","locale","family_name","email"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","A128KW","RSA1_5","A256KW","dir","A192KW"],"jwks_uri":"http://localhost:8080/openam/oauth2/connect/jwk_uri","subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES384","HS256","HS512","ES256","RS256","HS384","ES512"],"registration_endpoint":"http://localhost:8080/openam/oauth2/connect/register","token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],"token_endpoint":"http://localhost:8080/openam/oauth2/access_token"}

こんな風に表示されればOK!

RPの作成

初期設定

express公式サイトのGetting startedに従って、まずサンプルのWebアプリケーションを作成します。

$ mkdir myapp
$ npx express-generator

こんな感じのディレクトリ構成になっているはず

$ ls
app.js                  node_modules            package.json            routes
bin                     package-lock.json       public                  views

実装

実装はForgeRockのOpenIDConnectのサンプルRP[3]を参考にしながら作成していきます。(非常に分かり易かったのでオススメ!)

SSO連携というリンクをクリックすると、
OpenIDConnectのフローが開始されるようにしていきます。
見た目はこんな感じ
スクリーンショット 2020-08-24 20.08.01.png

viewsにリンクのボタンを加えます。

views/index.jade
extends layout

block content
  h1= title
  p Welcome to #{title}

  hoge-button
    a(href="http://localhost:3000/auth/openidconnect", target="_blank") SSO連携

routes/index.js
var express = require("express");
var router = express.Router();

/* GET home page. */
router.get("/", function (req, res, next) {
  res.render("index", { title: "Express" });
});

module.exports = router;

コントローラー(app.js)にロジックを直接追加しちゃいます。
気になる方は分けていただいても問題ないです。

app.js
// 参考:https://github.com/ForgeRock/exampleOAuth2Clients/tree/master/node-passport-openidconnect

// 各モジュールをインポート
var createError = require("http-errors");
var express = require("express");
var path = require("path");
// sessionを使うのに求められる
var cookieParser = require("cookie-parser");
var logger = require("morgan");

// pathを定義
// indexにログインボタンを設置
// ログイン失敗時 → loginfail
// ログイン成功時 → login
// に遷移するようにする
var indexRouter = require("./routes/index");
var loginFRouter = require("./routes/loginfail");
var loginRouter = require("./routes/login");

var app = express();

//session有効
var session = require("express-session");
app.use(
  session({
    //クッキー改ざん検証用ID
    secret: "YOUR_PASSWORD",
    //未初期化のセッションを保存するか
    saveUninitialized: false,
    //他にもsessionの寿命とか、httpsならsecureも設定できる
  })
);

// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");

app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));

app.use("/", indexRouter);

//追記ここから
app.use("/loginfail", loginFRouter);
app.use("/login", loginRouter);

//認証セクション
var passport = require("passport");
const { token } = require("morgan");
var OpenidConnectStrategy = require("passport-openidconnect").Strategy;

app.use(passport.initialize());
app.use(passport.session());

passport.use(
  new OpenidConnectStrategy(
    {
      issuer: "http://localhost:8080/openam/oauth2",
      authorizationURL: "http://localhost:8080/openam/oauth2/authorize",
      tokenURL: "http://localhost:8080/openam/oauth2/access_token",
      userInfoURL: "http://localhost:8080/openam/oauth2/userinfo",
      clientID: "sampleRP",
      clientSecret: "RP_PASSWORD",
      callbackURL: "http://localhost:3000/oauth2callback",
      scope: ["openid", "email", "profile"],
    },
    function (
      issuer,
      sub,
      profile,
      jwtClaims,
      accessToken,
      refreshToken,
      tokenResponse,
      done
    ) {
      //認証成功したらこの関数が実行される
      //ここでID tokenの検証を行う
      console.log("issuer: ", issuer);
      console.log("sub: ", sub);
      console.log("profile: ", profile);
      console.log("jwtClaims: ", jwtClaims);
      console.log("accessToken: ", accessToken);
      console.log("refreshToken: ", refreshToken);
      console.log("tokenResponse: ", tokenResponse);

      return done(null, {
        profile: profile,
        accessToken: {
          token: accessToken,
          scope: tokenResponse.scope,
          token_type: tokenResponse.token_type,
          expires_in: tokenResponse.expires_in,
        },
        idToken: {
          token: tokenResponse.id_token,
          claims: jwtClaims,
        },
      });
    }
  )
);

passport.serializeUser(function (user, done) {
  //userにはprofileが入る
  done(null, user);
});

passport.deserializeUser(function (obj, done) {
  done(null, obj);
});

app.get("/auth/openidconnect", passport.authenticate("openidconnect"));

app.get(
  "/oauth2callback",
  passport.authenticate("openidconnect", {
    failureRedirect: "/loginfail",
  }),
  function (req, res) {
    // Successful authentication, redirect home.
    console.log("認可コード:" + req.query.code);
    req.session.user = req.session.passport.user.displayName;
    res.redirect("/login");
  }
);
//ここまで

// catch 404 and forward to error handler
app.use(function (req, res, next) {
  next(createError(404));
});

// error handler
app.use(function (err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render("error");
});
module.exports = app;

login成功後は、/loginというページに遷移させる予定なので、
views/login.jade
routes/login.js
をそれぞれ追加します。

views/login.jade
extends layout

block content
  h1= title
  p Welcome to #{title}
  p login成功!
routes/login.js
var express = require("express");
var router = express.Router();

/* GET home page. */
router.get("/", function (req, res, next) {
  res.render("login", { title: "ログイン" });
});

module.exports = router;

login失敗時のページも作っておきます。
views/loginfail.jade
routes/loginfail.js

views/loginfail.jade
extends layout

block loginfail
  block content
  h1= title
  p Welcome to #{title}
  p Login失敗
routes/loginfail.js
var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  res.render('loginfail', { title: 'ログインできなかったよ' });
});

module.exports = router;

これでRPの作成は完了です。
最終的にはこんな感じのディレクトリ構成になりました。


$ ls
app.js                  node_modules            package.json            routes
bin                     package-lock.json       public                  views
$ ls views 
error.jade      index.jade      layout.jade     login.jade      loginfail.jade
$ ls routes 
index.js        login.js        loginfail.js

RPの登録

OpenAMにRPを登録します。
OpenAMにAdminでログイン後、Top Level Realm(トップレベルレルム)のApplications>OAuth2.0を選択します。
スクリーンショット 2020-08-24 21.03.48.png

エージェントの新規をクリック
スクリーンショット 2020-08-24 21.07.28.png

エージェントの名前とパスワードの入力を求められるので、
今回は下記のように入力し、作成を押します。

名前:sampleRP
パスワード:password

このパスワードは、先ほど作成したapp.js内のRP_PASSWORDにあたります。
作成を押した後、メインページに戻るので、エージェントから、先ほど作成したエージェントを選択し、設定を追加していきます。

項目 設定内容
リダイレクトURI http://localhost:3000/oauth2callback
スコープ openid, email, profole
Token Endpoint Authentication Method client_secret_post

そのほかの設定は、デフォルトのまま。

スクリーンショット 2020-08-24 21.33.22.png

スクリーンショット 2020-08-24 21.34.39.png

設定追加後、保存を押して登録完了です。

動作確認

app.js内のRP_PASSWORDをpasswordに、YOUR_PASSWORDを好きな文字列に変更して、早速RPを動かしてみます。

$ npm start

RPにアクセス!
http://localhost:3000/

SSO連携のリンクを押してみるとOpenAMのログイン画面に遷移します。
スクリーンショット 2020-08-24 20.26.52.png

初期設定で作成したアカウントのID/PWを入れてログイン!
アカウントを作成した記憶がない方はデフォルトの下記アカウントでもログインできるはずです。
ID: demo
password: changeit
ログイン後、個人情報提供の同意画面に遷移するのでAllowを選択します。

スクリーンショット 2020-08-24 20.29.13.png

ログイン成功画面に遷移しました。
スクリーンショット 2020-08-24 21.38.26.png

ターミナルにこんな感じに出力されていれば認証成功です。

スクリーンショット 2020-08-24 20.34.39.png
これでアクセストークン、IDトークンが取得できているはずなので、
この後、ユーザーの情報を取得したい場合は、OpenAMのユーザー情報エンドポイントにアクセストークンを GETで渡せば大丈夫なはずです。
参考:
https://backstage.forgerock.com/docs/am/5/oauth2-guide/#oauth2-byo-client

以上になります。

お疲れ様でした!

参考文献

[1] 株式会社オージス総研 テミストラクトソリューション部 氏縄 武尊."第三回 Relying Party の実装例 ~passport~".オブジェクトの広場.2016-03-10,(参照2020-08-24)
[2] Open Source Solution Technology Corporation.学認Shibboleth ShibbolethとOpenAMを連携させて学外と学内をシングルサインオン.2011
[3] ForgeRock."exampleOAuth2Clients/node-passport-openidconnect".Github.2020-3-25,(参照2020-08-24)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

esbuildがwebpackより187倍早いらしいので環境構築しよう

はじめに

久しぶりの投稿になります。
今回は以下の記事で、esbuidがすごい!!という話を聞きつけこの記事を書くことにしました。
参考: [Web フロントエンド] esbuild が爆速すぎて webpack / Rollup にはもう戻れない

どのくらいすごいのでしょうか?
参考に挙げている記事によると

esbuild は Go 言語で書かれた JavaScript および TypeScript のビルドツールです。 esbuild 単体でトランスパイル + バンドル + ミニファイできます。 JSX / TSX もサポートされています。そしてめっちゃくちゃ速いという触れ込みです。最初から速度を意識して無駄がないように書かれており、構文解析・出力・ソースマップ生成は並列化され、ネイティブコードで動作します。公式の README では three.js のビルドが Rollup + terser より 100 倍速い と謳っています。
https://www.kabuku.co.jp/developers/ultrafast-tsx-build-tool-esbuild

とのことです。
なるほどなるほどと。最近わたしもWebpackのBuild遅いなあと思っていたのでこの情報を鵜呑みにして、esbuildの環境構築をしたくなりました。

また、ドキュメントによるとesbuildはwebpackの187倍もBuildが早いみたいです。

esbuildの環境構築

環境構築についてですが、だいたいのモジュールバンドラにはコンフィグファイルがつきものです。esbuildのgithubを参考にしましたが、ワンライナーのCL上での実行例のみでコンフィグファイルらしいものの書き方見当たりません。
少し詳細に調べたところ、
https://github.com/evanw/esbuild/issues/39
以下に書いてありました。
ふむふむ、一応、対応はしているみたいです。

前準備

今回は、React+TypeScriptで記述されたプロジェクトを対象にBuildを行う環境構築をします。Vueについては、viteというモジュールバンドラがesbuildを利用して、Buildを行っているため、そちらを利用してくださいとのことです。

まずは以下の通りにnpmモジュールをインストールします。
npm install -D esbuild @types/node

Reactのプロジェクトは後ほど説明する注意点に気をつければなんでも大丈夫です。
わたしのgithubのプロジェクトを例に説明します。
https://github.com/olt556/esbuild-tmp

Buildスクリプトの作成

まず最低限、以下の条件を実現したいです。

  • developmentとproductionの環境でBuildを分ける
  • エントリーポイントの指定
  • Build後に出力されるESの規格の指定
  • プラットフォームの指定(node, browser)
  • production時にはminifyをかける
  • 出力先ディレクトリの指定
  • tsconfig.jsonの読み込み

以上の条件を元に作成した、esbuildのBuildファイルは以下の通りです。

build.ts
const { argv } = require('process')
const { build } = require('esbuild')
const path = require('path')
// optionsの定義
const options = {
  // 以下のdefineプロパティを設定しない場合Reactのプロジェクトの実行時にエラーが出ます
  define: { 'process.env.NODE_ENV': process.env.NODE_ENV },
  entryPoints: [path.resolve(__dirname, 'src/Index.tsx')],
  minify: argv[2] === 'production',
  bundle: true,
  target: 'es2016',
  platform: 'browser',
  outdir: path.resolve(__dirname, 'dist'),
  tsconfig: path.resolve(__dirname, 'tsconfig.json')
}
// Buildの実行
build(options).catch(err => {
  process.stderr.write(err.stderr)
  process.exit(1)
})

上記のファイルを以下のnodeコマンドで実行することでBuild可能です。
node build.ts production/development

npmスクリプトにして実行しやすくするのもいいかもしれませんね。

実装時の注意点(2020/08/24)

現時点でesbuildを利用する際には、以下のような注意点があります。

  • TypeScriptのトランスパイル時の型チェックに対応していません
  • css-modulesに対応していません
  • pluginsに対応していないので、各種loaderの読み込みや詳細な設定をすることはできません

おわりに

webpackesbuildに置き換えることによって、Reactを導入しているプロジェクトがCSSフレームワークや、CSS in JSを利用している場合、かなりの効果を発揮するかもしれませんね!!

もし何か質問やご指摘などありましたらお願いします!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

npm install --save firebase で発生したエラーの回避法

記事執筆時点で安定版のfirebase 6.2.4をインストールを試みたところ以下のエラーが出ました。

$ npm install --save firebase@6.2.6
:
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! grpc@1.20.3 install: `node-pre-gyp install --fallback-to-build --library=static_library`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the grpc@1.20.3 install script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     /Users/yokoyamaryouta/.npm/_logs/2020-05-10T05_49_16_325Z-debug.log

結論

安定版で実行したらインストールできました。以下から確認&試してみて下さい。

$ node -v
v14.8.0 //筆者の場合

こちら から確認&インストールできます。
安定版インストール後に再度実行したらいけました。

$ node -v
v12.18.3 //執筆時
$ npm install --save firebase@6.2.6
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JSON.stringifyで`TypeError: Converting circular structure to JSON`というエラーが出た時の対処法

オブジェクトをJSON.stringifyしたときに、循環参照が含まれていると以下のようなエラーが出ます。

コード
const a = { a1: "test" };
a.aa = a;
JSON.stringify(a);
実行結果
JSON.stringify(a);
     ^

TypeError: Converting circular structure to JSON
    --> starting at object with constructor 'Object'
    --- property 'aa' closes the circle
    at JSON.stringify (<anonymous>)

対処法

json-cyclicを使えば解決します。

json-cyclic - npm
https://www.npmjs.com/package/json-cyclic

npm install json-cyclic
コード
const { decycle, encycle } = require('json-cyclic');

const a = { a1: "test" };
a.aa = a;
console.log(JSON.stringify(decycle(a)));
実行結果
{"a1":"test","aa":{"$ref":"$"}}

json-cyclicは、内部で循環参照を検出したとき、復元ができるようにタグ付けを行い、参照を削除する仕組みです。

オブジェクトを復元したい場合は以下のようにencycleを使います。

コード
const { decycle, encycle } = require('json-cyclic');

const a = {a1: "test"};
a.aa = a;
const json = JSON.stringify(decycle(a));

const obj = JSON.parse(json);
const b = encycle(obj);
console.log(b);
console.log(b.aa.aa.aa.a1);
実行結果
{ a1: 'test', aa: [Circular] }
test

ちゃんと復元できることが確認できました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初めてLambdaとAPI Gatewayを触ってみる(まずは動かすお勉強)

こんにちは、端くれ駆け出しエンジニアの@Occhiii623です:grinning:

これから実装予定のサービスで使うことになるであろう、AWSサービスであるLambda(ラムダ)API Gatewayをまず触ってどんなものなのか知ろうとなりました。

そこで、この記事は私自身のアウトプットがいち目的ですが、自分のようなAWS超初心者で
「文字だけ読んでもわからんっ!とりあえず動かしてみたい!」
という方の参考になれば幸いです。
Qiita初投稿なので、お見苦しい点ご容赦ください:bow_tone1:

ちなみに私はAWSのEC2ぐらいしか使ったことのない超初心者です。

そもそもLambdaって何?

Amazon公式ページによると・・・

AWS Lambda はサーバーをプロビジョニングしたり管理する必要なくコードを実行できるコンピューティングサービスです。 AWS Lambda は必要時にのみてコードを実行し、1 日あたり数個のリクエストから 1 秒あたり数千のリクエストまで自動的にスケーリングします。使用したコンピューティング時間に対してのみお支払いいただきます。

これだけじゃピンとこなかったんですが、要はコードが実行される時だけコンテナを作って、サーバー稼働させるというように、常にサーバーが稼働しているわけではないんですよね。プログラムが実行される時だけ稼働します。

なので、Lambdaを利用するメリットとしては・・・

  • AWSのような使う分だけのコストがかかる従量課金の料金体系だと、常にサーバーを稼働させているEC2のインスタンスよりも費用が安くなる。
  • サーバーの構築や保守などの管理はAWSがやってくれ、利用者はサーバーの管理が一切不要になる(サーバー管理レス=サーバーレス)ため、メンテナンスを行う人的リソースも抑えられる(人件費もかなりお高いですからね)

:shamrock:とてもわかりやすくLambdaについて解説してくれている動画がありました。
【Schoo】AWS Lambdaを活用したサーバレス実践 -第1回- | 大澤 文孝 先生

ここの動画でも解説されているように、デメリットもあって
どれだけアクセスがあるかによって料金が変わってくるので、コスト見積もりがしにくいです。

それでもAWSの他サービスと連携できるメリットもあって、利用する恩恵の方が大きいと感じました。

Lambraを動かしてみよう

実際私がやってみた以下手順を追っていきたいと思います。

  1. 簡単なLambra関数を作ってみる
  2. Amazon API Gatewayと連携させてブラウザからLambda関数を呼び出してみる

簡単なLambra関数を作ってみる

コンソールから、サービス→Lambdaを選択して開きます。
そうすると、下記のような画面になります。
eb0373e795545f944ac5efdbdb52a343.png

すでに作ってあるやつがありますが、右上の関数の作成を押します。
5a258925bcc3757ff7f48b48e19b0e29.png
関数の作成画面になるので、左の一から作成を選択して下の基本的な情報を入力します。

関数名は、今回は「HelloWorldTest」とでもしておきます。
言語をいくつか選択できますが、今回はNode.jsを選択します。
アクセス権限については、既存のものを選択するか新しく作ることもできます。今回はテスト動作だけなので、「基本的な Lambda アクセス権限で新しいロールを作成」で進めます。

右下の関数の作成を押すとこんな画面になります。
30eea395008d3c422e0b037464c4d123.png

関数コードというところに、すでにHello Worldを出力するプログラムが書いてあります。
右上のテストというボタンをクリックして、適当な名前をつけて保存→再度テストのボタンをクリックするとプログラムが無事成功した表示とともに、コードを実行後の結果が出てきました。

# 実行結果
{
  "statusCode": 200,
  "body": "\"Hello from Lambda!\""
}

すでに書いてあるコードをコンソール上で編集することもできるのですが、あえてローカルでコードを作成し、ファイルをzip化して読み込んでみます。

簡単なコードを書きました。Hello World!をコンソール上で出すだけのメソッドと、戻り値としてaaaの中身を返すコードを作りました。

index.js
exports.handler = async (event) => { 
// 冒頭のexports~は、JSでいうimportと同じように
// Lambraで以下コードを読み込むために必要なLambda関数内のメソッド
    function hoge() {
        console.log("Hello World!");
    };
    const aaa = {
        test: "testです",
        name: "Mayumi",
        age: 31,
    }
    hoge();
    return aaa;
};

できたら、zip化します。

$ zip HelloWorldTest(←zipファイル名は適当) index.js

zipファイルができたら、コンソール上で読み込みます。

読み込めたら、index.jsの中身が変わってアップロードした内容に変わっていると思います。
右上のテストを実行すると、、、
8963dfd75f4f018b472190bcb2a4db93.png
無事動きました^^

Amazon API Gatewayと連携させてブラウザからLambda関数を呼び出してみる

最初はHTTP APIで作ったAPIをトリガーにやったのですが、この記事ではREST APIでやってみます。

▼こちらを参考にしました
AWS Lambdaで簡単なREST APIを作ってみた

APIとは???ってところからわからずでしたが・・・^^;
わかりやすく教わったのでまたそれは別記事でまとめたいと思います!

HTTP APIとREST APIの違いについては、こちらの記事がわかりやすかったです。
0からREST APIについて調べてみた

▼では手順
Lambda画面の+トリガーを追加からでも設定はできるのですがサービス検索からAPI Gatewayに飛んで新しいAPIを作成します。
c38796edad399041adc35d0b5acad3ed.png
右下のAPIの作成ボタンを押します。
そうすると下のようなページになるので、プルダウンのアクションから今回は特にデータを登録したり、更新したりするわけではないので、GETを選択します。
8dc3cecd7b31d6187114f90cdd58a81e.png

Lambda関数は、先ほど作ったものを選択して保存します。
保存ボタンを押すと、Lambra関数にアクセスする権限を与えますかといった表示されるので「はい」を押します。
※でないと、権限がなくAPI GatewayからLambra関数にアクセスできません。
39763bfaba329aebbe659894c7b46a6c.png

このままだとエンドポイントがない状態みたいなので、統合リクエストから細かい設定をします。
(参考記事どおりに行いました)

そしたら、最後にプルダウンのアクションから、APIのデプロイを選択して新しいステージを作成。
URLが発行されたら、アクセスしてみます!
0f979605424d38a623e6bd63247c5afd.png

Lambda関数の呼び出しができました!
Lambdaの画面に戻ると、API Gatewayがトリガーとして設定されていることが再確認できます。
4a734648746d40a604abb8015f1ed71e.png
これで、基本の「き」な操作ができました!

複数のJSファイルをwebpackで1つにしてLambra関数で動かしてみる

次のステップとして「複数のJSファイルをwebpackで1つのファイルにしてLambra関数で動かす」ってこともやりました。
普通のアプリケーションであれば、一つのリポジトリで管理して、起動時に実行環境でコードをまとめて読み込むってことをしますが、lamnda関数を使う場合にはアプリケーションの機能ごとに実行環境が分かれるので、コードごとにリポジトリを分けるような管理になる難しさがあるようです。

そこで、このwebpackの出番だ!というわけです。

webpackとは?

JSファイルをまとめる、高機能モジュールバンドラーです。(知らなかった)
モジュールは、一つひとつのJSファイルのことを指していてそれをバンドル(=まとめる)するのがwebpackです。

webpackを使ってファイルを一つにすることでどんないいことがあるのか?

  • ブラウザからリクエストが飛んだ時に複数のファイルだと接続する分時間がかかるが、これを一つにすることで読み込み速度が早くなる
  • 一つにまとめる時にes6やVue.js, TypeScript等で書かれたJSファイルをトランスパイルしてブラウザで実行可能なJavaScriptファイルへと変換してくれる(トランスパイルは、別言語を共通語にしてくれるようなものです)

なんだかとてもスグレものです。

導入にするには、Node.jsのバージョン13以上が必要です。
(それ12以下であれば、バージョンを更新してください)
私の場合、Ubuntu環境で行っています。

▼導入手順
1.まずローカルにディレクトリを作り、そこにJSファイルを2つ作成します。

(例)
myProjectフォルダ
├─index.js
└─service.js

二つのファイルの中身を書いてしまいます。
webpackビルド時に読み込むファイルは、このindex.jsにするので別ファイルのservice.jsをimportで読み込むようにします。

index.js
import { aaa } from "./service.js"
// service.jsの変数を、ここで変数hogeに代入
const hoge = aaa

exports.handler = async (event) => {
//Lambda関数内でイベント処理するのに必要なメソッドを書いておきます
console.log(hoge);
return hoge;
};
service.js
export const aaa = {
    test: "testです",
    name: "ohnishi",
    age: 28,
}

このまま実行しても、importexportという書き方はNode.jsに対応していないのでうまく動作しません。そこを、webpackでトランスパイルすることで実行できるようになるため、あえてこのままにしておきます。

2.できたらフォルダに移動

$ cd /Users/myName/Document/myProject

3.packege.jsonファイルを生成します。
-yをつけているのは、作成時に聞かれる質問をスキップするためです。

$ npm init -y

そうすると、ディレクトリ内にpackege.jsonというファイルができていると思います。

package.jsonとは?

package.jsonファイルは、Node.jsベースのJavaScriptアプリ開発において、npmでパッケージ(各種フレームワークやライブラリ)を管理するために使われる構成ファイル。

ITMediaより抜粋

私はRuby on Railsを触っていたので、それになぞると・・・

  • npm = RailsでいうGem(ジェム)
  • package.json = RailsでいうGemfile

こんな感じに関連づけられたら、腹落ちしました。
世の中には、ライブラリという便利な道具箱がたくさんあるので、それをnpmで管理して、自分のアプリケーションに入れるライブラリを管理するのにpackage.jsonがあるという感じです。

話が脱線しましたが、package.jsonが生成できたらwebpackをインストールします。

4.アプリケーションのディレクトリ上で以下コマンドを実行します。

npm i -D webpack webpack-cli

これで、webpackを使う準備ができました!

webpack.config.js

webpackを利用するためにはwebpack.config.jsというファイルに設定を記述する必要があります。

5.webpack.config.jsというファイルを作成して、中身のコードを書いていきます。
書く項目はある程度決まっているようなのですが、なかなか難しいため今回は最低限動かすための内容を書きます。

webpack.config.js
const path = require('path')
const webpack = require('webpack')
module.exports = {
  mode: "development", //今回は開発モードで記述
  entry: {
    index: path.join(__dirname, 'index.js') 
//webpackビルド時に読み込むファイルを記述します。
//entry項目では、/myProject/index.jsを読み込んでねという設定をしています。
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'index.js'
//output項目では、webpackビルド時に出力するファイル名と作成フォルダ名を指定しています。
//今回は、distという名前のフォルダ内に、index.jsというファイルを作成します。
  },
  target: 'node', //'node'として、Node.jsのような環境で使用するためにコンパイルするようwebpackに指示します
  module: {
    rules: [
      {
        test: /.js$/,  //拡張子を.jsで設定しています
        loader: 'babel-loader' //.jsと設定したファイルをbabel-loaderで読み込む設定をしています
      }
    ]
  }
}

6.babel-loaderがないと、webpackビルド時にエラーが出てしまうのでインストールします。

$ npm i --save-dev @babel/core @babel/polyfill @babel/preset-env babel-loader

7.まだこのままだと「buildなんてコマンドはないよ」と怒られてしまうので、今度はpackage.jsonにタスクを設定します。

package.json
{
  "name": "myProject",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack"  →追記箇所
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.11.4",
    "@babel/polyfill": "^7.10.4",
    "@babel/preset-env": "^7.11.0",
    "babel-loader": "^8.1.0",
    "webpack": "^4.44.1",
    "webpack-cli": "^3.3.12"
  }
}

8.インストールできたら、webpackを実行します。

$ npm run start

そうすると、distフォルダが作成されて中にindex.jsというファイルができていると思います。それをLambdaで読み込めるように、zip化します。

9.AWSのコンソールでLambda画面を開いて、生成したzipファイルを読み込みます。
466a55ca31f87490667634425e9d437a.png
読み込めたら、右上のテストから実行してみると無事実行されたことが確認できると思います。
ee746b54ee018aa5becf132c960190b7.png

すでに上に記述した手順を同じように踏んでREST APIも設定してLambra関数を呼び出してみます。
f1d940d4b6963c9bbd7ca9359d3029d3.png

呼び出せてます!

複数のJSファイルを作成⇒webpack⇒Lambda関数として読み込み⇒API GatewayでブラウザからLambda関数を呼び出す

以上の一連の流れを確認することができました。

Lambdaの何もわからない状態から、なんとなくこんな風に動くんだなというイメージをつけることができたので、一つのアプリケーションを実際作る時の実践的な使い方までを知れたらまた自身の備忘録としてまとめたいと思います:relieved:

参考URL

webpack.config.js の書き方をしっかり理解しよう - Qiita
最新版で学ぶwebpack 4入門 - JavaScriptのモジュールバンドラ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ejsでインクルードをするときに、パスをルート相対URLで指定したい

process.cwd()

Node.jsのprocess.cwd()は、コマンドが実行されたディレクトリを返します。

たとえば、次のようなファイル構成だったとしましょう。

./project
└ src/
 └ *.ejs
└ package.json
└ gulpfile.js

この場合、projectディレクトリでgulpコマンドをたたくことになります。その時にprocess.cwd()が返す値はpath/to/projectになるので、次のように1つ変数化しておけばルート相対URLのような感覚でパス名を記述することができます。

<% const ROOT = `${process.cwd()}/src`; %>
<%- include(`${ROOT}/path/to/_include.ejs`) -%>
<%- include(`${ROOT}/path/to/_include.ejs`) -%>
<%- include(`${ROOT}/path/to/_include.ejs`) -%>
<%- include(`${ROOT}/path/to/_include.ejs`) -%>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[JavaScript]ESLintの設定をグローバルルールで保存する

初めに

学習用でESLintが使いたいけど、プロジェクトごとに設定ファイル(.eslintrc.*)を作成するのは面倒。
そこで、設定ファイルをグローバルルールとして保存してプロジェクトごとに作成しなくていいようにしたので、その時にやった方法を備忘録としてまとめてみました。

方法

1. ESLintのインストール

まずはESLintをグローバルインストール。

> npm -g i eslint

ここで、-gは--global、iはinstallを省略したもの。

2. ESLintの設定ファイルをグローバルとして保存

.eslintrc.jsonを作成し、ユーザーディレクトリ直下に保存する。

  • .eslintrc.jsonを保存する場所
MacOS:/ Users / {USERNAME} 
Windows:C:\ Users \ {USERNAME}
  • .eslintrc.jsonの中身
.eslint.json
{
    "extends": "eslint:recommended",
    "env": {
        "node": true,
        "es6": true
    },
    "rules": {
        "semi": [
            "error",
            "never"
        ]
    }
}

各設定の簡単な説明

  • extendsプロパティ
    共有設定を指定する。
    eslint:recommendedはESLintの推奨ルールがセットになったもの。

  • envプロパティ
    環境を指定する。

    • "node": true
      Node.js 固有の変数や構文 (requireや特殊なトップレベル スコープ等) が定義され、利用できるようになる。
    • "es6": true
      ECMAScript 2015(ES6)で追加された構文や組込みオブジェクトが利用できるようになる。
  • rulesプロパティ
    ルールを設定する。
    大体のルールはextendsプロパティで設定されるため、extendsで設定されていないルールを追加したいときや設定しているルールを省きたいときに利用する。
    また、extendsで共有設定を指定しない場合はここで一からルールを設定する。

    • semiプロパティ
      セミコロンを付けるか付けないかの設定をする。
      "error"でルールが守れてないときはエラー表示。
      "never"で文末のセミコロン;を禁止する。

3. ESLintの実行

JavaScriptのプログラムを作成してグローバルルールが通っているか確認する。
sample.jsを作成し保存。

sample.js
console.log("hello world");

sample.jsが保存されているディレクトリに移動して以下のコマンドを実行。

> eslint sample.js

グローバルルールが通っていれば以下のようなエラーが表示されるはず。

  1:27  error  Extra semicolon  semi

✖ 1 problem (1 error, 0 warnings)
  1 error and 0 warnings potentially fixable with the `--fix` option.
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Node.js] Handlebars Tips

概要

テンプレートエンジンであるHandlebarsをいろいろ触ってみた。
Handlebars自体は珍しいライブラリではなく、他に記事があるので、日本語情報が見つからなかった機能を主に紹介する。

https://handlebarsjs.com/
Handlebarsを使う際は、ここを一度は目を通すことをお勧めする。

ここでは、通常のテンプレートとしての機能は紹介しない。
環境は、AWS Lambda Node.js上で動かす事を想定している。

pertialとhelper

Handlebarsで機能を拡張する方法には、この2種類がある。

  • partial : いわゆるサブテンプレート、テンプレートに動的に他のテンプレートを埋め込む事ができる。
  • helper : 関数。テンプレートの中でヘルパ関数を実行し、その結果をtemplateに埋め込む事ができる。
{{!sample.hbs}}
{{#if hoge }}
{{ sample.sub.hbs hoge }}
{{/if}}

例えばこんな感じで、sample.hbsというテンプレートに、ある条件に合致したときのみsample.sub.hbsという別のテンプレートを埋め込む。

{{!sample.hbs}}
{{>hoge a "aaa"}}
{{/if}}
// helperes.js
const hoge = (key1, key2) => {
   return key + ':' + value;
};
Handlebars.registerHelper('hoge', hoge);

たとえばこんなことをすると、aの値と":aaa"文字列を結合した結果を出力してくれる。
こんな単純なHelper関数だけではなく、{{#if}}のようなブロックヘルパー(内部に構造を持つヘルパー)を作成することもできる。

Tips紹介

precompile

コードの中でreadFileしてコンパイルすることも可能だが、簡単にprecompileできる。

templateの場合

{{!test.hbs}}
{{a}}

たとえばあらかじめこんなテンプレートを作成しておき、

$ handlebars test.hbs -f test.hbs.js

コマンドを実行することで、jsファイルにコンパイルされる。

global.Handlebars = require('handlebars')
require('test.hbs')

const template = Handlebars.templates['test.hbs']
console.log(template({a: 1}))
// 1

プリコンパイルしたコードを読み込めば、templateを実行することができる。

  • 前もってglobalにHandlebarsを設定する必要がある。

    この情報がどこにも書かれていない。サンプルを見ると、precompileしたコードをfileとして読んで、Handlebars.templateでロードしろと言っているようにも見えるが・・・そんなことをするよりこっちのほうが簡単で便利なきがするのだが・・・(もちろん名前空間の問題を抜きにすればだが)

  • テンプレートは、Handlebars.templatesにファイル名をキーに登録される。

partialの場合

partialもtemplateと同様にプリコンパイルできる。

{{!test.hbs}}
{{test.sub.hbs a}} {{! partialにはコンテキストを渡すことができる。}}
{{!test.sub.hbs}}
{{b}}

たとえばあらかじめこんなテンプレート、サブテンプレートを作成しておき、

$ handlebars test.hbs -f test.hbs.js
$ handlebars test.sub.hbs -p -f test.sub.hbs.js

コマンドを実行することで、jsファイルにコンパイルされる。

global.Handlebars = require('handlebars')
require('test.hbs')
require('test.sub.hbs')

const template = Handlebars.templates['test.hbs']
console.log(template({a:{b:1}}))
// 1
  • partialの扱いもtemplateとほぼ同じ。プリコンパイルオプションに-pを設定するだけ。
  • partialタグがインデントされていた場合、中身も自動的にインデントされる。

global.Handlebarsについて

globalにHandlebarsをセットする方法は推奨ではない可能性もある。
とすると以下のようにする必要がある。

const Handlebars = require('handlebars');
Handlebars.partials['sample.hbs'] = Handlebars.template(fs.readFileSync('sample.hbs.js'));

ただ、せっかくprecompileしたコードをわざわざreadFileで読むのか?それとももっといい方法があるのか?

globalに設定する場合、通常はpreloadすることになるだろう。

// setup.js
global.Handlebars = require('handlebars')
// package.json
{
  "jest": {
    "verbose": true,
    "setupFilesAfterEnv": [
      "<rootDir>/setup.js"
    ]
  }
}

jestでは、これでpreloadされた。

$ node -r ./setup.js

言わずもがなだと思うが、nodeであればこれでpreloadできる。

Helperについて

helperから@rootを取得したい

helperからコンテキストの情報や@rootが欲しくなった場合、どうすればいいだろうか?

// helpers.js

const getRoot = (key, _) => {
   return _.data.root[key];
};
Handlebars.registerHelper('getRoot', getRoot);
{{!test.hbs}}
{{#with a}}
   {{b}}
   {{getRoot "c"}}
{{/with}}

ヘルパー関数の通常の引数の後ろに、handlebarsの環境情報が全て格納されているからここから取得できる。

この情報も見つける事ができなかった。APIドキュメントにも記載はなかった。
重要な情報だと思うのだが・・・

ちなみに、現在のコンテキストだけならもっと簡単に取れる。

// helpers.js

const getValue = function(key){
   return this[key];
};
Handlebars.registerHelper('getValue', getValue);

ラムダ式ではなくfunctionで関数定義すれば、thisでコンテキストを参照できる。

helperの一括登録

// helpers.js
const helper1 = () => {...}
const helper2 = () => {...}

Handlebars.registerHelper({ helper1, helper2 });

複数のHelper関数を一括で登録できる。

Helperのテスト

helperに対して単体テストを書く場合、

// helpers.test.js
// must preload global.Handlebars
require('helpers.js')
const _ = Handlebars.compile;

describe('test helpers', () => {
  test('getRoot', () => {
    const template = _('{{#with a}}{{b}}{{getRoot "c"}}{{/with}}');
    expect(template({a:{b:1},c:2})).toEqual('12');
  });
});

こんな感じで、小さなテンプレートを使ってテストを書いていけばいい。
巨大なproductionテンプレートを使ってhelperのテストを書こうとすると、膨大な量のテストになるだろう。
単体テストをしよう。

結論

とりあえず上記により、Handlebarsを使ってやりたかった事はできました。
ただし、正直これが正しい作法かどうかはわかりません。ご意見があれば下さい。

もっと言えば、もうtemplateはReactあたりで書いて、jsx ==> jsにプリコンパイルすればいいんじゃないかという気もしなくもありません。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Node.jsでOSの環境変数を読み込む

yuta:~ $ node
> process.env
{ SHELL: '/bin/bash',
  SESSION_MANAGER:
   'local/CARMILLA:@/tmp/.ICE-unix/1792,unix/CARMILLA:/tmp/.ICE-unix/1792',
  QT_ACCESSIBILITY: '1',

実行

app.js
console.log(process.env.USER)
yuta:~ $ node app.js 
yuta
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

コロナで危険度の高い人を見つけたらイ〇ジンをぶっ掛けて治療するアプリ作ってみた

はじめに

この記事は chillSAP 夏の自由研究2020 の記事として執筆しています。

猛暑が続いておりますが、皆さまいかがお過ごしでしょうか。
今年はコロナの影響で、日々言い知れぬ不安に苛まれることも多いのではないでしょうか。
この状況下で発熱してしまった方のご心境は察するに余りあります。
このような方の症状を一刻でも早く緩和させるため、今回は特に治療の緊急度の高いご高齢&高熱の方を判定してイ〇ジンをかけて治療を行うアプリを開発しました。
(注:本記事はコロナウイルスに対する治療法を提案するものでもなければ、イ〇ジンの効果を証明するものでも無いことをご承知おきください。また、ご気分を害された方がいらっしゃったら誠に申し訳ございません。)

概要

コロナアーキ.png

Raspberry Piに接続されたカメラモジュール及びサーマルカメラモジュールにより、測定者の年齢を及び体温を推測します(①、②)。
推定年齢及び体温が基準値を超えた場合は危険性ありと判断し、サーボモータを介して水鉄砲からイソジンを発射します(③)。
またイソジンが足りなくなると困るので、同時にSAP Cloud Platform上のNode.jsアプリケーションを経由して(④)、SAP S/4HANA上の購買依頼伝票作成ODataサービスから購買依頼伝票を作成して購入を進めます(⑤)。

年齢推定や体温測定の結果はRaspberry Pi上でこんな感じで表示されます。
テスト_顔ペイント前.gif

必要なもの

製品 補足
Raspberry Pi 4 model:B
サーマルカメラモジュール (MLX90640)
Raspberry Pi Camera V2
サーボモータ(MG996R) それなりの力が出ないとと水鉄砲のトリガーを引けません
水鉄砲
イ〇ジン(ポピドンヨード入り)  キッコーマン醤油
SAP S/4 HANA 1709    オンプレミスver

実装手順

1. Raspberry Piへの各種モジュールの接続

今回はカメラモジュール、サーマルカメラモジュール、サーボモータをRaspberry Piに接続します。
またイソジン発射用の水鉄砲を適当な台に取り付け、サーボモータと接続します。
全体.jpg

Raspberry Piへの各種モジュールの接続は以下の端子及びGPIOピンを使用しています。
カメラモジュール:ラズパイのカメラ用端子
サーマルカメラモジュール:SDAをGPIO 2ピン、SCLをGPIO 3ピン
サーボモータ:GPIO 18ピン

サーボモータと水鉄砲との接続は強めの糸で行い、サーボモータが半回転したときにトリガーが引かれるようにしておきます。
水鉄砲のトリガーは基本的に結構固く、生半可な接続だと糸がずれてしまったりトリガーでは無く水鉄砲やサーボモータ自体が動いてしまうので、しっかり固定するようにしましょう。
サーボ+水鉄砲

また、水鉄砲の中には今回の主役であるキッコーマン醤油イ〇ジン(ポピドンヨード入り)を充填しておきましょう。
イ〇ジン

2. Pythonアプリの実装

Raspberry Piに接続した各種モジュールはPythonで記述したスクリプトにより制御します。
Pythonアプリの構成は以下の通りです。

.
├── face
│    └── face.py
├── httpclient
│    └── purchase_create.py
├── templates
│    └── index.html
├── thermal_camera
│    └── thermal_camera.py
├── watergun
│    └── watergun.py
└── camera_app.py

複数のカメラからの動画を別々のウィンドウに表示するのであればopencvのimshowメソッドを使用するだけでOKなのですが、今回は1画面内で(かつブラウザからサーバにアクセスして)複数の動画を表示したかったため、Python用の軽量WebアプリケーションフレームワークであるFlaskを利用しています。

まずエンドポイントに対してリクエストを送った際に表示される画面をindex.html内に記載します。

index.html
<html>
  <head>
    <title>Video Streaming Demonstration</title>
  </head>
  <body>
    <img src="{{ url_for('video_feed1') }}">
    <img src="{{ url_for('video_feed2') }}">

  </body>
</html>

index.html 内のimgタグで動画(厳密にはカメラモジュールで撮影した動画の1フレーム)を表示するようになっています。
ソースとなる動画は、url_forで指定したルート名と同一のルートをcamera_app.pyから探して取得しています。

camera_app.py
#!/usr/bin/env python
import os
from flask import Flask, render_template, Response
import adafruit_mlx90640
import time

from thermal_camera.thermal_camera import ThermalCamera
from face.face import Face
from httpclient.purchase_create import HttpClient 
from watergun.watergun import Watergun

app = Flask(__name__)

age = 0
temperature = 0
last_posted_time = time.time()

@app.route('/')
def index():
    """Video streaming home page."""
    return render_template('index.html')


def generate(camera, value):
    """Video streaming generator function."""
    while True:
        #画像のバイナリデータを取得
        frame = camera.get_frame()

        #推定年齢と体温を取得する
        global age
        global temperature
        global last_posted_time

        if value == 'face':
            age = frame[1]
        elif value == 'temperature':
            temperature = frame[1]

        #推定年齢が40歳以上、体温が36℃であり、前回連携時間から5秒以上が経過している場合
        if age >= 40 and temperature >= 36 and (time.time() - last_posted_time) >=5:
            last_posted_time = time.time()

            #水鉄砲を発射
            Watergun.shoot()

            #購買依頼伝票を作成
            HttpClient.createPurchaseReq()


        yield (b'--frame\r\n'
               b'Content-Type: image/jpeg\r\n\r\n' + frame[0] + b'\r\n')


@app.route('/video_feed1')
def video_feed1():
    """Video streaming route. Put this in the src attribute of an img tag."""
    return Response(generate(Face(),'face'),
                    mimetype='multipart/x-mixed-replace; boundary=frame')


@app.route('/video_feed2')
def video_feed2():
    """Video streaming route. Put this in the src attribute of an img tag."""
    return Response(generate(ThermalCamera(), 'temperature'),
                    mimetype='multipart/x-mixed-replace; boundary=frame')

if __name__ == '__main__':
    app.run(host='0.0.0.0', threaded=True, port=5000)

camera_app.pyでは、①Flaskアプリを起動、②顔認識プログラムからの画像及び推定年齢取得、③体温測定プログラムからの画像及び体温取得、④水鉄砲からのイ〇ジン噴射、⑤購買依頼伝票作成用APIへのリクエスト送信を行っています。
②③についてですが、バイト型の動画フレームをyieldで逐次出力しており、その際mimetypeをmultipart/x-mixed-replaceと指定することでサーバが紙芝居的にレンダリングするようにしています。

thermal_camera.pyではサーマルカメラモジュールからバイナリイメージを取得し、温度の情報と共に呼び出し元に返す実装がなされています。

thermal_camera.py
#!/usr/bin/env python3
import cv2
import time
from PIL import Image, ImageDraw, ImageFont
import os
import board
import busio
import adafruit_mlx90640
import math
import numpy

class ThermalCamera:

    # some utility functions
    def constrain(self, val, min_val, max_val):
        return min(max_val, max(min_val, val))


    def map_value(self, x, in_min, in_max, out_min, out_max):
        return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min


    def gaussian(self, x, a, b, c, d=0):
        return a * math.exp(-((x - b) ** 2) / (2 * c ** 2)) + d


    def gradient(self, x, width, cmap, spread=1):
        width = float(width)
        r = sum(
            [self.gaussian(x, p[1][0], p[0] * width, width / (spread * len(cmap))) for p in cmap]
        )
        g = sum(
            [self.gaussian(x, p[1][1], p[0] * width, width / (spread * len(cmap))) for p in cmap]
        )
        b = sum(
            [self.gaussian(x, p[1][2], p[0] * width, width / (spread * len(cmap))) for p in cmap]
        )
        r = int(self.constrain(r * 255, 0, 255))
        g = int(self.constrain(g * 255, 0, 255))
        b = int(self.constrain(b * 255, 0, 255))
        return r, g, b

    def get_frame(self):
        while True:
            INTERPOLATE = 10

            # MUST set I2C freq to 1MHz in /boot/config.txt
            i2c = busio.I2C(board.SCL, board.SDA)

            # low range of the sensor (this will be black on the screen)
            MINTEMP = 20.0
            # high range of the sensor (this will be white on the screen)
            MAXTEMP = 45.0

            # the list of colors we can choose from
            heatmap = (
                (0.0, (0, 0, 0)),
                (0.20, (0, 0, 0.5)),
                (0.40, (0, 0.5, 0)),
                (0.60, (0.5, 0, 0)),
                (0.80, (0.75, 0.75, 0)),
                (0.90, (1.0, 0.75, 0)),
                (1.00, (1.0, 1.0, 1.0)),
            )

            # how many color values we can have
            COLORDEPTH = 1000

            colormap = [0] * COLORDEPTH

            for i in range(COLORDEPTH):
                colormap[i] = self.gradient(i, COLORDEPTH, heatmap)

            # initialize the sensor
            mlx = adafruit_mlx90640.MLX90640(i2c)
            mlx.refresh_rate = adafruit_mlx90640.RefreshRate.REFRESH_2_HZ

            frame = [0] * 768

            try:
                mlx.getFrame(frame)
            except ValueError:
                continue  # these happen, no biggie - retry

            pixels = [0] * 768
            for i, pixel in enumerate(frame):
                coloridx = self.map_value(pixel, MINTEMP, MAXTEMP, 0, COLORDEPTH - 1)
                coloridx = int(self.constrain(coloridx, 0, COLORDEPTH - 1))
                pixels[i] = colormap[coloridx]

            for h in range(24):
                for w in range(32):
                    pixel = pixels[h * 32 + w]

            image = Image.new("RGB", (32, 24))
            image.putdata(pixels)            

            #numpy配列(RGBA) <- PILイメージ
            img_numpy = numpy.asarray(image)
            #numpy配列(BGR) <- numpy配列(RGBA)
            img_numpy_bgr = cv2.cvtColor(img_numpy, cv2.COLOR_RGBA2BGR)
            img = cv2.resize(img_numpy_bgr, (320, 240))

            #画像の中から温度の最大値を取得
            max_temperature = numpy.max(frame)

            #温度をテキストから画像に変換            
            canvasSize    = (320, 60)
            backgroundRGB = (255, 255, 255)
            textRGB       = (0, 0, 0)
            if max_temperature >= 36.0:
                textRGB = (225, 0, 0)
                backgroundRGB = (255, 255, 0)
            else:
                textRGB = (0, 0, 0)
                backgroundRGB = (255, 255, 255)
            text = "体温は" + str(max_temperature)[:4] + "℃です。"
            image_temperature  = Image.new('RGB', canvasSize, backgroundRGB)
            draw = ImageDraw.Draw(image_temperature)
            font = ImageFont.load_default()
            font = ImageFont.truetype("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", 30)
            draw.text((1, 1), text, fill=textRGB, font=font)

            #numpy配列(RGBA) <- PILイメージ
            img_numpy_temperature = numpy.asarray(image_temperature)
            #numpy配列(BGR) <- numpy配列(RGBA)
            img_temperature = cv2.cvtColor(img_numpy_temperature, cv2.COLOR_RGBA2BGR)


            #サーマル画像と温度テキスト画像を結合する
            img_concatenated = cv2.vconcat([img, img_temperature])

            # encode as a jpeg image and return it
            return cv2.imencode('.jpg', img_concatenated)[1].tobytes(), max_temperature

face.pyについては諸事情によりソースコードを非公開とさせていただきます。中身としてはカメラモジュールから取得した画像から顔認識を行い、年齢を推定した上で顔パーツをプロットしたバイナリイメージ、及び推定年齢を返す実装がなされています。

watergun.pyではサーボモータを適切な角度に動かす実装がなされており、これにより接続された水鉄砲のトリガーが引かれます。
ご使用の水鉄砲によって角度等の設定が変わってきます。

watergun.py
import pigpio
import time

class Watergun:
    def shoot():    
        gpio_pin0 = 18

        pi = pigpio.pi()
        pi.set_mode(gpio_pin0, pigpio.OUTPUT)

        # GPIO18: 50Hz、duty比2.5%
        pi.hardware_PWM(gpio_pin0,50,25000)

        time.sleep(2)

        # GPIO18: 50Hz、duty比7.25%
        pi.hardware_PWM(gpio_pin0,50,72500)

        pi.set_mode(gpio_pin0,pigpio.INPUT)
        pi.stop()

purchase_create.pyでSAP Cloud Platform上のNode.js APIを叩き、S/4HANA上で購買依頼伝票を作成します。
HttpClientでアクセスしているAPIのエンドポイントは「3. SAP Cloud SDK for JavaScriptを用いたAPI実装」でAPIをSAP Cloud Foundry上にデプロイした後にセットしてください。

purchase_create.py
import urllib.request
import urllib.error 
import lxml
import base64
import json
import re
import ssl
from bs4 import BeautifulSoup
from collections import defaultdict

class HttpClient :

    #POSTメソッドによる購買依頼伝票の作成
    def createPurchaseReq():

        url = <<APIのエンドポイント>>
        #ヘッダ情報を設定
        headers = {"Content-Type" : "application/json"}

        #ボディ情報を設定
        json_obj = {
            "matnr": "300002",
            "menge": 1,
            "meins": "PC"
        }

        # POSTリクエスト送信        
        json_body = json.dumps(json_obj).encode("utf-8")

        req = urllib.request.Request(url, data=json_body, headers=headers, method='POST')
        try:
            with urllib.request.urlopen(req) as response:

                #レスポンスを取得する        
                response_body = response.read().decode("utf-8")
                print()
                # JSONとして読み込む
                json_obj  = json.loads(response_body)

                # 値の取り出し
                pr_number = json_obj['matnr']
                print('購買依頼伝票:' + pr_number + 'を作成しました。')

                #レスポンスから購買依頼伝票番号を取得する

        #例外処理        
        except urllib.error.HTTPError as err:
            soup = BeautifulSoup(err, "lxml")
            print(soup)

3. SAP Cloud SDK for JavaScriptを用いたAPI実装

次にS/4 HANA上の購買依頼伝票作成用ODataサービスを呼び出すためのAPIを実装していきます。
普段はSAP Cloud SDKの中でもJavaの方を用いてS/4 HANA上にアクセスしていますが、最近NodeJsを触る機会があったので今回はSAP Cloud SDK for JavaScriptを用いてAPIを作成していきたいと思います。
(ODataサービスは相も変わらず「だっふんだ 」と言うとSAP S/4HANAで購買依頼伝票を打てるiOSアプリを実装する(2/2)で紹介されているものを使用しています。)

まず最初にコマンドライン上で以下を実行してNodeプロジェクトを立ち上げます。

$ npm init

次に必要なモジュールを順にインストールしていきます。
まずはS/4接続に必須のcloud-sdk-generatorをインストールします。

$ npm install @sap/cloud-sdk-generator

このライブラリを使用することでODataサービスのメタデータからVDMを作成したりS/4 HANAに簡単にアクセスすることが出来ます。
試しにルートにedmxフォルダを作成し、その中に拡張子がedmxのファイル(中身はODataサービスのメタデータをコピペ)を置いて以下のコマンドを実行しましょう。

$ npx generate-odata-client --inputDir edmx --outputDir vdm

するとルートにvdmというフォルダが作成され、edmxフォルダ下のメタデータに対応するvdmが作成されていることが分かります。
edmx_vdm.png

次にTypeScriptのコンパイルや実行に必要なモジュールをインストールしましょう。

$ npm install --save-dev typescript ts-node

その後tscを用いてコンパイルに関する設定ファイルであるtsconfig.jsonファイルを作成します。

$ npx tsc --init

tsconfig.jsonを開き、"experimentalDecorators"と"emitDecoratorMetadata"を有効にしておきましょう。

tsconfig.json
...
    /* Experimental Options */
     "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
     "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. 
...

次に、ビルド時に最初にcleanを行うためにrimrafをインストールします。

$ npm install rimraf

また今のうちにpackage.jsonの"scripts"を修正してコマンドライン上での実行が楽になるようにしておきます。

package.json
...
  "scripts": {
    "vdm": "npx generate-odata-client --inputDir edmx --outputDir vdm --forceOverwrite",
    "clean": "rimraf lib reports",
    "build": "npm run clean && npx tsc",
    "start": "node ./src/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

最後に、APIの実装に必要なモジュールをインストールしておきます。

$ npm install routing-controllers reflect-metadata typeorm

Node.jsアプリケーション実行時に起動されるロジックをindex.tsに記載します。

index.ts
import "reflect-metadata"
import { createExpressServer } from "routing-controllers";
import { PurchaseRequestController } from './PurchaseRequestController';

const app = createExpressServer({
    controllers: [PurchaseRequestController]
});

app.listen(8080);

Entity、Controller、Serviceを必要に応じて実装します。

PurchaseRequestEntity.ts
import { Entity } from 'typeorm';

@Entity()
export class PurchaseRequestEntity{

    matnr: string;
    menge: number;
    meins: string;

    constructor(matnr: string, menge: number, meins: string){
        this.matnr =matnr;
        this.menge = menge;
        this.meins = meins;
    }

}
PurchaseRequestController.ts
import { JsonController, Get, Post, Body } from 'routing-controllers';
import { PurchaseRequestEntity } from './PurchaseRequestEntity';
import { PurchaseRequestService } from './PurchaseRequestService';

@JsonController("/tr")
export class PurchaseRequestController {

    @Get("/all")
    getAllReq() {
        return new PurchaseRequestService().getAllPurchaseRequest();
    }   

    @Post("/single")
    createReq(@Body() pr:PurchaseRequestEntity) {
        return new PurchaseRequestService().createPurchaseRequest(pr);
    }   
}
PurchaseRequestService.ts
import { PurchaseReqSet } from '../vdm/ypurchase-request-create-service/PurchaseReqSet';
import * as PRSrv from '../vdm/ypurchase-request-create-service';
import { BigNumber } from 'bignumber.js';
import { PurchaseRequestEntity } from './PurchaseRequestEntity';

export class PurchaseRequestService {
    getAllPurchaseRequest() {
        return PRSrv.PurchaseReqSet.requestBuilder()
        .getAll()  //照会処理用のメソッド
        .top(10)
        .select(
            PRSrv.PurchaseReqSet.ALL_FIELDS
        )
        .execute({destinationName: '<<Destination名>>'});
    }

    createPurchaseRequest(pr: PurchaseRequestEntity) {
    const purchaseReqSet = PRSrv.PurchaseReqSet.builder()
                                              .matnr(pr.matnr)
                                              .menge(new BigNumber(pr.menge))
                                              .meins(pr.meins)
                                              .build();

    return PurchaseReqSet.requestBuilder()
                        .create(purchaseReqSet)
                        .execute({destinationName: '<<Destination名>>'});

    }
}

さて、CF環境へのデプロイに必要不可欠なmanifest.ymlをルートディレクトリに追加しましょう。
ここにConnectivityやDestinationをバインドするよう記述しても良いですが、今回は面倒なのでデプロイ後にマニュアルでSAP Cloud Platform上からバインドしています。

manifest.yml
---
applications:
- name: ynodejs_purchase_request
  memory: 128M
  buildpacks:
  - https://github.com/cloudfoundry/nodejs-buildpack

最終的なディレクトリ構成は以下の通りです。

.
├── edmx
│    └── YPURCHASE_REQUEST_CREATE_SRV.edmx
├── node_modules
├── src
│    ├── index.ts
│    ├── PurchaseRequestController.ts
│    ├── PurchaseRequestEntity.ts
│    └── PurchaseRequestService.ts
├── vdm
├── manifest.yml
├── package.json
├── package-lock.json
└── tsconfig.json

ビルドまで完了したらSCP Cloud Foundryにデプロイします。ルート階層でcf pushを行いましょう。manifest.yml内に記載したアプリ名を指定すればOKです。

$ cf push ynodejs_purchase_request

これでS/4HANA上で購買依頼伝票を作成するためのAPIが完成しました。
※「2. Pythonアプリの実装」のpurchase_create.py内でStubで置いていたAPIのエンドポイントを書き換えてください。

完成

同期の間で老け顔キャラだったのですが、機械にまで実年齢+7歳くらいで評価されたのは地味にショックでした。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

コロナで危険度の高い人を見つけたらすぐ治療するアプリ作ってみた

はじめに

この記事は chillSAP 夏の自由研究2020 の記事として執筆しています。

猛暑が続いておりますが、皆さまいかがお過ごしでしょうか。
今年はコロナの影響で、日々言い知れぬ不安に苛まれることも多いのではないでしょうか。
この状況下で発熱してしまった方のご心境は察するに余りあります。
このような方の症状を一刻でも早く緩和させるため、今回は特に治療の緊急度の高いご高齢&高熱の方を判定してイ〇ジンをかけて治療を行うアプリを開発しました。
(注:本記事はコロナウイルスに対する治療法を提案するものでもなければ、イ〇ジンの効果を証明するものでも無いことをご承知おきください。また、ご気分を害された方がいらっしゃったら誠に申し訳ございません。)

概要

コロナアーキ.png

Raspberry Piに接続されたカメラモジュール及びサーマルカメラモジュールにより、測定者の年齢を及び体温を推測します(①、②)。
推定年齢及び体温が基準値を超えた場合は危険性ありと判断し、サーボモータを介して水鉄砲からイソジンを発射します(③)。
またイソジンが足りなくなると困るので、同時にSAP Cloud Platform上のNode.jsアプリケーションを経由して(④)、SAP S/4HANA上の購買依頼伝票作成ODataサービスから購買依頼伝票を作成して購入を進めます(⑤)。

年齢推定や体温測定の結果はRaspberry Pi上でこんな感じで表示されます。
テスト_顔ペイント前.gif

必要なもの

製品 補足
Raspberry Pi 4 model:B
サーマルカメラモジュール (MLX90640)
Raspberry Pi Camera V2
サーボモータ(MG996R) それなりの力が出ないとと水鉄砲のトリガーを引けません
水鉄砲
イ〇ジン(ポピドンヨード入り)  キッコーマン醤油
SAP S/4 HANA 1709    オンプレミスver

実装手順

1. Raspberry Piへの各種モジュールの接続

今回はカメラモジュール、サーマルカメラモジュール、サーボモータをRaspberry Piに接続します。
またイソジン発射用の水鉄砲を適当な台に取り付け、サーボモータと接続します。
全体.jpg

Raspberry Piへの各種モジュールの接続は以下の端子及びGPIOピンを使用しています。
カメラモジュール:ラズパイのカメラ用端子
サーマルカメラモジュール:SDAをGPIO 2ピン、SCLをGPIO 3ピン
サーボモータ:GPIO 18ピン

サーボモータと水鉄砲との接続は強めの糸で行い、サーボモータが半回転したときにトリガーが引かれるようにしておきます。
水鉄砲のトリガーは基本的に結構固く、生半可な接続だと糸がずれてしまったりトリガーでは無く水鉄砲やサーボモータ自体が動いてしまうので、しっかり固定するようにしましょう。
サーボ+水鉄砲

また、水鉄砲の中には今回の主役であるキッコーマン醤油イ〇ジン(ポピドンヨード入り)を充填しておきましょう。
イ〇ジン

2. Pythonアプリの実装

Raspberry Piに接続した各種モジュールはPythonで記述したスクリプトにより制御します。
Pythonアプリの構成は以下の通りです。

.
├── face
│    └── face.py
├── httpclient
│    └── purchase_create.py
├── templates
│    └── index.html
├── thermal_camera
│    └── thermal_camera.py
├── watergun
│    └── watergun.py
└── camera_app.py

複数のカメラからの動画を別々のウィンドウに表示するのであればopencvのimshowメソッドを使用するだけでOKなのですが、今回は1画面内で(かつブラウザからサーバにアクセスして)複数の動画を表示したかったため、Python用の軽量WebアプリケーションフレームワークであるFlaskを利用しています。

まずエンドポイントに対してリクエストを送った際に表示される画面をindex.html内に記載します。

index.html
<html>
  <head>
    <title>Video Streaming Demonstration</title>
  </head>
  <body>
    <img src="{{ url_for('video_feed1') }}">
    <img src="{{ url_for('video_feed2') }}">

  </body>
</html>

index.html 内のimgタグで動画(厳密にはカメラモジュールで撮影した動画の1フレーム)を表示するようになっています。
ソースとなる動画は、url_forで指定したルート名と同一のルートをcamera_app.pyから探して取得しています。

camera_app.py
#!/usr/bin/env python
import os
from flask import Flask, render_template, Response
import adafruit_mlx90640
import time

from thermal_camera.thermal_camera import ThermalCamera
from face.face import Face
from httpclient.purchase_create import HttpClient 
from watergun.watergun import Watergun

app = Flask(__name__)

age = 0
temperature = 0
last_posted_time = time.time()

@app.route('/')
def index():
    """Video streaming home page."""
    return render_template('index.html')


def generate(camera, value):
    """Video streaming generator function."""
    while True:
        #画像のバイナリデータを取得
        frame = camera.get_frame()

        #推定年齢と体温を取得する
        global age
        global temperature
        global last_posted_time

        if value == 'face':
            age = frame[1]
        elif value == 'temperature':
            temperature = frame[1]

        #推定年齢が40歳以上、体温が36℃であり、前回連携時間から5秒以上が経過している場合
        if age >= 40 and temperature >= 36 and (time.time() - last_posted_time) >=5:
            last_posted_time = time.time()

            #水鉄砲を発射
            Watergun.shoot()

            #購買依頼伝票を作成
            HttpClient.createPurchaseReq()


        yield (b'--frame\r\n'
               b'Content-Type: image/jpeg\r\n\r\n' + frame[0] + b'\r\n')


@app.route('/video_feed1')
def video_feed1():
    """Video streaming route. Put this in the src attribute of an img tag."""
    return Response(generate(Face(),'face'),
                    mimetype='multipart/x-mixed-replace; boundary=frame')


@app.route('/video_feed2')
def video_feed2():
    """Video streaming route. Put this in the src attribute of an img tag."""
    return Response(generate(ThermalCamera(), 'temperature'),
                    mimetype='multipart/x-mixed-replace; boundary=frame')

if __name__ == '__main__':
    app.run(host='0.0.0.0', threaded=True, port=5000)

camera_app.pyでは、①Flaskアプリを起動、②顔認識プログラムからの画像及び推定年齢取得、③体温測定プログラムからの画像及び体温取得、④水鉄砲からのイ〇ジン噴射、⑤購買依頼伝票作成用APIへのリクエスト送信を行っています。
②③についてですが、バイト型の動画フレームをyieldで逐次出力しており、その際mimetypeをmultipart/x-mixed-replaceと指定することでサーバが紙芝居的にレンダリングするようにしています。

thermal_camera.pyではサーマルカメラモジュールからバイナリイメージを取得し、温度の情報と共に呼び出し元に返す実装がなされています。

thermal_camera.py
#!/usr/bin/env python3
import cv2
import time
from PIL import Image, ImageDraw, ImageFont
import os
import board
import busio
import adafruit_mlx90640
import math
import numpy

class ThermalCamera:

    # some utility functions
    def constrain(self, val, min_val, max_val):
        return min(max_val, max(min_val, val))


    def map_value(self, x, in_min, in_max, out_min, out_max):
        return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min


    def gaussian(self, x, a, b, c, d=0):
        return a * math.exp(-((x - b) ** 2) / (2 * c ** 2)) + d


    def gradient(self, x, width, cmap, spread=1):
        width = float(width)
        r = sum(
            [self.gaussian(x, p[1][0], p[0] * width, width / (spread * len(cmap))) for p in cmap]
        )
        g = sum(
            [self.gaussian(x, p[1][1], p[0] * width, width / (spread * len(cmap))) for p in cmap]
        )
        b = sum(
            [self.gaussian(x, p[1][2], p[0] * width, width / (spread * len(cmap))) for p in cmap]
        )
        r = int(self.constrain(r * 255, 0, 255))
        g = int(self.constrain(g * 255, 0, 255))
        b = int(self.constrain(b * 255, 0, 255))
        return r, g, b

    def get_frame(self):
        while True:
            INTERPOLATE = 10

            # MUST set I2C freq to 1MHz in /boot/config.txt
            i2c = busio.I2C(board.SCL, board.SDA)

            # low range of the sensor (this will be black on the screen)
            MINTEMP = 20.0
            # high range of the sensor (this will be white on the screen)
            MAXTEMP = 45.0

            # the list of colors we can choose from
            heatmap = (
                (0.0, (0, 0, 0)),
                (0.20, (0, 0, 0.5)),
                (0.40, (0, 0.5, 0)),
                (0.60, (0.5, 0, 0)),
                (0.80, (0.75, 0.75, 0)),
                (0.90, (1.0, 0.75, 0)),
                (1.00, (1.0, 1.0, 1.0)),
            )

            # how many color values we can have
            COLORDEPTH = 1000

            colormap = [0] * COLORDEPTH

            for i in range(COLORDEPTH):
                colormap[i] = self.gradient(i, COLORDEPTH, heatmap)

            # initialize the sensor
            mlx = adafruit_mlx90640.MLX90640(i2c)
            mlx.refresh_rate = adafruit_mlx90640.RefreshRate.REFRESH_2_HZ

            frame = [0] * 768

            try:
                mlx.getFrame(frame)
            except ValueError:
                continue  # these happen, no biggie - retry

            pixels = [0] * 768
            for i, pixel in enumerate(frame):
                coloridx = self.map_value(pixel, MINTEMP, MAXTEMP, 0, COLORDEPTH - 1)
                coloridx = int(self.constrain(coloridx, 0, COLORDEPTH - 1))
                pixels[i] = colormap[coloridx]

            for h in range(24):
                for w in range(32):
                    pixel = pixels[h * 32 + w]

            image = Image.new("RGB", (32, 24))
            image.putdata(pixels)            

            #numpy配列(RGBA) <- PILイメージ
            img_numpy = numpy.asarray(image)
            #numpy配列(BGR) <- numpy配列(RGBA)
            img_numpy_bgr = cv2.cvtColor(img_numpy, cv2.COLOR_RGBA2BGR)
            img = cv2.resize(img_numpy_bgr, (320, 240))

            #画像の中から温度の最大値を取得
            max_temperature = numpy.max(frame)

            #温度をテキストから画像に変換            
            canvasSize    = (320, 60)
            backgroundRGB = (255, 255, 255)
            textRGB       = (0, 0, 0)
            if max_temperature >= 36.0:
                textRGB = (225, 0, 0)
                backgroundRGB = (255, 255, 0)
            else:
                textRGB = (0, 0, 0)
                backgroundRGB = (255, 255, 255)
            text = "体温は" + str(max_temperature)[:4] + "℃です。"
            image_temperature  = Image.new('RGB', canvasSize, backgroundRGB)
            draw = ImageDraw.Draw(image_temperature)
            font = ImageFont.load_default()
            font = ImageFont.truetype("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", 30)
            draw.text((1, 1), text, fill=textRGB, font=font)

            #numpy配列(RGBA) <- PILイメージ
            img_numpy_temperature = numpy.asarray(image_temperature)
            #numpy配列(BGR) <- numpy配列(RGBA)
            img_temperature = cv2.cvtColor(img_numpy_temperature, cv2.COLOR_RGBA2BGR)


            #サーマル画像と温度テキスト画像を結合する
            img_concatenated = cv2.vconcat([img, img_temperature])

            # encode as a jpeg image and return it
            return cv2.imencode('.jpg', img_concatenated)[1].tobytes(), max_temperature

face.pyについては諸事情によりソースコードを非公開とさせていただきます。中身としてはカメラモジュールから取得した画像から顔認識を行い、年齢を推定した上で顔パーツをプロットしたバイナリイメージ、及び推定年齢を返す実装がなされています。

watergun.pyではサーボモータを適切な角度に動かす実装がなされており、これにより接続された水鉄砲のトリガーが引かれます。
ご使用の水鉄砲によって角度等の設定が変わってきます。

watergun.py
import pigpio
import time

class Watergun:
    def shoot():    
        gpio_pin0 = 18

        pi = pigpio.pi()
        pi.set_mode(gpio_pin0, pigpio.OUTPUT)

        # GPIO18: 50Hz、duty比2.5%
        pi.hardware_PWM(gpio_pin0,50,25000)

        time.sleep(2)

        # GPIO18: 50Hz、duty比7.25%
        pi.hardware_PWM(gpio_pin0,50,72500)

        pi.set_mode(gpio_pin0,pigpio.INPUT)
        pi.stop()

purchase_create.pyでSAP Cloud Platform上のNode.js APIを叩き、S/4HANA上で購買依頼伝票を作成します。
HttpClientでアクセスしているAPIのエンドポイントは「3. SAP Cloud SDK for JavaScriptを用いたAPI実装」でAPIをSAP Cloud Foundry上にデプロイした後にセットしてください。

purchase_create.py
import urllib.request
import urllib.error 
import lxml
import base64
import json
import re
import ssl
from bs4 import BeautifulSoup
from collections import defaultdict

class HttpClient :

    #POSTメソッドによる購買依頼伝票の作成
    def createPurchaseReq():

        url = <<APIのエンドポイント>>
        #ヘッダ情報を設定
        headers = {"Content-Type" : "application/json"}

        #ボディ情報を設定
        json_obj = {
            "matnr": "300002",
            "menge": 1,
            "meins": "PC"
        }

        # POSTリクエスト送信        
        json_body = json.dumps(json_obj).encode("utf-8")

        req = urllib.request.Request(url, data=json_body, headers=headers, method='POST')
        try:
            with urllib.request.urlopen(req) as response:

                #レスポンスを取得する        
                response_body = response.read().decode("utf-8")
                print()
                # JSONとして読み込む
                json_obj  = json.loads(response_body)

                # 値の取り出し
                pr_number = json_obj['matnr']
                print('購買依頼伝票:' + pr_number + 'を作成しました。')

                #レスポンスから購買依頼伝票番号を取得する

        #例外処理        
        except urllib.error.HTTPError as err:
            soup = BeautifulSoup(err, "lxml")
            print(soup)

3. SAP Cloud SDK for JavaScriptを用いたAPI実装

次にS/4 HANA上の購買依頼伝票作成用ODataサービスを呼び出すためのAPIを実装していきます。
普段はSAP Cloud SDKの中でもJavaの方を用いてS/4 HANA上にアクセスしていますが、最近NodeJsを触る機会があったので今回はSAP Cloud SDK for JavaScriptを用いてAPIを作成していきたいと思います。
(ODataサービスは相も変わらず「だっふんだ 」と言うとSAP S/4HANAで購買依頼伝票を打てるiOSアプリを実装する(2/2)で紹介されているものを使用しています。)

まず最初にコマンドライン上で以下を実行してNodeプロジェクトを立ち上げます。

$ npm init

次に必要なモジュールを順にインストールしていきます。
まずはS/4接続に必須のcloud-sdk-generatorをインストールします。

$ npm install @sap/cloud-sdk-generator

このライブラリを使用することでODataサービスのメタデータからVDMを作成したりS/4 HANAに簡単にアクセスすることが出来ます。
試しにルートにedmxフォルダを作成し、その中に拡張子がedmxのファイル(中身はODataサービスのメタデータをコピペ)を置いて以下のコマンドを実行しましょう。

$ npx generate-odata-client --inputDir edmx --outputDir vdm

するとルートにvdmというフォルダが作成され、edmxフォルダ下のメタデータに対応するvdmが作成されていることが分かります。
edmx_vdm.png

次にTypeScriptのコンパイルや実行に必要なモジュールをインストールしましょう。

$ npm install --save-dev typescript ts-node

その後tscを用いてコンパイルに関する設定ファイルであるtsconfig.jsonファイルを作成します。

$ npx tsc --init

tsconfig.jsonを開き、"experimentalDecorators"と"emitDecoratorMetadata"を有効にしておきましょう。

tsconfig.json
...
    /* Experimental Options */
     "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
     "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. 
...

次に、ビルド時に最初にcleanを行うためにrimrafをインストールします。

$ npm install rimraf

また今のうちにpackage.jsonの"scripts"を修正してコマンドライン上での実行が楽になるようにしておきます。

package.json
...
  "scripts": {
    "vdm": "npx generate-odata-client --inputDir edmx --outputDir vdm --forceOverwrite",
    "clean": "rimraf lib reports",
    "build": "npm run clean && npx tsc",
    "start": "node ./src/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

最後に、APIの実装に必要なモジュールをインストールしておきます。

$ npm install routing-controllers reflect-metadata typeorm

Node.jsアプリケーション実行時に起動されるロジックをindex.tsに記載します。

index.ts
import "reflect-metadata"
import { createExpressServer } from "routing-controllers";
import { PurchaseRequestController } from './PurchaseRequestController';

const app = createExpressServer({
    controllers: [PurchaseRequestController]
});

app.listen(8080);

Entity、Controller、Serviceを必要に応じて実装します。

PurchaseRequestEntity.ts
import { Entity } from 'typeorm';

@Entity()
export class PurchaseRequestEntity{

    matnr: string;
    menge: number;
    meins: string;

    constructor(matnr: string, menge: number, meins: string){
        this.matnr =matnr;
        this.menge = menge;
        this.meins = meins;
    }

}
PurchaseRequestController.ts
import { JsonController, Get, Post, Body } from 'routing-controllers';
import { PurchaseRequestEntity } from './PurchaseRequestEntity';
import { PurchaseRequestService } from './PurchaseRequestService';

@JsonController("/tr")
export class PurchaseRequestController {

    @Get("/all")
    getAllReq() {
        return new PurchaseRequestService().getAllPurchaseRequest();
    }   

    @Post("/single")
    createReq(@Body() pr:PurchaseRequestEntity) {
        return new PurchaseRequestService().createPurchaseRequest(pr);
    }   
}
PurchaseRequestService.ts
import { PurchaseReqSet } from '../vdm/ypurchase-request-create-service/PurchaseReqSet';
import * as PRSrv from '../vdm/ypurchase-request-create-service';
import { BigNumber } from 'bignumber.js';
import { PurchaseRequestEntity } from './PurchaseRequestEntity';

export class PurchaseRequestService {
    getAllPurchaseRequest() {
        return PRSrv.PurchaseReqSet.requestBuilder()
        .getAll()  //照会処理用のメソッド
        .top(10)
        .select(
            PRSrv.PurchaseReqSet.ALL_FIELDS
        )
        .execute({destinationName: '<<Destination名>>'});
    }

    createPurchaseRequest(pr: PurchaseRequestEntity) {
    const purchaseReqSet = PRSrv.PurchaseReqSet.builder()
                                              .matnr(pr.matnr)
                                              .menge(new BigNumber(pr.menge))
                                              .meins(pr.meins)
                                              .build();

    return PurchaseReqSet.requestBuilder()
                        .create(purchaseReqSet)
                        .execute({destinationName: '<<Destination名>>'});

    }
}

さて、CF環境へのデプロイに必要不可欠なmanifest.ymlをルートディレクトリに追加しましょう。
ここにConnectivityやDestinationをバインドするよう記述しても良いですが、今回は面倒なのでデプロイ後にマニュアルでSAP Cloud Platform上からバインドしています。

manifest.yml
---
applications:
- name: ynodejs_purchase_request
  memory: 128M
  buildpacks:
  - https://github.com/cloudfoundry/nodejs-buildpack

最終的なディレクトリ構成は以下の通りです。

.
├── edmx
│    └── YPURCHASE_REQUEST_CREATE_SRV.edmx
├── node_modules
├── src
│    ├── index.ts
│    ├── PurchaseRequestController.ts
│    ├── PurchaseRequestEntity.ts
│    └── PurchaseRequestService.ts
├── vdm
├── manifest.yml
├── package.json
├── package-lock.json
└── tsconfig.json

ビルドまで完了したらSCP Cloud Foundryにデプロイします。ルート階層でcf pushを行いましょう。manifest.yml内に記載したアプリ名を指定すればOKです。

$ cf push ynodejs_purchase_request

これでS/4HANA上で購買依頼伝票を作成するためのAPIが完成しました。
※「2. Pythonアプリの実装」のpurchase_create.py内でStubで置いていたAPIのエンドポイントを書き換えてください。

完成

同期の間で老け顔キャラだったのですが、機械にまで実年齢+7歳くらいで評価されたのは地味にショックでした。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

コロナで危険度の高い人を見つけたらすぐ治療しようとするアプリ作ってみた

はじめに

この記事は chillSAP 夏の自由研究2020 の記事として執筆しています。

猛暑が続いておりますが、皆さまいかがお過ごしでしょうか。
今年はコロナの影響で、日々言い知れぬ不安に苛まれることも多いのではないでしょうか。
この状況下で発熱してしまった方のご心境は察するに余りあります。
このような方の症状を一刻でも早く緩和させるため、今回は特に治療の緊急度の高いご高齢&高熱の方を判定してイ〇ジンをかけて治療を行うアプリを開発しました。
(注:本記事はコロナウイルスに対する治療法を提案するものでもなければ、イ〇ジンの効果を証明するものでも無いことをご承知おきください。また、ご気分を害された方がいらっしゃったら誠に申し訳ございません。)

概要

コロナアーキ.png

Raspberry Piに接続されたカメラモジュール及びサーマルカメラモジュールにより、測定者の年齢を及び体温を推測します(①、②)。
推定年齢及び体温が基準値を超えた場合は危険性ありと判断し、サーボモータを介して水鉄砲からイ〇ジンを発射します(③)。
またイ〇ジンが足りなくなると困るので、同時にSAP Cloud Platform上のNode.jsアプリケーションを経由して(④)、SAP S/4HANA上の購買依頼伝票作成ODataサービスから購買依頼伝票を作成して購入を進めます(⑤)。

年齢推定や体温測定の結果はRaspberry Pi上でこんな感じで表示されます。
テスト_顔ペイント前.gif

必要なもの

製品 補足
Raspberry Pi 4 model:B
サーマルカメラモジュール (MLX90640)
Raspberry Pi Camera V2
サーボモータ(MG996R) それなりの力が出ないとと水鉄砲のトリガーを引けません
水鉄砲
イ〇ジン(ポピドンヨード入り)  キッコーマン醤油
SAP S/4 HANA 1709    オンプレミスver

実装手順

1. Raspberry Piへの各種モジュールの接続

今回はカメラモジュール、サーマルカメラモジュール、サーボモータをRaspberry Piに接続します。
またイ〇ジン発射用の水鉄砲を適当な台に取り付け、サーボモータと接続します。
全体.jpg

Raspberry Piへの各種モジュールの接続は以下の端子及びGPIOピンを使用しています。
カメラモジュール:ラズパイのカメラ用端子
サーマルカメラモジュール:SDAをGPIO 2ピン、SCLをGPIO 3ピン
サーボモータ:GPIO 18ピン

サーボモータと水鉄砲との接続は強めの糸で行い、サーボモータが半回転したときにトリガーが引かれるようにしておきます。
水鉄砲のトリガーは基本的に結構固く、生半可な接続だと糸がずれてしまったりトリガーでは無く水鉄砲やサーボモータ自体が動いてしまうので、しっかり固定するようにしましょう。
サーボ+水鉄砲

また、水鉄砲の中には今回の主役であるキッコーマン醤油イ〇ジン(ポピドンヨード入り)を充填しておきましょう。
イ〇ジン

2. Pythonアプリの実装

Raspberry Piに接続した各種モジュールはPythonで記述したスクリプトにより制御します。
Pythonアプリの構成は以下の通りです。

.
├── face
│    └── face.py
├── httpclient
│    └── purchase_create.py
├── templates
│    └── index.html
├── thermal_camera
│    └── thermal_camera.py
├── watergun
│    └── watergun.py
└── camera_app.py

複数のカメラからの動画を別々のウィンドウに表示するのであればopencvのimshowメソッドを使用するだけでOKなのですが、今回は1画面内で(かつブラウザからサーバにアクセスして)複数の動画を表示したかったため、Python用の軽量WebアプリケーションフレームワークであるFlaskを利用しています。

まずエンドポイントに対してリクエストを送った際に表示される画面をindex.html内に記載します。

index.html
<html>
  <head>
    <title>Video Streaming Demonstration</title>
  </head>
  <body>
    <img src="{{ url_for('video_feed1') }}">
    <img src="{{ url_for('video_feed2') }}">

  </body>
</html>

index.html 内のimgタグで動画(厳密にはカメラモジュールで撮影した動画の1フレーム)を表示するようになっています。
ソースとなる動画は、url_forで指定したルート名と同一のルートをcamera_app.pyから探して取得しています。

camera_app.py
#!/usr/bin/env python
import os
from flask import Flask, render_template, Response
import adafruit_mlx90640
import time

from thermal_camera.thermal_camera import ThermalCamera
from face.face import Face
from httpclient.purchase_create import HttpClient 
from watergun.watergun import Watergun

app = Flask(__name__)

age = 0
temperature = 0
last_posted_time = time.time()

@app.route('/')
def index():
    """Video streaming home page."""
    return render_template('index.html')


def generate(camera, value):
    """Video streaming generator function."""
    while True:
        #画像のバイナリデータを取得
        frame = camera.get_frame()

        #推定年齢と体温を取得する
        global age
        global temperature
        global last_posted_time

        if value == 'face':
            age = frame[1]
        elif value == 'temperature':
            temperature = frame[1]

        #推定年齢が40歳以上、体温が36℃であり、前回連携時間から5秒以上が経過している場合
        if age >= 40 and temperature >= 36 and (time.time() - last_posted_time) >=5:
            last_posted_time = time.time()

            #水鉄砲を発射
            Watergun.shoot()

            #購買依頼伝票を作成
            HttpClient.createPurchaseReq()


        yield (b'--frame\r\n'
               b'Content-Type: image/jpeg\r\n\r\n' + frame[0] + b'\r\n')


@app.route('/video_feed1')
def video_feed1():
    """Video streaming route. Put this in the src attribute of an img tag."""
    return Response(generate(Face(),'face'),
                    mimetype='multipart/x-mixed-replace; boundary=frame')


@app.route('/video_feed2')
def video_feed2():
    """Video streaming route. Put this in the src attribute of an img tag."""
    return Response(generate(ThermalCamera(), 'temperature'),
                    mimetype='multipart/x-mixed-replace; boundary=frame')

if __name__ == '__main__':
    app.run(host='0.0.0.0', threaded=True, port=5000)

camera_app.pyでは、①Flaskアプリを起動、②顔認識プログラムからの画像及び推定年齢取得、③体温測定プログラムからの画像及び体温取得、④水鉄砲からのイ〇ジン噴射、⑤購買依頼伝票作成用APIへのリクエスト送信を行っています。
②③についてですが、バイト型の動画フレームをyieldで逐次出力しており、その際mimetypeをmultipart/x-mixed-replaceと指定することでサーバが紙芝居的にレンダリングするようにしています。

thermal_camera.pyではサーマルカメラモジュールからバイナリイメージを取得し、温度の情報と共に呼び出し元に返す実装がなされています。

thermal_camera.py
#!/usr/bin/env python3
import cv2
import time
from PIL import Image, ImageDraw, ImageFont
import os
import board
import busio
import adafruit_mlx90640
import math
import numpy

class ThermalCamera:

    # some utility functions
    def constrain(self, val, min_val, max_val):
        return min(max_val, max(min_val, val))


    def map_value(self, x, in_min, in_max, out_min, out_max):
        return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min


    def gaussian(self, x, a, b, c, d=0):
        return a * math.exp(-((x - b) ** 2) / (2 * c ** 2)) + d


    def gradient(self, x, width, cmap, spread=1):
        width = float(width)
        r = sum(
            [self.gaussian(x, p[1][0], p[0] * width, width / (spread * len(cmap))) for p in cmap]
        )
        g = sum(
            [self.gaussian(x, p[1][1], p[0] * width, width / (spread * len(cmap))) for p in cmap]
        )
        b = sum(
            [self.gaussian(x, p[1][2], p[0] * width, width / (spread * len(cmap))) for p in cmap]
        )
        r = int(self.constrain(r * 255, 0, 255))
        g = int(self.constrain(g * 255, 0, 255))
        b = int(self.constrain(b * 255, 0, 255))
        return r, g, b

    def get_frame(self):
        while True:
            INTERPOLATE = 10

            # MUST set I2C freq to 1MHz in /boot/config.txt
            i2c = busio.I2C(board.SCL, board.SDA)

            # low range of the sensor (this will be black on the screen)
            MINTEMP = 20.0
            # high range of the sensor (this will be white on the screen)
            MAXTEMP = 45.0

            # the list of colors we can choose from
            heatmap = (
                (0.0, (0, 0, 0)),
                (0.20, (0, 0, 0.5)),
                (0.40, (0, 0.5, 0)),
                (0.60, (0.5, 0, 0)),
                (0.80, (0.75, 0.75, 0)),
                (0.90, (1.0, 0.75, 0)),
                (1.00, (1.0, 1.0, 1.0)),
            )

            # how many color values we can have
            COLORDEPTH = 1000

            colormap = [0] * COLORDEPTH

            for i in range(COLORDEPTH):
                colormap[i] = self.gradient(i, COLORDEPTH, heatmap)

            # initialize the sensor
            mlx = adafruit_mlx90640.MLX90640(i2c)
            mlx.refresh_rate = adafruit_mlx90640.RefreshRate.REFRESH_2_HZ

            frame = [0] * 768

            try:
                mlx.getFrame(frame)
            except ValueError:
                continue  # these happen, no biggie - retry

            pixels = [0] * 768
            for i, pixel in enumerate(frame):
                coloridx = self.map_value(pixel, MINTEMP, MAXTEMP, 0, COLORDEPTH - 1)
                coloridx = int(self.constrain(coloridx, 0, COLORDEPTH - 1))
                pixels[i] = colormap[coloridx]

            for h in range(24):
                for w in range(32):
                    pixel = pixels[h * 32 + w]

            image = Image.new("RGB", (32, 24))
            image.putdata(pixels)            

            #numpy配列(RGBA) <- PILイメージ
            img_numpy = numpy.asarray(image)
            #numpy配列(BGR) <- numpy配列(RGBA)
            img_numpy_bgr = cv2.cvtColor(img_numpy, cv2.COLOR_RGBA2BGR)
            img = cv2.resize(img_numpy_bgr, (320, 240))

            #画像の中から温度の最大値を取得
            max_temperature = numpy.max(frame)

            #温度をテキストから画像に変換            
            canvasSize    = (320, 60)
            backgroundRGB = (255, 255, 255)
            textRGB       = (0, 0, 0)
            if max_temperature >= 36.0:
                textRGB = (225, 0, 0)
                backgroundRGB = (255, 255, 0)
            else:
                textRGB = (0, 0, 0)
                backgroundRGB = (255, 255, 255)
            text = "体温は" + str(max_temperature)[:4] + "℃です。"
            image_temperature  = Image.new('RGB', canvasSize, backgroundRGB)
            draw = ImageDraw.Draw(image_temperature)
            font = ImageFont.load_default()
            font = ImageFont.truetype("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", 30)
            draw.text((1, 1), text, fill=textRGB, font=font)

            #numpy配列(RGBA) <- PILイメージ
            img_numpy_temperature = numpy.asarray(image_temperature)
            #numpy配列(BGR) <- numpy配列(RGBA)
            img_temperature = cv2.cvtColor(img_numpy_temperature, cv2.COLOR_RGBA2BGR)


            #サーマル画像と温度テキスト画像を結合する
            img_concatenated = cv2.vconcat([img, img_temperature])

            # encode as a jpeg image and return it
            return cv2.imencode('.jpg', img_concatenated)[1].tobytes(), max_temperature

face.pyについては諸事情によりソースコードを非公開とさせていただきます。中身としてはカメラモジュールから取得した画像から顔認識を行い、年齢を推定した上で顔パーツをプロットしたバイナリイメージ、及び推定年齢を返す実装がなされています。

watergun.pyではサーボモータを適切な角度に動かす実装がなされており、これにより接続された水鉄砲のトリガーが引かれます。
ご使用の水鉄砲によって角度等の設定が変わってきます。

watergun.py
import pigpio
import time

class Watergun:
    def shoot():    
        gpio_pin0 = 18

        pi = pigpio.pi()
        pi.set_mode(gpio_pin0, pigpio.OUTPUT)

        # GPIO18: 50Hz、duty比2.5%
        pi.hardware_PWM(gpio_pin0,50,25000)

        time.sleep(2)

        # GPIO18: 50Hz、duty比7.25%
        pi.hardware_PWM(gpio_pin0,50,72500)

        pi.set_mode(gpio_pin0,pigpio.INPUT)
        pi.stop()

purchase_create.pyでSAP Cloud Platform上のNode.js APIを叩き、S/4HANA上で購買依頼伝票を作成します。
HttpClientでアクセスしているAPIのエンドポイントは「3. SAP Cloud SDK for JavaScriptを用いたAPI実装」でAPIをSAP Cloud Foundry上にデプロイした後にセットしてください。

purchase_create.py
import urllib.request
import urllib.error 
import lxml
import base64
import json
import re
import ssl
from bs4 import BeautifulSoup
from collections import defaultdict

class HttpClient :

    #POSTメソッドによる購買依頼伝票の作成
    def createPurchaseReq():

        url = <<APIのエンドポイント>>
        #ヘッダ情報を設定
        headers = {"Content-Type" : "application/json"}

        #ボディ情報を設定
        json_obj = {
            "matnr": "300002",
            "menge": 1,
            "meins": "PC"
        }

        # POSTリクエスト送信        
        json_body = json.dumps(json_obj).encode("utf-8")

        req = urllib.request.Request(url, data=json_body, headers=headers, method='POST')
        try:
            with urllib.request.urlopen(req) as response:

                #レスポンスを取得する        
                response_body = response.read().decode("utf-8")
                print()
                # JSONとして読み込む
                json_obj  = json.loads(response_body)

                # 値の取り出し
                pr_number = json_obj['matnr']
                print('購買依頼伝票:' + pr_number + 'を作成しました。')

                #レスポンスから購買依頼伝票番号を取得する

        #例外処理        
        except urllib.error.HTTPError as err:
            soup = BeautifulSoup(err, "lxml")
            print(soup)

3. SAP Cloud SDK for JavaScriptを用いたAPI実装

次にS/4 HANA上の購買依頼伝票作成用ODataサービスを呼び出すためのAPIを実装していきます。
普段はSAP Cloud SDKの中でもJavaの方を用いてS/4 HANA上にアクセスしていますが、最近NodeJsを触る機会があったので今回はSAP Cloud SDK for JavaScriptを用いてAPIを作成していきたいと思います。
(ODataサービスは相も変わらず「だっふんだ 」と言うとSAP S/4HANAで購買依頼伝票を打てるiOSアプリを実装する(2/2)で紹介されているものを使用しています。)

まず最初にコマンドライン上で以下を実行してNodeプロジェクトを立ち上げます。

$ npm init

次に必要なモジュールを順にインストールしていきます。
まずはS/4接続に必須のcloud-sdk-generatorをインストールします。

$ npm install @sap/cloud-sdk-generator

このライブラリを使用することでODataサービスのメタデータからVDMを作成したりS/4 HANAに簡単にアクセスすることが出来ます。
試しにルートにedmxフォルダを作成し、その中に拡張子がedmxのファイル(中身はODataサービスのメタデータをコピペ)を置いて以下のコマンドを実行しましょう。

$ npx generate-odata-client --inputDir edmx --outputDir vdm

するとルートにvdmというフォルダが作成され、edmxフォルダ下のメタデータに対応するvdmが作成されていることが分かります。
edmx_vdm.png

次にTypeScriptのコンパイルや実行に必要なモジュールをインストールしましょう。

$ npm install --save-dev typescript ts-node

その後tscを用いてコンパイルに関する設定ファイルであるtsconfig.jsonファイルを作成します。

$ npx tsc --init

tsconfig.jsonを開き、"experimentalDecorators"と"emitDecoratorMetadata"を有効にしておきましょう。

tsconfig.json
...
    /* Experimental Options */
     "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
     "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. 
...

次に、ビルド時に最初にcleanを行うためにrimrafをインストールします。

$ npm install rimraf

また今のうちにpackage.jsonの"scripts"を修正してコマンドライン上での実行が楽になるようにしておきます。

package.json
...
  "scripts": {
    "vdm": "npx generate-odata-client --inputDir edmx --outputDir vdm --forceOverwrite",
    "clean": "rimraf lib reports",
    "build": "npm run clean && npx tsc",
    "start": "node ./src/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

最後に、APIの実装に必要なモジュールをインストールしておきます。

$ npm install routing-controllers reflect-metadata typeorm

Node.jsアプリケーション実行時に起動されるロジックをindex.tsに記載します。

index.ts
import "reflect-metadata"
import { createExpressServer } from "routing-controllers";
import { PurchaseRequestController } from './PurchaseRequestController';

const app = createExpressServer({
    controllers: [PurchaseRequestController]
});

app.listen(8080);

Entity、Controller、Serviceを必要に応じて実装します。

PurchaseRequestEntity.ts
import { Entity } from 'typeorm';

@Entity()
export class PurchaseRequestEntity{

    matnr: string;
    menge: number;
    meins: string;

    constructor(matnr: string, menge: number, meins: string){
        this.matnr =matnr;
        this.menge = menge;
        this.meins = meins;
    }

}
PurchaseRequestController.ts
import { JsonController, Get, Post, Body } from 'routing-controllers';
import { PurchaseRequestEntity } from './PurchaseRequestEntity';
import { PurchaseRequestService } from './PurchaseRequestService';

@JsonController("/tr")
export class PurchaseRequestController {

    @Get("/all")
    getAllReq() {
        return new PurchaseRequestService().getAllPurchaseRequest();
    }   

    @Post("/single")
    createReq(@Body() pr:PurchaseRequestEntity) {
        return new PurchaseRequestService().createPurchaseRequest(pr);
    }   
}
PurchaseRequestService.ts
import { PurchaseReqSet } from '../vdm/ypurchase-request-create-service/PurchaseReqSet';
import * as PRSrv from '../vdm/ypurchase-request-create-service';
import { BigNumber } from 'bignumber.js';
import { PurchaseRequestEntity } from './PurchaseRequestEntity';

export class PurchaseRequestService {
    getAllPurchaseRequest() {
        return PRSrv.PurchaseReqSet.requestBuilder()
        .getAll()  //照会処理用のメソッド
        .top(10)
        .select(
            PRSrv.PurchaseReqSet.ALL_FIELDS
        )
        .execute({destinationName: '<<Destination名>>'});
    }

    createPurchaseRequest(pr: PurchaseRequestEntity) {
    const purchaseReqSet = PRSrv.PurchaseReqSet.builder()
                                              .matnr(pr.matnr)
                                              .menge(new BigNumber(pr.menge))
                                              .meins(pr.meins)
                                              .build();

    return PurchaseReqSet.requestBuilder()
                        .create(purchaseReqSet)
                        .execute({destinationName: '<<Destination名>>'});

    }
}

さて、CF環境へのデプロイに必要不可欠なmanifest.ymlをルートディレクトリに追加しましょう。
ここにConnectivityやDestinationをバインドするよう記述しても良いですが、今回は面倒なのでデプロイ後にマニュアルでSAP Cloud Platform上からバインドしています。

manifest.yml
---
applications:
- name: ynodejs_purchase_request
  memory: 128M
  buildpacks:
  - https://github.com/cloudfoundry/nodejs-buildpack

最終的なディレクトリ構成は以下の通りです。

.
├── edmx
│    └── YPURCHASE_REQUEST_CREATE_SRV.edmx
├── node_modules
├── src
│    ├── index.ts
│    ├── PurchaseRequestController.ts
│    ├── PurchaseRequestEntity.ts
│    └── PurchaseRequestService.ts
├── vdm
├── manifest.yml
├── package.json
├── package-lock.json
└── tsconfig.json

ビルドまで完了したらSCP Cloud Foundryにデプロイします。ルート階層でcf pushを行いましょう。manifest.yml内に記載したアプリ名を指定すればOKです。

$ cf push ynodejs_purchase_request

これでS/4HANA上で購買依頼伝票を作成するためのAPIが完成しました。
※「2. Pythonアプリの実装」のpurchase_create.py内でStubで置いていたAPIのエンドポイントを書き換えてください。

完成

同期の間で老け顔キャラだったのですが、機械にまで実年齢+7歳くらいで評価されたのは地味にショックでした。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む