Amazon Neptuneから取得したグラフデータをvis-network-reactで表示する

こちらは、[ゆるWeb勉強会@札幌 Advent Calendar 2021](https://adventar.org/calendars/6270) の 19日目の記事です。

## テーマ
  Amazon Neptuneからデータを取得し、
  Amplifyで作成したサイトでグラフを表示するまでを試します。
  ※ハンズオンを終え、もう少し触ってみたい方向けです。細かい手順を省略しています。

  * 任意のデータをAmazon Neptuneに保存
  * AWS Amplifyで作成したサイトからNeptuneに接続、データ取得
  * グラフ表示のライブラリはvis.jsベースの[vis-network-react](https://github.com/visjs/vis-network-react) 1.3.6
  


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177998/bdd52c09-b0e4-731c-9d0a-cc8bdeba1778.png)



## 背景

  AWSさんのハンズオンで環境の構築～表示確認までは出来ました。
  グラフデータの表示が面白かったので、表示したいグラフデータを少し変更して、表示出来るまでを試してみました。

ハンズオンはこちらです。
コードも用意されているので、流れをつかみたい方にお勧めです。

* [Amazon Neptune グラフデータ可視化ハンズオン](https://github.com/harunobukameda/Amazon-Neptune)

:::note info
  AWS亀田さんのNeptuneハンズオンです。
  Amazon Neptune、AWS Lambda function、Amazon API Gateway、S3の環境を作成し、
  S3に配置した静的コンテンツからNeptuneのデータを取得し、グラフデータを表示しています。
  
  JavaScriptのライブラリは無し、グラフ表示のライブラリはvis.js 4.21です。
:::


* [Amazon NeptuneとAWS Amplifyを利用したグラフアプリケーション開発](https://aws.amazon.com/jp/blogs/news/build-a-graph-application-with-amazon-neptune-and-aws-amplify/)

:::note info
  AWS AmplifyでAmazon Neptuneからデータを取得し表示するサンプルです。
  JavaScriptのライブラリはReact、グラフ表示のライブラリはreact-vis-forceです。
:::


## グラフ表示ライブラリの設定

今回は[vis.js](https://visjs.org/)を使用します。

Reactで作成するため、以下を使用しました。
https://github.com/visjs/vis-network-react

グラフの表示イメージを確認出来るサイトです。
https://visjs.github.io/vis-network/examples/

### vis-network-reactのインストール

```
npm i vis-network-react
```

### ダミーデータの表示

グラフ表示用のコンポーネントに値を渡します。

```
<VisNetworkReactComponent style={graphStyle} data={data} />
```

渡すデータは、「ノード：nodes」、「エッジ：edges」という名前で渡します。

>ノード（node）：別名バーテックス、頂点。点や丸で表現されるエンティティー。「ラベル」を付けて種別を分類することが多い。
>
>エッジ（edge）：別名リレーションシップ、辺。ノード間の関係性を表す。方向とタイプを有する。
>
>プロパティ（property）：別名、属性。ノードとエッジにおける属性情報。データはkey／value形式で保持される。
>
> 引用：[グラフデータベースとは何か　～ネットワーク状のデータ構造から瞬時に情報を検索するDBを解説](https://www.imagazine.co.jp/12805-2/)



```javascript:TestPage.js
import React, { useState, useEffect } from "react";

import { API } from "aws-amplify";
import * as queries from "../graphql/queries";

import VisNetworkReactComponent from "vis-network-react";


const TestPage = () =>{

  // dummy vis-network-react
  const defaultdata = {
    nodes: [
      { id: "user1", label: "佐藤", group: "User" },
      { id: "user2", label: "鈴木", group: "User" },
      { id: "user3", label: "高橋", group: "User" },
      { id: "user4", label: "田中", group: "User" },
      { id: "user5", label: "渡辺", group: "User" },
      { id: "kwd1", label: "AWS", group: "KeyWord", color: "#f7f5db", shape: "box" },
      { id: "kwd2", label: "AWS勉強中", group: "KeyWord", color: "#f7f5db", shape: "box" },
      { id: "kwd3", label: "コンテナ好き", group: "KeyWord", color: "#f7f5db", shape: "box" },
      { id: "kwd4", label: "猫好き", group: "KeyWord", color: "#f7f5db", shape: "box" },
      { id: "kwd5", label: "犬好き", group: "KeyWord", color: "#f7f5db", shape: "box" },
    ],
    edges: [
      { from: "user1", to: "kwd2" },
      { from: "user1", to: "kwd1" },
      { from: "user2", to: "kwd4" },
      { from: "user2", to: "kwd1" },
      { from: "user3", to: "kwd3" },
      { from: "user3", to: "kwd1" },
      { from: "user4", to: "kwd1" },
      { from: "user5", to: "kwd1" },
      { from: "user5", to: "kwd5" },
    ],
  };

  const graphStyle = {
    width: '600px',
    height: '600px',
  };

  const [data, setData] = useState(defaultdata);

  return (
    <>
      <VisNetworkReactComponent
        style={graphStyle}
        data={data}
      />
    </>
  );
}

export default TestPage;
```

ダミーデータの表示イメージです。
グラフ表示の確認がとれたため、Neptune投入用のデータを作成していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177998/ca346697-c9df-3b26-0959-81a0885880a0.png)



## Neptune用グラフデータの作成

ユーザーと、そのユーザーが興味を持っているキーワードを可視化する想定で
データを作成します。

### データ定義
#### ノード(Vertex)
| ノード名 | 説明 |
| ---- | ---- | 
| User | ユーザー名 |
| KeyWord | キーワード名 |

#### エッジ(Edge)

| エッジ名 | Source(矢印の元) | Target(矢印の先) | 説明 |
| ---- | ---- | ---- |---- | 
| Selected | User | KeyWord | ユーザーが関心のあるキーワード |


#### ノード(User)のデータ

| 項目名 | データ型 | 説明 | 備考 |
| ---- | ---- | ---- | ---- |
| ~id | String | ID | 一意 |
| name | String | ユーザー名 |  |
| ~label | String | ラベル名 |  |

#### ノード(KeyWord)のデータ

| 項目名 | データ型 | 説明 | 備考 |
| ---- | ---- | ---- | ---- |
| ~id | String | ID | 一意 |
| name | String | キーワード名 |  |
| group | String | キーワードグループ | (未使用) |
| ~label | String | ラベル名 |  |


#### エッジ(Selected)のデータ
  
| 項目名 | データ型 | 説明 | 備考 |
| ---- | ---- | ---- | ---- |
| ~id | String | ID | 一意 |
| ~label | String | ノードのプロパティ(name) |  |
| ~from | String | 接続元ノードのid |  |
| ~to | String | 接続先ノードのid |  |
| date | Date | 登録日付 | 自動入力 |

### Neptune投入用のデータ作成

作成し、S3バケットに配置します。
AWS CLIコマンドを実行し、データのバルクロードを行います。

[Gremlin用のフォーマット](https://docs.aws.amazon.com/neptune/latest/userguide/bulk-load-tutorial-format-gremlin.html)

<details><summary>Userノードのサンプル</summary><div>

```csvs:User.csv
~id,name:String,~label
"usr1","佐藤",User
"usr2","鈴木",User
"usr3","高橋",User
"usr4","田中",User
"usr5","渡辺",User
```
</div></details>

<details><summary>Keywordノードのサンプル</summary><div>

```csvs:Keyword.csv
~id,name:String,group:String,~label
"kw1","AWS","1",keyWord
"kw2","AWS勉強中","1",keyWord
"kw3","コンテナ好き","1",keyWord
"kw4","猫好き","2",keyWord
"kw5","犬好き","2",keyWord
"kw6","読書","2",keyWord
"kw7","旅行","2",keyWord
"kw8","音楽","2",keyWord
"kw9","運動","2",keyWord
```
</div></details>


<details><summary>エッジのサンプル</summary><div>

```csvs:User_Selected_KeyWord_edges.csv
~id,~label,~from,~to,date:Date
1,Selected,"usr1","kw1",
2,Selected,"usr1","kw4",
3,Selected,"usr1","kw2",
4,Selected,"usr1","kw7",
5,Selected,"usr2","kw1",
6,Selected,"usr2","kw2",
7,Selected,"usr2","kw3",
8,Selected,"usr2","kw6",
9,Selected,"usr3","kw1",
10,Selected,"usr3","kw2",
11,Selected,"usr3","kw5",
12,Selected,"usr3","kw7",
13,Selected,"usr4","kw1",
14,Selected,"usr4","kw5",
15,Selected,"usr4","kw2",
16,Selected,"usr4","kw8",
17,Selected,"usr5","kw1",
18,Selected,"usr5","kw3",
19,Selected,"usr5","kw6",
20,Selected,"usr5","kw9",
```
</div></details>


### Neptuneにデータ投入

以下のコマンドを、sourceのファイル名を書き換えて用意したcsvの回数分実行します。

AWS CLI例(AWS Cloud9などでNeptuneと同一VPC内から実行)

```shell
curl -X POST \
-H 'Content-Type: application/json' \
https://Neptuneのエンドポイント:8182/loader -d '
{
"source" : "s3://S3バケット名/読み込むcsvファイル名",
"format" : "csv",
"iamRoleArn" : "NeptuneクラスターにアタッチしたIAMロールのARN",
"region" : "リージョン名(東京はap-northeast-1)",
"failOnError" : "FALSE",
"parallelism" : "MEDIUM",
"updateSingleCardinalityProperties" : "FALSE",
"queueRequest" : "TRUE"
}
'
```

### Neptune Notebooksでデータの投入状況を確認

ハンズオンで使用したノートブックからクエリを実行し、
データの投入状況を確認します。

#### ノードの確認

```
g.V().valueMap()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177998/5ff5d05f-54b5-068b-52b1-9ed9ee507210.png)

#### エッジの確認

```
g.E().toList()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177998/a45dfcfd-2aa3-a2b7-dce7-7dcd4a7f2f85.png)

無事登録されていました。

### データを削除したい場合

データを削除したい場合は、drop()を実行します。
<font color="IndianRed">**以下は一括削除のコマンドです。ご注意ください。**</font>

```
g.V().drop().iterate()
g.E().drop().iterate()
```

## データ取得用Lambda関数の作成

ノードとエッジの値を個別に取得し、整形したものをjson形式で返却します。

```json
returnData = { nodes: data_nodes, edges: data_edge }
return JSON.stringify(returnData);
```

ノードはUser、KeyWordの2種類ありますが、
取得したいプロパティ名をnameに揃えているため、1回で取得します(省エネ)。

```javascript
const nodes = await g.V()
  .limit(100)
  .valueMap()
  .with_(withTokens)
  .toList();
```

変数に格納する際は、id、labelはそのままでOKでした。
プロパティ(nameなど)は配列になっていたため、name.toString()で値を取得しています。

```javascript
data_nodes = nodes.map(row => (
  {id: row.id, label: row.name.toString(), group: row.label}
));

```


<details><summary>Lambda関数の全体像です。</summary><div>

```javascript:index.js(Lambda関数「getGraphData-staging」)
const gremlin = require('gremlin');

exports.handler = async event => {
  const {DriverRemoteConnection} = gremlin.driver;
  const {Graph} = gremlin.structure;
  const dc = new DriverRemoteConnection(
    `wss://${process.env.NEPTUNE_ENDPOINT}:${process.env.NEPTUNE_PORT}/gremlin`,
    {mimeType: 'application/vnd.gremlin-v2.0+json'}
  );
  const graph = new Graph();
  const g = graph.traversal().withRemote(dc);
  const withTokens = '~tinkerpop.valueMap.tokens';

  try {
    let data_nodes = [];
    let data_edge = [];

    const nodes = await g.V()
      .limit(100)
      .valueMap()
      .with_(withTokens)
      .toList();
    data_nodes = nodes.map(row => (
      {id: row.id, label: row.name.toString(), group: row.label}
    ));

    const edge = await g.E().limit(100).toList();

    data_edge = edge.map((r) => {
      return {from: r.outV.id, to: r.inV.id};
    });

    const returnData = { nodes: data_nodes, edges: data_edge };
    
    dc.close();
    return JSON.stringify(returnData);

  } catch (error) {
    console.log('ERROR', error);
    dc.close();
  }
};
```
</div></details>


## フロントにNeptuneからのデータ取得処理を追加

GraphQLのAPIを使用して接続します。

```javascript:TestPage.js
  const [data, setData] = useState({nodes: [], edges: []});

  useEffect(() => {
    fetchMemberStatus();
  }, []);

  // neptuneからデータ取得
  const fetchMemberStatus = async () => {

    const apiData = await API.graphql({
      query: queries.getGraphData,
    });

    setData(JSON.parse(apiData.data.getGraphData));

  };
```

<details><summary>最終的な表示用コードです</summary><div>


```javascript:TestPage.js
import React, { useState, useEffect } from "react";

import { API } from "aws-amplify";
import * as queries from "../graphql/queries";

import VisNetworkReactComponent from "vis-network-react";


const TestPage = () =>{

  const graphStyle = {
    width: '600px',
    height: '600px',
  };

  const [data, setData] = useState({nodes: [], edges: []});

  useEffect(() => {
    fetchMemberStatus();
  }, []);

  // neptuneからデータ取得
  const fetchMemberStatus = async () => {

    const apiData = await API.graphql({
      query: queries.getGraphData,
    });

    setData(JSON.parse(apiData.data.getGraphData));

  };

  return (
    <>
      <VisNetworkReactComponent
        style={graphStyle}
        data={data}
      />
    </>
  );
}

export default TestPage;

```

</div></details>

無事Neptuneから取得したデータが表示されました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177998/10066fa2-e3b7-803c-2df4-5e6fae41bb00.png)


## 感想

当初はグラフデータのクエリで必要なデータのみ取得するべく、
Gremlinのクエリとにらめっこしていました。

サンプルを見ると、クエリ自体はシンプルだったため、
データの設計が大事だなと思いました。

(気づきポイント)

* 取得するプロパティ名を揃えると、クエリがすっきりする

| ノード名 | 変更前 | 変更後 |
|:-----------|:-----------|:------------:|
| User      | userName | name |
| KeyWord      | name | name(変更なし) |

変更前：
  - 取得項目が異なるため、クエリを分けるか、分岐処理が必要

変更後：
  - 1回のクエリで取得可能。判断は別の項目(label)で可能


* 各ノードのidには文字列を含めると管理が容易

| ノード名 | 変更前 | 変更後 |
|:-----------|:-----------|:------------:|
| User      | 数値(1,2,3,4,5...) | 文字列+数値(usr1,usr2,usr3,usr4,usr5...)     |
| KeyWord      | 数値(1,2,3,4,5...) | 文字列+数値(kw1,kw2,kw3,kw4,kw5...)|

変更前：
  - Edgeで、from、toのidがUserノードのものか、KeyWordノードのものか判断が難しい
  - nodesとしてUserとKeyWordの配列をマージする際に重複してしまう

変更後：
  - Edgeで、from、toのidが特定しやすい
  - UserとKeyWordの配列をマージしてもidが重複しない

---

**試した後は、後始末を忘れずに。**
Neptuneは停止か削除しておきましょう。
東京リージョンではdb.t3.mediumで0.15USD/時間(2021/12/19現在)です:purse:


## 参考資料

**お世話になったハンズオン**
[Amazon Neptune グラフデータ可視化ハンズオン](https://github.com/harunobukameda/Amazon-Neptune)
[Amazon NeptuneとAWS Amplifyを利用したグラフアプリケーション開発](https://aws.amazon.com/jp/blogs/news/build-a-graph-application-with-amazon-neptune-and-aws-amplify/)

**Neptuneやデータ構造の検討資料**
[Amazon Neptune Advanced Design Patterns](https://pages.awscloud.com/rs/112-TZM-766/images/Session%205%20-%20Neptune_Advanced_Design_Pattern_rev.pdf)
[20200714 AWS Black Belt Online Seminar Amazon Neptune](https://www.slideshare.net/AmazonWebServicesJapan/20200714-aws-black-belt-online-seminar-amazon-neptune)


**Amplifyの資料**
[Amplify学習リソース集](https://aws-amplify-jp.github.io/resources/?utm_source=pocket_mylist)
[React アプリケーションの構築](https://aws.amazon.com/jp/getting-started/hands-on/build-react-app-amplify-graphql/)


**クエリ言語(Gremlin)の資料**
[TinkerPop Documentation](https://tinkerpop.apache.org/docs/current/reference/#intro)
[PRACTICAL GREMLIN: An Apache TinkerPop Tutorial](https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html)