Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all 8843 articles
Browse latest View live

Gulp環境構築にて「AssertionError [ERR_ASSERTION]: Task function must be specified」の原因はgulp v4にバージョンアップした事による仕様変更

$
0
0

始めての投稿です。
1. エラー内容、背景
2. 環境
3. 解決方法
4. 原因
の順に記載して行きます。

1. エラー内容、背景

gulpにてSassを自動コンパイルしたかったため、Homebrew、Node.js、gujpをインストール。
gulp自動監視を起動しようと、コマンド実行すると、

qiita.rb
'AssertionError [ERR_ASSERTION]: Task function must be specified'

と言うエラーが。

2. 環境

mac

  • npm 6.7.0
  • Node.js 12.13.0
  • CLI 2.2.0
  • gulp 4.0.3

3. 解決方法

Node.jsgulpのバージョンを下げる。

  • Node.js 12.13.0
  • gulp 4.0.3

  ↓

  • Node.js 11.15.0
  • gulp 3.9.1

Node.jsのバージョンの下げ方は、

qiita.rb
$npminstall-gn

を実行した後に、

qiita.rb
$sudon11.15.0

参考:Node.jsのバージョンを管理するライブラリ「n」

gulpのバージョンの下げ方は、

qiita.rb
$sudonpminstall--save-devgulp@3.9.1

4. 原因

①gulpのバージョンが4に上がったことにより、gulpfile.jsのgulp.task()の引数の指定方法が変わった。
 gulp v4へのバージョンアップによる変更点などはこちらのページがわかりやすくまとめられています。
 gulp v4.0.0がプレリリース! 移行方法と変更点まとめ
②gulp v3とNode.js v12の組み合わせは動かない
 gulp v3とNode.js v12で実行した場合、

qiita.rb
puts'ReferenceError: primordials is not defined'

 と言うエラーが。
 すみませんが、v12ではなぜ動かないのかはわからず、以下のページを参考にし、11.15.0だと動くことを確認しました。
 参考:ReferenceErrorを修正する方法

以上、AssertionError [ERR_ASSERTION]: Task function must be specifiedの解決方法でした。


ant+をraspberry piで使う(node.js)

$
0
0

ラズパイでant+を使います

参考
https://www.johannesbader.ch/2014/06/track-your-heartrate-on-raspberry-pi-with-ant/

仕様物

raspberry pi AZ4Uを扱う

sudo apt-get install -y libusb-1.0-0-dev libudev-dev

/etc/udev/rules.d/garmin-ant2.rulesを新規追加

/etc/udev/rules.d/garmin-ant2.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fcf", ATTRS{idProduct}=="1008", RUN+="/sbin/modprobe usbserial vendor=0x0fcf product=0x1008", MODE="0666", OWNER="pi", GROUP="root"

nodejsのインストール

sudo apt install -y nodejs npm
sudo npm install n -g
sudo n stable
sudo apt remove --purge nodejs npm

適当にプログラムをかく

set up

mkdir -p ~/project/ant
cd ~/project/ant
npm init
npm install ant-plus

example

index.jsに書く

index.js
constAnt=require("ant-plus");conststick=newAnt.GarminStick2();constsensor=newAnt.HeartRateSensor(stick);letcount=0;sensor.on("hbData",function(data){count+=1;console.log(count,data.DeviceID,data.ComputedHeartRate);});stick.on("startup",function(){console.log("on start up");sensor.attach(0,0);});asyncfunctionmain(){if(!stick.open()){console.log("Stick not found!");return;}}main();

start

node index.js

Node.jsのCloud Tasksクライアントでタスク作成をリトライする

$
0
0

Node.jsのCloud Tasksクライアントを使っていると、たまにgRPCのエラーでタスクの作成に失敗することがありました。リトライのための設定がややわかりにくかったので、リトライする方法を解説します。

バージョン

  • @google-cloud/tasks: 1.5.1
  • google-gax: 1.7.5

設定方法

ドキュメントにあるように、 CloudTasksClient.createTask()の第2引数に CallOptionsを渡すとリトライなどの設定を行えます。具体的な設定例は次の通りです。

constclient=newCloudTasksClient();constrequest={parent:client.queuePath(project,location,queue),task:{appEngineHttpRequest:{httpMethod:"POST",relativeUri:"/log_payload"}}};constoptions={retry:{retryCodes:[13// INTERNAL],// リトライ対象のエラーコードを1つ以上指定するbackoffSettings:{initialRetryDelayMillis:100,// 最初のリトライ間隔(ミリ秒)retryDelayMultiplier:1.3,// リトライ間隔の増加率maxRetryDelayMillis:60000,// リトライ間隔の最大値initialRpcTimeoutMillis:20000,// 最初のタイムアウト(ミリ秒)rpcTimeoutMultiplier:1,// タイムアウトの増加率maxRpcTimeoutMillis:20000,// タイムアウトの最大値totalTimeoutMillis:600000// 全体のタイムアウト(ミリ秒)}}};const[response]=awaitclient.createTask(request,options);

retry.retryCodesに1つ以上のエラーコードを指定しないとリトライされません。エラーコードは実際にタスク作成に失敗したときの例外に codeとして含まれています。一覧はgoogle-gaxのソースコードで確認できます。

retry.backoffSettingsに書いているのは、createTask()の第2引数を指定しなかった場合に使われるデフォルト値です。ドキュメントに記載がなかったので、デバッグ実行して確認しました。このデフォルト値は設定ファイルから生成されているようです。

終わりに

ちゃんとコードを読んだわけではないですが、タスクの作成は冪等な処理ではないので、安全側に倒して自動リトライしないのだと思われます。リトライする場合には、同じタスクが2個作られてしまっても問題ないような実装にするよう気をつけましょう。

Node.jsでログにUser IDを自動出力

$
0
0

背景

ECサイトなどを運用する場合、ログにユーザの識別IDが付与されていると調査時に行動を把握することが容易なため便利です。
そこで今回はNode.js(Express)で実現してみたいとおもいます。

実装

userId保持用の箱を準備

ログを出力する際、userIdを参照できるようにシングルトンなクラスを作成しておきます。

requestContext.js
classRequestContext{init(userId){this.userId=userId}getUserId(){returnthis.userId}}exportdefaultnewRequestContext()

Expressサーバ

下記の記述を、他のルーティングの上部に配置します。

express/app.js
importrequestContextfrom'path/to/requestContext'app.use((req,res,next)=>{if(!req.session.userId){// sessionにuesrIdがない場合は、uuidを設定req.session.userId=uuid.v4()}// 事前に作成したrequestContextに、userIdを設定requestContext.init(req.session.userId)next()})

※ session管理には、express-sessionを使っています。
next()では、下部に処理を委ねます。

winston

今回loggerにはwinstonを用います。
例ではtimestampとログレベルも出力してます。

logger.js
import{createLogger,format,transports}from'winston'importpathfrom'path'importrequestContextfrom'./requestContext'importformaDatefrom'date-fns/format'const{combine,timestamp,printf}=formatconstlogFormatter=printf(({level,message,timestamp})=>`[${timestamp}] [${level.toUpperCase()}] [${requestContext.getUserId()}] ${message}`)consttimestampFormatter=()=>formaDate(newDate().toLocaleString('ja-JP',{timeZone:'Asia/Tokyo'}),'YYYY-MM-DD HH:mm:ss')constlogger=createLogger({level:'info',format:combine(timestamp({format:timestampFormatter}),logFormatter),transports:[newtransports.Console(),newtransports.File({filename:'path/to/application.log'})]})exportlogger

検証

logger呼び出し例

importloggerfrom'path/to/logger'logger.error('test!!')

出力例

application.log
[2019-10-30 11:08:18] [ERROR] [6ffd3f48-c967-43ac-88e3-d6886c82d3a5] test!!

さくっとデザインガイドを作成するための Fractal

$
0
0

突然ですが、デザイナーとエンジニアの間で一番多く共有されるものって何でしょう?
(おそらく)そうデザインテンプレートです。

K.S.ロジャースの島袋です。

今回は、デザインガイドを作成するために Fractalを使用してみた所感や使い方を書いていきたいと思います。なお、あくまでエンジニア主導と目線による使用感になるので、デザイナーから見た場合は少し違った感想になるかとは思いますのであしからず。

デザインガイド作成のきっかけ

実は今回のデザインガイドはデザイナーとエンジニア向けではなく、HTMLほぼ初心者の方でもHTMLを構築できるようにするためでした。なので、導入部分のデザイナーとエンジニアの共有とは少し違いますが、分かりやすい指標を作る必要があったため、デザインガイドを作成しました。

簡潔に作成するためにいくつかのフレームワークを検討しましたが、一番導入が簡単そうな Fractalを使うことにしました。

インストールと起動

インストールはとても簡単で npmの環境があればコマンド一発で可能です。
が、依存関係をコントロールやしやすい yarnがおすすめです。

CLIツール

buildなどでコマンドを頻繁に叩くので、CLIツールのほうが便利です。

yarn global add fractal

参考:公式ドキュメント

起動

公式から引用すると

  1. ターミナルでプロジェクトを作るディレクトリに移動
  2. fractal new <project-name>でプロジェクトを作成
  3. 表示される質問に答える
  4. 質問に全部答えるとプロジェクトが作成されるので移動
  5. fractal start --syncで開発用サーバーの起動
  6. 表示されるURLで見れるよ

となります。簡単ですね。

ディレクトリ構造と要素

Fractalは2つの要素で構成されます。

  1. デザインテンプレートを構成する COMPONENTS
  2. ドキュメントを構成する DOCUMENTATION

ディレクトリ構造も上記に即しており、非常に分かりやすかったです。

├── src
│   ├── components
│   └── docs
└── package.json

参考:公式ドキュメント

COMPONENTS

ざっくりと言うと、テンプレートエンジンに Handlebarsを使用して、READMEとか書けるといったものです。Handlebars自体エンジニアであれば見覚えある方も多く、使いやすくなってます。
詳しい使い方は公式ドキュメントを参照いただければと思います。

今回使ってみてテンプレート毎にREADMEを書けるのは、汎用性があって使いやすいと思いました。下記のように注意事項やtipsを書き込んでおけるので、共有する上では非常に助かりました。
map.jpg

その他に変数データを別途JSON形式のファイルで保持できたりテンプレート毎にステータスを設定できたりと痒い所に手が届く機能も多いです。

DOCUMENTATION

こちらはテンプレートとかはなく、純粋にドキュメントを書いていく部分になります。
使い方はその時の次第になるかと思いますが、 docsディレクトリの最初のファイルがアクセス時のトップページになるので、目次やデザインガイド全体のルールを載せると良いかと思います。

もちろん単純な文字だけでなく、変数的な使い方をすることもできます。
詳しい使い方は公式ドキュメントを参照いただければと思います。

使ってみた所感

正直言うと、かなり良いものを見つけることができたと思います。導入も簡単で使い勝手も良いので、今後似たような案件の場合、Fractalを使用しても良いなと思いました。
もちろん、気になった点もいくつかあるので書いておきます。(いくつかはissue出してみようかな…)

良いと思った点

  • npm利用ですぐにインストール・起動ができる
  • 起動オプションにホットリロードがある
  • buildで destを作成できる
  • 機能もディレクトリ構造も簡潔で分かりやすい

気になった点

  • npmを利用するので純粋なデザイナーさんだけでは導入できなさそう?
  • ホットリロードはあるが、記述にエラーがある場合強制シャットダウンされる
  • お願いします→お願いし〼のように日本語が謎の変換される(おそらく文字コードあたりの設定のような気がする)

他のフレームワークも同じような機能はありますが、導入前に調べた限り 扱いが簡単という点ではFratalが一つ飛び抜けているかと思います。

今回はここで終わりになりますが、ご感想やご指摘ありましたらどうぞよろしくお願いします。

あとづけ

ちなみに弊社、Tech系以外にも会社ブログも掲載してますので、気になった方は是非どうぞ。
https://www.wantedly.com/companies/ks-rogers

nodenvでinstall出来るnodeのバージョンを確認したら最新が出てこなかった時

$
0
0

nodenvでnodeのバージョンを管理していてnodeのバージョンを変える必要がある時

node 9.11.2 を node 12.12.0にしたいなど

【インストール出来るnodeの一覧を表示】
nodenv install -l
【結果】
〜
12.12.0
chakracore-dev
chakracore-nightly
〜

12.12.0がない

このようにnodeのバージョンが表示されない時は「node-build」が古いからだと思われ、ここにどうすればいいのか書いてあります
https://github.com/nodenv/node-build

Upgrading
# Via Homebrew
$ brew update && brew upgrade node-build

# As a nodenv plugin
$ cd "$(nodenv root)"/plugins/node-build && git pull

nodenvのプラグインとして使用しているのでcdを使ってnode-buildの階層まで移動

そしてgit pullするといいらしいです

その後もう一度

【インストール出来るnodeの一覧を表示】
nodenv install -l
【結果】
〜
12.10.0
12.11.0
12.11.1
12.12.0
〜

をするとinstall出来るnodeのリストが更新されていると思うので、

node install 12.12.0

で入れたいバージョンをinstall出来るようになっています

Node.jsのパッケージマネージャの使い方(yarn)

$
0
0

免責事項

この記事は初心者視点でザックリとした説明をしています。正確性に欠ける可能性がございますが、ご了承ください。「明らかに違うよ」ということがありましたら、ご指摘くださると幸いです。

環境

OS:最新版ではないMacOS
VirtualBox:5.2.26
Vagrant:2.2.6
Ubuntu:ubuntu/bionic64 v20181129.0.0

目次

  1. yarnとは
  2. yarnでパッケージのインストール
  3. yarnでパッケージを作ってみる

1. yarnとは

yarnとはNode.jsで使うパッケージを管理するものですが、
この説明をする前にパッケージについて説明します。

パッケージ

パッケージはライブラリを包むもの。
パッケージマネージャはライブラリを包み、管理するものです。

ライブラリ

ライブラリは先人が作った処理です。
汎用的な処理を実装する際に使います。
例えば、「複数の値の中で一番大きい数字がどれか判定したい!」という時があったとします。

ただ、この処理を1から作るのは、簡単そうにありません。

そこで、ライブラリを使います。
Javascriptには標準で、Math.max() という()内に引数として与えた数字の中で、一番大きい数字を返す「標準ビルトインオブジェクトMathのmaxメソッド」がライブラリとして標準で備わっています。

Math.max(2,3,1);// expected output: 3

標準ビルトインオブジェクト...JavaScriptが標準で提供しているオブジェクト(ex: MathやDateなど)、ライブラリとして提供されています。
Node.jsドキュメント...Node.jsがバージョンごとに標準で、ライブラリを提供しています。

続いて、yarnについて説明していきます。
Node.jsが標準で備えているパッケージ管理ツールは npm(エヌピーエム)と呼ばれるものです。
npm公式サイトには誰かが作ったライブラリが公開されており、自由に利用することができます。

$ npm install パッケージ名

でパッケージをインストールすることができます。

2. yarnでパッケージのインストール

しかし、npmをさらに使いやすくしたパッケージ管理ツールyarnが誕生しました。
使い方としては、npmとほとんど一緒で、

$ yarn add パッケージ名

とします。
yarn addでパッケージをインストールすると、インストールを実行したディレクトリの中にあるnode_modulesというディレクトリに、パッケージがインストールされます。

node_modules ディレクトリにインストールされたnpmパッケージは、自動的に読み込まれ、そのディレクトリ内で以下のように利用することができます。

const変数=require('パッケージ名');

パッケージをrequire関数で取得することで、利用できるようになります。

3. yarnでパッケージを作ってみる

yarn(npm)では他人のパッケージを利用するだけでなく、自分でパッケージを作成しすることもできます。

$ mkdir パッケージ名のディレクトリ
$ cd パッケージ名のディレクトリ
$ yarn init

yarn initと入力すると、パッケージの設定画面がでてきます。

//パッケージ名を入力します。
name: (パッケージ名)
//バージョンの設定します。
version: (1.0.0)
//パッケージの説明を入力します。
description:
//ライブラリとして読み込まれるJavaScriptファイルを設定します。
entry point: (index.js)
//Gitリポジトリを公開する場合のURLを入力します。
repository url:
//npmに登録する際の著者の名前を設定します。
author:
//ライセンス名を入力します。
license: (MIT)
//公開の可否を入力します。
private:

パッケージの設定が成功すると以下のように表示されます。

success Saved package.json
Done in 9.20s.

では、ライブラリとして読み込まれるJavaScriptファイルを作成し、パッケージを作っていきます。
モジュールは関数型とオブジェクト型のどちらでも定義できます。

モジュールを関数型で定義

index.js
'use strict';module.exports=add(a,b){returna+b;};

としても、

モジュールをオブジェクト型で定義

index.js
functionadd(a,b){returna+b;}module.exports={add:add};

OR

index.js
module.exports={add:(a,b)=>{returna+b;}};

でも、パッケージとして使うことができます。

使用先のファイルでは、以下のように使います。

関数型で定義されたモジュールのパッケージを使うには

app.js
constadd =require(パスorパッケージ名);constresult=add(1,2);console.log(result);// expected output: 3

オブジェクト型で定義されたモジュールのパッケージを使うには

app.js
const変数 =require(パスorパッケージ名);constresult=変数.add(1,2);console.log(result);// expected output: 3

requireの引数には、パッケージがnpm公式サイトに公開されていたらパッケージ名を指定し、
自分のPC上にあるならパスを指定します。

参考

「N予備校 プログラミングコース」
https://www.nnn.ed.nico/
「[NodeJS] モジュール定義について学ぶ」
https://www.yoheim.net/blog.php?q=20150101

入門者のためのNode.js関連用語の意味と体系化

$
0
0

公式ドキュメントやQiitaの解説は入門者には意味不明だったので、翻訳するための知識が必要と思ってここにまとめておく。

関連記事
入門者のためのNode.js関連用語の意味と体系化
What's "Git" ?
What's "nvm" ?
What's "Node.js" ?
What's "npm" ?
What's "React" ?
What's "Next.js" ?

基礎知識がないと記憶が定着しない

まず、こんな記事をまとめる必要性を感じたキッカケについて。知識も経験を触りだけの初心者なりに公式チュートリアルを読みながら自分のサイトをNext.jsで再構築してみようと試みたところ、ある程度まではチュートリアルに言われた通りにやることは可能だったが、次第に「そのコードを書く意味は何?」という疑問が1ステップに1つついてくるようになり、疑問が山積みになったあたりで知識体系が完全崩壊し、学んできたことが記憶に定着しないという事態に陥った。つまり知識が蓄積されず仕舞いに終わったと言うことで、それならば今学習していることの知識が定着しないのであれば、さらにその下層の知識体系から構築する必要があると考えた。よって、Next.jsの前にNode.js、Node.jsの前にWEBアプリのアーキテクチャなどの基礎知識、というように疑問にぶち当たるたびに下層知識に学習をシフトしていった結果、とりあえず用語の意味の理解と体系化が必要だという結論に至った。

参考文献
Node.jsの実行モデルを理解するために必要な前提知識を説明していく

以下は、上記の参考文献を初心者なりに再解釈して書き直した感じの内容となっている。

システムコール

プログラミング言語にはファイルを扱う機能がある。ファイル管理はOSの仕事なので、プログラムでファイルを扱う際にはOSに処理を渡す必要がある。この流れをシステムコールという。システムコールはOSによって異なるが、POSIXに準拠したOSにおいてはファイルディスクリプタによってファイルにアクセスする基本的なシステムコールが用意されている。

-open:ファイルを開く
-read:ファイルから読み込む
-write:ファイルに書き込む
-close:ファイルを閉じる

POSIX

Porable Operationing System Interfaceの略で、UNIXの標準規格のファイル管理システム。

同期・非同期

同期処理では、呼び出し側が処理を呼び出すと処理が終わるまで返ってこない。例えば、ファイル読み込みであればファイルの内容を読み取るまで返ってこない。

非同期処理では、処理を呼び出したら処理が終わる前に返ってくる。通常のファイル読み込みでは読み込み結果を格納するメモリ領域を渡すが、非同期の場合は呼び出し直後に結果を参照しても値が入っている保証はない。

非同期処理のメリットは、スループットの向上にある。スループットは一定時間単位で処理できる量の評価基準。同期処理では一つの処理が完了するまでメモリ要領を占有するため先着順の処理がメモリを埋め尽くして他の処理ができなくなるが、非同期処理ではCPUが処理を呼び出しはするがメモリは開け渡さずDMAを利用するためCPUが他の処理に着手することができる。その結果、CPUは先着順のタスクにメモリを占有されることなく、より多くのタスクに着手することができ、スループットの向上につながる。

用語意味
RAMRamdom Access Memoryの略で、自由なアクセス可能な主記憶装置のこと。CPUが処理を行う際の作業台のようなメモリでなので頻繁にデータが書き換えられ、電源が切れると作業に使用していた一時データも消去される。
ROMRead Only Memoryの略で、書き込み不可・読み出し専用、または単に内蔵ストレージおよび外付けストレージを指す補助記憶装置のこと。
CPUCentral Processing Unitの略で、記憶装置上にあるプログラムと呼ばれる命令列を順に読み込んで解釈・実行することでじょうほうの加工を行う中央処理装置。
I/OCPUや主記憶装置などの機器やシステムに外部からデータや信号を入力したり、外部に出力するための回路、装置、ソフトウェアのこと。入力装置としてはキーボード、マウス、タッチパネルなど、出力装置としてはディスプレイ、スピーカー、モーターなどがある。
DMADirect Memory Accessの略で、CPUを介さないデータ転送を実現する設計思想のこと。本来ならCPUが処理するデータ転送を引き受けることで、CPUは他のタスクに要領を割くことができる。

参考文献
DMAのメリットって何?(2/3)

ブロッキング・ノンブロッキング

Node.jsではノンブロッキングI/Oと呼ばれ、

用語意味
ブロッキング先着のIO処理が完了するまで待ち時間が発生する性質
ノンブロッキングIO処理に待ち時間が発生すると関数から返る性質。

簡単に言えば、処理が止まらない性質。

処理が止まりそうな場合はエラーを返してプログラムが完結しないように回避することで処理し続けることができる。
同期IO処理が終わるまでブロックする性質
非同期IO処理を別スレッドに渡す性質。

ノンブロッキングによって実現できる処理の仕方。

ある関数が呼び出されたとき、戻り値として本来渡したい結果は返さず、関数を終了させて呼び出し元に戻し、後で本来渡したかった値を返せる状態になったときにその値を通知する仕組み。
比較類似性
同期とブロッキングほぼ同じ
非同期とノンブロッキング

参考文献
非同期とノンブロッキングとあと何か
非同期処理ってどういうこと?JavaScriptで一から学ぶ

ソケット

用語意味
socket
bind
listen
accept

Node.jsの非同期処理

非同期処理ではIO処理の待ち時間ができないようにするが、必要としているのはIO処理の結果。別の処理をしつつ、結果が出たらそれを受け取ると言う処理はどうやっているか。正しい順序で処理が行われるようにしなくてはならない。

よそに依頼した処理がいつ終わるかは不明。処理完了は依頼された側しか分からないため、依頼した側に通知する仕組みが必要。

Promise

非同期処理を実現するためのオブジェクト

  • 関数の結果としてreturnできる
  • メソッドを持っている
  • 変数に格納できる

async/await

Promise のシンタックスシュガー

参考文献
Node.js 非同期処理・超入門 -- Promiseとasync/await

UML

UMLとは「Unified Modeling Language」の略で「統一モデリング言語」の意。分析、設計、実装のオブジェクト指向開発に用いられるモデリング手法。

UML図とは、ソフトウェアシステムの成果物を記述、視覚化、構築、文書化するために使用される国際的な業界標準のグラフィカル表記法。

参考文献
初心者が押さえておくべきのUML入門知識


初心者がReact+FirebaseでWebアプリを作成する② ~複数の入力を取得してデータベースに反映させる~

$
0
0

①の続きです。

①で作ったfirebaseのデータベースに、入力された値を登録するようにします。

ReactのstateとsetStateで入力値を受け取れるようにします。
例でstateでnameに空値' 'を入れておきます。

App.js
classAppextendsReact.Component{constructor(props){super(props);this.state={name:'',}}

TextFieldを設けて、onChangeでgetNameメソッドを発動させsetStateを行うようにします。

App.js
//メソッド定義getName(event){this.setState({name:event.target.value,});};
App.js
//returnの中<TextFieldlabel="名前"value={this.state.Name}onChange={(event)=>{this.getName(event)}}/>

これでTextFieldの値がstateのnameに代入されます。

データの送り先を決めるため、
firebaseのDatabaseから「+コレクションの開始」をクリックして、コレクションを作成します。
コレクション名はとりあえず「users」として、ドキュメントIDは自動を選択。
すると下のような画面になります。
firebase database collection dekita.png

buttonを作って、このコレクションにnameの情報を送りましょう。
次のようになります。

App.js
importReactfrom'react';import'./App.css';importfirebasefrom"firebase/app"import"firebase/auth"import"firebase/firestore"import{firestore}from'./plugins/firebase';importButtonfrom'@material-ui/core/Button';importTextFieldfrom'@material-ui/core/TextField';classAppextendsReact.Component{constructor(props){super(props);this.state={name:'',}//ここでbindでthisを束縛しないとエラーが起きますthis.getName=this.getName.bind(this);this.addData=this.addData.bind(this);}getName(event){this.setState({name:event.target.value,});}; //データベースに登録するためのメソッド。usersの部分がコレクション名になりますaddData(){firestore.collection('users').add({name:this.state.name,}).then(()=>{this.setState({name:'',});})};render(){return(<div><TextFieldlabel="名前"value={this.state.name}onChange={(event)=>{this.getName(event)}}/><ButtononClick={this.addData}>登録</Button>
</div> );}}exportdefaultApp;

すると↓のようなページができます。
「登録」をクリックしてテスト送信してみましょう。
database 山田太郎.png
反映できました。
database 山田太郎が追加された.png

同じ要領でstateにaddressを設けて、住所も同時に登録できるようにします。
getAddressメソッドでsetStateを行えるようにしましょう。

App.js
importReactfrom'react';import'./App.css';importfirebasefrom"firebase/app"import"firebase/auth"import"firebase/firestore"import{firestore}from'./plugins/firebase';importButtonfrom'@material-ui/core/Button';importTextFieldfrom'@material-ui/core/TextField';importTextareaAutosizefrom'@material-ui/core/TextareaAutosize';classAppextendsReact.Component{constructor(props){super(props);this.state={name:'',address:'',}this.getName=this.getName.bind(this);this.getAddress=this.getAddress.bind(this);this.addData=this.addData.bind(this);}getName(event){this.setState({name:event.target.value,});};getAddress(event){this.setState({address:event.target.value,});};//new Date()で登録日時も記録しますaddData(){firestore.collection('users').add({name:this.state.name,address:this.state.address,created_at:newDate(),}).then(()=>{this.setState({name:'',address:'',});})};render(){return(<div><form><TextFieldlabel="名前"value={this.state.name}onChange={(event)=>{this.getName(event)}}/><br/><TextareaAutosizearia-label="住所"rows={6}placeholder="住所"value={this.state.address}onChange={(event)=>{this.getAddress(event)}}/>;
<br/><ButtononClick={this.addData}>登録</Button>
</form>
</div> );}}exportdefaultApp;

これで入力箇所が2つできました。
明訓.png

登録を押すと…

明訓完了.png

できました!

次回は画像のアップロードを投稿したいと思います。

【DynamoDB】LastEvaluatedKeyを使ってscanリクエストで全件取得

$
0
0

はじめに

RDBMSを使っていると、allとかで全件取得出来て特に気にする必要がないのですが、DynamoDBでは1MBまでのデータ量の件数のみしか取得が出来ないので、知っていないとあれ全件取得出来ていない...みたいなことになり私のように痛い目にあってしまうので、自分への戒めの意味も込めて残しておきます。

情報ソース:DynamoDB でのスキャンの使用

環境

  • Nodejs

実装

constAWS=require('aws-sdk');constDynamoDB=newAWS.DynamoDB.DocumentClient({region:"ap-northeast-1"});consttableName='hoges';module.exports.hello=async(event,context)=>{try{// scan用のパラメーターをセットconstparams={TableName:tableName}// scanで取得したデータを格納する空の配列を定義しておくletitems=[]constscan=async()=>{constresult=awaitDynamoDB.scan(params).promise()items.push(...result.Items)// scanリクエストを行なった時にLastEvaluatedKeyがあれば、再帰的にリクエストを繰り返すif(result.LastEvaluatedKey){params.ExclusiveStartKey=result.LastEvaluatedKeyawaitscan()}}awaitscan()constcount=items.lengthfor(leti=0;i<count;i++){console.log(`id: ${items[i]['id']}`)}console.log(`Execution result: ${count}`)}catch(err){console.error(`[Error]: ${JSON.stringify(err)}`)returnerr}}

express-validator を使ってみたポイント

$
0
0

はじめに

以前、 Node.js + Express.jsで Web API を開発した際、 入力チェックに express-validatorを使いました。
express-validator は validator.jsがベースになっており、 validation や sanitize ができる便利なモジュールですが、提供されている API は文字列に対する検証になるため、 JSON パラメータの型を明確にチェックしたい場合には少し注意が必要でした。
今回は使用した際のポイントなんかを自分の整理がてらメモとして残します。

環境

OS: macOS Mojave 10.14.6
Node.js: 10.16.3
Express.js: 4.16.1
express-validator: 6.2.0

Case1(GET & 桁チェック)

引数必須
arg1
arg2min:2 max:4
arg3max:4
arg4min:2
case1.js
constexpress=require('express');constrouter=express.Router();const{check,validationResult}=require('express-validator');router.get('/',validateParam(),async(req,res)=>{consterrors=validationResult(req);if(!errors.isEmpty()){res.status(400).end();return;}res.status(200).end();});functionvalidateParam(){return[check('arg1').exists({checkFalsy:true}).isString(),check('arg2').optional({nullable:true}).isInt().isLength({min:2,max:4}),check('arg3').optional({nullable:true}).isInt().isLength({min:undefined,max:4}),check('arg4').optional({nullable:true}).isInt().isLength({min:2,max:undefined})];}module.exports=router;

OKな例

?arg1=hoge
?arg1=hoge&arg2=12
?arg1=hoge&arg2=1234
?arg1=hoge&arg3=12
?arg1=hoge&arg3=1
?arg1=hoge&arg3=1234
?arg1=hoge&arg4=12
?arg1=hoge&arg4=1234567890

NGな例

?arg1=hoge&arg2=1
?arg1=hoge&arg2=12345
?arg1=hoge&arg3=12345
?arg1=hoge&arg4=1

解説

GET メソッドを例に桁チェックを行います。
桁は isLength()を使用してチェックできます。
さらに、オプションで min (最小値)と max (最大値)が指定できます。
OKな例とNGな例で、オプションで指定した min と max の境界値が効いているのがわかります。

Case2(POST & 型チェック)

引数必須
arg1整数
arg2文字列の整数
arg3真偽値
case2.js
constexpress=require('express');constrouter=express.Router();const{check,validationResult}=require('express-validator');router.post('/',validateParam(),async(req,res)=>{consterrors=validationResult(req);if(!errors.isEmpty()){res.status(400).end();return;}res.status(200).end();});functionvalidateParam(){return[check('arg1').exists({nullable:true}).not().isString().isInt(),check('arg2').optional({nullable:true}).isString().isInt(),check('arg3').optional({nullable:true}).not().isString().not().isInt().isBoolean()];}module.exports=router;

OKな例

{
    arg1: 1,
    arg2: '1'
}

{
    arg1: 1,
    arg3: true    
}

NGな例

{
    arg1: 1,
    arg2: 'a'
}

{
    arg1: 1,
    arg2: 1
}

{
    arg1: 1,
    arg3: 'true'
}

{
    arg1: 1,
    arg3: 1
}

解説

POST メソッドを例に型チェックを行います。
冒頭に触れている通り、 express-validator の API は文字列に対する検証なので、少し工夫をします。
arg1 は整数なので、 .not().isString().isInt()を組み合わせて文字列ではないかつ整数という指定になります。
arg2 のように文字列の整数に限定したい場合は、.isString().isInt()を組み合わせて文字列かつ整数という指定になります。
arg3 は真偽値ですが、文字列の 'false', 'true'や整数の 0, 1が真偽値として判定されないようそれぞれ .not().isString().not().isInt()という指定をしています。ケースによっては上記を真偽値として判定したいという場合もあるので、その場合は適宜指定を外せばOKです。

Case3(POST & 条件付き必須)

引数必須
arg1真偽値
arg2arg1=true の場合のみ必須文字列
case3.js
constexpress=require('express');constrouter=express.Router();const{check,oneOf,validationResult}=require('express-validator');router.post('/',validateParam(),async(req,res)=>{consterrors=validationResult(req);if(!errors.isEmpty()){res.status(400).end();return;}res.status(200).end();});functionvalidateParam(){return[check('arg1').exists({checkNull:true}).not().isString().not().isInt().isBoolean(),check('arg2').optional({nullable:true}).isString(),oneOf([check('arg1').custom((value)=>value===false),check('arg2').exists({checkNull:true})])];}module.exports=router;

OKな例

{
    arg1: false
}

{
    arg1: true,
    arg2: 'hoge'
}

NGな例

{
    arg1: true
}

解説

POST メソッドを例に条件付き必須チェックを行います。
条件付き必須チェックを行う場合には oneOf()が使用できます。
oneOf()は Validation Chain の配列で、いずれか1つでも真であれば検証OK(=すべて偽だった場合に検証NG)となります。これを利用すると、 arg2 のarg1=true の場合のみ必須という条件は、arg1=falsearg2が必須どちらかが真という条件に置き換えることができます(もっとスマートなやり方があれば教えて下さい)。

まとめ

今回は簡単なサンプルを例にとって紹介しましたが、 express-validator のようなモジュールがあると、組み合わせでたいていの要件を満たすことができてとても便利です。
Web API で入力チェックはとても大切ですが、あまり時間をかけたくないところでもあります。また、実装者が複数いれば実装の粒度も変わりやすいので、実装者に依存しにくくなるというのも大きなメリットでしょうか。

今回、使用したコードはGitHubで公開しています。
https://github.com/ponko2bunbun/express-validator-sample

初心者がReact+FirebaseでWebアプリを作成する③ ~Firebase Authenticationの認証機能を使ってみた~

$
0
0

②の続きです。

今回はfirebaseの認証機能を使って、ログインしたユーザーがだけが書き込みをできるようにします。

↓のGitHubのページを参考にしました。
https://github.com/firebase/firebaseui-web-react

その他参考。
https://firebase.google.com/docs/auth/web/manage-users?hl=ja
https://firebase.google.com/docs/auth/web/start?hl=ja

まずはfirebaseのAuthenticathionのログイン方法でGoogleとメール/パスワード認証を有効にしてみましょう。
TwitterなどはAPIを申請をしなければならないので無効にしています。
authentication.png

以下のnpmコマンドを実行。

npm install --save react-firebaseui
npm install --save firebase

firebase-uiがインストールできたら↓のように変数uiConfigを定義して、
StyledFirebaseAuthで呼び出すようにします。

App.js
constuiConfig={   //認証するときポップアップしますsignInFlow:'popup',   //サインインしたときのリンク先signInSuccessUrl:'/',//認証方法一覧。//Googleの部分をFacebook、Twitter等に変えれば、そのAPIが使用できますsignInOptions:[firebase.auth.GoogleAuthProvider.PROVIDER_ID,firebase.auth.EmailAuthProvider.PROVIDER_ID,]};

今回は以下のようにまとめました。
stateにisLoginを設けて、true、falseでログイン状態を管理しています。
コンポーネント化していないので散らかっていますが、後ほどまとめていきます。

App.js
importReactfrom'react';import'./App.css';importfirebasefrom"firebase/app"import"firebase/auth"import"firebase/firestore"//①でモジュール化したfirebase.jsをインポートimport{firestore}from'./plugins/firebase';importButtonfrom'@material-ui/core/Button';importTextFieldfrom'@material-ui/core/TextField';importTextareaAutosizefrom'@material-ui/core/TextareaAutosize';importStyledFirebaseAuthfrom'react-firebaseui/StyledFirebaseAuth';classAppextendsReact.Component{constructor(props){super(props);this.state={name:'',address:'',isLogin:false,}this.getName=this.getName.bind(this);this.getAddress=this.getAddress.bind(this);this.addData=this.addData.bind(this);this.logOut=this.logOut.bind(this);}//コンポーネント配置時にログインできてるか確認しますcomponentDidMount(){firebase.auth().onAuthStateChanged(user=>{this.setState({isLogin:true,});});} //名前の入力フォーム情報読み込みgetName(event){this.setState({name:event.target.value,});}; //住所の入力フォーム情報読み込みgetAddress(event){this.setState({address:event.target.value,});}; //登録ボタンを押すとデーターベースに反映addData(){firestore.collection('users').add({name:this.state.name,address:this.state.address,created_at:newDate(),}).then(()=>{this.setState({name:'',address:'',});})}; //ログアウトボタンを押すとisLoginがfalseになるlogOut(){this.setState({isLogin:false,});}render(){constuiConfig={signInFlow:'popup',signInSuccessUrl:'/',signInOptions:[firebase.auth.GoogleAuthProvider.PROVIDER_ID,firebase.auth.EmailAuthProvider.PROVIDER_ID,]};  //空の変数を定義してifでログイン/ログアウト時の表示画面を切り替えるletsuccessfulUser;if(this.state.isLogin){successfulUser=(<div><form><TextFieldlabel="名前"value={this.state.name}onChange={(event)=>{this.getName(event)}}/><br/><TextareaAutosizearia-label="住所"rowsMax={4}placeholder="住所"value={this.state.address}onChange={(event)=>{this.getAddress(event)}}/>;
<br/><Buttonvariant="contained"color="primary"onClick={this.addData}>登録</Button>
</form>
<Buttonvariant="contained"color="secondary"onClick={this.logOut}>ログアウト</Button></div>
);}else{successfulUser=(<div><p>Pleasesign-in:</p>
<StyledFirebaseAuthuiConfig={uiConfig}firebaseAuth={firebase.auth()}/>
</div>
);}return(    //最終的な表示画面<div>{successfulUser}</div>
);}}exportdefaultApp;

するとログイン画面が表示されます。
pleasesignin.png

ログインできたら②で作った投稿画面へ切り替わります。
successfuluser.png

これでログインしたユーザーだけが書き込みをできるようになりました。
ユーザー情報もちゃんと登録されています。
ユーザー認証成功.png

componentDidMountについてはReactの公式ページが一番分かりやすかったです。
ライフサイクル図が秀逸だと思います。

https://ja.reactjs.org/docs/react-component.html

それではまた投稿いたします。

WEBでLINE風のチャットサイトを作る-その2

$
0
0

はじめに

 前回作成した基本的なインターフェースを拡充してチャットアプリとしての体裁を整えたいと思います。
 今回追加する機能は、ログイン機能とユーザー登録機能、簡易的なセキュリティ対策、スタンプ機能、画像アップロード機能です。
  

環境構築

前回に引き続きお手軽なクラウドサービスを使って環境構築を行います。

Paiza Cloud

paiza表紙.png
Paiza Cloudeにアクセスしてメールアドレスを登録すると、すぐに環境構築ができるようになります。

リンク先
https://paiza.cloud/ja/

サーバー作成

アカウントを作成したらサーバー作成ボタンを押しましょう
まだサーバーはありません.png
新規サーバー作成のポップアップで、Node.jsとMongoDBを選択してください。
サーバー設定.png
数秒間待っているとサーバー環境ができあがります。
ちなみに無料プランの場合は
* サーバーの最長利用時間は24時間
* サービスは外部へ公開されない
逆に言うと練習にはもってこいという事でしょうか。

アプリケーション構築

次に各種インストールを行い、アプリケーションの実行環境を構築します。
まずは画面からターミナルのアイコンをクリックしてください。

s_ターミナル.png

起動したターミナルに下記のコマンドを入れてgitからファイルを展開します。

git clone https://github.com/nstshirotays/chatapp-shot2.git

下記のようにディレクトリが作成されソースが展開されます。
git2.png

つぎにディレクトリを移動し、必要なパッケージを導入します。

cd chatapp-shot2
npm install

実行に必要なモジュールなどがpackage.jsonに従って自動的にインストールされます。

以上で必要な準備が整いましたので、あとはnodejsを起動してアプリを立ち上げます。

npm start

git3.png

エラーがでなければ、左側に緑色のブラウザアイコンが新しく点滅し始めます。
s_ブラウザ3000.png

このアイコンをクリックするとアプリが起動します。

アプリ実行

ログイン画面

まずはログイン画面です。
login.png
初回は誰も登録されていないので、Create an Account を押してユーザー登録画面に移ります。

ユーザー登録画面

NickNameとPassCodeを入れてユーザーを登録しましょう。
NickNameは英文字で4から12文字。PassCodeは数字で6から12文字です。
regisit.png

お好みでFaceIconを変更(png 32kbまで)できます。

ユーザーを登録したら実際にログインしてみましょう。

友達選択画面

list.png
登録されている自分以外の友達が一覧で表示されます。今回もデフォルトでEchoさんが登録されています。友達を選択すると会話画面に遷移します。

会話画面

ベースとなる会話画面です。前回からスタンプと画像アップロード機能が追加されています。
chatapp.png
Echoさんはこちらの会話に相槌を打ってくれるチャットボットです。

スタンプ画面

スタンプボタンを押すと一覧が表示されます。
stamp.png
今回はクリスマススタンプを入れてみました。
お好きなpngを public/files/stampsに入れてください。

アイコンネタ元 speckyboy.com

(ちなみにEchoさんはスタンプをもらうとそのスタンプ名を言ってくれる仕様にしました)

stam2p.png

画像アップロード

今回は画像アップロード機能を加えています。画像はjpegのみで、サイズは1000×1000以下です。
image1.png

残念ですが現時点ではEchoさんは画像の内容を認識できません。次回あたりにチャレンジしたいと思います。


アプリケーション解説

 画面も増えましたので前回のソースをnode.jsのアプリケーションフレームワークであるExpressで再構成しました。
 このためディレクトリ構造は下記のようになっています。

   |-helper       共通機能系
   |-models       データモデル系
   |-public
   |---files
   |-----stamps     スタンプの場所
   |---images
   |---javascripts
   |---stylesheets
   |-routes       get,postで呼ばれるjavascript
   |-views        html

参考にした記事
Express + Node.jsで基本を理解した次の一歩 - ディレクトリ構成をルーティング・ミドルウェアを理解して考えてみる

コード解説

それでは今回加えた主なソースを解説していきます。チャット画面については前回とほぼ同様ですので割愛します。

app.js

メインのプログラムです。前回はserver.jsとして実装しました。今回はexpressのアプリケーション自動生成(generator)機能を使ったのでこの生成されたapp.jsに各画面の呼び出しを加えています。

参考サイト 初心者のための Node.jsプログラミング入門

app.js
varcreateError=require('http-errors');varexpress=require('express');varpath=require('path');varcookieParser=require('cookie-parser');varlogger=require('morgan');varmongoose=require('mongoose');// ルーティング処理の呼び出し先を追加varloginRouter=require('./routes/login');varregisterRouter=require('./routes/register');varlistRouter=require('./routes/list');varchatRouter=require('./routes/chatapp');varerrRouter=require('./routes/errorpage');varapiRouter=require('./routes/api');varlogoutRouter=require('./routes/logout');varapp=express();// 変数宣言varMyID="";varMyName="";varFrID="";varFrName="";varbotTimer;// view engine setupapp.set('views',path.join(__dirname,'views'));app.set('view engine','ejs');// ファイルアップロードに関する拡張app.use(logger('dev'));//app.use(express.json());//app.use(express.urlencoded({ extended: false }));app.use(express.urlencoded({extended:true,limit:'10mb'}));app.use(express.json({extended:true,limit:'10mb'}));app.use(cookieParser());app.use(express.static(path.join(__dirname,'public')));// ルーティング処理の登録app.use('/',loginRouter);app.use('/auth',loginRouter);app.use('/register',registerRouter);app.use('/home',listRouter);app.use('/chat',chatRouter);app.use('/errorpage',errRouter);app.use('/api/messages',apiRouter);app.use('/logout',logoutRouter);// catch 404 and forward to error handlerapp.use(function(req,res,next){next(createError(404));});// error handlerapp.use(function(err,req,res,next){// set locals, only providing error in developmentres.locals.message=err.message;res.locals.error=req.app.get('env')==='development'?err:{};// render the error pageres.status(err.status||500);res.render('error');});module.exports=app;

基本的にはクライアントからのGETやPOSTに対して応答する処理(ルーティング)を登録しています。requireでルーティング処理が書かれたJavascriptを変数に登録し、それをapp.useでクライアントからのURLに紐づけています。

処理全体で利用する変数として自分と相手のID,Nameを変数として宣言しています。また、チャットボットの本体となるタイマー起動処理用の変数もここで宣言しています。本来であれば、別ファイルにしてexportsでオブジェクト風に見せるのがお作法かとも思うのですが、シンプルに直接宣言したほうがわかりやすいかと思ってこっちにしました。

今回はファイルのアップロードがあるためpostデータが大きくなり、そのままでは413エラー(request entity too large)となってしまいます。このための設定としてオプション値を設定しています。

参考にした記事
Express4でエラー「request entity too large」が発生する

login.js ログイン処理

login.js
varexpress=require('express');varrouter=express.Router();var{check,validationResult}=require('express-validator');varsanitize=require('mongo-sanitize');vardb=require('../helper/db');varUser=db.User;varbcrypt=require('bcryptjs');varjsonwebtoken=require('jsonwebtoken');constconfig=require('../config');//--------------------------------------------------------// ログイン画面の表示//--------------------------------------------------------/* GET home page. */router.get('/',function(req,res,next){res.clearCookie("auth");res.render('login',{error:false,errors:false});});//--------------------------------------------------------// ログイン処理//--------------------------------------------------------//ユーザ認証router.post('/',[check('nickName','ニックネームを入力して下さい').not().isEmpty().trim().escape().customSanitizer(value=>{// MongoDB Operator Injectionを防ぐために、ユーザー提供のデータをサニタイズしますvalue=value.replace(/[$.]/g,"");returnvalue;}),check('passCode','パスコードを入力して下さい').not().isEmpty().trim().escape(),],(req,res)=>{varerrors=validationResult(req);//検証エラーif(!errors.isEmpty()){res.render('register.ejs',{data:req.body,errors:errors.mapped()});}constnickName=sanitize(req.body.nickName);constpasscode=sanitize(req.body.passCode);User.findOne({nickName},(err,user)=>{if(err)returnres.status(500).send(err);if(!user)returnres.render('login.ejs',{error:'ユーザーが見つかりません',errors:false});//パスコードチェックbcrypt.compare(passcode,user.passCode,function(err,result){if(!result)returnres.render('login.ejs',{error:'パスコードが違います',errors:false});//JSON Webトークンを生成する,トークンの有効期限を15分に設定constexpiresIn=900;constaccessToken=jsonwebtoken.sign({id:user._id,name:user.nickName},config.secret,{expiresIn:expiresIn});//トークンをクッキーに保存するres.cookie('auth',accessToken,{maxAge:900000,httpOnly:true});MyID=user._id;MyName=user.nickName;res.redirect('/home');});});});module.exports=router;

login.jsは冒頭の宣言部分と、クライアントからのGET処理とPOST処理の3パートで構成されています。

GET処理ではクッキーをクリアして、res.renderでログインフォームをレンダリングしています。それだけです。

POSTの方は実際のログイン処理を実施しています。
router.post( URL、処理1、処理2、処理3・・・)
という感じでポスト後の処理を書いています。
まずはニックネームとパスワードの未入力をチェックしたあと、実際のDBへ接続してユーザーの有無を問い合わせています。DBへの接続についてはいわゆるSQLインジェクションという攻撃への備えが必要です。mongodbはSQLデーターベースではありませんが、やはり検索文字列に特殊なコードを入れると悪意のあるコードが実行されてしまいます。このためニックネームとパスワードについてはサニタイズ処理をしています。これはmongo-sanitizeというパッケージを利用しています。

参考サイト: HACKING NODEJS AND MONGODB

ニックネームとパスワードが一致すると、ユーザーIDとNameをセットしたクッキーが発行されます。これ以降はこのクッキーが認証済みの証となります。
ということで、クッキーが改ざんされても判別できるようにここではJson Web Token という仕様を使ってクッキーを署名付きにします。

参考記事: NodeJS + MongoDB - Simple API for Authentication, Registration and User Management
参考記事: JSON Web Token の効用

login.ejs ログイン画面

login.ejs
<!doctype html><html><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1"><title>Login</title><linkrel="stylesheet"href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm"crossorigin="anonymous"><linkrel="stylesheet"type="text/css"href="/stylesheets/style.css"></head><body><divclass="container"><divclass="row "><divclass="col-xs-1 col-sm-2 col-md-3 col-lg-4"></div><divclass="col-xs-10 col-sm-8 col-md-6 col-lg-4"><h1>Chat App</h1><formmethod="post"action="auth"><divclass="form-group"><%if(error){%><divclass="text-danger"style="width: 90%;margin:5%;"><%=error%></div><%}%><%if(errors.nickName){%><divclass="text-danger"style="width: 90%;margin:5%;"><%=errors.nickName.msg%></div><%}%><%if(errors.passCode){%><divclass="text-danger"style="width: 90%;margin:5%;"><%=errors.passCode.msg%></div><%}%><inputtype="text"class="form-control"style="width: 90%;margin:5%;"id="nickName"name="nickName"placeholder="NickName"required><inputtype="password"class="form-control"style="width: 90%;margin:5%;"id="passCode"name="passCode"placeholder="PassCode"required><buttontype="submit"class="btn"style="width: 90%;margin:5%;">Log in</button><center><ahref="register">Create an account</a></center></div></form></div></div></div></body></html>

 今回は画面をレスポンシブにするためにbootstrapのグリッドシステムを利用しています。
 このシステムは全体を12の列に分け、画面の解像度に応じて利用する列数を変更することで、一定の見栄えを維持するものです。
 LINE風ということで、スマホの縦長画面をイメージしたいので、PC画面やタブレット画面では左右にマージンを置きたいと考えました。本来であればoffset指定でできるはずですが、上手くいかなかったので、空のカラムdivを挟んであります。
 また、スマホなどの高解像度換算表示をさせないために、メタタグとしてwidth=device-widthを指定しています。

参考サイト: Bootstrap3の使い方

register.js ユーザー登録処理

ログイン画面から「Create an account」を選択すると表示されます。

register.js
varexpress=require('express');varrouter=express.Router();var{check,validationResult}=require('express-validator');vardb=require('../helper/db');varUser=db.User;constuserService=require('../models/user.service');//--------------------------------------------------------// ユーザー登録画面の表示//--------------------------------------------------------/* GET home page. */router.get('/',function(req,res,next){res.clearCookie("auth");res.render('register',{data:req.body,error:false,errors:false});});//--------------------------------------------------------// ユーザー登録処理//--------------------------------------------------------router.post('/',[check('nickName','Nick name は英文字のみです').not().isEmpty().isAlpha().trim().escape(),check('passCode','Pass code は数字のみです').not().isEmpty().isAlphanumeric().trim().escape(),check('nickName','Nick name は4文字以上12文字までです.').not().isEmpty().isLength({min:4,max:12}).trim().escape(),check('passCode','Pass code は6文字以上12文字までです.').not().isEmpty().isLength({min:6,max:12}).trim().escape(),check('nickName').custom(value=>{// MongoDB Operator Injectionを防ぐために、ユーザー提供のデータをサニタイズしますvalue=value.replace(/[$.]/g,"");//ニックネームを検証するreturnUser.findOne({'nickName':value}).then(user=>{if(user){returnPromise.reject(user['nickName']+'さんは登録済みです.');}});})],(req,res)=>{varerrors=validationResult(req);//検証エラーif(!errors.isEmpty()){res.render('register.ejs',{data:req.body,errors:errors.mapped()});}else{//エラーなし, データベースにユーザー情報を保存する     userService.create(req.body);res.redirect('/');}});module.exports=router;

 ここもGET処理は単にユーザー登録画面をレンダリングするだけです。POST処理でユーザー登録をしています。
 この際にexpress-validatorを使って文字種別や文字長の検査をしています。そしてDBを確認して既登録がなければ登録を行います。
 実際の登録はuserService = require('../models/user.service');で指定されたソースで行っています。

共通関数:ユーザー登録処理(user.service.js)

/models/user.service.js
// ユーザー登録操作varbcrypt=require('bcryptjs');constdb=require('../helper/db');constUser=db.User;constsaltRounds=10;module.exports={create};// ユーザーモデルを作成し、データベースに保存するfunctioncreate(userParam){constuser=newUser();user.nickName=userParam.nickName;// 画像はオプションです。デフォルト画像を使用して選択されていませんif(userParam.ufile){user.userImage=userParam.ufile;}if(userParam.passCode){// ハッシュパスコードを保存するuser.passCode=bcrypt.hashSync(userParam.passCode,bcrypt.genSaltSync(saltRounds));}// save useruser.save();}

パスワードはbcryptを使って10多重でハッシュ化しています。

参考記事: BCryptのすすめ
参考サイト: 本当は怖いパスワードの話 (1/4)

register.ejs ユーザー登録画面

register.ejs
<!DOCTYPE html><html><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1"><title>Register</title><linkrel="stylesheet"href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm"crossorigin="anonymous"><linkrel="stylesheet"type="text/css"href="/stylesheets/style.css"><script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script></head><body><formmethod="post"action="register"name="register-form"id="register-form"><divclass="form-group"><divclass="container"><divclass="row "><divclass="col-xs-12 col-sm-12 col-md-12 col-lg-12"><h1>Chat App</h1></div></div><divclass="row"><divclass="                  col-md-2 col-lg-3"></div><divclass="col-xs-8 col-sm-8 col-md-5 col-lg-4"><labelfor="nickName"class="col-form-label">NickName</label><inputtype="text"class="form-control"id="nickName"name ="nickName"placeholder="Nickname"maxlength="12"required><%if(errors.nickName){%><divclass="text-danger"><%=errors.nickName.msg%></div><%}%><labelfor="passCode"class="col-form-label">PassCode</label><inputtype="password"class="form-control"id="passCode"name="passCode"placeholder="Passcode"maxlength="12"required><%if(errors.passCode){%><divclass="text-danger"><%=errors.passCode.msg%></div><%}%></div><divclass="col-xs-4 col-sm-4 col-md-3 col-lg-2"><labelfor="FaceIcon"class="col-form-label">FaceIcon</label><imgsrc="/images/defaultFace.png"class="image"id="image-frame"height="50pv"width="50pv"/><inputid="imageFile"type="file"style="visibility:hidden"name="imageFile"/><inputtype="button"style="width: 100%;"value="Change"onclick="$('#imageFile').click();"class="btn"name="imagePath"/><inputid="b64"name="ufile"type="hidden"value=""/><divclass="text-danger"id="error"></div></div></div><divclass="row"><divclass="                    col-md-2 col-lg-3"></div><divclass="col-xs-12 col-sm-12 col-md-8 col-lg-6"></br><buttontype="submit"style="width: 100%;"class="btn rgst">Create</button></div></div></div></div></form></body><script>/*$("#imageFile").change(function(){
        readURL(this);
    });
    function readURL(input) {
        if (input.files && input.files[0]) {
            var reader = new FileReader();

            reader.onload = function (e) {
            console.log(e.target.result);
                $('#imgsrc').attr('src', e.target.result);
            }

            reader.readAsDataURL(input.files[0]);
        }
    }*/showImage(true);vartargetfile=null;$("#imageFile").onchange=function(evt){$("#error").innerHTML='';showImage(true);varfiles=evt.target.files;if(files.length==0)return;targetFile=files[0];console.log(targetFile);if(!targetFile.type.match(/image/)){$("#error").innerHTML='Select Image File';return;}if(targetFile.size>35000){$("#error").innerHTML='Image file size should be less than 35KB';return;}varbreader=newFileReader();breader.onload=readPNGFile;breader.readAsBinaryString(targetFile);}functionreadPNGFile(evt){varbin=evt.target.result;varsig=String.fromCharCode(0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a);varhead=bin.substr(0,8);if(sig!=head){$("#error").innerHTML="Image file type should be PNG";return;}showImage(true);varwidth=getBinValue(bin,8+0x08,4);varheight=getBinValue(bin,8+0x0c,4);vardepth=bin.charCodeAt(8+0x10);/*$("#info").innerHTML =
            "width: " + width + "px<br>" +
            "height: " + height + "px<br>" +
            "depth: " + depth + "bit";*/varreader=newFileReader();reader.onload=function(e){console.log(reader);$("#image-frame").src=reader.result;$("#b64").value=reader.result;}reader.readAsDataURL(targetFile)}functiongetBinValue(bin,i,size){varv=0;for(varj=0;j<size;j++){varb=bin.charCodeAt(i+j);v=(v<<8)+b;}returnv;}functionshowImage(b){varval=b?"block":"none";//$("#upbtn").style.display = val;console.log("val",val);$("#image-frame").style.display=val;//$("#info").style.display = val;}function$(id){returndocument.querySelector(id);}</script></html>

ここではpngファイルの選択と表示を行っています。

参考サイト: HTML5のFile APIでローカルファイル情報取得してやんよ!!!

list.js 友達リスト処理

list.js
varexpress=require('express');varrouter=express.Router();vardb=require('../helper/db');varUser=db.User;constverifyToken=require('../helper/VerifyToken');//--------------------------------------------------------// 友達リスト画面の出力//--------------------------------------------------------router.get('/',verifyToken,function(req,res,next){varusers=[];// ログインしたユーザーを除くすべての登録ユーザーをデータベースから取得し、ユーザー名とプロファイル画像のjsonオブジェクトを作成しますUser.find({nickName:{$ne:req.name}}).stream().on('data',function(doc){varbase64Data;if(doc.userImage!==undefined){base64Data=doc.userImage.replace(/^data:image\/png;base64,/,"")}users.push({id:doc._id,nickName:doc.nickName,userImage:base64Data});}).on('error',function(err){res.send(err);}).on('end',function(){res.render('list.ejs',{listUsers:users});});});module.exports=router;

データベースから自分以外の友達を検索し、その一覧を引数としてリスト画面のレンダリング処理を呼び出しています。

ちなみに、冒頭のrouter.get('/',verifyToken, function(req, res, next) { に書かれている verifyTokenが前述のJson Web Tokenの検証処理です。

共通関数:JSON Web Tokenの処理(VerifyToken.js)

/helper/VerifyToken.js
// Json Web Tokenのチェック処理varjwt=require('jsonwebtoken');constconfig=require('../config');vardb=require('../helper/db');varWaste=db.Waste;functionverifyToken(req,res,next){vartoken=req.cookies.auth;if(!token){returnres.status(403).render('errorpage.ejs',{error:'15分間無操作のためログアウトしました',errors:false});}// 破棄済みトークンを検索Waste.findOne({'cookie':token}).then(data=>{if(data){returnres.status(403).render('errorpage.ejs',{error:'不正なトークンです',errors:false});}});jwt.verify(token,config.secret,function(err,decoded){if(err){returnres.status(500).render('errorpage.ejs',{error:'Failed to authenticate token.',errors:false});}else{// すべてうまくいけば、他のルートで使用するために保存して次へreq.name=decoded.name;req.id=decoded.id;next();}});}module.exports=verifyToken;

 JWTが期限切れ(15分)もしくは不正なトークンの場合はエラーを返しています。
 また、ここではログアウト時に登録された破棄済みのクッキーと照合することによりクッキーの使い回しを防御しています。

最後に

いかがだったでしょうか。ちょっと前回から間が空いてしまいましたが、一応チャットサイトとして使えるようになったかと思います。パスワード変更ができないとか、エラー処理が中途半端とか、ログ機能がないとか、色々と細かくは実装していませんが友人同士での遊び程度では利用できるかと思っています。

Special Thanks

今回のソースはRupaliさんの支援を受けています。ありがとうございます。

次回は...

 次回はこのチャットサイトからGoogle Dialogflowを呼び出して、本格的なチャットボットを実現してみたいと思います。
 最終的には自然言語処理や画像認識、ブロックチェーンなんかにも足を伸ばせればと思ってます。

記事一覧

WEBでLINE風のチャットサイトを作る-その1

初心者がReact+FirebaseでWebアプリを作成する④ ~画像と入力値をまとめてアップロード~

$
0
0

③の続きです。
休日の間に連続投稿しておきます。
今回はFirebaseのStorageへ画像をアップロードします。

↓参考資料にした公式ガイド
https://firebase.google.com/docs/storage/web/upload-files?hl=ja

まずはfirebaseのStorageサービスを開始しましょう。
Storage.png
↑にアップロードしていきます。

storagerule.png
↑初期値のセキュリティルールでは認証ができたユーザーのみアップロードできるようになっているので、ケースによってセキュリティルールを変更しましょう。

fileを受け取るinputを作ってgetImageメソッドでsetStateを行います。

App.js
<inputtype="file"onChange={(event)=>{this.getImage(event)}}></input>
App.js
 //stateでimage:''を設けています getImage(event){letimage=event.target.files[0];this.setState({image:image,});}

入力フォームの入力値を送るボタンで、addDataメソッドを発動して、
画像とまとめて送信します。
出力ファイル名が分かりやすいように、
{this.state.name}+pngとしています。
また送信成功したらalertが表示されるようにしています。

App.js
//入力値をデータベースへ送るaddData(){firestore.collection('users').add({name:this.state.name,address:this.state.address,created_at:newDate(),}).then(()=>{this.setState({name:'',address:'',});})//ここからがstorageへのアップロードletstorageRef=firebase.storage().ref().child(this.state.name+`.png`);storageRef.put(this.state.image).then(function(snapshot){alert("送信されました");});}

↓全体がこちらです。

App.js
importReactfrom'react';import'./App.css';importfirebasefrom"firebase/app";import"firebase/auth";import"firebase/firestore";import{firestore}from'./plugins/firebase';importButtonfrom'@material-ui/core/Button';importTextFieldfrom'@material-ui/core/TextField';importTextareaAutosizefrom'@material-ui/core/TextareaAutosize';importStyledFirebaseAuthfrom'react-firebaseui/StyledFirebaseAuth';classAppextendsReact.Component{constructor(props){super(props);this.state={name:'',address:'',image:'',isLogin:false,}this.getName=this.getName.bind(this);this.getAddress=this.getAddress.bind(this);this.getImage=this.getImage.bind(this);this.addData=this.addData.bind(this);this.logOut=this.logOut.bind(this);}componentDidMount(){firebase.auth().onAuthStateChanged(user=>{this.setState({isLogin:true,});});}getName(event){this.setState({name:event.target.value,});};getAddress(event){this.setState({address:event.target.value,});};getImage(event){letimage=event.target.files[0];this.setState({image:image,});}addData(){firestore.collection('users').add({name:this.state.name,address:this.state.address,created_at:newDate(),}).then(()=>{this.setState({name:'',address:'',});})letstorageRef=firebase.storage().ref().child(this.state.name+`.png`);storageRef.put(this.state.image).then(function(snapshot){alert("送信されました");});}logOut(){this.setState({isLogin:false,});}render(){constuiConfig={signInFlow:'popup',signInSuccessUrl:'/',signInOptions:[firebase.auth.GoogleAuthProvider.PROVIDER_ID,firebase.auth.EmailAuthProvider.PROVIDER_ID,]};  letsuccessfulUser;if(this.state.isLogin){successfulUser=(<div><form><TextFieldlabel="名前"value={this.state.name}onChange={(event)=>{this.getName(event)}}/><br/><TextareaAutosizearia-label="住所"rowsMax={4}placeholder="住所"value={this.state.address}onChange={(event)=>{this.getAddress(event)}}/>;
<br/><inputtype="file"onChange={(event)=>{this.getImage(event)}}></input>
<br/><Buttonvariant="contained"color="primary"onClick={this.addData}>登録</Button>
</form>
<Buttonvariant="contained"color="secondary"onClick={this.logOut}>ログアウト</Button></div>
);}else{successfulUser=(<div><p>Pleasesign-in:</p>
<StyledFirebaseAuthuiConfig={uiConfig}firebaseAuth={firebase.auth()}/>
</div>
);}return(<div>{successfulUser}</div>
);}}exportdefaultApp;

↓のような表示になっているので、ファイルを送ってみます。

殿馬スタンバイ.png

送れました!
殿馬.png

データベースを見ると反映されています。
殿馬データベース.png

そしてストレージへはnameの名前でファイルが保存されています。
殿馬ストレージ.png

今回は以上となります。

サボテンでも分かる!SSR対応SPAアプリの作り方(React/TypeScript/Express)

$
0
0

Next.jsを使わないReactのSSR解説で分かりやすいやつが無かったので、書きます!

環境構築からとっても丁寧にやっていきます:cactus:

サボテンくん

最初に

読んで欲しい人

  • SSRしたいけど難しそう....:fearful:

って人はもちろん

  • 『何も分からないけどSPAが作ってみたい!:smiley:

って人も実際に作って楽しめるように書いてます。

SPA (Single Page Application) : ネイティブアプリ(AppStoreやGooglePlayからインストールできるやつ)っぽいWebサイトのこと。ReactやVueなどのJavaScriptフレームワークを使って作るのが一般的です。

作るもの

SSRを使用した簡単なカウントアップアプリを作ってみます。

countup.gif

最終的な制作物のソースコードはこちらです。

学べること

  • Node, Yarn, Git, ESList, Babel, Webpackのフロントエンド開発環境の構築。
  • React Hooks(useState)を使って、モダンな書き方のReactを書いてみる。
  • TypeScriptを使ってみる。
  • Express.jsを使ってSSRができるようになる。

ReactやTypeScriptの詳しい記法については踏み込みません。良質な記事がQiita等にたくさんあるので、気になるところは調べながら進めてください。

進め方について

なるべくコピペはせず、何をしているか考えて進めていただきたいです。

設定ファイルを暗記するのは無駄なので、設定ファイルはコピペしてください。

SSR(Server Side Rendering)とは

Reactなどで作成されたSPAアプリは、クライアント(ユーザーが使っているブラウザ)で実行され、UI (DOM)を形成します。その性質上、以下のような問題があります。

  • Googleのクローラが正しくインデックスしてくれない。
  • OGPを含む<head/>が全てのページで同じになるので、Twitterカードなどが意図通りに表示できない。
  • 初回表示速度(クリティカルレンダリングパス)に時間がかかる。

そこで、サーバー側でJavaScriptをHTMLをして返すことをSSR(Server Side Rendering)と呼びます。やっていることはほとんどPHPなどのサーバーサイド言語と同じですが、SSRと呼ぶので難しく感じるだけです。

not-ssr.jpg

この記事ではSSRを解説しますが、小規模な(ページ数が限定されている)プロダクトでは、SSRの代わりにPrerenderingという手法を検討した方がいいです。これは、あらかじめSSG(Static Site Generator)を使用して、JavaScriptをHTMLファイルに変換して配置しておく方法です。SSRより簡潔で、リクエストごとにサーバーでHTMLを生成する処理が必要がないので、メンテナンスがしやすいことと、サーバーにかかる負荷がSSRより小さいことがメリットです。

  • クローラ: Googleが世界中にあるサイトを把握するために、いろんなサイトを巡回させているロボットのこと。クローラがサイトの情報を保存することをインデックスと呼びます。最近GoogleのクローラはJavaScriptを実行してくれるようになったらしいですが、まだ完全ではなくSEOが劣る可能性があります。

  • SEO (Search Engine Optimization) : Googleなどの検索結果の上に方に表示されるように調整すること。

  • UI (User Interface) :サイトの見た目のこと。

  • DOM (Document Object Model) :ブラウザがHTMLを元に形成するツリー状の構造のこと。よく混同されますがHTMLとは別のものです。JavaScriptから操作できます。

  • OGP (Open Graph Protocol) : Twitterカードなど、SNS上でリンク先の情報を表示するときに使われるheadタグです。OGPをクロールに来るbotはGoogleのbotと違ってJavaScriptを解釈しません。

環境構築

フロントエンド(クライアントサイド)の環境構築はとても面倒で楽しくないです。環境構築が終われば楽しいので、投げ出さずに頑張ってください!

推奨環境

環境構築はmacOSを使っている前提で進めます。

Windows等ご利用の方は別途調べながら進めてみてください。

Homebrew

Macのパッケージマネージャです。パッケージとはソフトウェアのこと。Qiitaなど多くの記事でインストールしてあることが前提になっているので、Macで開発するなら必須です。

  1. AppStoreからXcodeをインストールします。

  2. 以下のコマンドを実行して、コマンドライン・デベロッパツールをインストールします。

$ xcode-select --install
  1. 以下のコマンドを実行して、Homebrewをインストールします。
$ /usr/bin/ruby -e"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
  1. 以下のコマンドを実行して、Example usage: ...が表示されればOKです。
$ brew

参考 : macOSにHomebrewをインストール

VSCode

たぶん現時点では最も優れたコードエディタであるVSCodeをインストールします。

ダウンロードボタン

  • ダウンロードしたzipファイルを解凍してインストーラを実行し、インストールを完了してください。

Node & Yarn

Nodeは、サーバーサイドで使えるJavaScript環境です。クライアントサイドの開発環境(Webpack等)にも必須なのでよく誤解がありますが、一応サーバーで動くものです。

Yarnは、Nodeのパッケージマネージャです。同じNodeのパッケージマネージャに有名なnpmがありますが、Yarnはnpmよりインストールの速度が速く、npmの上位互換と考えていいと思います。

NodeはNodebrewでバージョン管理すると便利なので、ここではNodebrewを使用してNodeをインストールします。

homebrew.jpg

  • 以下のコードを実行してください。

$(コマンドプロンプト)が書かれているコードは、Macのターミナルで実行してください。(iTerm等でも可)

# nodeのバージョン管理のためのnodebrewのインストール$ brew install nodebrew
# nodebrewのディレクトリのセットアップ$ nodebrew setup
# パスを通しておく$ echo"export PATH=$HOME/.nodebrew/current/bin:$PATH">> ~/.bash_profile
$ source ~/.bash_profile
# nodeのダウンロードとセット$ nodebrew install stable
$ nodebrew use stable
# yarnのインストール$ brew install yarn --ignore-dependencies

Yarnは「HomebrewのNode」に依存するので、Yarnをインストール時に「HomebrewのNode」も一緒にインストールしようとしますが、今回は「NodebrewのNode」使用しているので、--ignore-dependenciesオプションを使って「HomebrewのNode」をダウンロードさせないようにしています。

参考 : install nodebrew, node and yarn

作業ディレクトリの作成

  • 適当なディレクトリを作成して移動します。
$ mkdir ssr-sample &&cd$_

$_は直前に実行したコマンドの最後の引数(上のコマンドだとssr-sample)を表します。

  • Yarnを初期化(package.jsonの作成)しておきます。
$ yarn init -y
  • 作成したディレクトリをVSCodeで開きましょう。『ファイル』 >『 開く』から開けます。

ファイルから開く

Git

プログラムのバージョンを管理するために、Gitも入れておきましょう。Gitはプログラムの更新履歴を管理したり、複数人で一つのプロダクトを更新するのに役立ちます。

  • GitをHomebrewからインストールします。

  • $ brew install git
    
  • Gitを初期化します。
$ git init
  • .gitignoreを作成します。
$ touch .gitignore
  • .gitignoreに以下を書き込みます。
  /node_modules/

.gitignoreに書いたファイルはGitで管理されません。node_modulesディレクトリ内のファイルはYarnでインストールしたパッケージなので容量が多い上に、package.jsonがあれば$ yarn installコマンドで復元できるのでGit管理はしないように設定しましょう。

この記事ではローカルリポジトリへのコミットしかしないので、詳しく知りたい人はGitをまとめた別記事を参照してください。

ESLint

ESListは、コードがちゃんとしたフォーマットになっているかチェックして、修正もしてくれるツールです。JavaScriptはセミコロン『 ; 』をつけるかどうかなど、コーディングの自由度が高いので、チームで開発する場合など特に活躍します。

設定は好みがあるのでいろいろな種類がありますが、Airbnbという企業の作った一番人気のコーディング規則を使ってみましょう。(細かくカスタマイズもできます)

ESLintと関連パッケージのインストール

# airbnbのeslint設定に必要なパッケージを一括でインストールします。# 『It seems as if you are using Yarn. Would you like to use Yarn for the installation? (y/n)』と聞かれたら、『y』と入力してEnterを押してください。$ npx install-peerdeps --dev eslint-config-airbnb

npm(Yarnと同じパッケージマネージャで、Nodeと一緒にインストールされる)のnpxコマンドを使ってinstall-peerdepsコマンドを実行し、eslint-config-airbnbが必要としているパッケージ(eslintなど)を一括でインストールしています。

参考: Github: Airbnb/JavaScript

ESLintの設定ファイルを作成

.eslintrcを作成して、

$ touch .eslintrc

以下を書き込んでください。

{"extends":["airbnb","airbnb/hooks"],"env":{"browser":true},}

extendsは指定した設定を継承します。airbnbとReact Hooksを使用するためのairbnb/hooksを継承しています。

envbrowserを指定することで、window.documentなどブラウザでのDOM操作をするためのコードでエラーが出ないようにしています。

VSCodeのESLint拡張のインストール

ESLintをYarnで入れただけでは、VSCode上で構文チェックできません。

左側にある 拡張機能をインストールするボタンのマークをクリックして、『eslint』を検索し、ESLintをインストールしてください。

VSCode-ESLintの設定

VSCode上でのESLintを設定します。

  • 以下のコマンドを実行して.vscode/settings.jsonを作成します。
$ mkdir .vscode &&touch$_/settings.json
  • 以下の内容を書き込みます。(コピペ推奨
{"eslint.run":"onSave","eslint.autoFixOnSave":true,"eslint.validate":["javascript","javascriptreact",{"language":"typescript","autoFix":true},{"language":"typescriptreact","autoFix":true}]}

設定の詳細

  • eslint.run : onSaveを指定すると、保存時に構文チェックを行います。
  • eslint.autoFixOnSave : trueにすると、保存時に構文の修正を行います。
  • eslint.validate : ESLintを有効にするファイルの種類を指定します。それぞれ、.js(JavaScript)、.jsx(JavaScript + React)、.ts(TypeScript)、.tsx(TypeScript + React)を指定しています。.ts.tsxについては、autoFixを指定しないと修正されません。

参考 : microsoft/vscode-eslint

ESLintの設定が反映されずエラーが出たままのときは、VSCodeを開きなおすと直ります。

ファイル構成

.
├── .git               ← Gitが作った。Git管理のための隠しファイル。
├── node_modules       ← Yarnが作った。Yarnでインストールしたパッケージが入ってる
├── .eslintrc      ← 新しく作った。eslintの設定ファイル。
├── .gitignore         ← 新しく作った。Gitで管理しないファイルを指定する。
├── .vscode            ← 新しく作った。vscodeの設定ファイルを入れる。
│   └── settings.json  ← 新しく作った。vscode-eslintの設定をした。
├── package-lock.json  ← Yarnが作った。パッケージの依存関係が書いてある。編集とかはしない。
├── package.json       ← Yarnが作った。インストールしたパッケージとかが書いてある。
└── yarn.lock          ← Yarnが作った。Yarnの依存関係を管理する。

STEP1 - ExpressでWebサーバを立てる

Webアプリを配信するためにはWebサーバが必要です。Webサーバとは、HTMLやJavaScript、画像などをクライアント(ブラウザ)に渡すものです。SSRでは、NodeでJavaScriptをHTMLに変換する必要があるので、NodeのフレームワークであるExpressを使いましょう。

1. 必要なパッケージのインストール

$ yarn add express esm

インストールしたパッケージの概要は以下の通りです。
* Express.js : JavaScriptのNode.jsのWebアプリケーションフレームワークです。
* esm : node -r esm index.jsの様に指定すると、Nodeでimport/exportの構文を利用できるようになります。

2. index.jsの作成

  • index.jsを作成します。
$ touch index.js
  • index.jsに以下を書き込みます。
importexpressfrom'express';importssrfrom'./src/ssr';constapp=express();// 3000番ポートでWebサーバを立てるapp.listen(3000);// https://localhost:3000 にアクセスがあったら ssr() を返すapp.get('/',(_,res)=>{res.send(ssr());});

index.jsはExpressのルーティング(どのURLが来たら、どのファイルを返すか)などを設定するファイルです。今のコードでは、http://localhost:3000/でアクセスしたら、`./src/ssr`の`ssr()`を返す設定をしました。

3. ssr.jsの作成

  • src/ssr.jsを作成します。
$ mkdir src &&touch$_/ssr.js
  • src/ssr.jsに以下を書き込みます。
constssr=()=>(`
  <html>
    <head>
      <title>CountUp</title>
      <meta charset="utf-8"/>
    </head>
    <body>
      <div id="app">
        <h1>0</h1>
        <button type="button">+</button>
        <p>${newDate().toTimeString()}</p>
      </div>
    </body>
  </html>
`);exportdefaultssr;

src/ssr.jsは、3.で設定した通り、実際に返される文字列になります。

new Date().toTimeString()のところは、現在の時刻です。

4. package.jsonの編集

Expressを起動してWebサーバを立てるために、Nodeをindex.jsに対して実行するコマンドを設定します。

  • package.jsonに以下を追加します。
"scripts":{"start":"node -r esm index.js"},

追加した後のpackage.jsonはこのような感じになります。

{"name":"ssr-sample","version":"1.0.0","main":"index.js","license":"MIT","scripts":{"start":"node -r esm index.js"},"devDependencies":{"eslint":"^6.1.0","eslint-config-airbnb":"^18.0.1","eslint-plugin-import":"^2.18.2","eslint-plugin-jsx-a11y":"^6.2.3","eslint-plugin-react":"^7.16.0","eslint-plugin-react-hooks":"^1.7.0"},"dependencies":{"esm":"^3.2.25","express":"^4.17.1"}}

package.jsonscript:{}に追加したコマンドは、yarn startのように実行することができます。

5. Expressの起動

  • Expressを起動しましょう!
$ yarn start

http://localhost:3000/にアクセスして、下の画像のように表示されれば完璧です!これがSSRの基礎になります。

ページをリロードすると時間表示が更新されます。ページのリクエストごとにJavaScriptからHTMLを生成していることが確認できます。

まだハリボテのHTMLを生成しているだけなので、+ボタンを押しても何もおきません。

STEP1の画像

6. Gitにコミットする

  • 忘れないようにgitにコミットしておきます。
$ git add .
$ git commit -m "STEP1 - ExpressでWebサーバを立てる"

ファイル構成

.
├── .git
├── node_modules
├── .eslintrc
├── .gitignore
├── .vscode
│   └── settings.json
├── index.js           ← 新しく作った。Expressの設定。
├── package-lock.json
├── package.json       ← 編集した。startコマンドを追加。
├── src                ← 新しく作った。ソースファイルを入れる。
│   └── ssr.js         ← 新しく作った。ブラウザに送る文字列を返す。
└── yarn.lock

STEP2 - Reactを使う

STEP1では実装しなかったカウントアップの仕組みをReactを使って実装してみましょう。

ReactはそのままではNodeやブラウザで実行できないので、Nodeやブラウザで実行できるJavaScriptに変換するために、Babelを使用する必要があります。

1. 必要なパッケージのインストール

$ yarn add react react-dom
$ yarn add -D @babel/cli @babel/core @babel/preset-env core-js@3 @babel/preset-react

インストールしたパッケージの概要は以下の通りです。

  • React : JavaScriptのUIフレームワーク

    • react-dom : render()などを使うために必要です。
  • Babel : ES6の構文などをブラウザで表示できるように変換します。

    • @babel/core : Babelのコア。
    • @babel/cli : Babelをコマンドラインから使用できるようにします。
    • @babel/preset-env : 最新のJavaScriptを変換するための設定をまとめたプリセット。
    • core-js : babel/preset-envがPolyfillをするために必要です。
    • @babel/preset-react : Reactを変換するためのプリセット。

Polyfill : 最近の機能をサポートしていない古いブラウザーで、その機能を使えるようにするためにコードを変換すること。

-Dオプションをつけるかどうかの違い

yarn add-Dオプションをつけると、package.jsondevDependenciesに追加されます。dependenciesに追加してもdevDependenciesに追加してもパッケージとして公開しなければ違いはありませんが、ExpressやReactは、dependenciesの追加しないとESLintに怒られます。

参考 : 【いまさらですが】package.jsonのdependenciesとdevDependencies

2. .babelrcの作成

  • Babelの設定ファイル.babelrcを作成します。
$ touch .babelrc
  • 以下の内容を書き込みます。
{"presets":[["@babel/preset-env",{"useBuiltIns":"usage","corejs":3}],["@babel/preset-react",{"useBuiltIns":"usage","corejs":3}]]}

参考 : Babel7.4で非推奨になったbabel/polyfillの代替手段と設定方法

3. index.jsの編集

  • index.jsの2行目の./src/ssr./views/ssrに変更して、以下のように書き換えます。
importexpressfrom'express';importssrfrom'./views/ssr';constapp=express();app.listen(3000);app.get('/',(_,res)=>{constresponse=ssr();res.send(response);});

import先のパスを変更したのは、./ssrディレクトリ内のReactで書かれたファイルをBabelでコンパイルして、Nodeで実行できるファイルとして./viewsに出力するからです。

4. ssr.jsxの編集

  • src/ssr.jsの拡張子を.jsxに変更しします。
$ mv src/ssr.js src/ssr.jsx

.jsx : ReactでJSX(HTMLライクな記法)を用いる場合は、拡張子を.jsxにします。

  • renderToStringを使用して、以下のように書き換えます。
importReactfrom'react';import{renderToString}from'react-dom/server';importCountUpfrom'./CountUp';// React ElementをHTMLに変換constssr=()=>(`
  <html>
    <head>
      <title>CountUp</title>
      <meta charset="utf-8"/>
    </head>
    <body>
      <div id="app">
        ${renderToString(<CountUp/>)}</div>
    </body>
  </html>
`);exportdefaultssr;

renderToString()を使用すると、Reactのコンポーネントを文字列に変換することができます。上のコードでは、<CountUp />コンポーネントを文字列に変換して、ssr()で返す文字列の中に埋め込んでいます。

5. CountUp.tsxの作成

  • src/CountUp.jsxを作成します。
$ touch src/CountUp.jsx
  • 以下の内容を書き込みます。
importReact,{useState}from'react';constCountUp=()=>{const[count,setCount]=useState(0);return(<><h1>{count}</h1><buttontype="button"onClick={()=>setCount(count+1)}>+</button><p>{newDate().toTimeString()}</p></>);};exportdefaultCountUp;

useStateは、React Hooksの機能の一つです。上の例だと、countをState(状態)、setCount()をStateを更新する関数として宣言しています。stateの初期値はuseState()の引数で決まるので0です。Stateは絶対に直接変更せず、countを1増やす場合はsetCount(count + 1)のようにします。

<></>で囲んでいるのは、Reactコンポーネントの返り値は何かのタグで囲っていないとエラーが出るためです。

6. package.jsonの編集

  • Babelを実行するためのコマンド(babel)をpackage.jsonに書き込みます。
"scripts":{"start":"node -r esm index.js","babel":"babel src -x '.js,.jsx' -d views"},

-xオプションで対象とするファイルの拡張子を指定し、-dオプションで書き出し先のディレクトリを指定しています。

上のコマンドだと、『srcディレクトリに含まれる.js.jsx拡張子のファイルをコンパイルしてviewsディレクトリに書き出す』という意味になります。

7. Babelの実行

  • Babelを実行します。
$ yarn babel

すると、Babelによってsrcディレクトリのssr.jsxCountUp.jsxがviewsディレクトリに.js拡張子で書き出されるはずです。

8. Expressの起動

  • Expressを起動します。
$ yarn start

表示されるUIはSTEP1と同じです。

9. Gitにコミットする

忘れないようにgitにコミットしておきます。

$ git add .
$ git commit -m "STEP2 - Reactを使う"

ファイル構成

.
├── .git
├── node_modules
├── .babelrc           ← 新しく作った。Babelの設定ファイル。
├── .eslintrc
├── .gitignore
├── .vscode
│   └── settings.json
├── index.js           ← 編集した。ssr.jsのパスを変更。
├── package-lock.json
├── package.json       ← 編集した。babelコマンドを追加。
├── src
│   ├── CountUp.jsx    ← 編集した。ReactでState管理してカウントする。
│   └── ssr.jsx        ← 編集した。CountUpを文字列に変換する。
├── views              ← Babelによって生成される。
│   ├── CountUp.js    ← Babelによって生成される。
│   └── ssr.js      ← Babelによって生成される。
└── yarn.lock

ポイントはssr.jsx内で、renderToString()関数を使ってJSXをHTMLとして解釈できる文字列に変換しているところです。

しかし、+を押してもまだ何も起こりません。それもそのはず、サーバーでJavaScriptをHTMLに変換してクライアントに返しているだけで、クライアントであるブラウザではJavaScriptが動いていないからです。クライアントに返されるHTMLは、STEP1と同じです。

つまり、この記事の冒頭に載せたSSRの図は正確ではなく、修正すると以下のようになります。

SSRの図2

STEP3 - Webpackを使う

Expressで配信する静的なHTMLとは別に、クライアントで動作させるためのJavaScriptファイルも生成し、クライアントに読み込ませる必要があります。Webpackを使えば、Babelでコンパイルするのと同時に、複数のJavaScriptファイルを1つにバンドルすることができます。

1. 必要なパッケージのインストール

$ yarn add -D webpack webpack-cli babel-loader

インストールしたパッケージの概要は以下の通りです。

  • Webpack : 複数のJavaScriptなどのファイルを1つのファイルにバンドルします。
    • webpack-cli : Webpackをコマンドラインから使用できるようにします。
    • babel-loader : WebpackでBabelを使用してJavaScriptをコンパイルできるようにします。

2. エントリーポイントの作成

  • src/client.jsxを作成します。
$ touch src/client.jsx
  • 以下の内容を書き込みます。
importReactfrom'react';import{hydrate}from'react-dom';importCountUpfrom'./CountUp';// idがappの部分をhydrateで描画するhydrate(<CountUp/>,document.querySelector('#app'));

SSRでないReactアプリではrender()を使用してUIを描画しますが、SSRを使用してサーバーで描画されている場合はhydrate()を使用します。サーバーで描画した部分を、ブラウザで再描画しないようにするためです。

3. webpack.config.jsの作成

Webpackの設定ファイルwebpack.config.jsを作成します。

$ touch webpack.config.js

以下の内容を書き込みます。

constpath=require('path');module.exports={resolve:{// 対象にする拡張子の指定extensions:['.js','.jsx'],},entry:{// エントリーポイントの指定client:'./src/client.jsx',},output:{// アウトプット先のディレクトリを指定(assets)path:path.resolve(__dirname,'assets'),// アウトプットするファイルの名前を指定(名前は変更しない)filename:'[name].js',},module:{rules:[{// 拡張子が.jsか.jsxだった場合に適用するルールtest:/\.js(x?)$/,// node_modulesディレクトリ(Yarnでインストールしたパッケージが入ってる)は除外exclude:/node_modules/,use:[{// babelの設定loader:'babel-loader',options:{presets:[['@babel/preset-env',{useBuiltIns:'usage',corejs:3,},],['@babel/preset-react',{useBuiltIns:'usage',corejs:3,},],],},},],},],},};

4. Webpackを実行するコマンドの追加

  • Webpackを実行するためのコマンド(build)をpackage.jsonに書き込みます。
"scripts":{"start":"node -r esm index.js","babel":"babel src -x '.js,.jsx' -d views","build":"webpack --mode development",},

--modeオプションでproductiondevelopmentを指定しないと、ビルド時に警告が出ます。

5. Webpackの実行

  • Webpackを実行します。
$ yarn build

すると、Babelによってsrcディレクトリのファイルが、assetsディレクトリにclient.jsという1ファイルにまとめて書き出されます。client.jsと関係があるファイルだけがまとめられるので、ssr.jsはバンドルに含まれません。

6. client.jsのルーティング

Webpackでバンドルしたassets/client.jsをクライアントで読み込む必要があるので、Expressでassetsディレクトリ内のファイルを返すように設定します。

  • index.jsapp.use(express.static('assets'));を追加して、以下のように編集します。
importexpressfrom'express';importssrfrom'./views/ssr';constapp=express();app.listen(3000);app.use(express.static('assets'));app.get('/',(_,res)=>{constresponse=ssr();res.send(response);});

これで、http://localhost/client.jsにアクセスすることで、assets/client.jsを取得することができるようになりました。

7. ssr.jsxの編集

  • src/ssr.jsx<script src="./client.js"></script>を追加します。
importReactfrom'react';import{renderToString}from'react-dom/server';importCountUpfrom'./CountUp';constssr=():string=>(`
  <html>
    <head>
      <title>CountUp</title>
      <meta charset="utf-8"/>
    </head>
    <body>
      <div id="app">
        ${renderToString(<CountUp/>)}</div>
      <script src="./client.js"></script>
    </body>
  </html>
`);exportdefaultssr;

これで、ブラウザでWebpackでバンドルしたclient.jsが読み込まれることになります。

8. Babelの実行

"6."ssr.jsのを編集したので、Babelを実行して、サーバーで生成されるHTMLに<script src="./client.js"></script>が追加されるようにします。

  • Babelを実行します。
$ yarn babel

9. Expressの起動

  • Expressを起動します。
$ yarn start

表示は同じですが、+を押すことでカウントが増え、時刻表示も更新されるのが分かります。

countup.gif

10. Gitにコミットする

忘れないようにgitにコミットしておきます。

$ git add .
$ git commit -m "STEP3 - Webpackを使う"

ファイル構成

.
├── .git
├── node_modules
├── .babelrc
├── .eslintrc
├── .gitignore
├── .vscode
│   └── settings.json
├── assets               ← Webpackによって生成される。
│   └── client.js        ← Webpackによって生成される。srcをバンドルしたもの。
├── index.js             ← 編集した。client.jsを配信するように設定。
├── package-lock.json
├── package.json         ← 編集した。buildコマンドを追加。
├── src
│   ├── CountUp.jsx
│   ├── client.jsx       ← 新しく作った。ブラウザで読み込む用のJavaScript。
│   └── ssr.jsx          ← 編集した。client.jsをブラウザで読み込む。
├── views
│   ├── CountUp.js
│   ├── client.js        ← Babelによって生成される。が、サーバーでは必要ないので使われない。
│   └── ssr.js
├── webpack.config.js    ← 新しく作った。Webpackの設定ファイル。
└── yarn.lock

STEP4 - TypeScriptを使う

JavaScriptは動的型付けを行います。例えば、数値と文字を足し算するとこうなります。

consta=1;constb='2';constsum=a+b;// 12

これくらい簡単なプログラムだと素晴らしいことなんですが、プロジェクトが大きくなるとバグが起きやすくなります。一方、TypeScriptを使用して静的型付けを行うと、実行前にエラーを出してくれます。

consta=1;constb='2';constsum:number=a+b;// Error

sumの型をnumber(数値)として指定しているので、数値文字列の足し算に対してエラーを出してくれています。

1. 必要なパッケージのインストール

$ yarn add -D typescript @babel/preset-typescript

インストールしたパッケージの概要は以下の通りです。

  • Typescript : JavaScriptで静的型付けをできるようにした言語です。
    • @babel/preset-typescript : TypeScriptをBabelでコンパイルできるようにするプリセットです。

2. ESLintの設定

  • TypeScriptを扱うためのパーサをインストールします。
$ yarn add -D @typescript-eslint/parser
  • .eslintrcを以下のように編集します。
{"parser":"@typescript-eslint/parser","extends":["airbnb","airbnb/hooks"],"plugins":["@typescript-eslint"],"settings":{"import/extensions":[".js",".jsx",".ts",".tsx"],"import/resolver":{"node":{"extensions":[".js",".jsx",".ts",".tsx"]}}},"rules":{"react/jsx-filename-extension":["error",{"extensions":[".js",".jsx",".ts",".tsx"]}],"import/extensions":["error","always",{"js":"never","jsx":"never","ts":"never","tsx":"never"}]}}

TypeScriptをパース(構文解析)するために、parserを指定しています。継承しているairbnbには@typescript-eslintが含まれていないので、TypeScriptのプラグインも追加しました。

.tsxファイルでもJSXを扱えるようにするためにreact/jsx-filename-extensionルールを指定しています。

参考 : @typescript-eslintでtypescriptのlintをeslintで行いつつ、airbnbの設定でいきましょう的なお話

ESLint関係の設定は難しいので、動いてるからよし!ぐらいの心持ちで行きましょう。

3. 定義ファイルのインストール

Reactの型定義ファイルもインストールしておきます。

$ yarn add -D @types/react @types/react-dom

型定義ファイル : TypeScriptで型を扱うために、Reactなどの外部モジュールの型の定義が必要です。型定義ファイルは自作することもできます。

4. .babelrcの編集

  • .babelrc@babel/preset-typescriptを追加して、以下のように編集します。
{"presets":[["@babel/preset-env",{"useBuiltIns":"usage","corejs":3}],["@babel/preset-react",{"useBuiltIns":"usage","corejs":3}],"@babel/preset-typescript"]}

5. webpack.config.jsの編集

  • webpack.config.js.tstsxの拡張子を処理するように書き換えます。

  • また、Babelでコンパイルできるように、4.で行なった.babelrcと同じく@babel/preset-typescriptを追加します。

constpath=require('path');module.exports={resolve:{// 対象にする拡張子の指定(パッケージも含まれるので、.jsは必須)extensions:['.js','.jsx','.ts','.tsx'],},entry:{// エントリーポイントの指定client:'./src/client.tsx',},output:{// 書き出し先のディレクトリを指定(assets)path:path.resolve(__dirname,'assets'),// 書き出すファイルの名前を指定(名前は変更しない)filename:'[name].js',},module:{rules:[{// 拡張子が.jsか.jsxだった場合に適用するルールtest:/\.ts(x?)$/,// node_modulesディレクトリは除外exclude:/node_modules/,use:[{// babelの設定loader:'babel-loader',options:{presets:[['@babel/preset-env',{useBuiltIns:'usage',corejs:3,},],['@babel/preset-react',{useBuiltIns:'usage',corejs:3,},],'@babel/preset-typescript',],},},],},],},};

6. ssr.jsxの編集

  • src/ssr.jsxの拡張子を.tsxに変更します。
$ mv src/ssr.jsx src/ssr.tsx
  • src/ssr.tsx`を以下の様に書き換えます。

: stringという返り値の型を追加しました。

importReactfrom'react';import{renderToString}from'react-dom/server';importCountUpfrom'./CountUp';// 返り値の型を指定しました。constssr=():string=>(`
  <html>
    <head>
      <title>CountUp</title>
      <meta charset="utf-8"/>
    </head>
    <body>
      <div id="app">
        ${renderToString(<CountUp/>)}</div>
      <script src="./client.js"></script>
    </body>
  </html>
`);exportdefaultssr;

7. CountUp.jsxの編集

  • src/CountUp.jsxの拡張子を.tsxに変更します。
$ mv src/CountUp.jsx src/CountUp.tsx
  • src/CountUp.tsxに以下の内容を書き込みます。: JSX.Elementという返り値の型を追加しました。
importReact,{useState}from'react';constCountUp=():JSX.Element=>{// 返り値の型を指定しました。const[count,setCount]=useState(0);return(<><h1>{count}</h1><buttontype="button"onClick={()=>setCount(count+1)}>+</button><p>{newDate().toTimeString()}</p><>
  );
};

export default CountUp;

8. client.jsxの編集

  • src/client.jsxの拡張子を.tsxに変更します。
mv src/client.jsx src/client.tsx

9. package.jsonの編集

  • Babelの対象とする拡張子を、.js,.jsxから.ts,.tsxに編集します。
"scripts":{"start":"node -r esm index.js","build":"babel src -x '.ts,.tsx' -d views"},

10. Babelの実行

  • Babelを実行します。
$ yarn babel

Babelによって、srcディレクトリのgreeting.tsxssr.tsxがviewsディレクトリに.js拡張子で書き出されます。

11. Webpackの実行

  • Webpackを実行します。
$ yarn build

Webpackによって、srcディレクトリのファイルがclient.tsxをエントリーポイントとしてバンドルされ、viewsディレクトリにclient.jsという1ファイルにまとめて書き出されます。

12. Expressの起動

yarn startを実行してhttp://localhost:3000/を開きます。

$ yarn start

見た目は変わっていませんが、TypeScriptを使用することで強固なプログラムになりました。

13. Gitにコミットする

忘れないようにgitにコミットしておきます。

$ git add .
$ git commit -m "STEP4 - TypeScriptを使う"

ファイル構成

.
├── .git
├── node_modules
├── .babelrc            ← 編集した。TypeScriptをコンパイルする。
├── .eslintrc           ← 編集した。TypeScriptを構文チェックする。
├── .gitignore
├── .vscode
│   └── settings.json
├── assets
│   └── client.js
├── index.js
├── package-lock.json
├── package.json        ← 編集した。buildコマンドをTypeScriptに対応。
├── src
│   ├── CountUp.tsx     ← 編集した。拡張子を変更と型の追加。
│   ├── client.tsx      ← 編集した。拡張子を変更。
│   └── ssr.tsx         ← 編集した。拡張子を変更と型の追加。
├── views
│   ├── CountUp.js
│   ├── client.js
│   └── ssr.js
├── webpack.config.js   ← 編集した。TypeScriptをバンドルする。
└── yarn.lock

お疲れ様でした!:tada::tada::tada:

以上がモダンなTypeScript & ReactでのSSR/SPA開発です。

しっかりと開発環境を作ったので、複雑なプロジェクトにも応用できると思います。

参考

React Server-Side Rendering Example : めちゃくちゃ参考にしました。SSRありとなしをエンドポイントで分けているので、違いを体感できて面白いです。

最後に

分からないところ、ご指摘等あれば、お気軽にコメントやTwitterまでご連絡ください。

本当はContextを使うところまでやりたかったですが、分かりづらくなりそうだったのでやめました。

ちなみにサボテンのIQはらしいです:cactus:


Electronを使ってオセロアプリを作ろう #1

$
0
0

ElectronとはHTML、CSS、JavaScriptなどのWeb技術を使用してデスクトップアプリを作ることができるフレームワークです。
これを使用してプログラミング課題としてよく使われるオセロを実装してみたいと思います。
また今回はテストツールとしてJest、Cypressを使用します。

01.環境構築
02.基本開発

Electron環境設定

mac環境でパッケージ管理システムのhomebrew( https://brew.sh/index_ja )をインストールし以下のコマンドを 実行します。

brew install node 

Windowsの場合homebrewは使わず https://nodejs.org/download/ )からダウンロードを行いダウンロードしてください。

Electronのインストールはプロジェクトローカルに行いグローバル環境に影響が出ないように行います。 (任意のディレクトリを作成してくだい。)

mkdir electron_reversi 
cd electron_reversi 

npm初期化

パッケージの設定が記載されるpackage.jsonを作成するコマンドです。
後ほど書き換えも可能な為まずはデフォルトのまま作成してください。

npm init

package.jsonのmainを書き換え以下の様にします。
こちらはnpm initではentry pointとされていた部分です。
インストール状態の確認のため一旦src配下の呼び出しにしますが後でまた変更します。

 "main": “./main/main.js",

Electronのインストール

npmを使用しインストールします。

npm install --save-dev electron@5.0.4

※--save-devはローカルインストールを行うオプション です。
※@〜はバージョン指定です。指定なしで最新版をダウンロード出来ます。

起動

まずは起動できることを確認します。
npmから呼び出されるメインプロセスとメインプロセスから呼び出されるレンダラープロセスを作ります。
詳細を知りたい方はElectron公式ページをどうぞ。

main/main.js
// Electronのモジュールconst{app,BrowserWindow,ipcMain}=require('electron');// Electronの初期化完了後に実行app.on("ready",()=>{ //ウィンドウサイズを1280*720(フレームサイズを含まない)に設定する varmainWindow=newBrowserWindow({width:1280,height:720,useContentSize:true});//タイトル設定mainWindow.setTitle("reversi"); //使用するhtmlファイルを指定する mainWindow.loadURL(`file://${__dirname}/../dist/reversi.html`); // ウィンドウが閉じられたらアプリも終了 mainWindow.on("closed",()=>{  mainWindow=null; });});// 全てのウィンドウが閉じたら終了app.on("window-all-closed",()=>{ app.quit();});// メニューはリスタートのみconsttemplateMenu=[{label:'Game',submenu:[{label:'Restart',accelerator:'CmdOrCtrl+R',click(item,focusedWindow){if(focusedWindow)focusedWindow.reload()},}]}];
 ./dist/reversi.html
<!DOCTYPE html><html><head>  <metacharset="UTF-8"><style>html{overflow:hidden;}</style></head><body> <p>Hello World</p></body></html>

実際に起動してみます。

./node_modules/.bin/electron .

このままだと起動コマンドが分かりづらいためnpm(package.json)にコマンドを登録します。
./node_modules/.bin/へのパスが通っているものとして記載できます。

package.json
"scripts":{  "start":"electron ."},

今後のアプリ起動は以下の様になります。

npm start

起動できなかった場合は一度見直してみてください。
エラーが出ている場合には検索してみてください。

トランスパイル

オセロでは石を置く判断や勝敗、パスなど複数の関数が必要になります。
分かりやすくソースを管理するためにes6に準じたファイル分割、クラス作成します。
現在のelectronではes6をそのまま実行できない為、webpack・babelで変換処理(=トランスパイル)を行います。
( https://ics.media/entry/16028/ )
インストール

npm install -D webpack webpack-cli babel-loader @babel/core @babel/preset-env

起動コマンドをnpm に登録

package.json
"scripts":{  "start":"electron .",  "build":"webpack", "watch":"webpack -w"}

webpackの設定を記載

webpack.config.js
varmainConfig={ mode:"development", node:{  __dirname:false,  __filename:false }, module:{  rules:[   {    // 拡張子 .js の場合     test:/\.js$/,    exclude:/node_modules/,    use:[     {      // Babel を利用する       loader:"babel-loader"     }    ]   }  ] }, target:'electron-main', entry:{  "main":"./main/main.js" }};varrendererConfig={ mode:"development", node:{  __dirname:false,  __filename:false }, module:{  rules:[   {    // 拡張子 .js の場合     test:/\.js$/,    exclude:/node_modules/,    use:[     {      // Babel を利用する       loader:"babel-loader"     }    ]   }  ] }, target:'electron-renderer', entry:{  "reversi":"./renderer/reversi.js" }};module.exports=[mainConfig,rendererConfig];.babelrc{ presets:["@babel/preset-env"], "env":{  "test":{         "plugins":["transform-es2015-modules-commonjs"]  } }}

トランスパイル用に空のjsファイルを作成し、
トランスパイルを実行します。

touch renderer/reversi.js 
npm run build 

出力されたらエントリーポイントを出力ディレクトリに変更します。

package.json
 "main":“./dist/main.js", 

これで起動すると先ほどと同じように出力が行われるはずです。

常駐トランスパイル
変更毎に手動でトランスパイルすると手間な時はターミナルに常駐させることもできます。
ソースの保存毎に自動でトランスパイルが走ります。
起動用のターミナルとは別に常駐ターミナルを開いておきましょう。

npm run watch 

テスト設定

ここからはテスト駆動開発のための設定を行なっていきます。
テストツールのJEST、Electron用テストフレームワークSpectronをインストールします。
またbabelがテスト時にも動作するようプラグインも追加しておきます。

npm install --save-dev jest spectron babel-jest babel-plugin-transform-es2015-modules-commonjs 

テストコマンドもnpmへ記載します。

"scripts": {
   "start": "electron .",
   "build": "webpack",
 "watch": "webpack -w", 
   "test": "jest"
},

mainプロセステスト

アプリを起動しウィンドウが一つ表示されることを確認するテストを記載します。

test/main.test.js
constApplication=require('spectron').Applicationconstassert=require('assert')constelectronPath=require('electron')// Require Electron from the binaries included in node_modules. describe('Window',function(){ //タイムアウト時間を30秒に設定  jest.setTimeout(30000) letapp //テスト開始時に動作  beforeAll(function(){  //electronアプリのインスタンス作成   app=newApplication({   path:electronPath,   args:[`/${__dirname}/..`]  });  //アプリをスタート   returnapp.start() }) //テスト終了時に動作  afterAll(function(){  //アプリをストップ   returnapp.stop() }) it("アプリケーションを起動するとウィンドウが1つ表示される",function(){  app.client.getWindowCount().then((count)=>assert.equal(count,1)) })})

テストを実行します。

npm test

PASSと表示されていればテスト成功です。
テストが動作しない場合は一度再起動することで動作することがあるようです。

rendererテスト

レンダラーはページ毎の処理を行います。
関数レベルでテスト書いてみましょう。

test/renderer.test.js
importBoardfrom'../renderer/board';describe('Board',function(){  describe('put',()=>{    it('石置き関数では1と-1が交互に置かれる',()=>{      constboard=newBoard()      board.put(1,2)      expect(board.player).toBe(-1)      board.put(2,2)      expect(board.player).toBe(1)    })  })})

npm testを実行するとCannot find moduleになります。
条件を満たすファイルを作成してみましょう。

renderer/board.js
exportdefaultclassPut_stone{ constructor(){  this.player=1; } put(x,y){  this.player=-this.player }}

テストを実行してグリーンになることを確認してください

結合テスト

表示、モジュールを組み合わせてのテストにはcypressを使います。

npm install cypress --save-dev 

実行には以下のコマンドです。
停止しない限り常駐するので書き換え等を行う場合に新しいターミナルを開いておきましょう。

./node_modules/.bin/cypress open 

出てくる画面でOK,got it!をクリックします。
cypress/integration内にあるテストモジュールが表示されます。
exampleフォルダ内には様々な例が保存されており、クリックすることでCypress用のテストサイトに対して流すことができます。
Run all specsは全てのテスト順次を流すボタンです。
cypress/integration内のexampleフォルダごと削除しても問題ありませんが
構文など調べる際には便利なのでひとまずそのままにしておきます。

Helloテスト

最初に作ったHello worldをテストしてみましょう。
保存した時点でcypressが起動済みなら自動で検知されテストが実施されます。

cypress/integration/renderer.js
describe('reversi',function(){  it('initialize',function(){    cy.visit('/dist/reversi.html')    cy.get('p').contains("Hello")  })})

testコマンドではcypress配下のテストは実行したくないため
jsonを変更します。

    "test": "jest test" 

これでやっと環境構築ができました。
次でオセロの実装を行います。
02.基本開発

npm install --save で JSON.parse エラーが出る場合は package.json が壊れているので 空 JSON でも書き込もう #npm #node

$
0
0

Error

$ npm install --save puppeteer
npm ERR! file /Users/yumainaura/.ghq/github.com/GuildWorks/insides/insides-api/package.json
npm ERR! code EJSONPARSE
npm ERR! JSON.parse Failed to parse json
npm ERR! JSON.parse Unexpected end of JSON input while parsing near ''
npm ERR! JSON.parse Failed to parse package.json data.
npm ERR! JSON.parse package.json must be actual JSON, not just JavaScript.

npm ERR! A complete log of this run can be found in:
npm ERR!     /Users/yumainaura/.npm/_logs/2019-11-04T04_04_46_126Z-debug.log

解決

echo '{}' > package.json

npm install --save puppeteer

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2665

Electronを使ってオセロアプリを作ろう #2

$
0
0

01.環境構築
02.基本開発

設計

前章では設計を行わず大雑把に環境構築を行いましたが
ここで大まかな仕様を決めます。
オセロで登場するオブジェクトはゲーム盤、石、試合ルール、プレイヤーと言ったところでしょうか?
ゲーム盤は8x8のマス目があり2人のプレイヤーが石を交互に置き合い試合をします。
それぞれのオブジェクトがさらに詳細に仕様をつめていきます。

初期状態

まずは仕様をまとめ、Jestのテストを書きます。
黒石を1、白石を-1、置かれていない場所は0とすることにします。
オセロはゲーム開始時点で中央四つに石が置かれています。
これをテストにすると下のようになります。
boardは8x8なので[3,3][4,4][3,4][4,3]が最初が石を置く位置です。

./test/reversi.test.js

import Game from '../renderer/game’ ; 
describe('Game', function () {     
    : 
  describe('constructor()', () => {
    it('初期状態で中央に石が置かれている', () => {
      const game = new Game()
      expect(game.board[3][3]).toBe(-1)
      expect(game.board[4][4]).toBe(-1)
      expect(game.board[3][4]).toBe(1)
      expect(game.board[4][3]).toBe(1)
    })
  })
}) 

boardを定義していませんので勿論エラーになるはずです。
次にこれを実装します。また盤の状態を取得できるようにゲッターも作成します。

./renderer/board.js

export default class Board { 
  constructor(){ 
    this.player = 1; 
    this.board = [ 
      [0,0,0,0,0,0,0,0], 
      [0,0,0,0,0,0,0,0], 
      [0,0,0,0,0,0,0,0], 
      [0,0,0,-1,1,0,0,0], 
      [0,0,0,1,-1,0,0,0], 
      [0,0,0,0,0,0,0,0], 
      [0,0,0,0,0,0,0,0], 
      [0,0,0,0,0,0,0,0] 
    ]; 
  } 
  put(x,y) 
    this.player = -this.player; 
   
} 

ひとまずデータの初期状態ができました。

表示

初期状態ができましたのでこれを表示してみましょう。
表示部分は仕様変更が容易になるように自動テストは簡易にしておくのが望ましいです。
ここではテストを省略して後ほど行うようにします。

描画用にhtmlにcanvasを設置します。

html

<body> 
    <canvas id="canv" width="500px" height="500px"></canvas> 
    <script src="./reversi.js" ></script> 
</body> 

javascriptを作成します。
getContextで画面上の描画領域canvから2D描画のための情報を取得します。

reversi.js

import Game from '../renderer/game' ; 
import Draw from '../renderer/draw' 
var context = document.getElementById("canv").getContext('2d'); 
var game = new Game() 
var draw = new Draw(context) 
draw.draw_discs(board.board) 

描写のメインはdraw_boardとdraw_discsです。
canvasに対して四角と円でオセロ盤を表示しています。

const COLOR_LINE = "#FFFFFF"; 
const COLOR_BOARD = "#00BB33"; 
const COLOR_WHITE = "#FFFFFF"; 
const COLOR_BLACK = "#000000"; 
const CELL_SIZE = 60; 
const DISC_SIZE = 29; 
export default class Drawing { 
  constructor(context){ 
    this.context = context 
    this.draw_board() 
  } 
  draw_board(){ 
    this.context.beginPath() 
    this.context.clearRect(0,0,500,500); 
    this.context.lineWidth = 1; 
    this.context.fillStyle = COLOR_BOARD; 
    for (var x = 0; x < 8; x++) { 
        for (var y = 0; y < 8; y++) { 
            this.context.strokeStyle = COLOR_LINE; 
            this.context.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); 
            this.context.strokeRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); 
        } 
    } 
  } 
  draw_discs(board){ 
    this.board = board 
    for (var x = 0; x < 8; x++) { 
        for (var y = 0; y < 8; y++) { 
          this.context.beginPath() 
          if (this.board[x][y] == 1 ) { 
            this.context.fillStyle = COLOR_BLACK; 
            this.context.arc( 
              x * CELL_SIZE + CELL_SIZE/2, 
              y * CELL_SIZE + CELL_SIZE/2, 
              DISC_SIZE, 
              0, 
              Math.PI*2, 
              false 
            ); 
            this.context.fill(); 
          } 
          else if (this.board[x][y] == -1 ) { 
            this.context.fillStyle = COLOR_WHITE; 
            this.context.arc( 
              x * CELL_SIZE + CELL_SIZE/2, 
              y * CELL_SIZE + CELL_SIZE/2, 
              DISC_SIZE , 
              0, 
              Math.PI*2, 
              false 
            ); 
            this.context.fill(); 
          } 
        } 
    } 
  } 
} 

石を置く

put関数にオセロルールを実装していきます。
まずはテストに仕様を記述しましょう。

./test/reversi.test.js

  describe('put 石置き関数', () => { 
    it('異色を挟んで反対側に同色がある場合石が置け、ターンが切り替わる', () => {
      const game = new Game()
      game.put(2,3)
      expect(game.board[2][3]).toBe(1)
      game.put(4,2)
      expect(game.board[4][2]).toBe(-1)
    })
    it('置いてあるマスには石は置けずターンが変わらない', () => {
      const game = new Game()
      const def_player = game.player
      game.put(3,3)
      expect(def_player).toBe(game.player)
      game.put(3,4)
      expect(def_player).toBe(game.player)
      game.put(4,3)
      expect(def_player).toBe(game.player)
      game.put(4,4)
      expect(def_player).toBe(game.player)
    })
    it('異色が触れてない場所には置けない', () => {
      const game = new Game()
      const def_player = game.player
      game.put(0,0)
      expect(def_player).toBe(game.player)
      game.put(0,7)
      expect(def_player).toBe(game.player)
      game.put(7,0)
      expect(def_player).toBe(game.player)
      game.put(7,7)
      expect(def_player).toBe(game.player)
    })
  }) 

毎回mainのテストが実行される必要はないためスキップするようにしましょう

./test/main.test.js

describe.skip('Window', function () { 

実行時にskipedが1になりました。
また石置き関数がfailになりました。

実装

実装を進めていきます。 以下の実装には間違いがあります。
jestもfailのままです。
テストや出力を使って間違いを特定してみましょう。

  put(x,y) { 
    if( this.canPut(x,y) ){ 
      this.board[x][y] = this.player 
      this.player = -this.player 
    } 
  } 
  canPut(x,y) { 
    if( this.board[x][y] != 0 ) return false 
    for( let di_x = -1 ; di_x <= 1 ; di_x++ ){ 
      for( let di_y = -1 ; di_y <= 1 ; di_y++ ){ 
        //中心以外の8方向をチェックする 
        if ( di_x === 0 && di_y === 0 ) continue 
        //盤外の場合は次へ 
        if ( x+di_x < 0 || y+di_y < 0 || 7 < x+di_x || 7 < y+di_y ) continue 
        //接してる石が相手色でなければ次へ 
        if ( this.board[x+di_x][y+di_y] === -this.player ) continue 
        //再帰チェック 
        if( this.canPutSub( x+di_x, di_x, y+di_y, di_y ) ) return true 
      } 
    } 
    return false 
  } 
  canPutSub(x,di_x,y,di_y) { 
    //盤外の場合はfalseを返しこの方向のチェックを終わる 
    if ( x+di_x < 0 || y+di_y < 0 || 7 < x+di_x || 7 < y+di_y ) return false 
    //石がない場合はfalseを返しこの方向のチェックを終わる 
    if ( this.board[x+di_x][y+di_y] === 0 ) return false 
    //自色があればtrueを返す 
    if ( this.board[x+di_x][y+di_y] === this.player ) return true 
    //次の石のチェックへ 
    return this.canPutSub( x+di_x, di_x, y+di_y, di_y ) 
  } 

一番最下層の再帰関数からチェックしてみます。

    it.only('再帰関数テスト', () => { 
      const board = new Board() 
      expect(game.canPutSub(3,0,3,1)).toBeTruthy()
      expect(game.canPutSub(4,0,1,-1)).toBeFalsy()
      expect(game.canPutSub(5,1,2,0)).toBeFalsy()
      expect(game.canPutSub(3,-1,2,0)).toBeFalsy()

      expect(game.canPutSub(5,1,3,1)).toBeFalsy()
      expect(game.canPutSub(5,1,1,-1)).toBeFalsy()
      expect(game.canPutSub(3,-1,3,1)).toBeFalsy()
      expect(game.canPutSub(3,-1,1,-1)).toBeFalsy()
    }) 

総当たりでcanPut関数を実行してみます。
再帰関数のテストは削除するかonlyを外しましょう

    it.only('canPut関数テスト', () => { 
      const game = new Game() 
      for( let x = 0; x <= 7; x++ ){ 
        for( let y = 0; y <= 7; y++ ){ 
          console.log( 'x:' + x + ' y:' + y + ' put?:' + game.canPut(x,y)) 
        } 
      } 
    }) 

trueの個数が多いです。
本来であれば4-2,2-4,5-3,3-5の四つのみが置けるはずです。
1,1に対して全方向の再帰関数を実行してみましょう。

    it('再帰関数テスト', () => { 
      const game = new Game() 
      console.log(board.canPutSub(1,0,2,1)) 
      console.log(board.canPutSub(1,0,0,-1)) 
      console.log(board.canPutSub(2,1,1,0)) 
      console.log(board.canPutSub(0,-1,1,0)) 
      console.log(board.canPutSub(2,1,2,1)) 
      console.log(board.canPutSub(2,1,0,-1)) 
      console.log(board.canPutSub(0,-1,2,1)) 
      console.log(board.canPutSub(0,-1,0,-1)) 
    }) 

斜めだけTrueになっているようです。
ということはどこかで+-を間違っている?

見つけました。相手色でなければという表現が悪かったのかもしれません。

        //接してる石が相手色でなければ次へ 
        if ( this.board[x+di_x][y+di_y] === -this.player ) 
continue

ここは分かりやすくゲッターを定義してみましょう

  get enemy(){
    return -this.player
  }
        //接してる石が相手色でなければ次へ
        if ( this.board[x+di_x][y+di_y] != this.enemy ) continue

少し分かりやすくなったのではないでしょうか?
ログを表示していた部分はskipに設定し他のテストを実行します。
5passedでエラーはなくなるになるはずです。

石を裏返す

次は石を裏返す処理を実装します。
テストは2行追加しましょう

    it('異色を挟んで反対側に同色がある場合石が置け、ターンが切り替わる', () => { 
      const board = new Board() 
      board.put(4,2) 
      expect(board.board[4][2]).toBe(1) 
      expect(board.board[4][3]).toBe(1) 
      board.put(5,4) 
      expect(board.board[5][4]).toBe(-1) 
      expect(board.board[5][4]).toBe(-1) 
    }) 

実装は このようになります。

  turnDiscs(x,y) {
    if( this.board[x][y] != 0 ) return false
    for( let di_x = -1 ; di_x <= 1 ; di_x++ ){
      for( let di_y = -1 ; di_y <= 1 ; di_y++ ){
        //中心以外の8方向をチェックする
        if ( di_x === 0 && di_y === 0 ) continue
        //盤外の場合は次へ
        if ( x+di_x < 0 || y+di_y < 0 || 7 < x+di_x || 7 < y+di_y ) continue
        //接してる石が相手色でなければ次へ
        if ( this.board[x+di_x][y+di_y] != this.enemy ) continue
        //再帰チェック
        if( this.canPutSub( x+di_x, di_x, y+di_y, di_y ) ){
          this.turnDiscsSub( x, di_x, y, di_y )
        }
      }
    }
    return false
  }
  turnDiscsSub( x, di_x, y, di_y ) {
    //自色があればtrueを返す
    if ( this.board[x+di_x][y+di_y] === this.player ) return true
    this.board[x+di_x][y+di_y] = this.player
    //次の石のチェックへ
    return this.turnDiscsSub( x+di_x, di_x, y+di_y, di_y ) 
  }

テストは通りましたが配列の中身がどのようになっているのか気になります。次の文で表示してみましょう。

      console.log(board.board.map(x=>x.map(y=>y==-1?2:y).join('') ) )

mapは配列内部全てに処理をする関数です。二重にして二次元配列全てに処理をしています。
さらにアロー演算子と三項演算子を併用して-1を2に置き換えることで出力時のマス目ずれないようにしています。
joinは配列を一つにすることで無駄な改行が入らないようにしています。

マウス処理

オセロのルールは一部実装されましたがまだクリックしても石が置けません。マウス処理を追加しましょう。
addEventListenerでマウスをクリックし離した時点で処理を実行されるようにします。

renderer/reversi.js

import Game from '../renderer/game' ;
import Draw from '../renderer/draw'
var context = document.getElementById("canv").getContext('2d');

context.canvas.addEventListener('mouseup', ev_mouseClick)
var game = new Game()
var draw = new Draw(context)
draw.draw_discs(game.board)

function ev_mouseClick(e) {
  let x = Math.floor((e.clientX-e.target.getBoundingClientRect().top)/60)
  let y = Math.floor((e.clientY-e.target.getBoundingClientRect().left)/60)
  game.put(x,y)
  draw.draw_board()
  draw.draw_discs(game.board)
}

置けるようになりましたか?
ここまでくればなんとか人対人での勝負はできます。

TurnEnd

何度か打ってみると気づきますが、PASS機能がなく途中で打てなくなってしまうことがあります。また、両プレイヤーが置けない状態となったらその時点で勝敗が決しますが終わりもありません。
これらのルールを実装しましょう。
以下のサイトを参考にテストを作成しましょう。
オセロ豆知識

renderer.test.js

  describe('pass', () => {
    it('最速PASS手順', () => {
      const game = new Game()
      game.put(4,5)
      game.put(5,5)
      game.put(2,3)
      game.put(4,6)
      game.put(4,7)
      game.put(3,7)
      game.put(6,5)
      const def_player = game.player
      game.put(5,7)
      //PASSされて同じプレイヤーに戻ること
      expect(def_player).toBe(game.player)
      console.log(game.board.map(x=>x.map(y=>y==-1?2:y).join('') ) )
    })
    it('白全滅' ,() => {
      const game = new Game()
      game.put(4,5)
      game.put(5,3)
      game.put(4,2)
      game.put(3,5)
      game.put(2,4)
      game.put(5,5)
      game.put(4,6)
      game.put(5,4)
      game.put(6,4)
      //白が全滅していることを確認
      expect(game.disc_count(-1)).toBe(0)
      //試合が終了するとturnEndがTrueを返す
      expect(game.turnEnd()).toBeTruthy()
    })
    it('黒全滅' ,() => {
      const game = new Game()
      game.put(4,5)
      game.put(5,5)
      game.put(5,4)
      game.put(3,5)
      game.put(2,4)
      game.put(1,3)
      game.put(2,3)
      game.put(5,3)
      game.put(3,2)
      game.put(3,1)
      //黒が全滅していることを確認
      expect(game.disc_count(1)).toBe(0)
      //試合が終了するとturnEndがTrueを返す
      expect(game.turnEnd()).toBeTruthy()
    })
  })

PASSを実装するにはプレイヤーが置けるかどうか判断する必要があります。
まずは盤面における場所があるか確認する処理を追加しましょう。

game.js

  canPutChecker(){
    let canPutBoard = [
      [0,0,0,0,0,0,0,0],
      [0,0,0,0,0,0,0,0],
      [0,0,0,0,0,0,0,0],
      [0,0,0,0,0,0,0,0],
      [0,0,0,0,0,0,0,0],
      [0,0,0,0,0,0,0,0],
      [0,0,0,0,0,0,0,0],
      [0,0,0,0,0,0,0,0]
    ]
    for( let x = 0; x <= 7; x++ ){
      for( let y = 0; y <= 7; y++ ){
        canPutBoard[x][y] = this.canPut(x,y)
      }
    }
    return canPutBoard
  }

関数の確認のためにテストを書きます。
この辺りはテストがしづらくなるため一度consoleログで確認し問題がなければ比較式で書き直すという方法で作成しています。二つ目のチェックを比較式に直してみてください。

renderer.test.js

    it('チェックXチェック' ,() => {
      const game = new Game()
      expect(game.canPutChecker().map(x=>x.map(y=>y==true?1:0) ) ).toStrictEqual(
        [
          [0,0,0,0,0,0,0,0],
          [0,0,0,0,0,0,0,0],
          [0,0,0,1,0,0,0,0],
          [0,0,1,0,0,0,0,0],
          [0,0,0,0,0,1,0,0],
          [0,0,0,0,1,0,0,0],
          [0,0,0,0,0,0,0,0],
          [0,0,0,0,0,0,0,0]
        ]
      )
      game.put(4,5)
      console.log(game.canPutChecker().map(x=>x.map(y=>y==true?1:0).join(',') ) )
    })

置けるかどうかのチェックが出来るようになったのでTurnEnd関数を作成します。

game.js

  disc_count(player){
    let count=0
    //配列全てをサマリして置ける箇所数を出す
    this.board.forEach(x=>x.forEach(y=>y==player?count++:null))
    return count
  }
  turnEnd(){
    let sum=0
    //配列全てをサマリして置ける箇所数を出す
    this.canPutChecker().forEach(x=>x.forEach(y=>sum+=y))
    if( sum == 0 ){
      //プレイヤーをPASS
      this.player = -this.player
      //配列全てをサマリして置ける箇所数を出す
      sum = 0
      this.canPutChecker().forEach(x=>x.forEach(y=>sum+=y))
      if( sum == 0){
        return true
      }
    }
    return false
  }

ここでテストを実行し問題ないことを確認してください。
あとはこれらを画面に反映させます。

reversi.js

import Game from '../renderer/game' ;
import Draw from '../renderer/draw' ;
var context = document.getElementById("canv").getContext('2d');

context.canvas.addEventListener('mouseup', ev_mouseClick)
var game = new Game()
var draw = new Draw(context)
draw.draw_discs(game.board)

function ev_mouseClick(e) {
  let x = Math.floor((e.clientX-e.target.getBoundingClientRect().top)/60)
  let y = Math.floor((e.clientY-e.target.getBoundingClientRect().left)/60)
  game.put(x,y)
  draw.draw_board()
  draw.draw_discs(game.board)
  if(game.turnEnd()){
    alert(
      game.disc_count(1)>game.disc_count(-1)?"黒の勝利です":
      game.disc_count(1)<game.disc_count(-1)?"白の勝利です":
      "ドローです"
    )
    game = new Game()
    draw.draw_board()
    draw.draw_discs(game.board)
  }
}

これで実装が終わりと行きたいところですが、実際に起動してみると最後の石を置いた時点でアラートが出てしまい石が置かれず勝敗が出てしまいます。
alert処理はキャンバスのリフレッシュをブロックしてしまうためでこれを防ぐためにalert処理を非同期処理にします。
canvas要素の基本的な使い方まとめ

reversi.js

    setTimeout(()=>{
      alert(
        game.disc_count(1)>game.disc_count(-1)?"黒の勝利です":
        game.disc_count(1)<game.disc_count(-1)?"白の勝利です":
        "ドローです"
      )
      game = new Game()
      draw.draw_board()
      draw.draw_discs(game.board)
    },0)

setTimeoutを使ってアラートも非同期処理としてみました。
こちらの方法でも問題なく動作するかと思います。
しかし、こちらの書き方ではalert処理の再利用が考えられておらず後続処理の実行タイミングも明示的ではありません。
遅延実行する形に変更します。

reversi.js

function ev_mouseClick(e) {
  let x = Math.floor((e.clientX-e.target.getBoundingClientRect().top)/60)
  let y = Math.floor((e.clientY-e.target.getBoundingClientRect().left)/60)
  //alertを非同期としてPromiseでラップする
  //resolveはthenメソッドに渡された処理を実行する
  let alertWithNoBlock = msg => new Promise(
    (resolve, reject) => setTimeout(() => resolve(alert(msg)), 0));
  game.put(x,y)
  draw.draw_board()
  draw.draw_discs(game.board)
  if(game.turnEnd()){
    alertWithNoBlock(
      game.disc_count(1)>game.disc_count(-1)?"黒の勝利です":
      game.disc_count(1)<game.disc_count(-1)?"白の勝利です":
      "ドローです"
    ).then(result => {
      game = new Game()
      draw.draw_board()
      draw.draw_discs(game.board)
    });
  }
}

これで基本的な実装に関しては終了です!
交互に打てば最後の勝利判定までしてくれます。

次の章では見た目とAI機能の実装を行います!
置ける場所を光らせたり、今どちらのプレイヤーか表示したりと便利機能を実装していきましょう。

WebStormでChrome attach debug

$
0
0

English

vscode-chrome-debug - Attachと同じことをWebStormでやる方法です。公式ドキュメント含め誰も書いてないっぽいのでメモ。

vscode-chrome-debug - Attachの方法でChromeを起動して、Port 9229でChrome Debugging protocolをlistenさせます。Linuxの場合は google-chrome --remote-debugging-port=9229実行後、以下のコマンドでちゃんとlistenしているか確認。

$ netstat -an | grep 9229
tcp        0      0 127.0.0.1:9229          0.0.0.0:*               LISTEN

$ lsof -i :9229
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
chrome  18347  wsh  132u  IPv4 729759      0t0  TCP localhost:9229 (LISTEN)

WebStormでConfiguration "Attach to Node.js/Chrome" を作成:
image.png

作成したConfigurationでDebugを開始すると、Chromeで開いているタブ一覧が表示されるので、デバッグしたいタブを選びます。今回はFirebaseチュートリアルのFriendly Chat
image.png

無事ブレークポイントにヒットして人権を手にしました。
image.png

JavaScript基礎:配列のよく使うメソッド

$
0
0

はじめに

配列の操作は普段よく使うので、関連メソッドをまとめてみます。

配列の重複値を削除

new setで重複値取り除く

constdata=["four","one","two","three","one"]constnewData=newSet(data)console.log(newData)

image.png

配列の値置換

splice() メソッドは、 既存の要素を取り除いたり、置き換えたり、新しい要素を追加したりすることで、配列の内容を変更します。

constdata=["four","one","two","three","one"]data.splice(1,1,'one1','one2')console.log(data)

image.png

map、fromメソッドで配列値加工

fromメソッド

Array.from() メソッドは、配列風オブジェクトや反復可能オブジェクトから、新しい、浅いコピーの Array インスタンスを生成します。

constdata=["1","2","3"]console.log(Array.from(data,x=>x+x));

Array ["11", "22", "33"]

mapメソッド

vararr=[1,3,6,9];constmap=arr.map(x=>x**2);console.log(map);

Array [1, 9, 36, 81]

配列クリア

vararr=[1,3,6,9];// 長さを0でクリアarr.length=0// 空の配列でリセット//arr = []console.log(arr);

Array []

配列をオブジェクトに変換

vararr=["one","two","three"];varobj={...arr}console.log(obj);

Object { 0: "one", 1: "two", 2: "three" }

配列に初期値をfill

vararr=newArray(8).fill(100)console.log(arr)

Array [100, 100, 100, 100, 100, 100, 100, 100]

配列のマージ

constarr1=newArray(8).fill(100)constarr2=newArray(8).fill(200)constarr3=[...arr1,...arr2]console.log(arr3)

Array [100, 100, 100, 100, 100, 100, 100, 100, 200, 200, 200, 200, 200, 200, 200, 200]

concatも配列マージできるメソッドです。
concat() メソッドは、配列に他の配列や値をつないでできた新しい配列を返します。

スプレッド構文

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

配列の反転

constarray1=['one','two','three'];constreversed=array1.reverse();console.log('reversed: ',reversed);

"reversed: " Array ["three", "two", "one"]

reduceメソッド

reduce() は配列の各要素に対して(引数で与えられた)reducer 関数を実行して、単一の値にします。

constarray=[1,2,3,4];consttotal=array.reduce((total,currentValue)=>total+currentValue)console.log(total)

10

filterメソッド

filter() メソッドは、引数として与えられたテスト関数を各配列要素に対して実行し、それに合格したすべての配列要素からなる新しい配列を生成します。

constnums=[10,15,20,25];constoddNums=nums.filter(num=>num%2==1)console.log(oddNums)

Array [15, 25]

forEach

for句の代わりとしてよく使います。

constarray=['one','two','three'];array.forEach(function(element){console.log(element);});

"one"
"two"
"three"

sort

// 文字列で昇順にするconstdata=['1','2','10','20'];data.sort();console.log(data);// 数字で昇順にするconstdata2=[1,2,10,20];data2.sort();console.log(data2);// 自分の比較メソッドで降順にするconstdata3=[1,2,10,20];data3.sort(function(a,b){if(a<b){return1;}elseif(a>b){return-1;}else{return0;}});console.log(data3);

Array ["1", "10", "2", "20"]
Array [1, 10, 2, 20]
Array [20, 10, 2, 1]

ほかもいろんなメソッドがあります。
参考URL
Map: https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Map
Arraya: https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array
Set: https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Set

以上

Viewing all 8843 articles
Browse latest View live