内容简介:几个月前,一个优秀的 GraphQL Go 包下面列出的一些代码可能存在一些缺失,完整的代码请访问Spidey 包含了三个不同的服务并暴露给了 GraphQL 网关。集群内部的通信则通过gRPC 来完成。
- 原文地址: Using GraphQL with Microservices in Go
- 原文作者:Tin Rabzelj
- 译文出自: 掘金翻译计划
- 本文永久链接: github.com/xitu/gold-m…
- 译者: Changkun Ou
- 校对者: razertory
几个月前,一个优秀的 GraphQL Go 包 vektah/gqlgen 开始流行。本文描述了在 Spidey 项目(一个在线商店的基本微服务)中如何实现 GraphQL。
下面列出的一些代码可能存在一些缺失,完整的代码请访问 GitHub 。
架构
Spidey 包含了三个不同的服务并暴露给了 GraphQL 网关。集群内部的通信则通过gRPC 来完成。
账户服务管理了所有的账号;目录服务管理了所有的产品;订单服务则处理了所有的订单创建行为。它会与其他两个服务进行通信来告知订单是否正常完成。
独立的服务包含三层: Server 层 、 Service 层 以及 Repository 层 。服务端作负责通信,也就是 Spidey 中使用 gRPC。服务则包含了业务逻辑。仓库则负责对数据库进行读写操作。
起步
运行 Spidey 需要 Docker 、 Docker Compose 、 Go 、 Protocol Buffers 编译器及其 Go 插件以及非常有用的 vektah/gqlgen 包。
你还需要安装vgo(一个处于早期开发阶段的包管理工具)。工具 dep 也是一种选择,但是包含的 go.mod
文件会被忽略。
译注:在 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 定义。
下面是截取的一部分关于 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
账户服务的 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
目录服务的 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 服务
Order 订单服务就比较棘手了。他需要调用账户和目录服务来验证请求,因为一个订单只能给一个特定的账号和一个存在的商品进行创建。
Service
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( ℴ.ID, ℴ.CreatedAt, ℴ.AccountID, ℴ.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) } 复制代码
gRPC
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 服务
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 、 前端 、 后端 、 区块链 、 产品 、 设计 、 人工智能 等领域,想要查看更多优质译文请持续关注 掘金翻译计划 、官方微博、 知乎专栏 。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 开源 | GateKeeper:滴滴开源的使用 Go 编写的不依赖分布式数据库的 API 网关
- Soul 网关发布 2.2.0,让高性能网关变得如此简单!
- 远行API网关(200428)
- zuul网关实现解析
- 设计一个HTTP网关
- Kong 网关使用入门
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Web Design in a Nutshell
Jennifer Niederst / O'Reilly Media, Inc. / 2006-02-21 / USD 34.99
Are you still designing web sites like it's 1999? If so, you're in for a surprise. Since the last edition of this book appeared five years ago, there has been a major climate change with regard to web......一起来看看 《Web Design in a Nutshell》 这本书的介绍吧!