20200329のNode.jsに関する記事は11件です。

よく使われるHTTPステータスコードをNode.jsの実例で勉強する

はじめに

簡単なアプリを作りながらNode.jsと基本的なサーバーサイドプログラミングについて勉強していました。

また、ステータスコードの扱いについて本で読んでいると、こういう場合にはこのステータスコードを返せばいいよと書かれてあったのですが、実際のコードでそれをどう表現すればいいのか本だけではイマイチ理解できませんでした。

今回はNode.jsの復習を兼ねて手を動かしながら実例を作ってみました。

対象のステータスコード

  • 200: OK
  • 301: Moved Permanently
  • 303: See Other
  • 400: Bad Request
  • 401: Unauthorized
  • 404: Not Found

これ以外のステータスコードについてはまた別の記事でやるかも...?

例題

このような勤怠表を作ります。(見た目素のHTMLですが。。。)
basic認証でのログイン機能と、ログアウト、出勤退勤ボタンを押すと、ユーザ名と勤怠のログが生成される簡単なWebアプリです。
スクリーンショット 2020-03-29 19.09.00.png
簡単に機能を下記にまとめました。

パス メソッド 機能
/top GET 勤怠表の表示
/top/kintai POST 勤怠表への勤怠情報の追加
/logout GET ログアウト機能
/old-url GET 古いURLなので/topへリダイレクトさせる

準備

依存モジュールはこのようになっております。

package.json
{
  "dependencies": {
    "http": "^0.0.1-security",
    "http-auth": "^4.1.2",
    "pug": "^2.0.4"
  }
}

basic認証にはhttp-auth, テンプレートエンジンはpugを使用します。
次に、node.jsの雛形を記述します。

index.js
'use strict';
const http = require('http');
const auth = require('http-auth');
const pug = require('pug');
const basic = auth.basic({
    realm: 'Enter username and password',
    file: './password'
});

const server = http.createServer(basic.check((req, res) => {
    switch (req.url) {
    default:
        break;
    }
}));

const port = 8000;
server.listen(port, () => {
    console.info('Listening on ' + port);
});

ちなみにこのコードはこのままでは動きません。
また、認証に使用するユーザ名とパスワードはこのようなテキストファイルに格納しておきます。

password
guest1:2222
guest2:3333

ステータスコード : 200 OK

このステータスコードはリクエストが正常に完了したことを表します。GET,PUT,POSTメソッドで正常なリクエストが来たらまずこれでいいのではないでしょうか。
今回の例ではGETメソッドで/topにリクエストが来たらステータスコード200とHTMLファイルを返します。

index.js
'use strict';
const http = require('http');
const auth = require('http-auth');
const pug = require('pug');
const basic = auth.basic({
    realm: 'Enter username and password',
    file: './password'
});

//勤怠履歴
var history = [];

const server = http.createServer(basic.check((req, res) => {
    switch (req.url) {
    case '/top':
        if(req.method === 'GET'){
            res.writeHead(200, {
                'Content-Type': 'text/html; charset=utf-8'
            });
            res.end(pug.renderFile('./views/index.pug', {
                history: history,
                user: req.user
            }));            
        }
        break;

    default:
        break;
    }
}));

const port = 8000;
server.listen(port, () => {
    console.info('Listening on ' + port);
});

res.writeHeadでHTTPレスポンスヘッダに200のステータスコードを書き込んでいます。
また、res.endでpugテンプレートからHTMLに変換したものをレスポンスボディに書き込んでいます。
pugテンプレートは以下のようになります。

views/index.pug
doctype html
html(lang="ja")
  head
    meta(charset="utf-8")
    title 勤怠表
  body
    h1 勤怠表
    h2 あなたは #{user} 

node index.jsでプログラムを実行し、localhost:8000/topをブラウザで開くとページが見れます。
この時点でログインするとユーザ名だけが表示されるページができました。
index.jsでpugにhistoryuser.nameを渡していますが、historyは現段階では使用していません。

ステータスコード : 400 Bad Request

このステータスコードはリクエストの構文やパラメータが間違っている時に返します。
例えば、ユーザの入力からパスワードの設定を行う時に指定した要件を満たしていなかった場合など400を返します。
またリクエストのメソッドが指定されたURLには実装されていないときなどもこのコードを返します。
リクエストのメソッドが指定されたURLには実装されていない場合の処理をindex.jsに書き加えます。

index.js
    case '/top':
        if(req.method === 'GET'){
            res.writeHead(200, {
                'Content-Type': 'text/html; charset=utf-8'
            });
            res.end(pug.renderFile('./views/index2.pug', {
                history: history,
                user: req.user
            }));            
        }
+       else{
+           handleBadRequest(req, res);
+       }
        break;
     default:
         break;
     }
 }));


+function handleBadRequest(req, res) {
+    res.writeHead(400, {
+        'Content-Type': 'text/plain; charset=utf-8'
+    });
+    const message = "未対応のリクエストです。";
+    res.end('status code :' + res.statusCode + " " + message);
+}
+
 const port = 8000;

この例ではtop/にリクエストが来たものの、メソッドがGET以外ならばhandleBadRequest関数でレスポンスヘッダに400を書いています。
これでプログラムを再起動して、別のターミナルから

curl -X POST -u guest1:2222 -d "hoge=huga" localhost:8000/top 

などと実装されていないPOSTメソッドでリクエストを送ると、

status code :400 未対応のリクエストです。

と帰ってくるはずです。

ステータスコード : 301 Moved Permanently

こちらはリクエストで指定したURLが新しいURLに移行されている時に返します。古いURLにアクセスされると新しいURLにリダイレクトされます。新しいURLはレスポンスヘッダのLocationに絶対URLとして格納します。
ここでは古いURL('/old-url')にアクセスがくると新しいURL(/top)へリダイレクトを行います。
このコードを追加したものが以下の差分になります。

index.js
             handleBadRequest(req, res);
         }
+    case '/old-url' :
+        handleMovedPermanently(req, res);
+        break
     default:
         break;
    }
}));

function handleBadRequest(req, res) {
    res.writeHead(400, {
        'Content-Type': 'text/plain; charset=utf-8'
    });
    const message = "未対応のリクエストです。";
    res.end('status code :' + res.statusCode + " " + message);
}

+function handleMovedPermanently(req, res) {
+    res.writeHead(301, {
+        'Content-Type': 'text/html; charset=utf-8',
+        'Location' : '/top'
+    });
+   res.end('<!DOCTYPE html><html lang="jp"><body>' +
+            '<h1>新しいURLに移動しました</h1>' +
+            '<a href="/top">新しいほう</h1>' +
+            '</body></html>');
+}
 const port = 8000;
 server.listen(port, () => {
     console.info('Listening on ' + port);

Chromeでlocalhost:8000/old-urlを開くと/topにリダイレクトされます。
一方curlではレスポンスボディに新しいURLに移動した旨を知らせるHTMLが帰ってくると思います。

curl -X GET -u guest1:2222 localhost:8000/old-url
<!DOCTYPE html><html lang="jp"><body><h1>新しいURLに移動しました</h1><a href="/top">新しいほう</h1></body></html>%

ステータスコード : 401 Unauthorized

このステータスコードは認証に失敗したときに返します。
またユーザがログアウトするときはこのステータスコードを返せばOKです。
この例では、serverがbasic認証が通らなかったら自動的に401を返してくれるのでログアウト処理部分だけ対応することにします。

index.js
     case '/old-url' :
         handleMovedPermanently(req, res);
         break;
+    case '/logout' :
+        handleUnauthorized(req, res);
+        break;
     default:
         break;
     }
 }));
index.js
function handleMovedPermanently(req, res) {    
    res.writeHead(301, {
        'Content-Type': 'text/html; charset=utf-8',
        'Location' : '/top'
    });
    res.end('<!DOCTYPE html><html lang="jp"><body>' +
            '<h1>新しいURLに移動しました</h1>' +
            '<a href="/top">新しいほう</h1>' +
            '</body></html>');
}

+function handleUnauthorized(req, res) {
+    res.writeHead(401, {
+        'Content-Type': 'text/html; charset=utf-8'
+    });
+    res.end('<!DOCTYPE html><html lang="jp"><body>' +
+            '<h1>ログアウトしました</h1>' +
+            '<a href="/top">ログイン</h1>' +
+            '</body></html>');
+}

次にログアウトボタンをviewに追加します。

views/index.pug
doctype html
html(lang="ja")
  head
    meta(charset="utf-8")
    title 勤怠表
  body
    h1 勤怠表
    h2 あなたは #{user} 
    a(href="/logout")  ログアウト

ブラウザでlocalhost:8000/topを開き、ログアウトを押すと、ログアウトした旨のHMLが表示され、ログインを押すとまたユーザ名とパスワードが要求されます。

ステータスコード : 404 Not Found

このステータスコードは指定したリソースが存在しない場合に返します。
またこのときのレスポンスボディには理由を記述します。
"status code :404 指定したURLは見つかりません" という記述をレスポンスボディに追加しています。

index.js
     case '/logout' :
         handleUnauthorized(req, res);
         break;
     default:
+        handleNotFound(req, res);
         break;
index.js
function handleUnauthorized(req, res) {
    res.writeHead(401, {
        'Content-Type': 'text/html; charset=utf-8'
    });
    res.end('<!DOCTYPE html><html lang="jp"><body>' +
             '<h1>ログアウトしました</h1>' +
            '<a href="/top">ログイン</h1>' +
            '</body></html>');
}

+function handleNotFound(req, res){
+    res.writeHead(404, {
+        'Content-Type': 'text/plain; charset=utf-8'
+    });
+    const message = "指定したURLが見つかりません。";
+    res.end('status code :' + res.statusCode + " " +message);
+}

ブラウザでlocalhost:8000/hogeなどと存在しないURLを開くと、ページが存在しない旨が表示されます。

ステータスコード : 303 See Other

このステータスコードはリクエストに対する処理結果が別のURLで取得できる時に返します。例えばブラウザからPOSTで何かリソースを操作するリクエストの処理結果やそれを反映した結果をGETで取得する時に使います。

ここの例では、出勤ボタンを押すと、出勤のログを出し、退勤ボタンを押すと、退勤のログをユーザ名と一緒に流す機能をPOSTで実装します。

コードは以下の通りとなります。

index.js
             handleBadRequest(req, res);
         }
         break;
+    case '/top/kintai':
+        let data = [];
+        if(req.method === 'POST'){
+            req.on('data', (chunk) => {
+                data.push(chunk);
+            }).on('end', () => {
+                data = data.toString();
+                const decoded = decodeURIComponent(data);
+                let kintai = decoded ? decoded.split('kintai-button=')[1] : '';
+                kintai = req.user + ':' + kintai
+                history.unshift(kintai);
+                res.writeHead(303, {
+                    'Location': '/top'
+                });
+                res.end();
+            })
+        }
+        else{
+            handleBadRequest(req, res);
+        }
     case '/old-url' :
         handleMovedPermanently(req, res);
         break;

viewは以下の通りとなります。

views/index.pug
doctype html
html(lang="ja")
  head
    meta(charset="utf-8")
    title 勤怠表
  body
    h1 勤怠表
    h2 あなたは #{user} 
    a(href="/logout")  ログアウト
    form(method="post" action="/top/kintai")
      button(type="submit" name="kintai-button" value="出勤") 出勤
      button(type="submit" name="kintai-button" value="退勤") 退勤

    each element in history
      div.history
        <hr>
        p #{element}
    <hr>

コードの説明をします。
まず、localhost:8000/topをブラウザで開くと出勤ボタンと退勤ボタンが出現します。

出勤ボタンを押すとkintai-button="出勤"、退勤ボタンを押すとkintai-button="退勤"という情報(厳密には違いますが)が、index.jsのcase 'top/kintai':以下で処理されることになります。dataにはURLエンコーディングされた文字列が入ってくるので、これをdecodeすることによって人間が読める文字列をdecodedに取り出します。ここから、decodedの中(例;kintai-button="退勤")から=の右部分("退勤")だけを取り出したものとユーザネームをつなげたものをkintaiに格納します。そしてログ全体を格納する配列であるhistoryに前から追加していきます。なぜ普通のpush(後ろから追加)しないのかというと、新しいものをログの上部分に表示したかったからです。

最後に303のステータスコードと、リダイレクト先を指定('Location':'/top')をレスポンスヘッダに記述します。出勤ボタンを押されると、見かけ上はページの移動が行われていないように見え、そのページ上で次々とログが出てくるはずです。

最後に

この例題では他のステータスコードの具体例を思いつかなかったので、他のステータスコードはまた別の記事でやるかもしれません。
自分の学習ログ兼ねているので読みづらい文章だったかもしれませんがここまで読んでいただきありがとうございました。

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

常に動くLINEBOTにお引っ越し(レンタルサーバ+PHP編)

今回のモチベーション

前回、こちらの記事を参考にWikipedia APIを使った、調べものLINE botを作った。

前回の記事
https://qiita.com/shima-07/items/2322598ca5a40cfee47b

だが、

  • ngrokを立ち上げている時しか使えないから普段使えない。
  • いざ、ngrokを立ち上げるとアドレスが変わってしまうため、Messaging API settingsのwebhook URLを毎度変えないと動かない。

うーん。。。

ngrok立ち上げるのめんどくさい! 常に使えるようにしないと意味ないじゃん!

と思ったわけです。だから『常に動くようにしよう!』が今回の動機です。

今回やったこと

  • 1. まずは now を試してみた
  • 2. さくらのレンタルサーバでやることにした
  • 3. jsで書いていたものをPHPに書き直した

最終的にはPHP化してさくらサーバに載っけました。

1. まずは now を試してみた

このあたりを参考に進めてみる。

さすが良記事!サクサク進むぜと思いながら最後までいきデプロイ完了!
簡単だったなあと思いながら、Webhook URLに入れて「Verify」をクリック・・・・

image.png

う。。まあよくある。

(1時間ほど立ち向かう)
色々と試すが私の手におえないと判断して諦める。

2. さくらのレンタルサーバでやることにした

この記事を発見!
http://blog.hetabun.com/line-bot-php-sakura

これ通りやることで、「こんにちは」に対して「こんにちは!」と元気よく返してくれるボットができました。

3. jsで書いていたものをPHPに書き直した

ここからが本番。前回あげた下記jsのコードと同じような振る舞いをPHPで書いていく。
PHPももちろん初心者である。

再掲

server.js
'use strict';

const express = require('express');
const line = require('@line/bot-sdk');
const PORT = process.env.PORT || 3000;
// 追加
const axios = require('axios');

const config = {
    channelSecret: '作成したBOTのチャンネルシークレット',
    channelAccessToken: '作成したBOTのチャンネルアクセストークン'
};

const app = express();

app.get('/', (req, res) => res.send('Hello LINE BOT!(GET)')); //ブラウザ確認用(無くても問題ない)
app.post('/webhook', line.middleware(config), (req, res) => {

    //ここのif文はdeveloper consoleの"接続確認"用なので後で削除して問題ないです。
    if(req.body.events[0].replyToken === '00000000000000000000000000000000' && req.body.events[1].replyToken === 'ffffffffffffffffffffffffffffffff'){
        res.send('Hello LINE BOT!(POST)');
        console.log('疎通確認用');
        return; 
    }

    Promise
      .all(req.body.events.map(handleEvent))
      .then((result) => res.json(result));
});

const client = new line.Client(config);

function handleEvent(event) {
  if (event.type !== 'message' || event.message.type !== 'text') {
    return Promise.resolve(null);
  }

  let mes = ''
// console.log(event.message.text);
    if(event.message.text.indexOf('') > -1){
        // ?を含んでいる場合にはwikiで検索したものを出して、含んでない場合はurlを返す
    var str = event.message.text;
    var result = str.split( '' ).join( '' ); //?を取り除く処理
    mes = result + 'の説明:'; //wikiのbodyの前の一言
    getBody(event.source.userId,result); //wiki APIで取得できたらプッシュメッセージ

  }else{
    var result = event.message.text;
    mes = result + 'のURL:'; //wikiのurlの前の一言
    getUrl(event.source.userId,result); //wiki APIで取得できたらプッシュメッセージ
  }

  return client.replyMessage(event.replyToken, {
    type: 'text',
    text : mes 
});
}

const getBody = async (userId,word) => {
    const res = await axios.get('http://wikipedia.simpleapi.net/api?keyword='+ encodeURIComponent(word) + '&output=json');
    const item = res.data;
    // console.log(item); 

    await client.pushMessage(userId, {
        type: 'text',
        text: item[0].body,
    });
}
const getUrl = async (userId,word) => {
    const res = await axios.get('http://wikipedia.simpleapi.net/api?keyword='+ encodeURIComponent(word) + '&output=json');
    const item = res.data;
    // console.log(item); 
    await client.pushMessage(userId, {
        type: 'text',
        text: item[0].url,
    });
}

app.listen(PORT);
console.log(`Server running at ${PORT}`);

 処理の整理

  • LINEからのメッセージを受け取る
  • そのメッセージをWikipedia APIに渡して結果を受け取る
  • メッセージによってLINE側に返却するものを変える
    • ?があるときはurlを返す
    • ?がないときはbodyを返す

LINEからのメッセージを受け取る

参考にした記事中にあった下記の$text で取れているからそれはOK。

LINEからのメッセージ.php
//ユーザーからのメッセージ取得
$json_string = file_get_contents('php://input');
$jsonObj = json_decode($json_string);

$type = $jsonObj->{"events"}[0]->{"message"}->{"type"};
//メッセージ取得
$text = $jsonObj->{"events"}[0]->{"message"}->{"text"};
//ReplyToken取得
$replyToken = $jsonObj->{"events"}[0]->{"replyToken"};

そのメッセージをWikipedia APIに渡して結果を受け取る

ここが一番ハマった。
jsではres.data.item[0].body の構造で取れていたので、
同じノリで $value = $res->{"data"}->{"item"}[0]->{"body"}; のような書き方をして、、当然何も取れず。

結論、下記のような取り方でできた。

wikipediaAPIからURLやbody取得部分.php
        $keyword = mb_convert_encoding($text, "UTF-8", "auto");
        $res = file_get_contents('http://wikipedia.simpleapi.net/api?keyword=' . $keyword . '&output=json');

        $jsonwiki_decode = json_decode($res,true);
        // 0番目の物だけを抽出する。もっとたくさん抽出したいときはここを変更する
        $jsonwiki = $jsonwiki_decode[0];
        //欲しい項目だけの配列にする
        $wikidata = array(
            'url' => $jsonwiki["url"],
            'body' => $jsonwiki["body"]
        );
        $URL = $wikidata["url"];
        $body = $wikidata["body"]; 

流れとしては、

  • LINEから受け取ったキーワードをAPIのkeywordとして渡せるようにエンコードする
  • それをAPIに渡し$res として取得する
  • json_decodeして配列にする
  • そのキーワードに対しての一番先頭の回答を取得するため[0]を取得する
  • それに対して$wikidataとして必要な項目だけ取得する
  • LINEに返したいものは、$URL = $wikidata["url"] や $body = $wikidata["body"] として取得できる

補足

上記 $resにどんなものが入っているか見るために下記のようなものをページを作って見てました。( jsはconsole.log()で気軽に見えたけどphpではどうやってみたらいいかわからずわざわざこんなことしました。。。 )
image.png

check_data.php
<html>
<head>
<title> test </title>    
</head>

<body>
<form method="POST" action="show.php">
キーワード:
<input type="text" name="name" size="15" />
<input type="submit" name="submit" value="送信" />
</form> 

<?php
if($_REQUEST['submit'] != null){

    $input = $_REQUEST[name];
    //$textのなかに'?'が含まれている場合
    $text = str_replace('?', '', $input);
    $keyword = mb_convert_encoding($text, "UTF-8", "auto");
    $res = file_get_contents('http://wikipedia.simpleapi.net/api?keyword=' . $keyword . '&output=json');
    $jsonwiki_decode = json_decode($res,true);
    $jsonwiki = $jsonwiki_decode[0];
    $wikidata = array(
        'url' => $jsonwiki["url"],
        'body' => $jsonwiki["body"]
    );
    $URL = $wikidata["url"];
    $body = $wikidata["body"];

    print('$input: '.$input.'-----');
    print('$text: '.$text.'-----');
    print('$keyword: '.$keyword.'-----');
    print('$res: '.$res.'-----');
    print('$jsonwiki_decode :'.$jsonwiki_decode.'-----');
    print('$jsonwiki :'.$jsonwiki .'-----');
    print('$wikidata :'.$wikidata .'-----');
    print('URL: '.$URL.'-----');
    print('body: '.$body.'-----');  

 }
?>       
</body>
</html>

メッセージによってLINE側に返却するものを変える

  • ?がある場合はURLをLINEに返す。また、Wikipedia APIに渡すときには?を取り除く。
  • ?がない場合はBodyをLINEに返す。
分岐部分.php
    if(strpos($text,'?') !== false){
        //$textのなかに'?'が含まれている場合
        $text = str_replace('?', '', $text);
        $keyword = mb_convert_encoding($text, "UTF-8", "auto");
        $res = file_get_contents('http://wikipedia.simpleapi.net/api?keyword=' . $keyword . '&output=json');

        $jsonwiki_decode = json_decode($res,true);
        // 0番目の物だけを抽出する。もっとたくさん抽出したいときはここを変更する
        $jsonwiki = $jsonwiki_decode[0];
        //欲しい項目だけの配列にする
        $wikidata = array(
            'url' => $jsonwiki["url"],
            'body' => $jsonwiki["body"]
        );
        $URL = $wikidata["url"];
        $body = $wikidata["body"];

        // メッセージ部分
        $response_format_text = [
            "type" => "text",
            "text" => $URL
            ];

      }else{ // ?が含まれないときの処理
        $keyword = mb_convert_encoding($text, "UTF-8", "auto");
        $res = file_get_contents('http://wikipedia.simpleapi.net/api?keyword=' . $keyword . '&output=json');

        $jsonwiki_decode = json_decode($res,true);
        // 0番目の物だけを抽出する。もっとたくさん抽出したいときはここを変更する
        $jsonwiki = $jsonwiki_decode[0];
        //欲しい項目だけの配列にする
        $wikidata = array(
            'url' => $jsonwiki["url"],
            'body' => $jsonwiki["body"]
        );
        $URL = $wikidata["url"];
        $body = $wikidata["body"];      

        // メッセージ部分
        $response_format_text = [
            "type" => "text",
            "text" => $body
            ];
      }
    }

サンプル

全ソースコード

linebot.php
<?php

$accessToken = 'アクセストークン';

//ユーザーからのメッセージ取得
$json_string = file_get_contents('php://input');
$jsonObj = json_decode($json_string);

$type = $jsonObj->{"events"}[0]->{"message"}->{"type"};
//メッセージ取得
$text = $jsonObj->{"events"}[0]->{"message"}->{"text"};
//ReplyToken取得
$replyToken = $jsonObj->{"events"}[0]->{"replyToken"};

//メッセージ以外のときは何も返さず終了
if($type != "text"){
 exit;
}


if($type == "text"){
    if(strpos($text,'?') !== false){
        //$textのなかに'?'が含まれている場合
        $text = str_replace('?', '', $text);
        $keyword = mb_convert_encoding($text, "UTF-8", "auto");
        $res = file_get_contents('http://wikipedia.simpleapi.net/api?keyword=' . $keyword . '&output=json');

        $jsonwiki_decode = json_decode($res,true);
        // 0番目の物だけを抽出する。もっとたくさん抽出したいときはここを変更する
        $jsonwiki = $jsonwiki_decode[0];
        //欲しい項目だけの配列にする
        $wikidata = array(
            'url' => $jsonwiki["url"],
            'body' => $jsonwiki["body"]
        );
        $URL = $wikidata["url"];
        $body = $wikidata["body"];

        // メッセージ部分
        $response_format_text = [
            "type" => "text",
            "text" => $URL
            ];

      }else{ // ?が含まれないときの処理
        $keyword = mb_convert_encoding($text, "UTF-8", "auto");
        $res = file_get_contents('http://wikipedia.simpleapi.net/api?keyword=' . $keyword . '&output=json');

        $jsonwiki_decode = json_decode($res,true);
        // 0番目の物だけを抽出する。もっとたくさん抽出したいときはここを変更する
        $jsonwiki = $jsonwiki_decode[0];
        //欲しい項目だけの配列にする
        $wikidata = array(
            'url' => $jsonwiki["url"],
            'body' => $jsonwiki["body"]
        );
        $URL = $wikidata["url"];
        $body = $wikidata["body"];      

        // メッセージ部分
        $response_format_text = [
            "type" => "text",
            "text" => $body
            ];
      }
    }


$post_data = [
"replyToken" => $replyToken,
"messages" => [$response_format_text]
];

$ch = curl_init("https://api.line.me/v2/bot/message/reply");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data));
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
 'Content-Type: application/json; charser=UTF-8',
 'Authorization: Bearer ' . $accessToken
 ));
$result = curl_exec($ch);
curl_close($ch);

おわりに

APIから返ってきた値をいい感じで取ってくるところでだいぶハマりました。
JSONデータの扱い方にもっと慣れないとなあー

次はHerokuを使ったお引っ越しもやってみようと思います。

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

Catalinaのバージョンアップしたらrails sできなくなったけど、Node.jsのインストールで解決!

筆者の環境

macOS Catalina バージョン 10.15.4
使用言語:Ruby、JavaScript

エラー内容

不注意により、macOSがCatalina バージョン10.15.4に上がってしまった。

それから実装中のアプリでrails sすると、以下のエラーメッセージが表示されサーバーが起動しなくなった。

Could not find a JavaScript runtime. See https://github.com/rails/execjs for a list of available runtimes. (ExecJS::RuntimeUnavailable)

これで解決!

Node.jsをインストール

左側の12.16.1の方はインストールしても開けず、
右側(最新版)をインストールしたらrails s成功しました!

スクリーンショット 2020-03-29 19.12.24.png

番外編

他にも対処法はいくつかあり、therubyracerというGemのインストールでも解決できるようです。(参考リンクご参照ください)

現在、チーム開発中だったので、自分以外の複数端末への影響を考え今回はGem以外の方法を選択しました。

参考

にさせていただきました。ありがとうございます。
https://qiita.com/azusanakano/items/771dc9919f347de061d7

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

RSSで取得した記事を日付順にし、表示形式を変更する(Javascript・node.js)

解決する問題

RSSで取得した記事を、日付順にして日付の表示形式を変更する。

今回はRSSでコロナウイルスに関しての記事を取得しました。

対象読者

・node.jsでxmlを取得する事ができる方。
・express-generatorを触った事がある方。
・xmlの表示形式の知識がある方。

express-generatorを使ってXMLを取得しているところから進めていきます。

環境

OS: macOS
Node.js: v13.5.0
npm: 6.14.3
express: ~4.16.1
ejs: ~2.6.1,

RSSで記事を取得した時の状況

記事を取得し表示させると日付順にならず、見づらい

hello.js↓

router.get('/',(req, res, next) => {
    var opt = {
        host: 'news.google.com',
        port: 443,
        path: '/rss/search?q=corona&q=korona&hl=ja&gl=JP&ceid=JP:ja'
    };
    http.get(opt, (res2) => {
        var body = '';
        res2.on('data',(data) => {
            body += data;
        });
        res2.on('end', () => {
            parseString(body.trim(), (err, result) => {
                var data = {
                    title: 'コロナウイルスの最新情報を表示します',
                    content: result.rss.channel[0].item
                };
                res.render('hello', data);
            })
        });
    });
});  

hello.ejs↓

<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="content-type" content="text/html; charset=UTF-8">
        <title><%= title %></title>
        <link rel='stylesheet' href="/stylesheets/style.css" />
    </head>

    <body>
        <header>
            <h1><%= title %></h1>
        </header>
        <div role="main">
            <% if (content != null) { %>
                <ol>
                    <% for (var i in content) { %>
                        <% var obj = content[i]; %>
                        <li><%= obj.pubDate %><a href="<%=obj.link %>"><%= obj.title %></a></li>
                        </tr>
                    <% } %>
                </ol>
            <% } %>
        </div>
    </body>
</html>

スクリーンショット 2020-03-29 15.55.27.png

(今回取得したxml
https://news.google.com/rss/search?q=corona&q=korona&hl=ja&gl=JP&ceid=JP:ja)

解決策

①xmlの記事の中のitemを配列に入れていく。

for in を使ってxmlのitemを配列に入れる処理をループさせます。

配列を作る、ループを作る、配列へpushという順番です。

※ソートする為に配列へ入れます。

<!-- 配列を作成 -->
<% var hash = new Array %>

<!-- xmlitemをループ処理する -->
<% for (var i in content) { %>
    <% var obj = content[i]; %>  
    <!-- itemを配列へ追加 -->        
    <% hash.push(obj); %>
<% } %> 

※ejsの中にjsを書いています。
※content = result.rss.channel[0].item

②配列をソートして日付順に並べる

<!-- 配列をソートして日付順に変更 -->
<% hash.sort(function(a,b) {
    return (a.pubDate < b.pubDate ? 1 : -1);
}); %>

これで配列の中のitemが、xmlのpubDateの日付順に並びました。
※こちらもejsの中にjsを書いています。
※参考にした記事は下に載っています。

③日付の表示形式を変更

1、配列をfor inでループさせる
2、ループの中にDateオブジェクトを作成
3、日付の形式を変更
4、(西暦2020年を削除)任意
5、ループ処理の中で記事を表示する

<!-- 配列をループ処理 -->
<% for (var i in hash) { %>
    <% var obj2 = hash[i] %>

    <!-- 日付の表示形式を変更 -->
    <% 
        var date2 = new Date(obj2.pubDate);
        var b = date2.toLocaleString('ja-JP', {era:'long'});                            
        obj2.pubDate = b;
    %>
    <!-- 西暦を削除 -->
    <% var obj3_pubDate = obj2.pubDate.replace('西暦2020年', ''); %>

    <!-- 表示 -->
    <div class="center">
        <ul>
            <li>
                <a href="<%=obj2.link %>">
                    <div class="Date"><%=obj3_pubDate %></div>
                    <div class="title"><%="   " + obj2.title %></div>
                </a>
            </li>
         </ul>
    </div>
<% } %>

表示結果

css追加前↓
スクリーンショット 2020-03-29 17.25.04.png

css追加後↓
スクリーンショット 2020-03-29 17.26.40.png

変更後のコード

hello.ejs↓

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="content-type" content="text/html; charset=UTF-8">
        <title><%= title %></title>
        <link rel='stylesheet' href="/stylesheets/style.css" />
    </head>

    <div class="style">
        <!-- 更新日時を日本語へ変更する -->
        <% var update2 = new Date(update);
           var update3 = update2.toLocaleString('ja-JP', {era:'long'});                            
           update = update3;
        %>
        <!-- 更新日時の西暦を消す -->
        <% var update4 = update.replace('西暦2020年', ''); %>

    </div>

    <body>
        <header>
            <h1><%= title %>更新(<%= update4 %>)</h1>
        </header>
        <div role="main">
            <% if (content != null) { %>
                    <!-- 配列を作成 -->
                    <% var hash = new Array %>

                    <!-- xmlのitemをループ処理する -->
                    <% for (var i in content) { %>
                        <% var obj = content[i]; %>  
                        <!-- itemを配列へ追加 -->        
                        <% hash.push(obj); %>
                    <% } %> 

                    <!-- 配列をソートして日付順に変更 -->
                    <% hash.sort(function(a,b) {
                        return (a.pubDate < b.pubDate ? 1 : -1);
                    }); %>

                    <!-- 配列をループ処理 -->
                    <% for (var i in hash) { %>
                        <% var obj2 = hash[i] %>

                        <!-- 日付の表示形式を変更 -->
                        <% 
                            var date2 = new Date(obj2.pubDate);
                            var b = date2.toLocaleString('ja-JP', {era:'long'});                            
                            obj2.pubDate = b;
                        %>
                        <!-- 西暦を削除 -->
                        <% var obj3_pubDate = obj2.pubDate.replace('西暦2020年', ''); %>

                        <!-- 表示 -->
                        <div class="center">
                            <ul>
                                <li>
                                    <a href="<%=obj2.link %>">
                                        <div class="Date"><%=obj3_pubDate %></div>
                                        <div class="title"><%="   " + obj2.title %></div>
                                    </a>
                                </li>
                            </ul>
                        </div>
                    <% } %>
            <% } %>
        </div>
    </body>
</html>

hello.js↓

var express = require('express');
var router = express.Router();

var http = require('https');
var parseString = require('xml2js').parseString;

router.get('/',(req, res, next) => {
    var opt = {
        host: 'news.google.com',
        port: 443,
        path: '/rss/search?q=corona&q=Coronavirus&hl=ja&gl=JP&ceid=JP:ja'
    };
    http.get(opt, (res2) => {
        var body = '';
        res2.on('data',(data) => {
            body += data;
        });
        res2.on('end', () => {
            parseString(body.trim(), (err, result) => {
                var data = {
                    title: 'コロナウイルスの最新情報を表示します',
                    content: result.rss.channel[0].item,
                    update: result.rss.channel[0].lastBuildDate
                };
                res.render('hello', data);
            })
        });
    });
});  

module.exports = router;

参考

配列を日付順にソートする方法
https://infoteck-life.com/a0107-js-array-sort-date/
https://www.p-nt.com/technicblog/archives/58

Dateオブジェクトの使い方入門
https://www.sejuku.net/blog/30171

Dateオブジェクトのプロパティ(日付の表示形式を参考にしました。)
https://so-zou.jp/web-app/tech/programming/javascript/grammar/object/date.htm

ループ処理(今回はfor inでループしています。)
https://qiita.com/endam/items/808a084859e3a101ab8f

文字列から指定した文字を削除する(日付の西暦2020年を削除する為に参考にしました。)
https://zukucode.com/2017/04/javascript-string-remove.html

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

【JS】RSSで取得した記事を日付順にし、表示形式を変更する(Javascript・node.js)

解決する問題

RSSで取得した記事を、日付順にして日付の表示形式を変更する。

今回はRSSでコロナウイルスに関しての記事を取得しました。

対象読者

・node.jsでxmlを取得する事ができる方。
・express-generatorを触った事がある方。
・xmlの表示形式の知識がある方。

express-generatorを使ってXMLを取得しているところから進めていきます。

環境

OS: macOS
Node.js: v13.5.0
npm: 6.14.3
express: ~4.16.1
ejs: ~2.6.1,

RSSで記事を取得した時の状況

記事を取得し表示させると日付順にならず、見づらい

hello.js↓

router.get('/',(req, res, next) => {
    var opt = {
        host: 'news.google.com',
        port: 443,
        path: '/rss/search?q=corona&q=korona&hl=ja&gl=JP&ceid=JP:ja'
    };
    http.get(opt, (res2) => {
        var body = '';
        res2.on('data',(data) => {
            body += data;
        });
        res2.on('end', () => {
            parseString(body.trim(), (err, result) => {
                var data = {
                    title: 'コロナウイルスの最新情報を表示します',
                    content: result.rss.channel[0].item
                };
                res.render('hello', data);
            })
        });
    });
});  

hello.ejs↓

<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="content-type" content="text/html; charset=UTF-8">
        <title><%= title %></title>
        <link rel='stylesheet' href="/stylesheets/style.css" />
    </head>

    <body>
        <header>
            <h1><%= title %></h1>
        </header>
        <div role="main">
            <% if (content != null) { %>
                <ol>
                    <% for (var i in content) { %>
                        <% var obj = content[i]; %>
                        <li><%= obj.pubDate %><a href="<%=obj.link %>"><%= obj.title %></a></li>
                        </tr>
                    <% } %>
                </ol>
            <% } %>
        </div>
    </body>
</html>

スクリーンショット 2020-03-29 15.55.27.png

(今回取得したxml
https://news.google.com/rss/search?q=corona&q=korona&hl=ja&gl=JP&ceid=JP:ja)

解決策

①xmlの記事の中のitemを配列に入れていく。

for in を使ってxmlのitemを配列に入れる処理をループさせます。

配列を作る、ループを作る、配列へpushという順番です。

※ソートする為に配列へ入れます。

<!-- 配列を作成 -->
<% var hash = new Array %>

<!-- xmlitemをループ処理する -->
<% for (var i in content) { %>
    <% var obj = content[i]; %>  
    <!-- itemを配列へ追加 -->        
    <% hash.push(obj); %>
<% } %> 

※ejsの中にjsを書いています。
※content = result.rss.channel[0].item

②配列をソートして日付順に並べる

<!-- 配列をソートして日付順に変更 -->
<% hash.sort(function(a,b) {
    return (a.pubDate < b.pubDate ? 1 : -1);
}); %>

これで配列の中のitemが、xmlのpubDateの日付順に並びました。
※こちらもejsの中にjsを書いています。
※参考にした記事は下に載っています。

③日付の表示形式を変更

1、配列をfor inでループさせる
2、ループの中にDateオブジェクトを作成
3、日付の形式を変更
4、(西暦2020年を削除)任意
5、ループ処理の中で記事を表示する

<!-- 配列をループ処理 -->
<% for (var i in hash) { %>
    <% var obj2 = hash[i] %>

    <!-- 日付の表示形式を変更 -->
    <% 
        var date2 = new Date(obj2.pubDate);
        var b = date2.toLocaleString('ja-JP', {era:'long'});                            
        obj2.pubDate = b;
    %>
    <!-- 西暦を削除 -->
    <% var obj3_pubDate = obj2.pubDate.replace('西暦2020年', ''); %>

    <!-- 表示 -->
    <div class="center">
        <ul>
            <li>
                <a href="<%=obj2.link %>">
                    <div class="Date"><%=obj3_pubDate %></div>
                    <div class="title"><%="   " + obj2.title %></div>
                </a>
            </li>
         </ul>
    </div>
<% } %>

表示結果

css追加前↓
スクリーンショット 2020-03-29 17.25.04.png

css追加後↓
スクリーンショット 2020-03-29 17.26.40.png

変更後のコード

hello.ejs↓

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="content-type" content="text/html; charset=UTF-8">
        <title><%= title %></title>
        <link rel='stylesheet' href="/stylesheets/style.css" />
    </head>

    <div class="style">
        <!-- 更新日時を日本語へ変更する -->
        <% var update2 = new Date(update);
           var update3 = update2.toLocaleString('ja-JP', {era:'long'});                            
           update = update3;
        %>
        <!-- 更新日時の西暦を消す -->
        <% var update4 = update.replace('西暦2020年', ''); %>

    </div>

    <body>
        <header>
            <h1><%= title %>更新(<%= update4 %>)</h1>
        </header>
        <div role="main">
            <% if (content != null) { %>
                    <!-- 配列を作成 -->
                    <% var hash = new Array %>

                    <!-- xmlのitemをループ処理する -->
                    <% for (var i in content) { %>
                        <% var obj = content[i]; %>  
                        <!-- itemを配列へ追加 -->        
                        <% hash.push(obj); %>
                    <% } %> 

                    <!-- 配列をソートして日付順に変更 -->
                    <% hash.sort(function(a,b) {
                        return (a.pubDate < b.pubDate ? 1 : -1);
                    }); %>

                    <!-- 配列をループ処理 -->
                    <% for (var i in hash) { %>
                        <% var obj2 = hash[i] %>

                        <!-- 日付の表示形式を変更 -->
                        <% 
                            var date2 = new Date(obj2.pubDate);
                            var b = date2.toLocaleString('ja-JP', {era:'long'});                            
                            obj2.pubDate = b;
                        %>
                        <!-- 西暦を削除 -->
                        <% var obj3_pubDate = obj2.pubDate.replace('西暦2020年', ''); %>

                        <!-- 表示 -->
                        <div class="center">
                            <ul>
                                <li>
                                    <a href="<%=obj2.link %>">
                                        <div class="Date"><%=obj3_pubDate %></div>
                                        <div class="title"><%="   " + obj2.title %></div>
                                    </a>
                                </li>
                            </ul>
                        </div>
                    <% } %>
            <% } %>
        </div>
    </body>
</html>

hello.js↓

var express = require('express');
var router = express.Router();

var http = require('https');
var parseString = require('xml2js').parseString;

router.get('/',(req, res, next) => {
    var opt = {
        host: 'news.google.com',
        port: 443,
        path: '/rss/search?q=corona&q=Coronavirus&hl=ja&gl=JP&ceid=JP:ja'
    };
    http.get(opt, (res2) => {
        var body = '';
        res2.on('data',(data) => {
            body += data;
        });
        res2.on('end', () => {
            parseString(body.trim(), (err, result) => {
                var data = {
                    title: 'コロナウイルスの最新情報を表示します',
                    content: result.rss.channel[0].item,
                    update: result.rss.channel[0].lastBuildDate
                };
                res.render('hello', data);
            })
        });
    });
});  

module.exports = router;

参考

配列を日付順にソートする方法
https://infoteck-life.com/a0107-js-array-sort-date/
https://www.p-nt.com/technicblog/archives/58

Dateオブジェクトの使い方入門
https://www.sejuku.net/blog/30171

Dateオブジェクトのプロパティ(日付の表示形式を参考にしました。)
https://so-zou.jp/web-app/tech/programming/javascript/grammar/object/date.htm

ループ処理(今回はfor inでループしています。)
https://qiita.com/endam/items/808a084859e3a101ab8f

文字列から指定した文字を削除する(日付の西暦2020年を削除する為に参考にしました。)
https://zukucode.com/2017/04/javascript-string-remove.html

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

npmでReactのプロジェクト作成

npx create-react-app プロジェクト名

プロジェクト作成すると下記画面が表示される

Success! Created react-app at /Users/xxx/Myapp/React/learning/react-app
Inside that directory, you can run several commands:

  npm start
    Starts the development server.

  npm run build
    Bundles the app into static files for production.

  npm test
    Starts the test runner.

  npm run eject
    Removes this tool and copies build dependencies, configuration files
    and scripts into the app directory. If you do this, you can’t go back!

We suggest that you begin by typing:

  cd react-app
  npm start

上記の画面にはコマンド毎の操作説明が表示されており、こちらのコマンドはnpmの他にyarnでも実行可能

1.yarn start 又は npm start
・webブラウザを起動
2.yarn build 又は npm run build
・プロジェクトのビルド(プロジェクトのファイルから実際のWebサーバーにアップロードして利用するファイル類を生成する)
3.yarn test 又は npm test
・テストプログラムを実行して、アプリケーションのテストを行う
4.yarn eject 又は npm run eject
・プロジェクトのインジェクトを行う

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

位置情報を送ると最寄りのラーメン店を教えてくれるLINE botをつくってみた?(2020年版)

概要

 
 この記事はLINE bot・javascrptの初心者が、「LINE bot 面白い!」という熱意で作ったものの制作記です。

 何をつくったか...。
「LINE botに位置情報を送ると、その近辺のラーメン屋さんを教えてくれるbot」を作りました?!

【デモ】

 本記事ではこのLINE botのサーバプログラムと、ラーメン店情報を検索するためにぐるなび APIを解説しています。基礎的な機能のみ実装しているので、店舗の星の多さ、複数件表示などは未実装です。

 位置情報を送ると、その最寄りのラーメン屋さんを一軒返すところまで動かしました。
以下、2020年3月29日時点の情報です。

開発環境

OS:Windows 10
Node.js:v10.15.3

【ライブラリバージョン】
@line/bot-sdk:6.8.4
express:4.17.1
axios:0.19.2

構成

 構成は次のようになっています。ローカルPC上でNode.jsを動かしているので、グローバルネットワークとのつながりをngrokでトンネリングしています。

構成図.jpg

 位置情報をNode.jsサーバが受け取ると、ぐるなび APIを利用して最寄りののラーメン店情報を取得します。このとき、ユーザから送られた位置情報をぐるなび APIのリクエストURLに含めています。

実装

ぐるなび APIの調査

 ぐるなび APIはAPIの使用説明だけでなく、APIテストツールまで用意されていました。手始めに、freewordクエリを追加してリクエストしてみると、レスポンスが下の方に表示されます。
(アクセスキーはテスト用になっています)

image.png

 どんなクエリがあるかは、API仕様のレストラン検索 APIで公開されています。

プログラムからアクセスしてみる

 LINE botとのやり取りと、APIアクセスはこちらのソースコードをもとに進めていきます。
APIテストツールを使うと、リクエストURLが生成されるのでコピーしてaxisos.getに引数に与えます。
こんな感じです。

axios.get('https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=b17d2f36873123b0d0f09e9139556c79&freeword=ラーメン');

 この状態でアクセスしたら、次のようなエラーが発生しました。

Request path contains unescaped characters

 コーディングミスというよりは、URLとして認識されていないようなエラーメッセージですね。試しにフリーワードをローマ字(ra-men)にしたらエラーが解消されて、レスポンスが返るようになりました!
↓レスポンスのログ

ローマ字なら動いた.JPG

 一先ず、これでレスポンスが返るので先に進みます。日本語対応は後述の「後回しにしていた日本語対応」で解説しています。

最寄りの店に絞る

 フリーワード検索のみだと全国の店が検索されてしまうので、最寄りの店に絞ります。これはぐるなび APIのクエリに経緯を追加することで絞ることが可能です。

 最初は経緯を固定で動かします。経緯を調べるにはGoogleマップが便利です。今回はProtoOut Studioの本拠地であるgranicaにしてみました。

【リクエストURL」

https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=アクセスキー&latitude=35.704607&longitude=139.772416&range=2&freeword=ラーメン

 このURLで問題なくレスポンスが来ればOKです。

が、居酒屋が引っかかる!!!!!!
image.png

ラーメン店に絞る

 ラーメンに絞りたいので、レスポンスデータの中のcategoryでフィルタリングしました。

for(var num = 0; num <= response.data.rest.length; num++){
    if(response.data.rest[num].category === 'ラーメン'){
      console.log(response.data.rest[num]);
      ramen_url = response.data.rest[num].url_mobile;
      break;
    }
  }

 これで最寄りのラーメン店に絞ることができました!と、思いきや最寄りにラーメン店が見つからなかった場合、エラーが発生します。ちなみに、最寄りとは半径500m圏内です。今回は正常系のみ作ることを目的とし、エラーハンドリングをスルーします...。

後回しにしていた日本語対応

 フリーワードに日本語が入っていることが原因なので、URLにエンコードしてくれるencodeURI関数がありました。こちらの記事の通りになおすと、フリーワードに日本語が入っていても通るようになりました!
こんなコードです。

url = 'https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=アクセスキー&latitude=35.704607&longitude=139.772416&range=2&freeword=ラーメン';
  const encodeUrl = encodeURI(url);
  const response = await axios.get(encodeUrl);

LINE botへのレスポンスを位置情報にする

 このままテキストで返してもよいのですが、LINE botには位置情報を返す機能がありまので、位置情報で返してみました。ちなみに、テキストでURLを返すと次のようになります。
Screenshot_20200328-230452_LINE.jpg

これを位置情報にすると、

Screenshot_20200328-231744_LINE.jpg

こっちの方が見栄え良さそうですね イェーイ(/・ω・)/

LINE botで位置情報を返すコードは次の通りです。経緯だけではなく住所も返す必要があります。
コード中のhitnumは、レスポンスデータの中で、category”ラーメン”にヒットした位置が格納されているとします。ぐるなび APIの良いところはこれらの情報が全てそろっていることです?

return client.replyMessage(event.replyToken, {
  type: 'location',
  title: response.data.rest[hitnum].name,
  address: response.data.rest[hitnum].address,
  latitude: response.data.rest[hitnum].latitude,
  longitude: response.data.rest[hitnum].longitude
});

位置情報入力を追加

 最後に位置情報入力に対応させます。LINEから位置情報を受け取ってログに出し、URLに加えるコードは次の通りです。入力が位置情報のみとなるようにtypeで判定しています。

 if (event.type !== 'message' || event.message.type !== 'location') {
    return Promise.resolve(null);
  }
  console.log(event.message.latitude + ' : ' + event.message.longitude);
 url = 'https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=アクセスキー&latitude=' + event.message.latitude + '&longitude=' + event.message.longitude + '&range=2&freeword=ラーメン';

余談

 
 今回、正常系で動くところまで作れたのですが、ラーメン店が見つからないかった場合のエラーハンドリングを入れたかったなと思います。例えば、かわいい動物画像を出すとか。

 もう一つ思ったのは、jsonデータのコンソールログデバッグは時間がかかったなと。APIの解説がされているサイトをみて一気にコーディングできたらよいのですが...。まずは経験して慣れることが大切ですね。

 ちなみに、ProtoOut Studioの授業後から制作を開始し、Qiita公開まで6時間程度かかりました。
javascriptのデバッグ不慣れなのと、Qiita記事執筆をもう少し効率化できそうなので、もう少し頑張ってプロトアウトのサイクルを短くしてきたいなと思っています。

 ほんとに余談ですが、ProtoOut Studioの一環で進めているので、よくわからないエラーが発生して困っていると講師の方々に相談できます。校長である菅原のびすけさんに直接コーディング指南していただいてものすごく緊張していました(;'∀')

 今回はぐるなびAPIを利用しましたが、他にも猫の種類検索APIこの猫なに猫?Harry Potter APIなど気になるAPIが沢山あります(^^♪

 今後はこのLINE botをベースに、connpass APIやGoogle MAP APIなどと連携して、勉強会やイベントのお知らせと夕食を提案してくれるようなサービスにしていけたら面白そうかなと思っています。

参考

ソースコード

'use strict';

const axios = require('axios');       
const express = require('express');
const line = require('@line/bot-sdk');
const PORT = process.env.PORT || 3000;

const config = {
    channelSecret: 'LINE botのチャンネルシークレット',
    channelAccessToken: 'LINE botのアクセストークン'
};

const app = express();

app.get('/', (req, res) => res.send('Hello LINE BOT!(GET)')); //ブラウザ確認用(無くても問題ない)
app.post('/webhook', line.middleware(config), (req, res) => {
    console.log(req.body.events);

    //ここのif文はdeveloper consoleの"接続確認"用なので後で削除して問題ないです。
    if(req.body.events[0].replyToken === '00000000000000000000000000000000' && req.body.events[1].replyToken === 'ffffffffffffffffffffffffffffffff'){
        res.send('Hello LINE BOT!(POST)');
        console.log('疎通確認用');
        return; 
    }

    Promise
      .all(req.body.events.map(handleEvent))
      .then((result) => res.json(result));
});

const client = new line.Client(config);


async function handleEvent(event) {
  var url
  var ramen_url;
  var hitnum;

  // 位置情報のみに入力制限
  if (event.type !== 'message' || event.message.type !== 'location') {
    return Promise.resolve(null);
  }

  // 取得した位置情報をログに表示
  console.log(event.message.latitude + ' : ' + event.message.longitude);

  // ぐるなびAPIを使うためのURLに経緯を加える
  url = 'https://api.gnavi.co.j
p/RestSearchAPI/v3/?keyid=af2b4862cbe801c5e2e1b87ef52881f6&latitude=' + event.message.latitude + '&longitude=' + event.message.longitude + '&range=2&freeword=ラーメン';
  const encodeUrl = encodeURI(url);

  // ぐるなびAPIに問い合わせ
  const response = await axios.get(encodeUrl);

  //レスポンスの中からcategory"ラーメン"を探索
  for(var num = 0; num <= response.data.rest.length; num++){
    if(response.data.rest[num].category === 'ラーメン'){
//      console.log(response.data.rest[num]);
      hitnum = num;
      ramen_url = response.data.rest[num].url_mobile;
      break;
    }
  }

// ヒットしたラーメン店の住所をLINE botに返す
return client.replyMessage(event.replyToken, {
  type: 'location',
  title: response.data.rest[hitnum].name,
  address: response.data.rest[hitnum].address,
  latitude: response.data.rest[hitnum].latitude,
  longitude: response.data.rest[hitnum].longitude
});
}
app.listen(PORT);
console.log(`Server running at ${PORT}`);
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

位置情報を送ると最寄りのラーメン店を教えてくれるLINE botをつくってみた?

概要

 
 この記事はLINE bot・javascrptの初心者が、「LINE bot 面白い!」という熱意で作ったものの制作記です。

 何をつくったか...。
「LINE botに位置情報を送ると、その近辺のラーメン屋さんを教えてくれるbot」を作りました?!

【デモ】

 本記事ではこのLINE botのサーバプログラムと、ラーメン店情報を検索するためにぐるなび APIを解説しています。基礎的な機能のみ実装しているので、店舗の星の多さ、複数件表示などは未実装です。

 位置情報を送ると、その最寄りのラーメン屋さんを一軒返すところまで動かしました。
以下、2020年3月29日時点の情報です。

開発環境

OS:Windows 10
Node.js:v10.15.3

【ライブラリバージョン】
@line/bot-sdk:6.8.4
express:4.17.1
axios:0.19.2

構成

 構成は次のようになっています。ローカルPC上でNode.jsを動かしているので、グローバルネットワークとのつながりをngrokでトンネリングしています。

構成図.jpg

 位置情報をNode.jsサーバが受け取ると、ぐるなび APIを利用して最寄りののラーメン店情報を取得します。このとき、ユーザから送られた位置情報をぐるなび APIのリクエストURLに含めています。

実装

ぐるなび APIの調査

 ぐるなび APIはAPIの使用説明だけでなく、APIテストツールまで用意されていました。手始めに、freewordクエリを追加してリクエストしてみると、レスポンスが下の方に表示されます。
(アクセスキーはテスト用になっています)

image.png

 どんなクエリがあるかは、API仕様のレストラン検索 APIで公開されています。

プログラムからアクセスしてみる

 LINE botとのやり取りと、APIアクセスはこちらのソースコードをもとに進めていきます。
APIテストツールを使うと、リクエストURLが生成されるのでコピーしてaxisos.getに引数に与えます。
こんな感じです。

axios.get('https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=b17d2f36873123b0d0f09e9139556c79&freeword=ラーメン');

 この状態でアクセスしたら、次のようなエラーが発生しました。

Request path contains unescaped characters

 コーディングミスというよりは、URLとして認識されていないようなエラーメッセージですね。試しにフリーワードをローマ字(ra-men)にしたらエラーが解消されて、レスポンスが返るようになりました!
↓レスポンスのログ

ローマ字なら動いた.JPG

 一先ず、これでレスポンスが返るので先に進みます。日本語対応は後述の「後回しにしていた日本語対応」で解説しています。

最寄りの店に絞る

 フリーワード検索のみだと全国の店が検索されてしまうので、最寄りの店に絞ります。これはぐるなび APIのクエリに経緯を追加することで絞ることが可能です。

 最初は経緯を固定で動かします。経緯を調べるにはGoogleマップが便利です。今回はProtoOut Studioの本拠地であるgranicaにしてみました。

【リクエストURL」

https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=アクセスキー&latitude=35.704607&longitude=139.772416&range=2&freeword=ラーメン

 このURLで問題なくレスポンスが来ればOKです。

が、居酒屋が引っかかる!!!!!!
image.png

ラーメン店に絞る

 ラーメンに絞りたいので、レスポンスデータの中のcategoryでフィルタリングしました。

for(var num = 0; num <= response.data.rest.length; num++){
    if(response.data.rest[num].category === 'ラーメン'){
      console.log(response.data.rest[num]);
      ramen_url = response.data.rest[num].url_mobile;
      break;
    }
  }

 これで最寄りのラーメン店に絞ることができました!と、思いきや最寄りにラーメン店が見つからなかった場合、エラーが発生します。ちなみに、最寄りとは半径500m圏内です。今回は正常系のみ作ることを目的とし、エラーハンドリングをスルーします...。

後回しにしていた日本語対応

 フリーワードに日本語が入っていることが原因なので、URLにエンコードしてくれるencodeURI関数がありました。こちらの記事の通りになおすと、フリーワードに日本語が入っていても通るようになりました!
こんなコードです。

url = 'https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=アクセスキー&latitude=35.704607&longitude=139.772416&range=2&freeword=ラーメン';
  const encodeUrl = encodeURI(url);
  const response = await axios.get(encodeUrl);

LINE botへのレスポンスを位置情報にする

 このままテキストで返してもよいのですが、LINE botには位置情報を返す機能がありまので、位置情報で返してみました。ちなみに、テキストでURLを返すと次のようになります。
Screenshot_20200328-230452_LINE.jpg

これを位置情報にすると、

Screenshot_20200328-231744_LINE.jpg

こっちの方が見栄え良さそうですね イェーイ(/・ω・)/

LINE botで位置情報を返すコードは次の通りです。経緯だけではなく住所も返す必要があります。
コード中のhitnumは、レスポンスデータの中で、category”ラーメン”にヒットした位置が格納されているとします。ぐるなび APIの良いところはこれらの情報が全てそろっていることです?

return client.replyMessage(event.replyToken, {
  type: 'location',
  title: response.data.rest[hitnum].name,
  address: response.data.rest[hitnum].address,
  latitude: response.data.rest[hitnum].latitude,
  longitude: response.data.rest[hitnum].longitude
});

位置情報入力を追加

 最後に位置情報入力に対応させます。LINEから位置情報を受け取ってログに出し、URLに加えるコードは次の通りです。入力が位置情報のみとなるようにtypeで判定しています。

 if (event.type !== 'message' || event.message.type !== 'location') {
    return Promise.resolve(null);
  }
  console.log(event.message.latitude + ' : ' + event.message.longitude);
 url = 'https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=アクセスキー&latitude=' + event.message.latitude + '&longitude=' + event.message.longitude + '&range=2&freeword=ラーメン';

余談

 
 今回、正常系で動くところまで作れたのですが、ラーメン店が見つからないかった場合のエラーハンドリングを入れたかったなと思います。例えば、かわいい動物画像を出すとか。

 もう一つ思ったのは、jsonデータのコンソールログデバッグは時間がかかったなと。APIの解説がされているサイトをみて一気にコーディングできたらよいのですが...。まずは経験して慣れることが大切ですね。

 ちなみに、ProtoOut Studioの授業後から制作を開始し、Qiita公開まで6時間程度かかりました。
javascriptのデバッグ不慣れなのと、Qiita記事執筆をもう少し効率化できそうなので、もう少し頑張ってプロトアウトのサイクルを短くしてきたいなと思っています。

 ほんとに余談ですが、ProtoOut Studioの一環で進めているので、よくわからないエラーが発生して困っていると講師の方々に相談できます。校長である菅原のびすけさんに直接コーディング指南していただいてものすごく緊張していました(;'∀')

 今回はぐるなびAPIを利用しましたが、他にも猫の種類検索APIこの猫なに猫?Harry Potter APIなど気になるAPIが沢山あります(^^♪

 今後はこのLINE botをベースに、connpass APIやGoogle MAP APIなどと連携して、勉強会やイベントのお知らせと夕食を提案してくれるようなサービスにしていけたら面白そうかなと思っています。

参考

ソースコード

'use strict';

const axios = require('axios');       
const express = require('express');
const line = require('@line/bot-sdk');
const PORT = process.env.PORT || 3000;

const config = {
    channelSecret: 'LINE botのチャンネルシークレット',
    channelAccessToken: 'LINE botのアクセストークン'
};

const app = express();

app.get('/', (req, res) => res.send('Hello LINE BOT!(GET)')); //ブラウザ確認用(無くても問題ない)
app.post('/webhook', line.middleware(config), (req, res) => {
    console.log(req.body.events);

    //ここのif文はdeveloper consoleの"接続確認"用なので後で削除して問題ないです。
    if(req.body.events[0].replyToken === '00000000000000000000000000000000' && req.body.events[1].replyToken === 'ffffffffffffffffffffffffffffffff'){
        res.send('Hello LINE BOT!(POST)');
        console.log('疎通確認用');
        return; 
    }

    Promise
      .all(req.body.events.map(handleEvent))
      .then((result) => res.json(result));
});

const client = new line.Client(config);


async function handleEvent(event) {
  var url
  var ramen_url;
  var hitnum;

  // 位置情報のみに入力制限
  if (event.type !== 'message' || event.message.type !== 'location') {
    return Promise.resolve(null);
  }

  // 取得した位置情報をログに表示
  console.log(event.message.latitude + ' : ' + event.message.longitude);

  // ぐるなびAPIを使うためのURLに経緯を加える
  url = 'https://api.gnavi.co.j
p/RestSearchAPI/v3/?keyid=af2b4862cbe801c5e2e1b87ef52881f6&latitude=' + event.message.latitude + '&longitude=' + event.message.longitude + '&range=2&freeword=ラーメン';
  const encodeUrl = encodeURI(url);

  // ぐるなびAPIに問い合わせ
  const response = await axios.get(encodeUrl);

  //レスポンスの中からcategory"ラーメン"を探索
  for(var num = 0; num <= response.data.rest.length; num++){
    if(response.data.rest[num].category === 'ラーメン'){
//      console.log(response.data.rest[num]);
      hitnum = num;
      ramen_url = response.data.rest[num].url_mobile;
      break;
    }
  }

// ヒットしたラーメン店の住所をLINE botに返す
return client.replyMessage(event.replyToken, {
  type: 'location',
  title: response.data.rest[hitnum].name,
  address: response.data.rest[hitnum].address,
  latitude: response.data.rest[hitnum].latitude,
  longitude: response.data.rest[hitnum].longitude
});
}
app.listen(PORT);
console.log(`Server running at ${PORT}`);
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

新型コロナウイルス感染症相談ボットの作成

概要

普段は耳鼻咽喉科の開業医をしています。

新型コロナウイルス感染患者数が増えていますね。発熱や風邪症状が出ると心配になるかと思います。各自治体に「帰国者・接触者相談センターセンター」があり相談の目安が政府から公表されているのは皆さんもご存じかと思います。それとは別に新型コロナウイルス感染症が流行している国や地域に行った方などが発熱や風邪症状が出た場合は医療機関を受診する前にまず「帰国者・接触者相談センター」に連絡することになっています。

このあたりが少しややこしいので『ボットの質問に答えていくと「帰国者・接触者相談センター」に連絡すべきかどうかがわかるLINE Bot」を作成しました。

適切な電話相談のタイミングが分かり、感染の拡大や重症化の防止、相談センターの方の負担軽減につながればいいなと思っています。

完成動画

作成方法

政府や自治体からの新型コロナウイルスに関する発表、所轄の保健所や学会からの連絡を基にプログラムを作成しました。開発者(医師)自身の考えはプログラムには組み込んでいません。
新型コロナウイルスの感染が認められている国・地域の情報は外務省のホームページに、
国内のクラスターマップは厚生労働省のホームページ(地方自治体の報道発表等に基づき新型コロナウイルス厚生労働省対策本部が集計した速報値に基づくもので、随時更新される)にリンクさせました。
電話相談の基準を満たしているならば連絡先が表示され、そうでない場合は自宅安静やかかりつけ医への相談が推奨されます。
※このボットは「発熱や風邪症状がある方」を対象としています。

こちらのBotのコードをいじって作ったので変数名が一部そのままになってます。
耳年齢を判定するLINE Bot×Iotの作成

index.js
'use strict';
require('dotenv').config();
const express = require('express');
const line = require('@line/bot-sdk');
const PORT = process.env.PORT || 3000;

const config = {
    channelSecret: "自分のchannelSecret",
    channelAccessToken: "自分のchannelAccessToken"
};

const app = express();
app.use(express.static('public'));

app.get('/', (req, res) => res.send('Hello LINE BOT!(GET)')); //ブラウザ確認用(無くても問題ない)

app.post('/webhook', line.middleware(config), (req, res) => {
  console.log(req.body.events);

  //ここのif分はdeveloper consoleの"接続確認"用なので削除して問題ないです。
  if(req.body.events[0].replyToken === '00000000000000000000000000000000' && req.body.events[1].replyToken === 'ffffffffffffffffffffffffffffffff'){
    res.send('Hello LINE BOT!(POST)');
    console.log('疎通確認用');
    return;
  }

  Promise
      .all(req.body.events.map(event=>handleEvent(event,req)))
      .then((result) => res.json(result));     
});


const client = new line.Client(config);

function handleEvent(event, req) {
  // console.log(req);
  if (event.type !== 'message' || event.message.type !== 'text') {
    return Promise.resolve(null);
  }

  // LINE botのプログラム
  let ans = "";
  let question = event.message.text;

  let hz;

  let yes = "";
  let  no = "";
  if (question == "開始") {
    hz = 1;
    ans = "発熱または風邪症状がありますか?";
    yes = "発熱:はい";
    no = "発熱:いいえ";
  } else if (question == "発熱:はい") {
    hz = 2;
    ans = "新型コロナウイルス感染症であることが確定した人と濃厚接触歴がありますか?";
    yes = "感染者濃厚接触:はい";
    no = "感染者濃厚接触:いいえ";
  } else if (question == "感染者濃厚接触:いいえ") {
    hz = 3;
    ans = "発症前14日以内に新型コロナウイルス感染症の流行が確認されている国や地域に行きましたか?";
    yes = "流行地域:はい";
    no = "流行地域:いいえ";
  } else if (question == "流行地域:いいえ") {
    hz = 4;
    ans = "発症前14日以内に新型コロナウイルス感染症の流行が確認されている国や地域に行った人と濃厚接触歴がありますか?";
    yes = "流行地域、濃厚接触:はい";
    no = "流行地域、濃厚接触:いいえ";
  } else if (question == "流行地域、濃厚接触:いいえ") {
    hz = 5;
    ans = "発症前14日以内にライブハウス、スポーツクラブ、介護施設、老人ホーム、お祭り、クルーズ船など感染者が多発した施設あるいはイベントに行きましたか?";
    yes = "施設、イベント:はい";
    no = "施設、イベント:いいえ";
  } else if (question == "施設、イベント:いいえ") {
    hz = 4;
    ans = "発症前14日以内に感染者が多発した施設あるいはイベントに行った人と濃厚接触歴がありますか?";
    yes = "施設、濃厚接触:はい";
    no = "施設、濃厚接触:いいえ";
  } else if (question == "施設、濃厚接触:いいえ") {     
    ans = "以下の(1)または(2)の症状が2日以上続いていますか?(1)風邪の症状や37.5度以上の発熱(解熱剤を飲み続けなければならない場合も同様)、(2)強いだるさや息苦しさ";
    yes = "2日以上:はい";
    no = "2日以上:いいえ";
  } else if (question == "2日以上:はい") {      
    ans = "以下のいずれかに当てはまりますか?高齢である、糖尿病・心不全・呼吸器疾患(COPD等)の基礎疾患がある、透析を受けている、免疫抑制剤や抗がん剤等を用いている、妊娠中である";
    yes = "基礎疾患:はい";
    no = "基礎疾患:いいえ";
  } else if (question == "基礎疾患:いいえ") {     
    ans = "以下の(1)または(2)の症状が4日以上続いていますか?(1)風邪の症状や37.5度以上の発熱(解熱剤を飲み続けなければならない場合も同様)、(2)強いだるさや息苦しさ";
    yes = "4日以上:はい";
    no = "4日以上:いいえ";
  } else if (question == "発熱:いいえ" || question == "2日以上:いいえ" || question == "4日以上:いいえ") {    
    hz = 0;
    ans = "自宅で安静にするか、かかりつけ医にご相談ください。";
  } else if (question == "感染者濃厚接触:はい" || question == "流行地域:はい" || question == "流行地域濃厚接触:はい" || question == "流行地域、濃厚接触:はい" || question == "施設、イベント:はい" || question == "施設、濃厚接触:はい") {  
    hz = 6;
    ans="医療機関は受診せず、「船橋市新型コロナウイルス感染症相談センター(電話:047-409-3127)【聴覚障害などにより電話での相談が難しい方は、FAX:047-409-2952】」に連絡してください。";
  } else if (question == "基礎疾患:はい" || question == "4日以上:はい") {   
    hz = 7;
    ans = "「船橋市新型コロナウイルス感染症相談センター(電話:047-409-3127)【聴覚障害などにより電話での相談が難しい方は、FAX:047-409-2952】」に相談してください。";
  } else if (question == "電話") { 
    hz = 7;    
    ans = "「船橋市新型コロナウイルス感染症相談センター(電話:047-409-3127)【聴覚障害などにより電話での相談が難しい方は、FAX:047-409-2952】」に相談してください。";
  } 

  let title;
  let url;
  if (hz == 1) {
    url = "「はい」か「いいえ」どちらかをお選びください";
  } else if (hz == 2) {
    url = "※濃厚接触とは、以下のような状況です : 感染者と同居、車内や航空機内等で、2メートル以内での長い時間の会話 / マスクや手袋などの個人防護具なしでの感染者の看護・介護 / 感染者の唾や鼻水、体液等に直接触れた";
  } else if (hz == 3) {
    title = "流行国・地域はこちら"
    url = "https://www.anzen.mofa.go.jp/covid19/country_count.html";
  } else if (hz == 4) {
    url = "※濃厚接触とは、以下のような状況です : 同居、車内や航空機内等で、2メートル以内での長い時間の会話 / マスクや手袋などの個人防護具なしで看護・介護 / 唾や鼻水、体液等に直接触れた";
  } else if (hz == 5) {
    title = "クラスターマップはこちら"
    url = "https://www.mhlw.go.jp/content/10900000/000609647.pdf";
  } else if (hz == 6 || hz == 7) {
    title = "全国の相談センターはこちら"
    url = "https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/kenkou_iryou/covid19-kikokusyasessyokusya.html";
  }

  if (hz == 0) {
    return client.replyMessage(event.replyToken, {
      type: 'text',
      text: ans
    });
  }
  if (hz==1||hz == 2||hz==4)
  {
    return client.replyMessage(event.replyToken, [      
      {
      "type": "template",
      "altText": "this is a confirm template",
      "template": {
          "type": "confirm",
          "text": ans,
        "actions": [             
              {
                "type": "message",
                "label": "はい",
                "text": yes
              },
              {
                "type": "message",
                "label": "いいえ",
                "text": no
              }
          ]
      }
      },
       {
        type: "flex",
        altText: "追加情報",
        contents: {
          type: "bubble",
          body: {
            type: "box",
            layout: "vertical",
            spacing: "md",
            contents: [              
              {
                type: "text",
                text:url,
                wrap: true,
                color: "#aaaaaa",
                size: "lg"
              }
            ]
          }
        }
      }    
    ]);
  }
  if (hz==3||hz==5)
  {
    return client.replyMessage(event.replyToken, [
      {
        "type": "template",
        "altText": "this is a confirm template",
        "template": {
            "type": "confirm",
            "text": ans,
          "actions": [             
                {
                  "type": "message",
                  "label": "はい",
                  "text": yes
                },
                {
                  "type": "message",
                  "label": "いいえ",
                  "text": no
                }
            ]
        }
        },
      {
        type: "flex",
        altText: "Flex Message",
        contents: {
          type: "bubble",
          footer: {
            type: "box",
            layout: "vertical",
            spacing: "sm",
            contents: [

              {
                type: "button",
                action: {
                  type: "uri",
                  label: title,
                  uri: url
                },
                height: "sm",
                style: "link"
              }
            ]
          }
        }
      }
    ]);
  }
  if (hz==6||hz == 7)
  { console.log("+++++++++++++電話+++++++++++")
    return client.replyMessage(event.replyToken, [           
      {
        type: 'text',
        text: ans
      },
      {
        type: "flex",
        altText: "Flex Message",
        contents: {
          type: "bubble",
          footer: {
            type: "box",
            layout: "vertical",
            spacing: "sm",
            contents: [

              {
                type: "button",
                action: {
                  type: "uri",
                  label: title,
                  uri: url
                },
                height: "sm",
                style: "link"
              }
            ]
          }
        }
      }
    ]);
  }

  else {
    return client.replyMessage(event.replyToken, [     
      {
      "type": "template",
      "altText": "this is a confirm template",
      "template": {
          "type": "confirm",
          "text": ans,
        "actions": [             
              {
                "type": "message",
                "label": "はい",
                "text": yes
              },
              {
                "type": "message",
                "label": "いいえ",
                "text": no
              }
          ]
      }
      },    
    ]);
  }

}

(process.env.NOW_REGION) ? module.exports = app : app.listen(PORT);
console.log(`Server running at ${PORT}`);

考察

現在、連絡先の電話やFaxの番号は医院がある自治体(船橋市)の「帰国者・接触者相談センターセンター」のものになっています。船橋市在住でない方はこちらには電話しないようお願いいたします。他の地域のユーザーのため全国の相談センターが出ている厚生労働省のホームページをリンクしていますが、ユーザーの位置情報や自宅住所を入力するとユーザー居住地の「帰国者・接触者相談センター」の電話番号などが分かるようにできれば他の自治体の方も使いやすいものができるのではないかと思っています。やり方ご存じの方は教えていただけると嬉しいです。

ボットのURLとQRコード

URL https://lin.ee/teqrODx

QRコード
covid19.png

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

Node.jsを用いて、ORMにSequelize, DBはRDS(MySQL)という構成でApollo Serverを用いて、GraphQL Serverを構成する【勉強メモ】

最初に

これはNode.js環境で, ORMにはSequelize, DBにはMySQLを使った構成で、Apollo Serverを用いてGraphQLサーバを構築してみた際の備忘録となります。

と言っても自身でイチから、これらの構成を構築していったわけではなく、下記のチュートリアルを参照しながらの勉強メモとなります。
(ちょうど同じ構成での例を探していたところ、丁寧に書かれていた下記のドキュメントを見つけました。ありがたい)

How To Set Up a GraphQL Server in Node.js with Apollo Server and Sequelize

ちなみにこのドキュメントの中では、sqlite3を用いているので、そこはこちらでMySQLに置き換えて実践しています。
また細かなところで適宜アレンジを施しています。

大枠自体は変わらないので、この構成(Node.js, Sequelizeなどを Apollo Serverと組み合わせる)に興味ある方は、直接ドキュメントを読まれることをおすすめします。
(というかこのポストを読み進めていく場合は、上の DigitalOcean のチュートリアル記事とセットで読んでいくことをおすすめします)

自身で実際に実装したコードはGithubに置いています。
shinshin86/graphql-recipe-server

Sequelizeを用いたDB関連のセットアップ

まずはDB関連のセットアップから行っていきます。
(ここらへん Apollo Server というよりは Sequelize の基本的なセットアップの流れになります)

インストール&初期化

必要なライブラリをインストール

yarn add sequelize sequelize-cli mysql2

Sequelize関連の初期化処理を実施

yarn sequelize init

テーブルの作成

次に必要なmodelとmigrationを作成・実施していきます。

まずはUserテーブルから作成

yarn sequelize model:create --name User --attributes name:string,email:string,password:string

作成した項目は空の入力を許可しないようにするなどの設定を行います。
※ここについては参照先の記述( Step 2 — Creating Models and Migrations )を参照してください。

次にRecipeテーブル

yarn sequelize model:create --name Recipe --attributes title:string,ingredients:text,direction:text

こちらも同じ用にmodelとmigrationの内容を編集していきます。
また、ここで userId の追加も実施しています。
※ここについても参照先の記述( Step 2 — Creating Models and Migrations )を参照してください。

        userId: {
          allowNull: false,
          type: Sequelize.INTEGER.UNSIGNED,
        },

ここにはレシピを作成したユーザIDが格納され、後々レシピを作成したユーザ情報を取得するために使われます。

associateの設定

modelも編集したら、UserとRecipeでそれぞれassociateの設定を行っていきます。

  // models/user.js
  User.associate = function(models) {
    User.hasMany(models.Recipe)
  };
  // models/recipe.jsから抜粋
  Recipe.associate = function(models) {
    Recipe.belongsTo(models.User, { foreignKey: 'userId' })
  };

DBに対する文字コード関連の設定を記述する

また、上記 migrationmodels にはDBに対する文字コードの設定も忘れないように記述します。
これを忘れると、日本語で入力した場合に文字コード関連でエラーになります。
(実はすっかり忘れていて、日本語を使ってエラーになったりしていました)

migrationファイルの場合、 queryInterface.createTable の第3引数に下記の内容をセットします。

{
  charset: 'utf8',
  collate: 'utf8_general_ci',
}

また modelファイルの場合は、sequelize.define の第3引数に下記の記述をセットします。

{
  charset: 'utf8',
  collate: 'utf8_general_ci',
}

migrationの実施

すでにローカルにMySQLは立ち上がっているものとします。

Sequelizeはセットアップしたデフォルトの状態だと password: null でアクセスするようになっているかと思いますが、流石にそれは現実味がない気がしたので、一応形だけですが、

  • root というユーザ名、
  • password というパスワード

で接続するように config/config.json に記述しました。
なので、DB自体もそのような設定で動かしています。
(これも現実味ないといえばないですが...)

ちなみに自身はローカルで動くDocker上に、最新のMySQLを立ち上げて、そちらを使っていきます。

下記のコマンドでDBの作成・migrationを実施していきます

yarn sequelize db:create
yarn sequelize db:migrate

GraphQL Serverを作成する

ここからが本番です。
必要なライブラリをインストールしていきます。

apollo-servergraphql に依存するため、こちらも併せてインストールしています。
また bcryptjsはユーザのパスワードをハッシュ化するために使用します。

yarn add apollo-server graphql bcryptjs

次に src ディレクトリを作成し、必要なファイルを作成していきます。

mkdir src
nv src/index.js

ソース自体は参照元の Step 3 — Creating the GraphQL Server を参照してもらうとして、下記の context: { models } でmodels側とのつなぎ込みを行っているようでした。

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: { models }
})

ちなみにGraphQLには Query, Mutations, Subscriptions がありますが、このチュートリアルではQuery, Mutationsに焦点が当てられています。

schemaを作成していく(GraphQL)

次に src/schema.js を作成していきます。
記述を見ると、設定したassociateを反映させた構成になっているのが分かります。

  type User {
    id: Int!
    name: String!
    email: String!
    recipes: [Recipe!]!
  }

  type Recipe {
    id: Int!
    title: String!
    ingredients: String!
    direction: String!
    user: User!
  }

queryは3つ設定されているようです。

  • user IDを引数にしてユーザ情報を取得するもの
  • すべてのレシピを取得するもの
  • recipe IDを引数にしてレシピ情報を取得するもの
  type Query {
    user(id: Int!): User
    allRecipes: [Recipe!]!
    recipe(id: Int!): Recipe
  }

mutation は2つ設定されています。

  • name, email, password を引数にしてユーザを作成するもの
  • userId, title, ingredients, direction を引数にしてレシピを作成するもの
  type Mutation {
    createUser(name: String!, email: String!, password: String!): User!
    createRecipe(
      userId: Int!
      title: String!
      ingredients: String!
      direction: String!
    ): Recipe!
  }

resolverを設定していく(GraphQL)

上に書いたqueryに対応する実際の処理がresolverには書かれています。
実際にどういうロジックが動くのかはこちらを見れば、大体イメージがつくかと思います。
resolver内に実際に書かれるロジックは、特にGraphQL的なものというのはそれほどなく、実際の取得ロジックなどが書かれる形となるので(今回で言えば、Sequelizeを用いたデータ作成・取得処理など)、結構すぐに馴染める印象でした。

例えば MutationのcreateUserの場合ならば、下記のように実装されています。

async createUser(root, { name, email, password }, { models }) {
  return models.User.create({
    name,
    email,
    password: await bcrypt.hash(password, 10),
  });
},

Sequelize v5ではfindByIdではなくfindByPkとなる(余談)

ちなみに現時点で sequelize の最新のversionをインストールした場合、sequelizeのv5系 がインストールされるかと思います。
v5ではsequelizeに実装されていた findByIdは廃止され findByPk に移行しています。
参照しているドキュメントでは findById で書かれているので、 ここは findByPk に書き換える必要があります。
(sequelize v4系をインストールした場合は書き換える必要はありません)

diff --git a/src/resolvers.js b/src/resolvers.js
index 7372e74..0cacc9b 100644
--- a/src/resolvers.js
+++ b/src/resolvers.js
@@ -3,13 +3,13 @@ const bcrypt = require('bcryptjs');
 const resolvers = {
   Query: {
     async user(root, { id }, { models }) {
-      return models.User.findById(id);
+      return models.User.findByPk(id);
     },
     async allRecipes(root, args, { models }) {
       return models.Recipe.findAll();
     },
     async recipe(root, { id }, { models }) {
-      return models.Recipe.findById(id);
+      return models.Recipe.findByPk(id);
     },
   },
   Mutation: {

ApolloServerのcontextについて

第3引数として models が渡っていますが、これは src/indexでcontextに models を指定すると渡せるようです。

const models = require('../models');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: { models },
});

例えば下記のようなコードを書いて、該当の箇所のコードを動かすと、 { hoge: 'hogehoge' } がログとして出力されます。

diff --git a/src/index.js b/src/index.js
index e1049b7..133f156 100644
--- a/src/index.js
+++ b/src/index.js
@@ -6,7 +6,7 @@ const models = require('../models');
 const server = new ApolloServer({
   typeDefs,
   resolvers,
-  context: { models },
+  context: { models, hoge: 'hogehoge' },
 });

 server
diff --git a/src/resolvers.js b/src/resolvers.js
index 0cacc9b..3bc1d24 100644
--- a/src/resolvers.js
+++ b/src/resolvers.js
@@ -8,7 +8,8 @@ const resolvers = {
     async allRecipes(root, args, { models }) {
       return models.Recipe.findAll();
     },
-    async recipe(root, { id }, { models }) {
+    async recipe(root, { id }, { models, hoge }) {
+      console.log({hoge})
       return models.Recipe.findByPk(id);
     },
   },

他に書き残しとくべき箇所というのも、あまりないのですが、
下記の user.getRecipes(), recipe.getUser() などは Sequelize 側での処理になります。
associate で設定しているゆえに、こういう形で取得ができます。

  User: {
    async recipes(user) {
      return user.getRecipes();
    },
  },
  Recipe: {
    async user(recipe) {
      return recipe.getUser();
    },
  },

これで、動かすための必要な実装はすべて完了です。

Apollo Playgroundで実際に試してみる

下記コマンドでsevrerを起動します。

node src/index.js
# もしくは "yarn start"

http://localhost:4000/
にアクセスすると、親しみやすいApolloのPlaygroundが表示されます。

とりあえずuserを作成して見ようと思います。

mutation {
  createUser (
    name: "テストユーザ1"
    email: "text@example.com"
    password: "password"    
  ) {
    id
    name
    email
  }
}

すると下記のような反応が返ってきます。

{
  "data": {
    "createUser": {
      "id": 1,
      "name": "テストユーザ1",
      "email": "text@example.com"
    }
  }
}

サーバのログを見ると、SQLが発行されているのも確認できます。

Executing (default): INSERT INTO `Users` (`id`,`name`,`email`,`password`,`createdAt`,`updatedAt`) VALUES (DEFAULT,?,?,?,?,?);

次にレシピを作成します。
先ほど作成したテストユーザ1に紐づくレシピを作成します。

mutation {
  createRecipe(
    userId: 1
    title: "サンプルレシピ1"
    ingredients: "Salt, Pepper"
    direction: "Add salt, Add pepper"
  ) {
    id
    title
    ingredients
    direction
    user {
      id
      name
      email
    }
  }
}

下記のようなレスポンスが返ります。

{
  "data": {
    "createRecipe": {
      "id": 1,
      "title": "サンプルレシピ1",
      "ingredients": "Salt, Pepper",
      "direction": "Add salt, Add pepper",
      "user": {
        "id": 1,
        "name": "テストユーザ1",
        "email": "text@example.com"
      }
    }
  }
}

サーバのログには下記のようなSQLが発行されているのが確認できます。

INSERT INTO `Recipes` (`id`,`title`,`ingredients`,`direction`,`createdAt`,`updatedAt`,`userId`) VALUES (DEFAULT,?,?,?,?,?,?);

また、同時レスポンス時に必要となるSQLが発行されているのも分かります。

SELECT `id`, `name`, `email`, `password`, `createdAt`, `updatedAt` FROM `Users` AS `User` WHERE `User`.`id` = 1;

User作成時は SELECT は発行されていませんでしたが、今回は紐づくユーザ情報も返す必要があるため、SELECT クエリを発行する必要があったということかと想像します
(ソースはまだ読んでいません)

queryについてはあまりここに書かなくても、結構情報はある気がしたので、ざっくりと。

query {
  allRecipes {
    id
    title
    user {
      name
    }
  }
}

ちなみに上のようなqueryを発行した場合、SQL的には下記のように返ってくるようです。

SELECT `id`, `title`, `ingredients`, `direction`, `createdAt`, `updatedAt`, `userId`, `UserId` FROM `Recipes` AS `Recipe`;
SELECT `id`, `name`, `email`, `password`, `createdAt`, `updatedAt` FROM `Users` AS `User` WHERE `User`.`id` = 1;

テーブルjoinして取得するような動きではありませんが、別にそういうオプションがあるのか、実装的にそういう形になっているのかは今後調べていくこととします。
結果は下記の通り。

{
  "data": {
    "allRecipes": [
      {
        "id": 1,
        "title": "サンプルレシピ1",
        "user": {
          "name": "テストユーザ1"
        }
      }
    ]
  }
}

ざっくりとではありますが、以上勉強メモとなります。

参照

How To Set Up a GraphQL Server in Node.js with Apollo Server and Sequelize

Model | Sequelize(findByPk)

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

Mac Nodebrewを用いてNode.jsをインストールした時にエラーが出た話

目的

  • Nodebrewを用いたNode.jsインストール時に出たエラーを解決した話をまとめる

実施環境

  • ハードウェア環境
項目 情報 備考
OS macOS Catalina(10.15.3)
ハードウェア MacBook Air (11-inch ,2012)
プロセッサ 1.7 GHz デュアルコアIntel Core i5
メモリ 8 GB 1600 MHz DDR3
グラフィックス Intel HD Graphics 4000 1536 MB

エラー内容

  • Nodebrewを使用してNode.jsの最新の安定バージョンをインストールしようとした時に下記のエラーが発生した。
  • 実行コマンドをとエラー内容を下記に記載する。

    $ nodebrew install-binary stable
    Fetching: https://nodejs.org/dist/v12.16.1/node-v12.16.1-darwin-x64.tar.gz
    Warning: Failed to create the file                                                                                          
    Warning: /Users/ユーザ名/.nodebrew/src/v12.16.1/node-v12.16.1-darwin-x64.tar.gz: 
    Warning: No such file or directory
    
    curl: (23) Failed writing body (0 != 1016)
    download failed: https://nodejs.org/dist/v12.16.1/node-v12.16.1-darwin-x64.tar.gz
    

エラー原因

  • 原因は単純でインストール先のディレクトリがみなさんのPCに存在しないためインストールできないと言われている。

解決方法

  1. 下記コマンドを実行してインストール用のディレクトリを作成する。

    $ mkdir ~/.nodebrew/src
    
  2. もう一度問題のコマンドを実行する。

    $ nodebrew install-binary stable
    Fetching: https://nodejs.org/dist/v12.16.1/node-v12.16.1-darwin-x64.tar.gz ###################################################################################################################### 100.0%
    Installed successfully
    
  3. 正常に実行された。

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