- 原文地址:Using GraphQL with Microservices in Go
- 原文做者:Tin Rabzelj
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:Changkun Ou
- 校对者:razertory
几个月前,一个优秀的 GraphQL Go 包 vektah/gqlgen 开始流行。本文描述了在 Spidey 项目(一个在线商店的基本微服务)中如何实现 GraphQL。前端
下面列出的一些代码可能存在一些缺失,完整的代码请访问 GitHub。android
Spidey 包含了三个不一样的服务并暴露给了 GraphQL 网关。集群内部的通讯则经过 gRPC 来完成。ios
帐户服务管理了全部的帐号;目录服务管理了全部的产品;订单服务则处理了全部的订单建立行为。它会与其余两个服务进行通讯来告知订单是否正常完成。git
独立的服务包含三层:Server 层、Service 层以及Repository 层。服务端做负责通讯,也就是 Spidey 中使用 gRPC。服务则包含了业务逻辑。仓库则负责对数据库进行读写操做。github
运行 Spidey 须要 Docker、 Docker Compose、 Go、 Protocol Buffers 编译器及其 Go 插件以及很是有用的 vektah/gqlgen 包。golang
你还须要安装 vgo(一个处于早期开发阶段的包管理工具)。工具 dep 也是一种选择,可是包含的 go.mod
文件会被忽略。sql
译注:在 Go 1.11 中 vgo 做为官方集成的 Go Modules 发布,已集成在 go 命令中,使用 go mod 进行使用,指令与 vgo 基本一致。docker
每一个服务在其自身的子文件夹中实现,并至少包含一个 app.dockerfile
文件。app.dockerfile
文件用户构建数据库镜像。数据库
account
├── account.proto
├── app.dockerfile
├── cmd
│ └── account
│ └── main.go
├── db.dockerfile
└── up.sql
复制代码
全部服务经过外部的 docker-compose.yaml 定义。json
下面是截取的一部分关于 Account 服务的内容:
version: "3.6"
services:
account:
build:
context: "."
dockerfile: "./account/app.dockerfile"
depends_on:
- "account_db"
environment:
DATABASE_URL: "postgres://spidey:123456@account_db/spidey?sslmode=disable"
account_db:
build:
context: "./account"
dockerfile: "./db.dockerfile"
environment:
POSTGRES_DB: "spidey"
POSTGRES_USER: "spidey"
POSTGRES_PASSWORD: "123456"
restart: "unless-stopped"
复制代码
设置 context
的目的是保证 vendor
目录可以被复制到 Docker 容器中。全部服务共享相同的依赖、某些服务还依赖其余服务的定义。
帐户服务暴露了建立以及索引帐户的方法。
帐户服务的 API 定义的接口以下:
type Service interface {
PostAccount(ctx context.Context, name string) (*Account, error)
GetAccount(ctx context.Context, id string) (*Account, error)
GetAccounts(ctx context.Context, skip uint64, take uint64) ([]Account, error)
}
type Account struct {
ID string `json:"id"`
Name string `json:"name"`
}
复制代码
实现须要用到 Repository:
type accountService struct {
repository Repository
}
func NewService(r Repository) Service {
return &accountService{r}
}
复制代码
这个服务负责了全部的业务逻辑。PostAccount
函数的实现以下:
func (s *accountService) PostAccount(ctx context.Context, name string) (*Account, error) {
a := &Account{
Name: name,
ID: ksuid.New().String(),
}
if err := s.repository.PutAccount(ctx, *a); err != nil {
return nil, err
}
return a, nil
}
复制代码
它将线路协议解析处理为服务端,并将数据库处理为 Repository。
一个帐户的数据模型很是简单:
CREATE TABLE IF NOT EXISTS accounts (
id CHAR(27) PRIMARY KEY,
name VARCHAR(24) NOT NULL
);
复制代码
上面定义数据的 SQL 文件会复制到 Docker 容器中执行。
FROM postgres:10.3
COPY up.sql /docker-entrypoint-initdb.d/1.sql
CMD ["postgres"]
复制代码
PostgreSQL 数据库经过下面的 Repository 接口进行访问:
type Repository interface {
Close()
PutAccount(ctx context.Context, a Account) error
GetAccountByID(ctx context.Context, id string) (*Account, error)
ListAccounts(ctx context.Context, skip uint64, take uint64) ([]Account, error)
}
复制代码
Repository 基于 Go 标准库 SQL 包进行封装:
type postgresRepository struct {
db *sql.DB
}
func NewPostgresRepository(url string) (Repository, error) {
db, err := sql.Open("postgres", url)
if err != nil {
return nil, err
}
err = db.Ping()
if err != nil {
return nil, err
}
return &postgresRepository{db}, nil
}
复制代码
帐户服务的 gRPC 服务定义了下面的 Protocol Buffer:
syntax = "proto3";
package pb;
message Account {
string id = 1;
string name = 2;
}
message PostAccountRequest {
string name = 1;
}
message PostAccountResponse {
Account account = 1;
}
message GetAccountRequest {
string id = 1;
}
message GetAccountResponse {
Account account = 1;
}
message GetAccountsRequest {
uint64 skip = 1;
uint64 take = 2;
}
message GetAccountsResponse {
repeated Account accounts = 1;
}
service AccountService {
rpc PostAccount (PostAccountRequest) returns (PostAccountResponse) {} rpc GetAccount (GetAccountRequest) returns (GetAccountResponse) {} rpc GetAccounts (GetAccountsRequest) returns (GetAccountsResponse) {} } 复制代码
因为这个包被设置为了 pb
,因而生成的代码能够从 pb
子包导入使用。
gRPC 的代码可使用 Go 的 generate
指令配合 account/server.go 文件最上方的注释进行编译生成:
//go:generate protoc ./account.proto --go_out=plugins=grpc:./pb
package account
复制代码
运行下面的命令就能够将代码生成到 pb
子目录:
$ go generate account/server.go
复制代码
服务端做为 Service
服务接口的适配器,对应转换了请求和返回的类型。
type grpcServer struct {
service Service
}
func ListenGRPC(s Service, port int) error {
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return err
}
serv := grpc.NewServer()
pb.RegisterAccountServiceServer(serv, &grpcServer{s})
reflection.Register(serv)
return serv.Serve(lis)
}
复制代码
下面是 PostAccount
函数的实现:
func (s *grpcServer) PostAccount(ctx context.Context, r *pb.PostAccountRequest) (*pb.PostAccountResponse, error) {
a, err := s.service.PostAccount(ctx, r.Name)
if err != nil {
return nil, err
}
return &pb.PostAccountResponse{Account: &pb.Account{
Id: a.ID,
Name: a.Name,
}}, nil
}
复制代码
gRPC 服务端在 account/cmd/account/main.go 文件中进行初始化:
type Config struct {
DatabaseURL string `envconfig:"DATABASE_URL"`
}
func main() {
var cfg Config
err := envconfig.Process("", &cfg)
if err != nil {
log.Fatal(err)
}
var r account.Repository
retry.ForeverSleep(2*time.Second, func(_ int) (err error) {
r, err = account.NewPostgresRepository(cfg.DatabaseURL)
if err != nil {
log.Println(err)
}
return
})
defer r.Close()
log.Println("Listening on port 8080...")
s := account.NewService(r)
log.Fatal(account.ListenGRPC(s, 8080))
}
复制代码
客户端结构体的实现位于 account/client.go 文件中。这样帐户服务就能够在无需了解 RPC 内部实现的状况下进行实现,咱们以后再来详细讨论。
account, err := accountClient.GetAccount(ctx, accountId)
if err != nil {
log.Fatal(err)
}
复制代码
目录服务负责处理 Spidey 商店的商品。它实现了相似于帐户服务的功能,可是使用了 Elasticsearch 对商品进行持久化。
目录服务遵循下面的接口:
type Service interface {
PostProduct(ctx context.Context, name, description string, price float64) (*Product, error)
GetProduct(ctx context.Context, id string) (*Product, error)
GetProducts(ctx context.Context, skip uint64, take uint64) ([]Product, error)
GetProductsByIDs(ctx context.Context, ids []string) ([]Product, error)
SearchProducts(ctx context.Context, query string, skip uint64, take uint64) ([]Product, error)
}
type Product struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
}
复制代码
Repository 基于 Elasticsearch olivere/elastic 包进行实现。
type Repository interface {
Close()
PutProduct(ctx context.Context, p Product) error
GetProductByID(ctx context.Context, id string) (*Product, error)
ListProducts(ctx context.Context, skip uint64, take uint64) ([]Product, error)
ListProductsWithIDs(ctx context.Context, ids []string) ([]Product, error)
SearchProducts(ctx context.Context, query string, skip uint64, take uint64) ([]Product, error)
}
复制代码
因为 Elasticsearch 将文档和 ID 分开存储,所以实现的一个商品的辅助结构没有包含 ID:
type productDocument struct {
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
}
复制代码
将商品插入到数据库中:
func (r *elasticRepository) PutProduct(ctx context.Context, p Product) error {
_, err := r.client.Index().
Index("catalog").
Type("product").
Id(p.ID).
BodyJson(productDocument{
Name: p.Name,
Description: p.Description,
Price: p.Price,
}).
Do(ctx)
return err
}
复制代码
目录服务的 gRPC 服务定义在 catalog/catalog.proto 文件中,并在 catalog/server.go 中进行实现。与帐户服务不一样的是,它没有在服务接口中定义全部的 endpoint。
syntax = "proto3";
package pb;
message Product {
string id = 1;
string name = 2;
string description = 3;
double price = 4;
}
message PostProductRequest {
string name = 1;
string description = 2;
double price = 3;
}
message PostProductResponse {
Product product = 1;
}
message GetProductRequest {
string id = 1;
}
message GetProductResponse {
Product product = 1;
}
message GetProductsRequest {
uint64 skip = 1;
uint64 take = 2;
repeated string ids = 3;
string query = 4;
}
message GetProductsResponse {
repeated Product products = 1;
}
service CatalogService {
rpc PostProduct (PostProductRequest) returns (PostProductResponse) {} rpc GetProduct (GetProductRequest) returns (GetProductResponse) {} rpc GetProducts (GetProductsRequest) returns (GetProductsResponse) {} } 复制代码
尽管 GetProductRequest
消息包含了额外的字段,但经过 ID 的搜索与索引实现。
下面的代码展现了 GetProducts
函数的实现:
func (s *grpcServer) GetProducts(ctx context.Context, r *pb.GetProductsRequest) (*pb.GetProductsResponse, error) {
var res []Product
var err error
if r.Query != "" {
res, err = s.service.SearchProducts(ctx, r.Query, r.Skip, r.Take)
} else if len(r.Ids) != 0 {
res, err = s.service.GetProductsByIDs(ctx, r.Ids)
} else {
res, err = s.service.GetProducts(ctx, r.Skip, r.Take)
}
if err != nil {
log.Println(err)
return nil, err
}
products := []*pb.Product{}
for _, p := range res {
products = append(
products,
&pb.Product{
Id: p.ID,
Name: p.Name,
Description: p.Description,
Price: p.Price,
},
)
}
return &pb.GetProductsResponse{Products: products}, nil
}
复制代码
它决定了当给定何种参数来调用何种服务函数。其目标是模拟 REST HTTP 的 endpoint。
对于 /products?[ids=...]&[query=...]&skip=0&take=100
形式的请求,只有设计一个 endpoint 来完成 API 调用会相对容易一些。
Order 订单服务就比较棘手了。他须要调用帐户和目录服务来验证请求,由于一个订单只能给一个特定的帐号和一个存在的商品进行建立。
Service
接口定义了经过帐户建立和索引所有订单的接口。
type Service interface {
PostOrder(ctx context.Context, accountID string, products []OrderedProduct) (*Order, error)
GetOrdersForAccount(ctx context.Context, accountID string) ([]Order, error)
}
type Order struct {
ID string
CreatedAt time.Time
TotalPrice float64
AccountID string
Products []OrderedProduct
}
type OrderedProduct struct {
ID string
Name string
Description string
Price float64
Quantity uint32
}
复制代码
一个订单能够包含多个商品,所以数据模型必须支持这种形式。下面的 order_products
表描述了 ID 为 product_id
的订购产品以及此类产品的数量。而 product_id
字段必须能够从目录服务进行检索。
CREATE TABLE IF NOT EXISTS orders (
id CHAR(27) PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
account_id CHAR(27) NOT NULL,
total_price MONEY NOT NULL
);
CREATE TABLE IF NOT EXISTS order_products (
order_id CHAR(27) REFERENCES orders (id) ON DELETE CASCADE,
product_id CHAR(27),
quantity INT NOT NULL,
PRIMARY KEY (product_id, order_id)
);
复制代码
Repository
接口很简单:
type Repository interface {
Close()
PutOrder(ctx context.Context, o Order) error
GetOrdersForAccount(ctx context.Context, accountID string) ([]Order, error)
}
复制代码
但实现它却并不简单。
一个订单必须使用事务机制分两步插入,而后经过 join 语句进行查询。
从数据库中读取订单须要解析一个表状结构数据读取到对象结构中。下面的代码基于订单 ID 将商品读取到订单中:
orders := []Order{}
order := &Order{}
lastOrder := &Order{}
orderedProduct := &OrderedProduct{}
products := []OrderedProduct{}
// 将每行读取到 Order 结构体
for rows.Next() {
if err = rows.Scan(
&order.ID,
&order.CreatedAt,
&order.AccountID,
&order.TotalPrice,
&orderedProduct.ID,
&orderedProduct.Quantity,
); err != nil {
return nil, err
}
// 读取订单
if lastOrder.ID != "" && lastOrder.ID != order.ID {
newOrder := Order{
ID: lastOrder.ID,
AccountID: lastOrder.AccountID,
CreatedAt: lastOrder.CreatedAt,
TotalPrice: lastOrder.TotalPrice,
Products: products,
}
orders = append(orders, newOrder)
products = []OrderedProduct{}
}
// 读取商品
products = append(products, OrderedProduct{
ID: orderedProduct.ID,
Quantity: orderedProduct.Quantity,
})
*lastOrder = *order
}
// 添加最后一个订单 (或者第一个 :D)
if lastOrder != nil {
newOrder := Order{
ID: lastOrder.ID,
AccountID: lastOrder.AccountID,
CreatedAt: lastOrder.CreatedAt,
TotalPrice: lastOrder.TotalPrice,
Products: products,
}
orders = append(orders, newOrder)
}
复制代码
Order 服务的 gRPC 服务端须要在实现时与帐户和目录服务创建联系。
Protocol Buffers 定义以下:
syntax = "proto3";
package pb;
message Order {
message OrderProduct {
string id = 1;
string name = 2;
string description = 3;
double price = 4;
uint32 quantity = 5;
}
string id = 1;
bytes createdAt = 2;
string accountId = 3;
double totalPrice = 4;
repeated OrderProduct products = 5;
}
message PostOrderRequest {
message OrderProduct {
string productId = 2;
uint32 quantity = 3;
}
string accountId = 2;
repeated OrderProduct products = 4;
}
message PostOrderResponse {
Order order = 1;
}
message GetOrderRequest {
string id = 1;
}
message GetOrderResponse {
Order order = 1;
}
message GetOrdersForAccountRequest {
string accountId = 1;
}
message GetOrdersForAccountResponse {
repeated Order orders = 1;
}
service OrderService {
rpc PostOrder (PostOrderRequest) returns (PostOrderResponse) {} rpc GetOrdersForAccount (GetOrdersForAccountRequest) returns (GetOrdersForAccountResponse) {} } 复制代码
运行订单服务须要传递其余服务的 URL:
type grpcServer struct {
service Service
accountClient *account.Client
catalogClient *catalog.Client
}
func ListenGRPC(s Service, accountURL, catalogURL string, port int) error {
accountClient, err := account.NewClient(accountURL)
if err != nil {
return err
}
catalogClient, err := catalog.NewClient(catalogURL)
if err != nil {
accountClient.Close()
return err
}
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
accountClient.Close()
catalogClient.Close()
return err
}
serv := grpc.NewServer()
pb.RegisterOrderServiceServer(serv, &grpcServer{
s,
accountClient,
catalogClient,
})
reflection.Register(serv)
return serv.Serve(lis)
}
复制代码
建立订单涉及调用账户服务、检查账户是否存在、而后对产品执行相同操做。计算总价时还须要读取产品价格。你不会但愿用户能传入本身的商品的总价。
func (s *grpcServer) PostOrder( ctx context.Context, r *pb.PostOrderRequest, ) (*pb.PostOrderResponse, error) {
// 检查帐户是否存在
_, err := s.accountClient.GetAccount(ctx, r.AccountId)
if err != nil {
log.Println(err)
return nil, err
}
// 获取订单商品
productIDs := []string{}
for _, p := range r.Products {
productIDs = append(productIDs, p.ProductId)
}
orderedProducts, err := s.catalogClient.GetProducts(ctx, 0, 0, productIDs, "")
if err != nil {
log.Println(err)
return nil, err
}
// 构造商品
products := []OrderedProduct{}
for _, p := range orderedProducts {
product := OrderedProduct{
ID: p.ID,
Quantity: 0,
Price: p.Price,
Name: p.Name,
Description: p.Description,
}
for _, rp := range r.Products {
if rp.ProductId == p.ID {
product.Quantity = rp.Quantity
break
}
}
if product.Quantity != 0 {
products = append(products, product)
}
}
// 调用服务实现
order, err := s.service.PostOrder(ctx, r.AccountId, products)
if err != nil {
log.Println(err)
return nil, err
}
// 建立订单响应
orderProto := &pb.Order{
Id: order.ID,
AccountId: order.AccountID,
TotalPrice: order.TotalPrice,
Products: []*pb.Order_OrderProduct{},
}
orderProto.CreatedAt, _ = order.CreatedAt.MarshalBinary()
for _, p := range order.Products {
orderProto.Products = append(orderProto.Products, &pb.Order_OrderProduct{
Id: p.ID,
Name: p.Name,
Description: p.Description,
Price: p.Price,
Quantity: p.Quantity,
})
}
return &pb.PostOrderResponse{
Order: orderProto,
}, nil
}
复制代码
当请求特定帐户的订单时,因为须要产品的详情,所以调用目录服务是有必要的。
GraphQL schema 的定义在 graphql/schema.graphql 文件中:
scalar Time
type Account {
id: String!
name: String!
orders: [Order!]!
}
type Product {
id: String!
name: String!
description: String!
price: Float!
}
type Order {
id: String!
createdAt: Time!
totalPrice: Float!
products: [OrderedProduct!]!
}
type OrderedProduct {
id: String!
name: String!
description: String!
price: Float!
quantity: Int!
}
input PaginationInput {
skip: Int
take: Int
}
input AccountInput {
name: String!
}
input ProductInput {
name: String!
description: String!
price: Float!
}
input OrderProductInput {
id: String!
quantity: Int!
}
input OrderInput {
accountId: String!
products: [OrderProductInput!]!
}
type Mutation {
createAccount(account: AccountInput!): Account
createProduct(product: ProductInput!): Product
createOrder(order: OrderInput!): Order
}
type Query {
accounts(pagination: PaginationInput, id: String): [Account!]!
products(pagination: PaginationInput, query: String, id: String): [Product!]!
}
复制代码
gqlgen
工具会生成一堆类型,可是还须要对 Order
模型进行一些控制,在 graphql/types.json 文件中进行制定,从而不会自动生成模型:
{
"Order": "github.com/tinrab/spidey/graphql/graph.Order"
}
复制代码
如今能够手动实现 Order
结构了:
package graph
import time "time"
type Order struct {
ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
TotalPrice float64 `json:"totalPrice"`
Products []OrderedProduct `json:"products"`
}
复制代码
生成类型的指令在 graphql/graph/graph.go 顶部:
//go:generate gqlgen -schema ../schema.graphql -typemap ../types.json
package graph
复制代码
经过下面的命令运行:
$ go generate ./graphql/graph/graph.go
复制代码
GraphQL 服务端引用了全部其余服务。
type GraphQLServer struct {
accountClient *account.Client
catalogClient *catalog.Client
orderClient *order.Client
}
func NewGraphQLServer(accountUrl, catalogURL, orderURL string) (*GraphQLServer, error) {
// 链接帐户服务
accountClient, err := account.NewClient(accountUrl)
if err != nil {
return nil, err
}
// 链接目录服务
catalogClient, err := catalog.NewClient(catalogURL)
if err != nil {
accountClient.Close()
return nil, err
}
// 链接订单服务
orderClient, err := order.NewClient(orderURL)
if err != nil {
accountClient.Close()
catalogClient.Close()
return nil, err
}
return &GraphQLServer{
accountClient,
catalogClient,
orderClient,
}, nil
}
复制代码
GraphQLServer
结构体须要实现全部生成的 resolver。修改(Mutation)能够在 graphql/graph/mutations.go 中找到,查询(Query)则能够在 graphql/graph/queries.go 中找到。
修改操做经过调用相关服务客户端传入参数进行实现:
func (s *GraphQLServer) Mutation_createAccount(ctx context.Context, in AccountInput) (*Account, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
a, err := s.accountClient.PostAccount(ctx, in.Name)
if err != nil {
log.Println(err)
return nil, err
}
return &Account{
ID: a.ID,
Name: a.Name,
}, nil
}
复制代码
查询可以互相嵌套。在 Spidey 中,查询帐户还能够查询其订单,见 Account_orders
函数。
func (s *GraphQLServer) Query_accounts(ctx context.Context, pagination *PaginationInput, id *string) ([]Account, error) {
// 会被首先调用
// ...
}
func (s *GraphQLServer) Account_orders(ctx context.Context, obj *Account) ([]Order, error) {
// 而后执行这个函数,返回 "obj" 帐户的订单
// ...
}
复制代码
执行下面的命令就能够运行 Spidey:
$ vgo vendor
$ docker-compose up -d --build
复制代码
而后你就能够在浏览器中访问 http://localhost:8000/playground 来使用 GraphQL 工具建立一个帐户了:
mutation {
createAccount(account: {name: "John"}) {
id
name
}
}
复制代码
返回结果为:
{
"data": {
"createAccount": {
"id": "15t4u0du7t6vm9SRa4m3PrtREHb",
"name": "John"
}
}
}
复制代码
而后能够建立一些产品:
mutation {
a: createProduct(product: {name: "Kindle Oasis", description: "Kindle Oasis is the first waterproof Kindle with our largest 7-inch 300 ppi display, now with Audible when paired with Bluetooth.", price: 300}) { id },
b: createProduct(product: {name: "Samsung Galaxy S9", description: "Discover Galaxy S9 and S9+ and the revolutionary camera that adapts like the human eye.", price: 720}) { id },
c: createProduct(product: {name: "Sony PlayStation 4", description: "The PlayStation 4 is an eighth-generation home video game console developed by Sony Interactive Entertainment", price: 300}) { id },
d: createProduct(product: {name: "ASUS ZenBook Pro UX550VE", description: "Designed to entice. Crafted to perform.", price: 300}) { id },
e: createProduct(product: {name: "Mpow PC Headset 3.5mm", description: "Computer Headset with Microphone Noise Cancelling, Lightweight PC Headset Wired Headphones, Business Headset for Skype, Webinar, Phone, Call Center", price: 43}) { id }
}
复制代码
注意返回的 ID 值:
{
"data": {
"a": {
"id": "15t7jjANR47uODEPUIy1od5APnC"
},
"b": {
"id": "15t7jsTyrvs1m4EYu7TCes1EN5z"
},
"c": {
"id": "15t7jrfDhZKgxOdIcEtTUsriAsY"
},
"d": {
"id": "15t7jpKt4VkJ5iHbwt4rB5xR77w"
},
"e": {
"id": "15t7jsYs0YzK3B7drQuf1mX5Dyg"
}
}
}
复制代码
而后发起一些订单:
mutation {
createOrder(order: { accountId: "15t4u0du7t6vm9SRa4m3PrtREHb", products: [
{ id: "15t7jjANR47uODEPUIy1od5APnC", quantity: 2 },
{ id: "15t7jpKt4VkJ5iHbwt4rB5xR77w", quantity: 1 },
{ id: "15t7jrfDhZKgxOdIcEtTUsriAsY", quantity: 5 }
]}) {
id
createdAt
totalPrice
}
}
复制代码
根据返回结果检查返回的费用:
{
"data": {
"createOrder": {
"id": "15t8B6lkg80ZINTASts92nBzyE8",
"createdAt": "2018-06-11T21:18:18Z",
"totalPrice": 2400
}
}
}
复制代码
完整代码请查看 GitHub。
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。