こんにちは、eiyuuです。
弊社は座る席が自由、いわゆるフリーアドレスであるためその柔軟性という利点を享受できていますが、反面誰かと口頭で話をしたい場合や電話の取り次ぎの際、誰がどこにいるのかがわかりにくいという欠点も抱えています。
そこで社内の不便を見過ごせない男No.1のtacckさんと組んで、 フリーアドレス向け座席管理システム「せきとりくん」の開発をしています。
せきとりくんはこんな画面(図1)のシステムです。
2020/05/18現在、ざっくり以下のような機能を有しています。
せきとりくんでは各利用者が出勤しているかどうか、しているなら着席しているかどうかを確認することができます。図1左側の利用者一覧で、Aさんが未出勤、Bさんが出勤しているが離席中、eiyuuとtacckが着席中であることを表しています。しかし、せきとりくんのWeb UI自体に出退勤の状態を操作する機能はありません。なぜなら弊社では出退勤の管理に他社製の有料Webサービス(以下「勤怠B」と呼称)を使用しているため、せきとりくん上での出退勤操作を可能にしてしまうと状態の不整合が起こりうるからです。代わりに、勤怠Bとせきとりくんを連携させることで両システム上での出退勤状態を同期させようという話になったのが、本記事の背景です。
勤怠Bは自社開発の製品ではないため、出退勤のタイミングでせきとりくんにそれを通知するような機能を追加することはできません。また、勤怠Bは出退勤状態を取得できるAPIを提供していないため、定期的にせきとりくん側から勤怠Bに出退勤状態を問い合わせることもできません。そこで、勤怠BのWeb UI上での出退勤ボタンを押すイベントを検知してせきとりくんの出退勤操作APIを呼び出すChromeExtensionを作ろうということになりました。
ChromeExtensionを作るのに始めにやることはなんでしょうか?はい、名前を考えることですね。このChromeExtensionは弊社スタッフ達の出勤を知らせてくれるものです。その力強さを力士のそれに例えて「dohyouiri(土俵入り)」と名付けることにしました。名付けが終わったら次に manifest.json
を用意する必要があります。manifest.json
の最小構成は以下の通りです。
{
"manifest_version": 2,
"name": "dohyouiri",
"version": "0.1"
}
そして manifest.json
さえあればそれはもう立派なChromeExtensionです。その立派なディレクトリ構成が以下です。
dohyouiri/
└── manifest.json
さあGoogle Chromeで拡張機能ページ(chrome://extensions/)にアクセスして右上のデベロッパーモードをONにしてdohyouiriフォルダをウィンドウにドラッグ&ドロップしましょう。
図2のように見事にインストールされるはずです。次のセクションから、今はまだHello Worldよりもシンプルなこのdohyouiriに機能を付け加えていきましょう。
まずはdohyouiriが勤怠B上での出退勤アクションを検知できるようにしましょう。そのためには勤怠BのWeb UIの出退勤の仕組みを知る必要があります。勤怠Bの出退勤画面は図3のようになっています。
出勤時は出勤タブの「打刻」ボタン、退勤時は退出タブの「打刻」ボタンを押すことでそれぞれ出勤・退勤扱いになります。
これらのボタンのonclickイベントを取得できれば良いわけですね。図4・図5のように、出勤タブと退出タブの「打刻」ボタンは同一のエレメント button#js-punchButton
を共有しているようですね。幸い、開いているタブによって適用されているクラスが異なる(.punchButton--clockIn
と .punchButton--clockOut
)ようですので、ここを参照して区別できそうです。
勤怠Bについて十分知れたことですので、プログラムを書いていきましょう。dohyouiri.js
という名前にします。
function onClick(event) {
const class_name = event.currentTarget.className
if (/punchButton--clockIn/.test(class_name)) {
console.log('出勤')
} else if (/punchButton--clockOut/.test(class_name)) {
console.log('退勤')
}
}
const punch_button = document.getElementById('js-punchButton')
punch_button.onclick = onClick
更に、この dohyouiri.js
を使うという宣言を manifest.json
内で行います。
{
"manifest_version": 2,
"name": "dohyouiri",
"version": "0.1",
"content_scripts": [
{
"matches": [
"https://kintaib.example.jp/xxx/yyy/timeclock/punchmark/"
],
"js": [
"dohyouiri.js"
],
"run_at": "document_end"
}
]
}
matches
は、このページの時にスクリプトを実行するというフィルターです。今回は勤怠Bの打刻画面のURLを設定します(書いてあるのはダミーのURL)。js
には実行するスクリプトを設定します。run_at
はスクリプトを実行するタイミングです。今回はDOM構築直後を意味する document_end
を設定します。
現在のディレクトリ構成は以下の通りです。
dohyouiri/
├── dohyouiri.js
└── manifest.json
この状態で再び拡張機能ページにドラッグ&ドロップして打刻画面を一度リロードし、出勤タブと退出タブの「打刻」ボタンを押すと、図6のようにログが吐かれ意図どおりに動作していることがわかります。
イベントを検出できるようになったので、次はせきとりくんにそのことを教えてあげましょう。せきとりくんのAPIはAWS AppSync + Amazon DynamoDBで構築したGraphQL APIとして提供しています。GraphQLという言葉を初めて聞いたという人もいるかと思いますので、少し触れておきます。
GraphQLは、Web APIのためのクエリ言語と、保存されたデータに対する操作を実現するランタイムです。具体例を見てみましょう。GraphQLはこんな見た目の言語です。
query {
getMember(id: "abcdefgh-ijkl-mnop-qrst-uvwxyz123") {
id
name
state {
deskId
isWorking
}
}
}
これをGraphQL APIエンドポイントへのGETリクエストのパラメータとして送信すると、以下のようなレスポンスが得られます。
{
"data": {
"getMember": {
"id": "abcdefgh-ijkl-mnop-qrst-uvwxyz123",
"name": "eiyuu",
"state": {
"deskId": "desk-1",
"isWorking": true
}
}
}
}
クエリとレスポンスが似たような構造を持っているのがわかると思います。このように、GraphQLはクエリで指定した構造に対して過不足なくレスポンスを返すということを規定しています。実際クエリからnameのフィールドを消すと、レスポンスとしてnameは返されなくなります。この性質は複雑な構造を持つデータに対しても効率的にアクセスすることができることを意味し、「複雑な構造を持つデータの取得のためにAPIを何度も呼び出す必要がありがち」というREST APIの弱点に対するGraphQL APIの長所とも言えます(この記事では活かせてないけど!)。
せきとりくんは利用者の状態を保持するために、内部的にMemberState型を持っており、以下のような構造を持っています。GraphQLのスキーマ記述言語により記述されています。AWS AppSyncはこのスキーマ記述言語を解釈してAmazon DynamoDB上にテーブルを作成したり、クライアントから送信されてきたGraphQLを解釈して適切に処理するGraphQL APIエンドポイントを作成してくれます。
type MemberState @aws_api_key
@aws_cognito_user_pools {
id: ID!
deskId: String
isWorking: Boolean
}
id
はただのIDです。deskId
はどの座席に着席しているかを表します。isWorking
は true
の時勤務中、false
の時退勤済を表します。要はこの isWorking
を操作できれば良いわけですね。そのためには、以下のようなGraphQLを投げれば良いです。
mutation {
updateMemberState(input: {id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx", isWorking: true}) {
id
deskId
isWorking
}
}
先に紹介した getMember
とは違って、クエリの始まりが mutation
となっていますね。データを単に取得したい場合は query
から、データの変更を行いたい時には mutation
から始める決まりになっています。また、mutation
の時にはGETではなくPOSTを使います。"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx"には操作したい対象のMemberStateレコードのIDを指定します。ここは実際の使用者によって変わるものなので、パラメータとして渡せるように後から改良します。
まず manifest.json
に手を加えます。ChromeExtensionでリクエストを発行する場合には、その旨とリクエスト先のURLを宣言する必要があります。具体的には以下のように書きます。
{
"manifest_version": 2,
"name": "dohyouiri",
"version": "0.1",
"permissions": ["webRequest", "https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql"],
"background": {
"scripts": ["background.js"]
},
"content_scripts": [
{
"matches": [
"https://kintaib.example.jp/xxx/yyy/timeclock/punchmark/"
],
"js": [
"dohyouiri.js"
],
"run_at": "document_end"
}
]
}
permissions
の項目が増えています。webRequest
はリクエストを発行する旨の宣言で、続くURLはAppSyncのGraphQL APIエンドポイントです。これはAppSyncの設定画面から確認できます(書いてあるのはダミーのURL)。
また、background
の項目も増えています。実はChromeExtensionはこの background
に登録したスクリプトからしかリクエストを発行することができません。messagingという機能を使って dohyouiri.js
から background.js
にリクエストの発行を依頼するという手順を踏む必要があります。ということで、backgroud.js
を書いていきます。
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
const xhr = new XMLHttpRequest()
xhr.open('POST', request.url, true)
if (request.hasOwnProperty('headers')) {
request.headers.forEach(function(header) {
xhr.setRequestHeader(header[0], header[1])
})
}
xhr.onload = function (e) {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
sendResponse({message: 'OK', content: xhr.responseText, request: request, responseUrl: xhr.responseURL})
} else {
sendResponse({message: xhr.statusText})
}
}
}
xhr.onerror = function (e) {
sendResponse({message: xhr.statusText})
}
xhr.send(request.request_body)
return true
})
そして、dohyouiri.js
からmessagingを使って background.js
にURL・GraphQL・リクエストヘッダを送ります。
function createRequestBody(member_state_id, is_working) {
const obj = {
query: `
mutation UpdateMemberState($id: ID!, $isWorking: Boolean!) {
updateMemberState(input: {id: $id, isWorking: $isWorking}) {
id
deskId
isWorking
}
}
`,
variables: {
id: user_id,
isWorking: is_working,
},
operationName: 'UpdateMemberState',
}
return JSON.stringify(obj)
}
function onClick(event) {
const class_name = event.currentTarget.className
const url = 'https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql'
const api_key = 'xxxxxxxx'
const member_state_id = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx'
let request_body = null
if (/punchButton--clockIn/.test(class_name)) {
console.log('出勤')
request_body = createRequestBody(member_state_id, true)
} else if (/punchButton--clockOut/.test(class_name)) {
console.log('退勤')
request_body = createRequestBody(member_state_id, false)
}
if (request_body) {
const message = {
url: url,
headers: [
["Content-Type", "application/json"],
["x-api-key", api_key],
],
request_body: request_body,
}
chrome.runtime.sendMessage(
message,
response => console.log(response.content)
)
}
}
const punch_button = document.getElementById('js-punchButton')
punch_button.onclick = onClick
AppSyncへのリクエストにはAPIキーが必要な設定になっているので、x-api-key
ヘッダにAPIキーを載せます。APIキーはAppSyncの設定画面で確認できます。
また、GraphQLの内容が少し変わっていますね。
mutation UpdateMemberState($id: ID!, $isWorking: Boolean!) {
updateMemberState(input: {id: $id, isWorking: $isWorking}) {
id
deskId
isWorking
}
}
このように書くことで、id
と isWorking
の値をパラメータ化することができます。実際の値は variables
に記載します。実際に「打刻」ボタンを押すと図7のようなレスポンスが得られ、意図どおりに状態が変更されていることがわかります。
先の例では、url
、api_key
、member_state_id
がハードコーディングされていますね。url
は manifest.json
にも同様のものを書く必要があるためある程度仕方ありませんが、api_key
の内容がソース上に存在するのはあまり良くありませんし、member_state_id
に至ってはそもそも1人しか使えないものになってしまいます。ということで、この2つの値をChromeExtensionの設定画面から変更できるようにしましょう。設定画面を追加するには、毎度のごとく、manifest.json
に追記する必要があります。
{
"manifest_version": 2,
"name": "dohyouiri",
"version": "0.1",
"permissions": ["webRequest", "storage", "https://zsk7yzq6xfanjdwdfjhu7bg3gq.appsync-api.ap-northeast-1.amazonaws.com/graphql"],
"options_ui": {
"page": "options.html",
"open_in_tab": false
},
"background": {
"scripts": ["background.js"]
},
"content_scripts": [
{
"matches": [
"https://kintaib.example.jp/xxx/yyy/timeclock/punchmark/"
],
"js": [
"dohyouiri.js"
],
"run_at": "document_end"
}
]
}
options_ui
の項目が増えています。この項目の page
で設定画面のhtmlファイルを指定します。open_in_tab
を false
にすることで、設定画面用の新しいタブを開かないようにします。また、permissions
に storage
が増えています。これはChromeのStorage APIを使う際に必要な宣言です。このStorage APIを使って api_key
と member_state_id
を保存します。まずは options.html
から見ていきましょう。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>dohyouiri</title>
</head>
<body>
<form action="">
<input id="sekitorikun_member_state_id" type="text" placeholder="MemberStateID">
<br>
<input id="sekitorikun_api_key" type="text" placeholder="APIキー">
<br>
<button type="submit" id="confirm">更新</button>
</form>
</body>
<script src="options.js"></script>
</html>
2つの入力フォームと更新ボタンのあるシンプルな設定画面(図8)です。この画面には、拡張機能ページ(chrome://extensions/)の「詳細」-> 「拡張機能のオプション」から行くことができます。実際の保存は options.js
で行います。
const button = document.getElementById('confirm')
button.addEventListener('click', function() {
const values = {}
;['sekitorikun_member_state_id', 'sekitorikun_api_key'].forEach(key => {
values[key] = document.getElementById(key).value
})
chrome.storage.sync.set(values, function(){
chrome.extension.getBackgroundPage().alert('更新しました')
})
})
chrome.storage.sync.set
の第1引数にkey:valueの形で保存したい値を渡すことで、その値がブラウザに保存されます。保存された値は chrome.storage.sync.get
の第1引数に保存したキー名の配列を渡すことで取得できます。これを使って改良した dohyouiri.js
が以下です(変更のあるonClick関数のみ記載)。
function onClick(event) {
chrome.storage.sync.get(['sekitorikun_member_state_id', 'sekitorikun_api_key'], function(options){
const class_name = event.currentTarget.className
const url = 'https://zsk7yzq6xfanjdwdfjhu7bg3gq.appsync-api.ap-northeast-1.amazonaws.com/graphql'
const api_key = options['sekitorikun_api_key']
const member_state_id = options['sekitorikun_member_state_id']
let request_body = null
if (/punchButton--clockIn/.test(class_name)) {
console.log('出勤')
request_body = createRequestBody(member_state_id, true)
} else if (/punchButton--clockOut/.test(class_name)) {
console.log('退勤')
request_body = createRequestBody(member_state_id, false)
}
if (request_body) {
const message = {
url: url,
headers: [
["Content-Type", "application/json"],
["x-api-key", api_key],
],
request_body: request_body,
}
chrome.runtime.sendMessage(
message,
response => console.log(response.content)
)
}
})
}
これにてdohyouiriがめでたく完成です!やったぜ!
最終的なディレクトリ構成は以下の通りです。
dohyouiri/
├── background.js
├── dohyouiri.js
├── manifest.json
├── options.html
└── options.js
本記事では開発中のフリーアドレス向け座席管理システム「せきとりくん」を紹介し、勤怠Bと出退勤状態を同期する方法について説明しました。また、その連携を実現するためのChromeExtension「dohyouiri」を紹介し、その実装を示しました。また、せきとりくんのAPIで使用しているGraphQLという言語について簡単に説明しました。
本記事は社内の改善活動の1つを紹介することが主目的ではありますが、簡単なChromeExtension作成の手引きとしての側面もあります。ちょっと試しに何か作ってみて、ちょっとした(大きくても良い)不便を解決できるきっかけになれれば幸いです。