2023. 9. 15. 14:50ㆍBlockchain/Cosmos
Cosmos SDK로 구현 가능한 app chain은 여러 통신 방식이 있는데, 그 중 gRPC가 가장 중요하다. gRPC의 개념이나 동작 방식에 대한 좋은 글들은 굉장히 많으니 여기서는 잠깐 개념만 언급하고, Cosmos-SDK에서 gRPC는 어떻게 정의되어 있고 구현되어 있는지에 대한 글을 작성하려 한다.
gRPC
gRPC(google Remote Procedure Call)는 이름에서 확인할 수 있는 것처럼 구글이 개발한 통신 프레임워크이다. 통신 프로토콜로 TCP/IP, HTTP/2를 사용하고 IDL(Interface Definition Language)로 protobuf(protocol buffer)를 사용한다. 전송하는 데이터 크기가 작고 빠르게 처리할 수 있기 때문에 MSA의 component 간 통신 방식으로 많이 사용된다.
gRPC protobuf and client in Cosmos-SDK
그렇다면 Cosmos에서 사용되는 gRPC는 어떻게 구현 되었는지 확인해보자. 현재 기준 최근 release 버전은 v0.47.5로써, 해당 버전으로 확인해 볼 것이며 아래에서 설명할 내용들은 현재 main branch로 가고 있는 v0.50.X와는 구조 상 차이가 있다.
각 module에서 생성하는 gRPC protobuf 데이터 타입과 gRPC client에 대해 먼저 확인해 본다.
Protobuf
Cosmos SDK내에는 각 module에서 사용되는 protobuf 파일이 존재한다. 예를 bank module로 들어본다. Bank module을 구현하기 위한 proto파일은 크게 4개가 있다.(위치 - /proto/cosmos/bank/
) 각각의 파일들을 확인해보자.
bank.proto
- Cosmos에서 module 이름이 적힌 proto 파일은 대부분 module에서 사용하는 파라미터들을 정의한다.
bank.proto
에서는 denomination이나 supply metadata 등이 정의되어 있다.
- Cosmos에서 module 이름이 적힌 proto 파일은 대부분 module에서 사용하는 파라미터들을 정의한다.
genesis.proto
- Genesis에 포함되는 module의 항목들이 정의되어 있다. 아래는 Bank module의 genesis state가 정의된 proto message이다. Cosmos codec을 사용하여 marshal/unmarshal 되고, 우리가 일반적으로 볼 수 있는
genesis.json
에서 확인 가능한 항목들이다.
- Genesis에 포함되는 module의 항목들이 정의되어 있다. 아래는 Bank module의 genesis state가 정의된 proto message이다. Cosmos codec을 사용하여 marshal/unmarshal 되고, 우리가 일반적으로 볼 수 있는
// GenesisState defines the bank module's genesis state.
message GenesisState {
// params defines all the parameters of the module.
Params params = 1 [(gogoproto.nullable) = false, (amino.dont_omitempty) = true];
// balances is an array containing the balances of all the accounts.
repeated Balance balances = 2 [(gogoproto.nullable) = false, (amino.dont_omitempty) = true];
// supply represents the total supply. If it is left empty, then supply will be calculated based on the provided
// balances. Otherwise, it will be used to validate that the sum of the balances equals this amount.
repeated cosmos.base.v1beta1.Coin supply = 3 [
(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins",
(gogoproto.nullable) = false,
(amino.dont_omitempty) = true
];
// denom_metadata defines the metadata of the different coins.
repeated Metadata denom_metadata = 4 [(gogoproto.nullable) = false, (amino.dont_omitempty) = true];
// send_enabled defines the denoms where send is enabled or disabled.
//
// Since: cosmos-sdk 0.47
repeated SendEnabled send_enabled = 5 [(gogoproto.nullable) = false, (amino.dont_omitempty) = true];
}
query.proto
- Bank module의 query data 형식이 정의되어 있다. 메시지 하나만 예로 확인 해본다. 아래 코드 처럼 query service내에 각 API를 구현하는데, 각 API에는 request 데이터와 response 데이터 형식을 가지고 있다. Request 데이터
QueryBalanceRequest
를 input으로rpc Balance()
API의 메소드(cosmos.bank.v1beta1.Query/Balance
)를 호출하면QueryBalanceResponse
를 받을 수 있는 구조이다. 추가적으로 LCD에서 확인할 수 있는 HTTP URL도 정의할 수 있는데,Balance
API는 get 방식으로/cosmos/bank/v1beta1/balances/{address}/by_denom
으로 호출 가능하다.
- Bank module의 query data 형식이 정의되어 있다. 메시지 하나만 예로 확인 해본다. 아래 코드 처럼 query service내에 각 API를 구현하는데, 각 API에는 request 데이터와 response 데이터 형식을 가지고 있다. Request 데이터
// Query defines the gRPC querier service.
service Query {
// Balance queries the balance of a single coin for a single account.
rpc Balance(QueryBalanceRequest) returns (QueryBalanceResponse) {
option (cosmos.query.v1.module_query_safe) = true;
option (google.api.http).get = "/cosmos/bank/v1beta1/balances/{address}/by_denom";
}
...
// QueryBalanceRequest is the request type for the Query/Balance RPC method.
message QueryBalanceRequest {
option (gogoproto.equal) = false;
option (gogoproto.goproto_getters) = false;
// address is the address to query balances for.
string address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
// denom is the coin denom to query balances for.
string denom = 2;
}
// QueryBalanceResponse is the response type for the Query/Balance RPC method.
message QueryBalanceResponse {
// balance is the balance of the coin.
cosmos.base.v1beta1.Coin balance = 1;
}
tx.proto
- Bank module의 tx data 형식이 정의되어 있다. Query와 비슷하게 API가 정의되고, Request/Response message 타입이 정의되어서, Request message가 tx로 broadcast되면 체인에서는 tx를 처리하고 여기서 정의된 response message로 return해 준다. 보통 cosmos tx proto의 response는 빈값으로 준다.
// Msg defines the bank Msg service.
service Msg {
option (cosmos.msg.v1.service) = true;
// Send defines a method for sending coins from one account to another account.
rpc Send(MsgSend) returns (MsgSendResponse);
...
// MsgSend represents a message to send coins from one account to another.
message MsgSend {
option (cosmos.msg.v1.signer) = "from_address";
option (amino.name) = "cosmos-sdk/MsgSend";
option (gogoproto.equal) = false;
option (gogoproto.goproto_getters) = false;
string from_address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
string to_address = 2 [(cosmos_proto.scalar) = "cosmos.AddressString"];
repeated cosmos.base.v1beta1.Coin amount = 3 [
(gogoproto.nullable) = false,
(amino.dont_omitempty) = true,
(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"
];
}
// MsgSendResponse defines the Msg/Send response type.
message MsgSendResponse {}
Compile
Proto 파일을 생성했으면 golang에서 수행 가능 하도록 compile을 해 주어야 한다. 보통 protoc를 따로 설치해서 compile을 진행하지만 Cosmos는 proto 파일을 compile 해주는 script도 함께 제공하기 때문에 쉽게 pb.go
파일을 생성할 수 있다.
해당 script는 /scripts/protocgen.sh
에 있으며, Cosmos SDK가 제공하는 makefile을 이용하여 make proto-gen
명령 수행할 수 있다. 이를 통해 pb.go
를 새롭게 생성하거나, 기존 proto 파일이 업데이트 됐을 시 변경 사항을 반영할 수 있다 (Cosmos SDK는 protoc-gen-gogo
를 사용).
또한 docker image도 제공하는데, make proto-gen
명령어를 실행하면 docker를 바로 사용하기 때문에 docker를 미리 실행해 놔야 한다.
참고로 cosmos 기반 여러 체인을 동시에 고도화 했던 적이 있는데 make proto-gen 명령어가 안먹힌 경우가 있었다. 이 때 두 체인에서 proto 파일들이 다른것이 몇개 있었는데, 새로 업데이트를 시도한 체인에 반영이 되는게 아니라 이전에 실행한 체인에서 compile이 수행이 된다. 필자처럼 체인 모듈을 수정할 시에 잘 안 됐다면 이와 같은 현상일 수 있으니 proto gen을 위한 docker container를 삭제하고 새로 생성할 디렉토리에서 다시 시도해 볼 것을 권한다.
Compile을 수행하면 proto 파일에 설정한 go package 위치에 pb.go
가 생성된다. 보통 module의 types 폴더로 go package 위치를 설정하고, 예를 들었던 4가지 proto file들과 대응 되는 genesis.pb.go
, bank.pb.go
, tx.pb.go
, query.pb.go
가 해당 폴더에서 각각 생성된다.
추가적으로, query.pb.gw.go
파일이 생성된다. 이는 compile 시 protoc-gen-grpc-gateway
에서 생성되는 것으로, gRPC gateway를 통해 HTTP REST의 path를 제공하기 위함이다. 앞서 query proto file에서 보았던 /cosmos/bank/v1beta1/balances/{address}/by_denom
처럼 cosmos의 light client daemon(LCD)의 URL에 더하여 REST 엔드포인트가 제공된다.
Module keeper내에서 다른 로직을 추가하지 않았다면 gRPC gateway에서 HTTP로 전달되어 오는 response의 데이터는 일반 gRPC로 요청한 response와 동일하다.
Client
Compile 후 생성된 tx.pb.go
와 query.pb.go
에서는 query를 요청할 수 있도록 각 module 마다 tx/query client가 생성된다. Tx/Query client는 Invoke()
와 NewStream()
을 가지고 있는 gRPC client의 객체가 포함되며, module의 NewQueryClient()
로 가져올 수 있다.
// Query client
type queryClient struct {
cc grpc1.ClientConn
}
func NewQueryClient(cc grpc1.ClientConn) QueryClient {
return &queryClient{cc}
}
// Tx(message) client
type msgClient struct {
cc grpc1.ClientConn
}
func NewMsgClient(cc grpc1.ClientConn) MsgClient {
return &msgClient{cc}
}
Cosmos SDK의 CLI를 통한 요청 시 gRPC client connection 객체로 client.Context
를 사용한다. client.Context
는 grpc.ClientConn
interface의 method(Invoke()
등)를 보유하고 있다. 또한 이 컨텍스트가 노드 시작 명령어로 구동 시 gRPC client connection 객체를 내부 설정으로 가지고 있기 때문에, NewQueryClient()
의 input으로 많이 사용된다.
물론 따로 grpc.Dial()
을 사용해서 gRPC client connection 객체를 생성하여 input으로 넣어줘도 gRPC client를 생성할 수 있다.
Compile하면 tx/query client struct는 receiver로 tx.proto
및 query.proto
에서 생성한 API들에 대해 메소드들을 가지고 있어서 client의 chainning 형식으로 호출할 수 있다. 예를 들어, bank module query 중 아까 살펴 보았던 Balance()
API는 query.pb.go
에서 아래와 같이 생성된다.
func (c *queryClient) Balance(ctx context.Context, in *QueryBalanceRequest, opts ...grpc.CallOption) (*QueryBalanceResponse, error) {
out := new(QueryBalanceResponse)
err := c.cc.Invoke(ctx, "/cosmos.bank.v1beta1.Query/Balance", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
Messages
앞서 언급한 것처럼 compile을 하면 각각의 proto file에 대응되는 pb.go
파일이 생성된다.
각각의 파일에는 proto에서 정의한 파라미터 이름 및 타입이 결정된 상태이고, 해당 이름과 타입을 포함하여 각 메시지가 struct 데이터 타입으로 생성된다. 각 struct는 proto.message
의 method들이 구현된 상태로 생성이 되며, Cosmos SDK에서는 이 데이터들을 이용하여 transaction을 전송하거나 query를 요청한다.
Cosmos SDK의 EncodingConfig
에서는 이 proto message들을 다룰 수 있도록 encoding/decoding codec을 지원하기 때문에 데이터들을 편하게 handling 할 수 있다.
Module의 tx를 처리하거나 query를 응답할 비즈니스 로직을 구현할 때 보통 module 디텍토리 내의 keeper
에 각각 msg_server.go
, grpc_query.go
에서 작업한다.
msg_server.go
에서는 tx.pb.go
에서 생성된 각 module의 MsgServer
interface method들의 로직을 구현하며, grpc_query.go
에서는 query.pb.go
에서 생성된 각 module의 QueryServer
interface method들의 로직을 구현한다.
이 때의 각 method들의 input과 output으로 생성된 tx/query의 pb.go
의 request/response message가 사용된다.
아래 예시는 bank 모듈에서 각각 tx, query가 구현된 하나의 method이다. tx.pb.go
와 query.pb.go
에 생성된 request/response가 input/output으로 존재하는 것을 확인할 수 있을것이다.
구분 | Request | Response |
---|---|---|
Tx (Send) | types.MsgSend |
types.MsgSendResponse |
Query (Balance) | types.QueryBalanceRequest |
types.QueryBalanceResponse |
// /x/bank/keeper/msg_server.go
func (k msgServer) Send(goCtx context.Context, msg *types.MsgSend) (*types.MsgSendResponse, error) {
ctx := sdk.UnwrapSDKContext(goCtx)
if err := k.IsSendEnabledCoins(ctx, msg.Amount...); err != nil {
return nil, err
}
from, err := sdk.AccAddressFromBech32(msg.FromAddress)
if err != nil {
return nil, err
}
to, err := sdk.AccAddressFromBech32(msg.ToAddress)
if err != nil {
return nil, err
}
if k.BlockedAddr(to) {
return nil, sdkerrors.Wrapf(sdkerrors.ErrUnauthorized, "%s is not allowed to receive funds", msg.ToAddress)
}
err = k.SendCoins(ctx, from, to, msg.Amount)
if err != nil {
return nil, err
}
defer func() {
for _, a := range msg.Amount {
if a.Amount.IsInt64() {
telemetry.SetGaugeWithLabels(
[]string{"tx", "msg", "send"},
float32(a.Amount.Int64()),
[]metrics.Label{telemetry.NewLabel("denom", a.Denom)},
)
}
}
}()
return &types.MsgSendResponse{}, nil
}
// /x/bank/keeper/grpc_query.go
// Balance implements the Query/Balance gRPC method
func (k BaseKeeper) Balance(ctx context.Context, req *types.QueryBalanceRequest) (*types.QueryBalanceResponse, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "empty request")
}
if err := sdk.ValidateDenom(req.Denom); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
sdkCtx := sdk.UnwrapSDKContext(ctx)
address, err := sdk.AccAddressFromBech32(req.Address)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid address: %s", err.Error())
}
balance := k.GetBalance(sdkCtx, address, req.Denom)
return &types.QueryBalanceResponse{Balance: &balance}, nil
}
gRPC server in Cosmos-SDK
Server
위 섹션에서 Cosmos SDK의 gRPC 클라이언트와 메시지 구현 부분에 대해 확인 해 보았다. 이제 gRPC server로 넘어가보자. Cosmos-SDK의 baseapp package에 gRPC router와 register server가 정의되어 있다.
GRPCQueryRouter()
와 RegisterGRPCServer()
가 gRPC server 처리를 담당하며, 노드가 실행될 때 RegisterGRPCServer()
를 통해 등록된 각 module의 gRPC service에 대한 routing을 등록할 수 있다.
// baseapp/grpcserver.go
// RegisterGRPCServer registers gRPC services directly with the gRPC server.
func (app *BaseApp) RegisterGRPCServer(server gogogrpc.Server) {
// Define an interceptor for all gRPC queries: this interceptor will create
// a new sdk.Context, and pass it into the query handler.
interceptor := func(grpcCtx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
// If there's some metadata in the context, retrieve it.
md, ok := metadata.FromIncomingContext(grpcCtx)
if !ok {
return nil, status.Error(codes.Internal, "unable to retrieve metadata")
}
// Get height header from the request context, if present.
var height int64
if heightHeaders := md.Get(grpctypes.GRPCBlockHeightHeader); len(heightHeaders) == 1 {
height, err = strconv.ParseInt(heightHeaders[0], 10, 64)
if err != nil {
return nil, sdkerrors.Wrapf(
sdkerrors.ErrInvalidRequest,
"Baseapp.RegisterGRPCServer: invalid height header %q: %v", grpctypes.GRPCBlockHeightHeader, err)
}
if err := checkNegativeHeight(height); err != nil {
return nil, err
}
}
// Create the sdk.Context. Passing false as 2nd arg, as we can't
// actually support proofs with gRPC right now.
sdkCtx, err := app.CreateQueryContext(height, false)
if err != nil {
return nil, err
}
// Add relevant gRPC headers
if height == 0 {
height = sdkCtx.BlockHeight() // If height was not set in the request, set it to the latest
}
// Attach the sdk.Context into the gRPC's context.Context.
grpcCtx = context.WithValue(grpcCtx, sdk.SdkContextKey, sdkCtx)
md = metadata.Pairs(grpctypes.GRPCBlockHeightHeader, strconv.FormatInt(height, 10))
if err = grpc.SetHeader(grpcCtx, md); err != nil {
app.logger.Error("failed to set gRPC header", "err", err)
}
return handler(grpcCtx, req)
}
// Loop through all services and methods, add the interceptor, and register
// the service.
for _, data := range app.GRPCQueryRouter().serviceData {
desc := data.serviceDesc
newMethods := make([]grpc.MethodDesc, len(desc.Methods))
for i, method := range desc.Methods {
methodHandler := method.Handler
newMethods[i] = grpc.MethodDesc{
MethodName: method.MethodName,
Handler: func(srv interface{}, ctx context.Context, dec func(interface{}) error, _ grpc.UnaryServerInterceptor) (interface{}, error) {
return methodHandler(srv, ctx, dec, grpcmiddleware.ChainUnaryServer(
grpcrecovery.UnaryServerInterceptor(),
interceptor,
))
},
}
}
newDesc := &grpc.ServiceDesc{
ServiceName: desc.ServiceName,
HandlerType: desc.HandlerType,
Methods: newMethods,
Streams: desc.Streams,
Metadata: desc.Metadata,
}
server.RegisterService(newDesc, data.handler)
}
}
위 부분에서 gRPC method handler를 설정 시 interceptor를 구현하여 전달 들어오는 데이터들에 대한 전처리를 수행한다. Go에서 gRPC로 데이터를 전달 시 header 정보, metadata 등이 context에 포함 되기 때문에 context를 parsing한 뒤에 handler를 위한 전처리를 수행해야 한다.
Cosmos SDK는 streaming은 사용하지 않고 unary 데이터만 사용하기 때문에 UnaryServerInterceptor
만 구현되어 있으며, grpcmiddleware 패키지("github.com/grpc-ecosystem/go-grpc-middleware"
)의 ChainUnaryServer()
함수를 통해 구현한 interceptor를 handler에 추가한다.
Handler에 interceptor를 추가 후 app chain에 등록된 module들의 gRPC query router 서비스가 등록된다. 이 때 interceptor는 여러개를 등록할 수 있으며, interceptor가 등록된 순서도 중요하기 때문에 우선적으로 처리가 필요한 interceptor는 먼저 선언한다.
RegisterGRPCServer()
는 Cosmos SDK server 패키지("github.com/cosmos/cosmos-sdk/server"
)의 StartGRPCServer()
내에서 호출된다.
// StartGRPCServer starts a gRPC server on the given address.
func StartGRPCServer(clientCtx client.Context, app types.Application, cfg config.GRPCConfig) (*grpc.Server, error) {
maxSendMsgSize := cfg.MaxSendMsgSize
if maxSendMsgSize == 0 {
maxSendMsgSize = config.DefaultGRPCMaxSendMsgSize
}
maxRecvMsgSize := cfg.MaxRecvMsgSize
if maxRecvMsgSize == 0 {
maxRecvMsgSize = config.DefaultGRPCMaxRecvMsgSize
}
grpcSrv := grpc.NewServer(
grpc.ForceServerCodec(codec.NewProtoCodec(clientCtx.InterfaceRegistry).GRPCCodec()),
grpc.MaxSendMsgSize(maxSendMsgSize),
grpc.MaxRecvMsgSize(maxRecvMsgSize),
)
app.RegisterGRPCServer(grpcSrv)
// Reflection allows consumers to build dynamic clients that can write to any
// Cosmos SDK application without relying on application packages at compile
// time.
err := reflection.Register(grpcSrv, reflection.Config{
SigningModes: func() map[string]int32 {
modes := make(map[string]int32, len(clientCtx.TxConfig.SignModeHandler().Modes()))
for _, m := range clientCtx.TxConfig.SignModeHandler().Modes() {
modes[m.String()] = (int32)(m)
}
return modes
}(),
ChainID: clientCtx.ChainID,
SdkConfig: sdk.GetConfig(),
InterfaceRegistry: clientCtx.InterfaceRegistry,
})
if err != nil {
return nil, err
}
// Reflection allows external clients to see what services and methods
// the gRPC server exposes.
gogoreflection.Register(grpcSrv)
listener, err := net.Listen("tcp", cfg.Address)
if err != nil {
return nil, err
}
errCh := make(chan error)
go func() {
err = grpcSrv.Serve(listener)
if err != nil {
errCh <- fmt.Errorf("failed to serve: %w", err)
}
}()
select {
case err := <-errCh:
return nil, err
case <-time.After(types.ServerStartTime):
// assume server started successfully
return grpcSrv, nil
}
}
StartGRPCServer()
에서 grpc.NewServer()
를 통해 생성된 gRPC server(=위 코드에서 grpcSrv
)가 interface로 추상화 된 Application
내의 메소드로 RegisterGRPCServer()
가 호출된다.
생성된 grpcSrv
가 app.RegisterGRPCServer()
의 input으로 들어가서, 해당 grpcSrv
인스턴스 내부에 module의 service들이 등록된다. 서버 listener는 기본 라이브러리인 net
을 통해 구현되며, 기본적으로 Cosmos SDK의 gRPC port는 9090번이다.
따로 수정이 없었다면 cfg.Address
에는 9090 포트가 포함되어 있다. 최종적으로 grpcSrv.Serve(listener)
를 통해 server listen을 시작한다.
이 StartGRPCServer()
는 노드가 동작할 때 app.toml
의 gRPC enable이 true이면 실행된다. CLI를 통해 노드 start
를 수행하면 /server/start.go
의 startInProcess()
함수 내에서 StartGRPCServer()
를 구동한다.
Route
서버 실행 전에 각 모듈의 routing path 또한 지정이 되어야 한다. Cosmos SDK는 module 형식이기 때문에 각각의 모듈에서 routing을 구현하고 app.go
에서 module manager로 구현된 routing path를 등록한다. 각 module은 RegisterServices()
method를 가지고 있어야 하며, 해당 메소드에서 gRPC tx를 처리하는 messager server와 query를 수행하는 query server를 등록한다.
// 각 module의 module.go
// RegisterServices registers a GRPC query service to respond to the
// module-specific GRPC queries.
func (am AppModule) RegisterServices(cfg module.Configurator) {
types.RegisterMsgServer(cfg.MsgServer(), keeper.NewMsgServerImpl(am.keeper))
types.RegisterQueryServer(cfg.QueryServer(), am.keeper)
}
RegisterMsgServer()
와 RegisterQueryServer()
는 앞서 proto를 compile할 때 생성되었던 tx.pb.go
파일과 query.pb.go
파일에 자동으로 생성이 되며, 각 모듈 별로 RegisterServices()
를 module manager가 호출하여 app chain gRPC 서버에 라우팅을 등록한다. 보통의 app chain은 아래와 같이 app.go
에서 module manager에 등록된 module을 순회하며 gRPC 서버에 등록한다.
// 각 app chain의 app.go
// mm은 module manager
app.configurator = module.NewConfigurator(app.appCodec, app.MsgServiceRouter(), app.GRPCQueryRouter())
app.mm.RegisterServices(app.configurator)
// types/module/module.go
// RegisterServices registers all module services
func (m *Manager) RegisterServices(cfg Configurator) {
for _, module := range m.Modules {
module.RegisterServices(cfg)
}
}
Cosmos Module 구조와 gRPC
Cosmos SDK의 gRPC는 각 모듈의 state를 변경하기 위해 사용되다 보니, 앞서 설명한 내용에 module이 많이 언급되어 있다. 그래서 module 구조를 확인하여, proto compile 후 생성되는 pb.go
의 위치와 다른 파일들 간의 관계를 확인한다.
Module 구조는 기본적으로 아래처럼 되어 있으며 필요 시 파일들이 추가/삭제될 것이다. 아래에 설명하는 구조가 모든 모듈에 일치하는 것은 아니다.
.moduleName
├── client
│ └── cli
│ ├── query.go
│ └── tx.go
├── keeper
│ ├── module.go
│ ├── genesis.go
│ ├── grpc_query.go
│ ├── keeper.go
│ └── msg_server.go
├── types
│ ├── module.go
│ ├── module.pb.go
│ ├── codec.go
│ ├── errors.go
│ ├── expected_keeper.go
│ ├── genesis.go
│ ├── genesis.pb.go
│ ├── keys.go
│ ├── msg.go
│ ├── query.pb.go
│ ├── query.pb.gw.go
│ └── tx.pb.go
├── abci.go
├── handler.go
├── module.go
module.go
- App chain의
app.go
에 등록되기 위한 기본적인 method가 구현되어 있다. 모든 모듈들이 같은 구조를 가지고 있으며,AppModuleBasic
과AppModule
을 구현하기 위한 method가 필요하다.AppModuleBasic
은app.go
에서ModuleBasicManager
에 등록이 필요하며,AppModule
은ModuleManager
에 필요하다. Cosmos app chain을 구현하기 위한 codec, interface, genesis, route, begin/end blocker 등을 정의해야 한다.
- App chain의
handler.go
Capability module
의 메시지 라우팅에 사용되며, transaction 처리를 위한 message server의 method들을 분기 처리 한다. Module로 전달되어 오는sdk.Msg
타입에 따라msg_server.go
에서 구현된 method를 호출한다.
abci.go
- 보통 Cosmos block의 생성 전/후에 처리하는 로직을 구현한다. (BeginBlocker / EndBlocker)
types/module.pb.go
- Proto로 생성한 메시지들의 집합이다. Proto 파일을 compile 하면
module.proto
에 정의한 메시지가 생성된다.
- Proto로 생성한 메시지들의 집합이다. Proto 파일을 compile 하면
types/module.go
types.module.pb.go
의 메시지들을 다루는 로직이 구현되어 있다.
types/codec.go
module.go
에서 사용 되는RegisterLegacyAminoCodec()
이나RegisterInterfaces()
를 정의한다. Module transaction들에 대한 encoding/decoding을 위해 미리 선언하는 것이며, 여기에 없다면 tx 처리 시 알 수 없는 tx 형식으로 확인될 수 있다.
types/errors.go
- Module에서 사용되는 error들을 정의한다.
types/expected_keeper.go
- 만약 module keeper에 다른 module 들의 keeper를 사용해야 한다면, interface 형식으로 추상화하여 정의한다. 직접 외부 keeper를 가져오면 module들 끼리 과도하게 import dependency가 걸리기 때문에 interface로 가져오도록 구현하자.
types/genesis.go
- App chain을 init 하거나 export genesis를 할 때 사용되는
GenesisState
를 처리하는 로직에 대해 구현한다. 실제로GenesisState
를 핸들링 하는 것은keeper/genesis.go
에서 구현된다.
- App chain을 init 하거나 export genesis를 할 때 사용되는
types/genesis.pb.go
genesis.proto
로 생성한 메시지가 정의된다.GenesisState
는 app chain에서의genesis.json
에 포함되는 파라미터들이 존재하며, 처음 init할 때 필요한 파라미터 타입을 설정하거나 export genesis를 할 시에 현재 state를 가져와서 저장해야할 파라미터 타입을 정의해야 한다.
types/keys.go
- Keeper에서 key-value state DB에 저장하기 위한 key를 정의한다. 만약 state DB에서 iterator로 저장된 데이터를 순회 검색할 시에는
prefix
를 이용하여 key를 생성하는게 효율적이다. (prefix를 기준으로 data를 불러와서 iterate 할 수 있기 때문)
- Keeper에서 key-value state DB에 저장하기 위한 key를 정의한다. 만약 state DB에서 iterator로 저장된 데이터를 순회 검색할 시에는
types/msg.go
- Module transaction들이 덕타이핑으로
sdk.Msg
interface를 충족 하기 위한 메소드들을 구현한다. Signer를 불러오거나 message의 기본적인 validation을 확인할 수 있는 메소드가 존재한다.
- Module transaction들이 덕타이핑으로
types/query.pb.go
query.proto
로 생성한 request/response 메시지들의 집합이면서, query client와 query server가 제공하는 service들의 API 항목들이 정의된다.
types/query.pb.gw.go
- gRPC gateway로 HTTP REST를 제공하기 위한 routing 정보가 정의되어 있다.
types/tx.pb.go
tx.proto
로 생성한 request/response 메시지들의 집합이면서 msg client와 msg server가 제공하는 service들의 API 항목들이 정의된다.
keeper/keeper.go
- Module의 state 저장소를 다루거나, 로직을 처리하는
Keeper struct
를 정의한다.
- Module의 state 저장소를 다루거나, 로직을 처리하는
keeper/module.go
- 보통 module의 tx/query 시 데이터를 다루는 Set/Get 함수가 구현되어 있다. Tx 처리 시 state를 변경하거나 query 요청 시 state 저장값을 불러오는 역할을 수행한다.
keeper/genesis.go
GenesisState
를 핸들링한다. Init 시에genesis.json
에 있는 값들을 가져와서 state DB에 저장하거나, Export 시에 state에 저장되어 있는 값들을 가져와서genesis.json
을 생성하는 로직을 구현한다.
keeper/grpc_query.go
types/query.pb.go
에서 생성된 메시지들이 query API에 의해 체인으로 전송될 때 로직을 처리한다. 각 메소드들이 keeper struct의 receiver로 구현되어 있기 때문에 state DB를 조회하여 결과값을 응답한다.
keeper/msg_server.go
types.tx.pb.go
에서 생성된 메시지들이 tx broadcast로 체인에 전송될 때 로직을 처리한다. 각 메소드들이 message server struct의 receiver로 구현되어 있는데, message server에 keeper 객체가 존재하여, state DB에 접근할 수 있다.
client/cli/query.go
- CLI 명령을 정의하고, query 기능을 구현한다.
client/cli/tx.go
- CLI 명령을 정의하고, tx 기능을 구현한다.
Module의 파일들의 각각의 기능을 살펴보면 gRPC와 상당히 밀접하다. 결론적으로, 각 모듈들은 gRPC 서버로 인입되어 오는 데이터의 형식을 정의하거나, 요청 데이터를 처리/저장하는 역할을 맡고 있기 때문에 Cosmos의 기능 개발은 gRPC를 다루는 것부터 출발한다.
Conslusion
Intro에서 언급했듯이 Cosmos SDK는 여러 통신 방식이 있지만 외부와의 통신을 위한 메인 프로토콜을 gRPC를 채택하였다. 그렇기 때문에 app chain의 module 구현은 gRPC 요청과 응답을 처리하기 위한 기능을 구현하는 것이 대부분이다. 그래서 Cosmos SDK 기반 체인의 코어를 다루기 위해서는 gRPC에 대한 이해가 베이스로 들어가야 하며, gRPC를 다루기 위한 protobuf 및 데이터 처리에 대해 알고 있어야 한다.
다음번에 cosmos와 관련된 글을 쓸 때는 이왕 module의 구조에 대해 확인 했으니, module을 구현하는 순서 및 전략에 대해 정리 해보겠다.
'Blockchain > Cosmos' 카테고리의 다른 글
Cosmos-SDK transaction의 형식 및 데이터 (0) | 2024.06.24 |
---|---|
Evmos의 Ethereum Tx 처리 (1) | 2024.01.23 |
Inter-Blockchain Communication 구조와 Relayer (0) | 2023.07.07 |
Cosmos state query 방법 (0) | 2023.06.22 |
Cosmos SDK 기반 체인 다중 validator 환경 구성 (0) | 2023.06.15 |