Node-RED プロジェクトを爆速で起動・切り替え・終了する


この記事は [Node-RED Advent Calendar 2020 11日目](https://qiita.com/advent-calendar/2020/node-red) の記事です。

# TL;DR

Node-RED のプロジェクトを、ターミナルからこんな感じで起動・切り替え・終了するようにします。
ショートカットをトリガーに動作し、インタラクティブに選択することができます。
使用するシェルスクリプトは、[実装](https://qiita.com/naomaruru/private/5f5e4470b01530b6145e#%E5%AE%9F%E8%A3%85)にまとめてあります。

![Picture1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68124/a900aa7c-9208-4c59-c604-ae495c64e222.png)


# 背景・きっかけ

- IoTデバイスのアプリケーションを Node-RED で実装している
- 開発用 PC ローカルで Node-RED を起動・開発し、プロジェクト機能で管理している
- アプリケーションの数が増えてきて、管理するプロジェクトが増えてきた
- 複数のプロジェクトを扱う中で、様々な問題に直面した
- プロジェクトを簡単に爆速で切り替えて、今後の Node-RED ライフを快適にしたい！


# プロジェクト機能とは

> [プロジェクト : Node-RED日本ユーザ会](https://nodered.jp/docs/user-guide/projects/)

Node-REDのフローを、エディター上から Git 管理できる機能です。
プロジェクト機能を活用することで、以下のような利点があります。

- 一度開発したフローを再配布することができる
    - IoT で利用する多くのデバイスへのデプロイするケースなどを効率化できる
- 変更箇所（Diff)のノードの単位で確認できるため、コードレビューが容易になる
    - 欠陥を発見・修正したり、リファクタリングすることができる
    - チームの共有智を醸成し、共通言語化することができる
    - 可読性・保守性が高まる


# プロジェクト機能の困りごと

## ユーザディレクトリとルートディレクトが異なる

個人的には一番大きな問題でした。
以下のようなディレクトリが存在しているとします。

```zsh
$ tree
# .
# ├── dir_a
# └── dir_b
```

`dir_a` に移動して、[コマンドラインの使い方](https://nodered.jp/docs/getting-started/local#%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%E3%83%A9%E3%82%A4%E3%83%B3%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9) を参考に、ユーザディレクトリを設定して起動します。

```zsh
$ cd dir_a && node-red --userDir ../dir_b &
$ tree ..
# .
# ├── a # <= カレントディレクトリ
# └── b # <= ユーザディレクトリ
#     ├── flows.json
#     ├── flows_cred.json
#     ├── lib
#     │   └── flows
#     ├── package.json
#     └── settings.js
```

ファイル（ファイル名：`test`）に出力するフローを実装・実行します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68124/61de3afc-43fd-45d6-011d-afaee1ebf2e0.png)

```zsh
$ tree ..
# .
# ├── a # <= ルートディレクト（カレントディレクトリ）
# │   └── test
# └── b # <= ユーザディレクトリ
#     ├── flows.json
#     ├── flows_cred.json
#     ├── lib
#     │   └── flows
#     ├── package.json
#     └── settings.js
```

ルートディレクトリ（Node-RED を起動したディレクトリ）にファイルが生成されてしまいます。
設定値等を静的ファイルとしてフローとともに管理したいケースが多々あり、頭を悩ませていました。
解決するためには、Node-RED プロセスをプロジェクトディレクトリで起動しなくてはなりません。


## プロジェクト切り替えがつらい

複数のプロジェクトを並行開発することもあり、プロジェクト切り替えが増えてきました。
また、Node-RED はエディタ上で確認するのが最も効率的だと思っています。
そのため、「この実装どうしてたっけ？」という確認のために、一時的なプロジェクト切り替えが頻発します。
快適な Node-RED ライフのためには、少ないキー入力で簡単にプロジェクトの切り替えが必要です。


## 数が増えると覚えきれなくなりそう

今後さらにプロジェクト数が増加すると、「あの開発の続きやるか、、、プロジェクト名なんだっけ？」という未来が見えてきます。
現在のプロジェクトの一覧から、対象とするプロジェクトをインタラクティブに選択したいと感じ始めました。


# 実現したいこと

以上をまとめると、快適な Node-RED ライフには以下が必要です。

- ルートディレクリを切り替え先のプロジェクトディレクトリに変更できる
- 少ないキー入力で切り替えられる
- プロジェクトをインタラクティブに選択できる

次から、これらの実現に要するツールを見ていき、実装に移っていきます。

# 必要なツール群

## fzf とは

> [junegunn/fzf](https://github.com/junegunn/fzf)
> fzf is a general-purpose command-line fuzzy finder.
> <img width="50%" alt="fzf" src="https://raw.githubusercontent.com/junegunn/i/master/fzf-preview.png">


インクリメンタル検索ができるツールです。
プロジェクトの一覧表示・検索・選択に利用します。

## fx とは

> [antonmedv/fx](https://github.com/antonmedv/fx)
> Command-line JSON processing tool
> <img width="50%" alt="fzf" src="https://camo.githubusercontent.com/1bdef90eaab60a00024adb27a167e2ccdf713869bdf186350299b42d36311f2d/68747470733a2f2f6d6564762e696f2f6173736574732f66782e676966">

JSONファイルを処理するツールです。
configファイルのフィルタリング取得・更新に利用します。

# 事前準備

- [プロジェクト機能を有効化](https://nodered.jp/docs/user-guide/projects/#%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E6%9C%89%E5%8A%B9%E5%8C%96%E3%81%99%E3%82%8B)し、複数のプロジェクトを作成する
- [zsh　をインストール](https://github.com/ohmyzsh/ohmyzsh/wiki/Installing-ZSH#how-to-install-zsh-on-many-platforms)する（以降、zsh 利用）
- [fzf をインストール](https://github.com/junegunn/fzf#installation)する
- [fx をインストール](https://github.com/antonmedv/fx#install)する
- Node.JS をインストールする（Node-RED 使っている時点でOKなはず）

# 実装

## JSON ファイル内キー・バリュー置換スクリプト

以下のシェルスクリプトを `json-replace` として、実行権限を与えてパスが通る場所に保存します。

```zsh
#! /usr/bin/env zsh

self=`basename $0`

function usage_echo () {
  local _status=$?
  local info="
Usage:
  $self <json_file>
  $self <json_file> <target_key>
  $self <json_file> <target_key> <new_value>
"
  echo $info
  exit $_status
}
trap 'usage_echo' {1,2,3,15}

function object_assgin() {
  local file=$1
  local prefix=$2
  local target=$3
  local val=$4
  local result
  if echo $val | grep "{.*:.*}" 2>&1 > /dev/null; then
    local result=`fx $file $prefix "{...this, $target: $val}"`
  else
    local result=`fx $file $prefix "{...this, $target: \"$val\"}"`
  fi
  if [ "$prefix" = "." ]; then
    prefix=''
  fi
  node -e "
    const fs = require('fs');
    let jsonObject = JSON.parse(fs.readFileSync('$file', 'utf8'));
    jsonObject$prefix = $result;
    console.log(JSON.stringify(jsonObject))
  " | fx .
}

if [ $# -eq 1 ]; then
  local file=$1
  fx $file .
  read key\?"Type target key > "
  local prefix=${key%.*}
  if [ -z "$prefix" ]; then
    prefix='.'
  fi
  local target=${key##*.}
  fx $file $prefix
  read val\?"Type changed value > "
  json=`object_assgin $file $prefix $target $val` && echo $json > $file
  fx $file $prefix
elif [ $# -eq 2 ]; then
  local file=$1
  local key=$2
  if [[ ! "$TERM" =~ "^\." ]]; then
    local key=".$key"
  fi
  local prefix=${key%.*}
  if [ -z "$prefix" ]; then
    prefix='.'
  fi
  local target=${key##*.}
  fx $file $prefix
  read val\?"Type changed value > "
  json=`object_assgin $file $prefix $target $val` && echo $json > $file
  fx $file $prefix
elif [ $# -eq 3 ]; then
	local file=$1
  local key=$2
  if [[ ! "$TERM" =~ "^\." ]]; then
    local key=".$key"
  fi
  local prefix=${key%.*}
  if [ -z "$prefix" ]; then
    prefix='.'
  fi
  local target=${key##*.}
  local val=$3
  json=`object_assgin $file $prefix $target $val` && echo $json > $file
  fx $file $prefix
else
  usage-echo
fi

```

### 実行結果

```zsh
$ cat test.json
# {
#   "key1": "val1",
#   "key2": "val2"
# }

$ json-replace test.json key2 new_val
# {
#   "key1": "val1",
#   "key2": "new_val"
# }

$ cat test.json
# {
#   "key1": "val1",
#   "key2": "new_val"
# }
```

## Node-RED プロジェクト管理スクリプト

以下のシェルスクリプトを保存します（例：`nodered_pj_manager.sh`）。

```zsh
#! /usr/bin/env zsh

# Node-RED project manager with fzf
nodered_active_pj() {
  # 変数定義
  local NODE_RED=$HOME/.node-red
  local PORT=1880
  local LOCAL_ROOT=$HOME/nodered_root
  local CONFIG=$NODE_RED/.config.json
  local PROJECTS_ROOT=$NODE_RED/projects
  local QUIT_OPTION='QUIT'        # 削除オプション
  local LOCAL_OPTION='__LOCAL__'  # ローカル起動オプション（プロジェクトではない）
  local OPTIONS=($QUIT_OPTION $LOCAL_OPTION)
  local ACTIVE_KEY='.projects.activeProject'
  local PROJECTS_KEY='.projects.projects'
　 # アクティブプロジェクトの取得
  local ACTIVE=`fx $CONFIG $ACTIVE_KEY`
　 # プロジェクトの一覧取得
  local _PROJECTS="`fx $CONFIG $PROJECTS_KEY '?' | grep '["]' | \
    awk -F'"' '{print $2}'`\n`printf '%s\n' $OPTIONS`"
  local PROJECTS=`echo $_PROJECTS | sort`
　 # 終了 or ローカル or 対象プロジェクトの選択
  local PROJECT=`echo $PROJECTS | fzf --prompt="Node-RED PJ(current: $ACTIVE) > " \
    --preview "fx $CONFIG $PROJECTS_KEY.{} && \
    tree -C $PROJECTS_ROOT/{} -L 1 && git -C $PROJECTS_ROOT/{} log | head -50"`
  zle reset-prompt
  if [ -n "$PROJECT" ]; then
    if [ $PROJECT = $QUIT_OPTION ]; then # 終了選択時
      # 起動中の Node-RED プロセスの一覧取得
      local candidate=`ps | grep node-red | grep -v grep | awk '{print $NF}'`
      # 終了する Node-RED プロセスの選択
      local quit=`echo $candidate | fzf --prompt="Node-RED PJ(current: $ACTIVE) > " \
        --preview "fx $CONFIG $PROJECTS_KEY.{} && \
        tree -C $PROJECTS_ROOT/{} -L 1 && git -C $PROJECTS_ROOT/{} log | head -50"`
      if [ -z "$quit" ]; then
        zle reset-prompt
        return 1
      fi
      BUFFER="pkill $quit"
    elif [ $PROJECT = $LOCAL_OPTION ]; then # ローカル選択時
      if ps | grep $PROJECT | grep -v grep > /dev/null; then
        # アクティブプロジェクトの変更のみ
        BUFFER="json-replace $CONFIG $ACTIVE_KEY '' && cd $LOCAL_ROOT" 
      else
        # アクティブプロジェクトの変更 + ローカルの起動
        BUFFER="json-replace $CONFIG $ACTIVE_KEY '' && cd $LOCAL_ROOT && \
          node-red --title \"node-red $LOCAL_OPTION\" -p $PORT > /dev/null 2>&1 &"
      fi
      # MacOS and Google Chorome に対応、適宜修正
      open -a "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
        "http://localhost:$PORT"
    else # プロジェクト選択時
      local pj_root=$PROJECTS_ROOT/$PROJECT
      local pj_num=`echo $PROJECTS | grep -n $PROJECT | awk -F ':' '{print $1}'`
      local port=`echo $(( $PORT + $pj_num - 2 ))`
      if ps | grep $PROJECT | grep -v grep > /dev/null; then
        # アクティブプロジェクトの変更のみ
        BUFFER="json-replace $CONFIG $ACTIVE_KEY $PROJECT && cd $pj_root"
      else
        # アクティブプロジェクトの変更 + 該当プロジェクトの起動
        BUFFER="json-replace $CONFIG $ACTIVE_KEY $PROJECT && cd $pj_root && \
          node-red --title \"node-red $PROJECT\" -p $port > /dev/null 2>&1 &"
      fi
      # MacOS and Google Chorome に対応、適宜修正
      open -a "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
        "http://localhost:$port"
    fi
    zle accept-line
  fi
}
# ショートカットの設定
zle -N nodered_active_pj
bindkey '^N' nodered_active_pj
```

以下の3つの変数は環境に合わせて、適宜修正してください。
 
|変数|設定値|
|---|---|
|NODE_RED|ユーザディレクトリ|
|PORT|任意|
|LOCAL_ROOT|任意|

また、以下の記述は `Ctrl-N` を実行のトリガーとすることを意味します。
こちらも任意のショートカットに変更可能です。

```zsh
bindkey '^N' nodered_active_pj
```

> 詳細は混み入ってきますので割愛しますが、興味のある方は[各種ツール](https://qiita.com/naomaruru/private/5f5e4470b01530b6145e#%E5%BF%85%E8%A6%81%E3%81%AA%E3%83%84%E3%83%BC%E3%83%AB%E7%BE%A4)や [参考](https://qiita.com/naomaruru/private/5f5e4470b01530b6145e#%E5%8F%82%E8%80%83)をご参照ください。

ファイルを作成・カスタマイズを終えたら、`.zshrc` に以下を追記します。

```zsh
source <</path/to/ファイル名>>
# 例：source nodered_pj_manager.sh
```
 


# 使い方

## 共通

`Ctrl-N` を押下して、下記の出力を確認します。

![Screen Shot 2020-12-11 at 5.45.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68124/6fc4765d-d1bd-bcd6-8033-2386e0c54686.png)

> なお、fzf ではインクリメンタル検索を行うことができます。
> 入力に応じて候補が自動的に絞り込まれるため、選択がしやすくなります。
> 例：`a`を入力したため、候補が `project_a` と `__LOCAL__` に絞られています。

![Screen Shot 2020-12-11 at 5.47.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68124/1207c079-f01b-1304-7244-1209e3d8baaa.png)

`<<プロジェクト名>>`、`__LOCAL__`、`QUIT`の中から選択します。

|選択肢|結果|
|---|---|
|`<<プロジェクト名>>`|プロジェクトを起動してアクティブ化する|
|`__LOCAL__`|ローカルで起動する|
|`QUIT`|起動中のプロセスからプロジェクトを選択・終了する|


## 特定プロジェクト/ローカル選択時

> `project_c` を選択したケースを例示します。

以下の出力を確認します。

![Screen Shot 2020-12-11 at 5.36.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68124/5668d54c-000c-769c-77f9-c20f62aa6a8a.png)

自動でブラウザのタブが開かれます。
暫くしたらブラウザをリロードして、該当のプロジェクトが起動していること確認します。

![Screen Shot 2020-12-11 at 5.51.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68124/21321274-c271-f9c9-e155-3bd2adaa8b6e.png)


> 選択するプロジェクトごとに自動で異なるポート番号が割り振られます。
> そのため、複数のプロジェクトを同時に起動することが可能です。
> なお、`__LOCAL__` のみ `1880` 番ポートで固定になります。
> ブラウザのタブを閉じてしまった場合、同様の手順でプロジェクトを選択すれば自動的にタブが開きます。

## QUIT（終了）選択時

起動しているプロセスの一覧から、終了するプロジェクトを選択します。

![Screen Shot 2020-12-11 at 6.13.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68124/6d89c36d-04db-ac15-2a3e-a17336c10d01.png)

以下の出力により、選択したプロジェクトが終了していることを確認します。

![Screen Shot 2020-12-11 at 6.35.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68124/f28d12f8-8e9b-6e40-12ea-506289a78efe.png)


以上、簡単かつ爆速でプロジェクトを起動・切り替え・終了することができました。

# 参考

- [Node-RED で git 使ってる？](https://qiita.com/high-u/items/dcbcccb57b9d0c696c82#%EF%BC%97%E6%89%80%E6%84%9F)
- [fzfを使おう](https://qiita.com/kompiro/items/a09c0b44e7c741724c80)
- [fzfを活用してTerminalの作業効率を高める](https://qiita.com/kamykn/items/aa9920f07487559c0c7e)
- [jq だけじゃない JSON ツールのご紹介](https://qiita.com/navitime_tech/items/059f72cd08de9db4c41a)


# 最後に

- Node-RED のプロジェクト機能や実装内容について、誤り・改善点があればご指摘お願いします
- pm2 などのプロセスマネージャーを使うともっと綺麗・簡単にできるのかもしれませんが、ルートディレクトリ問題も解消できるか検討し切れていません。
- 2021年も快適な Node-RED ライフを一緒に送りましょう！


# 明日は

明日の Node-RED Advent Calendar 2020 の担当は [utaani](https://qiita.com/utaani) さんの「Cloudflare for Teamsを使ったNode-REDへのリモートアクセス」です！