はじめに
本記事では2019年から2020年にかけての年末年始を使って開発した、"Hello Worldを出力するアプリ"の話をしたいと思います。
Hello Worldを出力する、と言っても単にフロントエンドでReactの<p>Hello, World!</p>
と書いて出力したという話ではなく、
- フロントエンドでApollo Clientを使ってGraphQLでQuery/Mutationのリクエストを行い、
- BFF(Backend For Frontend)のApollo ServerでGraphQLのリクエストに応じたgRPCのリクエストをバックエンドへ内部的に行い、
- バックエンドで受け取ったgRPCリクエストに応じた処理(追加/削除)をWebFluxでリアクティブに実行し、逐次レスポンスを返却、
- BFFでgRPCのリクエストをGraphQLのレスポンスとして整形し、
- フロントエンドでGraphQLのレスポンスを用いて画面の表示、更新を行う
という内部的な処理を実行した上でHello Worldを出力するものを作りました。
早い話がオーバーエンジニアリングしたHello Worldアプリケーションですね。
アーキテクチャ図は大体下記のような感じになっています(外部から引用しているので図に記載されているKubernetes等はまだ利用できていません)。
モチベーションとしては2020年の年明けから上記技術を業務で使いそうな雰囲気になっているので、その予習としてこれらの技術を使ってHello Worldを出力するアプリを作ってみた、という感じになっています。
なお年末年始の時間の都合上クラウド上へデプロイすることは出来ませんでしたが、ローカルでDocker Composeを使ってコンテナとして各アプリを立ち上げ、ブラウザ上から操作できる、というところまで開発しています。
実際のアプリはこちらのリポジトリ から見ていただくことが可能です。
本記事ではこちらのアプリケーションの各レイヤについての詳細な技術スタックについて、バックエンド、BFF、フロントエンドの順に解説していきます。
GraphQLとgRPCを利用したアーキテクチャの導入の参考にしていただければと思います。
Backend(gRPCサーバー)
本アプリにおけるバックエンドの役割は、gRPCクライアントからのgRPCリクエストを受け取り、対応したCRUD処理を実行しレスポンスを返却することです。
なお今回のアプリケーションにおけるgRPCクライアントは、後述するBFFがこれに当たります。
gRPCサーバーの言語はKotlin、フレームワークはSpring Bootにしたかったので、リアクティブgRPCサービスを実現するために下記のdependenciesおよびpluginsを利用しました。
なおデータストアとしてはMySQLを利用しています。
バックエンドでは下記が主な技術スタックとなっています。
- WebFlux
- gRPC + Reactive gRPC
- Armeria
- Jib
WebFlux
WebFluxはSpringアプリケーションをリアクティブにするフレームワークです。
WebFluxを利用することでノンブロッキングな処理が可能となります。
一般的なブロッキング処理の場合は単一のリクエストの処理が完了するまでスレッドを占有するため、効率的にリクエストを捌くことが難しくなりますが、ノンブロッキングであればスレッドを複数のリクエストに対して利用でき、効率的にリクエストを処理することが可能になります
WebFluxを利用するためにはdependenciesにスターターを追加するだけでOKです。
dependencies {
(...)
implementation("org.springframework.boot:spring-boot-starter-webflux")
}
WebFluxを利用するには、リクエストとレスポンスをMono<T>
あるいはFlux<T>
という型でラッピングする必要があります。
MonoはString
などの単一の型を、FluxはList<String>
などの連続する値のレスポンスに用います(なお正確にはMonoとFluxはReactorというリアクティブライブラリが提供しています)。
参考として、典型的なREST Controllerをリアクティブにすると、下記のようなコードになります。
なお本アプリでは実際には次に述べるgRPCのStubを利用してリクエストを捌くことになります。
@RestController
@RequestMapping("/greetings")
class GreetingController(
private val service: GreetingService
) {
@GetMapping("/{id}")
fun getGreeting(@PathVariable("id") id: Int): Mono<Greeting> =
Mono.just(service.getGreeting(id).get())
@GetMapping
fun getHelloWorldList(): Flux<Greeting> = Flux.fromIterable(service.getGreetingList())
@PostMapping
fun saveHelloWorld(@RequestParam("message") message: String): Mono<Greeting> =
Mono.just(service.saveGreeting(message))
}
Further Reading
gRPC + Reactive gRPC
WebFluxでアプリケーションをリアクティブにできた後は、次にgRPCを利用できるようにする必要があります。
dependenciesに下記を追加することでgRPCに対応できます。
dependencies {
(...)
implementation("io.grpc:grpc-netty-shaded:1.26.0")
implementation("io.grpc:grpc-protobuf:1.26.0")
implementation("io.grpc:grpc-stub:1.26.0")
}
次にgRPCサービスを構築するため、protoファイルを作成し、サービスとメッセージを定義します。
実際には下記のように定義します。
こちらは一般的な定義ですね。
syntax = "proto3";
package example.grpc.helloworld;
option java_package = "com.example.helloworld.grpc.greeting";
option java_multiple_files = true;
service GreetingService {
rpc SaveGreeting (SaveGreetingRequest) returns (SaveGreetingResponse) {
}
rpc GetGreeting (GetGreetingRequest) returns (GetGreetingResponse) {
}
rpc GetGreetings (GetGreetingsRequest) returns (GetGreetingsResponse) {
}
}
message Greeting {
int32 id = 1;
string message = 2;
}
message SaveGreetingRequest {
string message = 1;
}
message SaveGreetingResponse {
Greeting greeting = 1;
}
message GetGreetingRequest {
int32 id = 1;
}
message GetGreetingResponse {
Greeting greeting = 1;
}
message GetGreetingsRequest {
}
message GetGreetingsResponse {
repeated Greeting greetings = 1;
}
また、gRPCサービスをリアクティブに動作させるためには、Reacive gRPCというDependencyも必要です。
gradleで下記の記述を行うことで、gradleのtaskとしてjavaクラスを生成することが可能になり、かつ生成されるjavaクラスがリアクティブなものになります。
protobuf {
protoc { artifact = "com.google.protobuf:protoc:3.11.0" }
plugins {
id("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:1.26.0"
}
id("reactorGrpc") {
artifact = "com.salesforce.servicelibs:reactor-grpc:1.0.0"
}
}
generateProtoTasks {
ofSourceSet("main").forEach {
it.plugins {
id("grpc")
id("reactorGrpc")
}
}
}
}
下記を記述の上、gradleでgenerateProto
タスクを実行すると、javaクラスが生成されます。
次にgRPCのサービスのロジックを生成する必要があります。
一例として下記のようなコードでサービスのロジックを記述することができます。
class GreetingGrpcService(
private val repository: GreetingRepository
) : ReactorGreetingServiceGrpc.GreetingServiceImplBase() {
override fun getGreeting(request: Mono<GetGreetingRequest>): Mono<GetGreetingResponse> =
request.map {
val greeting = repository.findById(it.id)
if (greeting.isPresent) {
GetGreetingResponse.newBuilder()
.setGreeting(greeting.let {
com.example.helloworld.grpc.greeting.Greeting
.newBuilder()
.setMessage(it.get().message)
.setId(it.get().id)
.build()
})
.build()
} else {
GetGreetingResponse.getDefaultInstance()
}
}
override fun getGreetings(request: Mono<GetGreetingsRequest>): Mono<GetGreetingsResponse> =
request.map {
val greetings = repository.findAll().map {
com.example.helloworld.grpc.greeting.Greeting
.newBuilder()
.setMessage(it.message)
.setId(it.id)
.build()
}
GetGreetingsResponse
.newBuilder()
.addAllGreetings(greetings)
.build()
}
override fun saveGreeting(request: Mono<SaveGreetingRequest>): Mono<SaveGreetingResponse> =
request.map {
val greeting = repository.save(Greeting(it.message)).let {
com.example.helloworld.grpc.greeting.Greeting
.newBuilder()
.setMessage(it.message)
.setId(it.id)
.build()
}
SaveGreetingResponse
.newBuilder()
.setGreeting(greeting)
.build()
}
}
Reactive gRPCを利用した時に特徴的なのが、実装するgRPCのサービス(Stub)がReactiveなリクエストとレスポンスを利用する点です。
今回はMonoのみを使っていますが、gRPCのStreamを利用するとFluxを使うことになるはずです。
比較としてReactiveではない場合のコードも記載しておきます。
class GreetingGrpcService(
private val repository: GreetingRepository
) : GreetingServiceGrpc.GreetingServiceImplBase() {
override fun getGreeting(request: GetGreetingRequest?, responseObserver: StreamObserver<GetGreetingResponse>?) {
val id = request?.id ?: -1
val greeting = repository
.findById(id)
val response = if (greeting.isPresent) {
GetGreetingResponse.newBuilder()
.setGreeting(greeting.let {
com.example.helloworld.grpc.greeting.Greeting
.newBuilder()
.setMessage(it.get().message)
.setId(it.get().id)
.build()
})
.build()
} else {
GetGreetingResponse.getDefaultInstance()
}
responseObserver?.onNext(response)
responseObserver?.onCompleted()
}
override fun getGreetings(request: GetGreetingsRequest?, responseObserver: StreamObserver<GetGreetingsResponse>?) {
val greetings = repository.findAll().map {
com.example.helloworld.grpc.greeting.Greeting
.newBuilder()
.setMessage(it.message)
.setId(it.id)
.build()
}
val response = GetGreetingsResponse
.newBuilder()
.addAllGreetings(greetings)
.build()
responseObserver?.onNext(response)
responseObserver?.onCompleted()
}
override fun saveGreeting(request: SaveGreetingRequest?, responseObserver: StreamObserver<SaveGreetingResponse>?) {
val message = request?.message ?: "Hello, World!"
val greeting = repository.save(Greeting(message)).let {
com.example.helloworld.grpc.greeting.Greeting
.newBuilder()
.setMessage(it.message)
.setId(it.id)
.build()
}
val response = SaveGreetingResponse
.newBuilder()
.setGreeting(greeting)
.build()
responseObserver?.onNext(response)
responseObserver?.onCompleted()
}
}
Further Reading
Armeria
gRPCのサービスが実装できたら、次にそれを利用するようサービスをSpring Bootに登録する必要があります。
Spring Bootではgrpc-spring-boot-starter
というdependencyによってgRPCサービスを利用することもできますが、今回は後述のArmeria
を使ってgRPCサービスを利用できるようにしました。
ArmeriaはLINEが開発しているマイクロサービスフレームワークで、去年参加したLINE DEV DAY 2019で紹介されていたため、試しに利用してみることにしました。
下記Dependencyの追加を行うことでArmeriaが利用可能になります。
dependencies {
listOf(
"armeria",
"armeria-grpc",
"armeria-logback",
"armeria-spring-boot-webflux-starter"
).forEach {
implementation("com.linecorp.armeria:$it:0.97.0")
}
Armeriaの機能としては様々ありますが、今回は最低限として、
- gRPCのインテグレーション
- GUIでのgRPCのドキュメンテーションおよびデバッグ
を利用してみました。
下記のような設定ファイルを記述することで、ArmeriaでのgRPCのサービス登録やドキュメンテーションエンドポイントの利用が可能になります。
@Configuration
class ArmeriaConfiguration(
private val repository: GreetingRepository
) {
@Bean
fun armeriaServerConfigurator(): ArmeriaServerConfigurator {
return ArmeriaServerConfigurator {
it.serviceUnder("/docs", DocServiceBuilder()
.exampleRequestForMethod(
GreetingServiceGrpc.SERVICE_NAME,
"GetGreeting",
GetGreetingRequest.newBuilder().setId(1).build()
)
.exampleRequestForMethod(
GreetingServiceGrpc.SERVICE_NAME,
"GetGreetings",
GetGreetingsRequest.newBuilder().build()
)
.exampleRequestForMethod(
GreetingServiceGrpc.SERVICE_NAME,
"SaveGreeting",
SaveGreetingRequest.newBuilder().setMessage("Hello, World!").build()
)
.build()
)
it.decorator(LoggingService.newDecorator())
it.accessLogWriter(AccessLogWriter.combined(), false)
it.service(GrpcService.builder()
.addService(GreetingGrpcService(repository))
.supportedSerializationFormats(GrpcSerializationFormats.values())
.enableUnframedRequests(true)
.build())
}
}
}
ArmeriaではgRPCのサービス登録とは別個に、ドキュメンテーション用のエンドポイントを設置することができます。
それを利用するとgRPCのサービス一覧、リクエストとレスポンスの構造体定義の参照、そしてGUIでリクエストの送信、デバッグ)が可能になります。
感覚としてはSwagger-UIをgRPCで利用しているような感覚で、直感的に利用することができました。
Further Reading
Jib
JibはGoogleが開発した、JVMアプリをコンテナ化するライブラリで、Docker imageを構築する際のベストプラクティスをカバーしてくれています。
下記のような設定をbuild.gradleに記述すると、設定した内容を利用してdocker imageをビルドすることができます。
簡単ですね!
plugins {
(...)
id("com.google.cloud.tools.jib") version "1.8.0"
}
jib {
to {
image = "wildmouse/greeting_api"
}
container {
// TODO: make active profile variable
args = listOf("--spring.profiles.active=local")
}
}
Further Reading
- Jib GitHub
- 7 best practices for building containers
- Java コンテナ化ツール「Jib」はどのくらい Docker のベストプラクティスを満たしているのか
BFF
BFFはBackends For Frontendsの略称で、その名の通りフロントエンドのためのバックエンド を提供しています。
マイクロサービスアーキテクチャにおいては別名APIゲートウェイとも呼ばれ、一つのデザインパターンとして提唱されています。
BFFを利用することでフロントエンド側から受けたリクエストをバックエンドの手前で捌き、疎結合なアーキテクチャを実現することができます。
BFFのメリットとしては、
- フロントエンド側では単一のAPIエンドポイント(BFF)のみを意識すれば良くなる、エンドポイントの管理が楽になる
- フロントエンド側にマイクロサービス を隠蔽させ、それぞれの個別のマイクロサービスを意識させないようにできる
- 各マイクロサービスのエンドポイントをフロントエンドで管理させずに済む
- フロント側で有効なプロトコルから内部で別のプロトコルに変換して処理を行うことができる
等があります。
そして今回利用するApollo ServerはまさにBFFにうってつけの存在で、フロントエンドから受けたGraphQLのリクエストをgRPCへと変換し、先述のバックエンドへgRPCリクエストを出し、gRPCでレスポンスを受け取り、そしてフロントエンドへGraphQLのレスポンスとして返却することができます。
Apollo Serverを利用することでフロントエンド側からはAPIの実装を意識することなく、GraphQL Schemaの定義だけを参照してデータ取得を行えば良いことになり、レイヤの分離に役立ちます。
またgRPCを利用したマイクロサービスアーキテクチャ構成にする場合、追加したマイクロサービス についてのresolverを追加すればすぐにBFF、ひいてはフロントエンドからgRPCを利用することができるようになります。
実装としてはGraphQLを利用するために、GraphQL Schemaを定義します。
フロントエンド側ではこのSchemaに定義してある内容を利用することで、何がリクエストできるのか、そして何がレスポンスとして返却されるのかが一目瞭然になります。
const typeDefs = gql`
type Greeting {
id: Int!
message: String!
}
type Query {
greeting(id: Int!): Greeting
greetings: [Greeting!]!
}
type Mutation {
greeting(message: String!): Greeting!
}
`
次に対応するGraphQLのリクエストが来た際に、対応する処理を定義するresolverを記述します。
今回はGraphQLで内部的にgRPCのリクエストを行うという記述をここに記載します。
const resolvers = {
Query: {
greeting: async (_source, {id}) => {
const result = await GetGreeting({id}, (err, result) => {
return result
})
if (!result.hasOwnProperty("greeting")) {
return undefined
}
return result.greeting
},
greetings: async (_source, {}) => {
const result = await GetGreetings({}, (err, result) => {
return result
})
if (!result.hasOwnProperty("greetings")) {
return []
}
return result.greetings
}
},
Mutation: {
greeting: async (_source, {message}) => {
const {greeting} = await SaveGreeting({message}, (err, result) => {
return result
})
return greeting
}
}
}
次に実際にリクエストするgRPCクライアントを記述します。
@grpc/proto-loader
を使ってprotoファイルからサービスとメッセージの定義を読み込んで、対応するStubを記述します。
const protoLoader = require('@grpc/proto-loader')
const grpc = require('grpc')
const greetingProtoPath = '../api/src/main/proto/greeting.proto'
const greetingProtoDefinition = protoLoader.loadSync(greetingProtoPath)
const greetingPackageDefinition = grpc.loadPackageDefinition(greetingProtoDefinition).example.grpc.helloworld
const clientUri = process.env.CLIENT_URI || "localhost:8080"
const client = new greetingPackageDefinition.GreetingService(
clientUri, grpc.credentials.createInsecure()
)
const GetGreeting = (params, context) => {
return new Promise((resolve, reject) => {
client.GetGreeting({id: params.id}, {}, (err, result) => {
return resolve(result)
})
})
}
const GetGreetings = (params, context) => {
return new Promise((resolve, reject) => {
client.GetGreetings({}, {}, (err, result) => {
return resolve(result)
})
})
}
const SaveGreeting = ({message}, context) => {
return new Promise((resolve, reject) => {
client.SaveGreeting({message}, {}, (err, result) => {
return resolve(result)
})
})
}
最後にApollo Serverを定義・起動すればBFFは出来上がりです。
const server = new ApolloServer({
typeDefs,
resolvers
})
server.listen().then(({url}) => {
console.log(`Server ready at ${url}`)
})
起動してlocalhost:4000
を開くと、GraphiQLと呼ばれるGUIが開き、画面でGraphQLのリクエストと操作を行うことができます。
なお当然のことながら、Apollo ServerはgRPCの代わりにREST APIをresolverの内部で利用することが可能です。
REST APIを利用する場合のコードは下記のようになります。
class GreetingAPI extends RESTDataSource {
constructor() {
super()
this.baseURL = 'http://localhost:8080/'
}
async getGreeting(id) {
return this.get(`greetings/${id}`)
.then(greeting => greeting)
.catch(error => console.error(error))
}
async getHelloWorlds() {
return await this.get('greetings')
.then(greetings => greetings)
.catch(error => console.error(error))
}
async saveHelloWorld(message) {
return await this.post(`greetings?message=${message}`)
.then(greeting => greeting)
.catch(error => console.error(error))
}
}
const resolvers = {
Query: {
greeting: async (_source, {id}, {dataSources}) => {
const greeting = await dataSources.greetingAPI.getGreeting(id)
return greeting
},
greetings: async (_source, {}, {dataSources}) => {
const greetings = await dataSources.greetingAPI.getGreetings()
return greetings
}
},
Mutation: {
greeting: async (_source, {message}, {dataSources}) => {
await dataSources.greetingAPI.saveGreeting(message)
}
}
}
Further Reading
Frontend
フロントエンドではブラウザでユーザーの操作を受け付け、画面側での操作に応じてGraphQLのリクエストを行います。
リクエストはApollo Clientを利用して行い、フロントエンドからBFFのApollo Serverへリクエストが送られます。
Clientの利用をするにはApolloClientのインスタンスを作成すればOKです。
const clientUri = process.env.CLIENT_URI || "http://localhost:4000"
const link = createHttpLink({
uri: clientUri,
fetch: fetch
})
const client = new ApolloClient({
link: link,
cache: new InMemoryCache(),
})
フロントエンドのレンダリングはSSR用のフレームワークであるNext.jsを利用し、サーバサイドでレンダリングを行った上でブラウザへレンダリング結果を返却します。
Next.jsのgetInitialProps
上でApollo Clientを利用しGraphQLのqueryリクエストをBFFに送信、取得した結果をcomponentのpropsとして返却します。
Home.getInitialProps = async () => {
const greetings = await client.query({
query: gql`
query {
greetings {
id
message
}
}
`
})
.then((result: ApolloQueryResult<{ greetings: Greeting[] }>) => result.data.greetings)
.catch(error => console.error(error))
return {
greetings: greetings
}
}
レンダリング後の画面ではユーザーの画面操作でメッセージを入力・保存する操作が行われた時にはmutationのリクエストを行い、BFFへリクエストします。
const Home = ({greetings}: Props) => {
const router = useRouter()
const [message, setMessage] = useState('')
const onClickGreet = useCallback(async () => {
if (message == '') {
return
}
const isSaved = await client.mutate({
mutation: gql`
mutation {
greeting(message: "${message}") {
id
message
}
}
`
})
.then(result => {
return true
})
.catch(error => {
return false
})
if (isSaved) {
await router.push('/')
}
}, [message])
if (greetings.length == 0) {
return (
<>
<input value={message} onChange={(e) => setMessage(e.target.value)}/>
<button onClick={onClickGreet}>Greet</button>
<p>There is no greeting. Please say hi!</p>
</>
)
}
return (
<>
<input value={message} onChange={(e) => setMessage(e.target.value)}/>
<button onClick={onClickGreet}>Greet</button>
<ul>
{
greetings.map((greeting) =>
<li key={greeting.id}>
<a href={`/hello-world/${greeting.id}`}>See greeting {greeting.id}</a>
</li>
)
}
</ul>
</>
)
}
上記Apollo Clientからのリクエストができ、想定したレスポンスが返ってきたとしたら、記事冒頭で述べた一連の処理ができるようになったということになります。
- フロントエンドでApollo Clientを使ってGraphQLでQuery/Mutationのリクエストを行い、
- BFF(Backend For Frontend)のApollo ServerでGraphQLのリクエストに応じたgRPCのリクエストをバックエンドへ行い、
- バックエンドで受け取ったgRPCリクエストに応じた処理(追加/削除)をWebFluxでリアクティブに実行し、逐次レスポンスを返却、
- BFFでgRPCのリクエストをGraphQLのレスポンスとして整形し、
- フロントエンドでGraphQLのレスポンスを用いて画面の表示、更新を行う
Awesome!
Further Reading
まとめ
以上、作成したアプリケーションで構築したアプリケーションのアーキテクチャと、利用した技術についてそれぞれ解説させていただきました。
単純なHello Worldを出力するアプリではありましたが、モダンな技術ばかり使って開発ができ、かなり楽しかったです。
今回ApolloとgRPC, Armeria, WebFluxについては私の方ではほぼほぼ初めて触りましたが、年末年始の1週間程度で構築できましたし、最初の導入コスト自体はそこまで高くなかった印象です。
当たり前のことかもしれないが、導入自体は公式ドキュメントを読んだりGitHub上のコードを参考にすることで動くものは作れますね。
ただ実務で導入するとなるとユニットテストの導入や各レイヤ間の整合性の担保、インフラストラクチャの考慮など、様々な点を考慮する必要が出てきます。
今後は本記事で構築したアーキテクチャのより発展的な部分について深掘りしていきたいと思いますが、ひとまずは上記の内容で一区切りにしたいと思います。
それでは、ありがとうございました。