20200524のJavaScriptに関する記事は30件です。

javascriptで動画の明るさを調整できるchrome拡張機能を作ったから、作り方を1から解説してみる

初めに

adjusting the video bright
Youtubeやニコニコ動画の動画の明るさを調整できるchrome拡張機能です。

地味に便利な拡張機能と思います。

自分なりの解釈で書いているので、間違っているところがあるかもしれないので参考程度に見ておいてください。

必要なファイル

  • index.html (browser actionで開くときに必要)
  • background.js (backgroundで必要)
  • content.js (content_scriptで必要)
  • manifest.json (chrome拡張機能の名前や説明、使うchrome APIなどを記述するときに使う 必須なファイル) 。

まとめる

まず、コードを書く前にやるべきことをまとめます。

1,ポップアップ画面はどのような画面にするか
明るさを調整するだけなので、シンプルでいいです。

input type="range"で調整できるようにして、それを左に置き,
決定ボタンは右に置くようにします。
決定ボタンを押せば、動画が暗くなるようにします。

popup.png

汚くてすみません....
右上のiconをクリックすればポップアップが表示されて、rangeが左,ボタンが右 という感じです。
最低でもinputタグのrangeタイプとbuttonタグがあれば十分です。あとはお好みのデザインにしても構いません。

2.必要なjavascriptファイル。メッセージパッシング

まずyoutubeの画面を暗くするには、cssであれこれやるのですが、それをするためにはDOM操作が必要になります。
ポップアップに使うHtmlファイルの外部jsファイルでやればいいかと思うとできません。

DOM操作を行うためのJsファイルは決まってます。
manifest.jsonにあるcontents_scriptに指定したjsファイルです。

ポップアップのhtmlファイルにcontent_scriptsに指定したjsファイルを外部jsファイルとしてdom操作をしたら、出来るのかというと,これもできません。

ポップアップするhtmlファイルをこの記事では拡張機能側と呼んでおきます。
拡張機能側からdom操作をすることはできません。

1.png

じゃあどうすればいいのかというと、拡張機能側からcontent_scriptsのjsファイルに必要なデータを渡して、そのjsファイル(content.js)でdom操作を行うようにすればいいことです。

無題.png

htmlだけじゃcontent_scriptsにデータを送信できないので、外部jsファイルでcontent_scriptsに送信します。
またデータを送信するにはmanifest.jsonのbackgroundに指定されたbackground.jsがchromeAPIを使い、送信していきます。

流れをまとめると

1,拡張機能側で必要なinputの値を取って、決定ボタンを押す

2,外部jsファイル(background.js)がinputの値などのデータをcontent_scriptsに送信

3,content_scriptsはデータを受け取り、dom操作でページに反映する。

このような感じです。

この送信したり受け取ったりすることをメッセージパッシング(message passing)と言います。

コードを書いていく

1,DOM操作で、動画を暗くするやり方を知る。
まず,暗くするやり方を身につけましょう。

Youtubeの動画の画面をデベロッパーツールで見ます。すると、videoタグでclass属性はvideo-streamなどが記載されていることが分かります。
では、そこに filter: brigtness(0.5); とデベロッパーツールの下の方にある、cssを入力できる場所に入力してみると画面が暗くなります。

cssのfilterプロパティとは画像や動画を加工するために使われています。
特に画像に使われます。
MDNにbrightness以外のものもあるので見てください。

つまり、content_scriptsで指定したjsファイル(content.js)で、videoタグのclass属性を取得さえすれば出来るということです。

let movie = document.getElementsByClassName("video-stream");
movie[0].style.filter = "brightness(0.5)";

まず1行目でvideo-streamクラスを取得します。
2行目なのですが、なぜmovie[0]の[0]を付けたのかということを簡単に言いますと、特定するためです。
id属性は一つしか付与することはできないのに対し、class属性は複数のタグに同じクラス名を付与できます。

movieだけだと、どのタグについてるvideo-streamクラスなのかわかりません。
なので、ここは一番最初のvideo-streamクラスにfilterプロパティをつけたいので、movie[0]としています。

<div class="video-stream">0</div> //movie[0]
<div class="video-stream">1</div> //movie[1]

一つ目のタグを取りたいなら[0]をつけ、2つ目のタグをとりたいなら[1]をつけるようにする。
なお、id属性をとるのなら、[0]などについては必要ありません。

2,manifest.jsonを作る

まずサンプルをここに載せて置きます。

manifest.json
{
   "manifest_version": 2,
   "name": "",
   "description": "",
   "version": "1.0",
   "browser_action": {
     "default_icon" : "",
     "default_title": "",
     "default_popup": ""
   },
   "icons":{
     "16":"16x16....",
     "
   }
   "permissions" : [
   ],
   "content_scripts": [{
       "matches": ["<all_urls>"],
       "js": [""]
   }],
   "background":{
     "scripts": [""],
     "persistent": false
   }
 }

masnifest.jsonの書き方

manifest.jsonにはこういう感じで書いていきます。
まず、"manifest_version" : 2とありますが、manifestのバージョンで、現在バージョンが2であるためです。これは絶対にこのまま書いておきましょう

nameとは拡張機能の名前です。

descriptionとは拡張機能の説明です。どのような拡張機能か書きましょう。

versionとは拡張機能のバージョンを指します。なにか変えたりしてアップデートを行った場合、versionも変えましょう。作成した拡張機能をアップデートをして、再びウェブストアにアップロードするときversionを変えてなかったら、エラーガ起こります。

続いてbrowser_actionについて

default_icon拡張機能のアイコンです。
38x38 の大きさの画像を作成して、指定してあげてください。

default_titleは右上にある拡張機能のアイコンにマウスオーバー(マウスを乗せる)すると出てくる名前です。

default_popupに関しては,htmlファイルを指定します。
指定した場合、アイコンをクリックすると、ポップアップが出てきます。
指定しなかった場合,何も出てきません。

icons指定された画像はウェブストアの時に使います
16x16 48x48 128x128の大きさを指定してあげてください

permissionsではchrome APIを使いたいときに記述します。
permissionsに書かなくても使えるchromeAPIはあります。

content_scriptsdom操作をするときに使います。
machesには正規表現で書き、拡張機能を使う対象のサイトを書きます。
jsでは動作させるスクリプトを書きます。
content_scriptsで、今見ているタブ(サイト)をDOM操作で色々と書き換えることができます。

background: バックグランドページとイベントページがあります。
今はイベントページが推奨されています。
scriptsバックグランドで動作するjsファイルを指定します。
persistentはfalseにすると、イベントページとなり何も書かなかったらバックグランドページとなります。
バックグラウンドページは裏側ではずっと動いているのに対して、イベントページでは必要なときに動くのでpersistent: falseは書いておきましょう。

ポップアップ画面を作る

次にポップアップ画面をデザインしていきます。
これは好きなようにして構いませんが、最低でも

  <input type="range">
  <button></button>

この二つは必要です。

3,chrome APIをリファレンスを読みながら作っていく。
基本的に chrome api を読んでいきながら作ってみましょう。

まず,どんなapiを使うのかまとめると

chrome tabs api
chrome runtime api

これらを使っていきます。
tabs APIで送信して、runtime APIで受け取るようにします。
なおtabs APIbackgroundで動くjavascriptでないと動作しないので、ポップアップするHtmlファイルの外部jsにbackground.jsを使います。

つまり
1,htmlのinputタグの値をbackground.jsで取得し、tabsAPIを使って送信

2,content_scriptsのjsで,background.jsが送信したデータをruntime APIで受け取る

3,受け取ったデータを使いDOM操作をやる。

background.jsを書いていこう!

background.js
let btn = document.getElementById('btn'); //button を取得
let col = document.getElementById('elem'); // input type=range を取得

ボタンをクリックしたら、送信したいので

background.js
btn.addEventListener('click',function(){
    //ここにapiを記述
}

このように記述します。
まずchrome tabs apiに行きます。tabsAPIで使いたいメソッドは、

  • query()
  • sendMessage()

この二つのメソッドとなります。

まずqueryメソッドについて学んでいこうと思います。

chrome.tabs.query(object queryInfo, function callback)

このようなコードとなっています。何が何だかわからないと思いますが見ていきましょう。
queryメソッドの引数にはobject queryInfo, とあります。最後にコンマ( , )があるので、第一引数はここまでということが分かります。

まずobjectとありますが、これは型を示しています。
javascriptでオブジェクトを書くときは,

sample.js
let obj = {
  "***": ***
}

と書くように、このobject queryInfoも{}で囲んであげて、
その中に ○○○:○○○と書いていきます。

sample.js
chrome.tabs.query({}, function callback)

chrome.tabs.query({○○○:○○○, ○○○:○○○}, function callback)

このような感じになります。この{}の中にjsonと同じように書いていきます。
公式サイトを見ますが、
active pinned audibleとずらっと書いてあります。その左にbooleanと書いてありますが、これが何を示しているのかというと、簡単に言えば設定です。
booleanというとtrueやfalseということなので、
{active: true} また{active: false}このような感じで書けということです。

ここで使うオプションはactivecurrentWindowの二つのオプションです。
ちなみにどっちもbooleanです。

activeはタブがウィンドウでアクティブかどうか。とグーグル翻訳で出ています。
つまり今このタブを開いているかどうかです。
trueにしましょう。

currentWindowはタブが現在のウィンドウにあるかどうかです。
つまり、今見ているサイトかどうかです。これもtrueにしましょう。

この二つのオプションをtrueにすることで、開いているサイトで尚且つ、今見ているサイトの情報を取得することになります。

sample.js
chrome.tabs.query({active: true, currentWindow: true }, () => {
})

object queryInfoが第一引数に対して、第二引数はfunction callbackです。
引数の中に関数があるため、第二引数はコールバック関数であることは一目瞭然です。

コールバック関数の引数には,object queryInfoで指定されたタブ(サイト)の情報があり、それをsendMessageメソッドに渡します。

sample.js
chrome.tabs.sendMessage(integer tabId, any message, object options, funct
ion responseCallback)

第一引数には、見ているサイトのtabIdを書かないといけないので、queryメソッドのコールバック関数にサイトの情報 (tabId)があるので、使います。

sample.js
chrome.tabs.query({active: true, currentWindow: true},tab => {
  chrome.tabs.sendMessage(tab[0].id, any message, object options, fu
  nction responseCallback)
})

tab[0].idtabIdを取得できます。

第二引数には送信したいデータを指定します。
公式サイトには送信するメッセージ。このメッセージは、JSONで送信可能なオブジェクトである必要がありますと書いています。
つまりjson形式で書いてくださいということです。

また送信したいデータはinput type="range"の値です。

sample.js
let col = document.getElementById('elem'); // input tag

chrome.tabs.query({active: true, currentWindow: true},tab => {
  chrome.tabs.sendMessage(tab[0].id, { dark : Number(col.value)})
})

{dark : Number(col.value)}と第二引数に書きました。
Numberメソッドで、引数にあるinput range の値を数値に変えています。

第三引数からに関しては必要ないと思っています。
まず第一、このinput rangeの値さえcontent_scriptsに渡せばいいのですから、一方的なんですよね。

これをまとめたコードがこちらです。

background.js
let btn = document.getElementById('btn');
let col = document.getElementById('elem');

btn.addEventListener('click',function(){
 chrome.tabs.query({active: true, currentWindow: true},tab => {
   chrome.tabs.sendMessage(tab[0].id,{ dark : Number(col.value) })
 })  
})

これでボタンをクリックすればapiが実行され、content_scriptsに送信されます。

次はcontent.jsで送信されたデータを受け取り、dom操作で反映させます。

content.jsを書く

受け取る方法は簡単で,
runtime apichrome.runtime.onMessage.addListener()を使うだけです。

content.js
chrome.runtime.onMessage.addListener(function callback)

引数の中に関数functionがあるのでコールバック関数となります。
コールバック関数の引数には,

第一引数にmessage, 第二引数にsender, 第三引数にsendResponseがあります。

content.js
chrome.runtime.onMessage.addListener(
  function(message, sender, sendResponse){

  }
)

受け取ったデータをDOM操作するというシンプルなことですから、第一引数のmessageだけで、問題ないです。
messageには送信したデータが入っています。

あとは、最初に学んだDOM操作を関数の中に書いてあげます。

content.js
chrome.runtime.onMessage.addListener(
  function(message, sender, sendResponse){
    let movie = document.getElementsByClassName('video-stream');
    movie[0].style.filter = `brightness(${message.dark})`
  }
)

brightness(${message.dark})はテンプレートリテラルを使ってます。バッククォートで囲んであげてください。

作った拡張機能を読み込んでみよう

これで拡張機能は作れたので読み込んでみましょう。

1, まずブラウザを開き,右上の3つの点をクリック
2, その他のツールをクリック出てきた、拡張機能をクリック
3, パッケージ化されていない拡張機能を読み込むをクリックし、フォルダを選択
4,完了です。ページに戻り右上に見ると追加されているのが分かります。
エラーの場合は、manifest.jsonにちょっとおかしいところがあります。なおしましょう。

ウェブストアにアップロードしよう。

実際にアップロードするのは、この記事で作った拡張機能ではなく、オリジナルの拡張機能でお願いします。

1.chromeウェブストアに行く。
2,右上にある歯車マークを押し,デベロッパーダッシュモードを選択
3,1ドル(だいたい100円)を払う。
4,作ったフォルダを圧縮してzipにしてから、アップロード。
5,ダッシュボードに行き、英語で拡張機能の説明を書き、完了したら審査に出す。自分は英語が書けないので、DeepLを使い書きました。

これでアップ完了です。自分はアップしてから五日ほどで審査が終わり、ストアに出せるようになりました。最初は検索しても出てきませんでしたが、2日ほど待つと後ろの方ですが検索に出てくるようになりました。
またアップデートしたものを、ウェブストアに出すとき、審査されますが自分は1日で終わりました。

終わりに

初めて拡張機能を作ってみましたが、chromeAPIがちょっと難しかったなと思います。
adjusting the video brightという拡張機能をできれば試してほしいと思います。
UIが崩れてるかもしれないので、もし崩れてたら教えてほしいです。
お願いします!

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

Rails6.0でselectboxの複数選択をおしゃれにするJavaScriptライブラリ「select2」の導入方法

はじめに

皆様、こんにちは!
佐久間まゆちゃんのプロデューサーの@hiroki_tanakaです。

先日、Rails6.0系でselectboxの複数選択をおしゃれにするプラグインのselect2を導入することがありました。
その際にRails5系までの導入方法との違いに少しハマったので、調べたことをまとめました。

利用環境

  • Ruby 2.6.6
  • Rails 6.0.2

select2とは

select2とはHTMLのselectboxのデザインをおしゃれにするJavaScriptのライブラリです。
公式サイトはこちらです。→select2
単一選択のselectboxのUIだけでなく、複数選択のselectboxも簡単に実装する事ができるプラグインです。

公式サイト上のサンプル

  • select2導入前
    image.png

  • select2導入後の複数選択プルダウン
    image.png

Rails6.0での導入方法

Rails5系でのselect2の導入はGemを使用しての導入となっていましたが、Rails6.0からはGemではなくWebpacker使用するようになりました。
1つずつ手順を紹介したいと思います。

※Rails5でselect2の導入は下記のページが非常に詳細に説明しています。
【Rails5】Select2で複数選択可能なセレクトボックスを作る

1. yarnでselect2に必要なライブラリ導入

下記のコマンドを実行して、Railsアプリケーションにselect2を導入します。
この時、select2だけではなくselect2の導入に必要なjQuery(及びpopper.js)とselect2のUI部分に当たるbootstrapも併せて導入します。

$ yarn add jquery
$ yarn add popper.js
$ yarn add bootstrap
$ yarn add select2

2. Rails上でjQuery・popper.jsを使用できるように設定

Railsアプリケーションのwebpack/environment.jsにjQueryをRailsのどのファイルからも呼び出せるように設定します。

config/webpack/environment.js
const { environment } = require('@rails/webpacker')

// jQueryとBootstapのJSを使えるように
const webpack = require('webpack')
environment.plugins.prepend(
    'Provide',
    new webpack.ProvidePlugin({
        $: 'jquery',
        jQuery: 'jquery',
        Popper: 'popper.js'
    })
)
module.exports = environment

また、packs/application.jsのマニュフェストにJQueryを追加します。

app/javascript/packs/application.js
require("jquery")

3. Rails上でbootstrapを使用できるように設定。

下記の2つの設定を行い、Railsアプリケーションでbootstrapを使用できるように設定します。

app/javascript/packs/application.js
import 'bootstrap';
import '../stylesheets/application';
app/javascript/stylesheets/application.scss
@import '~bootstrap/scss/bootstrap';

4. erbファイルでwebpackerを使用するように設定。

Railsアプリケーションの全てのViewファイルでwebpackerに導入したbootstrap・jQueryが使用できるように大元のlayouts/application.html.erbに設定します。

app/views/layouts/application.html.erb
<head>
<%= stylesheet_pack_tag "application", media: "all" %>
<%= javascript_pack_tag 'application' %>
</head>

5. selectboxをerb上に定義し、select2を使用するようにJSファイルに記載。

アプリケーションのerb上に複数選択を行うselectboxを定義します。
この時、select_tagはmultiple: trueとoption設定すれば、複数選択可能なselectboxとなります。

そして、該当のselectboxがselect2を使用するようにerbファイルに対応するJSファイルに記載します。

app/views/test.html.erb
<%= javascript_pack_tag 'test' %>
<%= select_tag('animal', options_for_select([['いぬ', 'dog'], ['ねこ', 'cat'], ['とり', 'bird'], ['うし', 'cow'], ['へび', 'snake']]), class: "form-control", multiple: true) %>
app/javascript/packs/test.js
$(function () {
    $('#animal').select2();
});

6. 完成!!

image.png

おまけ:select2のoption利用

select2には様々なoptionが用意されています。→select2 Options
これらのoptionはjQueryで設定することが可能です。
下記はoption設定の一例です。

app/javascript/packs/test.js
$(function () {
    $('#animal').select2({
        width: 'resolve' // 幅をページサイズに併せて動的に変更する。
        theme: "classic" // クラシックUIに変更する。
        debug: true // ブラウザのコンソールにデバッグメッセージを出力する。
    });
});

おわりに

yarnを使用することでselect2の導入が更に簡単になったように感じます。
ただ、select2の内部挙動に関してはわからないことが多いので理解を深めたいです。

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

GoogleMapを使ってサイクリングに出かけよう

タイトルから、何の技術の話かわからないですね。
GoogleMapにナビゲーション機能ってありますよね、それをサイクリングでナビゲートしてもらおうというものです。
サイクリング中は、手が使えないので、音声ナビがあると便利です。
ですが、ポケストップを探すたびに、立ち止まってスマホを見るのは手間ですし、軽快ではありません。

そこで、(ポケストップに限りませんが)サイクリング前に、途中のチェックポイントを複数覚えておいて、チェックポイントまでの道のりをナビゲートしてもらって、到着したら、ポケストップのアイテムをもらったり、コンビニで休憩したのち、次のチェックポイントを目指す、というものです。

image.png

原理の説明:GoogleMapの機能

GoogleMapは、外部のアプリやブラウザから起動させることができます。
その際に、パラメータの指定によって、場所の表示だけでなく、出発地点と目的地を指定して、ナビゲーションを開始させることもできます。

ですので、まずはブラウザから、サイクリングで回りたいチェックポイントのリストを作って、GoogleMapを起動して次のチェックポイントまでナビゲーションしてもらいます。
チェックポイントまで到着したら、またブラウザを立ち上げて、今度は次のチェックポイントを指定してGoogleMapを起動させる、これを最後の目的地まで繰り返すわけです。

GoogleMapのナビゲーション機能

ナビゲーション中は、以下のことを適宜音声で教えてくれます。

・予定通りの道を進んでいるかどうか
・次の曲がり角まで何メートルか
・今曲がるべき曲がり角か
・予定の道を外れたか
・目的地の近くに来たか
・目的地に着いたか

上記は、ナビを開始すると、設定が選べるようになり、「詳しい音声案内」のスイッチをOnにした場合です。Offの場合はもうちょっと少ない気がします。

もろもろGitHubに上げておきました。

poruruba/orientation_navigator
 https://github.com/poruruba/orientation_navigator

画面説明

ブラウザを起動するとこんな感じの画面が表示されます。

image.png

まずは、チェックポイントを追加します。タブ「チェックポイント」を選択し、チェックポイントを追加します。

image.png

GoogleMapがはめ込まれて表示されるので、出発地点を選択します。

image.png

さらに、同じように次のチェックポイントを追加します。

image.png

経由地っていうチェックボックスがあります。通常は次のチェックポイントに到着するとGoogleMapのナビゲーションが終わってしまうのですが、経由地は次のチェックポイントまでの途中の経由地であって、経由地を通過しただけでは、GoogleMapのナビゲーションは終わらないようにしています。

オリエンテーションタブを選択すると、マーキングされているのがわかります。
これで準備完了です。
さっそく、「オリエンテーション開始」ボタンを押下してみましょう。

image.png

そうすると、こんな感じでGoogleMapが立ち上がり、ナビゲーション開始待ちとなります。
ちなみに、PCのChromeブラウザからの画面ですが、Androidから使うと、ブラウザのGoogleMapか、ネイティブのGoogleMapアプリ、どちらを起動するかの選択肢が出てきます。もちろん、ネイティブのGoogleMapアプリの方がナビゲーションとしては使い勝手が良いです。

image.png

あとは、開始してしまえば、いつものナビゲーションが始まります。
イヤホンで、GoogleMusic(Youtube Music)でも聞きながら、サイクリングしましょう。

チェックポイントに到着したら、もう一度ブラウザに戻りましょう。

image.png

「チェックポイントに到着しましたか?」ボタンを押下すると、次のチェックポイントに出発のボタンに代わりますので、押下すると、またGoogleMapが立ち上がります。今度は、1つ目のチェックポイントから、2つ目のチェックポイントへのナビゲーションです。

おおよそ、イメージはつかめましたでしょうか?

マイスポット機能

毎度毎度、場所を選択するのはめんどうです。特に家の周りはいつものコースを決めていますが、毎度ポケストップを指定するのは面倒です。
そこで、あらかじめよくいくスポットをマイスポット機能として登録しておけば、それを選択するだけで、チェックポイントに追加されるようになります。

マイスポットのタブを選択して、登録します。

image.png

そうすると、こんな感じで、チェックポイント登録する際に、マイスポットから選択することができます。

image.png

サーバ同期

実は、チェックポイントを追加したり、マイスポットを追加したりしたら、サーバ側にデータを保持するようにしています。ですので、ブラウザを立ち上げなおしても、以前の状態が復元されるようにしています。また、ナビゲーション中にチェックポイントに到達したりした時もサーバ側に同期するようにしています。

とはいってもクライアント・サーバいずれもかなり手抜きしています。
クライアント側は、Vueのwatchを使って、対象のデータが変更されたら、サーバに一括アップロードしているだけです。
取得も、ブラウザでの起動時だけです。

GoogleMap起動のURL生成

大事なところをピックアップしました。
travelmode、origin、destination、waypointsを指定しているのがわかります。

travelmode
 歩きの移動か、車の移動か、電車の移動かを指定します。自転車がありますが、日本では使えないようです。

origin
 出発地点です。ブラウザで設定したチェックポイントに相当します。経由地ではありません。

destination
 これもブラウザで設定したチェックポイントですが、originのチェックポイントの次のチェックポイントです。

waypoints
 これが経由地です。

start.js
            // GoogleMap起動のパラメータを生成
            var params = "";
            var origin = this.checkpoints[this.origin_index];
            params += "&travelmode=" + this.travelmode;
            params += "&origin=" + encodeURIComponent(origin.lat + ',' + origin.lng);
            if( destination_index <= (this.checkpoints.length - 1)){
                var destination = this.checkpoints[destination_index];
                params += "&destination=" + encodeURIComponent(destination.lat + ',' + destination.lng);
            }
            if( (this.origin_index + 1) < destination_index ){
                var waypoints = "";
                for( var i = (this.origin_index + 1) ; i < destination_index ; i++ ){
                    if( i != (this.origin_index + 1) ) waypoints += '|';
                    waypoints += this.checkpoints[i].lat + ',' + this.checkpoints[i].lng;
                }
                params += "&waypoints=" + encodeURIComponent(waypoints);
            }
            var href = 'https://www.google.com/maps/dir/?api=1' + params;
            console.log(href);
            this.destination_completed = false;
            // GoogleMapを起動
            window.open(href, '_blank');

詳しくは以下を参照してください。

GoolgeMap Developers Guide Univarsal cross-platform syntax
 https://developers.google.com/maps/documentation/urls/guide?hl=ja#directions-action

ソース一式

Javascriptのソースです。

start.js
'use strict';

//var vConsole = new VConsole();

const default_lat = 35.465878;
const default_lng = 139.622329;
const base_url = "http://localhost:10080";

var vue_options = {
    el: "#top",
    data: {
        progress_title: '', // for progress-dialog

        origin_index: 0, // 出発のインデックス
        destination_completed : true, // チェックポイントに到着したかどうか
        checkpoints: [], // チェックポイントのリスト
        map_markers: [], // Map1に配置のマーカ
        myspots: [], // マイスポットのリスト
        dialog_params: {}, // モーダルダイアログの入出力パラメタ
        map2_markers: [], // Map2に配置のマーカ
        default_latlng: new google.maps.LatLng(default_lat, default_lng), // デフォルトのロケーション(現在地に上書き)
        travelmode: 'walking', // GoogleMapに指定するtravelmode
    },
    computed: {
        // ボタンに表示するテキスト
        orientation_text: function(){
            if( !this.destination_completed )
                return 'チェックポイントに到着しましたか?';

            if( this.origin_index == 0 )
                return 'オリエンテーション開始';
            else if( this.origin_index >= (this.checkpoints.length - 1) )
                return '最終目的地に到着しました。';
            else
                return '次のチェックポイントに出発';
        }
    },
    watch: {
        checkpoints: function(){
            // Mapに配置のマーカを再設定
            for( var i = 0 ; i < this.map_markers.length; i++ )
                this.map_markers[i].setMap(null);
            this.map_markers = [];
            for( var i = 0 ; i < this.checkpoints.length ; i++ ){
                var latlng = new google.maps.LatLng(this.checkpoints[i].lat, this.checkpoints[i].lng); 
                var mopts = {
                    position: latlng,
                    map: this.map,
                    label: String(i + 1)
                };
                var marker = new google.maps.Marker(mopts);
                this.map_markers.push(marker);
            }

            try{
                // サーバに同期
                update_data('checkpoints', this.checkpoints);
            }catch(error){
                this.toast_show("サーバにデータをアップロードできませんでした。");
            };
        },
        myspots: function(){
            try{
                // サーバに同期
                update_data('myspots', this.myspots);
            }catch(error){
                this.toast_show("サーバにデータをアップロードできませんでした。");
            };
        },
        origin_index: function(){
            try{
                // サーバに同期
                update_data('orientation', { origin_index: this.origin_index, destination_completed: this.destination_completed });
            }catch(error){
                this.toast_show("サーバにデータをアップロードできませんでした。");
            };
        },
        destination_completed: function(){
            try{
                // サーバに同期
                update_data('orientation', { origin_index: this.origin_index, destination_completed: this.destination_completed });
            }catch(error){
                this.toast_show("サーバにデータをアップロードできませんでした。");
            };
        },
    },
    methods: {
        // デフォルトのロケーションに移動
        map_goto_current_location: function(){
            var latlng = this.default_latlng;
            this.map.setCenter(latlng);                
        },
        // オリエンテーションタブ選択時にデフォルトのロケーションまたは出発位置に移動
        orientation_update_view: function(){
            var latlng = this.default_latlng;
            if( this.checkpoints.length > 0 )
                latlng = new google.maps.LatLng(this.checkpoints[this.origin_index].lat, this.checkpoints[this.origin_index].lng);
            this.map.setCenter(latlng);                
        },
        // オリエンテーションを指定位置からリスタート
        orientation_restart: function(index){
            if( index < 0 )
                if( !window.confirm('本当に最初から初めてもいいですか?') )
                    return;

            this.origin_index = (index < 0) ? 0 : index;
            this.destination_completed = true;
            this.orientation_next();
        },
        // 次の目的地(経由地を除く)を取得
        get_next_destination: function(){
            var destination_index = this.origin_index + 1;
            for( ; destination_index < this.checkpoints.length ; destination_index++ )
                if( !this.checkpoints[destination_index].waypoint )
                    break;
            if( destination_index >= this.checkpoints.length )
                destination_index = this.checkpoints.length - 1;
            return destination_index;
        },
        // 次へのボタンを押下
        orientation_next: function(){
            if( this.checkpoints.length == 0 ){
                alert('チェックポイントを追加してください。');
                return;
            }else if( this.checkpoints.length == 1 ){
                alert('次のチェックポイントを追加してください。');
                return;
            }

            if( this.origin_index >= (this.checkpoints.length - 1) ){
                alert('すでに目的地に到達しています。');
                return;
            }

            var destination_index = this.get_next_destination();
            if( (destination_index - (this.origin_index + 1)) > 9){
                alert('経由地の数が多すぎます。(9以下)');
                return;
            }

            if( !this.destination_completed ){
                // チェックポイントに到達
                this.origin_index = destination_index;
                this.destination_completed = true;

                // 目的位置に到達
                if( destination_index >= (this.checkpoints.length - 1) )
                    this.dialog_open('#orientation_complete_dialog');

                return;
            }

            // GoogleMap起動のパラメータを生成
            var params = "";
            var origin = this.checkpoints[this.origin_index];
            params += "&travelmode=" + this.travelmode;
            params += "&origin=" + encodeURIComponent(origin.lat + ',' + origin.lng);
            if( destination_index <= (this.checkpoints.length - 1)){
                var destination = this.checkpoints[destination_index];
                params += "&destination=" + encodeURIComponent(destination.lat + ',' + destination.lng);
            }
            if( (this.origin_index + 1) < destination_index ){
                var waypoints = "";
                for( var i = (this.origin_index + 1) ; i < destination_index ; i++ ){
                    if( i != (this.origin_index + 1) ) waypoints += '|';
                    waypoints += this.checkpoints[i].lat + ',' + this.checkpoints[i].lng;
                }
                params += "&waypoints=" + encodeURIComponent(waypoints);
            }
            var href = 'https://www.google.com/maps/dir/?api=1' + params;
            console.log(href);
            this.destination_completed = false;
            // GoogleMapを起動
            window.open(href, '_blank');
        },

        // Map2のマーカをクリアし、指定場所に移動
        map2_cleanup: function(latlng){
            for( var i = 0 ; i < this.map2_markers.length; i++ )
                this.map2_markers[i].setMap(null);
            this.map2_markers = [];
            if( this.map2_default_marker ){
                this.map2_default_marker.setMap(null);
                this.map2_default_marker = null;
            }
            this.map2.setCenter(latlng);
        },
        // モーダルダイアログの結果処理
        dialog_submit: function(){
            if( this.dialog_params.title == 'マイスポットの追加' ){
                var location = this.map2.getCenter();
                var name = this.dialog_params.name;
                this.myspots.push({
                    name, lat: location.lat(), lng: location.lng()
                });
            }else if( this.dialog_params.title == 'チェックポイントの追加' ){
                var location = this.map2.getCenter();
                var name = this.dialog_params.name;
                this.checkpoints.push({
                    name, lat: location.lat(), lng: location.lng()
                });
            }else if( this.dialog_params.title == 'マイスポットの変更'){
                var location = this.map2.getCenter();
                this.myspots[this.dialog_params.index].lat = location.lat();
                this.myspots[this.dialog_params.index].lng = location.lng();
            }else if( this.dialog_params.title == 'チェックポイントの変更' ){
                var location = this.map2.getCenter();
                this.checkpoints[this.dialog_params.index].lat = location.lat();
                this.checkpoints[this.dialog_params.index].lng = location.lng();
            }
            this.dialog_close('#select_location_dialog');
        },

        // マイスポットの追加(地図から)のためのモーダルダイアログ表示
        do_myspot_append: function(){
            this.map2_cleanup(this.default_latlng);
            this.map2_default_marker = new google.maps.Marker({
                position: this.default_latlng,
                map: this.map2,
            });

            this.dialog_params = {
                title: 'マイスポットの追加',
                is_input_name: true,
                is_input_submit: true,
            };
            this.dialog_open('#select_location_dialog');
        },
        // マイスポットの削除
        do_myspot_delete: function(index){
            if( !window.confirm('本当に削除していいですか?') )
                return;

            Vue.delete(this.myspots, index);
        },
        // マイスポットの名前変更
        do_myspot_change_name: function(index){
            var name = window.prompt('新しい名前', this.myspots[index].name);
            if( !name )
                return;
            this.myspots[index].name = name;
        },
        // マイスポットのロケーション変更
        do_myspot_change_location: function(index){
            var latlng = new google.maps.LatLng(this.myspots[index].lat, this.myspots[index].lng);
            this.map2_cleanup(latlng);
            this.map2_default_marker = new google.maps.Marker({
                position: latlng,
                map: this.map2,
            });

            this.dialog_params = {
                title: 'マイスポットの変更',
                index: index,
                is_input_submit: true,
            };
            this.dialog_open('#select_location_dialog');
        },

        // チェックポイントリストのリセット
        do_checkpoints_reset: function(){
            if( !window.confirm('本当にリセットしていいですか?') )
                return;
            this.origin_index = 0;
            this.destination_completed = true;
            this.checkpoints = [];
        },
        // チェックポイントの追加(マイスポットから)のためのモーダルダイアログ表示
        do_checkpoint_append_myspot: function(){
            if( this.myspots.length == 0 ){
                alert('マイスポットが登録されていません。');
                return;
            }

            this.map2_cleanup(this.default_latlng);

            this.dialog_params = {
                title: 'チェックポイントの追加(マイスポット)',
            };
            var _this = this;
            for( var i = 0 ; i < this.myspots.length ; i++ ){
                var mopts = {
                  position: new google.maps.LatLng(this.myspots[i].lat, this.myspots[i].lng),
                  map: this.map2,
                };
                var marker = new google.maps.Marker(mopts);
                this.map2_markers.push(marker);
                marker.addListener('click', function(e){
                    for( var i = 0 ; i < _this.map2_markers.length ; i++ ){
                        if( _this.map2_markers[i] == this ){
                            _this.checkpoints.push(_this.myspots[i]);
                            _this.dialog_close('#select_location_dialog');
                            return;
                        }
                    }
                });
            }
            this.dialog_open('#select_location_dialog');
        },
        // チェックポイント追加(地図から)のためのモーダルダイアログ表示
        do_checkpoint_append: function(){
            this.map2_cleanup(this.default_latlng);
            this.map2_default_marker = new google.maps.Marker({
                position: this.default_latlng,
                map: this.map2,
            });

            this.dialog_params = {
                title: 'チェックポイントの追加',
                is_input_name: true,
                is_input_submit: true,
                name : (this.checkpoints.length == 0) ? '現在地' : '',
            };
            this.dialog_open('#select_location_dialog');
        },
        // チェックポイントの削除
        do_checkpoint_delete: function(index){
            if( !window.confirm('本当に削除していいですか?') )
                return;

            Vue.delete(this.checkpoints, index);
        },
        // チェックポイントの名前変更
        do_checkpoint_change_name: function(index){
            var name = window.prompt('新しい名前', this.checkpoints[index].name);
            if( !name )
                return;
            this.checkpoints[index].name = name;
        },
        // チェックポイントのロケーション変更
        do_checkpoint_change_location: function(index){
            var latlng = new google.maps.LatLng(this.checkpoints[index].lat, this.checkpoints[index].lng);
            this.map2_cleanup(latlng);
            this.map2_default_marker = new google.maps.Marker({
                position: latlng,
                map: this.map2,
            });

            this.dialog_params = {
                title: 'チェックポイントの変更',
                index: index,
                is_input_submit: true,
            };
            this.dialog_open('#select_location_dialog');
        },
        // チェックポイントの順番変更
        do_checkpoint_change_index: function(index, event){
            var newIndex = event.target.selectedIndex;
            var temp = this.checkpoints[index];
            Vue.set(this.checkpoints, index, this.checkpoints[newIndex]);
            Vue.set(this.checkpoints, newIndex, temp);
        },
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

        // 現在地情報の取得
        navigator.geolocation.getCurrentPosition((pos) =>{
            this.default_latlng = new google.maps.LatLng(pos.coords.latitude, pos.coords.longitude);
            this.map_goto_current_location();
        }, (error) =>{
            this.toast_show("現在地を取得できませんでした。");
        });

        // Mapの生成
        var myOptions = { 
          zoom: 15, 
          center: this.default_latlng, 
          mapTypeId: google.maps.MapTypeId.ROADMAP,
          mapTypeControl: false,
          streetViewControl: false,
        }; 
        var canvas = $('#map_canvas')[0];
        this.map = new google.maps.Map(canvas, myOptions);

        // Map2(モーダルダイアログ用)の生成
        var canvas2 = $('#map_canvas2')[0];
        this.map2 = new google.maps.Map(canvas2, myOptions);
        google.maps.event.addListener(this.map2, 'center_changed', () =>{
            if( !this.map2_default_marker )
                return;
            var location = this.map2.getCenter();
            this.map2_default_marker.setPosition(location);
        });

        // サーバ保持データの取得
        get_data('myspots')
        .then(data =>{
            this.myspots = data;
            return get_data('checkpoints');
        })
        .then(data => {
            this.checkpoints = data;
            return get_data('orientation');
        })
        .then(data => {
            if( data.origin_index != undefined )
                this.origin_index = data.origin_index;
            if( data.destination_completed != undefined )
                this.destination_completed = data.destination_completed;
        })
        .catch(error =>{
            this.toast_show("サーバからデータを取得できませんでした。");
        });
    }
};
vue_add_methods(vue_options, methods_bootstrap);
vue_add_components(vue_options, components_bootstrap);
var vue = new Vue( vue_options );

function do_post(url, body) {
    const headers = new Headers({ "Content-Type": "application/json; charset=utf-8" });

    return fetch(new URL(url).toString(), {
        method: 'POST',
        body: JSON.stringify(body),
        headers: headers
      })
      .then((response) => {
        if (!response.ok)
          throw 'status is not 200';
        return response.json();
      });
}

async function get_data(type){
    return do_post(base_url + '/get-data', { type: type })
    .then(json =>{
        if( json.status != 'OK' )
            throw "post failed";
        return json.result.data;
    });
}

async function update_data(type, data){
    var body = {
        type: type,
        data: data,
    }
    return do_post(base_url + '/update-data', body)
    .then(json =>{
        if( json.status != 'OK' )
            throw "post failed";
    });
}

次はHTMLです。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>

  <title>オリエンテーション ナビゲータ</title>

  <link rel="stylesheet" href="css/start.css">
  <script src="js/methods_bootstrap.js"></script>
  <script src="js/components_bootstrap.js"></script>
  <script src="js/vue_utils.js"></script>

  <script src="dist/js/vconsole.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>

  <script type="text/javascript" src="//maps.google.com/maps/api/js?key=【GoogleAPIキー】"></script> 
</head>
<body>
    <div id="top" class="container">
        <h1>オリエンテーション ナビゲータ</h1>

        <ul class="nav nav-tabs">
          <li role="presentation" class="active"><a href="#oriatation" v-on:click="orientation_update_view" data-toggle="tab">オリエンテーション</a></li>
          <li role="presentation"><a href="#checkpoint" data-toggle="tab">チェックポイント</a></li>
          <li role="presentation"><a href="#myspot" data-toggle="tab">マイスポット</a></li>
        </ul>

        <div class="tab-content">
          <div id="oriatation" class="tab-pane fade in active">
            <br>
            <span class="form-inline">
              <select class="form-control" v-model="travelmode">
                <option value="walking">walking</option>
                <option value="bicycling">bicycling</option>
                <option value="driving">driving</option>
                <option value="transit">transit</option>
              </select>
            </span>
            <div class="btn-group">
              <button class="btn btn-primary" v-on:click="orientation_next">{{orientation_text}}</button>
              <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown"><span class="caret"></span></button>              
              <ul class="dropdown-menu">
                <li><a v-on:click="orientation_restart(-1)">最初から再開</a></li>
                <li><a v-on:click="orientation_restart(origin_index)">今のチェックポイントを再開</a></li>
              </ul>
            </div>
            <span v-if="checkpoints.length >= 2">
              <br><br>
              <label>現在地:</label>{{origin_index + 1}} {{checkpoints[origin_index].name}},
              <label>目的地:</label>{{get_next_destination() + 1}} {{checkpoints[get_next_destination()].name}}
            </span>
            <button class="btn btn-default btn-xs pull-right" v-on:click="map_goto_current_location">現在地へ</button>
            <br>
            <div class="row" id="map_canvas" style="margin: 15px; height:600px"></div>
          </div>

          <div id="checkpoint" class="tab-pane fade in">
            <br>
            <button class="btn btn-primary" v-on:click="do_checkpoints_reset">チェックポイントのリセット</button>
            <table class="table table-striped">
              <thead>
                <tr><th>#</th><th>名前</th><th>経由地</th><th>編集</th></tr>
              </thead>
              <tbody>
                <tr v-for="(point, index) in checkpoints">
                  <td width="1px">
                    <div class="form-inline">
                      <select v-bind:value="index" v-on:change="do_checkpoint_change_index(index, $event)">
                        <option v-for="(point2, index2) in checkpoints" v-bind:value="index2" v-bind:selected="index==index2">{{index2 + 1}}</option>
                      </select>
                    </div>
                  </td>
                  <td>
                    <button class="btn btn-default btn-xs" v-on:click="do_checkpoint_delete(index)">削除</button> {{point.name}}
                  </td>
                  <td>
                    <input v-if="index!=0 && index!=(checkpoints.length-1)" type="checkbox" v-model="point.waypoint">
                  </td>
                  <td>
                    <div class="btn-group">
                      <button class="btn btn-default btn-sm" v-on:click="do_checkpoint_change_name(index)">名前</button>
                      <button class="btn btn-default btn-sm" v-on:click="do_checkpoint_change_location(index)">場所</button>
                    </div>
                  </td>
                </tr>
                <tr>
                  <td></td>
                  <td>
                    <div class="btn-group">
                      <button class="btn btn-default btn-sm" v-on:click="do_checkpoint_append">チェックポイント追加</button>
                      <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown"><span class="caret"></span></button>              
                      <ul class="dropdown-menu">
                        <li><a v-on:click="do_checkpoint_append_myspot">マイスポットから追加</a></li>
                      </ul>
                    </div>
                  </td><td></td><td></td>
                </tr>
              </tbody>
            </table>            
          </div>

          <div id="myspot" class="tab-pane fade in">
            <br>
            <table class="table table-striped">
              <thead>
                <tr><th>#</th><th>名前</th><th>緯度</th><th>経度</th><th>編集</th></tr>
              </thead>
              <tbody>
                <tr v-for="(spot, index) in myspots">
                  <td width="1px">{{index + 1}}</td>
                  <td><button class="btn btn-default btn-xs" v-on:click="do_myspot_delete(index)">削除</button> {{spot.name}}</td>
                  <td>{{spot.lat.toFixed(7)}}</td><td>{{spot.lng.toFixed(7)}}</td>
                  <td>
                    <div class="btn-group">
                      <button class="btn btn-default btn-sm" v-on:click="do_myspot_change_name(index)">名前</button>
                      <button class="btn btn-default btn-sm" v-on:click="do_myspot_change_location(index)">場所</button>
                    </div>
                  </td>
                </tr>
                <tr>
                  <td></td>
                  <td>
                    <button class="btn btn-default btn-sm" v-on:click="do_myspot_append">地図から追加</button>
                  </td>
                  <td></td><td></td><td></td>
                </tr>
              </tbody>
            </table>            
          </div>
          <br>
          <br>
        </div>

        <modal-dialog size="lg" id="orientation_complete_dialog">
          <div slot="content">
            <div class="modal-header">
              <h4 class="modal-title">オリエンテーション達成</h4>
            </div>
            <div class="modal-body">
              <center>
                オリエンテーション達成です。おめでとうございます。<br>
                <img src="img/goal_figure.png">
              </center>
            </div>
            <div class="modal-footer">
              <button class="btn btn-default" v-on:click="dialog_close('#orientation_complete_dialog')">閉じる</button>
            </div>
          </div>
        </modal-dialog>

        <modal-dialog size="lg" id="select_location_dialog">
          <div slot="content">
            <div class="modal-header">
              <h4 class="modal-title">{{dialog_params.title}}</h4>
            </div>
            <div class="modal-body">
              <div class="form-inline">
                <button class="btn btn-default" v-on:click="dialog_submit" v-if="dialog_params.is_input_submit">この場所にする</button>
                <span v-if="dialog_params.is_input_name">
                  <label>名前</label> <input type="text" class="form-control" v-model="dialog_params.name">
                </span>
              </div>
              <div class="row" id="map_canvas2" style="margin: 20px; height:300px"></div>
            </div>
            <div class="modal-footer">
              <button class="btn btn-default" v-on:click="dialog_close('#select_location_dialog')">キャンセル</button>
            </div>
          </div>
        </modal-dialog>


        <!-- for progress-dialog -->
        <progress-dialog v-bind:title="progress_title"></progress-dialog>
    </div>

    <script src="js/start.js"></script>
</body>

もう長すぎてわけわかんないですよね。。。
その他ユーティリティのファイル等含めて、GitHubに上げています。

セットアップ:GoogleMap API利用の準備

GoogleMapを利用するには、GoogleからAPIキーを払い出してもらう必要があります。
以下のサイトの通りに実施すれば、特に問題はなかったです。

Get Started with Google Maps Platform
 https://developers.google.com/maps/gmp-get-started?hl=ja

最後に、API Keyを生成するのですが、Web APIsのMaps Embed APIを採用しました。

セットアップ:サーバの展開

以下のGitHubから一式ダウンロードしておきます。

poruruba/orientation_navigator
 https://github.com/poruruba/orientation_navigator

そして、以下の通りに実行します。

unzip orientation_navigator.zip
cd orientation_navigator
npm install
mkdir data
node app.js

以下、修正が必要です。

public/start.js
 7行目のあたり
 const base_url = "http://localhost:10080";

上記を立ち上げたサーバのURLを指定します。

public/index.html
 34行目あたり
 <script type="text/javascript" src="//maps.google.com/maps/api/js?key=【GoogleAPIキー】"></script>

また、HTML5の現在地情報取得機能を使っているのですが、それを利用するには、HTTPSである必要があります。

mkdir certs

このディレクトリに、SSL証明書類を配置しましょう。

以下が参考になります。
 SSL証明書を取得しよう

以上

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

async/await(Promise)のreturnとエラーハンドリング

目的

JavaScriptにおけるasync/awaitとPromiseは同じものですが、
エラーハンドリングに関しては違いがあったり、returnに気をつけないといけなかったり、
はまったり、見落としたりする箇所があるので、まとめておきます。

async/awaitとPromiseの関係性については、記事中で詳しくは触れません。

おさらい

Promise

正常系

// 非同期でメッセージがかえってくる
const pResult = Promise.resolve("結果です");

// 結果が帰ってきてないので、Promise型
console.log(pResult.toString()); // "[object Promise]"

// thenで結果を待つ
pResult.then(result => {
  console.log(result.toString()); // "結果です"
});

エラー発生

// 非同期でErrorがthrowされた状態
// RESTでAPIエラー起こすなどでここに入る
const pError = Promise.reject(new Error("エラーです"));

// 結果が帰ってきてないので、Promise型
console.log(pError.toString()); // "[object Promise]"

// catchでエラーを取る
pError
  .then(result => {
    console.log("到達しない!");
  })
  .catch(error => {
    console.log(error.toString()); // "Error: エラーです"
  });

async/await

正常系

// async(Promise)内でしかawaitできない
(async () => {
  // 非同期でメッセージがかえってくる
  const pResult = Promise.resolve("結果です");

  // 結果が帰ってきてないので、Promise型
  console.log(pResult.toString()); // "[object Promise]"

  // awaitで結果を待つ
  const result = await pResult;

  console.log(result.toString()); // "結果です"
})()

エラー発生

(async () => {
  // 非同期でErrorがthrowされた状態
  // RESTでAPIエラー起こすなどでここに入る
  const pError = Promise.reject(new Error("エラーです"));

  // 結果が帰ってきてないので、Promise型
  console.log(pError.toString()); // "[object Promise]"

  // throwされるので、tryでエラーを取る
  try {
    await pError;

    console.log("到達しない!");
  } catch (error) {
    console.log(error.toString()); // "Error: エラーです"
  }
})()

returnとエラーハンドリング

どのようにreturnするかは、影響が大きいので、きちんと考えて書きます。
結果としてreturn書かなくても十分な場面は多いですが、returnを考慮した上で、書く、書かないを考えていきたい場所です。

returnの考慮が必須になる場面が3パターンほどあります、注意していきましょう。

  • 1. 非同期の結果を使う場合
  • 2. エラーが発生する場合
  • 3. 複数の非同期がある場合

1. 非同期の結果を使う場合

returnの有無でどうなるか、基本的な挙動なのでとりあえず覚えておきましょう。

returnを省略すると、undefinedになる

// Promise
const pResult = Promise.resolve("結果です");

pResult
  .then(result => {
    console.log(result) // "結果です"
    // returnしない!
  })
  .then(result => {
    // 結果が引き継がれない!
    console.log(result) // undefined
  })

async/awaitの場合、returnしなかったらundefinedになるのは直感的ですね。

// async/await
const pResult = Promise.resolve("結果です");

(async () => {
  async function edit1(){
    const result = await pResult;
    console.log(result) // "結果です"
    // returnしない!
  }

  const edited1 = await edit1();
  console.log(edited1) // undefined
})()

returnは、後続の引数になる

// Promise
const pResult = Promise.resolve("結果です");

pResult
  .then(result => {
    console.log(result) // "結果です"
    return `${result} 2回目`;
  })
  .then(result => {
    console.log(result) // "結果です 2回目"
  })
// async/await
const pResult = Promise.resolve("結果です");

(async () => {
  async function edit1(){
    const result = await pResult;
    console.log(result) // "結果です"
    return `${result} 2回目`;
  }

  const edited1 = await edit1();
  console.log(edited1) // "結果です 2回目"
})()

2. エラーが発生する場合

正常系とほぼ同じ挙動ですが、
1つの非同期に対して、2パターン(正常、エラー)は常に考えていくので、考慮するパターンが純粋に増えていきます。
考慮するパターンが多いと、バグる確率がどんどん上がってきます。

catchでreturnを省略すると、undefinedで復旧する

catch後は正常系に戻りますが、returnを省略すると、後続の引数はundefinedになります。
なにかエラー処理をはさんだ時に、意図しない復旧をしてしまう場合があるので、注意です。

// Promise
const pError = Promise.reject(new Error("エラーです"));

pError
  .catch(error => {
    console.log(error.toString()); // "Error: エラーです"
    // returnなし
  })
  .then(result => {
    console.log(result); // undefined
  })

こちらもasync/awaitであれば、直感的だと思います。
しかし、個人的にtry~catchは、Promiseでのcatchよりも視認性が悪いので好きではないです。

// async/await
const pError = Promise.reject(new Error("エラーです"));

(async () => {
  async function edit1(){
    try {
      return await pError;
    } catch(error){
      console.log(error.toString()); // "Error: エラーです"
      // returnなし
    }
  }

  const edited1 = await edit1();
  console.log(edited1); // undefined
})()

catchで書いたreturnは、復旧時の引数になる

エラー後、正常系に戻しつつ、デフォルトの挙動を指定したい場合などに書きます。

// Promise
const pError = Promise.reject(new Error("エラーです"));

pError
  .catch(error => {
    console.log(error.toString()); // "Error: エラーです"
    return "デフォルト値";
  })
  .then(result => {
    console.log(result); // "デフォルト値"
  });
// async/await
const pError = Promise.reject(new Error("エラーです"));

(async () => {
  async function edit1(){
    try {
      return await pError;
    } catch(error){
      console.log(error.toString()); // "Error: エラーです"
      return "デフォルト値";
    }
  }

  const edited1 = await edit1();
  console.log(edited1); // "デフォルト値"
})()

復習ですが、awaitをreturnし忘れると、正常系がundefinedになるので、バグです。

    try {
      // これでは後続がundefined
      // await pError;

      // return必要
      return await pError;
    } ...

catch時、再throwでエラー継続

エラーハンドリングをした上で、後続の正常系を止めたい場合などは、throwします。

// Promise
const pError = Promise.reject(new Error("エラーです"));

pError
  .catch(error => {
    console.log(error.toString()); // "Error: エラーです"
    throw error;
  })
  .then(result => {
    console.log("到達しない!");
  })
  .catch(error => {
    console.log(`${error.toString()} 2回目`); // "Error: エラーです 2回目"
  })
// async/await
const pError = Promise.reject(new Error("エラーです"));

(async () => {
  async function edit1(){
    try {
      return await pError;
    } catch(error){
      console.log(error.toString()); // "Error: エラーです"
      throw error;
    }
  }

  try {
    const edited1 = await edit1();
    console.log("到達しない!");
  } catch(error){
    console.log(`${error.toString()} 2回目`); // "Error: エラーです 2回目"
  }
})()

3. 複数の非同期がある場合

1つの非同期で、2つ(正常、エラー)考えることがありましたが、
複数の非同期がある場合、全てで正常とエラー考慮が必要になってくるので、かなりつらい気持ちになると思います。
しかし残念ながら、複数の非同期をwaitしながら実行したり、エラーになったら後続を止めたかったりするのは、頻出です。

2回非同期をして、結果を使う

結果をわたすときはreturnします、return漏れるとundefinedです。
実はthenはflatMapになっているので、Promiseをreturnしても、result2でPromiseはネストになりません。

// Promise
const pResult1 = Promise.resolve("結果 1回目");

pResult1
  .then(result1 => {
    // 1回目の結果を使って、2回目の非同期をする
    return Promise.resolve(`${result1} 2回目`);
  })
  .then(result2 => {
    // result2はPromiseではない
    console.log(result2); // "結果 1回目 2回目"
  })
// async/await
const pResult1 = Promise.resolve("結果 1回目");

(async () => {
  async function edit1(){
    const result1 = await pResult1;
    return Promise.resolve(`${result1} 2回目`);
  }
  const result2 = await edit1();
  console.log(result2); // "結果 1回目 2回目"
})()

1つ目の非同期でエラーが起きたら、2つ目の非同期は実行しないが、正常系に戻す

returnさえ忘れなければ、おかしな動作はないです。
もし2つ目のPromiseをreturnし忘れ、エラーが発生した場合、catchに入らなくなるので、結構ハマります。

// Promise
const pResult1 = Promise.reject(new Error("エラーです"));

pResult1
  .then(result1 => {
    return Promise.resolve(`${result1} 2回目`);
  })
  .catch(error => {
    return "デフォルト値";
  })
  .then(result2 => {
    console.log(result2); // "デフォルト値"
  })
// async/await
const pResult1 = Promise.reject(new Error("エラーです"));

(async () => {
  async function edit1(){
    const result1 = await pResult1;
    return Promise.resolve(`${result1} 2回目`);
  }

  let result2;
  try {
    result2 = await edit1();
  } catch(error){
    result2 = "デフォルト値";
  }
  console.log(result2); // "デフォルト値"
})()

余談ですが、async/awaitだからといって、try~catch文を使わないといけないわけでは無いです。
Promiseのcatchを混ぜた方が綺麗です。

// async/await + Promise#catch
const pResult1 = Promise.reject(new Error("エラーです"));

(async () => {
  async function edit1(){
    const result1 = await pResult1;
    return Promise.resolve(`${result1} 2回目`);
  }

  const result2 = await edit1().catch(error => "デフォルト値");
  console.log(result2); // "デフォルト値"
})()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Day23 Javascriptメソッドまとめ

window.alert('')

window.alert('表示したい文');

console.log('')

console.log('こんばんは');

変数の宣言 let, const

let name = 'yamada';
console.log(name + 'さん、こんばんは');

条件分岐 if, else if

let num = 60;

if (num % 15 == 0) {
  console.log(num + 'は3と5の倍数です');
} else if (num % 3 == 0) {
  console.log(num + 'は3の倍数です');
} else if (num % 5 == 0) {
  console.log(num + 'は5の倍数です');
} else {
  console.log(num + 'は3の倍数でも、5の倍数でもありません');
}

=>6035の倍数です

*条件式は()内に記述。実行内容は{}で記述。

javascript配列

1.配列の要素取得let list = []

let list = ['Ruby', 'Ruby on Rails', 'JavaScript', 'HTML', 'CSS'];
console.log(list[2]);

=> javascript 

2.配列の要素数取得length

let list = ['Ruby', 'Ruby on Rails', 'JavaScript', 'HTML', 'CSS'];
console.log(list.length);

=> 5

3.配列の要素追加push

let list = ['Ruby', 'Ruby on Rails', 'JavaScript', 'HTML', 'CSS'];
list.push('GitHub');
console.log(list);

4.配列の要素削除pop shift

let list = ['Ruby', 'Ruby on Rails', 'JavaScript', 'HTML', 'CSS'];
list.pop();
console.log(list);

=>最後の要素の'CSS'が削除
let list = ['Ruby', 'Ruby on Rails', 'JavaScript', 'HTML', 'CSS'];
list.shift();
console.log(list);

=>最初の要素の'Ruby'が削除

*Rubyでは取り除く要素数を指定できますが、JavaScriptではできません。

オブジェクト {プロパティ名: '値'}

let obj = { name: 'yamada' };

nameはプロパティ、yamadaは値

繰り返し for

for (let i = 0; i < 繰り返す回数; i = i + 1) {
  // 繰り返す処理の内容
}

関数の定義 function 関数名(引数)

function sayHello(){
  console.log('hello');
}

function sayName(name){
  console.log(name);
}

let myName = 'yamada';
sayHello();
sayName(myName);

=>hello
  yamada

*引数がない場合も()は記述必要

返り値 return

function calc(num1,num2){
  num1*num2;
}

let num1 = 3;
let num2 = 4;
console.log(calc(num1,num2));

=>出力されない

function calc(num1,num2){
  return num1*num2;
}

let num1 = 3;
let num2 = 4;
console.log(calc(num1,num2));

=> returnで返り値を明治。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Javascriptで作る モーダルウィンドウ

まず、モーダルを作る場合、部品が何個あるのか考えます。
今回のモーダルウィンドウでいうと、
①モーダルを呼び出すボタン
②モーダルが出てきた際の背景が黒い画像
③モーダルウィンドウ

大きく分けて、この3つが必要となる。

①モーダルを呼び出すボタン

<section class="container">
      <p>ボタンを押すとモーダルウィンドウが開きます</p>
       <button type="button" class="btn">開く</button>
    </section>

②モーダルが出てきた際の背景が黒い画像

<div class="mask" id="blackMask"></div>

③モーダルウィンドウ

<section class="modal" id="">
      <div class="top">
        <h1>Vue.js Modal Window!</h1>
        <input type="text">
      </div>
      <div class="under">
       <button type="button" class="btn">送信</button>
      </div>
    </section>

次にCSSで非表示設定、Javascriptで動きを付けていく

①mask と modalを非表示にする(CSS)
1.display: none;
2.transform: translateY(150px,-500px);
※モーダルは上から落ちてくる感じにしたいので、translateY(-500px);で上に置いとく
※SCSSの場合は、CSSではなく、IDの方にクラスを付けるため、{}の外側に書く

  const open = document.getElementById('open');
  const idMask = document.getElementById('idmask');
  const idModal = document.getElementById('idmodal');

  open.addEventListener('click', () => {
    idMask.classList.add('active');
    idModal.classList.add('drop');
  });
  idMask.addEventListener('click', () => {
    idMask.classList.remove('active');
    idModal.classList.remove('drop');
  });

基本的には、要素を取得してきてclickイベントで要素の付け外しを行う

おまけ

.modal {
  background: #ffffff;
  width: 200px;
  border-radius: 4px;
  transform: translate(150px,-500px);
  transition: transform 0.4s;

transition: transform 0.4s;を設定してあげることで、上からゆっくりと
モーダルが落ちてくる。

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

@kintone/rest-api-client への移行を考える

えっ!? kintone JS SDK が・・・!!?

kintone の主戦場である JavaScript/TypeScript 界隈で kintone とのやりとりをカプセル化・抽象化するツールとして欠かせない存在である kintone JS SDK

なんといつの間にか deprecated になっていました。

最終バージョンは 0.7.8 と、結局正式版リリースまで到達せず終了と言う事になりそうです。

旧 JS SDK の代替として 2020 年 2 月に @kintone/rest-api-client 正式版がリリースされていました。
Git リポジトリのパスが妙に深いのが気になるところではありますが、執筆時点(2020 年 5 月下旬)では最新版が 1.3.0 まで上がって来ており、一般的な利用の範囲内では旧 JS SDK を置き換えられるものと判断して良いのではないかと思われます。

kintone 開発キットの数奇な運命

思い起こせば kintone の開発キットは波乱含みであったように思います。
時系列で並べてみると、

時期 出来事
2018 年 5 月 kintone API SDK(β) for Node.js(kintone-nodejs-sdk)リリース
2019 年 3 月 kintone JS SDK リリース
2020 年 2 月 kintone JavaScript Client (@kintone/rest-api-client) リリース

なんと! 初代、2 代目と、共に 1 年未満で次期ツールがリリースされると共に deprecated の憂き目を見ています!
短命で知られるジョースター家の男子だってもう少し長生きするよ!と言う感じです。

@kintone/rest-api-client の特徴

新しい SDK、@kintone/rest-api-client はそれ自体が TypeScript で開発されており、旧 JS SDK が抱えていた(そして kintone extension でも解決できなかった)コード補完効かない問題を解決しています。
旧 JS SDK に d.ts ファイルが結局最後まで用意されなかった事を考えれば隔世の感がありますね。

旧 JS SDK では、確か 0.3 から 0.4 へのバージョンアップ時(0.4 から 0.5 の時だったかも)、関数に引数を渡す方式がそれまでの変数の羅列からオブジェクトを引き渡す形式に変わり数字以上の大きな影響がありましたが、@kintone/rest-api-client は初めからオブジェクトを引き渡す形になっています。
まあ昨今の潮流から言っても自然ですね。

他にも、同じ機能を受け持つ関数の名前が変わったり、戻り値の形が変わっていたりと、基本的には「そりゃこうあるべきだろ」と言うポジティブな方向にブラッシュアップされており、旧 JS SDK が抱えていた不自然さがだいぶ改善されています。

と言う事で、この記事では 3 代目ジョジョ、もとい 3 代目 SDK である @kintone/rest-api-client について、旧 JS SDK からの移行と言う観点で各種レコード操作の実際のコードの違いを含めて見ていきたいと思います。

@kintone/rest-api-client を試す

以下の解説では、他の記事 でも解説している kintone 側で用意している雛形アプリの 1 つ「案件管理」アプリを利用しています。
以下のような下処理をやっています。

  • アプリを作成時、「サンプルデータを含める」で作成
  • 各フィールドのフィールドコードをフィールド名と合わせておく
  • プロジェクトは kintone-cli で作成
  • 作成したプロジェクトを開き@kintone/rest-api-client をインストール

kintone-cli についてもいつか記事を書きたいと思っています。

さて、プロジェクトの準備ができたら早速動作を見てみます。
良く使う関数類を確かめてみましょう。

なお、確認のために使用したコードは GitHub にアップしています。

レコード 1 件取得・レコード複数取得

getRecord() 及び getRecords() に関しては 旧 JS SDK との間でリクエスト・レスポンスの違いはありませんでした。
クエリの指定の仕方も変わりません。

@kintone/rest-api-clientのgetRecords()
const result = await new KintoneRestAPIClient().record.getRecords({
  app: appId,
  fields: ["レコード番号", "確度", "会社名"],
  query: '確度 in ("A") order by レコード番号 asc',
});
{
  "records": [
    // 省略
  ],
  "totalCount": null
}

レコード一括取得

レコード一括取得は関数名が違います。
旧 JS SDK では getAllRecordsByQuery(), getAllRecordsByCursor() と言う関数名でしたが、@kintone/rest-api-client では目的に応じて getAllRecords(), getAllRecordsWithId(), getAllRecordsWithOffset(), getAllRecordsWithCursor() と 4 つに分かれています。
これらの関数は旧 JS SDK とは渡す引数の構成も違って来ており、単純に関数名を一括置換するだけでは移行できないので注意が必要です。

旧JSSDKのgetAllRecordsByQuery()
const result = await new kintoneJSSDK.Record().getAllRecordsByQuery({
  app: appId,
  fields: ["レコード番号", "確度", "会社名"],
  query: '確度 in ("A") order by レコード番号 asc',
});
@kintone/rest-api-clientのgetAllRecords()
const result = await new KintoneRestAPIClient().record.getAllRecords({
  app: appId,
  fields: ["レコード番号", "確度", "会社名"],
  condition: '確度 in ("A")',
  orderBy: "レコード番号 asc",
});

query の部分が condition に変わっており、order byorderBy パラメータに切り出す必要があるのが見て取れますね。

ただ、getAllRecordsWithCursor() 関数は、

@kintone/rest-api-clientのgetAllRecordsWithCursor()
const result = await new KintoneRestAPIClient().record.getAllRecordsWithCursor({
  app: appId,
  fields: ["レコード番号", "確度", "会社名"],
  query: '確度 in ("A") order by レコード番号 asc',
});

と、旧 JS SDK の getAllRecordsByCursor() と同じような書き方が出来ます。

レスポンスに関しては、旧 JS SDK が

旧JSSDKのレスポンス
{
  "records": [
    // 省略
  ],
  "totalCount": 10
}

と言う形で返って来るのに対し、@kintone/rest-api-client では

@kintone/rest-api-clientのレスポンス
[
  // 省略
]

と、ちょうど records[] の中身に相当するものだけが返却されます。

従って、旧 JS SDK で getAllRecordsBy****() を使用していた部分は、それなりに慎重に書き換えが必要になります。

レコード 1 件登録

これは両 SDK とも変わりはありません。
レスポンスの形式も同じです。

レコード複数登録

これも両 SDK とも変わりはありません。
100 件までの同時登録が可能と言う仕様も変わりません。

レスポンスは、@kintone/rest-api-client では以下のように

@kintone/rest-api-clientのレスポンス
{
  ids: [ (省略) ],
  records: [ { id: 'xx', revision: 'xxx' }, ... ],
  revisions: [ (省略) ],
}

と、旧 JS SDK にはなかった records[] が含まれるようになっています。
情報としては重複していますが、使い勝手は良くなっていると言えるでしょう。

レコード一括登録

リクエストの形式は変わらず、100 件以上のレコード登録時に利用すると言う目的も同じです。

一方、レスポンスは大きく異なります。

旧 JS SDK では

旧JSSDKのレスポンス
{
  "results": [
    {
      "ids": [ (省略) ],
      "revisions": [ (省略) ]
    },
    ...(省略)
  ]
}

となっていたのに対し、@kintone/rest-api-client では

@kintone/rest-api-clientのレスポンス
{
  "records": [{ "id": "xx", "revision": "xxx" }, ...(省略)]
}

となっています。
addRecords()records[] のみが取り出された格好です。

addAllRecords() は内部的には bulkRequest API をコールしており、もともとこの API の目的が複数アプリのレコード一括操作であることから、旧 JS SDK では results[] と言う配列に格納されて結果が返される仕様だったわけです。
しかしながら正直なところ SDK の関数を利用する側からすれば無駄に複雑で意味不明に見えるレスポンス形式であったのは否めません。
addAllRecords() 関数は複数アプリに対してどうこうと言う操作ができるわけではないので)
@kintone/rest-api-client では、この不合理を解決し、あるべき形でレスポンスを戻すようになりました。
非常に使いやすくなったと言える一方、旧 JS SDK からの移行に関しては注意を要する箇所です。

レコード 1 件更新

これは両 SDK とも変わりはありません。
レスポンスの形式も同じです。

レコード複数更新

これも同じ。関数名の書き換えだけで OK です。

なお、@kintone/rest-api-client の updateRecord() 等いくつかの関数では、コード補完で表示されるポップアップの内容に誤りが見られます。
resized-mod-01-updateRecords.png
これってどこ由来?と思って GitHub 上の当該ソースnode_modulesd.ts ファイル等を確かめてみたのですが、それらでは本来あるべき値になっている。
どういう理由でここがおかしくなっているのか不明です。
VS Code 側のバグ?筆者の環境だけかしら?

レコード一括更新

上述の レコード一括登録と同様、レスポンスの形が大きく異なります。

旧 JS SDK では

旧JSSDKのレスポンス
{
  "results": [
    {
      "records": [{ "id": "xx", "revision": "xxx" }, ...(省略)]
    },
    ...(省略)
  ]
}

ですが、@kintone/rest-api-client では

@kintone/rest-api-clientのレスポンス
{
  "records": [{ "id": "xx", "revision": "xxx" }, ...(省略)]
}

となります。
旧 JS SDK の results[0] が取り出されて返って来ている格好です。

レコード複数削除

これは両 SDK とも変わりはありません。
レスポンスの形式も同じで、全レコード正常削除できれば {} が返ります。

なお、@kintone/rest-api-client の deleteRecords() や次に触れる deleteAllRecords()updateRecords() 等と同様コード補完に誤りがあるので注意です。

レコード一括削除

レコード一括削除は両 SDK 間でコンセプトが大きく異なっています。
旧 JS SDK では

旧JSSDKのdeleteAllRecordsByQuery()
const result = await new kintoneJSSDK.Record().deleteAllRecordsByQuery({
  app: appId,
  query: '確度 in ("A")',
});

と言った具合に、クエリで削除対象とする条件を指定する事ができました。
一方 @kintone/rest-api-client では

@kintone/rest-api-clientのdeleteAllRecords()
const result = await new KintoneRestAPIClient().record.deleteAllRecords({
  app: appId,
  records: [{ id: "1" }, { id: "2" }, { id: "3" }],
});

と言った形であくまで ID (とリビジョン)で構成されるオブジェクトの配列で対象を指定する形になります。
@kintone/rest-api-client にはクエリで削除対象を抽出して削除、と言う機能を持つ関数は実装されていません。

これは恐らく、削除対象をクエリで指定すると言うのは kintone 側に対象抽出を丸投げしてしまう事になり、しかもレスポンスにはどのレコードを削除したと言う明確な結果が返って来ない点を不合理だと判断したものと思われます。
そりゃ「削除したいなら削除したいものを明示してくれよ」と要求するのは当然ですよね。
で、その @kintone/rest-api-client で指定する records ですが、これは addRecords() 等で返却される records オブジェクトと同じ形式で、その意味でも @kintone/rest-api-client の考え方には一貫性があります。

戻り値も形式が異なります。

旧 JS SDK では

旧JSSDKのレスポンス
{
  "results": [{}]
}

ですが、@kintone/rest-api-client では

@kintone/rest-api-clientのレスポンス
{}

です。

まとめ

各関数について、単純に置き換え可能か、修正が必要かどうかをまとめると以下のようになります。

処理内容 kintone JS SDK @kintone/rest-api-client 移行時のコード修正
レコード 1 件取得 getRecord() getRecord() 不要
レコード複数取得 getRecords() getRecords() 不要
レコード一括取得(クエリ) getAllRecordsByQuery() getAllRecords() かなり修正が必要(渡す引数と戻り値の形式が異なる)
レコード一括取得(カーソル) getAllRecordsByCursor() getAllRecordsWithCursor() 修正が必要(戻り値の形式が異なる)
レコード 1 件登録 addRecord() addRecord() 不要
レコード複数登録 addRecords() addRecords() 不要
レコード一括登録 addAllRecords() addAllRecords() 修正が必要(戻り値の形式が異なる)
レコード 1 件更新 updateRecordById() updateRecord() 不要
レコード複数更新 updateRecords() updateRecords() 不要
レコード一括更新 addAllRecords() addAllRecords() 修正が必要(戻り値の形式が異なる)
レコード複数削除 deleteRecords() deleteRecords() 不要
レコード一括削除 deleteAllRecordsByQuery() deleteAllRecords() かなり修正が必要(関数のコンセプト自体が異なる)

上述の「不要」の部分にしても、

旧JSSDK
const result = await new kintoneJSSDK.Record().getRecords({
  app: appId,
});

@kintone/rest-api-client
const result = await new KintoneRestAPIClient().record.getRecords({
  app: appId,
});

と書き換える程度の修正は当然必要ですのでご注意ください。

感想

と言う事で、新たにリリースされたクライアントツールであるところの @kintone/rest-api-client についてざっと見て来ました。
もちろん他にもカーソルだったりコメントであったりの操作もあるので、これで全部と言うわけではありません。異常系のパターンも見てませんしね。

最初の方でも述べましたが、 @kintone/rest-api-client は現時点で既に旧 JS SDK の大部分の機能をカバーしており、新規プロジェクトにおいては旧 JS SDK ではなくこちらをファーストチョイスとして問題ないクォリティでしょう。
一方、既に旧 JS SDK を導入済みのプロジェクトでは、関数の名前や引数、レスポンスの形式がそれなりに異なることから、安易な移行(置き換え)には慎重さが求められそうです。

初めの方に見た通り、kintone の SDK は 2 代連続で 1 年程度で deprecated の憂き目を見ており、当代であるところの 3 代目が来年の夏を無事に迎えられるかは今は判りません・・・が、さすがにそろそろこれが決定版!と言えるようなものになって欲しいなと思います。

kintone は開発者の環境を良くするために様々なツールをリリースする事に非常に積極的で、そのおかげで 開発者ドキュメント を上から下まで全部読み込まなければカスタマイズなんて碌にできません、みたいな時代は既に過ぎ去ったと言えるでしょう。
ただその分、それぞれ個々のツールは若干機能不足が散見されたり、メンテナンスが止まったり、今回のように短期間で代替ライブラリへの移行が発生したりと、どことなくこれらのプロジェクトに関わる全体としてリソースが不足気味なのかなあと感じさせる場面も。
そんなん言うならお前がコントリビューターとして貢献しろよ。って話かも知れませんがね。

と言うわけで @kintone/rest-api-client のざっくりレビューでした!
どちら様も良き kintone ライフを!

参考

kintone JavaScript Client (@kintone/rest-api-client)

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

Java Scriptの基礎

JavaScriptとは

JavaScriptとは、プログラミング言語の一つで、1990年代中盤に登場しました。
サイトのプルダウンや画面を更新しないでサーバーと通信したい時に使われます。
略称は、JSです。

HTMLへの導入方法

htmlファイルと同じ階層にJSファイルがある場合htmlファイルに以下のように記述するとJSファイルが読み込まれます。

index.html
<head>
  <script src="script.js"></script>
</head>

headタグ内にある、scriptタグsrc属性にファイル名を記述します。JSの拡張子は.jsです。

window.alert()

ブラウザにアラートを表示させるメソッドです。
表示させたい値を引数にとります。また、変数でも可能です。

console.log()

ブラウザのコンソールにテキストを表示させるメソッドです。
表示させたい値を引数にとります。また、変数でも可能です。

変数での記述方法は以下になります。

script.js
var team = "鹿島アントラーズ";
console.log( team + "の勝利です。");

コンソールが面に 鹿島アントラーズの勝利です。 が表示されます。

変数宣言について

varはES6バージョンの書き方です。
それ以降の変数宣言の仕方としては。
・let
・const
の二つが用いられます。

letは、後で書き換えられ変数宣言です。
constは、後で書き換えられない変数宣言です。

条件分岐

script.js
let number = 100;
if (number % 15 == 0) {
console.log(number + "は、3と5の倍数です。");
} else if (number % 3 == 0) {
console.log(number + "は、3の倍数です。");
} else if (number % 5 == 0) {
console.log(number + "は、5の倍数です。");
} else {
console.log(number + "は、3の倍数でも、5の倍数でもありません");
}

上記のように記述するとコンソールに、100は、5の倍数です。 と表示されます。
ifの後のカッコ内に条件式を記述します。その条件に当てはまった場合は、波カッコ無いの記述が処理されます。
最初の条件式が偽だった場合は、else if以降に処理が移ります。その条件が真だったら波カッコ内の処理を開始、偽だった場合はさらに次に進みます。elseはどの条件にも当てはまらなかった場合に出力したい処理を記述します。

配列

JSには配列という概念があります。

script.js
let name = ["takuya", "shingo", "tsuyoshi", "masahiro", "GORO"];
console.log(name);

##コンソールでの表示
(5) ["takuya", "shingo", "tsuyoshi", "masahiro", "GORO"]


---------配列の数を取得---------
lengthメソッドを使います。
------------------------------
console.log(name.length);

##コンソールでの表示
5

---------配列の最後に要素を追加---------
pushメソッドを使います。
-------------------------------------
name.push("morikun");
console.log(name);

///"morikun"が追加されます。

##コンソールでの表示
(6) ["takuya", "shingo", "tsuyoshi", "masahiro", "GORO", "morikun"]

---------配列の最後の要素を削除---------
popメソッドを使います。
-------------------------------------
name.pop();
console.log(name);

///"morikun"が削除されます。

##コンソールでの表示
(5) ["takuya", "shingo", "tsuyoshi", "masahiro", "GORO"]

---------配列の最初の要素を削除---------
shiftメソッドを使います。
-------------------------------------
name.shift();
console.log(name);

///"takuya"が削除されます。

##コンソールでの表示
(4) ["shingo", "tsuyoshi", "masahiro", "GORO"]

popメソッドとshiftメソッドに指定して複数の要素を削除することはできません。

オブジェクト

波カッコを使ってオブジェクトを生成します。
{}内に名前と値をセットにして管理します。このセットのことをプロパティと言います。
最初からオブジェクトにプロパティを定義して生成することもできますし、空の波カッコで生成することもできます。

script.js
let takuya = { name: "kimura", age: 40, team: "SMAP" };
console.log(takuya);

###コンソールでの表示
{name: "kimura", age: 40, team: "SMAP"}

///名前がteamにあたる値を取得
console.log(takuya.team);

###コンソールでの表示
SMAP

///名前がageにあたる値を更新
takuya.age = 47;
console.log(takuya.age);

###コンソールでの表示
47

for文

繰り返しの構文です。

for(let i = 0; i < 繰り返す回数; i += 1) {繰り返す処理}と記述します。

script.js
num = 1;
for (let i = 0; i < 10; i += 1) {
console.log(num + "回目の出力になります!");
num += 1;
}

###コンソールでの表示
1回目の出力です。
2回目の出力です。
3回目の出力です。
4回目の出力です。
5回目の出力です。
6回目の出力です。
7回目の出力です。
8回目の出力です。
9回目の出力です。
10回目の出力です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

mapboxでお手軽に人工衛星の軌道を投影する

人工衛星がどの位置にあるのか、mapbox上にパッと表示する方法です。

地図は地理院タイルを利用します。

衛星の位置はTwo Line Elements(TLE)をcelestrakから入手します。

TLEを緯度経度に変換するアルゴリズムは、sgp4が有名です。
このアルゴリズムは観測から15日後の衛星位置を数10km程度の誤差で予測できるすぐれものです。

自前で作ろうと思いましたが、アルゴリズムの中身を見ると精度が良いだけに面倒だったので、素直に既存のライブラリに頼ることにします。
node-sgp4

またユーザがTLEを入力できるようにしたいので、入力値がTLEをとして適切かバリデーションするのにもライブラリをお借りすることにします。
tle-validator

サンプル

デモ
スクリーンショット 2020-05-24 20.16.01.png

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title>TLE</title>

    <script src='https://api.mapbox.com/mapbox-gl-js/v1.9.1/mapbox-gl.js'></script>
    <link href='https://api.mapbox.com/mapbox-gl-js/v1.9.1/mapbox-gl.css' rel='stylesheet' />
    <style>
        #map {
            height: 480px;
        }

        textarea {
            width: 450px;
            height: 3em;
            line-height: 1.5;
            font-size: 12px;
        }

        #error {
            font-size: 10px;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <div style="margin-top: 20px;">
        <textarea id="tle"></textarea>
    </div>
    <div>
        <button id="add">追加</button>
        <p id="error"></p>
    </div>
</body>
<script src='./dist/tle.js'></script>
</html>
import SGP4 from 'sgp4';
import TLEValidator from 'tle-validator';
const simulateUpdateSecond = 60;
const animationDurationMilliSecond = 50;
const maxSecond = 3600 * 24;

const map = new mapboxgl.Map({
    container: "map",
    center: [139.765, 35.65],
    zoom: 1,
    minZoom: 1,
    maxZoom: 8
});

map.addLayer({
    "id": "base/pale",
    "type": "raster",
    "source": {
        type: "raster",
        tiles: [
            "https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png"
        ],
        attribution: "<a href='http: //maps.gsi.go.jp/development/ichiran.html'>地理院タイル</a>",
        tileSize: 256,
        minzoom: 2,
        maxzoom: 8
    }
});


map.on('load', function () {
    document.getElementById("add").addEventListener('click', function () {
        document.getElementById("error").innerText = "";
        let texts = document.getElementById("tle").value.split(/\n/);

        function setAnimation(line1, line2) {

            const satRec = SGP4.twoline2rv(line1, line2, SGP4.wgs84());
            const now = new Date();
            const id = now.getTime();
            const datetime = now;
            const counterclockwise = getAngularVelocity(datetime).z > 0;
            const meanMotion = satRec.no; //rad/min
            const orbitPeriod = 2 * Math.PI / (meanMotion / 60) //sec
            const limitSecond = Math.min(maxSecond, orbitPeriod)

            function getPosition(datetime) {
                //https://github.com/joshuaferrara/node-sgp4/blob/master/example.js
                const positionAndVelocity = SGP4.propogate(satRec, datetime.getUTCFullYear(), datetime.getUTCMonth() + 1, datetime.getUTCDate(), datetime.getUTCHours(), datetime.getUTCMinutes(), datetime.getUTCSeconds());
                const gmst = SGP4.gstimeFromDate(datetime.getUTCFullYear(), datetime.getUTCMonth() + 1, datetime.getUTCDate(), datetime.getUTCHours(), datetime.getUTCMinutes(), datetime.getUTCSeconds());
                const geodeticCoordinates = SGP4.eciToGeodetic(positionAndVelocity.position, gmst);
                const longitude = SGP4.degreesLong(geodeticCoordinates.longitude);
                const latitude = SGP4.degreesLat(geodeticCoordinates.latitude);
                return [longitude, latitude]
            }

            function getAngularVelocity(datetime) {
                const positionAndVelocity = SGP4.propogate(satRec, datetime.getUTCFullYear(), datetime.getUTCMonth() + 1, datetime.getUTCDate(), datetime.getUTCHours(), datetime.getUTCMinutes(), datetime.getUTCSeconds());
                const position = positionAndVelocity.position;
                const velocity = positionAndVelocity.velocity;
                const posLength = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
                const velLength = Math.sqrt(velocity.x ** 2 + velocity.y ** 2 + velocity.z ** 2);
                return {
                    x: (position.y * velocity.z - position.z * velocity.y) / posLength / velLength,
                    y: (position.z * velocity.x - position.x * velocity.z) / posLength / velLength,
                    z: (position.x * velocity.y - position.y * velocity.x) / posLength / velLength
                }
            }

            map.addLayer({
                'id': 'line_' + id,
                'type': 'line',
                'source': {
                    'type': 'geojson',
                    'data': {
                        'type': 'FeatureCollection',
                        'features': [{
                            'type': 'Feature',
                            'geometry': {
                                'type': 'LineString',
                                'coordinates': [
                                    [0, 0]
                                ]
                            }
                        }]
                    }
                },
                'layout': {
                    'line-cap': 'round',
                    'line-join': 'round'
                },
                'paint': {
                    'line-color': '#ed6498',
                    'line-width': 2,
                    'line-opacity': 0.8
                }
            });
            map.addLayer({
                'id': 'point_' + id,
                'source': {
                    'type': 'geojson',
                    'data': {
                        'type': 'Point',
                        'coordinates': [0, 0]
                    }
                },
                'type': 'circle',
                'paint': {
                    'circle-radius': 10,
                    'circle-color': '#007cbf'
                }
            });

            function animate(timestamp) {
                return function () {
                    let datetime = new Date(timestamp)
                    let crossingAntiMeridianNumber = 0;
                    let elapsedSec = 0;

                    let lines = [
                        []
                    ];
                    let before = getPosition(datetime)
                    while (elapsedSec < limitSecond) {
                        let lonlat = getPosition(datetime);
                        if (counterclockwise) {
                            if (lonlat[0] < 0 && before[0] > 0) {
                                const lat = (lonlat[1] - before[1]) / (360 + lonlat[0] - before[0]) * (180 - before[0]) + before[1];
                                lines[crossingAntiMeridianNumber].push([180, lat])
                                lines[++crossingAntiMeridianNumber] = [
                                    [-180, lat]
                                ]
                            }
                        } else {
                            if (lonlat[0] > 0 && before[0] < 0) {
                                const lat = (lonlat[1] - before[1]) / (-360 + lonlat[0] - before[0]) * (-180 - before[0]) + before[1];
                                lines[crossingAntiMeridianNumber].push([-180, lat])
                                lines[++crossingAntiMeridianNumber] = [
                                    [180, lat]
                                ]
                            }
                        }

                        lines[crossingAntiMeridianNumber].push(lonlat);
                        before = lonlat
                        if (elapsedSec < limitSecond / 2)
                            setTimeout(function () {
                                map.getSource('point_' + id).setData({
                                    'type': 'Point',
                                    'coordinates': lonlat
                                });
                            }, 50 * elapsedSec);

                        datetime.setSeconds(datetime.getSeconds() + simulateUpdateSecond)
                        elapsedSec += simulateUpdateSecond;
                    }

                    function createFeature(line) {
                        return {
                            'type': 'Feature',
                            'geometry': {
                                'type': 'LineString',
                                'coordinates': line
                            }
                        }
                    }
                    let geojson = {
                        'type': 'FeatureCollection',
                        'features': lines.map(function (line) {
                            return createFeature(line)
                        })
                    };
                    map.getSource('line_' + id).setData(geojson);
                    setTimeout(animate(datetime.getTime()), animationDurationMilliSecond * elapsedSec / 2);
                }
            }
            animate(now.getTime())();
        }

        if (texts.length > 1 && TLEValidator.validateTLE(texts[0].trim(), texts[1].trim())) {
            setAnimation(texts[0].trim(), texts[1].trim());
            document.getElementById("tle").value = "";
        } else {
            document.getElementById("error").innerText = "フォーマットが正しくありません。"
        }
    })
});

Javascriptはwebpackでコンパイルしています。コンパイルせず使いたい際はライブラリをダウンロードして適宜読み込ませてください。

ロジック

mapboxのAnimate a lineAnimate a pointをベースに人工衛星特有のロジックを加えています。

function getAngularVelocity(datetime) {
    const positionAndVelocity = SGP4.propogate(satRec, datetime.getUTCFullYear(), datetime.getUTCMonth() + 1, datetime.getUTCDate(), datetime.getUTCHours(), datetime.getUTCMinutes(), datetime.getUTCSeconds());
    const position = positionAndVelocity.position;
    const velocity = positionAndVelocity.velocity;
    const posLength = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
    const velLength = Math.sqrt(velocity.x ** 2 + velocity.y ** 2 + velocity.z ** 2);
    return {
        x: (position.y * velocity.z - position.z * velocity.y) / posLength / velLength,
        y: (position.z * velocity.x - position.x * velocity.z) / posLength / velLength,
        z: (position.x * velocity.y - position.y * velocity.x) / posLength / velLength
    }
}

SGP4.propogateは人工衛星の位置ベクトルと速度ベクトルを返します。
これを使って角速度ベクトルを求め回転方向を判別しています。

const counterclockwise = getAngularVelocity(datetime).z > 0;

経度180度線をまたぐ際はポリゴンを分割しないと表示がおかしなことになってしまうので配列を追加し、線が途切れないように線形補間で180度の点を与えています。

let lonlat = getPosition(datetime);
if (counterclockwise) {
    if (lonlat[0] < 0 && before[0] > 0) {                       
        const lat = (lonlat[1] - before[1]) / (360 + lonlat[0] - before[0]) * (180 - before[0]) + before[1];
        lines[crossingAntiMeridianNumber].push([180, lat])
        lines[++crossingAntiMeridianNumber] = [
            [-180, lat]
        ]
    }
} else {
    if (lonlat[0] > 0 && before[0] < 0) {
        const lat = (lonlat[1] - before[1]) / (-360 + lonlat[0] - before[0]) * (-180 - before[0]) + before[1];
        lines[crossingAntiMeridianNumber].push([-180, lat])
        lines[++crossingAntiMeridianNumber] = [
            [180, lat]
        ]
    }
}

表示はanimationDurationMilliSecond[ms]ごとにsimulateUpdateSecond[s]分進めます。
また軌道の予測線は、軌道maxRev周期またはmaxSecond[s]のより短い分描画します。
予測線は定期的に更新されます(元となるTLEを更新しているわけではないので、当然徐々に誤差が大きくなっていきます)。

const simulateUpdateSecond = 60;
const animationDurationMilliSecond = 50;
const maxSecond = 3600 * 24;
const maxRev = 1;

衛星例

代表的な衛星はこちらにまとめられているので参考にしてください。
国際宇宙ステーションや今流行りのstarlink
連邦破産法11条の適用申請したonewebを見ることができます。
モルニヤ軌道をとるmolniya衛星なんかも面白いです。

この中に無い衛星のTLEはここで検索します。
例えば「みちびき」を調べたいときは「qzs」で検索すると見つかります。



参考
Two-line element set
きどうようそのひみつ
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WebAssemblyをwindows上で使用する

はじめに

Web画面の開発などでは良くJavaScriptが使用されていますが複雑な処理を行うと動作が遅くなる場合があります。
そのため、より早くより効率的に開発をするためにCやC++といったコンパイル可能な言語を使用してWebの開発を可能にしたものがWebアセンブリになります。
Emscriptenを使用してWindowsの開発環境を作る方法は公式に記載されていますが、自分の悩んだところやエラーなどを含めてまとめてみました。

環境

  • windows 10
  • python:3.6.5
  • emcc:1.39.16
  • clang:11.0.0

環境作成

2020/05/24時点で、EmscriptenはGit上からソースを取得してインストールを行う方法しかないようです。
GitのクライアントをWindowsに入れたくないため、GitのWebからソースをダウンロードしてインストールしています。

ソースのダウンロード

Gitのサイトからダウンロードして解凍するか
クローンすることでソースを取得します。
解凍先のパスに日本語が入っていると後々エラーするため日本語のないパスの方が良いです。

インストール

・コマンドプロンプトを起動する
※フォルダの場所によっては管理者として実行が必要

・ソースを配置したフォルダに移動する
自分はC:\webassenbleに配置したので以下のコマンドになります。
コマンド:cd C:\webassenble\emsdk-master

・インストール
配置したソースの中にemsdkがあるはずなのでそれを使用してインストールを行います。
Emscriptenはバージョンの指定をしてインストール更新が可能になっており、今回はlatest(最新)を入れています。
このコマンドを実行した後に大体10分ほど時間がかかります。
コマンド:emsdk install latest

コマンド結果

Installing SDK 'sdk-releases-upstream-ae5001f
~~~~ 略 ~~~~~
Done installing SDK 'sdk-releases-

・設定更新
Emscripten用の設定を行います。この設定はWindows自体には設定されずにユーザのフォルダの直下のファイルに設定されます。
コマンド:emsdk activate latest

コマンド結果

Writing .emscripten configuration file to user home directory
~~~~ 略 ~~~~~
dows Registry, rerun this command with the option --global.

・設定反映
現在開いているコマンドプロンプトに環境設定を読み込みます。
コマンド:emsdk_env.bat

・インストール確認
インストールできたかを確認します。このコマンドでバージョン等が出てくればOKです。
この時にパスなどに日本語が含まれるとエラーします。
コマンド:emcc -v

実行

実行するには実行したいC/C++ファイルを作成して、上でインストールしたEmscripten用のプロンプト上でコンパイルします。

C/C++ファイルの作成

今回は新しい言語に遭遇したときにお決まりとなるhelloworldを表示するプログラムを作成します。

cmain.cpp
#include <stdio.h>

int main() {
  printf("hello, world!\n");
  return 0;
}

Emscripten用のプロンプト起動

ダウンロードしてきたemsdk-masterの下にあるemcmdprompt.batをダブルクリックしてEmscripten用のコマンドプロンプトを起動します。

コンパイル

作成したC++ファイルのフォルダまで移動してemccコマンドでコンパイルを行います。
3分ほどしてコンパイルが成功すると、今いるフォルダにa.out.jsa.out.wasmができています。
コマンド:emcc cmain.cpp
※この時も場所によっては管理者による実行が必要になります。

実行

a.out.jsをnodeで実行するとhello, worldが表示されていることがわかります。

>node a.out.js
hello, world!

おわりに

Webアセンブリについて、公式の手順通りにインストールして実行してみました。
比較的簡単にできましたが、ところどころつまずく点や考える点などがありました。
次回以降はこの環境を使ってもう少し色々なことをしていこうと思います。

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

簡単なローカルサーバーの立て方

Javascriptを使ったホームページ制作などをしている際に、ちょっと動作を確認しようとブラウザでファイルを直接開いた場合(URLがfile:// ~ の場合など)、JSが動作しない時があります。
これは同一性ポリシーと呼ばれる制約を受けるからなのですが、私を含め多くのプログラミング初心者はこの時初めて同一性ポリシーについて知ると思うので、この場面に直面した時の「じゃあ結局どうすれば動作確認できるようになるの?」について紹介します。

あくまで、初心者に対する動作確認をするための紹介であり、同一性ポリシー及びCORSについての説明はほとんどしません。(知りたい人は各自調べてください。)

対象でない人

Apacheとかnginxを自分でセットアップできる人

解決策の手順

解決方法は2つあります。
1. VSCodeの「Live Server」拡張機能を使用する
2. python3を用いてターミナルでpython -m http.serverを使用する
ここで、python3としているのは現在のpythonの標準がpython3だからで特に理由はありません。python2を使っている人は想定していません。

解決策1について

これが最も簡単だと思います。私も普段はVSCodeでプログラムを書くことが多いのでチャチャっと確認したいときはこれを使用する時があります。

手順1(インストール)

VSCodeがインストールされている状態だと仮定します。
(インストールしていない人はこちら(VSCodeの公式サイト)より自身のOSにあったタイプをインストールしてください。)

インストールができたら、VSCodeの拡張機能にある「Live Server」を検索してインストール

起動方法

インストール後、VSCodeを再起動して右下の「Go Live」をクリックすると自動でデフォルトブラウザが起動して表示されます。

スクリーンショット 2020-05-24 21.04.53.png

また、一度閉じてしまっても、http://127.0.0.1:5500/(http://localhost:5500/でも良い)にアクセスすることでも同様に開くことができます。(デフォルトのポートは5500番)

ちなみに、この時最初に表示されるのは、VSCodeで開いているディレクトリです。
index.htmlがあればindex.htmlが、なければディレクトリの中身が表示されます。

解決策2について

Mac, LinuxなどのUnix系OSを使っている人であればpython3は標準でインストールされていると思うのでターミナルで

$ python3 -m http.server

と入力しするとターミナルのカレントディレクトリに対応した場所が開かれローカルサーバーが立ちます。
簡単に説明するとフラグ-mはモジュールの使用を意味するので、httpパッケージのserverモジュールを使用するという意味になります。(間違ってたらごめんなさい。)

もし、Python2系、WindowsOSを使用している場合は、
Homebrewもしくは公式サイト(こちら)よりインストールしてきて上記のコマンドを実行することでローカルサーバーが立ちます。

終わりに

良いローカルサーバーライフを!(笑)

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

jQuery文法チートシート

jQueryとは

JavaScriptをより簡単に書けるように設計されたJavaScriptライブラリのこと
jQuery公式サイト

HTML&CSS操作系

text

テキストの変更と取得


See the Pen
jQuery_text
by engineerhikaru (@engineerhikaru)
on CodePen.


html

HTMLの変更と取得


See the Pen
jQuery_html
by engineerhikaru (@engineerhikaru)
on CodePen.


prepend

要素内の先頭にHTMLを挿入


See the Pen
jQuery_prepend
by engineerhikaru (@engineerhikaru)
on CodePen.


append

要素内の最後にHTMLを挿入


See the Pen
jQuery_append
by engineerhikaru (@engineerhikaru)
on CodePen.


before

要素の前にHTMLを挿入


See the Pen
jQuery_before
by engineerhikaru (@engineerhikaru)
on CodePen.


after

要素の後にHTMLを挿入


See the Pen
jQuery_after
by engineerhikaru (@engineerhikaru)
on CodePen.


prependTo

他の要素内の先頭に要素を移動


See the Pen
jQuery_prependTo
by engineerhikaru (@engineerhikaru)
on CodePen.


appendTo

他の要素内の最後に要素を移動


See the Pen
jQuery_appendTo
by engineerhikaru (@engineerhikaru)
on CodePen.


insertBefore

他の要素の前に要素を移動


See the Pen
jQuery_insertBefore
by engineerhikaru (@engineerhikaru)
on CodePen.


insertAfter

他の要素の後に要素を移動


See the Pen
jQuery_insertAfter
by engineerhikaru (@engineerhikaru)
on CodePen.


wrap

指定した要素を他の要素で入れ子にする


See the Pen
jQuery_wrap
by engineerhikaru (@engineerhikaru)
on CodePen.


wrapAll

指定した要素の要素全体を他の要素で入れ子にする


See the Pen
jQuery_wrapAll
by engineerhikaru (@engineerhikaru)
on CodePen.


wrapInner

指定した要素の子要素・テキストを他の要素で入れ子にする


See the Pen
jQuery_wrapInner
by engineerhikaru (@engineerhikaru)
on CodePen.


unWrap

指定した要素の親要素を除外


See the Pen
jQuery_unwrap
by engineerhikaru (@engineerhikaru)
on CodePen.


replaceWith

指定した要素を他の要素に書き換える


See the Pen
jQuery_replaceWith
by engineerhikaru (@engineerhikaru)
on CodePen.


remove

remove:指定した要素を削除
removeAttr:指定した要素の属性を削除
removeClass:指定した要素のクラスを削除


See the Pen
jQuery_remove
by engineerhikaru (@engineerhikaru)
on CodePen.


attr

指定した属性の値を変更・取得


See the Pen
jQuery_attr
by engineerhikaru (@engineerhikaru)
on CodePen.


addClass

指定した要素にクラスを追加


See the Pen
jQuery_addClass
by engineerhikaru (@engineerhikaru)
on CodePen.


css

指定したCSSプロパティの値を設定・取得


See the Pen
jQuery_css
by engineerhikaru (@engineerhikaru)
on CodePen.


イベント処理系

click

指定した要素のクリック時に処理を実行


See the Pen
jQuery_click
by engineerhikaru (@engineerhikaru)
on CodePen.


dbclick

指定した要素のダブルクリック時に処理を実行


See the Pen
jQuery_dblclick
by engineerhikaru (@engineerhikaru)
on CodePen.


hover

指定した要素のマウスオーバー/アウト時に処理を実行


See the Pen
jQuery_hover
by engineerhikaru (@engineerhikaru)
on CodePen.


mouseover ・ mouseout

mouseover:指定した要素のマウスオーバー時に処理を実行
mouseout:指定した要素のマウスアウト時に処理を実行


See the Pen
jQuery_mouseover
by engineerhikaru (@engineerhikaru)
on CodePen.


mousedown ・ mouseup

mousedown:指定した要素のマウスボタンが押された時に処理を実行
mouseup:指定した要素のマウスボタンが離された時に処理を実行


See the Pen
jQuery_mousedown
by engineerhikaru (@engineerhikaru)
on CodePen.


mousemove

マウスが移動した時に処理を実行


See the Pen
jQuery_mousemove
by engineerhikaru (@engineerhikaru)
on CodePen.


one

イベント発生時に一度だけ処理を実行


See the Pen
jQuery_one
by engineerhikaru (@engineerhikaru)
on CodePen.


on

対象要素を絞ってイベントを登録


See the Pen
jQuery_on
by engineerhikaru (@engineerhikaru)
on CodePen.


off

設定されているイベント処理を取り消し


See the Pen
jQuery_off
by engineerhikaru (@engineerhikaru)
on CodePen.


focus ・ blur

focus:フォームにフォーカスされた時に処理を実行
blur:フォームのフォーカスが外れた時に処理を実行


See the Pen
jQuery_focus
by engineerhikaru (@engineerhikaru)
on CodePen.


change

フォームの入力値が変更された時に処理を実行


See the Pen
jQuery_change
by engineerhikaru (@engineerhikaru)
on CodePen.


submit

フォームの送信ボタンがクリックされた時に処理を実行


See the Pen
jQuery_submit
by engineerhikaru (@engineerhikaru)
on CodePen.


アニメーション系

show ・ hide

show:要素を徐々に表示
hide:要素を徐々に非表示


See the Pen
jQuery_show
by engineerhikaru (@engineerhikaru)
on CodePen.


toggle

要素を徐々に表示・非表示


See the Pen
jQuery_toggle
by engineerhikaru (@engineerhikaru)
on CodePen.


slideDown ・ slideUp

slideDown:要素をスライドアニメーションで表示
slideUp:要素をスライドアニメーションで非表示


See the Pen
jQuery_slideDown-slideUp
by engineerhikaru (@engineerhikaru)
on CodePen.


slideToggle

要素をスライドアニメーションで表示・非表示


See the Pen
jQuery_slideToggle
by engineerhikaru (@engineerhikaru)
on CodePen.


fadeIn ・ fadeOut

fadeIn:要素をフェードインで表示
fadeOut:要素をフェードアウトで非表示


See the Pen
jQuery_fadeIn-fadeOut
by engineerhikaru (@engineerhikaru)
on CodePen.


fadeTo

要素の透明度を指定した値に徐々に変更


See the Pen
jQuery_fadeTo
by engineerhikaru (@engineerhikaru)
on CodePen.


animate

任意のCSSプロパティの値を徐々に変更


See the Pen
jQuery_animate
by engineerhikaru (@engineerhikaru)
on CodePen.


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

文章中の誤字を発見するwebアプリを作った

文章中の誤字を見つけ出して修正するためのwebアプリを作った。
Screenshot from 2020-05-24 20-25-48.png

できたもの

https://intriguing-soda.glitch.me/

使ったもの

気になるところ

  • 正しい箇所が修正候補として指摘されてしまう場合がある(特に間違っている箇所の隣の文字)
  • ejsの使用について
server.js
//これがあれば
app.set("view engine", "ejs");
//こっちはいらない?
var ejs = require("ejs");
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

LinkeInのつながり申請をJavaScriptで自動化&思わぬ落とし穴

こんにちは、大学生としてプログラミングで遊んでいるKantaです!
今回はLinkedInのつながり申請のJavaScriptでの自動化について記事を書きます。

なぜLinkedIn?つながりを増やす意味は?

エンジニアのみなさんはLinkedInを有効活用していらっしゃいますよね。
業界のトップに位置する人の経験や経歴、スキルを分析することで、自分がどのような方向に進んでいくべきか、何を身に付けておくべきかを考えるきっかけになります。
また、業界のポジションの種類などの知識も自然と学ぶことができます。意外とつながり申請を受け入れてくれる人は多いです。
つながりの質も長期的には重要ですが、つながりの数はある程度多ければ多いほど、リクルーターからのメッセージも増え、大学生の私でも在宅ワークやインターンの募集を受け取ることは多いです。意外な出会いをもたらしてくれるSNS、それがLinkedInです。

繋がりを増やすために、知人に申請したり、憧れの企業にお勤めの方々に申請などを手動でポチポチするのは手間です。
その時間があれば、スクワットが何回できるでしょうか?魚を何匹捌けるでしょうか?

そんなことを考えていると、「リンクトインの繋がり申請を自動化できないか?」
という考えが浮かんでみました。それが今回の記事を書くきっかけです。

「つながり申請の自動化」具体的な操作に分解してみる

Chromeの拡張機能Tampermonkeyを用いてブラウザ上でJavaScriptを実行していきます。
具体的は操作は以下です:

  1. ユーザーの検索結果の画面の繋がり申請ボタンをクリックする。
  2. 確認ウィンドウがでてきたら、送信ボタンをクリックする。
  3. もし申請にメールアドレス認証などが必要であればキャンセルボタンを押す。
  4. ページのどの部分のユーザーに申請したかを確認する。
  5. もしページの最後であれば、次のページに向かう。

この1-5の流れを、繋がりボタンが存在する限り、ランダム間隔をおいてループで実行する。

大まかな自動化の流れ

さて、目的を定めたら次はChromeの開発者ツールでサイトの調査をします。
右クリックで出てくるメニューから「検証」ボタンをクリックします。

スクリーンショット 2020-05-24 18.26.17.png

これがみなさん大好き、Chrome開発者ツールですね。
スクリーンショット 2020-05-24 18.29.53.png
Chromeの開発者ツールで画像のような四角形にカーソルが載ったアイコンをクリックすると、特定の要素のコードなどが見れます。
スクリーンショット 2020-05-24 18.32.09.png
このアイコンをクリックした後に、ページ上の部分をクリックすると、その部分のHTML上での場所など、様々な情報が見れます。

要素の特定

さて、JavaScriptでDOMに変更を加えるには、目的の要素を一意に特定する必要があります。
目的の要素を一意に特定する手段としてid,class,属性があります。

下の画像のように、選択した要素のJS Pathをコピーして利用するという手もありますが、「あるタグの何番目の子要素」みたいな特定方法なので汎用性に欠けます。また、HTMLの構造の変更に影響を受けやすいです。特に自動生成されているサイトや、変更が著しいサイトではお勧めできません。
スクリーンショット 2020-05-24 18.33.51.png

よって、一貫した要素の特定をするためには、id, class, タグの属性,クエリセレクタなどを用いるのが最適です。
WEBサイトの作成者も、CSSなどで装飾する際に、分かりやすい命名法をしてくれます。
そうした命名から、要素の機能を推定できます。

例えば、LinkedInのつながり申請ボタンの部分のHTML要素を覗いてみると次のようになっています。
(注意:一部省略してます。)

<button data-control-name="srp_profile_actions">省略..</button>

属性がこのように与えられていると、サイトの設計者だけでなく、自動化ツールを作る我々も恩恵を受けることができます。
このようにして、classや属性のなかで、機能が推定しやすく変更の影響を受けにくいものを選んで、クエリセレクタでHTMLcollextionを取得します。
これはHTMLの要素の参照からなる配列のようなものです。

特定した要素をJavaScriptで操作する(DOM操作)

要素が特定できたら、JavaScriptのおなじみのDOM操作をしていきましょう。
上の例のボタンを操作するためには、まずこうします。

var sendInvitationElements=document.querySelectorAll('button[data-control-name="srp_profile_actions"]');

こうして各要素に対しておなじみのdocumentオブジェクトのメソッドやプロパティがつかえます。
例えば、上で取得した要素たちのうち、i番目の要素をクリックしたことにするには、

sendInvitationElements[i].click();

のようなコードを書けばいいですね。このような感じでHTMLの一部の中身を取得したり、クリックしたり、ループしたり、条件分岐の処理を書いていけば案外すぐに自動化は可能です。
このようにして、自動化のスクリプトを書いていきましょう。

完成したもの "LinkedInAutoConnector"

コード:
https://github.com/kantasv/LinkedInAutoConnector/blob/master/linkedInAutoConnector.js

まず、拡張機能を有効にした後、キーワードで検索し、その後"People"の欄をクリックすると繋がり申請が自動で始まります。
スクリーンショット 2020-05-24 19.50.01.png
一応、「もう十分」となったときのために、画面左下にボタンを用意しています。
これをクリックすることで一時的につながり申請が止まります。
スクリーンショット 2020-05-24 19.51.43.png
これで、つながりを増やしまくるぜ!と怒涛のつながり申請をしまくると意気込んでいた筆者ですが、世の中はそんなに甘くありませんでした。
正確には覚えていませんが、二百人くらいはつながりが増えたところで、思わぬ落とし穴にはまりました。

落とし穴:LinkedInで自動化ツールの利用は利用規約違反

アカウント一時停止

ある朝、LinkedInにアクセスすると、ログインができません。
眠い目をこすりながら、メッセージを見ると、アカウントが一時的に停止されたとのメッセージに気付きました。
めちゃめちゃ朝から焦りました。
本人確認が必要とのことで、カスタマーサポートとやりとりを行いました。

LinkedInの利用規約には・・・

アカウントが停止された背景について詳しく調べてみると・・・
次のページに該当するように、LinkedInでの各種自動化ツールの利用は利用規約違反だったのです。
https://www.linkedin.com/help/linkedin/answer/60453/-?lang=ja

"ボットやその他の自動化された方法を使用して、弊社のサービスしたり、連絡先を追加/ダウンロードしたり、メッセージを送信またはリダイレクトすること"
この部分が該当していました。

本人確認によりアカウント復活

自分のパスポートの画像を送信することで、アカウントは復活しました。
スクリーンショット 2020-05-24 20.02.43.png
このような出来事は人生で初めてだったので、正直焦りました。。。

まとめ

自動化ツールは便利なので、自粛期間中にみなさんも量産してみてはいかがでしょうか。
ただ、そうしたツールの利用を許可しないWEBサイトも少なからず存在します。
また、こうしたツールは一歩間違えればDDoS Attackのようにサーバーに負荷をかけてしまうことも考えれます。
たとえ自動操作の間隔をランダムにしたり、回りくどい回避策をとったとして、いつかは対策されますし、ばれます。
節度をもって、自動化で毎日をより充実させることができるといいですね。
それでは、ここまで読んでいただきありがとうございました。

コメント欄に、あなたが最近した自動化、これからしたい自動化、あったらいいなこんな自動化など書き込んでください!
楽しみにしています!

どのようにしたら自動化できるかということに「純粋な知的好奇心」があるみなさんに最後にソースコードをここにも残しておきます。
GitHubにも挙げてます。
https://github.com/kantasv/LinkedInAutoConnector/blob/master/linkedInAutoConnector.js

| (function () { |
|:--|
|     'use strict'; |
|  |
|     var pageIndex; |
|     var setIntervalId; |
|  |
|     var isCuurentPageSearchResultPage = () => { |
|         return window.location.href.toLocaleLowerCase().indexOf('search/results/people/') != 1 |
|     } |
|  |
|  |
|     //detects page result index when current page is clearly the people search result page |
|     if (isCuurentPageSearchResultPage()) { |
|         window.location.href.split('&').forEach(elm => { |
|             if (elm.indexOf('page=') != -1) { |
|                 var start = 'page='.length |
|                 pageIndex = parseInt(elm.slice(start)) |
|                 console.log('pageIndex', pageIndex) |
|             } |
|         }) |
|         //when could not "page=" but clearly the current page is clearly the people search result page |
|         if (!pageIndex) { |
|             window.location.href += '&page=1' |
|         } |
|  |
|     } |
|  |
|     var initAutoConnector = () => { |
|         var connectButtonCandidates = document.querySelectorAll('button[data-control-name="srp_profile_actions"]'); |
|         var connectButtons = []; |
|  |
|         //filter buttons: get only connect buttons |
|         for (var i = 0; i < connectButtonCandidates.length; i++) { |
|             //console.log('Button condition', connectButtonCandidates[i].innerText) |
|             if (connectButtonCandidates[i].innerText == 'Connect') { connectButtons.push(connectButtonCandidates[i]) }; |
|         } |
|  |
|  |
|         //gets random integers ranged from 0 to 300 |
|         //by changing intervals, LinkedIn is not likely to detect this sort of automation |
|         var getRandomInteger = () => { |
|             var min = 0; |
|             var max = 300; |
|             return Math.floor(Math.random() * (max + 1 - min)) + min; |
|         } |
|  |
|         var connectButtonCount = 0 |
|  |
|         var connectButtonOperation = () => { |
|  |
|             if (connectButtonCount < connectButtons.length) { |
|  |
|                 //clicks "Connect button" |
|                 connectButtons[connectButtonCount].click() |
|                 //clicks "Send Invitation" button |
|                 setTimeout(() => { |
|                     //clicks only when "Send Invitation" exists |
|                     var sendInvitationElement = document.querySelector('button[aria-label="Send invitation"]') |
|                     var verificationOperationDismissElement = document.querySelector('button[aria-label="Dismiss"]') |
|                     if (sendInvitationElement) { |
|                         console.log('sendInvitationElement exists', sendInvitationElement) |
|                         sendInvitationElement.click() |
|                     } else if (verificationOperationDismissElement) { |
|                         console.log(verificationOperationDismissElement) |
|                         //cancels when you face "verify" dialog |
|                         verificationOperationDismissElement.click() |
|                     } else { |
|                         console.log('Unexpected error. Could not find button elements for operations.') |
|                     } |
|                     connectButtonCount++ |
|                 }, getRandomInteger()) |
|                  |
|  |
|             } else { |
|                 console.log('already clicked all connect buttons') |
|                 clearInterval(setIntervalId) |
|                 //when no connect buttons available |
|                 //then move to next result page |
|                 var currentUrl = window.location.href |
|                 console.log(`page=${pageIndex}`, '->', `page=${pageIndex + 1}`) |
|                 var nextPageUrl = currentUrl.replace(`page=${pageIndex}`, `page=${pageIndex + 1}`) |
|                 //console.log(currentUrl.slice(50),nextPageUrl.slice(50)) |
|                 setTimeout(() => { window.location.href = nextPageUrl }, 1000) |
|  |
|             } |
|  |
|         } |
|  |
|         setIntervalId = setInterval(connectButtonOperation, 1000 + getRandomInteger()) |
|  |
|     } |
|  |
|  |
|     //checks if the current page is the search result page |
|     if (isCuurentPageSearchResultPage()) { |
|  |
|         console.log('Found "Connect" buttons. Startsing automatically conectiong...') |
|         window.onload = () => { initAutoConnector() } |
|  |
|     } else { |
|         console.log('Could not find "Connect" buttons. This page may not be search result page.') |
|     } |
|  |
|  |
|     //adds Autoconnector bar element to DOM |
|     var initACBar = () => { |
|  |
|         var autoconnectStopButton = document.createElement('div'); |
|         autoconnectStopButton.innerHTML = `<span id='ACstatus'>Sending invitations automatically...</span><p id='ACstopButton'>Click here to stop LinkedIn Autoconector temporalily</p> |
|         <h6>LinkedIn Autoconnector - Powered by Kanta Yamaoka.</h6>` |
|  |
|  |
|         var css = (prop, value) => { |
|             autoconnectStopButton.style[prop] = value |
|         } |
|  |
|         css('width', '30%') |
|         css('height', '100px') |
|         css('backgroundColor', 'white') |
|         css('color', '#0178B5') |
|         css('border', '2px solid #0178B5') |
|         css('borderRadius', '10px') |
|         css('textAlign', 'center') |
|         css('textHeight', '10px') |
|         css('position', 'fixed') |
|         css('bottom', '10%') |
|         css('left', '10%') |
|         css('zIndex', '10000') |
|  |
|  |
|  |
|         document.body.appendChild(autoconnectStopButton) |
|         document.getElementById('ACstopButton').style.margin = '10px' |
|  |
|         autoconnectStopButton.onclick = () => { |
|             clearInterval(setIntervalId) |
|             css('backgroundColor', '#c8c8c8') |
|             document.getElementById('ACstatus').innerText = 'Autoconnector temporalily disabled.' |
|             document.getElementById('ACstopButton').innerText = 'To use Autoconecttor again, please refresh the page.' |
|  |
|         } |
|  |
|  |
|     } |
|  |
|     initACBar() |
|  |
| })(); |

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

LinkedInのつながり申請をJavaScriptで自動化&思わぬ落とし穴

こんにちは、大学生としてプログラミングで遊んでいるKantaです!
今回はLinkedInのつながり申請のJavaScriptでの自動化について記事を書きます。

なぜLinkedIn?つながりを増やす意味は?

エンジニアのみなさんはLinkedInを有効活用していらっしゃいますよね。
業界のトップに位置する人の経験や経歴、スキルを分析することで、自分がどのような方向に進んでいくべきか、何を身に付けておくべきかを考えるきっかけになります。
また、業界のポジションの種類などの知識も自然と学ぶことができます。意外とつながり申請を受け入れてくれる人は多いです。
つながりの質も長期的には重要ですが、つながりの数はある程度多ければ多いほど、リクルーターからのメッセージも増え、大学生の私でも在宅ワークやインターンの募集を受け取ることは多いです。意外な出会いをもたらしてくれるSNS、それがLinkedInです。

ただ、繋がりを増やすために、知人に申請したり、憧れの企業にお勤めの方々に申請などを手動でポチポチするのは手間です。
また、日本の大学生の知人はそもそもそんなにLinkedInやってません・・。
その時間があれば、スクワットが何回できるでしょうか?魚を何匹捌けるでしょうか?

そんなことを考えていると、「リンクトインの繋がり申請をターゲットを定めて自動化できないか?」
という考えが浮かんでみました。それが今回の記事を書くきっかけです。

「つながり申請の自動化」具体的な操作に分解してみる

Chromeの拡張機能Tampermonkeyを用いてブラウザ上でJavaScriptを実行していきます。
具体的は操作は以下です:

  1. ユーザーの検索結果の画面の繋がり申請ボタンをクリックする。
  2. 確認ウィンドウがでてきたら、送信ボタンをクリックする。
  3. もし申請にメールアドレス認証などが必要であればキャンセルボタンを押す。
  4. ページのどの部分のユーザーに申請したかを確認する。
  5. もしページの最後であれば、次のページに向かう。

この1-5の流れを、繋がりボタンが存在する限り、ランダム間隔をおいてループで実行する。

大まかな自動化の流れ

さて、目的を定めたら次はChromeの開発者ツールでサイトの調査をします。
右クリックで出てくるメニューから「検証」ボタンをクリックします。

スクリーンショット 2020-05-24 18.26.17.png

これがみなさん大好き、Chrome開発者ツールですね。
スクリーンショット 2020-05-24 18.29.53.png
Chromeの開発者ツールで画像のような四角形にカーソルが載ったアイコンをクリックすると、特定の要素のコードなどが見れます。
スクリーンショット 2020-05-24 18.32.09.png
このアイコンをクリックした後に、ページ上の部分をクリックすると、その部分のHTML上での場所など、様々な情報が見れます。

要素の特定

さて、JavaScriptでDOMに変更を加えるには、目的の要素を一意に特定する必要があります。
目的の要素を一意に特定する手段としてid,class,属性があります。

下の画像のように、選択した要素のJS Pathをコピーして利用するという手もありますが、「あるタグの何番目の子要素」みたいな特定方法なので汎用性に欠けます。また、HTMLの構造の変更に影響を受けやすいです。特に自動生成されているサイトや、変更が著しいサイトではお勧めできません。
スクリーンショット 2020-05-24 18.33.51.png

よって、一貫した要素の特定をするためには、id, class, タグの属性,クエリセレクタなどを用いるのが最適です。
WEBサイトの作成者も、CSSなどで装飾する際に、分かりやすい命名法をしてくれます。
そうした命名から、要素の機能を推定できます。

例えば、LinkedInのつながり申請ボタンの部分のHTML要素を覗いてみると次のようになっています。
(注意:一部省略してます。)

<button data-control-name="srp_profile_actions">省略..</button>

属性がこのように与えられていると、サイトの設計者だけでなく、自動化ツールを作る我々も恩恵を受けることができます。
このようにして、classや属性のなかで、機能が推定しやすく変更の影響を受けにくいものを選んで、クエリセレクタでHTMLcollextionを取得します。
これはHTMLの要素の参照からなる配列のようなものです。

特定した要素をJavaScriptで操作する(DOM操作)

要素が特定できたら、JavaScriptのおなじみのDOM操作をしていきましょう。
上の例のボタンを操作するためには、まずこうします。

var sendInvitationElements=document.querySelectorAll('button[data-control-name="srp_profile_actions"]');

こうして各要素に対しておなじみのdocumentオブジェクトのメソッドやプロパティがつかえます。
例えば、上で取得した要素たちのうち、i番目の要素をクリックしたことにするには、

sendInvitationElements[i].click();

のようなコードを書けばいいですね。このような感じでHTMLの一部の中身を取得したり、クリックしたり、ループしたり、条件分岐の処理を書いていけば案外すぐに自動化は可能です。
このようにして、自動化のスクリプトを書いていきましょう。

完成したもの "LinkedInAutoConnector"

コード:
https://github.com/kantasv/LinkedInAutoConnector/blob/master/linkedInAutoConnector.js

これをChrome拡張機能のTampermonkeyに入れれば、"LinkedInAutoConnector"が実行できます。

まず、拡張機能を有効にした後、キーワードで検索し、その後"People"の欄をクリックすると繋がり申請が自動で始まります。
スクリーンショット 2020-05-24 19.50.01.png
一応、「もう十分」となったときのために、画面左下にボタンを用意しています。
これをクリックすることで一時的につながり申請が止まります。
スクリーンショット 2020-05-24 19.51.43.png
これで、つながりを増やしまくるぜ!と怒涛のつながり申請をしまくると意気込んでいた筆者ですが、世の中はそんなに甘くありませんでした。
正確には覚えていませんが、二百人くらいはつながりが増えたところで、思わぬ落とし穴にはまりました。

落とし穴:LinkedInで自動化ツールの利用は利用規約違反

アカウント一時停止

ある朝、LinkedInにアクセスすると、ログインができません。
眠い目をこすりながら、メッセージを見ると、アカウントが一時的に停止されたとのメッセージに気付きました。
めちゃめちゃ朝から焦りました。
本人確認が必要とのことで、カスタマーサポートとやりとりを行いました。

LinkedInの利用規約には・・・

アカウントが停止された背景について詳しく調べてみると・・・
次のページに該当するように、LinkedInでの各種自動化ツールの利用は利用規約違反だったのです。
https://www.linkedin.com/help/linkedin/answer/60453/-?lang=ja

"ボットやその他の自動化された方法を使用して、弊社のサービスしたり、連絡先を追加/ダウンロードしたり、メッセージを送信またはリダイレクトすること"
この部分が該当していました。

本人確認によりアカウント復活

自分のパスポートの画像を送信することで、アカウントは復活しました。
スクリーンショット 2020-05-24 20.02.43.png
このような出来事は人生で初めてだったので、正直焦りました。。。

まとめ

自動化ツールは便利なので、自粛期間中にみなさんも量産してみてはいかがでしょうか。
ただ、そうしたツールの利用を許可しないWEBサイトも少なからず存在します。
また、こうしたツールは一歩間違えればDDoS Attackのようにサーバーに負荷をかけてしまうことも考えれます。
たとえ自動操作の間隔をランダムにしたり、回りくどい回避策をとったとして、いつかは対策されますし、ばれます。
節度をもって、自動化で毎日をより充実させることができるといいですね。
それでは、ここまで読んでいただきありがとうございました。

コメント欄に、あなたが最近した自動化、これからしたい自動化、あったらいいなこんな自動化など書き込んでください!
楽しみにしています!

どのようにしたら自動化できるかということに「純粋な知的好奇心」があるみなさんに最後にソースコードをここにも残しておきます。
GitHubにも挙げてます。
https://github.com/kantasv/LinkedInAutoConnector/blob/master/linkedInAutoConnector.js

| (function () { |
|:--|
|     'use strict'; |
|  |
|     var pageIndex; |
|     var setIntervalId; |
|  |
|     var isCuurentPageSearchResultPage = () => { |
|         return window.location.href.toLocaleLowerCase().indexOf('search/results/people/') != 1 |
|     } |
|  |
|  |
|     //detects page result index when current page is clearly the people search result page |
|     if (isCuurentPageSearchResultPage()) { |
|         window.location.href.split('&').forEach(elm => { |
|             if (elm.indexOf('page=') != -1) { |
|                 var start = 'page='.length |
|                 pageIndex = parseInt(elm.slice(start)) |
|                 console.log('pageIndex', pageIndex) |
|             } |
|         }) |
|         //when could not "page=" but clearly the current page is clearly the people search result page |
|         if (!pageIndex) { |
|             window.location.href += '&page=1' |
|         } |
|  |
|     } |
|  |
|     var initAutoConnector = () => { |
|         var connectButtonCandidates = document.querySelectorAll('button[data-control-name="srp_profile_actions"]'); |
|         var connectButtons = []; |
|  |
|         //filter buttons: get only connect buttons |
|         for (var i = 0; i < connectButtonCandidates.length; i++) { |
|             //console.log('Button condition', connectButtonCandidates[i].innerText) |
|             if (connectButtonCandidates[i].innerText == 'Connect') { connectButtons.push(connectButtonCandidates[i]) }; |
|         } |
|  |
|  |
|         //gets random integers ranged from 0 to 300 |
|         //by changing intervals, LinkedIn is not likely to detect this sort of automation |
|         var getRandomInteger = () => { |
|             var min = 0; |
|             var max = 300; |
|             return Math.floor(Math.random() * (max + 1 - min)) + min; |
|         } |
|  |
|         var connectButtonCount = 0 |
|  |
|         var connectButtonOperation = () => { |
|  |
|             if (connectButtonCount < connectButtons.length) { |
|  |
|                 //clicks "Connect button" |
|                 connectButtons[connectButtonCount].click() |
|                 //clicks "Send Invitation" button |
|                 setTimeout(() => { |
|                     //clicks only when "Send Invitation" exists |
|                     var sendInvitationElement = document.querySelector('button[aria-label="Send invitation"]') |
|                     var verificationOperationDismissElement = document.querySelector('button[aria-label="Dismiss"]') |
|                     if (sendInvitationElement) { |
|                         console.log('sendInvitationElement exists', sendInvitationElement) |
|                         sendInvitationElement.click() |
|                     } else if (verificationOperationDismissElement) { |
|                         console.log(verificationOperationDismissElement) |
|                         //cancels when you face "verify" dialog |
|                         verificationOperationDismissElement.click() |
|                     } else { |
|                         console.log('Unexpected error. Could not find button elements for operations.') |
|                     } |
|                     connectButtonCount++ |
|                 }, getRandomInteger()) |
|                  |
|  |
|             } else { |
|                 console.log('already clicked all connect buttons') |
|                 clearInterval(setIntervalId) |
|                 //when no connect buttons available |
|                 //then move to next result page |
|                 var currentUrl = window.location.href |
|                 console.log(`page=${pageIndex}`, '->', `page=${pageIndex + 1}`) |
|                 var nextPageUrl = currentUrl.replace(`page=${pageIndex}`, `page=${pageIndex + 1}`) |
|                 //console.log(currentUrl.slice(50),nextPageUrl.slice(50)) |
|                 setTimeout(() => { window.location.href = nextPageUrl }, 1000) |
|  |
|             } |
|  |
|         } |
|  |
|         setIntervalId = setInterval(connectButtonOperation, 1000 + getRandomInteger()) |
|  |
|     } |
|  |
|  |
|     //checks if the current page is the search result page |
|     if (isCuurentPageSearchResultPage()) { |
|  |
|         console.log('Found "Connect" buttons. Startsing automatically conectiong...') |
|         window.onload = () => { initAutoConnector() } |
|  |
|     } else { |
|         console.log('Could not find "Connect" buttons. This page may not be search result page.') |
|     } |
|  |
|  |
|     //adds Autoconnector bar element to DOM |
|     var initACBar = () => { |
|  |
|         var autoconnectStopButton = document.createElement('div'); |
|         autoconnectStopButton.innerHTML = `<span id='ACstatus'>Sending invitations automatically...</span><p id='ACstopButton'>Click here to stop LinkedIn Autoconector temporalily</p> |
|         <h6>LinkedIn Autoconnector - Powered by Kanta Yamaoka.</h6>` |
|  |
|  |
|         var css = (prop, value) => { |
|             autoconnectStopButton.style[prop] = value |
|         } |
|  |
|         css('width', '30%') |
|         css('height', '100px') |
|         css('backgroundColor', 'white') |
|         css('color', '#0178B5') |
|         css('border', '2px solid #0178B5') |
|         css('borderRadius', '10px') |
|         css('textAlign', 'center') |
|         css('textHeight', '10px') |
|         css('position', 'fixed') |
|         css('bottom', '10%') |
|         css('left', '10%') |
|         css('zIndex', '10000') |
|  |
|  |
|  |
|         document.body.appendChild(autoconnectStopButton) |
|         document.getElementById('ACstopButton').style.margin = '10px' |
|  |
|         autoconnectStopButton.onclick = () => { |
|             clearInterval(setIntervalId) |
|             css('backgroundColor', '#c8c8c8') |
|             document.getElementById('ACstatus').innerText = 'Autoconnector temporalily disabled.' |
|             document.getElementById('ACstopButton').innerText = 'To use Autoconecttor again, please refresh the page.' |
|  |
|         } |
|  |
|  |
|     } |
|  |
|     initACBar() |
|  |
| })(); |

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

noteのタイトルとテキストをコピーするブックマークレット

noteにエキスポート機能がなかったので、
簡易的にタイトルとテキストをコピーするブックマークレットを作りました。

javascript:(function(){var elements = document.getElementsByClassName("o-noteContentText");var text = elements[0];var range = document.createRange();range.selectNodeContents(text);window.getSelection().addRange(range);document.execCommand("copy");})();

↑のコードをコピーして、
適当なページをブックマークに追加→編集で、「URL」に貼り付けて下さい。「名前」は任意で。


ブックマークレット.png

補足:コードの解説

noteのタイトルと文章部分がo-noteContentTextというclass名で囲われていたので、
そのノードを取得、選択してコピーするという単純な構造です。

なので、class名が変わるような更新があると使えなくなります><

javascript:(
 function(){
  var elements = document.getElementsByClassName("o-noteContentText");
  var text = elements[0];

  var range = document.createRange();
  range.selectNodeContents(text);
  window.getSelection().addRange(range);

  document.execCommand("copy");
 }
)();

理想をいえば、複数ページを一気にコピーできるとこまで、できれば良かったのですが、
すみません、力不足でした…

お手数ですが、作成したページを一つずつ開いて、お使いくださいm(_ _)m

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

express-generatorで開発の雛形をサクッと作ってみたときのメモ

きょうぷろぐらみんぐをはじめたました!(大嘘)

webpackあたりまだ良くわかっていない人です。

今回、express-generarotを使って環境?を作ったときに知ったこととかをメモしていく。
メモだよ。
必要があればコメントでメモ修正していただけるとありがたき幸せ。

express-generatorの巻

expressで環境を構築するときにexpress-generatorというものを使うと構造をぱぱっと作ってくれる。
そのために

npm install express-generator -g

をする。-gでいれるよ。グローバルインストールするのが重要。たぶん。

あとは

express hoge

でサクッと構造を作る。

色々オプションつけられるよ。

express --view pug --css sass hoge

みたな。
--view pugとかはexpress -hに書いてあるから見てね。

(ちょっと脱線)package.jsonの巻

これまでテキトーに
npm installみたいなのをしていたが、ESlintとかそういうのを導入しているときにnpm install -Dみたいなことを書いているサイトがあったから気になって調べてみた。

そしたら、実は開発用のパッケージとアプリの動作用のパッケージで記述される場所が違ったらしい。ほぇ〜。

下のを読んだ。
参考:https://qiita.com/chihiro/items/ca1529f9b3d016af53ec

アプリの動作に必要なものはdependencies

npm install --save hoge

開発に必要なものはdevDependencies

npm install --save-dev hoge

としていれるんだと。

で実際にpackage.jsonを使ってパッケージ入れるときは

package.jsonに書かれているすべてのパッケージをインストールする

npm install

package.jsondependenciesに書かれているパッケージのみインストールする

npm install --production

だから、開発に必要なパッケージは

npm install --save-dev eslint

で書かなくちゃいけないんだな。
ESlintとかはそうだね。

ちなみにnpm 5.0.0以降は--saveはつけなくてOK。

npm v5以降↓↓

参考:https://docs.npmjs.com/cli/install

アプリの動作に必要なものはdependencies

npm install hoge

開発に必要なものはdevDependencies

npm install -D hoge

ESLintの巻

ESLintってのを入れると、書いているコードを既存のルールに当てはめて勝手にきれいにしてくれるのでめちゃくちゃおすすめ。
当然入れる。

ESLintのは開発でしか使わないので

npm install -D eslint

これでESLintを使える。
初期設定は
eslint --initなんだけど、グローバルインストールしていないから

 ./node_modules/.bin/eslint --init       

でESlintの初期設定を行う。
いろいろ選べば勝手に.eslintrc.jsonみたいなのを作ってくれる。

? How would you like to use ESLint? (Use arrow keys)
  To check syntax only 
❯ To check syntax and find problems 
  To check syntax, find problems, and enforce code style 

上みたい感じなのが出てくるからいい感じに選んでくれ。

nodemonの巻

コードを更新するたびにCtrl + cするのがだるいのでnodemonを使う。
これは開発するときだけ使うので

npm install -D nodemon

だね。

さて、package.jsonのscriptを変更する必要があります。

  "scripts": {
    "start": "node ./bin/www"
  },

こうなっていると思うから

  "scripts": {
    "start": "nodemon ./bin/www"
  },

とします。

これでコード編集されると自動で監視してくれるようになるよ。わーい。

以上?

やったのはこんだけ。
あとは普通にexpress通りに作った。

調べてたらES6を使うためにbabelを入れようみたいな記事があったが、上の設定だけでアロー関数使えたよ。よくわからん。
だれか教えてTT

以上です。

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

Javacript Dateメソッド わかりやすく日時を表示する

Dateオブジェクト

1現在の日時を取得する
2過去や未来の日時を指定する
3日時の計算をする

①Dateオブジェクトを使う際は初期化をする

const now = new Date();

newはオブジェクトを初期化するためのキーワード。

現在日時を記憶した状態でDateオブジェクトを初期化する
new Date();

②年、月、日などを個別に取得する

const now = new Date();
    const year = now.getFullYear();
    const month = now.getMonth();
    const date = now.getDate();
    const hour = now.getHours();
    const min = now.getMinutes();


③取得したらあとは出力するだけ

const output = `${year}/${month + 1}/${date} ${hour}:${min}`;
document.getElementById('time').textContent = output;

*now.getMonth・・・実際の月-1になるため+1しないといけない

12時間時計にしてみよう

① ampmを定義し、空の文字列(文字列が0個の文字列)を代入して準備する

let ampm = '';

②午前中、午後に場合わけ

 let ampm ='';
    if(hour < 12){
        ampm ='a.m.';
    } else{
        ampm = 'p.m.'
    }

③24時間→12時間に変える

 const output = `${year}/${month + 1}/${date} ${hour%12}:${min}${ampm}`;
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

変数参照の遅延

解決

グローバルスコープでも以下のように修正して解決しました。
"/3[0-9]/g" → "/3[0-9]/"で解決しました。

問題

変数regをグローバルスコープに設定

sample.js
 let reg = /3[0-9]/g;


for(let i = 1; i < 101; i++) {



    if(reg.test(i)) {

        console.log(i + "は10の位が30の数字だよ!!");

    } else if(i % 3 == 0 ) {

       console.log(i + "は三で割り切れるよ!!");

    }else {

        console.log(i);
    }

}

結果

30は10の位が30の数字だよ!!
31
32は10の位が30の数字だよ!!
33は三で割り切れるよ!!
34は10の位が30の数字だよ!!
35
36は10の位が30の数字だよ!!
37
38は10の位が30の数字だよ!!
39は三で割り切れるよ!!

ローカル変数iの10の位が30の時に所々、期待した出力値が出ない。
例( i = 31の時"31は10の位が30の数字だよ!!"と出力されて欲しいが"31"と出力される。)

原因はforループの処理速度にによるグローバル変数regを参照する速度が追いついていないのか?
と考えたので変数regをブロックスコープに設定。
※javascriptには"スコープチェーン"と言う概念がある。
この概念によると、javascriptは狭いスコープに位置する変数を優先して参照するらしい。

sample.js



for(let i = 1; i < 101; i++) {

  let reg = /3[0-9]/g;

    if(reg.test(i)) {

        console.log(i + "は10の位が30の数字だよ!!");

    } else if(i % 3 == 0 ) {

       console.log(i + "は三で割り切れるよ!!");

    }else {

        console.log(i);
    }

}

結果

30は10の位が30の数字だよ!!
31は10の位が30の数字だよ!!
32は10の位が30の数字だよ!!
33は10の位が30の数字だよ!!
34は10の位が30の数字だよ!!
35は10の位が30の数字だよ!!
36は10の位が30の数字だよ!!
37は10の位が30の数字だよ!!
38は10の位が30の数字だよ!!
39は10の位が30の数字だよ!!

PS

やはり変数の参照速度の遅延が原因なのか??
わかる方、是非ご教授ください。

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

JavaScriptでのDOMを使って色々な操作をしてみた

作業前のウェブページ

JavaScriptPractice.png

DOMの置換をしてみた

innerHTMLを使用しましょう。
これを使うとHTMLの要素を書き換えることができます。
では早速コードです。
以下はscript.jsでの書き込みです。

window.addEventListener("load", function() {
  let btn = document.querySelector("a#btn-square");
  btn.addEventListener("click", function() {
    console.log("Hello world");
  });
 以下、置換をするために追加したコードです
  let btn2 = document.querySelector("a#btn-square2");
  let changeText = document.querySelector("p.text2");
  btn2.addEventListener("click", function() {
  changeText.innerHTML = '変更されました';
  });
});

以下、HTMLです。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>JavaScriptPractice</title>
    <link rel="stylesheet" type="text/css" href="style.css">
    <script src="script.js"></script>
  </head>
  <body>
    <div class = "top">
      <a href="#" id="btn-square">おはよう</a>
      <p class = "text">挨拶は大事だよ</p>
    </div>
    <div class = "middle">
      <a href="#" id="btn-square2">こんにちは</a>
      <p class = "text2">挨拶は基本だよ</p>
    </div>
    <div class = "down">
      <a href="#" id="btn-square3">おやすみ</a>
    </div>
  </body>
</html>

やっていることは単純です。
まず、document.querySelector("a#btn-square2");で
要素を取得しています。そしてそれを変数で定義しています。
let changeText = document.querySelector("p.text2");でも
要素を取得しています。同じように変数で定義をしています。
更に以下のコードがありましたね。

btn2.addEventListener("click", function() {
  changeText.innerHTML = '変更されました';
  });

このコードではクリックしたときに変数(changeText)にあるものをinnerHTMLメソッドを使って変更をさせるようにしています。
その結果、置換ボタンを押すと以下のようになります。
JavaScriptPractice.png

DOMの追加をしてみる

今度はクリックすると要素が追加される機能を実装しようと思います。
それにはclassList.addメソッドを用います。
では実際のコードです。

window.addEventListener("load", function() {
  let btn = document.querySelector("a#btn-square");
  btn.addEventListener("click", function() {
    console.log("Hello world");
  });
  let btn2 = document.querySelector("a#btn-square2");
  let changeText = document.querySelector("p.text2");
  btn2.addEventListener("click", function() {
  changeText.innerHTML = '変更されました';
  });
  let btn3 = document.querySelector("a#btn-square3");
  btn3.addEventListener("click", function() {
    changeText.classList.add("red");
  });
});

これとは別にCSSファイルにredクラスの中にbackground-color:red;を
記述する必要性があります。

これもやっていることは凄く単純で、
document.querySelector("a#btn-square3");で要素を取得しています。
変数で定義し、その変数にaddEventListenerメソッドを用いて
イベントを発火させようとしています。
更に、置換の際に用いた変数changeTextにclassList.addメソッドを用いて
変数にredというクラスを加えるよう指示
します。
これで加えるボタンを押すと以下のようになるはずです。
JavaScriptPractice.png

DOMの削除をしてみる

今度はすでにあるクラスを削除する実装をしていこうと思います。
それにはclassList.removeメソッドを用います。
では実際のコードです。

window.addEventListener("load", function() {
  let btn = document.querySelector("a#btn-square");
  btn.addEventListener("click", function() {
    console.log("Hello world");
  });
  let btn2 = document.querySelector("a#btn-square2");
  let changeText = document.querySelector("p.text2");
  btn2.addEventListener("click", function() {
  changeText.innerHTML = '変更されました';
  });

  let btn3 = document.querySelector("a#btn-square3");
  btn3.addEventListener("click", function() {
    changeText.classList.add("red");
  });

  let btn4 = document.querySelector("a#btn-square4");
  btn4.addEventListener("click", function(){
    changeText.classList.remove("red")
  })
});

これも単純です。
document.querySelector("a#btn-square4");で要素を取得します
そしてbtn4.addEventListenerでイベント発火準備OKです。
changeText.classList.remove("red")でredクラスを消すイベントを指示しています。
つまり、先ほどみたく赤色になった状態で削除ボタンを押すと赤色だけが消えます。
試してみてください。
遊んでみると理解が深まるのでお勧めです。

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

FireStoreの基本と操作 - データのCRUD操作とクエリ

Cloud FireStoreとは

Cloud FireStore(以下FireStore)とは、FireBaseの提供するドキュメント指向型
のNoSQLデータベースです。
NoSQLとしての特徴としてのスキーマレススケーラブルといった特徴のほかにリアルタイムアップデートセキュリティルールオフラインサポートといった独自の特徴を備えており、特にバックエンドを介さずにクライントサイドから直接操作できるという点が大きなポイントです。
また、β版から正式リリースされたのが2019年2月ということもあり、比較的新しい技術です。

RealTimeDatabaseとの違い

FireStoreの登場時期がつい最近ということに触れましたが、FireStoreが登場する以前はRealTimeDataBaseが使用されていました。FireStoreは、RealTimeDatabaseの特徴を受け継いだデータベースであり、RealTiemDatabaseの弱点であったデータモデルを改善したりクエリを強化したりなどより使いやすくなっています。ですから、新たにプロジェクトを開始する場合にはほとんどの場合FireStoreを利用するべきです。

以下は、FireStoreとRealTimeDatabaseの比較です。

RealTimeDatabase FireStore
データモデル JSON
クエリ ・制限あり(フィルタリングと並び替えをの両方を同時に行うことはできない)・取得したデータのすべての子ノードを返す・JSONツリーの個々のノードまでアクセスできる・インデックスを必要としないが、データセットが大きくなるにつれて特定のクエリのパフォーマンスは低下する・
セキュリティルール 読み込みルールと書き込みルールはカスケード式に適応される
スケーラビリティ スケーリングにはシャーディングが必要
課金 帯域幅とストレージにのみ課金され、課金レートは高くなる

FireStoreのデータモデル

FireStoreは、MySQLPostgreSQLなどのSQLデータベースと違い、「テーブル」や「行」はありません。代わりに、データはドキュメントに格納され、それがコレクションとしてまとめられています。

docuemnt.png

ドキュメント

ドキュメントは、JSONとよく似たデータ構造です。
ブール値、文字列、数値、タイムスタンプ、配列、マップなどのなどのデータ型を持つ値を、キーバリューによってデータを保存します。
例えば、ユーザーを表すドキュメントは次のようになります。

name: {
  firstName: '鈴木',
  lastName: '太郎',
},
sex: 'male',
birthDay: 847694648,
favoriteFoods: ['寿司', 'ラーメン', '焼き肉']

また、ドキュメントはスキーマレスであるため例えば同じユーザーを表すコレクションの中でも異なるデータ構造をもたせることができます。

name: '佐々木寿人',
birthDay: 847694648,
favoriteSongs: ['pretender', '紅蓮華', 'マリーゴールド']

ただし、あまりに自由なデータ構造をもたせるとアプリケーションで扱いにくいデータになってしまうので、スキーマを定義した上で使用するのが一般的です。スキーマレスな構造は、例えばレガシーなデータと互換性をもたせるために使われることがあります。

また、ドキュメントのデータサイズには制約があり、ドキュメント1剣あたりのサイズが1MBまでに制限されています。

ドキュメントはアプリケーションでそのまま扱えるように設計するのがポイントとなります。

コレクション

コレクションはドキュメントを格納するコンテナであり、すべてのドキュメントはコレクションの中に保存されます。
例えば、さきほどのドキュメントはUsersコレクションに格納されることになります。

コレクション内のドキュメントの名前は一意である必要があり、独自のキーをしていするかFirestoreで自動的にランダムなIDを振り分けることになります。

リファレンス

リファレンスは、ドキュメントが格納されているパスを表現するモデルで、データベースの場所によって一意に識別されます。
例えば、先程のUsersコレクションにアクセスするためには、次のようなレファレンスを作成します。

users/jkfjakdfjaffahi@a
users/ahjioghja@gihjafu

また、リファレンスはそのままFirestoreにデータとして保存することができます。リファレンス型のデータの保存は、ドキュメント間の関係を表現する方法として利用されます。

サブコレクション

ドキュメントの階層構造を作るために、サブコレクションを利用することができます。
サブコレクションはドキュメントの中にさらにコレクションを持つという構造になっており、ルートコレクションから見るとコレクション/ドキュメント/コレクション/ドキュメントといった構造になります。
サブコレクションはドキュメントの親子関係、所有/被所有を表現するためにしようされ、例えばユーザー(Usres)記事(Articles)の関係は次のようになります。

users
  jkfjakdfjaffahi@a
    name: '鈴木太郎'
    sex: 'male',
      articles
        fjlkafakjfafflakju
          title: '記事1'
          body: 'ここに内容が入ります'
          published: false, 
          createdAt: 1560000000
  ahjioghja@gihjafu
    name: '佐々木寿人'
    sex: 'male',
      articles
        lkafhja;kfhahgi
          title: '記事2'
          body: 'あいうえお'
          published: true, 
          createdAt: 1460000000

リファレンスは次のように表します。
users/fjlkafakjfafflakju/articles/fjlkafakjfafflakju

なぜサブコレクションを利用するのか

このデータ構造を見て、こんなふうに思った方もいるのではないのでしょうか。
「Firestoreはそもそもデータ型としてリストやマップを備えているのだから、サブコレクションを利用しなくともそれらを利用すればよいのではないか」

つまり、次のようなデータ構造としても同じなのではないか、ということです

users
  jkfjakdfjaffahi@a
    name: '鈴木太郎'
    sex: 'male',
    articles: [
      { 
    id: fjlkafakjfafflakju
        title: '記事1'
        body: 'ここに内容が入ります'
        published: false, 
        createdAt: 1560000000
      }

確かに、この構造はよく見慣れたJSONの構造であり、必要なデータを一度のクエリで取得できるというメリットもあります。
しかし、以下の点から基本的に階層データはサブコレクションで保持すべきです。

ドキュメントのデータサイズには制限がある

前出のとおり、ドキュメントのデータサイズは1MBまでという制限があります。通常の利用には問題ないのですが、上記の例のようにドキュメントのリストやマップにネストした構造は、ユーザーの操作とともに数が増えていくようなデータを保持するには適していません。

クエリ上の観点

FireStoreでのドキュメントに対するクエリは、常にドキュメント全体を返します。
つまり、上記のデータ構造でユーザーデータを取得するとき、必要がないときでも常に記事のデータを取得しなければいけないため、クエリのサイズが大きくなり問題となります。

ドキュメントを取得するときに、通常その下の階層にあるサブコレクションは取得されません。サブコレクションは必要なときだけ取得すればよいことになります。
 

セキュリティルール上の観点

セキュリティルールを設計する際にもネストしたマップやリストを利用している場合問題が生じます。

セキュリティルールでは、for文や一時変数が利用できないため、リストの要素数がドキュメントごとに異なっていたり、ネストしたマップの型が統一されていない場合は安全なスキーマ検証ができなくなります。

さらに、データの秘匿に関しても問題になります。
例えば、ユーザのデータには公開してもよいデータ(名前、プロフィール)と他人には隠しておきたいが、本人は参照したいデータ(メールアドレス、住所)があるはずです。

以上のような問題は、サブコレクションによって解決されます。
サブコレクションはJSONツリー型でしかデータを保持できなかったRealTimeDatabaseの弱点を克服した構造ともいえるでしょう。

コレクショングループ

さらに、サブコレクションを利用する利点としてコレクショングループを利用することができるという点が上げられます。
コレクショングループは、同一のIDをもつサブコレクションを一つのコレクションとみなして扱うことができる機能です。
通常のクエリでは、UsersのサブコレクションであるArticlesを取得するには、users/{uid}/articlesとしてアクセスします。
ユーザーに紐づくすべての記事を取得するには単純ですが、
すべての記事を横断して取得するためにはユーザーごとの記事を取得する必要がありました。

しかし、コレクショングループを利用すれば、階層化されたサブコレクションを一度に取得することができます。

コレクショングループクエリを使用するためには、コレクショングループクエリをサポートするインデックスを作成する必要があります。
さらに、ウェブとモバイルSDKの場合は、コレクショングループクエリを許可するルールも作成する必要があります。

ドキュメントのデータ型

FireStoreのドキュメントには、以下のデータ型がサポートされています。

  • 配列(リスト)
  • ブール値
  • バイト
  • 日時
  • 浮動小数点数
  • 地理的座標
  • 整数
  • マップ
  • null
  • 参照(リファレンス)
  • テキスト文字列

FireStoreを使ってみる

Firebaseの概要についてここまで説明してきました。
ここからは、実際にFireStoreを使いながら進めていきます。

データベースを有効化する

Firebaseのプロジェクトを作成したら、左のナビゲーションバーからDatabaseを選択します。
スクリーンショット 20200503 18.33.54.png

データベースの作成をクリックして、テストモードを選択しましょう。

スクリーンショット 20200503 18.40.30.png

スクリーンショット 20200503 18.41.01.png

テストモードは、誰でもデータベースの読み取りや書き込みが行える状態であるため、決してテストモードのまま本番環境で使用してはいけません。

次に、データベースのロケーションを選択します。
ロケーションは、データを利用するユーザーとサービス近いほどレイテンシが小さくなります。
あなたが日本のユーザーをアプリケーションのターゲットにしているのなら、asia-northeast1(東京)asia-northeast1(大阪)を選択すれば無難でしょう。
スクリーンショット 20200503 18.49.07.png

データベースの作成が完了したら、次のような画面が表示されます。
スクリーンショット 20200503 18.51.40.png

データを追加する

それでは、早速データを追加しましょう。
ますはコレクションを開始します。ここでは、usersコレクションを作成します。
スクリーンショット 20200503 19.39.38.png

コレクションを作成したら、そのまま最初のドキュメントを追加しましょう。
ドキュメントのIDと、ドキュメントのフィールドを追加します。

スクリーンショット 20200503 19.42.51.png

ドキュメントのIDは、なにも入力しなけらばランダムなIDが自動で使用されます。
今回のようにusersコレクションを作成する場合には、 Firebase Authenticationを利用して作成したユーザーのuidを指定することが一般的です。
uidを使用したらドキュメントIDの一意性が確保されますし。ログインしているユーザーの情報を簡単に取得することができます。

ドキュメントのフィールドには、キーとタイプ、値を設定します。

キー タイプ
name string 鈴木太郎

ドキュメントの追加が完了したら、データが投入されていることが確認できます。

スクリーンショット 20200503 19.50.04.png

この画面から、さらにコレクションやドキュメントの追加、修正、削除などを行うことができます。

JavaScriptでアプリケーションからFirestoreを利用する

ダッシュボードからFireStoreを利用する方法はわかりましたが、おそらくこれはアプリケーションを利用する上で望んでいる方法ではないでしょう(すべてのアプリケーションの依頼をうけてあなたがデータベースを直接操作するようにしますか?)

Firebase JavaScript SDKを利用してアプリケーション上から操作できるようにしましょう。

開発環境の設定

まずは、アプリケーション上でFireStoreを使えるようにするための設定をします。

Firebaseライブラリの追加

<script>タグからFirebaseとFirestoreのライブラリをアプリケーションに追加します。

<script src="https://www.gstatic.com/firebasejs/7.2.3/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/7.2.3/firebase-firestore.js"></script>

または、npmからパッケージをインストールします。

npm install firebase

npmを利用した場合には、インストールしたパッケージをimportしましょう。

import firebase from 'firebase/app'
import 'firebase/firestore'

Firestoreを初期化する

APIキーなどをセットして、Firebaseを初期化します。
Firestoreはfirebase.firestore()の名前空間から使用できます。

if (!firebase.apps.length) {
  firebase.initializeApp(
    {
      apiKey: process.env.VUE_APP_APIKEY,
      authDomain: process.env.VUE_APP_AUTHDOMAIN,
      databaseURL: process.env.VUE_APP_DATABASEURL,
      projectId: process.env.VUE_APP_PROJECTID,
      storageBucket: process.env.VUE_APP_STORAGEBUCKET,
      messagingSenderId: process.env.VUE_APP_MESSAGINGSENDERID,
      appId: process.env.VUE_APP_APPID,
      measurementId: process.env.VUE_APP_MEASUREMENTID
    }
  )
}

const db = firebase.firestore()

ドキュメントを追加する

これでFirestoreが使えるようになったので、早速基本のCRUD操作からやっていきます。
まずは、ダッシュボード上で行ったようにドキュメントを追加します。

IDを指定してドキュメントを追加

IDを指定してドキュメントを追加するには、ドキュメントのリファレンスを作成してからset()メソッドを使用します。set()メソッドは、ドキュメントIDがすでに存在する場合はドキュメントの更新を行い、存在しないドキュメントIDが渡された場合そのドキュメントIDで新規作成をします。

cosnt db = firebase.firestore()

// ログインしているユーザーの情報を取得します。
const user = firebase.auth().currentUser()

// ユーザーコレクションへのリファレンスを作成します。
const userRef = db.collection('user')

// ドキュメントIDにはログインユーザーのuidを指定します。
// setの引数にはJavaScriptのオブジェクトの形式でデータを渡します。
userRef.doc(user.uid).set({
  name: '鈴木太郎',
  age: 22,
  birthday: new Date('1996-11-11') // timestampe型にはDateオブジェクトを渡します。
  createdAt: db.FieldValue.serverTimestamp() // サーバーの時間をセットすることもできます。
})
.then(() => // 処理が成功したとき)
.catch(e => // エラーが発生したとき)

IDを自動で割り当ててドキュメントを追加

IDを自動で割り当ててドキュメントを追加するには、2つの方法があります。

  • set()
  • add()

なお、2つの方法は完全に等価であり、どちらか好みの方法を利用することができます。

set()を利用する

1つ目の方法は、IDを指定して追加する方法と同じく、set()を利用します。
単純に、doc()に何も渡さなければ、自動的にIDが割り当てられます。

cosnt db = firebase.firestore()

const userRef = db.collection('user')

// doc()の引数にはなにも渡しません
userRef.doc().set({
  name: '鈴木太郎',
  age: 22,
  birthday: new Date('1996-11-11') 
  createdAt: db.FieldValue.serverTimestamp() 
})
.then(() => // 処理が成功したとき)
.catch(e => // エラーが発生したとき)

add()を利用する

add()を利用しても同じようにドキュメントを作成できます。

cosnt db = firebase.firestore()

const userRef = db.collection('user')

// doc()の引数にはなにも渡しません
userRef.add({
  name: '鈴木太郎',
  age: 22,
  birthday: new Date('1996-11-11') 
  createdAt: db.FieldValue.serverTimestamp() 
})
.then(() => // 処理が成功したとき)
.catch(e => // エラーが発生したとき)

なお、ドキュメント作成時に存在しないコレクションが指定された場合には、そのコレクションも同時に作成します。

ドキュメントを更新する

ドキュメントを更新するには、以下の2つの方法があります。

  • update()
  • set()

この2つの方法は細部が異なるので見ていきましょう。

update()を利用する

ドキュメント全体を上書きせずに一部のフィールドを更新するには、update()メソッドを利用します。

cosnt db = firebase.firestore()

const userRef = db.collection('user')
// ログインしているユーザーの情報を取得します。
const user = firebase.auth().currentUser()

// ageフィールドのみを更新します

userRef.doc(user.uid).updaete({
  age: 24,
})
.then(() => // 処理が成功したとき)
.catch(e) = // エラーが発生したとき)

set()を利用する

set()メソッドは前述の通り、既に存在するドキュメントIDを指定した場合ドキュメントを更新します。
しかし、set()メソッドのデフォルトの動作に注意してください。set()のデフォルトの動作は引数で与えられた値でドキュメントを置き換えるため、もともと持っていたフィールドはすべて失われ新しいオブジェクトの情報だけ残ります。

つまり、update()を利用するのと同じ感覚で下記のように指定した場合異なる動作をするおのでは注意が必要です。

cosnt db = firebase.firestore()

const userRef = db.collection('user')
// ログインしているユーザーの情報を取得します。
const user = firebase.auth().currentUser()

// ageフィールドのみを更新しようとしましたが、もともと持っていたname、birthday、createdAtは失われてしまいます

userRef.doc(user.uid).set({
  age: 24,
})
.then(() => // 処理が成功したとき)
.catch(e) = // エラーが発生したとき)
// 予期した結果
{
  name: '鈴木太郎',
  age: 24,
  birthDay: timestampオブジェクト,
  createdAt: timestampオブジェクト
}

// 実際の結果
{
  age: 24
}

デフォルトの動作では、変更したくないフィールドも明示的に渡す必要があり、少々不便です。
そこで、一部のフィールドだけを更新したいときは、SetOptionsを第2引数に渡し、mergeパラメータにtrueを指定します。

cosnt db = firebase.firestore()

const userRef = db.collection('user')
// ログインしているユーザーの情報を取得します。
const user = firebase.auth().currentUser()

// ageフィールドのみを更新しようとしましたが、もともと持っていたname、birthday、createdAtは失われてしまいます

userRef.doc(user.uid).set({
  age: 24,
}, { merge: true })
.then(() => // 処理が成功したとき)
.catch(e) = // エラーが発生したとき)

ドキュメントを削除する

ドキュメントの削除には、delete()メソッドを利用します。

cosnt db = firebase.firestore()

const userRef = db.collection('user')
// ログインしているユーザーの情報を取得します。
const user = firebase.auth().currentUser()

userRef.doc(user.uid).delete()
  .then(() => // 処理が成功したとき)
  .catch(e) = // エラーが発生したとき)

単一のドキュメントを取得する

単一のドキュメントを取得するには、get()メソッドを利用します。

cosnt db = firebase.firestore()

const userRef = db.collection('user')
// ログインしているユーザーの情報を取得します。
const user = firebase.auth().currentUser()

const result = []
userRef.doc(user.uid).get()
  .then(doc => {
    if (doc.exists) {
        result.push({
          id: doc.id,
          ...doc.date() // doc.data()からデータのオブジェクトを取得できます。
        })
    } else {
      console.log('結果は空です')
    }
   })
  .catch(e => // エラーが発生したとき )

get()が成功したら、doc.exists()でドキュメントが空でないかチェックします。
ドキュメントが存在したのならば、doc.idでIDを、doc.data()からデータのオブジェクトを取得できます。

クエリを発行する

単一のドキュメントに対するCRUD操作を見てきました。
しかし、一般的なアプリケーションでは複数のデータを条件によって取得する欲求があるはずです。Firestoreがどのようばクエリを発行できるか見ていきましょう。

単純なクエリ

次の例は、すべての記事を返します。

cosnt db = firebase.firestore()
// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef.get()
  .then(querySnapshot => {
    if (querySnapshot.empty) { // querySnapshot.emptyがtrueの場合コレクションにデータが存在しません。
        console.log('結果は空です')
    } else {
      // querySnapshotをループしてデータを取り出します。
      querySnapshot.forEach(doc => {
         // 単一のドキュメントの操作と同じです。
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

コレクションの参照にget()メソッドを利用して、すべてのコレクションを取得することができます。
また、一般的なNoSQL系データベースと異なりFirestoreのクエリ結果はすべて強い整合性をもつことが特徴です。サーバーからドキュメントを取得する場合は
常に最新のデータにアクセスすることが保証されています。

フィルタを利用する

Firestoreでは、SQLデータベースのようにwhere()メソッドを利用することでクエリをフィルタリングすることができます。
where()メソッドは、3つの引数を受け取り、フィルタリングするフィールド、比較演算、値の順に受けれ入れます。
比較演算子には、以下の8つが利用できます。

  • =
  • <
  • <=
  • >
  • >=
  • in
  • array-contains
  • array-contains-any
=(等価演算子)

次の例は、ログイン中のユーザーの記事を取得します。

cosnt db = firebase.firestore()

// ログインしているユーザーの情報を取得します。
const user = firebase.auth().currentUser()
// ユーザードキュメントへの参照を取得
const userRef = db.collection('user').doc(user.uid)

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  // auhtorフィールドは参照型です
  .where('auhtor', '==', userRef)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

クエリに.where('auhtor', '==', userRef)が追加されています。これが基本的なwhere()メソッドの使用方法です。

< <= > >=(比較演算子)

比較演算子も同じように利用できます。
次の例は、2020年4月以降の記事を取得します。

cosnt db = firebase.firestore()

// 起点となる日付を作成
// firestoreの日付型はDateオブジェクトで比較できます。
const date = new Date('2020-04')

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  // createdAtフィールドは日付型です
  .where('createdAt', '>=', date)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )
inクエリ

inクエリはフィールドがいくつかの値のいずれかに等しいドキュメントを取得します。
inクエリは、Firestoreで単純なORクエリを実行するのに適した方法です。

次の例は、記事のタイトルが「Denoとはなにか - 実際につかってみる」「FireBase①」「JavaScript ES2015」の記事を取得します。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  // 配列で値を渡します
  .where('title', 'in', ['Denoとはなにか - 実際につかってみる', 'FireBase①', 'JavaScript ES2015'])
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

なお、inクエリに渡せる値は10個までという制約があります。

array-contains(配列メンバーシップ)

array_containsは配列型のフィードに対して使用します。
フィールドの配列に値が含まれていた場合、そのドキュメントを返します。
次の例は、JavaScriptというタグが使用されている記事を取得します。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('tags', 'array-contains', 'JavaScript')
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

クエリ対象の値が配列内に複数存在する場合でも、ドキュメントは結果に 1 回だけ含まれます。

array-contains-any(配列メンバーシップ)

array-contains-anyは、配列型に対するinクエリです。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('tags', 'array-contains-any', ['JavaScript', 'PHP', 'Firebase'])
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

inクエリと同様、渡せる値は10までの制約があります。

複合クエリ

1回のクエリの中で、複数のwhere()メソッドを呼び出して作成することができます。複合クエリはAND条件として扱われます。

等価演算子=に対する複合クエリ

等価演算子==に対する複合クエリには制限がなく、複数回フィルタをかけることができます。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('auhtor', '==', userRef)
  .where('createdAt', '==', new Date())
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )
比較演算子に対する複合クエリ

比較演算子に対して複合クエリを使用する場合、1つのフィールドに対するクエリは有効です。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('createdAt', '>=', new Date('2019-04'))
  .where('createdAt', '<', new Date('2020-04'))
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

しかし、複数のフィールドに対して同時に比較演算子を使用することはできません。次のようなクエリはエラーになります。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  // 複数のフィールドに対する比較演算子はエラー!
  .where('createdAt', '>=', new Date('2019-04'))
  .where('rating', '<', 5)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )
等価演算子と比較演算子、配列メンバーシップを同時に利用する

等価演算子と比較演算子、配列メンバーシップを同時に利用するクエリでは、**複合インデックスを作成する必要があります。
例えば、複合インデックスを作成していない状態で次のようなクエリを発行しようとしてみます。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  // 等価演算子と比較演算子を同時に利用する
  .where('createdAt', '>=', new Date('2019-04'))
  .where('published', '==', true)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

次のようなエラーが発生してしまいました。

スクリーンショット 20200524 16.39.11.png

このクエリにはインデックスが必要ですという旨のエラーです。
メッセージに示されたURLをクリックすると、コンソールへ移動して自動的に複合インデックスを作成してくれます。

スクリーンショット 20200524 16.42.56.png

inクエリ、配列メンバーシップ

inarray-containsarray-contains-anyは、複合クエリの中で一度だけ使用することができます。

クエリのソート

orderBy()メソッドを使用すると、クエリ結果を並び替えることができます。1回のクエリで複数のフィールドに対してソートをすることができます。
次の例では、作成日の降順、評価の昇順で並び替えます。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .order('createdAt', 'desc')
  // ソート順を指定しなかった場合、昇順になります。
  .order('rating')
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

なお、 orderBy() メソッドは、指定したフィールドの有無によるフィルタも行います。 指定したフィールドがないドキュメントは結果セットには含まれません。

orderBy()メソッドはwhere()メソッドと組み合わせて使用することができますが、比較演算子を利用する場合には最初の並べ替えは同じフィールドである必要があります。
次のクエリはエラーになります。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('createdAt', '>=', new Date('2019-04'))
  // 比較演算子と異なるフィールドでソートしようとするとエラー
  .order('rating')
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

さらに、等価演算子を利用して異なるフィールドでソートする際には複合インデックスを作成する必要があります。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('published', '==', true)
  // 複合インデックスの作成が必要
  .order('createdAt', 'desc')
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

データの取得数の制限

limit()メソッドを利用すると、データを取得した数だけ取得します。
次の例では、最新の記事上位10件に限って取得をします。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .order('createdAt', 'desc')
  // 10件だけ取得
  .limit(10)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

ページネーション

Firestoreのクエリを用いてページネーションを行ってみましょう。
limit句は先程紹介しましたが、offset句はサポートしておりません。その代わりには、startAfter()を利用してクエリの開始点を指定することでページネーションを実現します。

startAfter()には、パラメータドキュメントを渡すことができます。つまり、前回実施したクエリの最後のドキュメントを指定すれば、次のページを取得することができます。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
const limit = 10
// 最後のドキュメントを保持しておきます。
let lastDoc
// すべてのドキュメントを取得したかの判定に使用します。
let isFinish = false

articleRef
  .order('createdAt', 'desc')
  .limit(limit)
  .startAfter(lastDoc)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        // 取得したコレクションが空だったらすべてのドキュメントを取得したと判定
        isFinish = true
    } else {
      if (querySnapshot.size < limit) {
          // 取得したコレクションの数がlimitよりも少なければこれ以上データはない
          isFinish = true
      }
      // 最後のドキュメントを取得
      lastdoc = querySnapshot.docs[querySnapshot.docs.length - 1]
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

2ページ以降も、最初のページと同じ条件のクエリを発行する必要があります。
また、明確にページ数を指定するタイプのページネーションは推奨されていません。(3ページ目を取得しようとしても、2ページ目の終わりがわからない)
無限スクロールによるページネーションの実装が推奨されています。

リアルタイムリスナー

Firestoreの大きな特徴の一つとして、リアルタイムリスナーがあります。リアルタイムリスナーは、クライアント側でFirestoreの最新の状態を監視し、変化があった場合には直ちに状態を同期することができます。
リアルタイムリスナーを利用するにはget()メソッドの代わりにonSnapshot()メソッドを利用します。

cosnt db = firebase.firestore()
const articleRef = db.collection('articles')

const result = []
articleRef
  .getonSnapshot()
  .then(querySnapshot => {
    if (querySnapshot.empty) { 
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

リアルタイムリスナーは、例えばチャットのような機能も簡単に実装することができます。

また、ドキュメントがどのような変更がされたか確認することもできます。

cosnt db = firebase.firestore()
const articleRef = db.collection('articles')

const result = []
articleRef
  .getonSnapshot()
  .then(querySnapshot => {
    snapshot.docChanges().forEach(change {
      if (change.type === "added") {
        console.log('追加されたドキュメント', change.doc.data());
      }
      if (change.type === "modified") {
        console.log('変更されたドキュメント', change.doc.data());
      }
      if (change.type === "removed") {
        console.log('削除されたドキュメント', change.doc.data());
      }
     })
  }

リアルタイムリスナーはユーザー体験を向上させますが、単純なクエリのほうが適している場合もあります。
例えば、ブログなどで記事を見ている最中に(今この瞬間ですね)突然本文の内容が変わったり削除されたりすることを好ましいと思う人は少ないでしょう。

また、先程のページネーションと組み合わせたりするときも注意が必要です。ページ送りをしている最中にデータの並び順が変わった場合、再度同じドキュメント取得してしまったりなどページ付がおかしくなったりすることがあります。

さらに、データが頻繁に更新されるような場合、データが次々と追加されたり入れ替わるさまを眺めるのは楽しいかもしれませんが、バッテリーや通信量の面でユーザーからは不評を得るかもしれません。

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

Javascript -イベント フォームに内容を送る- 自分用ノート

読み取りたいフォームの内容を読み取る方法

取得した要素<form>.読みたいりたいフォームのname属性 .value
    処理内容
}

①定数を定義する

const search =

②入力内容を読み取るため

要素を取得(getElementByIdメソッド)
const search = document.getElementById('form')

③★結構重要。フォームにname属性指定をする

<input type="text" name="word">

④Javasctiptのプログラムが読み取る。word(ネーム属性)指定。

const search = document.getElementById('form').word

⑤valueプロパティに入れて保存させる

const search = document.getElementById('form').word.value

基本動作をキャンセルさせる方法

イベントオブジェクトをファンクションで受け取る

document.getElementById('form') = funcion(event)

渡されたのはイベント"オブジェクト"
→プロパティやメソッドを持っている

タグの基本動作をキャンセルする働きを持つ.
functionの{〜に追加}

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

[Django]テンプレートタグから直接JavaScriptの変数に値を渡す方法

Background

TemplateViewcontext_dataからhtmlにそのまま表示させたい時、下記の通りでタグをセットすると表示することができます。

views.py
from django.views.generic import TemplateView

class SampleTemplateView(TemplateView):
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['areas'] = ["japan","tokyo","osaka","nagoya","sapporo","sendai","fukuoka"]
        return context
index.html
...
    <ul>
    {% for item in areas %}
        <li> {{ item }} </li>
    {% endif %}
    </ul>
...
  • japan
  • tokyo
  • osaka
  • nagoya
  • sapporo
  • sendai
  • fukuoka

と表示。

しかし、Javascriptのライブラリを使って描画させたい時に直接値を渡したい場合があります。
ここでは渡す方法を紹介します。

Failure

そのまま渡すと、、、

<script type="text/javascript">
    var areas = "{{ areas }}";
    console.log(areas);
</script>
[&#x27;japan&#x27;, &#x27;tokyo&#x27;, &#x27;osaka&#x27;, &#x27;nagoya&#x27;, &#x27;sapporo&#x27;, &#x27;sendai&#x27;, &#x27;fukuoka&#x27;]

となってシングルクオテーション、"<"、">"などが自動で変換されます。
文字列型なので正規表現で置換した後に JSON.parse でdict化でいいかなと思ったのですがうまくできず。

で、調べた結果二つの方法を見つけました。

Method 1

泥臭いですが、各要素を呼び出してjavascriptコードに書き込む方法です。

var areas = [
{% for area in areas %}
{% with index=forloop.counter0 %}
{% if 0 < index%},{% endif %}
{% endwith %}
"{{area.name}}"
{% endfor %}
];
console.log(areas);

Method 2

もう一つは、autoescapeで⾃動エスケープ制御をoffにする方法です。

{% autoescape off %}
var areas = {{ areas }};
{% endautoescape %}
console.log(areas);

こっちの方がラクです。

Reference

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

Safariの文字入力でハマった

シングルクォーテーションだけ入力チェックが正しく動かない

inputタグへの入力文字の制御を行なっていて、PCブラウザやAndroidでは上手くいくのに、iOSだけシングルクォーテーションがうまくいかずハマりました。

keydownイベントでは問題ない

keydownイベントのevent.keyを確認しましたが文字コード0x27「'」で問題なく処理されます。

inputイベントに問題あり

event.target.valueをログ出ししていて、文字コードが0x27「'」でないことに気付きました。
入力の一文字目は\u2018「‘」、二文字目以降は\u2019「’」に勝手に変換されるようです。

下記のコードで変換かけてあげて回避できました。

event.target.value = event.target.value.replace(/[\u2018\u2019]/g, '\'');

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

Gridsomeを触ってみたメモ

JAMstackでVue.js以上Nuxt.js未満なCMS?のイメージのGridsomeを触ってみたのでメモ。

v14で試そうと思ったらうまくいかなかったのでv12にして試したら上手く行きました。(後述)

https://gridsome.org/docs/

チュートリアルをなぞってみる

コマンドラインツールのインストール

$ npm i -g @gridsome/cli

ここが既に重い印象。(ネガ

ブログプロジェクトを作成

$ gridsome create n0bisuke-app

ここでsharpのビルドエラー

$ gridsome create n0bisuke-app

内部でsharpという画像のリサイズとかをしてくれるモジュールを使ってるみたいで、このモジュールがNode.js v14だとビルドが通らなかったです。

https://www.npmjs.com/package/sharp

省略

info sharp Using cached /Users/n0bisuke/.npm/_libvips/libvips-8.8.1-darwin-x64.tar.gz
prebuild-install WARN install No prebuilt binaries found (target=14.2.0 runtime=node arch=x64 libc= platform=darwin)
gyp info it worked if it ends with ok
gyp info using node-gyp@5.1.0
gyp info using node@14.2.0 | darwin | x64

省略

取り急ぎNode.js v12を利用したらビルドが通りました。

ローカル起動

プロジェクト内で以下のコマンドで起動します。

$ gridsome develop
 DONE  Compiled successfully in 4292ms                                           2:10:48


  Site running at:                                         
  - Local:                 http://localhost:8080/          
  - Network:               http://192.168.2.100:8080/      

  Explore GraphQL data at: http://localhost:8080/___explore

http://localhost:8080で起動しました。

ビルド

以下のコマンドでビルドができます。 distフォルダが出来上がるのでこれをそのままどこかに載せれば良いみたいです。

$ gridsome build
Gridsome v0.7.14

Initializing plugins...
Load sources - 0s
Create GraphQL schema - 0.03s
Create pages and templates - 0.05s
Generate temporary code - 0.04s
Bootstrap finish - 2.22s
Compile assets - 8.7s
Execute GraphQL (3 queries) - 0s
Write out page data (3 files) - 0s
Render HTML (3 files) - 0.27s
Process files (0 files) - 0s
Process images (9 images) - 1s


  Done in 12.38s

スクリーンショット 2020-05-21 2.17.08.png

vercelでデプロイ

公式ドキュメントにもありますが、

プロジェクトのルートでvercelコマンドを実行するだけでした。カンタン。

$ vercel

対話式で聞かれますが全てエンターで大丈夫でした。↓ログです。

Set up and deploy “~/Documents/ds/3_prototypes/n0bisuke-app”? [Y/n] y
? Which scope do you want to deploy to? n0bisuke
? Link to existing project? [y/N] n
? What’s your project’s name? n0bisuke-app
? In which directory is your code located? ./
> Upload [====================] 99% 0.0sAuto-detected project settings (Gridsome):
- Build Command: `npm run build` or `gridsome build`
- Output Directory: dist
- Development Command: gridsome develop -p $PORT
? Want to override the settings? [y/N] n
?  Linked to n0bisuke/n0bisuke-app (created .vercel and added it to .gitignore)
?  Inspect: https://vercel.com/n0bisuke/n0bisuke-app/fx37uus5b [3s]
✅  Production: https://n0bisuke-app.now.sh [copied to clipboard] [41s]
?  Deployed to production. Run `now --prod` to overwrite later (https://zeit.ink/2F).
?  To change the domain or build command, go to https://zeit.co/n0bisuke/n0bisuke-app/settings

distフォルダに移動してからかなぁとか思ってましたがルートの位置で大丈夫です。vercel側がdistフォルダを見てなのかpackage.jsonを見てなのか判断してくれます。

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

ハンズフリーで考えを書き出す試み(GAS)

やったこと

PCに向かってしゃべった内容を認識しブラウザに書き出します。

エンターという言葉を認識すると改行し、また要点という言葉を認識すると■要点の下に書き込み位置を移動します。

※音声入力で改行、書き込み位置の移動をしてる様子です。
GAS-record-qiita.gif

背景

考えをまとめるとき、思いつくことをPCで書き出して(タイピングして)してまとめることが多いので、ハンズフリーでできないものかとGASで作ってみました。
茶碗洗いしてるときや、アイロンかけてるときに人と話してる感覚で考えを書き出せたらなと。

結果

実用的なレベルには至りませんでした。

  • タイピングに比べ入力に時間がかかる
  • 誤変換が多い

といった理由で、タイピングに比べ非常にストレスでした。

ただ、

  • 音声の認識速度を上げる(前後の文脈をあまり考慮させず、認識した言葉をすぐに確定させる)
  • Cloud Speech-to-Textとか使ってもう少し変換精度を上げる

などができれば、ある程度使えるかもしれません。

環境

  • 実行環境
    • chrome (PCで試しましたが多分スマホでも使えます)
  • プログラム
    • GAS
    • javascript
    • Web Speech API

コード

手順は以下です

  1. googleスプレッドシートなどからスクリプトを作成する
  2. コード.gsを編集し、index.htmlを新規作成する
  3. 下記コードをコピペ
  4. 公開>アプリケーションとして導入>更新
  5. 表示されたURLにアクセス
  6. startボタンを押す

特定の言葉を認識した場合、以下の処理をします。
エンター・・・改行
ブレスト・・・■ブレスト の下に書き込み
要点・・・■要点 の下に書き込み
まとめ・・・■まとめ の下に書き込み

コード.gs
var spreadsheet = SpreadsheetApp.getActive(); //シートを取得
var sheet_rec = spreadsheet.getSheetByName("rec");
var last_row = 1;

/** include(html名)でhtmlファイルを読み込む */
function include(filename) {

    return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

/** index.htmlを開く */
function doGet() {
    var template = "index";

    return HtmlService.createTemplateFromFile(template)
        .evaluate()
        .setSandboxMode(HtmlService.SandboxMode.IFRAME);
}
index.html
<button id="start-btn">start</button>
<button id="stop-btn">stop</button>
<div id="write-sheet">
  <p>■ブレスト</p>
  <div id="brest"></div>
  <p>■要点</p>
  <div id="point"></div>
  <p>■まとめ</p>
  <div id="summary"></div>
</div>

<script>
  const startBtn = document.querySelector('#start-btn');
  const stopBtn = document.querySelector('#stop-btn');
  const writeDiv = document.querySelector('#write-sheet');
  const brestDiv = document.querySelector('#brest');
  const pointDiv = document.querySelector('#point');
  const summaryDiv = document.querySelector('#summary');

  SpeechRecognition = webkitSpeechRecognition || SpeechRecognition;
  let recognition = new SpeechRecognition();

  recognition.lang = 'ja-JP';
  recognition.interimResults = true;
  recognition.continuous = true;

  let finalTranscript = ''; // 確定した(黒の)認識結果
  let writePosition = brestDiv;

  recognition.onresult = (event) => {
    let interimTranscript = ''; // 暫定(灰色)の認識結果
    for (let i = event.resultIndex; i < event.results.length; i++) {
      let transcript = event.results[i][0].transcript;
      if (event.results[i].isFinal) {
        finalTranscript += transcript;
      } else {
        interimTranscript = transcript;
      }
    }
    finalTranscript = finalTranscript.replace('エンター', '<br>');

    // TODO:1回の書き込みで2箇所への移動はできない問題解決
    if (finalTranscript.indexOf('ブレスト') != -1) {
      // 移動前の書き込み
      writePosition.innerHTML = finalTranscript.substr(0, (finalTranscript.indexOf('ブレスト')));
      // 位置フラグ変更
      writePosition = brestDiv;
      // 移動後の書き込み
      finalTranscript = writePosition.innerHTML + finalTranscript.substr((finalTranscript.lastIndexOf('ブレスト')));
      finalTranscript = finalTranscript.replace('ブレスト', '');
    } else if (finalTranscript.indexOf('要点') != -1) {
      writePosition.innerHTML = finalTranscript.substr(0, (finalTranscript.indexOf('要点')) );
      writePosition = pointDiv;
      finalTranscript = writePosition.innerHTML + finalTranscript.substr((finalTranscript.lastIndexOf('要点')));
      finalTranscript = finalTranscript.replace('要点', '');
    } else if (finalTranscript.indexOf('まとめ') != -1) {
      // 移動前の書き込み
      writePosition.innerHTML = finalTranscript.substr(0, (finalTranscript.indexOf('まとめ')));
      // 位置フラグ変更
      writePosition = summaryDiv;
      // 移動後の書き込み
      finalTranscript = writePosition.innerHTML + finalTranscript.substr((finalTranscript.lastIndexOf('まとめ')));
      finalTranscript = finalTranscript.replace('まとめ', '');
    }

    writePosition.innerHTML = finalTranscript + '<i style="color:#ddd;">' + interimTranscript + '</i>';

  }

  startBtn.onclick = () => {
    recognition.start();
  }
  stopBtn.onclick = () => {
    recognition.stop();
    alert("読み取り終わり");
  }
</script>

以上です。
何か気づいた点あればコメントください。


メモ

このプログラムだと、ページを更新すると記入内容がリセットされてしまうので、スプレッドシートに結果を記入できると便利かもしれません。

下記のようなコードで行けると思ったのですが、htmlからgasの関数を呼び出せずエラーとなりました。
暇なときに解決しようと思います...。

コード.gs
var spreadsheet = SpreadsheetApp.getActive(); //シートを取得
var sheet_rec = spreadsheet.getSheetByName("rec");
var last_row = 1;

function writeHTML(HTMLcontent) {
    last_row = sheet_rec.getLastRow(); // 空白でない最終行の位置を取得
    sheet_rec.getRange(last_row + 1, 2).setValue(HTMLcontent);
}
index.html
   stopBtn.onclick = () => {
     recognition.stop();
     google.script.run.withSuccessHandler(function () {
       alert("読み取り終わり");
     }).writeHTML(writeDiv.innerHTML);
   }

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

JavaScriptでのイベントについて

イベントとは

結論、HTML要素に対して処理要求することです。
例えば、
「ユーザーがブラウザ上のボタンをクリックした」
これはクリックしたというのがイベントに当たります。

イベント駆動

JSでは「イベント」が発生したコードが実行される仕組みを持っています。
このイベントを取得するためにはノードに対して処理を書く必要があります。

イベントリスナ

ではどのようにして処理を書くのか。
addEventListenerメソッドを使います

(ノードオブジェクト).addEventListener("イベント名","関数");

これでノードオブジェクトにイベントが起きた時、関数が実行する仕組みです。
実際に具体例でみていきましょう。
JavaScriptPractice.png
このようなウェブページがあるとして、イベントを起こしてみましょう。

let btn = document.querySelector("a#btn-square");

function hello() {
  console.log("Hello world");
}

btn.addEventListener("click", hello);

本来はこれでHello Worldが出力されるはずですが、
以下のようなエラーが出ることもあります。
JavaScriptPractice.png
これはscriptのタグがどこに書いてあるかに関わってきます。
基本的にscriptを読み込む記述はHTMLのheadタグ内に書き込みます。
ここでもプログラミングの原理原則を思い出してみてください。
上から順番に読み込みます。
その際にボタンのコードが読み込まれる前に
イベントを読み込むためのコードが読み込まれてしまうので
エラーが出てしまうと言った現象です。

なので以下のようなメソッドを使いましょう。

window.onload = function() { 処理 };
window.addEventListener('load', function() { 処理 });

2通りのパターンがあります。今回は②の方法を使います。
これはページの読み込みが終わったら実行するようになります。
なので先ほどの記述を以下のように訂正します。

function HelloWithButton() {
let btn = document.querySelector("a#btn-square");

function hello() {
  console.log("Hello world");
}

btn.addEventListener("click", hello);
}

window.addEventListener("load", HelloWithButton);

そうすると、以下の結果になります。
JavaScriptPractice.png
では、手順をまとめます。

まとめ

①DOMツリーからノードを取得
②JSでやりたい処理内容を書く
③イベントを発火でHTMLを動かす

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

DockerでMySQLを起動して、Intelij IDEAで接続する方法

DockerでMySQLを起動して、Intelij IDEAにdockerプラグインを導入して接続する方法を紹介します。画像多めです。

作業環境

OS:Windows 10
エディション:HOME
バージョン:2004

事前準備

①Windowsを最新版にアップデートする

下記のサイトを参考に、Windows 10 HOMEの場合は必ず最新版までアップデートしておきます。
https://www.atmarkit.co.jp/ait/articles/1701/07/news037.html
最新版になるまで、何回もやる可能性があります。下のような画面になれば、OKです。
202005191040.png

②IntelliJ IDEAのインストールする

以下のサイトを参考にIntelliJ IDEAの Community Edition をインストールし、日本語化します。
https://sukkiri.jp/technologies/ides/intellij-idea/intellij-idea-win.html
※Editionが2つあるので、注意してください。
202005241417.png

③Dockerをインストールする

以下のサイトを参考に、Dockerをインストールします。
https://techracho.bpsinc.jp/ebi/2020_03_27/90477

Windows 10 HOMEの場合は、インストール方法が特殊なので、下記のサイトを参考にインストールしてください。
https://tech.guitarrapc.com/entry/2020/04/21/034236

④Dockerの設定

Dockerのインストールが終わったら、起動して、上の歯車のアイコンを押して、設定を開きます。
202005191315.png
「General」の中にある「Expose daemon on …」をオンにし、下にある「Apply & Restart」を押します。
※Dockerの画面が出ない場合は、ここからアクセスします。
202005190946.png

サンプルソースをダウンロードする

今回は下記のサイトから、サンプルコードをダウンロードします。
https://github.com/miyabayt/spring-boot-doma2-sample
下の画像にように、「Clone or download」という緑のボタンを押すと、下のような画面が出てくるので、「Download ZIP」を押してダウンロードします。
202005191300.png
ZIPファイルをダウンロードして、デスクトップなどに解凍してから、Cドライブ直下に移動してください。
202005200019.png
私の環境下では、Cドライブ直下に解凍すると、下記のように解凍されました。元のフォルダの構成がとは異なるので、エラーの原因になります。上記の画像と同じ構成になるように解凍してください。
202005192249.png

IntelliJ IDEAにプラグインを導入する

事前準備が完了したら、IntelliJ IDEAに4つプラグインを導入します。

①Lombok pluginをインストールする

「構成」→「プラグイン」を押します。
202005191058.png
下記のような、プラグインを追加する画面に変わります。
202005191100.png
赤枠で囲ったところに「Lombok」と入力すると プラグインが表示されるので、インストールボタンを押して、インストールします。
202005191103.png

②Eclipse Code Formatterをインストールする

次に「Eclipse Code Formatter」と入力すると プラグインが表示されるので、インストールボタンを押して、インストールします。
202005191218.png

③Dockerをインストールする

次に「Docker」と入力すると プラグインが表示されるので、インストールボタンを押して、インストールします。
202005191223.png

④Python Community Editionをインストールする

次に「Python Community Edition」と入力すると プラグインが表示されるので、インストールボタンを押して、インストールします。
202005191226.png
※このプラグインはIntelliJ IDEAからおすすめされて入れたので、不要の可能性もあります。
プラグインのインストールが終わったら、OKを押すと前の画面に戻ります。

導入したプラグインを設定する

次にプラグインの設定をしていきます。
「構成」→「設定」を押し、設定の画面を開きます。
202005191056.png

①Lombok pluginの設定

左側の「ビルド、実行、デプロイ」を押し、「コンパイラー」を開き、その中の「注釈プロセッサー」を押します。
202005191016.png
右側の画面が変わるので、「注釈処理を使用可能にする」にチェックを入れ、上の画面のようになればOKです。

②Eclipse Code Formatterの設定

左側の「その他の設定」→「Eclipse Code Formatter」を押します。
202005191020.png
右側の画面が変わるので、「Use the Eclipse code formatter」にチェックを入れ、「Eclipse Java Formatter config file」の右の「参照」を押すと、ファイル選択の画面が出てきます。
202005191021.png
先程解凍した「spring-boot-doma2-sample-master」というフォルダを開き、その中の「eclipse-formatter.xml」を選びます。

eclipse-formatter.xmlまでのパスは、Cドライブ直下に「spring-boot-doma2-sample-master」がある場合は、下記の通りになります。
C:\spring-boot-doma2-sample-master\eclipse-formatter.xml

③Dockerの設定

左側の「ビルド、実行、デプロイ」を押し、「Docker」を押します。
202005191014.png
「TCPソケット」を選び、接続が完了すればOKです。
接続できない場合は、まずはDockerが起動しているかどうか確認してください。

以上でプラグインの設定は終了です。

IntelliJ IDEAの設定を変更する

プラグインの設定が完了したら、IntelliJ IDEAの設定を変更していきます。

①bootRunを実行している場合でもビルドされるようにする

Intellij上で「Ctrl+Shift+A」を押すと、小さい画面が出てくるので、「レジストリー」と入力します。
202005191328.png
すると下に「レジストリー」と出てくるので、ここを選びます。
202005191331.png
「compiler.automake.allow.when.app.running」という項目探して、右側のチェックボックスにチェックを入れます。終わったら、「閉じる」を押します。

②コンソール出力の文字化けを防ぐ

Windowsの場合は、コンソール出力が文字化けするため、C:¥Program Files¥JetBrains¥IntelliJ Idea xx.x.x¥binの中にある「idea64.exe.vmoptions」というファイルを開きます。
202005191338.png
一番下の行に「-Dfile.encoding=UTF-8」を追記して保存します。
202005191339.png

③Java11を設定する

「構成」→「プロジェクト構造」を選びます。
202005191345.png
設定が開くので、「プロジェクト設定」→「プロジェクト」を開き、「プロジェクトSDK」と「プロジェクト言語レベル」をそれぞれ、「11」を選びます。
202005191352.png
もし、「プロジェクトSDK」で「11java version “11.0.7”」といった項目が出ない場合は、以下のページより「Java SE 11 (LTS)」の「JDK Download」からダウンロードして、インストールしてください。
https://www.oracle.com/java/technologies/javase-downloads.html

※詳しいインストール手順が必要な場合はこちらを参考にしてみてください。
https://sukkiri.jp/technologies/processors/jdk/jdk-win_install.html

DockerでMySQLを起動する

Dockerを起動します。Dockerの画面が出ない場合は、ここからアクセスします。
202005190946.png
白いクジラのアイコンの上で右クリックをして、「Dashboard」を選びます。
202005190948.png
Dockerの画面が出てきたら、赤枠で囲った▶部分を押します。
202005190949.png
MySQLが出てくるので、赤枠で囲ったボタンを押すと、サーバーの起動が始まります。
202005190952.png
下の画面が出てくればOKです。
202005190953.png

初回起動の場合

初回起動で、上記の画面が出てこない場合は、以下の手順でMySQLを起動してください。
202005191610.png

①docker-compose.ymlが存在するフォルダを確認する

サンプルコードの中には、DockerでMySQLを起動するための設定ファイル「docker-compose.yml」が含まれています。
202005200106.png
Cドライブ直下に「spring-boot-doma2-sample-master」がある場合は、「C:\spring-boot-doma2-sample-master\docker」にあります。

②Windows Power Shell を管理者権限で起動する

Dockerにコンテナなどを追加するので、タスクバーのwindowsマークの上で右クリックして、Windows Power Shell を管理者権限で起動します。
202005200041.png

③Windows Power ShellからDockerのMySQLを起動する

急にWindows Power Shellが出てきて、難しそうですが、入力するのは以下の2行だけです。

cd C:\spring-boot-doma2-sample-master\docker
docker-compose up

先程の「docker-compose.yml」というファイルが存在するフォルダまで移動します。Cドライブ直下に「spring-boot-doma2-sample-master」がある場合は、以下のようになります。

cd C:\spring-boot-doma2-sample-master\docker

“docker-compose”というコマンドが使えるかどうかを確かめるために、以下のように入力します。

docker-compose --version

以下のようにバージョンが表示されればOKです。

docker-compose version 1.25.5, build 8a1c60f6

次に以下のように入力して、MySQLを起動します

docker-compose up

エラーが出てうまく行かない場合は、docker-compose.ymlが存在するフォルダまで移動してから、上記のコードを実行しているかどうか、確認してください。
このコマンドが終了すると、Dockerの画面が下記のように変わります。
202005190953.png
簡単にDockerでMySQLを起動することができますね。

Dockerで起動したMySQLに、Intelij IDEAを接続する

Intelij IDEAを起動して、「オープンまたはインポート」を選びます。
202005191417.png
Cドライブ直下に解凍した「spring-boot-doma2-sample-master」を選び、OKを押します。
202005191420.png
下のような画面が出てくるので、「サービス」を選びます。
202005190959.png
画面が変わると「Docker」が出てくるので、選ぶと接続が始まります。202005191000.png
接続が完了すると、以下のような画面になります。
202005191001.png
以上で、Dockerで起動したMySQLに、Intelij IDEAで接続することができました。

サンプルコードを動かす

ここからは、サンプルコードを動かしていきます。
サンプルコードを動かす前に、プラグインの設定とJavaの設定を確認してください。
確認が終了したら、Intelij IDEAの「ターミナル」を開きます。
202005191002.png

①バージョン確認

ターミナルが起動したら、以下のように入力します。

cd C:\spring-boot-doma2-sample-master

次に、以下のようにコードを入力します。

gradle -v

以下のように、バージョンが表示されます。

------------------------------------------------------------
Gradle 6.3
------------------------------------------------------------

Build time:   2020-03-24 19:52:07 UTC
Revision:     bacd40b727b0130eeac8855ae3f9fd9a0b207c60

Kotlin:       1.3.70
Groovy:       2.5.10
Ant:          Apache Ant(TM) version 1.10.7 compiled on September 1 2019
JVM:          11.0.7 (Oracle Corporation 11.0.7+8-LTS)
OS:           Windows 10 10.0 amd64

②サンプルコードを動かす

次にサンプルコードを動かします。以下のように入力します。

gradlew composeUp

プログラムが終わったら、以下のように入力します。

gradlew :sample-web-admin:bootRun

しばらく待つと、95%で止まりますが、そのままでOKです。
202005191007.png
これでアプリが起動したので、http://localhost:18081/adminにアクセスします。
screencapture-localhost-18081-admin-login-2020-05-19-10_09_21 (1).png
上のような画面が出てきたら、成功です。お疲れさまでした。

この記事は先輩エンジニアからアドバイスを受け、個人ブログから移植しました。おかしいところなどありましたら、ご指摘いただけると幸いです。

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

Playwrightで簡単なクロスブラウザテストを書いてみた

2020 年 5 月の頭頃、Playwright が 1.0 に到達した際に、作成チームによって書かれた記事がホットエントリーになっていました。
https://medium.com/@arjunattam/fast-and-reliable-cross-browser-testing-with-playwright-155c0e8a821f

タイミング良くクロスブラウザテストを書く機会があったので、簡単なテストを書いてみました。
※ ブラウザ毎に挙動の異なる API のテストだと良かったのですが、こちらで実施するのはボタンをクリックして innerText の変更をテストするのみです。

ソースはこちらにあります。
https://github.com/nnashiki/playwright_sample

Playwrigh とは

Chromium、Firefox、Safari を簡単にクロスブラウザテストするための、Node library とのことです。

Playwright is a Node library to automate Chromium, Firefox and WebKit with a single API. Playwright is built to enable cross-browser web automation that is ever-green, capable, reliable and fast.

https://playwright.dev/
https://github.com/microsoft/playwright

冒頭に挙げた medium の記事では、Chromium、WebKit、FireFox、WebDriver などに協力を得た(巨人の肩に乗ってるんだぜ!)とあります。
アーリアダプターはこんな感じに充実しています。

テスト対象

"ボタン押下前"と書かれているボタンを押すと"ボタン押下前"に変わる簡単なものです。

以下コマンドでサービスを立ち上げます。
docker run --rm --name sample -p 8090:80 -v $PWD/src/app:/usr/share/nginx/html -d nginx

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <title>ボタンテスト</title>
    <link rel="icon" href="favicon.ico" />
    <!- bootstrap4 ->
    <link
      rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />
    <!- bootstrap4 ->
  </head>
  <body>
    <div class="container">
      <button id="test_button1" type="button" class="btn btn-primary">
        ボタン押下前
      </button>
    </div>
    <script type="text/javascript" src="./index.js?0.0"></script>
    <!- bootstrap4 ->
    <script
      src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
      integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
      integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
      integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
      crossorigin="anonymous"
    ></script>
    <!- bootstrap4 ->
  </body>
</html>
index.js
const button1 = document.getElementById("test_button1");
button1.addEventListener("click", (e) => {
  document.getElementById(e.target.id).innerText = "ボタン押下後";
});

テストコード

利用コード

test runner は Jest を使用しています。
各ブラウザで自動操縦でボタンをクリックして、表記の変化を確認するテストです。
ブラウザ単位でテスト結果を確認したいので分割しました。

src/test/button_action.test.js
const playwright = require("playwright");

// #test_button1 のボタンを押下して、ボタンの表記を取得する
const croll_click = async (browserType) => {
  const browser = await playwright[browserType].launch();
  const context = await browser.newContext();
  const page = await context.newPage();
  await page.goto("http://localhost:8090");
  await page.click("#test_button1");
  const click_after_text = await page.evaluate(() => {
    return document.getElementById("test_button1").innerText;
  });
  await browser.close();
  return click_after_text;
};

test("click chromium", async () => {
  let result = await croll_click("chromium");
  expect(result).toMatch(/ボタン押下後/);
});

test("click firefox", async () => {
  let result = await croll_click("firefox");
  expect(result).toMatch(/ボタン押下後/);
});

test("click webkit", async () => {
  let result = await croll_click("webkit");
  expect(result).toMatch(/ボタン押下後/);
});

page の中での javascript(webAPI)の実行は以下を参考にしました。
https://playwright.dev/#version=v1.0.2&path=docs%2Fverification.md&q=evaluating-javascript

Jest の set up、tear down でブラウザの呼び出しと終了をするには以下が参考になります。
https://playwright.dev/#version=v1.0.2&path=docs%2Ftest-runners.md&q=jest--jasmine

環境構築

Playwright、 Jest を install します。

npm install --save-dev playwright
npm install --save-dev jest

実行してみる

jest src/test を実行します。
ブラウザ毎のテストを実行することができました。

> jest src/test

 PASS  src/test/button_action.test.js (7.1 s)
  ✓ click chromium (1327 ms)
  ✓ click firefox (4186 ms)
  ✓ click webkit (1073 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        7.594 s
Ran all test suites matching /src\/test/i.

tips

そのまま実行すると、Jest の default time out が引っかかることがありました。

 FAIL  src/test/button_action.test.js (8.725 s)
  ✓ click chromium (2076 ms)
  ✕ click firefox (5002 ms)
  ✓ click webkit (1018 ms)

  ● click firefox

    : Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Error:

      22 | });
      23 |
    > 24 | test("click firefox", async () => {
         | ^
      25 |   let result = await croll_click("firefox");
      26 |   expect(result).toMatch(/ボタン押下後/);
      27 | });

      at new Spec (node_modules/jest-jasmine2/build/jasmine/Spec.js:116:22)
      at Object.<anonymous> (src/test/button_action.test.js:24:1)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 passed, 3 total
Snapshots:   0 total
Time:        9.605 s, estimated 12 s
Ran all test suites.

そこで、timeout を伸ばして実行しました。
jest src/test --testTimeout=10000
5000mm を超えても実行できています。

$ jest src/test --testTimeout=10000
 PASS  src/test/button_action.test.js (11.592 s)
  ✓ click chromium (3831 ms)
  ✓ click firefox (5042 ms)
  ✓ click webkit (1948 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        12.957 s
Ran all test suites matching /src\/test/i.

まとめ

puppeteer は使った事が無いので、比較はできませんが利用の開始はとても簡単でした。
Playwright は普及しそうなので、今後も追ってみたいと思います。

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

TypeScriptで学ぶデザインパターン〜Flyweight編〜

対象読者

  • デザインパターンを学習あるいは復習したい方
  • TypeScriptが既に読めるあるいは気合いで読める方
    • いずれかのオブジェクト指向言語を知っている方は気合いで読めると思います
  • UMLが既に読めるあるいは気合いで読める方

環境

  • OS: macOS Mojave
  • Node.js: v12.7.0
  • npm: 6.14.3
  • TypeScript: Version 3.8.3

本シリーズ記事一覧(随時更新)

Flyweightパターンとは

インスタンスを共有するためのパターンです。

サンプルコード

Flyweightパターンで作られたクラス群がどんなものになるのか確認していきましょう。

今回は、題材として"数値を出力する簡単な機能"を想定します。GitHubにも公開しています。

modules/Number.ts

数値を表現するクラスです。

Number.ts
export default class Number {
  private value: number;

  constructor(value: number) {
    this.value = value;
  }

  getValue(): number {
    return this.value;
  }
}

どのメソッドも特に解説の必要はないので割愛します。

modules/NumberFactory.ts

数値インスタンスを作成するクラスです。

NumberFactory.ts
import Number from "./Number";

export default class NumberFactory {
  private static singleton: NumberFactory = new NumberFactory;
  private numbers: { [key: number]: Number; } = {};

  private constructor() {}

  static getInstance(): NumberFactory {
    return NumberFactory.singleton;
  }

  getNumber(value: number): Number {
    if (!(value in this.numbers)) {
      console.log(value + ': インスタンス生成');
      this.numbers[value] = new Number(value);
    }
    return this.numbers[value];
  }
}

getInstanceにある通り、Singletonパターンになっています。
getNumberではNumberインスタンスを生成して返却します。ただ数値が重複していた場合は新たにインスタンスを作成せず、インスタンスを保持しているプロパティであるnumbersから返却します。

modules/Main.ts

Flyweightパターンで作成されたクラス群を実際に使用している処理です。

Main.ts
import NumberFactory from "./modules/NumberFactory";

const numberFactory: NumberFactory = NumberFactory.getInstance();
const firstNumber: number = numberFactory.getNumber(1).getValue();
const secondNumber: number = numberFactory.getNumber(2).getValue();
const thirdNumber: number = numberFactory.getNumber(3).getValue();
const fourthNumber: number = numberFactory.getNumber(2).getValue();

console.log(firstNumber);
console.log(secondNumber);
console.log(thirdNumber);
console.log(fourthNumber);

firstNumberSecondNumberThirdNumberは新たにインスタンスが作られていますが、fourthNumberだけは新たにインスタンスは作られません。secondNumberと同様だからです。

クラス図

ここまでFlyweightパターンで作られたクラス群を1つずつ確認してきました。次にクラス図を示します。Flyweightパターンの全体像を整理するのにお役立てください。

Flyweight.png

  • Flyweight: サンプルコードではStateインターフェースが対応
  • FlyweightFactory: サンプルコードではDayStateクラスNightStateクラスが対応
  • Client: サンプルコードではClockContextが対応

LucidChartを使用して作成

解説

最後に、このデザインパターンの存在意義を考えます。

インスタンスを作成するとメモリを使用します。メモリは無限に使えるわけではないので、複数作成する必要のないインスタンスを共有して使用できることがメリットです。インスタンスを共有するので様々なところで呼ばれている場合は修正時の影響範囲が大きいという問題もあるので注意が必要です。

補足

サンプルコードの実行方法はこちらと同様です。

参考

あとがたり

そろそろ書くことなくなってきたわ。とりあえずあともう少しGoFデザインパターン完走頑張るぞよ。

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