この記事は株式会社富士通システムズウェブテクノロジーが企画するいのべこ夏休みアドベントカレンダー 2020の29日目の記事です。本記事の掲載内容は私自身の見解であり、所属する組織を代表するものではありません。
#はじめに
さて、
かわいいですよね。この子。
一見、チンアナゴのようにも見えますが、この子はDenoのマスコットキャラクターです。
##Denoとは?
DenoはNode.jsの製作者であるRyan Dahlによって作られた、新しいJavaScript/TypeScriptランタイムです。バージョン1.0.0が、2020年5月13日にリリースされたばかりです。
Ryan Dahlが、自身が製作したNode.jsで後悔している設計ミスを踏まえて作られています。
Ryan DahlがJSConf EU 2018で講演した「Node.jsに関する10の反省点」がYoutubeにあります。
10 Things I Regret About Node.js - Ryan Dahl - JSConf EU
日本語で分かりやすく解説しているエントリーはこちらです。
Node.js における設計ミス By Ryan Dahl - from scratch
(余談になりますが、自分が製作したものに対して、後悔している点を率直に述べているのが好印象でした。次に繋げていくためにも、失敗を失敗と素直に認められるようにありたいです。)
Denoの詳細については、こちらのエントリーが参考になります。
Denoとはなにか - 実際につかってみる - Qiita
Denoの登場でNode.jsの時代は終わるのか? - Qiita
私のJSの知識は、JQueryぐらいで止まっていると言っていいレベルですが、Denoくん(?名前は不詳)のかわいさに惹かれて、Denoを使ってアプリを作ってみることにしました。
なにぶんJSに不慣れなため、気づかずに不適切なコードを載せてしまっているかもしれません。とりあえず、動かしてみたというレベルのコードですので、その様な目で見てもらえると幸いです。
#やりたいこと
私が所属する部門では、部門運営のタスクをTrelloでチケット管理し、コミュニケーションツールとしてSlackを活用しています。
部長が超絶マメなため、自らTrelloに部員が行うべきタスクをチケット化してキッチリ起票してくれます。ただ、私のようなうっかり者の部員は、うっかり社内タスクの期日を忘れ、期限を過ぎてしまうことも・・・
ということで、社内タスクの対応漏れを減らし、期限が切れタスクがあればすぐに気づいて対応できるようにしたい。そのために、作りたいものは以下です。
- 毎朝、期限当日のTrelloのタスクをSlackに通知する
- 毎朝、期限切れのTrelloのタスクをSlackに通知する
定期的なタイミングでSlackに通知できればよいので、バッチ処理のようなものを作り、それをcronからスケジュール起動するのが最もシンプルな気がします。しかし、せっかくなのでDenoを使いREST APIとして実装してみたいと思います。
REST APIをサービス起動しておいて、cronで定期的にそこへ向けてcurlコマンドを実行するイメージですね。
準備
Trelloで開発者向けAPIキー、トークン発行
期限が迫ったTrelloのカード(Trelloではチケットのことをカードと呼びます)をAPIで取得するためには、以下が必要となります。
- 開発者向けAPIキー
- トークン
上記は以下の手順で取得することができます。
- Trelloにログイン
- https://trello.com/app-key にアクセス → 開発者向けAPIキーが表示されるのでそれをコピー
- トークンの有効化 → 許可をクリック → トークンが表示されるので、それをコピー
Trello のREST API調査
Trelloから取得したいのは、「今スプリントでやること」と「進行中」の2つのリスト(Trelloでは列のことをリストと呼びます)上にあるカードの一覧です。カード情報として取得したい項目は以下です。
- タイトル
- カードのURL
- 期限
- メンバー
Trello の REST APIリファレンスに該当するAPIが存在しないか確認します。
The Trello REST API
探してみると、Get Cards in a List というAPIが今回の用途にマッチしました。
- Get Cards in a List
このAPIのパラメータには、id : The ID of the list が必須となっています。
リストIDは以下のAPIを実行すると取得できます。
- Get Lists on a Board
こちらのAPIのパラメータには、id : The ID of the board が必須となっています。
ボードIDは、Trelloでボードを開くとURLに含まれているので、そこから取得可能です。
https://trello.com/b/{ボードID}/{ボード名}
このボードIDと、開発者向けAPIキー、トークンをパラメータに指定し、「Get Lists on a Board」のAPIを実行することで、「今スプリントでやること」と「進行中」の2つのリストIDが取得できます。さらに、取得したリストID(開発者向けAPIキー、トークンも)をパラメータに指定し、「Get Cards in a List」のAPIを実行することで、リスト上のカード一覧が取得できます。
取得されてくるカード情報の中には、以下の欲しい項目が含まれていることが確認できました。
- タイトル →
name
- カードのURL →
shortUrl
- 期限 →
due
- メンバー →
idMembers
APIの実行は、PostmanというWebAPIのテストクライアントツールを使いました。UIの使い勝手が良く、実行したリクエストの履歴が保存されるのも便利です。
ここまでで、TrelloのAPIで欲しい情報が取得できることと、どのAPIをどんなパラメータで実行すれば良いのかが把握できました。
Slack App 登録、Incoming Webhooks 設定
確か、SlackにIncoming Webhooksを登録するには、チャンネルに対してカスタムインテグレーションを設定する流れだったと記憶していたのですが、Slackの仕様が変更されており、個別アプリから登録するフローとなっていました。
新しい仕様については、こちらのエントリーを参考にさせていただきました。
slackのIncoming webhookが新しくなっていたのでまとめてみた - Qiita
そこには重要な仕様変更が記載されていました。それはこちらです。
- Postのパラメータでusernameやicon_emoji/icon_urlなどを指定してもユーザ名やアイコンは変えれない
実はこの仕様変更を把握しておらず、実際にパラメータ指定してPOSTしても変更されないなー、おかしいなーとという状況に陥りました。
今回、期限当日のTrelloのタスクを通知するアイコンと、期限切れのTrelloのタスクをSlackに通知するアイコンは別にしたいと考えていました。新しい仕様では、Appを2つ作成し、それぞれIncoming Webhooksを設定する必要がありそうです。
Appの登録と、Incoming Webhooksの設定は、以下のURLから行えます。
https://api.slack.com/apps
設定手順はシンプルで、特に迷うことなく設定できました。詳細な手順はここでは割愛します。登録した2つアプリはこちらです。
どちらが期限切れを通知するアプリかは、アイコンを見れば一目瞭然ですね!
Denoのインストール(Windows)
続いて、Denoの開発環境構築です。
私の開発端末にWindows 10 Pro のノートPCを利用しているため、Windows OS のインストール手順となります。
インストール方法は、公式サイトに書かれている通り行えばOKです。
https://deno.land/#installation
PowerShell (Windows):
iwr https://deno.land/x/install/install.ps1 -useb | iex
インストールができているかの確認のため、下記コマンドを実行します。
$ deno -v
deno 1.3.1
公式サイトにあるGetting Started のsimple program を実行してみます。
$ deno run https://deno.land/std/examples/welcome.ts
Welcome to Deno 🦕
Denoくんが出てきました!
さらにもう一つ、Getting Startedにあるより複雑なプログラムを実行します。
ローカルに server.ts ファイルを作成し、公式サイトのコードを貼り付けます。
import { serve } from "https://deno.land/std@0.67.0/http/server.ts";
const s = serve({ port: 8000 });
console.log("http://localhost:8000/");
for await (const req of s) {
req.respond({ body: "Hello World\n" });
}
こちらのコードは特に説明がありませんが、stdのhttpライブラリを利用し、8000ポートでhttpサーバが立ち上がるものと理解しています。
以下のコードで実行します。
$ deno run --allow-net server.ts
ここで指定しているオプションの --allow-net
は、ネットワークにアクセスするために必要なものです。
Denoはデフォルトでセキュアになっており、ネットワークやファイルへのアクセスが必要な場合、オプションで許可しないとアクセスが有効になりません。
Deno is secure by default. Therefore, unless you specifically enable it, a deno module has no file, network, or environment access for example. Access to security-sensitive areas or functions requires the use of permissions to be granted to a deno process on the command line.
DeepLによる翻訳↓
Denoはデフォルトでセキュアになっています。そのため、特に有効にしない限り、Denoモジュールは、例えばファイル、ネットワーク、環境へのアクセスはできません。セキュリティ上重要な領域や機能へのアクセスは、コマンドライン上で deno プロセスに付与されるパーミッションを使用する必要があります。
サーバ起動後、別のコンソールでcurlコマンドを実行すると、Hello Worldが返ってくるはずです。
$ curl http://localhost:8000/
Hello World
アプリ作成
まずは、シンプルなREST APIをPOSTで作成
いよいよ準備が終わり、REST APIの実装をしていきます。
まずは、固定のメッセージをレスポンスとして返却するだけのREST APIをPOSTで作成します。
作成するREST APIのURIは以下とします。
APIを作成していくにあたって、以下のソースコードを参考にさせていただきました。
Push9828/deno-demo
Controllerの実装
リクエストが呼び出されたときに実行する処理をControllerに実装します。
controllers/
配下に、slack_notification.ts を作成します。
// @desc POST notify slack
// @route POST /api/v1/notify
const notify = async ({ response }: { response: any }) => {
response.body = {
success: true,
data: 'notification completed'
}
}
export { notify }
Routerの実装
URLとControllerをマッピングするRouterを実装します。
ルート直下に routes.ts を作成します。
import { Router } from 'https://deno.land/x/oak/mod.ts'
import { notify } from './controllers/slack_notification.ts'
const router = new Router()
router.post('/api/v1/notify', notify)
export default router
Applicationの実装
Appのエントリーポイントを実装します。
server.ts を書き換えます。
import { Application } from 'https://deno.land/x/oak/mod.ts'
import router from './routes.ts'
const port = 8000
const app = new Application()
app.use(router.routes())
app.use(router.allowedMethods())
console.log(`Server running on port ${port}`)
await app.listen({ port })
サーバ起動&動作確認
$ deno run --allow-net server.ts
Server running on port 8000
$ curl -X POST http://localhost:8000/api/v1/notify
{"success":true,"data":"notification completed"}
起動したサーバに対して、POSTでリクエストを実行すると、正常にレスポンスが返ってきます。
これでAPIの骨格ができたので、内部の作り込みをしていきます。
Http clientをどうするか問題
今回作成するAPIは、TrelloやSlackに対してAPIの呼び出しを行いたいので、Http clientが必要となります。JSのHttp clientは、axiosが有名のようですが、DenoはXHR(XMLHttpRequest)オブジェクトを持たないためaxiosを使うことはできないようです。Denoで扱えるHttp clientのライブラリをネットで探した結果、TypeScriptでも使えてREADME.mdが充実しているsoxaというライブラリを使うことにしました。
fakoua/soxa: Promise based HTTP client for the deno
後で調べた所、ky というライブラリも有名なようです。どちらかと言えば、こちらの方が情報が多そうです。
sindresorhus/ky: 🌳 Tiny & elegant HTTP client based on window.fetch
Trelloの当日期限/期限切れカードの取得
soxaライブラリを利用してTrelloのREST API 呼び出しをしていく訳ですが、その前に開発者向けAPIキーや、トークンなどの設定値を保持するファイルを作成します。jsonにするか迷いましたが、JSの方が楽そうなので、settings.jsを作成し、そこに設定値を集約させることにしました。
export default {
trello : {
auth : {
token : "{トークン}",
key : "{開発者向けAPIキー}"
},
listids : ["{リストID}", "{リストID}"]
},
slack : {
notification_caution : {
webhook_url : "https://hooks.slack.com/services/xxxxxxx/xxxxxxx/xxxxxxx"
},
notification_alert : {
webhook_url : "https://hooks.slack.com/services/xxxxxxx/xxxxxxx/xxxxxxx"
}
},
users : [{
"name" : "{部員の名前}",
"trello_username" : "{Trelloのプロフィール名}",
"slack_member_id" : "{SlackのメンバーID}"
}, {
"name" : "{部員の名前}",
"trello_username" : "{Trelloのプロフィール名}",
"slack_member_id" : "{SlackのメンバーID}"
}, {
...
}
]
}
ここで定義した設定値を利用しつつ、ControllerにTrelloの当日期限/期限切れカードの取得処理を実装します。
import { soxa } from 'https://deno.land/x/soxa/mod.ts'
import { format } from 'https://deno.land/x/date_fns/index.js'
import settings from '../settings.js'
// @desc POST notify slack
// @route POST /api/v1/notify
const notify = async ({ response }: { response: any }) => {
let target_list_on_cards = null;
for (const listid of settings.trello.listids) {
let list_on_cards = await soxa.get('https://api.trello.com/1/lists/' + listid + '/cards?key=' + settings.trello.auth.key + '&token=' + settings.trello.auth.token)
target_list_on_cards = (target_list_on_cards == null) ? list_on_cards.data : target_list_on_cards.concat(list_on_cards.data)
}
let today = format(new Date(), 'yyyy-MM-dd')
console.log('today : ' + today)
const tasks_due_today = target_list_on_cards.filter((v: any) => (v['due'] != null && v['due'].slice(0, 10) === today))
const overdue_tasks = target_list_on_cards.filter((v: any) => (v['due'] != null && v['due'].slice(0, 10) < today))
for (const task of tasks_due_today) {
await notifySlackOfTasksDueToday(task)
}
for (const task of overdue_tasks) {
await notifySlackOfOverdueTasks(task)
}
response.body = {
success: true,
data: 'notification completed'
}
}
// 以下、省略
カードを取得したいリストは複数存在するため、特定のリストにあるカード一覧を取得するAPIを複数実行し、取得結果の配列をtarget_list_on_cards
に結合していってます。
そして、target_list_on_cards
からfilterを用いて、当日期限のカードと期限切れカードを抽出し、それぞれtasks_due_today
、overdue_tasks
に配列で保持しています。
Slackへの投稿
続いて、Slackへの投稿処理を実行します。
カードのメンバーに設定された人に対してメンションを飛ばしたいところです。メンションの記法は、<@user_id>
です。このuser_idは、ユーザープロフィールのその他にある「メンバーIDをコピー」で取得できます。
この手順で全員分のメンバーIDをSlackの画面から取得し、settings.js に設定しておきます。
Slackへの投稿処理の関する実装箇所を以下に掲載します。
async function notifySlackOfTasksDueToday(task: any): Promise<void> {
let mention = ''
let idMembers = task['idMembers']
for (const memberId of idMembers) {
const slack_user_id = await getSlackUserId(memberId)
mention += '<@' + slack_user_id + '>'
}
let message = ''
if (mention != null) message += mention + "\n"
message += 'チケットの対応期限が本日迄です。対応をお忘れなく。'+ "\n"
message += "<" + task['shortUrl'] + "|" + task['name'] + ">"
let response = await soxa.post(settings.slack.notification_caution.webhook_url, {}, {
headers: {'Content-type': 'application/json'},
data: {
"text": message
}
});
}
async function notifySlackOfOverdueTasks(task: any): Promise<void> {
let mention = ''
let idMembers = task['idMembers']
for (const memberId of idMembers) {
const slack_user_id = await getSlackUserId(memberId)
mention += '<@' + slack_user_id + '>'
}
let message = ''
if (mention != null) message += mention + "\n"
message += 'チケットの対応期限が切れています。急ぎ対応をお願いします。'+ "\n"
message += "<" + task['shortUrl'] + "|" + task['name'] + ">"
let response = await soxa.post(settings.slack.notification_alert.webhook_url, {}, {
headers: {'Content-type': 'application/json'},
data: {
"text": message
}
});
}
async function getSlackUserId(memberId: string): Promise<string> {
let response = await soxa.get('https://api.trello.com/1/members/' + memberId + '?key=' + settings.trello.auth.key + '&token=' + settings.trello.auth.token)
const usr = settings.users.find((user: any) => user.trello_username === response.data['username'])
return usr.slack_member_id
}
サーバ起動&API実行結果
$ deno run --allow-net server.ts
Server running on port 8000
$ curl -X POST http://localhost:8000/api/v1/notify
{"success":true,"data":"notification completed"}
SlackにTrelloの当日期限/期限切れカードが通知されてきました!
モザイクをかけているため見えませんが、メンションもちゃんとできています。
この後、UbuntuのサーバにDenoの実行環境の構築と、アプリをデプロイしました。
また、毎朝、curlでAPIが実行されるようにcron設定をしました。
これで忘れずにタスクを片付けることができそうです!
終わりに
最後に、アプリ作成を通じて印象に残った事や気づきを記しておきます。
- JSの進化に驚き
- Deno以前の話しですが、久しぶりにJSに触れたこともあり、JSの機能拡張に驚きました。
- ES2015で追加された機能なのでしょうか?アロー関数、for ~ of、find、filter、map、reduceなどの便利な関数があり、今回の実装でもなるべく使うことを心掛けてみました。これらを利用することにより、以前よりシンプルにコードが書けるようになったと実感しました。
- Deno以前の話しですが、久しぶりにJSに触れたこともあり、JSの機能拡張に驚きました。
- ローカルにnode_modulesができないのが嬉しい
- Node.js では、ローカルにnode_modulesディレクトリが生成され、そこに重量級のライブラリがダウンロードされてきますが、それがないのは想像以上に身軽で気持ちの良いです。
- オプション指定しないとネットワークにアクセスできないのが新鮮
- Denoはデフォルトでセキュアになっており、セキュリティ上重要な領域に対するアクセスは、プロセス起動時のコマンドでオプション指定が必要なのが新鮮でした。
- 何となく設計思想にシンプルさが感じられる
- 文字通りですが、Denoの設計思想としてシンプルさを追求している事が伝わってきて素敵でした。
Denoに触れてアプリを作ってみましたが、新しい気づきがいくつもあり、とても楽しかったです。
これからも日々の業務の運用で活用し、改良を重ねていきたいと思います。
それと、TypeSriptの文法を勉強して、コードのリファクタもしたいところです。ユニットテストの作成方法や実行方法についても気になっています。できれば、調べた内容を別途、記事として公開したいと思います。