Cosmos Node Monitor 開発 (Angular x Firebase)
自己紹介
株式会社CauchyEのエンジニアの松岡靖典です。
ブロックチェーンの技術的な面白さに魅了され、 Web 開発の世界を歩み始めました。
NPO法人NEM技術普及推進会NEMTUSの理事としてブロックチェーン技術の普及推進活動に従事したり、個人開発等の取り組みも行っています。
個人的には JavaScript, TypeScript, Angular, Firebase, NEM, Symbol, Cosmos が好きです。
ただし、チャンスがあれば他にも様々な技術に触れていきたいと思っています。
記事概要
現在、社内での開発中の Cosmos SDK 製ブロックチェーンを対象に、Cosmos SDK 製ブロックチェーンのノードのモニタリング結果をリスト表示できるツール「Cosmos Node Monitor」を個人開発しました。
(対象ブロックチェーンの REST API へアクセス可能なノードがあれば、)設定を少し変えるだけで、様々な Cosmos SDK 製ブロックチェーンに対応できるよう意識して実装したので、今後、他の Cosmos SDK 製ブロックチェーンを使用したプロジェクトにも色々と対応していけるといいなと思っています。
- GitHub: https://github.com/next-web-technology/cosmos-node-monitor
- Cosmos Node Monitor サンプルアプリ: https://ununifi-node-monitor-test.web.app/
実装の詳細については上述の GitHub やサンプルアプリを参照ください。
本記事では、開発で使用した技術スタックや、開発の際にいつも心がけていることや、今回の開発の際に感じたこと等について記載します。
技術スタック
個別の技術スタックについては、公式のリンクや参考資料等を脚注にまとめておきました。
- フロントエンド
- バックエンド
- CI/CD
設計及び実装概要
バックエンド
ノードの状態を定期的に監視するジョブをバックエンドで実行する設計にしています。
フロントエンドからそういったジョブを実行するには CORS 等の制約があったり、各ユーザーの手元のブラウザからノードへの通信が行われるとノードへの通信が増えすぎてノードへの負荷が高まり過ぎるといった問題が考えられるためです。
REST API を叩いてブロックチェーンの情報を取得する箇所は @cosmos-client/core https://github.com/cosmos-client/cosmos-client-ts を使用しています。
バックエンドでの詳細な処理の流れは以下の通りです。これらの処理を実行する際、Firebase Functions での定期実行機能や、Firestore へのデータ登録をトリガーに Firebase Functions を起動できる機能等といった Firebase の便利な機能から大きな恩恵を受けています。
- 最初に利用可能な REST API ノードのドメインを手動で Firestore に登録しておく
- 5 分に 1 回、以下のようにノードのモニタリングを行う
- 1 で登録された全 REST API ノードと通信し、以下の情報を取得して、Firestore に登録する
- ノード情報
- 最新ブロック高さ
- ブロックチェーンネットワークとの同期状態
- 1 で登録された全 REST API ノードの中から、正常に動作しているものをランダムに選んで、以下の情報を取得して、Firestore に登録する
- 全バリデーターノードの情報一覧
- 1 で登録された全 REST API ノードと通信し、以下の情報を取得して、Firestore に登録する
5分に1回行う定期的な処理は以下のようにFirebase Functionsにてトリガーしています。
import * as functions from "firebase-functions";
import { initializeApp } from "firebase-admin";
initializeApp();
export const triggerJobsExecutedPeriodically = functions
.runWith({
timeoutSeconds: 60, // タイムアウト時間(秒)設定
memory: "128MB", // メモリ上限値設定
})
.region("asia-northeast1") // region設定
.pubsub.schedule("*/5 * * * *") // cronの書式で定期実行タイミング・頻度を設定
.onRun(async () => {
/* ここに処理を書く */
})
各ノードと通信し、結果を Firestore に保存する処理は以下のように Firebase Functions で実装しています。
export const executeJobOnCreateJob = functions
.runWith({
timeoutSeconds: 60,
memory: "128MB",
})
.region("asia-northeast1")
.firestore.document("jobs/{domain}") // ここで指定した Firestore のコレクション に対して
.onCreate(async (change, context) => { // 新しいドキュメントが作成されたら以下の処理が実行される
/* ここに処理を書く */
})
Firestore のデータの読み込み・書き込みは、Firebase Functions では Firebase の Admin SDK を用いて以下のように実装しています。
// 例: Nodeという型のデータが格納されるFirestoreのコレクションからの読み取りや書き込み
import { firestore } from "firebase-admin";
import { Node } from "./node.model"; // 型定義のimport
// nodesコレクションから全件リストを読み込み ... データ数が多い場合は課金額が大きくなりすぎないよう使用可否を慎重に判断したほうが良い
export const getNodes = async (): Promise<Node[]> => {
const db = firestore();
const nodeRef = db.collection("nodes");
const nodesSnapshot = await nodeRef.get();
const nodes: Node[] = []; // 注意: 本当はconverterを介して厳密に型付けすべき
nodesSnapshot.forEach((nodeSnapshot) =>
nodes.push(nodeSnapshot.data() as Node) // 注意: 本当はconverterを介して厳密に型付けすべき
);
return nodes;
};
// nodesコレクションからドキュメントIDを指定して1件のデータを読み込み
export const getNode = async (domain: string): Promise<Node> => {
const db = firestore();
const nodesRef = db.collection("nodes");
const nodeDoc = await nodesRef.doc(domain).get();
if (!nodeDoc.exists) {
throw Error("Not Found");
} else {
const node: Node = nodeDoc.data() as Node; // 注意: 本当はconverterを介して厳密に型付けすべき
return node;
}
};
// nodesコレクションにドキュメントIDを指定して1件のデータを書き込み
export const setNode = async (node: Node): Promise<void> => {
const db = firestore();
const nodesRef = db.collection("nodes");
if (node.domain) {
await nodesRef.doc(node.domain).set(node);
}
};
@cosmos-client/core を使用してブロックチェーン上の情報を取得する箇所は以下のような形で実装しています。
import { cosmosclient, rest } from "@cosmos-client/core";
// 3. 自動生成されたInlineResponse20037等のような型の名前はわかりにくいので
// 2で(補完を頼りに意図する内容を)実装した後で型を確認し必要に応じて使うのがおすすめ
import { InlineResponse20037 } from "@cosmos-client/core/esm/openapi";
export const getCosmosNodeInfo = async (
restApiUrl: string,
chainId: string
): Promise<InlineResponse20037 | undefined> => {
// 1. @cosmos-client/coreを使用する際の定型的な記述
// sdkというよりも接続先のAPIのURLやchainId等の情報を保持したものと捉えたほうが正確か
const sdk = new cosmosclient.CosmosSDK(restApiUrl, chainId);
try {
// 2. ここを「rest.」のように書く際にエディタ上で補完が効く
// 入力候補として表示される選択肢をを見ながら実装し、返り値の型が何になるか把握した後、必要あれば3. のimport等を実装
const result = await rest.tendermint.getNodeInfo(sdk); //
if (result.status !== 200) {
return undefined;
}
const nodeInfo = result.data;
return nodeInfo;
} catch (error) {
return undefined;
}
};
フロントエンド
フロントエンドは非常にシンプルです。SPA で Firestore のリアルタイムリスナーで受け取った情報をほとんどそのまま表示しているだけです。
Firestore のリアルタイムリスナーと RxJS を組み合わせることによって、最初に適切に宣言さえしておけば、更新があったら、更新された値が自動的に流れていって、SPA 上の表示に自動的に反映させることができるのがとても便利です。
- REST API ノードリストの表示 ... Firestore のリアルタイムリスナーで受け取った情報をそのまま表示
- バリデーターノードリストの表示 ... Firestore のリアルタイムリスナーで受け取った情報をそのまま表示
フロントエンドから Firestore のリアルタイムなデータを受け取って表示する一例を以下に示します。
(0) Angular で Firestore のデータを扱えるようにするための設定
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { provideFirebaseApp, initializeApp } from '@angular/fire/app'; // この行がポイント
import { getFirestore, provideFirestore } from '@angular/fire/firestore'; // この行がポイント
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { environment } from 'src/environments/environment';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
AppRoutingModule,
provideFirebaseApp(() => initializeApp(environment.firebase)), // この行がポイント
provideFirestore(() => getFirestore()), // この行がポイント
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
(1) Infrastructure Service ... Firestore からのデータ読み込みの役割を担う
import { Injectable } from '@angular/core';
import {
collection,
Firestore,
onSnapshot,
} from '@angular/fire/firestore';
import { BehaviorSubject, Observable } from 'rxjs';
import { Node } from './node.model';
import { InterfaceNodeService } from './node.service';
// ↑ Serviceから読み込んだこのInterfaceを満たすようInfrastructure Serviceを実装することが強制されることで依存性逆転の原則を実現
@Injectable({
providedIn: 'root',
})
export class NodeInfrastructureService implements InterfaceNodeService { // この記述で強制される
db: Firestore;
nodes$: BehaviorSubject<Node[]>;
constructor(firestore: Firestore) {
this.db = firestore;
this.nodes$ = new BehaviorSubject([] as Node[]); // 注意: 本当はconverterを介して厳密に型付けすべき
}
getAllNodes$(): Observable<Node[]> {
const nodesCollectionRef = collection(this.db, 'nodes');
const unsubscribe = onSnapshot(nodesCollectionRef, (nodesSnapshot) => { // Firestoreから新しいデータが流れてくる毎に、このメソッドが実行される
const nodes: Node[] = [];
nodesSnapshot.forEach((doc) => {
const unknownNode = doc.data() as unknown; // 注意: 本当はconverterを介して厳密に型付けすべき
const node = unknownNode as Node; // 注意: 本当はconverterを介して厳密に型付けすべき
nodes.push(node);
});
this.nodes$.next(nodes); // 新しいデータがFirestoreから流れてくる毎に、この行で新しいデータを返り値に流してやっている
});
return this.nodes$.asObservable();
}
}
(2) Service ... フロントエンドのコンポーネントは、 Infrastructure Service をそのまま使うのではなく、 Service(や必要によっては Application Service) を使う
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { NodeInfrastructureService } from './node.infrastructure.service';
import { Node } from './node.model';
export interface InterfaceNodeService {
getAllNodes$: () => Observable<Node[]>;
}
@Injectable({
providedIn: 'root',
})
export class NodeService {
constructor(private nodeInfrastructureService: NodeInfrastructureService) {}
getAllNodes$(): Observable<Node[]> {
return this.nodeInfrastructureService.getAllNodes$();
}
}
(3) Page Component
import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { Node } from 'src/app/models/nodes/node.model';
import { NodeService } from 'src/app/models/nodes/node.service';
@Component({
selector: 'app-rest-api-nodes',
templateUrl: './rest-api-nodes.component.html',
styleUrls: ['./rest-api-nodes.component.css'],
})
export class RestApiNodesComponent {
nodes$: Observable<Node[]>;
constructor(private nodeService: NodeService) {
this.nodes$ = this.nodeService.getAllNodes$(); // Observableな値が流れてくる
}
}
<app-view-rest-api-nodes [nodes]="nodes$ | async"></app-view-rest-api-nodes>
<!-- Observableな値を async pipe で値として受け取って子コンポーネントに渡している -->
(4) View Component
import { Component, Input } from '@angular/core';
import { Node } from 'src/app/models/nodes/node.model';
@Component({
selector: 'app-view-rest-api-nodes',
templateUrl: './rest-api-nodes.component.html',
styleUrls: ['./rest-api-nodes.component.css'],
})
export class ViewRestApiNodesComponent {
@Input() nodes?: Node[] | null; // 親コンポーネントから値を受け取って使用するものは@Inputデコレーターをつけて扱う
constructor() {}
}
<div class="flex-1">
<span class="text-lg font-bold">REST API Node List</span>
</div>
<div class="overflow-x-auto">
<table class="table w-full table-zebra">
<thead>
<tr>
<th>Domain</th>
<th>Chain ID</th>
<th>Http</th>
<th>Sync</th>
<th>Block</th>
<th>Node</th>
</tr>
</thead>
<tbody>
<!-- データが存在する場合、しない場合の場合分けをして、それぞれの場合に表示すべきhtmlをここで実装する -->
<ng-container
*ngIf="nodes && nodes.length > 0; then existNodes; else noNodes"
></ng-container>
<ng-template #existNodes>
<tr *ngFor="let node of nodes">
<th>{{ node.domain }}</th>
<th>{{ node.chainId }}</th>
<th>
<span *ngIf="node.restApiHttpEnabled" class="bg-green-500"
>Enabled</span
>
<span *ngIf="!node.restApiHttpEnabled" class="bg-red-500"
>Disabled</span
>
</th>
<th>
<a
*ngIf="node.syncStatus"
href="{{
node.restApiHttp
}}/cosmos/base/tendermint/v1beta1/syncing"
class="link link-primary bg-green-500"
>Status</a
>
<a
*ngIf="!node.syncStatus"
href="{{
node.restApiHttp
}}/cosmos/base/tendermint/v1beta1/syncing"
class="link link-primary bg-red-500"
>Status</a
>
</th>
<th>
<a
href="{{
node.restApiHttp
}}/cosmos/base/tendermint/v1beta1/blocks/{{
node.latestBlockHeight
}}"
class="link link-primary mx-2"
>{{ node.latestBlockHeight }}</a
>
<a
href="{{
node.restApiHttp
}}/cosmos/base/tendermint/v1beta1/blocks/latest"
class="link link-primary"
>Latest</a
>
</th>
<th>
<a
href="{{
node.restApiHttp
}}/cosmos/base/tendermint/v1beta1/node_info"
class="link link-primary"
>Info</a
>
</th>
</tr>
</ng-template>
<ng-template #noNodes>
<span>Any node doesn't exist!</span>
</ng-template>
</tbody>
</table>
</div>
実装でのポイントは以下の通りです。
- データが 1. Infrastructure Service -> 2. Service -> 3. Page Component -> 4. View Component のように流れていく構造で実装していること。
- 依存性の向きは 1. Infrastructure Service -> 2. Service <- 3. Page Component <- 4. View Component になり、1 の Infrastructure Service がデータの流れと異なり Service に(仮想的に)依存した実装になるよう interface を介して強制されて、逆方向を向いていること(≒依存性逆転の法則)。 -> これによって、インフラ側で大きな変更がおきてもその変更を 1. Infrastructure Service の層に留めておくことができ、フロントエンド全体を大幅に書き直す必要が出るような状況を避けやすくできる効果がある。
- 親コンポーネントに新しいデータが流れてきたら、そのデータを async pipe で子コンポーネントに流して、子コンポーネントでそれを表示してやることで、データ更新等のイベントを細かく扱うことなく、常に最新の情報が表示されるような状態を可能にしていること。
設定等
開発の際には、ESLint, Prettier, VSCode の設定の組み合わせで、ファイル保存時に自動的にLint & Format されるよう設定して作業を進めています。
また、Slack への通知や、GitHub でのブランチプロテクションルール等の設定によって、何かあったときに早めに気づけたり、意図せずに重要な main ブランチを吹き飛ばしたりしてしまわないような守りも意識しています。
- ESLint(, angular-eslint [14]), Prettier 等を設定し、保存時に自動的に Lint & Format されるよう設定 [15]
- Slack と GitHub を連携して GitHub の Issue やプルリク等の通知を Slack チャンネルに流すよう設定 [16]
- GitHub にてブランチプロテクションルールを設定
- main ブランチに直 push できないように設定
- プルリクは Approve 無しにマージできないように設定
- プルリクは CI が Pass しないとマージできないように設定
CI/CD
細かい単位で開発→デプロイ→検証のサイクルをどんどん回していきたいので、デプロイは自動化しておきたいと考えていて、普段の個人開発では以下のような設定で作業を進めています。
- プルリク作成時に、自動的に build や test の CI が実行され、それらが Pass しないと main ブランチにマージできないよう設定
- main ブランチへマージ時に、テストネット環境向けアプリが、自動的に Firebase へ GitHub Actions でデプロイされるよう CD を設定
- Release 時に、メインネット環境向けアプリが、自動的に Firebase へ GitHub Actions でデプロイされるよう CD を設定
このあたりの設定は要件によって、色々な選択肢があると思いますが、個人的には GitHub Flow [17] に基づいたシンプルな形が好みです。
例えば Cosmos Node Monitor では以下のような GitHub Actions を設定して CI/CD を運用しています。
(1) プルリク作成すると CI が自動的に実行される
name: CI Angular
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run build --if-present
- run: npm test -- --watch=false --browsers=ChromeHeadless
name: CI Firebase Functions
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
working-directory: ./functions
- run: npm run build
working-directory: ./functions
(2) テストネット環境は新しい変更がmainブランチにマージされたらその都度デプロイされる
name: CD Firebase Hosting UnUniFi Test
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run build:ununifi-test --if-present
- run: npm test -- --watch=false --browsers=ChromeHeadless
- run: npx firebase deploy --project=ununifi-test --token=${{ secrets.FIREBASE_TOKEN }} --only hosting
name: CD Firebase Functions UnUniFi Test
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
working-directory: ./functions
- run: npm run build
working-directory: ./functions
- run: npx firebase deploy --project=ununifi-test --token=${{ secrets.FIREBASE_TOKEN }} --only functions
working-directory: ./functions
(3) メインネット環境はリリースしたらデプロイされる ... メインネット未ローンチのブロックチェーンを扱っているため、フロントエンドの Firebase Hosting のみ設定済で、バックエンドの Firebase Functions は未設定
name: CD Firebase Hosting UnUniFi
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run build:ununifi --if-present
- run: npm test -- --watch=false --browsers=ChromeHeadless
- run: npx firebase deploy --project=ununifi --token=${{ secrets.FIREBASE_TOKEN }} --only hosting
要件が許す限りシンプルで自動化された流れにして、できるだけデプロイに手をかけずに済むようにするのが大事だと思っています。
CI については、自分自身の個人開発では、正直、TDD と胸をはって言える程のテストはなかなか書けていなかったりします。
その代わり、テストを書かなくても実装に確信が持てるくらいのシンプルなコンポーネント分割や処理の記述を心がけたり、Testing Library を用いてできるだけシンプルに表示されるべき情報が表示されているかといった最低限の確認をできるようなテストを書くような意識で開発を行っています。
このあたり、呼吸するかのごとく自然にテストを書いて、そのテストをパスさせられるような開発を少しずつでも目指していきたいところです。
CI/CD ともに、GitHub Actions による絶大な恩恵を受けていて、とても楽に自動テスト、自動デプロイが行えるのはすごくありがたいです。
所感(課題感)
ここまで、このアプリの開発や、普段の個人開発で心がけていること等を書いてきました。
ここからは、このアプリの開発や、普段の個人開発で感じている(主に課題感)について書いてみます。
料金には注意が必要
Firebase はサーバーレスかつかなり気前の良い無料枠があるため、小規模なアプリから、それなりの規模のアプリまで、かなり柔軟に低コストで運営可能だと思います。
とはいえ、Firebase Functions や Firestore の利用は、アプリがスケールするとリソース消費が大きくなりがちで、そのあたりの設計や事前検証は重要と感じました。
例えば今回自分が作ったアプリ(が対象としているブロックチェーン)は、5分に1回の定期実行で、最大で15ノード程度を監視する形になるので、さほど料金を心配する必要はありませんが、例えば1分に1回かつ最大100ノードといったレベルだったり、1分に1回かつ数百~千ノードといったレベルの監視を行おうとすると、(おそらく同様の実装で可能ではあるもの、)かなりの料金が必要になってしまうと思います。
Firestore の型については少し手を抜いている
自分が個人開発する際、規模感の小さなアプリだと、Firestore のスキーマをあえて縛っていなかったり、Firestoreからデータを取り出す際に、あえて型を手抜きな書き方にしているときがあります。
firestore.rules
のschemaやwithConverter
等でこのあたりをきっちりガードすると、より堅牢な構成にできると思うので、アプリを長期的に育てていきたい場合は、このあたりにもチャレンジしていきたいです。
Firebase の SDK バージョン v8(namespaced) から v9(modular) への変化は結構大きい
今回、新たにアプリを作ったので、少し意識して、v9(modular) のバージョンの書き方に挑戦してみました。
例えばドキュメントの読み取りの際、v8(namespaced)の場合、以下のようなイメージで、何も考えずとも再帰的に「.」つなげてメソッドチェーン的に、エディタの補完でさくさく書いていけていたのに対し、
db.collection("cities")
.doc("cityId")
.get()
.then((doc) => {
if (doc.exists) {
/* ここに対象データが存在する場合の処理を書く */
console.log(doc.data())
} else {
/* ここに対象データが存在しない場合の処理を書く */
}
}).catch((error) => {
/* ここにエラー時の処理を書く */
});
v9(modular)の場合、以下のようになって、記述量は減って、一見楽になったかな?と思いきや...
import { collection, doc, getDoc } from "firebase/firestore";
const docRef = doc(db, "cities", "cityId");
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
/* ここに対象データが存在する場合の処理を書く */
docSnap.data()
} else {
/* ここに対象データが存在しない場合の処理を書く */
}
実際には、各行の処理を、メソッドチェーン相当に、エディタで補完してサクサク書くことができなくなって、ある程度、何を書く必要があるか覚えておかないとダメになって、これが思いの外、自分にとってはしんどかったです。
v8(namespaced) の頃は、Firestore 関連処理は、必要になったらその都度都度、メソッドチェーンでつなげて書き捨てて全然ストレスなかったのですが、v9(modular) では、(ちょっと覚えて慣れれば、言うほどの事は無くなるかもしれませんが、)何らかの形で共通化やコード生成自動化して、それを使いまわしする仕組みを作りたいかなと感じました。
バックエンドとフロントエンドで似たような処理を書きがち
小さな個人開発アプリだと、気合で両方書く形でやれなくもないですが、規模感の大きな開発だと、両者を統合的に扱えるよう、枠組みを作ったほうが良い場合が多いでしょう。
Firestoreの読み取り、書き込み等の書き味が、書き捨てしにくい形に変わったこともあわせて考えると、扱うデータの型に応じて、firestore.rules
のスキーマや withConverter 等も含む型ガード的な部分も含め、コードを自動生成して、フロントエンド・バックエンド双方から利用可能にするような取組の必要性を改めて感じました。
まとめ
会社で規模感のあるアプリを開発している時は、自動化された枠組みに自然に乗って作業させてもらっていることも多く、個人で小粒なアプリをゼロから作ってみると、そういった自動化された枠組みの重要性・必要性・強みを改めて感じることができるとともに、それらのベースとなっている部分への理解が深まって面白いです。
また、ブロックチェーン関連の開発プロジェクトは、多くの場合、公開されている情報で、付随的なアプリを各自で色々作っていける楽しさがあります。
これからも、会社での開発だけでなく、個人開発でも、色々と面白いものを作っていけたらと思っています。
CauchyE は一緒に働いてくれる人を待っています!
ブロックチェーンやデータサイエンスに興味のあるエンジニアを積極的に採用中です!
以下のページから応募お待ちしております。
https://cauchye.com/company/recruit
-
angular-fontawesome GitHubレポジトリ ↩︎
-
@cosmos-client/core GitHubレポジトリ, npm パッケージ ↩︎
-
Testing Library公式サイト Angular Testing Library ↩︎
-
[angular-eslint] ↩︎
Discussion