AWS Amplify で自宅の温度や湿度を可視化する IoT アプリ開発にチャレンジしてみよう !
Author : 白山 文彦
はじめまして!プロトタイピング ソリューションアーキテクトの白山です。
最近は IoT (Internet of Things: モノのインターネット) という言葉がよく聞かれるように、様々なデバイスがインターネットに繋がるようになりました。今回は、3000 円以下で買えるようなお手軽な IoT デバイスと、AWS Amplify を使って自分だけの IoT アプリ開発にチャレンジしてみたいと思います!
*本記事は Amplify Library for Android が GA 発表となる前の内容となります。
対象読者
今回は、バックエンド開発に Node.js、フロントエンド開発に Android を採用しましたが、もちろん iOS やウェブでの構築も可能です。
後述する AWS Amplify の API はプラットフォームを問わず共通してご理解いただける部分も多いので、IoT デバイスとクラウド技術にご興味がお有りの方は是非お気軽にお読みいただければ幸いです !
デモ
早速ですが、先に完成イメージをお見せします。今回は、家庭内の温度や湿度を可視化する Android アプリを開発してみたいと思います。
温度や湿度などのセンサーデータは Inkbird-ミニ (IBS-TH1 MINI) を使って収集することにします。
また、Inkbird-ミニから定期的にデータを収集してサーバに送信するために Raspberry Pi を利用することにしました。
今回は手元にあった Raspberry Pi 2 Model B を利用しましたが、Node.js を使って AWS に接続できる IoT デバイスならなんでも良いので、それ以外のデバイスを使われる方はお持ちのデバイスを前提にしてご参考にしてください。
アーキテクチャ
今回のアプリの要件は次の通りです。
- センサーデータは Inkbird-ミニから Raspberry Pi 経由で AWS へ定期的に送信する
- ユーザーは ID とパスワードを使ってアプリにログインできる
- ユーザーは検索条件を指定して温度や湿度などのセンサーデータを取得できる
アーキテクチャ図は次の通りです。現時点で各 AWS サービスについて理解している必要はありません。作りながら解説していくのでご安心ください !
センサー値を取得する
まずはセンサーから値を取得できないことには何も始まりません。Inkbird-ミニを設置して Raspberry Pi から値を取得するところまで目指してみます。
Inkbird-ミニを開封して電池を入れると自動的に起動します。500 円玉ほどの大きさで、厚さ 1cm ほどの非常に小さいセンサーです。電池式なのでそのまま好きな場所に設置してください。
早速 Raspberry Pi から Inkbird-ミニにアクセスといきたいところですが、その前に正しくセンサー値が取れているか確認するために、Engbird という公式アプリを使って確認してみたいと思います。Android端末ををお使いの場合は Google Play ストアから、iOS 端末をお使いの場合は App Store からインストールしてください。
アプリを立ち上げると、次のように簡単に周囲の Inkbird-ミニとペアリングすることができます。16 進数 2 桁区切りの ID は、この Inkbird-ミニを一意に特定できる MAC アドレスです。後の工程で利用するのでメモしておいてください。
ペアリングした Inkbird-ミニのセンサーデータは、この通り簡単にチャートで確認することができます。
このように、基本的な温度や湿度は公式アプリで確認することができます。ただし、これだけでは年単位でデータを貯めていくことは困難ですし、ある温度・湿度を超えた場合にエアコンなどその他の家電と連携するといった高度な使い方はできません。
したがって、本記事ではセンサーデバイスと連携してデータを取得し、それを AWS に貯めていくことで「自分だけのセンサーデータ収集基盤」を構築し、それをフロントエンドアプリを使ってビジュアライズするアプローチを採ることにします。
Raspberry Pi からセンサー値を取得する
それでは Inkbird-ミニと連携するように Raspberry Pi を設定していきます。
今回用意した Raspberry Pi 2 Model B は Bluetooth モジュールを備えていないので、別途 USB タイプのBluetoothドングルを用意する必要があります。今回は PLANEX BT-Micro4 を使いましたが、Raspberry Pi 上の Raspbian から認識できるものなら何でもよいと思います。
基本的には USB ポートに Bluetooth ドングルを差し込むだけで利用することができます。hciconfig コマンドで次のような出力が得られれば問題有りません。もし何も出ない場合は sudo apt-get install bluez してから再度確かめてみてください。
% hciconfig
hci0: Type: Primary Bus: USB
BD Address: 00:1B:DC:F3:77:D2 ACL MTU: 310:10 SCO MTU: 64:8
UP RUNNING
RX bytes:98014 acl:0 sco:0 events:3490 errors:0
TX bytes:3468 acl:0 sco:0 commands:57 errors:0
次に、周囲の Inkbird-ミニデバイスの信号を検出できるかコマンドラインから確認します。MAC アドレス部分はお使いのデバイスのものに読み替えてください。
% sudo hcitool lescan | grep 'B0:7E:11:EE:5A:08'
B0:7E:11:EE:5A:08 (unknown)
B0:7E:11:EE:5A:08 sps
B0:7E:11:EE:5A:08 (unknown)
B0:7E:11:EE:5A:08 sps
問題なく取得できました !
もしうまく行かない場合は、デバイスが近くにあるか、MAC アドレスは間違っていないかなどを確認してみてください。
Node.js からセンサー値を取得する
Raspberry Pi から Inkbird-ミニの信号を受け取ることができたので、プログラムから具体的なセンサー値を受け取れるか確認してみます。
今回は、この後のステップで利用する AWS Amplify とシームレスに統合できる Node.js を利用することにします。
nodebrew のセットアップ
この後のステップで、何度か Node.js のバージョンを切り替えて作業する工程があります。これをやりやすくするため、Node.js のバージョンマネージャである nodebrew を導入します。次の通りインストールしてください。
% curl -L git.io/nodebrew | perl - setup
# bashの場合は .bashrc と読み替えてください
% echo 'export PATH=$HOME/.nodebrew/current/bin:$PATH' >> ~/.zshrc
% source ~/.zshrc
利用可能な Node.js のバージョンは nodebrew ls-remote で確認できます。
% nodebrew ls-remote | grep v8
v8.0.0 v8.1.0 v8.1.1 v8.1.2
...
v8.16.0 v8.16.1 v8.16.2 v8.17.0
今回センサー値を取得するのに使うライブラリが Node 8 系のみ対応しているため、v8.17.0 をインストールします。
% nodebrew install v8.17.0
% nodebrew ls
v8.17.0
% nodebrew use v8.17.0
use v8.17.0
% which node
/home/shiroyama/.nodebrew/current/bin/node
% which npm
/home/shiroyama/.nodebrew/current/bin/npm
% node -v
v8.17.0
% npm -v
6.13.4
@abandonware/noble でセンサー値の取得
Node.js から Bluetooth モジュールの値を取得する際によく利用される noble というライブラリがありますが、これは最終コミットから2年以上経ってやや古くなっています。したがって、今回はそのforkでより新しいAPIをサポートし、現在もメンテナンスされている @abandonware/noble を使うことにします。
まずは README に従い、依存関係をインストールします。
% sudo apt-get update
% sudo apt-get install bluetooth bluez libbluetooth-dev libudev-dev
次に builders_flash という名前でプロジェクトを作成します。
% mkdir builders_flash
% cd builders_flash/
npm init -y でプロジェクトを初期化し、型の恩恵を受けるために TypeScript をインストールします。
% npm init -y
% npm i -D typescript @types/node
TypeScript の設定ファイルを初期化・設定します。
% npx tsc --init
% vim tsconfig.json
tsconfig.json の設定例は例えば次の通りです。
"allowJs": true とすると、TypeScript のプロジェクトに JavaScript ファイルを含めることができます。後述しますが、Amplify では設定情報が aws-exports.js というファイルで自動生成されるので、このオプションは true に設定してください。
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"allowJs": true,
"sourceMap": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*"
]
}
センサー値を取得するためのライブラリなどをインストールします。
ここが最大の鬼門です。エラーが起きた場合は、Node.js のバージョンが指定したとおり 8 系になっているかなどを確認してください。致命的なエラーが発生せずにインストールできれば完了です。
% npm i @abandonware/noble
% npm i bluetooth-hci-socket
% npm install --save @types/noble
いよいよコードに取り掛かります。src/noble.ts を作成し、次のようなコードを書いていきます。const MAC_ADDRESS はお使いの Inkbird-ミニの MAC アドレスに変更してください。
TypeScript
const MAC_ADDRESS = 'B0:7E:11:EE:5A:08'
const INTERVAL = 1000 * 60 * 30
import { Peripheral, Advertisement } from 'noble'
const noble = require('@abandonware/noble')
noble.on('stateChange', (state: string) => {
console.log(`state: ${state}`)
if (state === 'poweredOn') {
noble.startScanning([], true)
} else {
noble.stopScanning()
}
})
noble.on('scanStart', () => { console.log('scanStart') })
noble.on('scanStop', () => { console.log('scanStop') })
noble.on('discover', (peripheral: Peripheral) => {
const address = peripheral.address
console.log(`address: ${address}`)
const advertisement: Advertisement = peripheral.advertisement
const manufacturerData: Buffer = advertisement.manufacturerData
// Inkbird-ミニ発見時の処理
if (MAC_ADDRESS.toLowerCase() == address.toLowerCase() && manufacturerData !== undefined) {
// 先頭2バイトが温度
const temperature = manufacturerData.readInt16LE(0) / 100
// 次の2バイトが湿度
const humidity = manufacturerData.readInt16LE(2) / 100
const battery = manufacturerData[7]
const datetime = new Date().toISOString()
console.log(`temperature: ${temperature}, humidity: ${humidity}, battery: ${battery}, datetime: ${datetime}`)
noble.stopScanning()
}
})
setInterval(() => {
noble.startScanning([], true)
}, INTERVAL)
このプログラムは、30 分ごとに周囲の Bluetooth モジュールを探し、Inkbird-ミニが見つかったら温度・湿度・バッテリー残量などのセンサー値を取得してスキャンをやめます。30 分経つとまた同じことを繰り返します。
実行してみたいと思います。tsc コマンドでトランスパイルしてもよいのですが、ts-node というコマンドを導入して TypeScript のまま確認することにします。センサーにアクセスするため sudo で実行している点に注意してください。
% npm i -D ts-node
% sudo npx ts-node src/noble.ts
Raspberry Pi の計算能力はそれほど高くないので少し時間がかかるかも知れませんが、数秒して次のように表示されれば成功です。
% sudo npx ts-node src/noble.ts
state: poweredOn
scanStart
address: xx:xx:xx:xx:xx:xx
address: xx:xx:xx:xx:xx:xx
address: xx:xx:xx:xx:xx:xx
address: xx:xx:xx:xx:xx:xx
address: xx:xx:xx:xx:xx:xx
address: b0:7e:11:ee:5a:08
temperature: 27.41, humidity: 62.81, battery: 40, datetime: 2020-05-16T02:36:46.350Z
scanStop
うまく行ったようです !
センサー値を取得するまでが少し大変ですが、ここが準備では最も重要なので、エラーが起きた場合はメッセージとこの記事の手順を照らし合わせながらひとつひとつ確認してみてください。
AWS Amplify でセンサー値を保存・取得する
さて、いよいよ取得したデータを AWS 上に保存してアプリから利用できるようにしてみたいと思います。今回は AWS Amplify というサービスを使ってみることにします。
AWS Amplify とは
Amplify とはサーバーレスなバックエンドをセットアップするための CLI、フロントエンドで利用できる UI コンポーネント、CI/CD やホスティングのためのコンソールを含む Web およびモバイルアプリ開発のためのフレームワークです。
Amplify に関してはスタートアップが AWS Amplify を使うべき 3 つの理由というエントリに非常にわかりやすくまとまっています。
今回はバックエンドの構築に Amplify CLI を使う他、Node.js からデータを保存するときと Android アプリからデータを取得する際に Amplify Framework を利用します。
クリックすると拡大します
Amplify のセットアップ
まずは Amplify CLI をインストールします。Amplify CLI は Node 10 以降のみサポートしているため (2020 年 5 月現在)、nodebrew で 10 系をインストールして切り替えます。
% nodebrew install v10.20.1
% nodebrew use v10.20.1
% node -v
v10.20.1
それでは Amplify CLI をインストールします。
% npm install -g @aws-amplify/cli@4.18.1
% amplify -v
4.18.1
次に amplify configure コマンドで初期設定をします。
% amplify configure
ここでは amplify コマンドを実行する際に利用する IAM ユーザーの作成とクレデンシャルの発行を行います。
region にはバックエンドをプロビジョニングするリージョンを入力します。次に表示された URL をブラウザにコピーアンドペーストし、AWS マネジメントコンソールから IAM ユーザーを作成します。完了したらアクセスキーとシークレットキーが発行されるのでメモし、Raspberry Pi のコンソール上にコピーアンドペーストします。
クリックすると拡大します
次に amplify init コマンドでプロジェクトを Amplify 対応させます。先ほど作成した builders_flash ディレクトリにいることを再確認してからコマンドを実行してください。
% amplify init
Raspberry Pi で Node.js を実行するのはかなり重いので、画面が応答するまでしばらく時間がかかるかも知れませんが、対話型コンソールが表示されるまでしばらくお待ち下さい。
応答したら次の例を参考に入力してみてください。
default editor は後述する GraphQL のスキーマ編集等で利用するエディタです。入力し終わるとAWS 上に Amplify のプロジェクトが作成されます。
クリックすると拡大します
完了したら amplify status コマンドで現在の状況を確認してみます。
% amplify status
Current Environment: dev
| Category | Resource name | Operation | Provider plugin |
| -------- | ------------- | --------- | --------------- |
正しく Amplify のプロジェクトが作成されました。まだ何も追加していないので空欄なのは問題ありません。
API を追加
次に、この Amplify プロジェクトに API を追加します。このプロジェクトでは
- Raspberry Pi からデータの書き込み
- Android アプリからデータの読み出し
の双方に GraphQL を利用することにします。
amplify add api コマンドを入力し、対話型コンソールを次の例を参考に入力します。
% amplify add api
Choose the default authorization type for the API はこの API の標準の認証方法を指定します。Raspberry Pi から定期的にデータを書き込む都合上、ここでは API Key を選択します。キーの有効期限は一旦 1 年ということで 365 を入力しておきましょう。その他の設定はそのままで構いません。
クリックすると拡大します
次に、GraphQL のスキーマファイルの編集を促されます。ここでは取り扱うセンサーデータを次のように定義します。
type Sensor @model
@searchable
@key(fields: ["id", "datetime"]) {
id: ID!
name: String!
temperature: Float!
humidity: Float!
battery: Float!
datetime: AWSDateTime!
}
各フィールドは次を意図しています。
- id: センサー ID
- name: センサー名
- temperature: 温度
- humidity: 湿度
- battery: バッテリー残量
- datetime: データを書き込んだ日時
また @key(fields: ["id", "datetime"]) は「センサー ID + 時間」で各レコードが一意に特定できるキー設計であることを意図しています。最後に @searchable はこのモデルがそれぞれのフィールドを条件として検索可能であることを意味します。これはどういうことでしょうか。
Amplify で GraphQL の API を追加すると、バックエンドでは AWS AppSync という GraphQL のマネージドサービスがプロビジョニングされます。AWS AppSync では標準のデータソースとして Amazon DynamoDB が作成されます。
DynamoDB は 1 日に 10 兆件以上のリクエストを処理することができ、毎秒 2000 万件を超えるリクエストをサポートするキーバリュー / ドキュメントデータベースですが、キーに指定した属性を使ってクエリするので、取得したい属性に応じてキー設計をする必要があります。今回のユースケースとして「温度が何度以上、湿度が何 % 以上」といった複数の条件でクエリしたい上に、センサーが増えればこの条件はどんどん増えていくので DynamoDB でクエリするのは必ずしも効率的ではありません。
なんと Amplify ではスキーマに @searchable と指定するだけで Amazon Elasticsearch Service が追加でプロビジョニングされ、DynamoDBに書き込まれたデータは DynamoDB Streams 経由で Elasticsearch にも書き込まれ、GraphQL のクエリ時にはこの Elasticsearch をデータソースとして自由にデータを取得することができるのです ! しかも、ユーザーはそのような事情をまったく知る必要がなく、単にスキーマに @searchable と書きさえすればよいのです !
スキーマを保存したらもう一度 amplify status で状況を確認してみましょう。Category: Api が作成されていることがわかります。
% amplify status
Current Environment: dev
| Category | Resource name | Operation | Provider plugin |
| -------- | ------------- | --------- | ----------------- |
| Api | buildersflash | Create | awscloudformation |
amplify push コマンドでこれを AWS 上に反映します。
% amplify push
途中で一度対話型コンソールに戻ってきます。ここではスキーマに応じたコード生成を自動的に行うことができるので、language target: typescript で TypeScript をターゲットにしておきましょう。その他の部分はそのままで構いません。
保存したらこの API を実現するのに必要な AWS のリソースがバックエンドにプロビジョニングされます。数分間かかるのでしばらくお待ち下さい。
クリックすると拡大します
Node.js からセンサー値を書き込む
準備が整ったので、取得したセンサー値を Amplify 経由で AWS 上に書き込んでいきたいと思います。こちらで Amplify CLI を使う作業はもうないので、センサー値が取得できるように再び Node.js を 8 系に戻します。
% nodebrew use v8.17.0
つづいて先ほど作った Node.js のプロジェクトに Amplify を使ってデータを書き込むための Amplify Framework を追加します。
% npm i aws-amplify
さて、これから Node.js で Amplify を利用しますが、先ほどの工程で注目すべきファイルがいくつか生成されています。
まずは src/aws-exports.js です。これは Amplify CLI で自動生成されるファイルで、中には GraphQL API のエンドポイントや API Key などのクレデンシャル情報が書き込まれています。冒頭で TypeScript の設定ファイル tsconfig.json を "allowJs": true と設定している場合はそのまま読み込むことができます。もしこのオプションを明示的にオフにしている場合は、次の例のようにリネームしておいてください。
% mv src/aws-exports.js src/aws-exports.ts
次に src/API.ts です。ここには先ほど作成したスキーマを元に、GraphQL の読み書きで必要なデータ型がすべて自動生成されています。一例を挙げると、Sensor を書き込む際の入力型である CreateSensorInput は次のようになっています。
TypeScript
export type CreateSensorInput = {
id: string,
name: string,
temperature: number,
humidity: number,
battery: number,
datetime: string,
};
最後に src/graphql/(mutations|queries|subscriptions).ts です。これらは、GraphQL に読み書きするクエリやミューテーションなどの文字列を定義しています。一例を挙げると、Sensor を書き込むための createSensor は次のようになっています。
TypeScript
export const createSensor = /* GraphQL */ `
mutation CreateSensor(
$input: CreateSensorInput!
$condition: ModelSensorConditionInput
) {
createSensor(input: $input, condition: $condition) {
id
name
temperature
humidity
battery
datetime
}
}
`;
これらを駆使することで GraphQL の型安全性を享受しながらも便利にプログラミングをすることができます。それでは早速 Amplify にデータを読み書きしてみたいと思います。src/amplify.ts を作成し、次のように編集してみてください。
TypeScript
import Amplify from 'aws-amplify'
import API, { graphqlOperation, GraphQLResult } from '@aws-amplify/api'
import * as Types from './API'
import { createSensor } from './graphql/mutations'
import { listSensors } from './graphql/queries'
import awsconfig from './aws-exports'
Amplify.configure(awsconfig)
const MAC_ADDRESS = 'B0:7E:11:EE:5A:08'
const DEVICE_NAME = 'IBS-TH1'
const TEMPERATURE = getRandom(25.0, 35.0)
const HUMIDITY = getRandom(50.0, 70.0)
const BATTERY = getRandom(0, 100)
const DATETIME = new Date().toISOString()
try {
mutation(id, name, temperature, humidity, battery, datetime)
query()
} catch (error) {
console.log(error)
}
function getRandom(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
// 書き込み処理(ミューテーション)
async function mutation(id: string, name: string, temperature: number, humidity: number, battery: number, datetime: string) {
const input: Types.CreateSensorInput = {
id: MAC_ADDRESS,
name: DEVICE_NAME,
temperature: TEMPERATURE,
humidity: HUMIDITY,
battery: BATTERY,
datetime: DATETIME,
}
const variables: Types.CreateSensorMutationVariables = {
input: input
}
try {
const result = await API.graphql(graphqlOperation(createSensor, variables)) as GraphQLResult<Types.CreateSensorMutation>
console.log(result)
} catch (error) {
console.log(error)
}
}
// 読み出し処理(クエリ)
async function query() {
try {
const result = await API.graphql(graphqlOperation(listSensors)) as GraphQLResult<Types.ListSensorsQuery>
result.data?.listSensors?.items?.forEach((item) => { console.log(item) })
} catch (error) {
console.log(error)
}
}
このプログラムは次のことをしています。
- mutation() - ダミーデータを用意し、自動生成された createSensor を使ってミューテーションでデータを書き込み
- query() - 自動生成された listSensors を使ってクエリでデータ読み込み
次の部分で設定ファイルから API Key やエンドポイントを読み込んでいるので、コード内で特にそれらの情報を設定する必要はありません。
TypeScript
import awsconfig from './aws-exports'
Amplify.configure(awsconfig)
それでは ts-node コマンドで実行してみます。
% npx ts-node src/amplify.ts
読み書きしたデータがコンソール上に表示されれば成功です !
ここまでくれば完成したも同然です ! src/noble.ts と src/amplify.ts の内容を組み合わせて、src/sensor.ts を次の通り作成しましょう。
TypeScript
import { Peripheral, Advertisement } from 'noble'
import Amplify from 'aws-amplify'
import API, { graphqlOperation, GraphQLResult } from '@aws-amplify/api'
import * as Types from './API'
import { createSensor } from './graphql/mutations'
import { listSensors } from './graphql/queries'
import awsconfig from './aws-exports'
Amplify.configure(awsconfig)
const MAC_ADDRESS = 'B0:7E:11:EE:5A:08'
const DEVICE_NAME = 'IBS-TH1'
const INTERVAL = 1000 * 60 * 30
const noble = require('@abandonware/noble')
noble.on('stateChange', (state: string) => {
console.log(`state: ${state}`)
if (state === 'poweredOn') {
noble.startScanning([], true)
} else {
noble.stopScanning()
}
})
noble.on('scanStart', () => { console.log('scanStart') })
noble.on('scanStop', () => { console.log('scanStop') })
noble.on('discover', async (peripheral: Peripheral) => {
const address = peripheral.address
console.log(`address: ${address}`)
const advertisement: Advertisement = peripheral.advertisement
const manufacturerData: Buffer = advertisement.manufacturerData
if (MAC_ADDRESS.toLowerCase() == address.toLowerCase() && manufacturerData !== undefined) {
const temperature = manufacturerData.readInt16LE(0) / 100
const humidity = manufacturerData.readInt16LE(2) / 100
const battery = manufacturerData[7]
const datetime = new Date().toISOString()
console.log(`temperature: ${temperature}, humidity: ${humidity}, battery: ${battery}, datetime: ${datetime}`)
try {
// センサーで取得したデータをミューテーションで保存
await mutation(MAC_ADDRESS, DEVICE_NAME, temperature, humidity, battery, datetime)
} catch (error) {
console.log(error)
}
noble.stopScanning()
}
})
setInterval(() => {
noble.startScanning([], true)
}, INTERVAL)
async function mutation(id: string, name: string, temperature: number, humidity: number, battery: number, datetime: string) {
const input = {
id: id,
name: name,
temperature: temperature,
humidity: humidity,
battery: battery,
datetime: datetime,
}
const result = await API.graphql(graphqlOperation(createSensor, {input: input}))
console.log(result)
}
取得したセンサーデータを mutation() で書き込むだけです。保存して実行します。
% npx tsc
% sudo node ./dist/sensor.js
tsc コマンドでトランスパイルして実行する際に型のエラーが表示される場合は次のようなファイルを用意すればやり過ごすことができるでしょう。
% cat<<'EOS'>src/my.d.ts
declare module 'graphql/language/ast' { export type DocumentNode = any }
EOS
このスクリプトは明示的に停止するまで 30 分おきにセンサー値を取得し、Amplify 経由で AppSync にデータを書き込み続けます。Raspberry Pi でのデータ取得はこれで完了です ! お疲れさまでした。
Android からデータを取得する
バックエンドが完成したので、今度は Android フロントエンドアプリを作りたいと思います。
モバイルアプリから Amplify (特に GraphQL を使うための AppSync) を使う際、もしプロダクション環境で利用する場合は安定版の AWS Mobile SDK を使うことが推奨されます。しかし、昨年末の AWS re:Invent 2019 で Amplify のモバイルアプリ専用実装であるAmplify Libraries for iOS / Android (preview) が発表されました。
AWS Mobile SDK が「AWS のサービス単位」であるのに対し、Amplify Libraries は「カテゴリ」という単位で「やりたいこと」にフォーカスした直感的な API で利用することが可能です。まだプレビューという位置づけですが、近い将来 Mobile SDK に取って代わる可能性は充分にあるため、本記事では Amplify Libraries を使った実装方法を紹介したいと思います。
Amplify Libraries のセットアップ
ここでは macOS Mojave 10.14.6、Android Studio 3.6.3 で解説しますが、その他の環境でも同様の手順で構築可能ですので適宜読み替えてください。また、プロジェクトディレクトリは builders_flash_android とします。ここに空の Android プロジェクトを作成しておいてください。
% mkdir builders_flash_android
% cd builders_flash_android
まずは Amplify Libraries - GETTING STARTED のウェブサイトに移動してください。そして、Andoroid アプリ開発環境にも Amplify CLI をインストールします。
% npm install -g @aws-amplify/cli@4.18.1
次に、Create your applicationの2. Install Amplify Libraries を参考に、プロジェクトレベルの build.gradle を次のように編集します。
Gradle
buildscript {
repositories {
google()
jcenter()
// Add this line into `repositories` in `buildscript`
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.3'
}
}
allprojects {
repositories {
google()
jcenter()
// Add this line into `repositories` in `allprojects`
mavenCentral()
}
}
次に、アプリケーションレベルの build.gradle を次のように編集します。
Gradle
android {
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
}
dependencies {
implementation 'com.amplifyframework:core:0.10.0'
implementation 'com.amplifyframework:aws-api:0.10.0'
}
準備はこれで完了です。
Amplify Libraries のセットアップ
この Andorid プロジェクトをどうやって Raspberry Pi 上から構築した既存の Node.js の Amplify プロジェクトに接続するのでしょう?
実は Amplify には amplify pull というコマンドを使って異なるプラットフォーム同士で同じプロジェクトや GraphQL スキーマなどを共有して簡単にコラボレーションできるという便利な機能が備わっています。
% amplify pull
ここではこの環境に初めて Amplify をセットアップする状況を想定し Do you want to use an AWS profile ? には No と回答し、Raspberry Pi 上に Amplify をセットアップする際にメモしたアクセスキーとシークレットキー、リージョンなどを手動で入力します。すると Node.js で構築した Amplify プロジェクト buildersflash が一覧に表示されるので選択します。
クリックすると拡大します
そして、リソースディレクトリとしてデフォルトの app/src/main/res を選択します。
クリックすると拡大します
Android Studioで確認すると app/src/main/res に
- amplifyconfiguration.json
- awsconfiguration.json
が作成されていることがわかります。中身の細かい点は意識する必要がありませんが、この中に既存の Amplify プロジェクトで設定したのと同じエンドポイントや API Key が書き込まれています。
また、Android Studio を Project ビューに切り替えて amplify/backend/api/amplifyDatasource/schema.graphql を確認すると、こちらも既存の Amplify プロジェクトで作成した GraphQL スキーマが同期していることが見て取れます。
クリックすると拡大します
ここでプロジェクトルートで ./gradlew modelgen コマンドを実行すると、このスキーマを元に com.amplifyframework.datastore.generated.model.Sensor などのプラットフォーム固有のモデルファイルが自動生成されていることがわかります。
% ./gradlew modelgen
クリックすると拡大します
あとはこれを使ってデータの読み書きをするだけです。このようにプラットフォームをまたいでもシームレスな開発体験を得られることがご理解いただけると思います。
API Key を使ったデータの読み出し
まずは Node.js のプロジェクト同様、シンプルに API Key を使ったデータの読み出しを試してみます。と言っても、amplify pull した時点で API Key はすでにプロジェクトに取り込まれているので、アプリケーションコードからは単に Amplify からデータを取得するだけです。
最初にアプリケーション内で Amplify を初期化します。カスタムアプリケーションクラス MyApplication を作成します。
Kotlin
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
try {
// Amplifyをアプリ全体で1度だけ初期化する
Amplify.addPlugin(AWSApiPlugin())
Amplify.configure(applicationContext)
Log.i("ApiQuickstart", "All set and ready to go!")
} catch (exception: AmplifyException) {
Log.e("ApiQuickstart", exception.message, exception)
}
}
}
MyApplication は忘れないように AndroidManifest.xml に登録しましょう。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="us.shiroyama.buildersflash">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".MyApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
それではクエリを試してみましょう !
クエリは Amplify.API.query() メソッドで簡単に試すことができます。第1引数には取得する対象のデータ型 (今回は Sensor::class.java)を、第 2 引数にはクエリ条件 (今回はセンサー ID が一致するレコード) を、最後に第 3, 第 4 引数に結果を受け取るコールバックを渡します。
Kotlin
private fun listSensors() {
// クエリ
Amplify.API.query(
Sensor::class.java,
Sensor.ID.eq(SENSOR_ID),
Consumer { result ->
Log.i(TAG, "result: ${result.data}")
// result を使って画面を更新
},
Consumer { error -> Log.e(TAG, error.message, error) }
)
}
第 2 引数のクエリ条件を組み立てる部分は and() でつないでいくことで複数条件を簡単に指定することができます。たとえば次の listSensorsWithCondition() ではセンサー ID に加えて and(Sensor.HUMIDITY.gt(70.0) の部分で「湿度が 70 % より多い」という条件を与えています。ログを見て、指定した通りに絞り込まれたデータが取得できていることを確認してください。
Kotlin
private fun listSensorsWithCondition() {
Amplify.API.query(
Sensor::class.java,
// 任意の条件を組み合わせる
Sensor.ID.eq(SENSOR_ID).and(Sensor.HUMIDITY.gt(70.0)),
Consumer { result ->
Log.i(TAG, "result: ${result.data}")
// result を使って画面を更新
},
Consumer { error -> Log.e(TAG, error.message, error) }
)
}
Elasticsearch を使ったクエリ
これで見事に条件付きでクエリをすることができたのでしょうか。試しに確認してみることにします。
AWS マネジメントコンソールにログインし、AWS Amplify のページに移動します。
Amplify CLI で作成したプロジェクトを選択します。
Backend environments > API と選択します。
API > View in AppSycn と選択します。こうすることでバックエンドの AppSync の設定を見ることができます。
AppSync のメニューから「スキーマ」を選択してスキーマを確認してみます。
想像以上にたくさんのスキーマ定義が自動生成されていることに驚かれるかもしれません。中を注意深く見ていると type Query の中にいくつかのクエリを見つけることができます。
この中で、
- listSensors : DynamoDB をデータソースとしたリゾルバに紐づく
- searchSensors : Elasticsearch をデータソースとしたリゾルバに紐づく
というように、利用するリゾルバによってデータソースが変わることが見て取れます。今回のアプリで利用したいのは searchSensors の方です。
そこで、ログを有効にしてどのクエリとリゾルバが使われているのか確認してみたいと思います。
設定 > ログを有効化 して、ログレベルを「すべて」に設定します。
マネジメントコンソールの CloudWatch から「ロググループ」を選択し、フィルタに /aws/appsync と入力してログを確認します。
ログを確認すると、RequestMaping のあたりに listSensors という文字列が見えます。これは listSensors が使われているということなので、正しくElasticsearch にリクエストが飛んでいません。
実は、Andorid 側のコードでデフォルトのままクエリ条件を指定しても、それはあくまで DynamoDB のキーでヒットさせた後、DynamoDB のフィルタ式で条件にマッチするものを絞り込んでいるに過ぎないのです。この方法だと、同じセンサー ID のレコードを一旦すべて取得してからフィルタすることになるので効率がよいとは言えません。
そこで、確実に searchSensors が使われるクエリで確認してみたいと思います。
AppSync ではマネジメントコンソール上から自分で任意の GraphQL クエリを組み立てて実際のバックエンドに対して簡単にリクエストを投げることができます。AppSync の左メニューから「クエリ」を選択し、つぎのようなクエリを自分で入力してください。
query SearchSensor(
$filter: SearchableSensorFilterInput
$limit: Int
$nextToken: String) {
searchSensors(
filter: $filter,
limit: $limit,
nextToken: $nextToken) {
items {
battery
datetime
humidity
id
name
temperature
}
nextToken
}
}
次に、「クエリ変数」に次のような JSON を入力してください。
{
"filter": {
"and": [
{
"id": {
"eq": "B0:7E:11:EE:5A:08"
}
},
{
"temperature": {
"gt": 0
}
},
{
"humidity": {
"gt": 0
}
},
{
"datetime": {
"gt": "2020-05-01T00:00:00Z"
}
},
{
"datetime": {
"lt": "2020-05-06T00:00:00Z"
}
}
]
},
"sort": {
"field": "datetime",
"direction": "desc"
},
"limit": 1000
}
これで実行ボタンを押して、次の図のように右ペインにクエリ結果が表示されたら成功です。
もう一度ログを確認してみると、今度は意図通りに searchSensors が使われていることがわかります。
したがって、Android 側でもこれと同じクエリを発行できれば Elasticsearch を使った検索をすることができます。
Android から Elasticsearch を使った検索
残念ながら Amplify Libraries for Android はまだプレビューということもあって、 searchSensors を簡単に使うための仕組みがまだ提供されていません。ただし、Node.js 版クライアントで確認したように、結局はクエリのための文字列を組み立てられればよいので、今回は自分で組み立てることにします。
Amplify.API.query() は GraphQLRequest<T> を使って任意のクエリを実行することができるので、この部分を自作して searchSensors が呼ばれるようにします。
Kotlin
val request: GraphQLRequest<Sensor> = /* ここを自作する */
Amplify.API.query(
request,
{ result -> },
{ error -> }
)
クエリはどのように組み立てても自由なのですが、既存のコードがAppSyncGraphQLRequestFactory.java というクラスでクエリを構築しているので、将来的に追加されるときに近い API になると想像し、公式の実装を尊重して次のように buildQuery() メソッドを作りました。これがベストという訳ではまったくないので、実装時にはどのように書いていただいても問題ありませんが、参考までに掲載します。
Kotlin
private val DEFAULT_QUERY_LIMIT = 1000
private val DEFAULT_LEVEL_DEPTH = 2
// sortSensors のための GraphQLRequest を作成するメソッド
@Throws(ApiException::class)
fun <T : Model> buildQuery(
modelClass: Class<T>,
predicate: QueryPredicate?,
sortMap: Map<String, String>?
): GraphQLRequest<T> {
return try {
val doc = StringBuilder()
val variables: MutableMap<String, Any> = HashMap()
val schema = ModelSchema.fromModelClass(modelClass)
val graphQlTypeName = schema.getName()
// 生成されたモデルを使ってクエリを組み立て
doc.append("query ")
.append("Search")
.append(StringUtils.capitalizeFirst(graphQlTypeName))
.append("(")
.append("\$filter: Searchable")
.append(graphQlTypeName)
.append("FilterInput ")
.append("\$sort: Searchable")
.append(graphQlTypeName)
.append("SortInput ")
.append("\$limit: Int \$nextToken: String) { search")
.append(StringUtils.capitalizeFirst(graphQlTypeName))
.append("s(filter: \$filter, sort: \$sort, limit: \$limit, nextToken: \$nextToken) { items {")
.append(
getModelFields(
modelClass,
DEFAULT_LEVEL_DEPTH
)
)
.append("} nextToken }}")
// filter or limit があればそれを追加
predicate?.let {
variables["filter"] = parsePredicate(predicate)
variables["limit"] = DEFAULT_QUERY_LIMIT
}
// sort 条件があればそれを追加
sortMap?.let {
variables["sort"] = sortMap
}
GraphQLRequest(
doc.toString(),
variables,
modelClass,
GsonVariablesSerializer()
)
} catch (exception: AmplifyException) {
throw ApiException(
"Could not generate a schema for the specified class",
exception,
"Check the included exception for more details"
)
}
}
これを踏まえて searchSensors クエリを使うメソッドは次のようになりました。
Kotlin
private fun searchSensors(
temperature: Float,
humidity: Float,
dateFrom: String,
dateTo: String,
sortField: String,
sortDirection: String
) {
// 検索条件を組み立て
val predicate = Sensor.ID.eq(SENSOR_ID)
.and(Sensor.TEMPERATURE.gt(temperature))
.and(Sensor.HUMIDITY.gt(humidity))
.and(Sensor.DATETIME.gt(dateFrom))
.and(Sensor.DATETIME.lt(dateTo))
val sortMap: Map<String, String> = mapOf("field" to sortField, "direction" to sortDirection)
val request = buildQuery(Sensor::class.java, predicate, sortMap)
// 自作した buildQuery で searchSensors クエリを実行
Amplify.API.query(
request,
{ result ->
Log.i(TAG, "result: ${result.data}")
// このメソッドはこれから実装
initChart(result.data ?: emptyList())
},
{ error -> Log.e(TAG, error.message, error) }
)
}
クエリ結果がログに出力されること、CloudWatch Logs で正しく searchSensors が使われていることなどを確認してください。
チャートを描画する
期待した通りのクエリが行えるようになったので、いよいよその結果を使ってチャートを描画してみたいと思います。今回は MPAndroidChart というライブラリを利用しました。
サイトを参考に次のように依存関係を指定します。
Gradle
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
}
今回はラインチャートを表現するため、LineChart クラスをレイアウトに追加して各データポイントを追加することでチャートを実装しました。
なお、このライブラリの細かい使い方は本記事の主眼ではないため、詳細な使い方は本ライブラリの公式サイトに譲ります。ここでは val timeDiffList について簡潔に説明します。
MPAndroidChart は、各データポイントを表現する Entry クラスに x 軸、y 軸の値を浮動小数点で渡す設計になっています。したがって、各点の x 軸は前の点からの「経過分数」を計算して渡すようにしました。なので、これは単に最初のデータポイントからの経過分数の差分が蓄積していると考えてください。
以上を踏まえ、チャート描画のための initChart() メソッドは次のようになりました。
Kotlin
// ラインチャート
lateinit var lineChart: LineChart
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
lineChart = findViewById(R.id.line_chart)
}
private fun initChart(sensors: Iterable<Sensor>) {
val list = sensors.iterator().asSequence().toList()
// データが空ならば初期表示
if (list.isEmpty()) {
toast("Not Found")
lineChart.data = null
lineChart.invalidate()
return
}
// 最初のデータポイントの日付をラインチャートの詳細として表示
val description = dateToISO8601(list[0].datetime)
lineChart.description = Description().apply { text = description }
val temperatureList = ArrayList<Entry>()
val humidityList = ArrayList<Entry>()
val batteryList = ArrayList<Entry>()
// 基準となる最初のデータポイントからの差分を分単位で計算
val timeDiffList = ArrayList<Float>().apply { add(0.0f) }
for (i in 1 until list.size) {
val to = list[i].datetime.time
val from = list[i - 1].datetime.time
val diff = (to - from) / (1000 * 60)
timeDiffList.add(timeDiffList[timeDiffList.size - 1] + diff.toFloat())
}
// 各データポイントを作成
for ((i, sensor) in list.withIndex()) {
val diff = timeDiffList[i]
temperatureList.add(Entry(diff, sensor.temperature))
humidityList.add(Entry(diff, sensor.humidity))
batteryList.add(Entry(diff, sensor.battery))
}
// データポイントのリストからデータセットを作成するためのローカル関数
fun createDataSet(list: List<Entry>, label: String, @ColorInt colorInt: Int): LineDataSet {
return LineDataSet(list, label).apply {
setDrawIcons(false)
color = colorInt
setCircleColor(colorInt)
lineWidth = 1f
circleRadius = 3f
setDrawCircleHole(false)
valueTextSize = 0f
setDrawFilled(true)
formLineWidth = 1f
formLineDashEffect = DashPathEffect(floatArrayOf(10f, 5f), 0f)
formSize = 15f
fillColor = colorInt
}
}
val temperatureSet = createDataSet(temperatureList, "Temperature", Color.BLUE)
val humiditySet = createDataSet(humidityList, "Humidity", Color.GREEN)
val batterySet = createDataSet(batteryList, "Battery", Color.RED)
// 描画するためにデータセットを束ねる
val dataSets = ArrayList<ILineDataSet>().apply {
add(temperatureSet)
add(humiditySet)
//add(batterySet)
}
// ラインチャートに描画
val lineData = LineData(dataSets)
lineChart.data = lineData
lineChart.invalidate()
}
取得したデータを使って、次のようなチャートが描画できれば完成です !
認証機能の追加
API Key でクエリできることはわかったので、最後に認証機能を追加したいと思います。Amplify では Amazon Cognito と連携して認証機能を簡単にアプリに組み込むことができます。
Amplify では認証モードとして「デフォルトの認証」と「追加の認証プロバイダ」を持つことができます。今回はアプリからはサインアップしたアカウントでログインして使わせるために、デフォルトの認証を Amazon Congnito User Pool、バックエンドの Node.js からは引き続きデータを書き込みたいので、追加の認証を API Key にすることにします。
まず、API の設定を変更するために amplify update api コマンドを実行します。
% amplify update api
対話型コンソールでは Update auth settings を選択します。
クリックすると拡大します
default authrization type は Amazon Congnito User Pool を選択します。
クリックすると拡大します
Configure additional auth type? : Yes とし、API Key を選択します。
クリックすると拡大します
API Key の情報は、以前 Node.js 環境で作ったのと同じものを入力して構いません。以前のキーはそのまま使い続けることができます。
クリックすると拡大します
スキーマのアップデート
複数の認証プロバイダを設定した場合、GraphQL のスキーマファイルに @auth ディレクティブを追加し、認証モードごとのアクセス権を設定します。
たとえば { allow: public, provider: apiKey, operations: [read, create] } とすることで、API Key 経由では読み書きができることを、 { allow: private, provider: userPools, operations: [read] } とすることで Cognito User Pool 経由では読み出しのみできることを意味します。
type Sensor @model
@searchable
@key(fields: ["id", "datetime"])
@auth(rules: [
{ allow: public, provider: apiKey, operations: [read, create] },
{ allow: private, provider: userPools, operations: [read] },
]){
id: ID!
name: String!
temperature: Float!
humidity: Float!
battery: Float!
datetime: AWSDateTime!
}
ここまで完了したら、 amplify push で変更をプロビジョニングします。
これで Android アプリから Congnito User Pool を使う準備ができました。
Android アプリに認証機能を組み込む
それでは Cognito User Pool を使った認証機能を Android アプリに組み込みたいと思います。アプリケーションレベルの build.gradle に次の依存関係を追加します。
Gradle
//For AWSMobileClient only:
implementation 'com.amazonaws:aws-android-sdk-mobile-client:2.16.+'
//For the drop-in UI also:
implementation 'com.amazonaws:aws-android-sdk-auth-userpools:2.16.+'
implementation 'com.amazonaws:aws-android-sdk-auth-ui:2.16.+'
//For hosted UI also:
implementation 'com.amazonaws:aws-android-sdk-cognitoauth:2.16.+'
また、そのままユーザに表示して使える Drop-in UI を利用したい場合は、API レベルを 23 以上にする必要があります。
Gradle
minSdkVersion 23
最後に、AndroidManifest.xml に次のパーミッションがあることを確認してください。
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
アプリに組み込む際には、まず AWSMobileClient を初期化します。
Kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// AWSMobileClient を初期化
AWSMobileClient.getInstance()
.initialize(applicationContext, object : Callback<UserStateDetails> {
override fun onResult(userStateDetails: UserStateDetails) {
Log.i(TAG, "onResult: " + userStateDetails.userState)
}
override fun onError(e: Exception) {
Log.e(TAG, "INIT: Initialization error.", e)
}
})
}
次に UserStateListener を用意し、サインインしていない場合はログイン画面を表示するように実装します。このリスナーは前出の AWSMobileClient に登録することで、ログイン状態の変更に応じて処理を変えることができます。
Kotlin
// サインインしていない場合にログイン画面を表示するリスナー
private val userStateListener = UserStateListener { details ->
Log.i(TAG, "onUserStateChanged: " + details.userState)
when (details.userState) {
UserState.SIGNED_IN -> {
Log.i(TAG, "userState: SIGNED_IN")
}
else -> {
Log.i(TAG, "userState: else: " + details.userState)
AWSMobileClient.getInstance().showSignIn(this@MainActivity)
}
}
}
リスナーの登録と解除は、画面を行き来してもリスナが多重に登録されないように onStart/onStop などで行うと良いでしょう。
Kotlin
override fun onStart() {
super.onStart()
// onStart でリスナの登録
AWSMobileClient.getInstance().addUserStateListener(userStateListener)
}
override fun onStop() {
// onStop でリスナの解除
AWSMobileClient.getInstance().removeUserStateListener(userStateListener)
super.onStop()
}
これで準備ができました ! アプリを起動し、サインインしていない場合はログイン画面が表示されることを確認してください。この画面の Create New Account からアカウントの作成もすることができます。
ログインに成功したらチャートを表示できることも確認してください。
最後に、ログアウト機能を実装します。ログアウトは Android のオプションメニューとして追加することにします。
res/menu/menu.xml を次のように作成します。
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menuSignOut"
android:title="Sign Out"
app:showAsAction="always" />
</menu>
そして、コード上から inflate し、メニュー選択時にログアウト機能が実行されるように実装します。
Kotlin
// ログアウト用のレイアウトを描画
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu, menu)
return true
}
// オプションメニューを実装
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menuSignOut -> {
logout()
true
}
else -> {
super.onOptionsItemSelected(item)
}
}
}
// ログアウト処理
private fun logout() {
AWSMobileClient.getInstance().signOut(
SignOutOptions.builder().invalidateTokens(true).build(),
object : Callback<Void> {
override fun onResult(result: Void?) {
Log.i(TAG, "signOut(): onResult ok")
}
override fun onError(e: java.lang.Exception?) {
Log.e(TAG, "signOut(): onResult error")
}
})
右上にサインアウトメニューが表示され、タップすることでログアウトできたら完了です。
これでアプリに認証機能を組み込むことができました !
おわりに
非常に駆け足になってしまいましたが、これで基本的な機能をすべて実装することができました。多様な要件が求められるアプリでありながら、Amplify を使って Node.js で構築したバックエンドの設定を Android から pull してシームレスに連携するなど、その便利な機能の一端をご紹介できたと思います。
今回たまたまフロントエンドに Android を選びましたが、すでにバックエンドは構築してあるので iOS でもウェブでもフロントエンドを実装することができます。API デザインは共通しているので、プラットフォームを行き来しても迷うことなく開発できることでしょう。
また、今回は他の AWS サービスと連携するところまで紙面を割くことができませんでしたが、
- 特定のセンサーが特定の値を超えたらメールや SMS で通知する
- そのイベントをトリガーにしてエアコンをオン/オフする
- お風呂の給湯温度を調整する
など、想像力の働く限り色んな面白いことを実現することができるでしょう。まさに可能性は無限大 !
願わくは、この記事が読者のみなさんの想像力を掻き立て、Amplify と AWS のサービスを使って楽しくて便利な世界が加速する一助となることを祈念してやみません。最後までお読みいただきありがとうございました !
筆者プロフィール
白山 文彦 (しろやま ふみひこ)
アマゾン ウェブ サービス ジャパン合同会社
プロトタイピング ソリューションアーキテクト
インフラからウェブアプリ、モバイルアプリ開発まで何でもやるプログラマ。プロトタイピングスペシャリストとしてお客様のアイディアを現実世界に持ってくるお手伝いをしています。
趣味は子育て、懸垂、競技プログラミングなど。
AWS を無料でお試しいただけます