LoginSignup
4
0

More than 3 years have passed since last update.

HyperLedgerFabricのfabric-samplesでRESTfulAPIを使う

Last updated at Posted at 2019-11-04

HyperLedgerFablicの公式チュートリアルには
javascript / typesucript / javaで実行するクライアントSDKの記述がありますが、
Ver.1.4.3現在、RestfulAPIで動かすための記述がありません。

Webサーバーを立ててSDKのコードを組み込むしかないの?
と思いきや、公式で紹介しているfablic-samplesの中にも、REST APIサーバーとして動くクライアントがありました。今回はそれをご紹介したいと思います。

HyperLedgerFablic Composerを使ったり、amazon・microsoftなどのベンダーが公開しているブロックチェーン基盤を使用することで、RESTfulAPIを楽に使うことができるようです。
本記事はfablic-samplesだけで、RESTFulAPIまでやりたいというときのためのガイドです。たぶん、その内公式チュートリアルでも言及されると思いますけども。

対象リポジトリ

balance-transferアプリですね。
上記リポジトリのReadMeに、RESTAPIサーバーの起動まで、分かりやすく手順が記載してあります。
本記事では、RESTAPIサーバーをbalance-transferアプリでなく、fabcarアプリで実行するようにしたいと思います。
組織:2、ピア:2、CA:2、チャンネル:1 の構成です。

準備

fabcarアプリケーションを起動するところから始めます。
2019/11 現在の最新バージョン1.4.3を取得します。

curl -sSL http://bit.ly/2ysbOFE | bash -s 1.4.3
cd fabric-samples/fabcar/
./startFabric.sh

チェーンコードの言語指定は引数なしで、Golangにしておいて下さい。

確認環境

MAC OS 10.14.6
node v8.6.0(※v9以上はNGかも?)
npm v6.12.0
Docker v18.09.2
git v2.11.0

作業開始

  • app/
  • app.js
  • config/
  • config.js
  • package.json
  • artifacts/org1.yaml
  • artifacts/org2.yaml

上記のファイルを、balance-transfer/からfirst-network/へコピーします。

cd ../balance-transfer/
cp -rf app* config.* package.json artifacts/org*.yaml ../first-network/

なぜfabcarでなくfirst-networkかというと、fabcarのアプリではネットワークにfirst-networkを使用しているからです。(1.4.0ではbasic-network)

first-network/に移動し、npm installします。

cd ../first-network/
npm install

package.jsonには、expressフレームワークのRESTfuiAPI用のツールなどが記述されています。
無事にインストールできたら、次に進みます。

first-networkの接続プロファイルを変更していきます。
基本的には、balance-transferの接続プロファイル
balance-transfer/articles/network-config.yamlを参考にします。

しかしbalance-transferとfirst-networkでは、プロファイルの構造が違っていて、first-networkはあとから組織が増えていっても動的に出力できる作りになっています。

具体的にはccp-template.yamlの内容を、
ccp-generate.shのシェルを使い
connection-org1.yaml, connection-org2.yaml
といったように組織の数だけ出力しています。

なので、こちらもその作りに合わせた変更が必要になっていきます。

ccp-template.yaml
---
name: first-network-org${ORG}
version: 1.0.0
client:
  organization: Org${ORG}
  connection:
    timeout:
      peer:
        endorser: '300'
#▼ 追加
channels:
    mychannel:
        orderers:
        - orderer.example.com
        peers:
          peer0.org1.example.com:
            endorsingPeer: true
            chaincodeQuery: true
            ledgerQuery: true
            eventSource: true
          peer1.org1.example.com:
            endorsingPeer: false
            chaincodeQuery: true
            ledgerQuery: true
            eventSource: false
          peer0.org2.example.com:
            endorsingPeer: true
            chaincodeQuery: true
            ledgerQuery: true
            eventSource: true
          peer1.org2.example.com:
            endorsingPeer: false
            chaincodeQuery: true
            ledgerQuery: true
            eventSource: false
          chaincodes:
          - fabcar:v0
#▲ 追加
organizations:
  Org${ORG}:
    mspid: Org${ORG}MSP
    peers:
    - peer0.org${ORG}.example.com
    - peer1.org${ORG}.example.com
    certificateAuthorities:
    - ca.org${ORG}.example.com
    #▼ 追加
    adminPrivateKey:
      path: crypto-config/peerOrganizations/org${ORG}.example.com/users/Admin@org${ORG}.example.com/msp/keystore/${ADMIN_PRIVATE_KEY}
    signedCert:
      path: crypto-config/peerOrganizations/org${ORG}.example.com/users/Admin@org${ORG}.example.com/msp/signcerts/Admin@org${ORG}.example.com-cert.pem
    #▲ 追加
#▼ 追加
orderers:
  orderer.example.com:
    url: grpcs://localhost:7050
    grpcOptions:
      ssl-target-name-override: orderer.example.com
    tlsCACerts:
      path: crypto-config/ordererOrganizations/example.com/orderers/orderer.example.com/tls/ca.crt
#▲ 追加
peers:
  peer0.org${ORG}.example.com:
    url: grpcs://localhost:${P0PORT}
    tlsCACerts:
      pem: |
        ${PEERPEM}
    grpcOptions:
      ssl-target-name-override: peer0.org${ORG}.example.com
      hostnameOverride: peer0.org${ORG}.example.com
  peer1.org${ORG}.example.com:
    url: grpcs://localhost:${P1PORT}
    tlsCACerts:
      pem: |
        ${PEERPEM}
    grpcOptions:
      ssl-target-name-override: peer1.org${ORG}.example.com
      hostnameOverride: peer1.org${ORG}.example.com
certificateAuthorities:
  ca.org${ORG}.example.com:
    url: https://localhost:${CAPORT}
    httpOptions:
      verify: false
    tlsCACerts:
      pem: |
        ${CAPEM}
    #▼ 追加
    registrar:
      - enrollId: admin
      - enrollSecret: adminpw
    #▲ 追加
    caName: ca-org${ORG}

追加した各項目は、RESTAPIサーバーのアプリ上から参照するfirst-networkの構成情報です。
管理者情報も載せていますので、RESTAPIでチェーンコードのインストールやチャンネル作成も可能です。(開発用の機能らしい)

その他の詳細については、balance-transfer/artifacts/network-config.yamlに英語でコメントがありますので、気になりましたらご確認ください。自分でまだきちんと分かっていないので…。

シェルの方は各要素を置き換えているだけです。

テンプレートファイル新規作成
touch organizations-template.yaml
touch peers-template.yaml
touch certificate-template.yaml
touch ccp-template-allorg.yaml

とりあえず編集内容をまるっと載せます。

organizations-template.yaml
Org${ORG}:
    mspid: Org${ORG}MSP
    peers:
    - peer0.org${ORG}.example.com
    - peer1.org${ORG}.example.com
    certificateAuthorities:
    - ca.org${ORG}.example.com
    adminPrivateKey:
      path: crypto-config/peerOrganizations/org${ORG}.example.com/users/Admin@org${ORG}.example.com/msp/keystore/${ADMIN_PRIVATE_KEY}
    signedCert:
      path: crypto-config/peerOrganizations/org${ORG}.example.com/users/Admin@org${ORG}.example.com/msp/signcerts/Admin@org${ORG}.example.comcert.pem
peers-template.yaml
peer0.org${ORG}.example.com:
    url: grpcs://localhost:${P0PORT}
    tlsCACerts:
      pem: |
        ${PEERPEM}
    grpcOptions:
      ssl-target-name-override: peer0.org${ORG}.example.com
      hostnameOverride: peer0.org${ORG}.example.com
  peer1.org${ORG}.example.com:
    url: grpcs://localhost:${P1PORT}
    tlsCACerts:
      pem: |
        ${PEERPEM}
    grpcOptions:
      ssl-target-name-override: peer1.org${ORG}.example.com
      hostnameOverride: peer1.org${ORG}.example.com
certificate-template.yaml
ca.org${ORG}.example.com:
    url: https://localhost:${CAPORT}
    httpOptions:
      verify: false
    tlsCACerts:
      pem: |
        ${CAPEM}
    registrar:
      - enrollId: admin
      - enrollSecret: adminpw
    caName: ca-org
ccp-template-allorg.yaml
---
name: first-network-org${ORG}
version: 1.0.0
channels:
    mychannel:
        orderers:
        - orderer.example.com
        peers:
          peer0.org1.example.com:
            endorsingPeer: true
            chaincodeQuery: true
            ledgerQuery: true
            eventSource: true
          peer1.org1.example.com:
            endorsingPeer: false
            chaincodeQuery: true
            ledgerQuery: true
            eventSource: false
          peer0.org2.example.com:
            endorsingPeer: true
            chaincodeQuery: true
            ledgerQuery: true
            eventSource: true
          peer1.org2.example.com:
            endorsingPeer: false
            chaincodeQuery: true
            ledgerQuery: true
            eventSource: false
          chaincodes:
          - fabcar:v0
organizations:
  ${ORGANIZATIONS}
orderers:
  orderer.example.com:
    url: grpcs://localhost:7050
    grpcOptions:
      ssl-target-name-override: orderer.example.com
    tlsCACerts:
      path: crypto-config/ordererOrganizations/example.com/orderers/orderer.example.com/tls/ca.crt
peers:
  ${PEERS}
certificateAuthorities:
  ${CERTIFICATE_AUTH}
ccp-generate.sh
#!/bin/bash

function one_line_pem {
    echo "`awk 'NF {sub(/\\n/, ""); printf "%s\\\\\\\n",$0;}' $1`"
}

function json_ccp {
    local PP=$(one_line_pem $5)
    local CP=$(one_line_pem $6)
    sed -e "s/\${ORG}/$1/g" \
        -e "s/\${P0PORT}/$2/" \
        -e "s/\${P1PORT}/$3/" \
        -e "s/\${CAPORT}/$4/" \
        -e "s#\${PEERPEM}#$PP#" \
        -e "s#\${CAPEM}#$CP#" \
        -e "s#\${ADMIN_PRIVATE_KEY}#$7#" \
        ccp-template.json 
}

function yaml_ccp {
    local PP=$(one_line_pem $5)
    local CP=$(one_line_pem $6)
    sed -e "s/\${ORG}/$1/g" \
        -e "s/\${P0PORT}/$2/" \
        -e "s/\${P1PORT}/$3/" \
        -e "s/\${CAPORT}/$4/" \
        -e "s#\${PEERPEM}#$PP#" \
        -e "s#\${CAPEM}#$CP#" \
        -e "s#\${ADMIN_PRIVATE_KEY}#$7#" \
        ccp-template.yaml | sed -e $'s/\\\\n/\\\n        /g'
}
function organizations_template {
    sed -e "s/\${ORG}/$1/g" \
        -e "s#\${ADMIN_PRIVATE_KEY}#$2#" \
        organizations-template.yaml | sed -e $'s/\\\\n/\\\n        /g'
}
function peers_template {
    local PP=$(one_line_pem $4)
    sed -e "s/\${ORG}/$1/g" \
        -e "s/\${P0PORT}/$2/" \
        -e "s/\${P1PORT}/$3/" \
        -e "s#\${PEERPEM}#$PP#" \
        peers-template.yaml | sed -e $'s/\\\\n/\\\n        /g'
}
function certificate_template {
    local CP=$(one_line_pem $3)    
    sed -e "s/\${ORG}/$1/g" \
        -e "s/\${CAPORT}/$2/" \
        -e "s#\${CAPEM}#$CP#" \
        certificate-template.yaml | sed -e $'s/\\\\n/\\\n        /g'
}
function yaml_ccp {
    local PP=$(one_line_pem $5)
    local CP=$(one_line_pem $6)
    sed -e "s/\${ORG}/$1/g" \
        -e "s/\${P0PORT}/$2/" \
        -e "s/\${P1PORT}/$3/" \
        -e "s/\${CAPORT}/$4/" \
        -e "s#\${PEERPEM}#$PP#" \
        -e "s#\${CAPEM}#$CP#" \
        -e "s#\${ADMIN_PRIVATE_KEY}#$ADMIN_PRIVATE_KEY#" \
        ccp-template.yaml | sed -e $'s/\\\\n/\\\n        /g'
}
ADMIN_PRIVATE_KEY=$(cd crypto-config/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/keystore && ls *_sk)
ORG=1
P0PORT=7051
P1PORT=8051
CAPORT=7054
PEERPEM=crypto-config/peerOrganizations/org1.example.com/tlsca/tlsca.org1.example.com-cert.pem
CAPEM=crypto-config/peerOrganizations/org1.example.com/ca/ca.org1.example.com-cert.pem
echo "$(json_ccp $ORG $P0PORT $P1PORT $CAPORT $PEERPEM $CAPEM $ADMIN_PRIVATE_KEY)" > connection-org1.json
echo "$(yaml_ccp $ORG $P0PORT $P1PORT $CAPORT $PEERPEM $CAPEM $ADMIN_PRIVATE_KEY)" > connection-org1.yaml

ORGANIZATIONS=`organizations_template $ORG $ADMIN_PRIVATE_KEY`
PEERS=`peers_template $ORG $P0PORT $P1PORT $PEERPEM`
CERTIFICATE_AUTH=`certificate_template $ORG $CAPORT $PEERPEM`

ADMIN_PRIVATE_KEY=$(cd crypto-config/peerOrganizations/org2.example.com/users/Admin@org2.example.com/msp/keystore && ls *_sk)
ORG=2
P0PORT=9051
P1PORT=10051
CAPORT=8054
PEERPEM=crypto-config/peerOrganizations/org2.example.com/tlsca/tlsca.org2.example.com-cert.pem
CAPEM=crypto-config/peerOrganizations/org2.example.com/ca/ca.org2.example.com-cert.pem
echo "$(json_ccp $ORG $P0PORT $P1PORT $CAPORT $PEERPEM $CAPEM $ADMIN_PRIVATE_KEY)" > connection-org2.json
echo "$(yaml_ccp $ORG $P0PORT $P1PORT $CAPORT $PEERPEM $CAPEM $ADMIN_PRIVATE_KEY)" > connection-org2.yaml

ORGANIZATIONS+=`organizations_template $ORG $ADMIN_PRIVATE_KEY`
PEERS+=`peers_template $ORG $P0PORT $P1PORT $PEERPEM`
CERTIFICATE_AUTH+=`certificate_template $ORG $CAPORT $PEERPEM`

source=`cat ccp-template-allorg.yaml`
PARTS1="${ORGANIZATIONS}"
PARTS2="${PEERS}"
PARTS3="${CERTIFICATE_AUTH}"

res="${source/\$\{ORGANIZATIONS\}/$PARTS1}"
res="${res/\$\{PEERS\}/$PARTS2}"
echo "${res/\$\{CERTIFICATE_AUTH\}/$PARTS3}" > connection-org-all.yaml

connection-org-all.yamlが、最終的な接続プロファイルです。
最初のfabcar/statFabric.sh内で、ccp-generate.shが実行されていますが、編集したのでもう一度シェルを実行してください。

接続プロファイル生成
./ccp-generate.sh

ちなみに、同じファイルのjson版は作らなくても、
今回のREST API用のクライアントは動くようです。

出力されたconnection-org-all.yamlがREST APIサーバーのアプリから参照されるように、config.jsファイルを修正します。

config.js
pr util = require('util');
var path = require('path');
var hfc = require('fabric-client');

//var file = 'network-config%s.yaml';
var file = 'connection-org-all.yaml';

var env = process.env.TARGET_NETWORK;
//if (env)
//        file = util.format(file, '-' + env);
//else
//        file = util.format(file, '');
// indicate to the application where the setup file is located so it able
// to have the hfc load it to initalize the fabric client instance
//hfc.setConfigSetting('network-connection-profile-path',path.join(__dirname, 'artifacts' ,file));
hfc.setConfigSetting('network-connection-profile-path',path.join(__dirname, file));
//hfc.setConfigSetting('Org1-connection-profile-path',path.join(__dirname, 'artifacts', 'org1.yaml'));
hfc.setConfigSetting('Org1-connection-profile-path',path.join(__dirname, 'org1.yaml'));
//hfc.setConfigSetting('Org2-connection-profile-path',path.join(__dirname, 'artifacts', 'org2.yaml'));
// some other settings the application might need to know
hfc.addConfigFile(path.join(__dirname, 'config.json'));

かなりガッツリとコメントアウトします。
内容的にはconnection-org-all.yamlを見るようにしているだけです。

加えて、チェーンコードの参照パスも修正しましょう。次の2箇所です。

(1)config.json:6行目
変更前  "CC_SRC_PATH":"../articles",
変更後  "CC_SRC_PATH":"../../chaincode",
(2)basic-network/node_modules/fabric-client/lib/packager/Golang.js:42行目
変更前  const projDir = path.join(goPath, 'src', chaincodePath);
変更後  const projDir = path.join(goPath, chaincodePath);

実行

これでAPIサーバーが起動できる状態になりました。
コンソールを2つ使います。
片方でfirst-network/に移動して、試してみましょう。

PORT=4000 node app
[2019-10-27 23:16:35.871] [INFO] SampleWebApp - ****************** SERVER STARTED ************************
[2019-10-27 23:16:35.875] [INFO] SampleWebApp - ***************  http://localhost:4000  ******************

上記のように表示されたら起動に成功しています。
もう片方のコンソールからcurlコマンドでリクエストを投げます。

ユーザ作成/取得
curl -s -X POST http://localhost:4000/users -H "content-type: application/x-www-form-urlencoded" -d 'username=Jim&orgName=Org1'

{"success":true,"secret":"","message":"admin enrolled Successfully","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzIyMjE5MDYsInVzZXJuYW1lIjoiYWRtaW4iLCJvcmdOYW1lIjoiT3JnMSIsImlhdCI6MTU3MjE4NTkwNn0.vUkqLz4eIverwM1DI8wENxJqIe3uRBiyAB21ZcIym9k"}

上記のように応答があれば成功です!
このAPIはユーザを組織に参加させ、証明書を発行します。
返却されるJSON Web Token(JWT)の値は控えておいてください。
以降のAPIはこのtokenを付帯してリクエストする必要があります。
例として、新車登録のチェーンコードを実行するリクエストに、authorizationヘッダでJWTを付帯させます。

curl -s -X POST \
  http://localhost:4000/channels/mychannel/chaincodes/fabcar \
  -H "authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzIyMjIwNDEsInVzZXJuYW1lIjoiYWRtaW4yIiwib3JnTmFtZSI6Ik9yZzEiLCJpYXQiOjE1NzIxODYwNDF9.w0EvpfQ07RGqTanEgTQn1B6N4xRLs_Lpp6T87OqE2kU" \
  -H "content-type: application/json" \
  -d '{
    "peers": ["peer0.org1.example.com", "peer0.org2.example.com"],
    "fcn":"createCar",
    "args":["CAR999", "Honda", "Accord", "Black", "Tom"]
}'


{"success":true,"message":"Successfully invoked the chaincode Org1 to the channel 'mychannel' for transaction ID: dac7d98a930697cbea61012fadffb3092199935930d41f4658492c77415bcf5c"}

また、fabcarのエンドーズメントポリシーがMSP1とMSP2になっているので
2つのエンドーズメントピア("peer0.org1.example.com", "peer0.org2.example.com")を指定してください。

結果は上記のような感じになります。
その他に、次のようなAPIが実行可能です。

  • ユーザ作成/取得
  • チャンネル作成
  • チャンネル参加
  • チェーンコードインストール
  • チェーンコードインスタンス化
  • チェーンコード実行(POST/GET)
  • ブロック参照
  • トランザクション参照
  • チェーン情報取得
  • イントール済みチェーンコード一覧取得
  • インスタンス化済みチェーンコード一覧取得
  • チャンネル一覧取得

詳しくはこちらをご確認ください。
https://github.com/hyperledger/fabric-samples/tree/release-1.4/balance-transfer

ブロックを眺める

可視性は良くありませんが、上記のAPIでブロックの中身を見ることが可能です。

チェーンの長さを見る(lowの値-1が最新ブロック番号)
curl -s -X GET   "http://localhost:4000/channels/mychannel?peer=peer0.org1.example.com"   -H "authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzIyMjIwNDEsInVzZXJuYW1lIjoiYWRtaW4yIiwib3JnTmFtZSI6Ik9yZzEiLCJpYXQiOjE1NzIxODYwNDF9.w0EvpfQ07RGqTanEgTQn1B6N4xRLs_Lpp6T87OqE2kU"   -H "content-type: application/json" | grep --color=auto "low"

...
{"height":{"low":5,"high":0,"unsigned":true}
...
番号を指定してブロック参照する
curl -s -X GET \
  "http://localhost:4000/channels/mychannel/blocks/4?peer=peer0.org1.example.com" \
  -H "authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzIyMjIwNDEsInVzZXJuYW1lIjoiYWRtaW4yIiwib3JnTmFtZSI6Ik9yZzEiLCJpYXQiOjE1NzIxODYwNDF9.w0EvpfQ07RGqTanEgTQn1B6N4xRLs_Lpp6T87OqE2kU" \
  -H "content-type: application/json" | grep --color=auto "tx_id"
...
{"channel_header":{"type":3,"version":1,"timestamp":"2019-10-27T15:22:53.967Z","channel_id":"mychannel","tx_id":"bc895feb278702eb7e413aaabf3e583e8129ea620e175daad9e21f9e4bceed4f","epoch":"0","extension":{"type":"Buffer","data":[18,8,18,6,102,97,98,99,97,114]},
...

上記によりtx_id(トランザクションID)を控えるか、さきほどのチェーンコード実行のリクエストから返却されたトランザクションIDをパラメータに指定して、ブロック内のトランザクションを問い合わせます。

トランザクションの中身を見る
curl -s -X GET http://localhost:4000/channels/mychannel/transactions/bc895feb278702eb7e413aaabf3e583e8129ea620e175daad9e21f9e4bceed4f?peer=peer0.org1.example.com \
  -H "authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzIyMjIwNDEsInVzZXJuYW1lIjoiYWRtaW4yIiwib3JnTmFtZSI6Ik9yZzEiLCJpYXQiOjE1NzIxODYwNDF9.w0EvpfQ07RGqTanEgTQn1B6N4xRLs_Lpp6T87OqE2kU" \
  -H "content-type: application/json"  | grep --color=auto "writes"

...
"writes":[{"key":"CAR999","is_delete":false,"value":"{\"color\":\"Black\",\"docType\":\"car\",\"make\":\"Honda\",\"model\":\"Accord\",\"owner\":\"Tom\"}"}],
...

これでfabric_samplesのカスタマイズだけでいい感じのシステムが作れそうですね:smile:

ちなみにこれまで言及のなかった、org1.yaml, org2.yamlのファイルですが、
この中で、ユーザの秘密鍵・公開鍵・証明書が出力される場所を指定しています。

org1.yaml43行目,50行目
path: "./fabric-client-kv-org1"
path: "/tmp/fabric-client-kv-org1"

上記デフォルトの設定だと、ユーザ登録のAPIを実行したとき、証明書はfirst-network/fabric-client-kv-org1に、
公開鍵・秘密鍵は/tmp/fabric-client-kv-org1に出力されます。

これを適宜移すことで、既存のクライアントSDKアプリとの連携が可能になります。
たとえばfabcar/javascript-low-level/下のアプリなら、

org1.yaml43行目,50行目
path: "../fabcar/javascript-low-level/hfc-key-store/"
path: "../fabcar/javascript-low-level/hfc-key-store/"

とすれば連携できます。
fabcar/javascript/などの場合は、証明書・秘密鍵・公開鍵の管理がwalletであるため、
ディレクトリ構造が少し違うので、改造が必要になると思います。

今回説明させて頂いたコードをこちらに置きましたので、参考になれば幸いです。
また、記載した手順をやり直す場合は、ネットワークの初期化を行ってください。

ネットワーク初期化
cd fabric-samples/first-network
docker rm -f $(docker ps -aq)
docker rmi -f $(docker images | grep dev | awk '{print $3}')
rm -rf fabric-client-kv-org[1-2]
rm -rf /tmp/fabric-client-kv-org[1-2]
rm -rf channel-artifacts/*
rm -rf crypto-config/*

以上です。おつかれさまでした。

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0