はじめに
設計がしっかりしていないまま開発をしてしまうとビジネスロジックとライブラリが密結合となりがちです。
密結合度が高いほど修正が困難となり、継続的な開発の難易度が上がっていきます。(技術的負債)
またプロジェクトが大きくなってくると扱うデータ量も多くなり処理速度もデータ量に比例するため、計算量オーダーの影響を受けます。
プロジェクトのそれぞれの機能に対して
- 再利用可能
- テストしやすい
- 機能追加しやすい
- ビジネスロジックとライブラリ、REST API(+マスターデータ)を分離できる
となっていれば継続的な開発がしやすいです。
最近ではクライアントサイドではクリーンアーキテクチャ、Atomic Design、バックエンドではマイクロサービス化という設計方法があります。
この設計が良いと感じているのはビジネスロジックと機能の責務を分離し、
ライブラリとREST API(+マスターデータ)を再利用可能にすることだと思っています。
今回は次のような構成でシステム化してみます。
フロントエンド(クリーンアーキテクチャ)
- React+Redux(FRP)
- Atomic Design
バックエンド(マイクロサービス) - NodeJS+gRPC
この構成のサンプルリポジトリをGitHubに作成しました。
クリーンアーキテクチャについて
クリーンアーキテクチャの詳細は次の記事が詳しいです。
参考:持続可能な開発を目指す ~ ドメイン・ユースケース駆動(クリーンアーキテクチャ) + 単方向に制限した処理 + FRP
・データ・処理の流れを単方向にする
・ユースケース単位で処理を単方向にする
・各インターフェース方式を単方向にする
・FRP(Functional Reactive Programming)、「時間と共に変化する値」と「振る舞い」同士の関係性を宣言するプログラミングスタイルを導入する
データフロー&処理フローが単一方向でデータの変化があったときにObserverパターンに則ってUI表示を変更させるという意味です。(単一方向でないとデータの整合性が保てなくなる)
React
React+Redux設計が優れているのは上記のクリーンアーキテクチャ設計を満たしているからです。
ReactはFRPのフレームワークで状態値(state)を変更したときにrenderメソッドが呼ばれ、子コンポーネントのUIにもデータが伝播(props)し、表示が更新されます。
参考:React入門
Reactでは次の枠線単位のコンポーネント(UI部品)に分離できます。
公式サンプルのSearchに入力するとすべてのコンポーネントの表示が連動します。
Redux
Reduxは(クライアントサイドの)アプリケーション全体のデータを保存する唯一のStore、単一方向にデータを更新するためのActions、状態ツリーを更新するreducerから構成されます。
3つの原則
・アプリケーション全体の状態。それは1つのStoreの中にある、1つのオブジェクトツリーで保持されます。
・状態を変える唯一の方法は、Actionを送ることです。Actionは、何が起きたかを記述するオブジェクトです。
・Actionによってどのように状態ツリーが転換されるか明示するために、純粋なReducerを書きます。
次の図がわかりやすいです。
- viewからactionsにイベントを発行してAPIコールを行う
- APIコールは非同期のため、reducerはデフォルト(初期値)のstateを返却する
- APIコールが完了時、reducerは取得したデータをstateに加工して返却する
非同期処理用のMiddlewaresに該当するものにredux-thunkやredux-sagaなどがあります。
ちなみにReduxは同様の設計思想であるFluxやMobXなどでも代替可能です。
マイクロサービスについて
マイクロサービスとはシステムを複数のサービスの集合体として構成し、サービス相互をRESTful Web APIのようなシンプルで軽量な手段で連携する手法です。その最大のメリットは、小規模なサービス群を疎結合する作りにすることにより、「一枚岩」(モノリシック)のシステムの複雑さから自由になることです。つまり、マイクロサービスの考え方を導入することで、変化に強いシステムを作ることができます。またAPIなため、Input Outputの設計さえ統一しておけば、各々好きなプログラミング言語でバックエンドのロジックを組み立てることができます。
ただし、システムの複雑度が小さい段階でマイクロサービスを導入すると、モノリシックな作り方に比べて、かえって生産性が落ちてしまうデメリットがあります。また、サーバ間のAPIコールに速度的なデメリットがあります。
参考:「マイクロサービス」のメリットをざっくり言うと「変化に対応しやすい」こと
マイクロサービスの具体的な設計例は次の記事がとてもわかり易いです。
参考:マイクロサービスアーキテクチャにおけるAPIコールの仕方とHTMLレンダリング
ポイントは次の3点です。
- データ種別ごとにマイクロサービス化する(サーバを分離し、API化する)
- 複数のマイクロサービスをまたぐデータを返却するAPIはBFFをサービス別に作成する(サービス固有のビジネスロジックやログイン管理はBFFに集約する)
- API単位でサーバが分離しているため、負荷が高いマイクロサービスのみ冗長化可能
さらにSSO(シングル・サインオン)サーバなどを導入することでサービスをまたいでのログイン管理を行う設計も可能です。
参考:マイクロサービス時代のSSOを実現する「Keycloak」とは
綺麗なRESTful Web APIの設計に関しては次の記事がとても参考になります
参考:綺麗なAPI速習会
- api命名規則の統一(リソース名、アクション名を統一する)
- apiのバージョン管理を行う
- apiのpagingパラメータ
gRPC
一般的なモノリシックなサービスが直接APIを返すのに対し、マイクロサービスはBFFとマイクロサービスサーバ間のAPIコールが速度的なボトルネックになります。
そこでgRPCを導入することでその課題が緩和できるようになります。
参考:REST APIの設計で消耗している感じたときのgRPC入門
実際の導入事例:社内のバックエンド開発にgRPCを導入してみた
gRPCはGoogle社内でも使用されているOSSのRPCフレームワークで次の特徴があります。
・多言語対応されている(C++, Java, Go, Python, Ruby, Node.js, Android Java, C#, Objective-C, PHP等)
・通信速度が早い
実装言語にもよるかもしれませんが、次の例だと通常のJSON+HTTPで設計するよりもgRPC+Protobufでは10倍のベンチマーク速度がでています。
参考:Benchmarks comparing gRPC+Protobuf vs JSON+HTTP in Go
また、最近だとNginXレベルで対応されたようです。
参考:nginx に実装された gRPC サポートを試してみる
サンプルの説明
今回React+Redux+SSR版をベースにgRPC機能を追加で作成しています。
React+ReduxおよびSSRに関しては下記記事とgithubサンプルを参考にしてください。
・ReactJSで作る今時のSPA入門(基本編)
・webpack4でReact16のSSR(サーバサイドレンダリング)をする
ReactJSサンプル一式(Step by Step、説明は各git branchのReadmeを参考)
・ReactJS勉強会
2018/11/05追記、gRPC-webなるものが出て、ブラウザから直接gRPCサーバにアクセスすることも可能になりました。簡易な場合はBFFを仲介せずに直接gRPCサーバにアクセスする構成も可能になりました。
クリーンアーキテクチャ+Atomic Designについて
ページ単位でデータ層とやりとりを行うUsecaseを作成します。
データ層(redux)とのやりとりにはreact-reduxのconnectデコレータを使います。
こうすることでデータ周りの管理をすべてUsecaseにまとめることができます。(UIとデータの分離)
- ページコンポーネントをWrapするHOC(High Order Component)でUsecaseを作成
- データバインド、イベントハンドラの記述をすべてUsecaseにまとめる(UI状態以外のイベントハンドラの実装はUIコンポーネントに書かない)
HOCのUsecaseにまとめているため、React Nativeの場合でもロジック部分が再利用可能になります。
import React from 'react'
import { connect } from 'react-redux'
import { load } from 'reducer/user'
// 引数
export default () => {
// WrapするReact Component引数
return (WrappedComponent) => {
// 処理をフックする
return connect(
// propsに受け取るreducerのstate
state => ({
users: state.user.users,
}),
// propsに付与するactions
{ load }
)(class extends React.Component {
constructor (props) {
super(props)
this.state = {
open: false,
user: null,
}
}
componentDidMount() {
// user取得APIコールのactionをキックする
this.props.load()
}
handleClickOpen = (user) => {
this.setState({
open: true,
user: user,
})
}
handleRequestClose = () => {
this.setState({ open: false })
}
handlePageMove = (path) => {
this.props.history.push(path)
}
render () {
const { users } = this.props
const { open, user } = this.state
// propsにinject属性追加
return <WrappedComponent
{...this.props}
users={users}
open={open}
user={user}
handleClickOpen={this.handleClickOpen}
handleRequestClose={this.handleRequestClose}
handlePageMove={this.handlePageMove}
/>
}
})
}
}
Atomic Designとは
デザインを原子 < 分子 < 有機体 < テンプレート < ページの粒度に分類して管理することで
デザインパーツの管理コストを削減、デザインの統一性を図るという手法です。
原子: デザインの最小要素、カラー、フォント、見出し、ボタン、入力欄等
分子: 原子を組み合わせてグルーピングしたパターン(見出しと本文のセットなど)
有機体: 分子を組み合わせて作り出されたインタフェース(見出し+メニュー欄→ナビゲーションバー)
テンプレート: 有機体を組み合わせてできた、ページのワイヤーフレーム
ページ: ワイヤーフレーム外のページ独自デザイン要素も入ったページそのもの
上記の粒度でUIコンポーネントを作成しています。
まず、UserPageはUserPageTemplateのワイヤーフレームに
UserPageUsercase経由の具体的なパラメータを渡すことでページ生成を行っています。
ページ以下のUIコンポーネントはUI状態以外のパラメータを各コンポーネント内のstateで管理しないようにします。
有機体、分子、原子の単位で作られたUIコンポーネントは再利用可能なように作成します。
import React from 'react'
import UserPageUsercase from 'components/usecases/UserPageUsecase'
import UserPageTemplates from 'components/templates/UserPageTemplate'
export default UserPageUsercase()(({ users, open, user, handleClickOpen, handleRequestClose, handlePageMove }) =>
<UserPageTemplates
headerContent='ユーザページ(PC)'
headerContentMobile='ユーザページ(スマホ)'
headerButtonTitle='TODOページへ'
onClickPageMove={() => handlePageMove('/todo')}
users={users}
onClickEmail={handleClickOpen}
open={!!open}
onCloseDialog={handleRequestClose}
dialogTitle='メールアドレス'
email={user ? user.email : null}
/>
)
UserPageTemplateコンポーネントです。
templatesはページの構造にUIコンポーネントを配置済みの状態です。
ページが実態化されるのはUserPageから具体的なパラメータが渡ってきたときに生成できるようなワイヤーフレーム(雛形)の形にします。
import React from 'react'
import Header from 'components/organisms/Header'
import UserList from 'components/organisms/UserList'
import Diag from 'components/organisms/Diag'
const UserPageTemplate = ({
headerContent, headerContentMobile, headerButtonTitle,
users, onClickEmail,
open, onCloseDialog, dialogTitle, email, onClickPageMove,
}) => (
<div>
<Header
content={headerContent}
contentMobile={headerContentMobile}
buttonTitle={headerButtonTitle}
onClickPageMove={onClickPageMove}
/>
<UserList
users={users}
onClick={onClickEmail}/>
<Diag
open={!!open}
onClose={onCloseDialog}
title={dialogTitle}
content={email}
/>
</div>
)
export default UserPageTemplate
UserListコンポーネントです。
複数のUIコンポーネントからなっている1つの複合体なので
organismsに該当します。再利用可能です。
import React from 'react'
import styled from 'styled-components'
import { withTheme } from 'material-ui/styles'
import { Avatar, CardContent } from 'material-ui'
import EmailButton from 'components/modules/EmailButton'
import StyledCard from 'components/atoms/StyledCard'
const UserList = ({theme, users, onClick}) => {
const {primary, secondary} = theme.palette
if (!users || users.length === 0) return null
const Name = styled.p`
&& {
margin: 10px;
color: ${primary[500]}
}`
const Gender = styled.p`
&& {
margin: 10px;
color: ${secondary[500]}
}`
return users.map((user) => (
// ループで展開する要素には一意なkeyをつける(ReactJSの決まり事)
<StyledCard key={user.email}>
<CardContent>
<Avatar src={user.picture.thumbnail} />
<Name>{'名前:' + user.name.first + ' ' + user.name.last}</Name>
<Gender>{'性別:' + (user.gender == 'male' ? '男性' : '女性')}</Gender>
<div style={{textAlign: 'right'}} >
<EmailButton onClick={() => onClick(user)} />
</div>
</CardContent>
</StyledCard>
))
}
export default withTheme()(UserList)
EmailButtonコンポーネントです。
Eメールのアイコンとボタンの組み合わせのため、
分子(modules)に該当します。再利用可能です。
import React from 'react'
import { orange } from 'material-ui/colors'
import { Email } from 'material-ui-icons'
import StyledButton from 'components/atoms/StyledButton'
import styled from 'styled-components'
const StyledEmail = styled(Email)`
&& {
marginRight: 5;
color: ${orange[200]};
}
`
const EmailButton = ({onClick}) => (
<StyledButton onClick={onClick}><StyledEmail/>メールする</StyledButton>
)
export default EmailButton
StyledButtonコンポーネントです。
これ以上要素分解できないため、
原子(atoms)に該当します。再利用可能です。
material-uiのButtonコンポーネントをstyled-componentsでCSSスタイルを変更しています。
import { Button } from 'material-ui'
import styled from 'styled-components'
const StyledButton = styled(Button)`
&& {
background: linear-gradient(45deg, #fe6b8b 30%, #ff8e53 90%);
border-radius: 3px;
border: 0;
color: white;
height: 48px;
padding: 0 30px;
box-shadow: 0 3px 5px 2px rgba(255, 105, 135, .30);
}
`
export default StyledButton
gRPC(NodeJS版)について
今回NodeJSで実装しているため、gRPCのNodeJS版ライブラリを使用しています。
grpc-node
gRPCはRPC通信するためのインタフェースを.protoファイルに記述します。
今回はproto3のバージョンでインタフェースを記述しています。
Language Guide (proto3)
vscodeの方は次のプラグインを入れることでシンタックスハイライトが有効になります
vscode-proto3
syntax = "proto3";
// サービス定義
service user {
// rpc メソッド名(引数)returns (戻り値)で定義する
rpc index (UserEmpty) returns (UserListReply) {}
rpc show (UserIdOnly) returns (UserRequest) {}
rpc create (UserRequest) returns (UserRequest) {}
rpc update (UserRequest) returns (UserRequest) {}
rpc remove (UserIdOnly) returns (UserEmpty) {}
}
// メッセージ定義
message UserEmpty {}
message UserListReply {
// 配列で返す場合はrepeated指定
repeated UserRequest users = 1;
}
message UserRequest {
// データ型、変数名、データオフセットを指定
// 指定できるデータ型:https://developers.google.com/protocol-buffers/docs/proto3
string id = 1;
string gender = 2;
int32 age = 3;
UserName name = 4;
string email = 5;
UserPicture picture = 6;
bool isPublic = 7;
}
message UserName {
string title = 1;
string first = 2;
string last = 3;
}
message UserPicture {
string large = 1;
string medium = 2;
string thumbnail = 3;
}
message UserIdOnly {
string id = 1;
}
microserviceサーバです。
user.protoファイルを読み込み、
各種rpcインタフェースを実装して、UserデータのCRUD操作をできるようにしています。
各種rpcの実装はメソッド名(渡ってくる引数、結果返却コールバック関数)
の形で記載する必要があります。
結果返却コールバック関数の第一引数はエラー値、第二引数は返却結果です。(各rpcインタフェース定義の戻り値と一致する必要があります。)
実際のデータ保存、取得はlowdb.jsにて行っています。(後述)
const grpc = require('grpc')
const path = require('path')
const PROTO_PATH = path.join(__dirname, '../common/proto/user.proto')
const user = grpc.load(PROTO_PATH).user
const lowdb = require('./lowdb')
// 例外ハンドリング
process.on('uncaughtException', (err) => console.log('uncaughtException => ' + err))
process.on('unhandledRejection', (err) => console.log('unhandledRejection => ' + err))
// UserのCRUDを行うマイクロサービス
class User {
index (call, callback) {
const datas = lowdb.index()
console.log(datas)
return callback(null, datas)
}
show (call, callback) {
const data = lowdb.show(call.request.id)
if (data) {
console.log(data)
return callback(null, data)
}
return callback('Can not find user.')
}
create (call, callback) {
const newUser = Object.assign({}, call.request)
console.log(`create ${newUser}`)
lowdb.create(newUser)
return callback(null, newUser)
}
update (call, callback) {
if (!call.request.id) {
return callback('User id can not find.')
}
const user = Object.assign({}, call.request)
console.log(`uddate ${user}`)
lowdb.update(call.request.id, user)
return callback(null, call.request)
}
remove (call, callback) {
if (!call.request.id) {
return callback('User id can not find.')
}
console.log(`remove ${call.request.id}`)
lowdb.remove(call.request.id)
return callback(null)
}
}
const getServer = function (service, serviceCall, listener) {
const server = new grpc.Server()
server.addService(service, serviceCall)
server.bind(listener, grpc.ServerCredentials.createInsecure())
return server
}
// gRPCサーバ起動
const articleServer = getServer(user.service, new User, '0.0.0.0:50051')
articleServer.start()
console.log('gRPC listening on port 50051')
今回は簡易的にjsonファイルに保存するようにしています。
lowdbというライブラリを使用することで
jsonファイルをDBのように操作できます。
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
const adapter = new FileSync('db.json')
const db = low(adapter)
// デフォルトUserデータをセット (JSONファイルが空の場合)
db.defaults({ users: [
{
'id': '934441926',
'age': 30,
'gender': 'female',
'name': {
'title': 'ms',
'first': 'georgia',
'last': 'gregory',
},
'email': 'georgia.gregory@example.com',
'picture': {
'large': 'https://randomuser.me/api/portraits/women/39.jpg',
'medium': 'https://randomuser.me/api/portraits/med/women/39.jpg',
'thumbnail': 'https://randomuser.me/api/portraits/thumb/women/39.jpg',
},
'isPublic': true,
},
]}).write()
module.exports = {
index,
show,
create,
update,
remove,
}
function index() {
return db.get('users')
.value()
}
function show(id) {
return db.get('users')
.find({id})
.value()
}
function create(data) {
db.get('users')
.push(data)
.write()
}
function update(id, data) {
db.get('posts')
.find({id})
.assign(data)
.write()
}
function remove(id) {
db.get('users')
.remove({id})
.write()
}
BFF側からのgRPC呼び出しの実装です。
呼び出し側でもuser.protoが必要になることに注意です。
実際の呼び出しの際にはrpcインタフェースに沿ったパラメータを引数に渡す必要があります。
例えばshow関数の場合、message UserIdOnlyで定義したパラメータを引数としてRPCコールを行います。
{id: '934441926'}
const path = require('path')
const grpc = require('grpc')
const PROTO_PATH = path.join(__dirname, '../common/proto/user.proto')
const Client = grpc.load(PROTO_PATH).user
const getClient = function (address) {
return new Client(address, grpc.credentials.createInsecure())
}
const client = getClient('127.0.0.1:50051')
module.exports = {
index,
show,
create,
update,
remove,
}
async function index() {
return new Promise((resolve, reject) => {
// get list
client.index({}, function (err, res) {
if (err) {
return reject(err)
}
return resolve(res.users)
})
})
}
async function show(id) {
return new Promise((resolve, reject) => {
// get by id
client.show({id}, function(err, res) {
if (err) {
return reject(err)
}
return resolve(res)
})
})
}
async function create(data) {
return new Promise((resolve, reject) => {
// insert
client.create(data, function (err, res) {
if (err) {
return reject(err)
}
return resolve(res)
})
})
}
async function update(data) {
return new Promise((resolve, reject) => {
// update
client.update(data, function (err, res) {
if (err) {
return reject(err)
}
return resolve(res)
})
})
}
async function remove(id) {
return new Promise((resolve, reject) => {
client.remove({id}, function (err, res) {
if (err) {
return reject(err)
}
return resolve(res)
})
})
}