Helmの基本的な使い方

chartの検索

helm search repo <repo名>
helm search hub <hub名>

% helm search hub bitnami

repoはローカル環境に追加されたレポジトリ。
インストールしたいchartを探し、それがローカルのレポジトリにない場合は、そのレポジトリをローカルに落としてくる必要がある。

repoの確認

% helm repo list

repoの追加

helm repo add <repo名>

% helm repo add bitnami https://charts.bitnami.com/bitnami

追加後は定期的に、レポジトリのupdateが必要。この辺りはaptとかhomebrewとかと同じ概念。

% helm repo update

デプロイ

helm install <リリース名> -f custom-values.yaml <chart名>
helm install <リリース名> --set key=value <chart名>

% helm install sample-nginx -f custom-values.yaml bitnami/nginx
% helm install sample-nginx --set imagePullPolicy=Always bitnami/nginx

テンプレートに対してのデフォルトの設定がvalues.yamlに記述されている。直接values.yamlを編集しても良いが、上書きしたい設定を別ファイルにして-fで指定するのが一般的。

更新

% helm upgrade sample-nginx -f values.yaml bitnami/nginx
% helm upgrade sample-nginx --set imagePullPolicy=Always bitnami/nginx

helmによりデプロイされたリリースの確認

% helm list

リリースの詳細情報

Usage:
  helm get [command]

Available Commands:
  all         download all information for a named release
  hooks       download all hooks for a named release
  manifest    download the manifest for a named release
  notes       download the notes for a named release
  values      download the values file for a named release
# マニフェストを確認
helm get manifest sample-nginx

# chartに渡したvaluesを確認
helm get values sample-nginx

チャート自体の詳細確認

Usage:
  helm show [command]

Aliases:
  show, inspect

Available Commands:
  all         show all information of the chart
  chart       show the chart's definition
  crds        show the chart's CRDs
  readme      show the chart's README
  values      show the chart's values
# チャートに設定可能なvaluesの確認
% helm show values bitnami/nginx

history確認

helm history <リリース名>

% helm history sample-nginx

helmはhistoryを管理するので、REVISIONを確認することができるし、rollbackすることもできる。

rollback

helm rollback <リリース名> <REVISION>

% helm rollback sample-nginx 2

ロールバックしても、ロールバック対象のリビジョンが新しいリビジョンとしてデプロイされるので注意。

アンデプロイ

helm uninstall <リリース名>

% helm uninstall sample-nginx

historyも全て削除されるので、rollbackで戻せない。
historyを残したい場合は、--keep-historyをつける。

既存のchartをダウンロードする

helm pull <chart名>

% helm pull bitnami/nginx
% tar xvzf nginx-9.9.3.tgz

# 色々編集する

% helm install sample-nginx ./nginx

Homebrewでよく使うコマンド

homebrew自体のupdate, formulaeリストの更新

% brew update

インストールされているformulaeの一覧

% brew list
% brew list --versions

インストール可能なformulaeの取得

% brew search <formulae名>
% brew search mysql
==> Formulae
automysqlbackup               mysql++                       mysql-client@5.7              mysql-sandbox                 mysql@5.6                     mysqltuner
mysql ✔                       mysql-client                  mysql-connector-c++           mysql-search-replace          mysql@5.7                     qt-mysql

チェックマークが入っているものはインストール済み。 思ったよりインストールできるバージョンが少ないですね、細かくバージョン指定したい場合は直接ダウンロードする感じかな。

formulaeの詳細情報

% brew info <formulae名>
% brew info mysql
mysql: stable 8.0.28 (bottled)
Open source relational database management system
https://dev.mysql.com/doc/refman/8.0/en/
Conflicts with:
  mariadb (because mysql, mariadb, and percona install the same binaries)
  percona-server (because mysql, mariadb, and percona install the same binaries)
/usr/local/Cellar/mysql/8.0.25 (301 files, 301.5MB) *
  Poured from bottle on 2021-05-20 at 01:52:38
From: https://github.com/Homebrew/homebrew-core/blob/HEAD/Formula/mysql.rb
License: GPL-2.0-only with Universal-FOSS-exception-1.0
==> Dependencies
Build: cmake ✘, pkg-config ✔
Required: icu4c ✔, libevent ✔, libfido2 ✘, lz4 ✔, openssl@1.1 ✔, protobuf ✘, zstd ✔
....

formulaeであるmysqlは最新が8.0.28で、今インストールされているのは8.0.25なので、アップデートできる。

% brew upgrade mysql
# インストール済みのformulaeを全部最新にしたい場合
% brew upgrade

インストールされていない場合は、Not installedと出る。

% brew info mysql@5.7
mysql@5.7: stable 5.7.37 (bottled) [keg-only]
Open source relational database management system
https://dev.mysql.com/doc/refman/5.7/en/
Not installed
From: https://github.com/Homebrew/homebrew-core/blob/HEAD/Formula/mysql@5.7.rb
License: GPL-2.0-only
==> Dependencies
Build: cmake ✘
Required: openssl@1.1 ✔
...

brew infoインストール済みのパッケージの情報ではなく、現在のバージョンのhomebrewが管理できる全formulaeに関する情報を取得できる。
つまりこれ。https://formulae.brew.sh/formula/
今pcにインストールされているhomebrewが、この最新のformulaeのリストを参照するようにbrew updateをまずするのが基本だと思うが、今は、brew installすると、

Running `brew update --preinstall`...

と最初に出るので勝手にやってくれるみたい。

バージョン切り替え

最新版から(8.0系)から5.7系のmysqlに切り替えたいとする。一旦現在のformulaeをunlinkしてからlinkを張る。

% brew install mysql@5.7
% brew unlink mysql && brew link mysql@5.7 --force

使われてないformulaeの削除

% brew cleanup

Remove stale lock files and outdated downloads for all formulae and casks, and remove old versions of installed formulae. If arguments are specified, only do this for the given formulae and casks. Removes all downloads more than 120 days old. This can be adjusted with HOMEBREW_CLEANUP_MAX_AGE_DAYS.

120日以上の前のダウンロードが対象みたい。--dry-runができるので確認してから消す方が良さそう。

requestAnimationFrameで端末負荷を測定する

背景

最近ブラウザから端末負荷を測定したい場面に出くわしました。 弊社ではwebrtcを扱っていて、ライブ中の端末負荷の変化を測定したかったのですが、残念ながらブラウザAPIは用意されていません。

そこでrequestAnimationFrameapiを使用して画面の描画回数を測定することで、間接的に端末負荷の指標とすることにしました。

Window.requestAnimationFrame()

requestAnimationFrameは本来はアニメーションを実装するために使用するAPIです。

Window.requestAnimationFrame() - Web API | MDN

ブラウザにアニメーションを行いたいことを知らせ、指定した関数を呼び出して次の再描画の前にアニメーションを更新することを要求します。

ブラウザが画面を描画するたびにコールされ、そのタイミングで任意の関数を実行したい場合に使用するのが一般的かと思います。

今回は、ブラウザが画面を描画するたびにコールされることを利用して、描画1回あたりに要した時間を計測しています。 1回あたりに要した時間が分かれば、画面描画のfpsも計算することができます。 つまり、端末負荷が上がって画面がカクカクする状態になればfpsも下がることを利用して、端末負荷の指標としましました。

正常時の基準とべきfpsは60ですが、注意も必要です。

このコールバックの回数は、たいてい毎秒 60 回ですが、一般的に多くのブラウザーでは W3C の勧告に従って、ディスプレイのリフレッシュレートに合わせて行われます。

特にゲーミング用のディスプレイなどはリフレッシュレートが高く設定されている場合もあり、必ずしも60を基準にできるとは限りません。

そもそももっと直接計測する方法はないのか?

google meetはどうやって端末負荷を計測しているのか?

googleの公式ではないので確かではないですが、chrome extensions apiを使用していると思われます。

javascript - How is Google Meet able to show CPU usage? - Stack Overflow

試しにgoogle meetをchrome以外のブラウザでアクセスしてみると、cpu使用率のグラフが表示されなくなっています。 かなり有力そうです。

残念ながらextensionsのapiをfrontendのコードから呼び出すことはできないので断念しました。 googleは外部に公開していない内部apiを通じて取得しているのではなかろうかと推測されます。

実装

requestAnimationFrame()のコールバック関数の中で、再起的にrequestAnimationFrame()を呼び出しています。 60回ごとに平均を計算しています。(60はあくまで目安です)

ポイントはInfinityです。理由が分かっていないのですが、時々lastCalledTimecurrentTimeが一致してしまうことがあり、fpsInfinityになってしまいます。 これを避けるために、Infinityを除外する処理を加えています。

const measureFps = () => {
  let lastCalledTime: number | undefined;
  let counter = 0;
  let fpsArray: number[] = [];

  const calcFps = () => {
    const currentTime = Date.now();

    if (lastCalledTime === undefined) {
      lastCalledTime = currentTime;
    } else {
      const delta = (currentTime - lastCalledTime) / 1000;
      lastCalledTime = currentTime;
      const fps = 1 / delta;

      if (counter >= 60) {
        const sum = fpsArray.reduce((a, b) => a + b);
        const average = sum / fpsArray.length;
        counter = 0;
        fpsArray = [fps];
        console.log({ average });

      } else if (fps !== Infinity) {
        fpsArray.push(fps);
        counter++;
      }
    }
    requestAnimationFrame(calcFps);
  };

  requestAnimationFrame(calcFps);
};

参考: Simple way to calculate the FPS when using requestAnimationFrame · GitHub

それほど多くもないですが、Infinityを弾いた分は誤差になるので根本解決はしたいなと思ってます...

それにしてもネイティブアプリはいいなぁ。読んで頂きありがとうございました!

Goの整数型

最近goを勉強し始めたので、備忘録としてgoの整数型についてまとめます。

符号あり整数

サイズ(bit) 最小値 最大値
int8 8 -128 127
int16 16 -32768 32767
int32(rune) 32 -2147483648 2147483647
int64 64 -9223372036854775808 9223372036854775807

確認するにはmath.MinXXX, math.MaxXXXを使います。

package main

import (
  "fmt"
  "math"
)

func main() {
  fmt.Printf("%d, %d", math.MinInt8, math.MaxInt8)
  fmt.Printf("%d, %d", math.MinInt16, math.MaxInt16)
  fmt.Printf("%d, %d", math.MinInt32, math.MaxInt32)
  fmt.Printf("%d, %d", math.MinInt64, math.MaxInt64)
}

符号なし整数

サイズ(bit) 最小値 最大値
uint8(byte) 8 0 255
uint16 16 16 65535
uint32 32 32 4294967295
uint64 64 64 18446744073709551615
uintptr * * *

uintptrポインターの値を指します。

確認するにはmath.MaxXXXを使います。最小値は常に0なのでメソッドは存在しません。

注意点としては、戻り値は明示的に指定しない限りintであるということです。また型推論では常にintになります。
従って、math.MaxUint32まではintで表せますが、math.MaxUint64intでは表せないので、変数に代入する場合は型を明示的に指定する必要があります。
fmt.Printlnなどで出力する場合は、型キャストが必要です。

package main

import (
  "fmt"
  "math"
  "reflect"
)

func main() {
  fmt.Println(reflect.TypeOf(math.MaxUint16)) // => int
  fmt.Println(math.MaxUint32) // => 4294967295

  i := math.MaxUint64 // => constant 18446744073709551615 overflows int
  var j uint64 = math.MaxUint64
  fmt.Println(i) // => 18446744073709551615

  fmt.Println(math.MaxUint64) // => constant 18446744073709551615 overflows int
  fmt.Println(uint64(math.MaxUint64)) // => 18446744073709551615
}

特別な理由がない限り、整数を定義するときはintにすべきです。
型推論ではintが採用されるのは前述の通りですが、intは実装系に依存し、32bit系ではint32, 64bit系ではint64と同じです。

浮動小数

サイズ(bit) 最小値 最大値
float32 32
float64 64

複素数

サイズ(bit) 最小値 最大値
complex64 float32の実部と虚部から成る
complex128 float64の実部と虚部から成る

complex()複素数を生成できる。

Rubyの引数いろいろ

昨年のクリスマスにRuby3.0がリリースされました。8年ぶりのメジャーバージョンアップで、その目玉として静的型付けが導入されたことはご存知の方も多いのではないでしょうか。
それ以外にも様々な変更がありましたが、そのうちの一つでキーワード引数の扱いが変更になりました。引数は種類が多くてややこしいですよね。私もよくわからなくなるのでまとめてみようと思います。

引数の書き方

Rubyは関数呼び出しや、定義の時に引数のカッコを省略可能です。

def greet name
  puts "Hello, #{name}!"
end

greet "Taro" #=> "Hello, Taro!"

と書いても、

def greet(name)
  puts "Hello, #{name}!"
end

greet("Taro") #=> "Hello, Taro!"

と書いてもOKです。私はカッコをつける派ですが、付けないコードもよく見かけます。

デフォルト引数

引数に初期値を持たせることができます。これによりメソッド呼び出し時に、引数が必須ではなくなります。

def greet(name="Taro")
  puts "Hello, #{name}!"
end

greet #=> "Hello, Taro!"
greet("Jiro") #=> "Hello, Jiro!"

def greet(name)
  puts "Hello, #{name}!"
end

greet #=> ArgumentError

デフォルト引数と普通の引数の両方を渡したい場合は、引数の個数からRubyが推測をしてくれます。

def greet(name="Taro", age)
  puts "#{name} is #{age}."
end

greet(10) #=> "Taro is 10."
greet("Jiro", 10) #=> "Jiro is 10."

可変長引数

引数の個数が決まっていない場合に使用します。名前の通りですね。

def greet(*name)
  name.each do |name|
    puts "Hello, #{name}!" 
  end
end

greet("Taro", "Jiro") #=> "Hello, Taro" "Hello, Jiro"

引数の前に*を付けます。これで複数の引数を配列として受け取れます。配列なので、ループ等で処理してあげます。

可変長引数と、普通の引数の両方を渡したい場合は、デフォルト引数の場合と同様、引数の数からRubyが推測してくれます。

def greet(*name, msg)
  name.each do |name|
    puts "#{msg}, #{name}!"
  end
end

greet("Taro", "Jiro", "Hello") #=> "Hello, Taro!" "Hello, Jiro!"

オプション引数

可変長引数に類似の引数です。引数にキーとバリューの組み合わせを指定したい場合に使用します。引数は可変長です。

def greet(**args)
  args.each do |arg|
    puts "#{arg[0].capitalize} is #{arg[1]}."
  end
end

greet(name: "Taro", age: 10) #=> "Name is Taro." "Age is 10."

引数の前に**を付けます。これで複数のキーとバリューの組み合わせをハッシュとして受け取れます。指定するキーはなんでも良いので使い道は限定されると思います。あらかじめキーだけは指定しておくキーワード引数の方が使用頻度は高いと思います。

キーワード引数

引数にキーとバリューの組み合わせを指定します。引数が複数あると、何をどの順に渡しているのか分かりにくくなります。そういう場合はキーワード引数を使います。呼び出す時は、キーとバリューをセットで渡します。オプション引数との違いは、キーはあらかじめ指定しておく必要があるということです。

def greet(name:, age:)
  puts "#{name} is #{age}."
end

greet(name: "Taro", age: 10) #=> "Taro is 10."

どの引数がどういう意味を持つのかが一目瞭然だと思います。

キーワード引数はデフォルト引数を渡すこともできます。

def greet(name: "Taro", age: 10)
  puts "#{name} is #{age}."
end

greet #=> "Taro is 10."
greet(name: "Jiro") #=> "Jiro is 10."

キーワード引数はRuby2.0から導入された機能で、それ以前はハッシュを使ってキーワード引数を擬似的に実現していました。

def greet(options = {})
  name = options[:name]
  age = options[:age]

  puts "#{name} is #{age}."
end

greet(name: "Taro", age: 10) #=> "Taro is 10."

引数にキーとバリューの組み合わせを指定する場合、明示的にハッシュで書いても、{}を省略した形で書いても暗黙的に双方向に変換されます。

# キーワード引数の場合
def greet(name: "Taro", age: 10)
  puts "#{name} is #{age}."
end

profile = {name: "Jiro", age: 20}
greet(profile) #=> "Jiro is 20."
greet(name: "Jiro", age: 20) #=> "Jiro is 20."


# 普通の引数でハッシュを受け取る場合
def greet(options = {})
  name = options[:name]
  age = options[:age]

  puts "#{name} is #{age}."
end

greet(profile) #=> "Jiro is 20."
greet(name: "Jiro", age: 20) #=> "Jiro is 20."

しかしRuby3.0からは、この仕様が変更になっています。キーワード引数の扱いが独立し、キーワード引数に{}をつけた場合に動かなくなりました。(普通の引数でハッシュを受け取る場合は互換性が維持されています。)従って明示的に、ハッシュを展開する必要があります。**をつければOKです。

def greet(name: "Taro", age: 10)
  puts "#{name} is #{age}."
end

profile = {name: "Jiro", age: 20}
greet(profile) #=> Ruby3.0からエラー
greet(**profile) #=> "Jiro is 20."

***

*Splat Operator)は配列を展開する演算子**Double Splat Operator)はハッシュを展開する演算子です。 Javascriptで言うところの...Spread Operator)のような感じだと思います。

array = [1, 2, 3]
hash = {a: 1, b: 2}

[a, b, array, c] #=> [a, b, [1, 2, 3], c]
[a, b, *array, c] #=> [a, b, 1, 2, 3, c]

{A: 10, B: 11, hash, C: 12} #=> SyntaxError
{A: 10, B: 11, **hash, C: 12} #=> {A: 10, B: 11, a: 1, b: 2, C: 12}

***を使うと配列やハッシュを展開できるので、逆も可能です。これを利用したのが可変長引数、オプション引数です。 メソッド定義の際に*nameのように引数を指定すると、呼び出すときに引数が配列に変換されるという仕組みです。

ブロック引数

引数にブロックを指定できます。定義する際は&を引数の前に書きます。メソッド内ではyieldでブロックを呼び出します。

def greet(&block)
  yield
end

greet { puts "hello" } #=> "hello"

ブロック引数にロックを渡したい場合は、呼び出しの際も&を書いてやればOKです。

def greet(&block)
  puts block.yield("hello")
end

proc_obj = proc { |str| str.upcase }
greet(&proc_ojb)

定義時に引数にブロックを指定していないメソッドにもブロックは渡すことができます。ただ、注意点としては呼び出し方はyieldのみ限定されます。callで呼び出すことはできません。

def greet
  if block_given?
    yield
  else
    puts "No block"
  end
end

proc_obj = -> do
  File.write("hoge.txt", "Hello, world")
  File.read("hoge.txt")
end

greet(&proc_obj) #=> "Hello, world"
greet #=> "No block"

if block_given?で引数にブロックが渡ってきているか条件分岐が可能です。

ブロックの呼び出し方いろいろ

block.call
block.yeild
yeild

procはオブジェクトの作り方も、呼び出し方も何パターンもあるのでまた別に書きたいと思います。

参考
https://techlife.cookpad.com/entry/2020/12/25/155741
https://qiita.com/jnchito/items/74e0930c54df90f9704c
https://qiita.com/rtoya/items/33617078501776fdcad7
https://ryotatake.hatenablog.com/entry/2018/11/20/splat_operator

CORSをちゃんと理解する

今回はCORSについてまとめたいと思います。私はよくサーバー側の設定を忘れて、ブラウザに怒られたりしてますが、今ひとつ理解できず、コピペして終わってました。Railsapiモードでアプリを作る時とかも、よくわからずgem rack-corsとか書いてました。最近、FetchAPIについて学ぶ機会があって、ブラウザからのhttp通信についてちゃんと調べようと思ったのがきっかけでCORSについてもまとめてみました。

corsとは?

CORS(Cross-Origin Resource Sharing)は異なるオリジン間での通信を許可する仕組み。CSRFなどのセキュリティ面から、XMLHttpRequestFetchAPIなどのhttp通信は同一オリジンポリシーに従います。従ってオリジンが異なる場合はそれを許可するための、適切なcorsに関するヘッダーが必要になってきます。

オリジンとは?

オリジン = ドメイン + プロトコル + ポート番号

https://domain-a.comみたいな部分がオリジンですね。つまり、https://domain-a.comからhttps://domain-b.comへの通信はデフォルトでは許可されず、corsによる設定が必要になるという事です。

ではcorsの設定についてみていきます。

プリフライトリクエス

後述する特定の条件以外の場合は、プリフライトリクエストと呼ばれるリクエストを最初に行い、あらかじめサーバーがリクエストに応答可能か確認を行います。httpのOPTIONSリクエストによって、これを行います。

リクエス

OPTIONS / HTTP/1.1
Origin: https://domain-a.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-Custom-Header, Content-Type

通信を行いたいメソッドや、ヘッダーをサーバーに対して提示します。Access-Control-Request-Headersに関しては後述の単純リクエストで許可されるヘッダー以外を指定します。

レスポンス

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://domain-a.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-Custom-Header, Content-Type
Access-Control-Max-Age: 86400

レスポンスには許可するオリジン、メソッド、ヘッダー情報が含まれます。Access-Control-Allow-Originに関してはワイルドカードの使用が可能なので、全てを許可する場合は、

Access-Control-Allow-Origin: *

と書くことが可能です。
Access-Control-Max-Ageは、プリフライトリクエストの結果をキャッシュしておける上限時間です。

単純リクエス

以下のいずれかの条件を満たす場合は、プリフライトリクエストを送らずに直接http通信を行うことが可能です。
逆に言えば、この条件を満たさない場合は、全てまず、プリフライトリクエストを行わなければなりません。

  • 以下のメソッドのいずれかである。
    GET, HEAD, POST

  • 以下のヘッダーのみを含む。
    Accept, Accept-Language, Content-Language, Content-Type, DPR, Downlink, Save-Data, Viewport-Width, Width

  • Content-Typeヘッダーは以下のみ。
    application/x-www-form-urlencoded, multipart/form-data, text/plain

  • リクエストに使用される XMLHttpRequestUpload にイベントリスナーが登録されていない。

  • リクエストに ReadableStream オブジェクトが使用されていない。

資格情報(認証)

認証を行うためにCookieをヘッダーに含めて送信したい場合は多くあると思います。デフォルトではヘッダーにCookieは含めないので、リクエスト時に設定が必要です。 XMLHttpRequestの場合

const xhr = new XMLHttpRequest();
xhr.withCredentials = true;

FetchAPIの場合

fetch('https://domain-a.com', {
  mode: 'cors',
  credentials: 'include',
});

これにより、リクエストヘッダーにCookieヘッダーが含まれるようになります。
レスポンスに次のヘッダーが含まれば、許可されたことになります。

Access-Control-Allow-Credentials: true

これはプリフライトリクエストの場合でも、実際のリクエストの場合にも(もちろん単純リクエストの場合も)含まれます。プリフライトリクエストの場合は、実際のリクエストにCookieを含められるかどうか、実際のリクエストの場合は、それをブラウザで利用して良いかを定めています。つまり、実際のリクエストでこれがtrueでなかった場合は、ブラウザによって無視されるということになります。

注意点としては、資格情報を送る場合はAccess-Control-Allow-Originワイルドカードを設定できません。特定のオリジン名を指定する必要があります。

参考
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS http://www.tohoho-web.com/ex/cors.html https://qiita.com/att55/items/2154a8aad8bf1409db2b

Socket.io + Express を試してみる

WebSocketとは?

双方向通信を可能にするプロトコルの一つです。リアルタイムで通信を行いたいチャットなどに利用されます。

普通のhttp/https通信では、クライエントからリクエストが送られる度にコネクションが張られ、サーバーからレスポンスが返ると、コネクションは切断されます。 つまり、通信の度にコネクションを張り直す必要があり、クライエントからのリクエストなしにサーバーからレスポンスを送信することができません。

これを解決するために、従来はロングポーリング(comet)という技術が使われていました。 一定の間隔でブラウザからリクエストを送り、サーバーは新着情報がなければレスポンスを一定時間保留にします。その間に新着があればそれを返し、なければ何もなかったというレスポンスを返します。サーバー側の保留なしの単純なポーリングよりリアルタイム性が高いのが特徴です。これによって擬似的に、リアルタイム通信が可能になりますが、以下の欠点があります。

  • ヘッダーが冗長でオーバーヘッドが大きい
  • タイムアウトがある
  • httpコネクションを占有してしまう

一方でWebsocketは、最小2byte, 最大でも14byteと軽量かつ、HTTPをアップグレーデして、TCP上で双方向通信を実現します。 クライエントからの最初のリクエストのヘッダーは次のようになります。

GET / HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: xxxxxxxxxxx==
Sec-WebSocket-Version: 13

サーバーからのレスポンスは次のようになります。

HTTP/1.1 101 OK
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: yyyyyyyyyy=

なんとなく直感で理解できるのではないでしょうか。
Sec-Websocket-KeySec-Websocket-Acceptはそれぞれ対応しており、クライエントが自分のリクエストに対するレスポンスだと認識できるようになっています。 ちなみに、101は、Upgradeリクエストヘッダーが送られてきた場合に返却するSwitchinig Protocols レスポンスです。 この時点で、Websocketコネクションが確立されました。

Socket.ioとは?

手軽にwebsocketを実現するjavascriptライブラリで、フロント側と、node.jsで実装されたサーバー側から作られています。 WebSocketが繋がらなくなった時に、ロングポーリングポーリングなどの代替手段を提供してくれる他、到達確認や認可、namespaceなどを提供してくれます。

チャットを作ってみる

まずexpresssocket.ioをインストールします。

yarn add express socket.io

次のようにapp.jsを作成します。

// app.js

const express = require('express');
const app = express();
const http = require('http').createServer(app);

const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.send('<h1>Hello world</h1>');
});

http.listen(PORT, () => {
  console.log(`Server listening. Port: ${PORT}`);
});

expressに関する記述のみですが、動作確認をしておきましょう。

node app.js

ブラウザで、localhost:3000にアクセスします。Hello worldと表示されるはずです。

次に表示部分をhtmlファイルに分けていきます。

// app.js

app.use(express.static(`${__dirname}/public`));

app.get('/', (req, res) => {
  res.send(`${__dirname}/index.html`);
});

まず、app.jsでファイルを読み込みます。静的ファイルを分けたいので、staticを使っています。

<!-- public/index.html -->

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Socket.io</title>
    <link rel="stylesheet" href="index.css">
  </head>
  <body>
    <div class="chat-field">
      <div class="chat-area">
        <ul id="messages"></ul>
      </div>
      <form class="chat-form" action="">
        <input id="m" />
        <button class="disabled">Send</button>
      </form>
    </div>

    <script src="/socket.io/socket.io.js"></script>
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
    <script type="text/javascript" src="index.js"></script>

  </body>
</html>
/* public/index.css */

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font: 13px Helvetica, Arial;
}

.chat-field {
  margin: 10em auto;
  width: 40%;
  height: 50em;
  border-radius: 5px;
  box-shadow: 5px 5px 25px 0 rgba(0, 0, 0, .5);
  text-align: center;
}

.chat-area {
  margin: 5em auto;
  width: 90%;
  height: 40em;
  overflow: scroll;
}

.chat-area li {
  margin: 0.5em;
  padding: 0.7em;
  background-color: rgb(223, 223, 223);
  border-radius: 8px;
  list-style-type: none;
}

.chat-form input {
  margin-right: 0.3em;
  padding: 0.6em;
  width: 70%;
  border: 2px solid blueviolet;
  border-radius: 5px;
}

.chat-form button {
  padding: 0.6em;
  border: 2px solid blueviolet;
  border-radius: 5px;
  background-color: blueviolet;
  color: white;
}

次にindex.jsを書いていきます。アクセス時に、websocketを張り、送信ボタンを押すとそれを通じて通信を行い、レスポンスを表示する仕様です。

// public/index.js

$(() => {
  $('input').change(function() {
    let text = $(this).val();
    if (text === null) {
      $('button').prop('disabled', true);
    } else {
      $('button').prop('disabled', false);
    };
  });

  const socket = io();
  $('form').submit(e => {
    e.preventDefault();

    socket.emit('post', $('#m').val());
    $('#m').val('');
    $('button').prop('disabled', true);

    return false;
  });

  socket.on('msg', res => {
    $('#messages').append($('<li>').text(res));
  });
});

socket.ioインスタンスに対して、emitで送信、onで受信を行っています。第一引数は、送信側と受信側で同じキーを指定します。 ここまでで一度動作確認をしておきましょう。次のような画面になっていれば成功です。 f:id:shyu_61:20201220135226p:plain

続いてサーバー側にsocket.io部分を書いていきます。

// app.js

const io = require('socket.io')(http);

io.on('connection', socket => {
  console.log('user connected');

  socket.on('post', post => {
    io.emit('msg', post);
  });
});

onで受信し、postをキーに持つメッセージを受け取ります。そして、受け取ったものをそのまま返却していますが、これはconnectionが張られている全てのユーザーに送信されます。動作確認してみてください。

これで簡単なチャットは作成できましたが、自分と相手のメッセージくらい区別できるようにしておきましょう。 具体的には、接続時にユーザー一意のトークンを発行し、ユーザーがリクエストの際にそのトークンを含めることで実装します。

トークン生成にはcryptoを使用し、socket.idでユーザーごとに割り振られたidを取得できるのでこれを元に作成します。

// app.js

const crypto = require('crypto');

io.on('connection', socket => {
  console.log('user connected');

  (() => {
    const token = makeToken(socket.id);
    io.to(socket.id).emit('token', { token: token });
  })();

  socket.on('post', post => {
    io.emit('msg', post);
  });
});

const makeToken = id => {
  return crypto.createHash('sha1').update(id).digest('hex');
};

全てのユーザーに送信者のトークンを返却してるのが相当イケてないですが今回はよしとしてください(笑)

最後にクライエント側で識別を行って、ラインのように左右に出し分けたいと思います。

// index.js

const IAM = { token: null };
socket.on('token', data => {
  IAM.token = data.token;
});

$('form').submit(e => {
  e.preventDefault();

  socket.emit('post', {
    msg: $('#m').val(),
    token: IAM.token,
  });

  $('#m').val('');
  $('button').prop('disabled', true);

  return false;
});

socket.on('msg', res => {
  if (res.token === IAM.token) {
    $('#messages').append($('<li class="mine">').text(res.msg));
  } else {
    $('#messages').append($('<li class="others">').text(res.msg));
  }
});

まず、tokenを受け取り、IAMにセットしておきます。(tokenはconnectionが張られた際に、サーバーから送信されるものです。) メッセージの送信時に、トークンを含めるように変更し、受け取り時はIAMと比較して自分が送信したものかどうか判定します。class属性を変えることで表示を分けます。

最後にindex.cssを変更します。

/* index.css */

.chat-area li {
  padding: 0.7em;
  background-color: rgb(223, 223, 223);
  border-radius: 8px;
  list-style-type: none;
}

.mine {
  margin: 0.5em;
  width: 50%;
}

.others {
  margin: 0.5em 0.5em 0.5em auto;
  width: 50%;
}

これで完成です。ブラウザで複数タブを開いて、チャットをやりとりしてみてください。 最終コードはgithubにあげてます。 f:id:shyu_61:20201220135633p:plain

参考にさせて頂きましたm

WebSocketについて調べてみた。 - Qiita
socket.io が提供してくれているものは何か - from scratch
はじめてのSocket.io #2 チャット編「自分がemitした通信内容か判定する」