การพัฒนา API ใน Go โดยใช้ Goa
เผยแพร่แล้ว: 2022-03-11การพัฒนา API เป็นประเด็นร้อนในปัจจุบัน มีวิธีมากมายที่คุณสามารถพัฒนาและส่งมอบ API และบริษัทขนาดใหญ่ได้พัฒนาโซลูชันจำนวนมากเพื่อช่วยให้คุณเริ่มต้นแอปพลิเคชันได้อย่างรวดเร็ว
อย่างไรก็ตาม ตัวเลือกเหล่านี้ส่วนใหญ่ขาดคุณสมบัติหลัก นั่นคือ การจัดการวงจรการพัฒนา ดังนั้น นักพัฒนาจึงใช้เวลาบางรอบในการสร้าง API ที่มีประโยชน์และแข็งแกร่ง แต่สุดท้ายก็ต้องดิ้นรนกับวิวัฒนาการของโค้ดที่คาดหวังและผลกระทบที่การเปลี่ยนแปลงเล็กน้อยใน API มีอยู่ในซอร์สโค้ด
ในปี 2016 Raphael Simon ได้สร้าง Goa ซึ่งเป็นเฟรมเวิร์กสำหรับการพัฒนา API ใน Golang โดยมีวงจรชีวิตที่ทำให้การออกแบบ API เป็นอันดับแรก ใน Goa คำจำกัดความ API ของคุณไม่ได้ถูกอธิบายว่าเป็นโค้ดเท่านั้น แต่ยังเป็นแหล่งที่มาของรหัสเซิร์ฟเวอร์ โค้ดไคลเอ็นต์ และเอกสารประกอบอีกด้วย ซึ่งหมายความว่าโค้ดของคุณได้รับการอธิบายไว้ในคำจำกัดความ API โดยใช้ Golang Domain Specific Language (DSL) จากนั้นจึงสร้างโดยใช้ goa cli และนำไปใช้แยกต่างหากจากซอร์สโค้ดของแอปพลิเคชันของคุณ
นั่นคือเหตุผลที่กัวส่องแสง เป็นโซลูชันที่มีสัญญาวงจรการพัฒนาที่กำหนดไว้อย่างดีซึ่งอาศัยแนวทางปฏิบัติที่ดีที่สุดเมื่อสร้างโค้ด (เช่น การแยกโดเมนและข้อกังวลต่างๆ ออกเป็นชั้นๆ ดังนั้นด้านการขนส่งจะไม่รบกวนด้านธุรกิจของแอปพลิเคชัน) ตามรูปแบบสถาปัตยกรรมที่สะอาดซึ่งประกอบได้ โมดูลถูกสร้างขึ้นสำหรับชั้นการขนส่ง จุดปลาย และตรรกะทางธุรกิจในแอปพลิเคชันของคุณ
คุณลักษณะบางอย่างของ Goa ตามที่กำหนดโดยเว็บไซต์อย่างเป็นทางการ ได้แก่ :
- ความสามารถในการย่อย สลาย ได้ แพ็กเกจ อัลกอริธึมการสร้างโค้ด และโค้ดที่สร้างขึ้นทั้งหมดเป็นแบบโมดูล
- ขนส่งไม่เชื่อเรื่องพระเจ้า การแยกเลเยอร์การขนส่งจากการใช้งานบริการจริงหมายความว่าบริการเดียวกันสามารถเปิดเผยปลายทางที่สามารถเข้าถึงได้ผ่านการขนส่งหลายรายการ เช่น HTTP และ/หรือ gRPC
- การแยกความกังวล การใช้งานบริการจริงแยกจากรหัสการขนส่ง
- การใช้ประเภทห้องสมุดมาตรฐาน Go ทำให้ง่ายต่อการติดต่อกับรหัสภายนอก
ในบทความนี้ ฉันจะสร้างแอปพลิเคชันและแนะนำคุณตลอดขั้นตอนของวงจรการพัฒนา API แอปพลิเคชันจัดการรายละเอียดเกี่ยวกับลูกค้า เช่น ชื่อ ที่อยู่ หมายเลขโทรศัพท์ โซเชียลมีเดีย ฯลฯ ในท้ายที่สุด เราจะพยายามขยายและเพิ่มคุณสมบัติใหม่เพื่อใช้วงจรการพัฒนา
เริ่มกันเลย!
การเตรียมพื้นที่พัฒนาของคุณ
ขั้นตอนแรกของเราคือการเริ่มต้นที่เก็บและเปิดใช้งานการสนับสนุนโมดูล Go:
mkdir -p clients/design cd clients go mod init clients
ในท้ายที่สุด โครงสร้าง repo ของคุณควรเป็นดังนี้:
$ tree . ├── design └── go.mod
การออกแบบ API ของคุณ
แหล่งที่มาของความจริงสำหรับ API ของคุณคือคำจำกัดความการออกแบบของคุณ ตามที่ระบุไว้ในเอกสาร "Goa ช่วยให้คุณคิดเกี่ยวกับ API ของคุณโดยไม่คำนึงถึงข้อกังวลด้านการใช้งานใดๆ จากนั้นตรวจสอบการออกแบบนั้นกับผู้มีส่วนได้ส่วนเสียทั้งหมดก่อนที่จะเขียนการใช้งาน" ซึ่งหมายความว่าทุกองค์ประกอบของ API ถูกกำหนดไว้ที่นี่ก่อน ก่อนที่โค้ดแอปพลิเคชันจริงจะถูกสร้างขึ้น แต่พอคุย!
เปิดไฟล์ clients/design/design.go
และเพิ่มเนื้อหาด้านล่าง:
/* This is the design file. It contains the API specification, methods, inputs, and outputs using Goa DSL code. The objective is to use this as a single source of truth for the entire API source code. */ package design import ( . "goa.design/goa/v3/dsl" ) // Main API declaration var _ = API("clients", func() { Title("An api for clients") Description("This api manages clients with CRUD operations") Server("clients", func() { Host("localhost", func() { URI("http://localhost:8080/api/v1") }) }) }) // Client Service declaration with two methods and Swagger API specification file var _ = Service("client", func() { Description("The Client service allows access to client members") Method("add", func() { Payload(func() { Field(1, "ClientID", String, "Client ID") Field(2, "ClientName", String, "Client ID") Required("ClientID", "ClientName") }) Result(Empty) Error("not_found", NotFound, "Client not found") HTTP(func() { POST("/api/v1/client/{ClientID}") Response(StatusCreated) }) }) Method("get", func() { Payload(func() { Field(1, "ClientID", String, "Client ID") Required("ClientID") }) Result(ClientManagement) Error("not_found", NotFound, "Client not found") HTTP(func() { GET("/api/v1/client/{ClientID}") Response(StatusOK) }) }) Method("show", func() { Result(CollectionOf(ClientManagement)) HTTP(func() { GET("/api/v1/client") Response(StatusOK) }) }) Files("/openapi.json", "./gen/http/openapi.json") }) // ClientManagement is a custom ResultType used to configure views for our custom type var ClientManagement = ResultType("application/vnd.client", func() { Description("A ClientManagement type describes a Client of company.") Reference(Client) TypeName("ClientManagement") Attributes(func() { Attribute("ClientID", String, "ID is the unique id of the Client.", func() { Example("ABCDEF12356890") }) Field(2, "ClientName") }) View("default", func() { Attribute("ClientID") Attribute("ClientName") }) Required("ClientID") }) // Client is the custom type for clients in our database var Client = Type("Client", func() { Description("Client describes a customer of company.") Attribute("ClientID", String, "ID is the unique id of the Client Member.", func() { Example("ABCDEF12356890") }) Attribute("ClientName", String, "Name of the Client", func() { Example("John Doe Limited") }) Required("ClientID", "ClientName") }) // NotFound is a custom type where we add the queried field in the response var NotFound = Type("NotFound", func() { Description("NotFound is the type returned when " + "the requested data that does not exist.") Attribute("message", String, "Message of error", func() { Example("Client ABCDEF12356890 not found") }) Field(2, "id", String, "ID of missing data") Required("message", "id") })
สิ่งแรกที่คุณสังเกตได้คือ DSL ด้านบนคือชุดของฟังก์ชัน Go ที่สามารถประกอบขึ้นเพื่ออธิบาย API ของบริการระยะไกลได้ ฟังก์ชันประกอบด้วยการใช้อาร์กิวเมนต์ของฟังก์ชันที่ไม่ระบุชื่อ ในฟังก์ชัน DSL เรามีชุดย่อยของฟังก์ชันที่ไม่ควรจะปรากฏในฟังก์ชันอื่นๆ ซึ่งเราเรียกว่า DSL ระดับบนสุด ด้านล่างนี้ คุณมีชุดฟังก์ชัน DSL และโครงสร้างบางส่วน:
ดังนั้น ในการออกแบบเริ่มต้นของเรา DSL ระดับบนสุดของ API ที่อธิบาย API ของลูกค้าของเรา บริการ DSL ระดับบนสุดหนึ่งบริการที่อธิบายบริการ API หลัก clients
และการให้บริการไฟล์ API swagger และ DSL ระดับบนสุดสองประเภทสำหรับการอธิบาย ประเภทมุมมองออบเจ็กต์ที่ใช้ในเพย์โหลดการขนส่ง
ฟังก์ชัน API
เป็น DSL ระดับบนสุดที่เป็นตัวเลือกซึ่งแสดงรายการคุณสมบัติส่วนกลางของ API เช่น ชื่อ คำอธิบาย และเซิร์ฟเวอร์อย่างน้อยหนึ่งเซิร์ฟเวอร์ที่อาจเปิดเผยชุดบริการต่างๆ ในกรณีของเรา เซิร์ฟเวอร์เดียวก็เพียงพอแล้ว แต่คุณยังสามารถให้บริการที่แตกต่างกันในระดับต่างๆ เช่น การพัฒนา การทดสอบ และการผลิต เป็นต้น
ฟังก์ชัน Service
กำหนดกลุ่มของวิธีการที่อาจแมปกับทรัพยากรในการขนส่ง บริการอาจกำหนดการตอบสนองข้อผิดพลาดทั่วไป วิธีการบริการอธิบายโดยใช้ Method
การ ฟังก์ชันนี้กำหนดวิธี payload (อินพุต) และประเภทผลลัพธ์ (เอาต์พุต) หากคุณละเว้นเพย์โหลดหรือประเภทผลลัพธ์ ระบบจะใช้ประเภทว่างในตัว ซึ่งแมปกับเนื้อหาว่างใน HTTP
สุดท้าย ฟังก์ชัน Type
หรือ ResultType
จะกำหนดประเภทที่ผู้ใช้กำหนด ความแตกต่างที่สำคัญคือประเภทผลลัพธ์จะกำหนดชุดของ "มุมมอง" ด้วย
ในตัวอย่างของเรา เราได้อธิบาย API และอธิบายว่าควรให้บริการอย่างไร และเราได้สร้างสิ่งต่อไปนี้ด้วย:
- บริการที่เรียกว่า
clients
- สามวิธี:
add
(สำหรับสร้างไคลเอนต์เดียว)get
(สำหรับการดึงลูกค้าหนึ่งราย) และshow
(สำหรับการแสดงรายการไคลเอนต์ทั้งหมด) - ประเภทที่กำหนดเองของเรา ซึ่งจะมีประโยชน์เมื่อเรารวมเข้ากับฐานข้อมูลและประเภทข้อผิดพลาดที่กำหนดเอง
เมื่ออธิบายแอปพลิเคชันของเราแล้ว เราก็สามารถสร้างรหัสสำเร็จรูปได้ คำสั่งต่อไปนี้ใช้เส้นทางการนำเข้าแพ็คเกจการออกแบบเป็นอาร์กิวเมนต์ นอกจากนี้ยังยอมรับพาธไปยังไดเร็กทอรีเอาต์พุตเป็นแฟล็กทางเลือก:
goa gen clients/design
คำสั่งจะแสดงชื่อไฟล์ที่สร้างขึ้น ในนั้นไดเร็กทอรี gen
มีไดเร็กทอรีย่อยชื่อแอปพลิเคชันซึ่งมีรหัสบริการที่ไม่ขึ้นกับการขนส่ง ไดเรกทอรีย่อย http
อธิบายการขนส่ง HTTP (เรามีรหัสเซิร์ฟเวอร์และไคลเอนต์พร้อมตรรกะในการเข้ารหัสและถอดรหัสคำขอและการตอบกลับ และรหัส CLI เพื่อสร้างคำขอ HTTP จากบรรทัดคำสั่ง) นอกจากนี้ยังมีไฟล์ข้อกำหนด Open API 2.0 ในรูปแบบ JSON และ YAML
คุณสามารถคัดลอกเนื้อหาของไฟล์ swagger และวางลงในโปรแกรมแก้ไข Swagger ออนไลน์ (เช่นที่ swagger.io) เพื่อแสดงเอกสารข้อกำหนด API ของคุณ รองรับทั้งรูปแบบ YAML และ JSON
ตอนนี้เราพร้อมแล้วสำหรับขั้นตอนต่อไปในวงจรชีวิตการพัฒนา
การนำ API ของคุณไปใช้
หลังจากที่สร้างรหัสต้นแบบแล้ว ก็ถึงเวลาเพิ่มตรรกะทางธุรกิจบางอย่างลงไป ณ จุดนี้ นี่คือลักษณะของโค้ดของคุณควรมีลักษณะดังนี้:
ที่ทุกไฟล์ด้านบนได้รับการดูแลรักษาและอัปเดตโดย Goa ทุกครั้งที่เราเรียกใช้ CLI ดังนั้น เมื่อสถาปัตยกรรมพัฒนาขึ้น การออกแบบของคุณจะเป็นไปตามวิวัฒนาการ และซอร์สโค้ดของคุณก็เช่นกัน ในการปรับใช้แอปพลิเคชัน เราดำเนินการคำสั่งด้านล่าง (จะสร้างการใช้งานพื้นฐานของบริการพร้อมกับไฟล์เซิร์ฟเวอร์ที่สร้างได้ซึ่งหมุน goroutines เพื่อเริ่มต้นเซิร์ฟเวอร์ HTTP และไฟล์ไคลเอนต์ที่ส่งคำขอไปยังเซิร์ฟเวอร์นั้นได้):
goa example clients/design
สิ่งนี้จะสร้างโฟลเดอร์ cmd ที่มีทั้งเซิร์ฟเวอร์และไคลเอนต์ที่สร้างได้ จะมีแอปพลิเคชันของคุณและไฟล์เหล่านั้นคือไฟล์ที่คุณควรเก็บรักษาไว้หลังจากที่ Goa สร้างมันขึ้นมาในครั้งแรก
เอกสารประกอบของ Goa ทำให้ชัดเจนว่า: "คำสั่งนี้สร้างจุดเริ่มต้นสำหรับบริการเพื่อช่วยในการพัฒนาบูตสแตรป โดยเฉพาะอย่างยิ่ง คำสั่งนี้ไม่ได้หมายถึงการเรียกใช้ซ้ำเมื่อการออกแบบเปลี่ยนไป"
ตอนนี้รหัสของคุณจะมีลักษณะดังนี้:
โดยที่ client.go
เป็นไฟล์ตัวอย่างที่มีการใช้งานจำลองทั้งวิธี get
และ show
มาเพิ่มตรรกะทางธุรกิจกันเถอะ!
เพื่อความง่าย เราจะใช้ SQLite แทนฐานข้อมูลในหน่วยความจำและ Gorm เป็น ORM ของเรา สร้างไฟล์ sqlite.go
และเพิ่มเนื้อหาด้านล่าง - ซึ่งจะเพิ่มตรรกะของฐานข้อมูลเพื่อสร้างระเบียนในฐานข้อมูลและแสดงรายการหนึ่งและ/หรือหลายแถวจากฐานข้อมูล:
package clients import ( "clients/gen/client" "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/sqlite" ) var db *gorm.DB var err error type Client *client.ClientManagement // InitDB is the function that starts a database file and table structures // if not created then returns db object for next functions func InitDB() *gorm.DB { // Opening file db, err := gorm.Open("sqlite3", "./data.db") // Display SQL queries db.LogMode(true) // Error if err != nil { panic(err) } // Creating the table if it doesn't exist var TableStruct = client.ClientManagement{} if !db.HasTable(TableStruct) { db.CreateTable(TableStruct) db.Set("gorm:table_options", "ENGINE=InnoDB").CreateTable(TableStruct) } return db } // GetClient retrieves one client by its ID func GetClient(clientID string) (client.ClientManagement, error) { db := InitDB() defer db.Close() var clients client.ClientManagement db.Where("client_id = ?", clientID).First(&clients) return clients, err } // CreateClient created a client row in DB func CreateClient(client Client) error { db := InitDB() defer db.Close() err := db.Create(&client).Error return err } // ListClients retrieves the clients stored in Database func ListClients() (client.ClientManagementCollection, error) { db := InitDB() defer db.Close() var clients client.ClientManagementCollection err := db.Find(&clients).Error return clients, err }
จากนั้น เราแก้ไข client.go เพื่ออัปเดตวิธีการทั้งหมดใน Client Service ใช้งานการเรียกฐานข้อมูล และสร้างการตอบสนอง API:
// Add implements add. func (s *clientsrvc) Add(ctx context.Context, p *client.AddPayload) (err error) { s.logger.Print("client.add started") newClient := client.ClientManagement{ ClientID: p.ClientID, ClientName: p.ClientName, } err = CreateClient(&newClient) if err != nil { s.logger.Print("An error occurred...") s.logger.Print(err) return } s.logger.Print("client.add completed") return } // Get implements get. func (s *clientsrvc) Get(ctx context.Context, p *client.GetPayload) (res *client.ClientManagement, err error) { s.logger.Print("client.get started") result, err := GetClient(p.ClientID) if err != nil { s.logger.Print("An error occurred...") s.logger.Print(err) return } s.logger.Print("client.get completed") return &result, err } // Show implements show. func (s *clientsrvc) Show(ctx context.Context) (res client.ClientManagementCollection, err error) { s.logger.Print("client.show started") res, err = ListClients() if err != nil { s.logger.Print("An error occurred...") s.logger.Print(err) return } s.logger.Print("client.show completed") return }
แอปพลิเคชันแรกของเราพร้อมที่จะรวบรวม รันคำสั่งต่อไปนี้เพื่อสร้างเซิร์ฟเวอร์และไคลเอนต์ไบนารี:
go build ./cmd/clients go build ./cmd/clients-cli
ในการรันเซิร์ฟเวอร์ เพียงแค่เรียกใช้ ./ ./clients
ปล่อยให้มันทำงานไปก่อน คุณควรเห็นมันทำงานสำเร็จ ดังต่อไปนี้:
$ ./clients [clients] 00:00:01 HTTP "Add" mounted on POST /api/v1/client/{ClientID} [clients] 00:00:01 HTTP "Get" mounted on GET /api/v1/client/{ClientID} [clients] 00:00:01 HTTP "Show" mounted on GET /api/v1/client [clients] 00:00:01 HTTP "./gen/http/openapi.json" mounted on GET /openapi.json [clients] 00:00:01 HTTP server listening on "localhost:8080"
เราพร้อมที่จะทำการทดสอบในแอปพลิเคชันของเรา ลองใช้วิธีการทั้งหมดโดยใช้ cli:
$ ./clients-cli client add --body '{"ClientName": "Cool Company"}' \ --client-id "1" $ ./clients-cli client get --client-id "1" { "ClientID": "1", "ClientName": "Cool Company" } $ ./clients-cli client show [ { "ClientID": "1", "ClientName": "Cool Company" } ]
หากคุณได้รับข้อผิดพลาดใดๆ ให้ตรวจสอบบันทึกของเซิร์ฟเวอร์เพื่อให้แน่ใจว่าลอจิก SQLite ORM นั้นดี และคุณไม่ได้พบกับข้อผิดพลาดของฐานข้อมูลใดๆ เช่น ฐานข้อมูลที่ไม่ได้เริ่มต้น หรือการสืบค้นที่ไม่มีแถวกลับ
ขยาย API ของคุณ
เฟรมเวิร์กสนับสนุนการพัฒนาปลั๊กอินเพื่อขยาย API ของคุณและเพิ่มคุณสมบัติอื่นๆ ได้อย่างง่ายดาย Goa มีพื้นที่เก็บข้อมูลสำหรับปลั๊กอินที่สร้างโดยชุมชน
ดังที่ฉันอธิบายไว้ก่อนหน้านี้ ในฐานะที่เป็นส่วนหนึ่งของวงจรการพัฒนา เราสามารถพึ่งพาชุดเครื่องมือเพื่อขยายแอปพลิเคชันของเราโดยกลับไปที่ข้อกำหนดการออกแบบ อัปเดต และรีเฟรชโค้ดที่สร้างขึ้น มาดูกันว่าปลั๊กอินสามารถช่วยได้อย่างไรโดยการเพิ่ม CORS และการรับรองความถูกต้องให้กับ API
อัปเดตไฟล์ clients/design/design.go
ไปที่เนื้อหาด้านล่าง:
/* This is the design file. It contains the API specification, methods, inputs, and outputs using Goa DSL code. The objective is to use this as a single source of truth for the entire API source code. */ package design import ( . "goa.design/goa/v3/dsl" cors "goa.design/plugins/v3/cors/dsl" ) // Main API declaration var _ = API("clients", func() { Title("An api for clients") Description("This api manages clients with CRUD operations") cors.Origin("/.*localhost.*/", func() { cors.Headers("X-Authorization", "X-Time", "X-Api-Version", "Content-Type", "Origin", "Authorization") cors.Methods("GET", "POST", "OPTIONS") cors.Expose("Content-Type", "Origin") cors.MaxAge(100) cors.Credentials() }) Server("clients", func() { Host("localhost", func() { URI("http://localhost:8080/api/v1") }) }) }) // Client Service declaration with two methods and Swagger API specification file var _ = Service("client", func() { Description("The Client service allows access to client members") Error("unauthorized", String, "Credentials are invalid") HTTP(func() { Response("unauthorized", StatusUnauthorized) }) Method("add", func() { Payload(func() { TokenField(1, "token", String, func() { Description("JWT used for authentication") }) Field(2, "ClientID", String, "Client ID") Field(3, "ClientName", String, "Client ID") Field(4, "ContactName", String, "Contact Name") Field(5, "ContactEmail", String, "Contact Email") Field(6, "ContactMobile", Int, "Contact Mobile Number") Required("token", "ClientID", "ClientName", "ContactName", "ContactEmail", "ContactMobile") }) Security(JWTAuth, func() { Scope("api:write") }) Result(Empty) Error("invalid-scopes", String, "Token scopes are invalid") Error("not_found", NotFound, "Client not found") HTTP(func() { POST("/api/v1/client/{ClientID}") Header("token:X-Authorization") Response("invalid-scopes", StatusForbidden) Response(StatusCreated) }) }) Method("get", func() { Payload(func() { TokenField(1, "token", String, func() { Description("JWT used for authentication") }) Field(2, "ClientID", String, "Client ID") Required("token", "ClientID") }) Security(JWTAuth, func() { Scope("api:read") }) Result(ClientManagement) Error("invalid-scopes", String, "Token scopes are invalid") Error("not_found", NotFound, "Client not found") HTTP(func() { GET("/api/v1/client/{ClientID}") Header("token:X-Authorization") Response("invalid-scopes", StatusForbidden) Response(StatusOK) }) }) Method("show", func() { Payload(func() { TokenField(1, "token", String, func() { Description("JWT used for authentication") }) Required("token") }) Security(JWTAuth, func() { Scope("api:read") }) Result(CollectionOf(ClientManagement)) Error("invalid-scopes", String, "Token scopes are invalid") HTTP(func() { GET("/api/v1/client") Header("token:X-Authorization") Response("invalid-scopes", StatusForbidden) Response(StatusOK) }) }) Files("/openapi.json", "./gen/http/openapi.json") }) // ClientManagement is a custom ResultType used to // configure views for our custom type var ClientManagement = ResultType("application/vnd.client", func() { Description("A ClientManagement type describes a Client of company.") Reference(Client) TypeName("ClientManagement") Attributes(func() { Attribute("ClientID", String, "ID is the unique id of the Client.", func() { Example("ABCDEF12356890") }) Field(2, "ClientName") Attribute("ContactName", String, "Name of the Contact.", func() { Example("John Doe") }) Field(4, "ContactEmail") Field(5, "ContactMobile") }) View("default", func() { Attribute("ClientID") Attribute("ClientName") Attribute("ContactName") Attribute("ContactEmail") Attribute("ContactMobile") }) Required("ClientID") }) // Client is the custom type for clients in our database var Client = Type("Client", func() { Description("Client describes a customer of company.") Attribute("ClientID", String, "ID is the unique id of the Client Member.", func() { Example("ABCDEF12356890") }) Attribute("ClientName", String, "Name of the Client", func() { Example("John Doe Limited") }) Attribute("ContactName", String, "Name of the Client Contact.", func() { Example("John Doe") }) Attribute("ContactEmail", String, "Email of the Client Contact", func() { Example("[email protected]") }) Attribute("ContactMobile", Int, "Mobile number of the Client Contact", func() { Example(12365474235) }) Required("ClientID", "ClientName", "ContactName", "ContactEmail", "ContactMobile") }) // NotFound is a custom type where we add the queried field in the response var NotFound = Type("NotFound", func() { Description("NotFound is the type returned " + "when the requested data that does not exist.") Attribute("message", String, "Message of error", func() { Example("Client ABCDEF12356890 not found") }) Field(2, "id", String, "ID of missing data") Required("message", "id") }) // Creds is a custom type for replying Tokens var Creds = Type("Creds", func() { Field(1, "jwt", String, "JWT token", func() { Example("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9" + "lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHD" + "cEfxjoYZgeFONFh7HgQ") }) Required("jwt") }) // JWTAuth is the JWTSecurity DSL function for adding JWT support in the API var JWTAuth = JWTSecurity("jwt", func() { Description(`Secures endpoint by requiring a valid JWT token retrieved via the signin endpoint. Supports scopes "api:read" and "api:write".`) Scope("api:read", "Read-only access") Scope("api:write", "Read and write access") }) // BasicAuth is the BasicAuth DSL function for // adding basic auth support in the API var BasicAuth = BasicAuthSecurity("basic", func() { Description("Basic authentication used to " + "authenticate security principal during signin") Scope("api:read", "Read-only access") }) // Signin Service is the service used to authenticate users and assign JWT tokens for their sessions var _ = Service("signin", func() { Description("The Signin service authenticates users and validate tokens") Error("unauthorized", String, "Credentials are invalid") HTTP(func() { Response("unauthorized", StatusUnauthorized) }) Method("authenticate", func() { Description("Creates a valid JWT") Security(BasicAuth) Payload(func() { Description("Credentials used to authenticate to retrieve JWT token") UsernameField(1, "username", String, "Username used to perform signin", func() { Example("user") }) PasswordField(2, "password", String, "Password used to perform signin", func() { Example("password") }) Required("username", "password") }) Result(Creds) HTTP(func() { POST("/signin/authenticate") Response(StatusOK) }) }) })
คุณสามารถสังเกตเห็นความแตกต่างที่สำคัญสองประการในการออกแบบใหม่ เรากำหนดขอบเขตความปลอดภัยในบริการ client
เพื่อให้เราสามารถตรวจสอบว่าผู้ใช้ได้รับอนุญาตให้เรียกใช้บริการหรือไม่ และเรากำหนดบริการที่สองที่เรียกว่า signin
ซึ่งเราจะใช้เพื่อตรวจสอบสิทธิ์ผู้ใช้และสร้าง JSON Web Tokens (JWT) ซึ่ง ฝ่ายบริการ client
จะใช้เพื่ออนุญาตการโทร เรายังได้เพิ่มฟิลด์เพิ่มเติมให้กับประเภทไคลเอนต์ที่กำหนดเองของเรา นี่เป็นกรณีทั่วไปในการพัฒนา API—ความจำเป็นในการปรับรูปร่างหรือปรับโครงสร้างข้อมูล

ในการออกแบบ การเปลี่ยนแปลงเหล่านี้อาจฟังดูง่าย แต่เมื่อพิจารณาถึงการเปลี่ยนแปลงแล้ว มีคุณลักษณะเพียงเล็กน้อยที่จำเป็นเพื่อให้บรรลุตามที่อธิบายไว้ในการออกแบบ ยกตัวอย่าง แผนผังสถาปัตยกรรมสำหรับการรับรองความถูกต้องและการอนุญาตโดยใช้วิธีการ API ของเรา:
นี่เป็นคุณสมบัติใหม่ทั้งหมดที่โค้ดของเรายังไม่มี อีกครั้งที่ Goa เพิ่มมูลค่าให้กับความพยายามในการพัฒนาของคุณมากขึ้น มาปรับใช้คุณลักษณะเหล่านี้ที่ฝั่งการขนส่งโดยสร้างซอร์สโค้ดใหม่อีกครั้งด้วยคำสั่งด้านล่าง:
goa gen clients/design
ณ จุดนี้ หากคุณบังเอิญใช้ Git คุณจะสังเกตเห็นว่ามีไฟล์ใหม่ปรากฏอยู่ โดยไฟล์อื่นๆ จะแสดงว่าอัปเดตแล้ว นี่เป็นเพราะ Goa รีเฟรชโค้ดสำเร็จรูปตามลำดับโดยที่เราไม่ต้องดำเนินการใดๆ
ตอนนี้ เราต้องติดตั้งโค้ดฝั่งบริการ ในแอปพลิเคชันในโลกแห่งความเป็นจริง คุณจะต้องอัปเดตแอปพลิเคชันด้วยตนเองหลังจากอัปเดตแหล่งที่มาเพื่อให้สอดคล้องกับการเปลี่ยนแปลงการออกแบบทั้งหมด นี่คือวิธีที่ Goa แนะนำให้เราดำเนินการต่อ แต่เพื่อความกระชับ ฉันจะลบและสร้างแอปพลิเคชันตัวอย่างขึ้นใหม่เพื่อให้เราไปถึงจุดนั้นได้เร็วขึ้น เรียกใช้คำสั่งด้านล่างเพื่อลบแอปพลิเคชันตัวอย่างและสร้างใหม่:
rm -rf cmd client.go goa example clients/design
ด้วยเหตุนี้ รหัสของคุณควรมีลักษณะดังนี้:
เราสามารถเห็นไฟล์ใหม่หนึ่งไฟล์ในแอปพลิเคชันตัวอย่างของเรา: signin.go
ซึ่งมีตรรกะบริการการลงชื่อเข้าใช้ อย่างไรก็ตาม เราจะเห็นได้ว่า client.go
ได้รับการอัปเดตด้วยฟังก์ชัน JWTAuth สำหรับการตรวจสอบโทเค็น ซึ่งตรงกับสิ่งที่เราได้เขียนไว้ในการออกแบบ ดังนั้นทุกการเรียกใช้เมธอดใดๆ ในไคลเอนต์จะถูกสกัดกั้นเพื่อตรวจสอบความถูกต้องของโทเค็นและส่งต่อก็ต่อเมื่อได้รับอนุญาตจากโทเค็นที่ถูกต้องและขอบเขตที่ถูกต้อง
ดังนั้น เราจะอัปเดตวิธีการในบริการการลงชื่อเข้าใช้ของเราภายใน signin.go
เพื่อเพิ่มตรรกะในการสร้างโทเค็นที่ API จะสร้างสำหรับผู้ใช้ที่ผ่านการตรวจสอบสิทธิ์ คัดลอกและวางบริบทต่อไปนี้ลงใน signin.go
:
package clients import ( signin "clients/gen/signin" "context" "log" "time" jwt "github.com/dgrijalva/jwt-go" "goa.design/goa/v3/security" ) // signin service example implementation. // The example methods log the requests and return zero values. type signinsrvc struct { logger *log.Logger } // NewSignin returns the signin service implementation. func NewSignin(logger *log.Logger) signin.Service { return &signinsrvc{logger} } // BasicAuth implements the authorization logic for service "signin" for the // "basic" security scheme. func (s *signinsrvc) BasicAuth(ctx context.Context, user, pass string, scheme *security.BasicScheme) (context.Context, error) { if user != "gopher" && pass != "academy" { return ctx, signin. Unauthorized("invalid username and password combination") } return ctx, nil } // Creates a valid JWT func (s *signinsrvc) Authenticate(ctx context.Context, p *signin.AuthenticatePayload) (res *signin.Creds, err error) { // create JWT token token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "nbf": time.Date(2015, 10, 10, 12, 0, 0, 0, time.UTC).Unix(), "iat": time.Now().Unix(), "exp": time.Now().Add(time.Duration(9) * time.Minute).Unix(), "scopes": []string{"api:read", "api:write"}, }) s.logger.Printf("user '%s' logged in", p.Username) // note that if "SignedString" returns an error then it is returned as // an internal error to the client t, err := token.SignedString(Key) if err != nil { return nil, err } res = &signin.Creds{ JWT: t, } return }
สุดท้าย เนื่องจากเราได้เพิ่มฟิลด์ลงในประเภทที่กำหนดเองมากขึ้น เราจึงต้องอัปเดตวิธีการเพิ่มบนบริการไคลเอ็นต์ใน client.go
เพื่อให้สอดคล้องกับการเปลี่ยนแปลงดังกล่าว คัดลอกและวางข้อมูลต่อไปนี้เพื่ออัปเดต client.go
ของคุณ:
package clients import ( client "clients/gen/client" "context" "log" jwt "github.com/dgrijalva/jwt-go" "goa.design/goa/v3/security" ) var ( // Key is the key used in JWT authentication Key = []byte("secret") ) // client service example implementation. // The example methods log the requests and return zero values. type clientsrvc struct { logger *log.Logger } // NewClient returns the client service implementation. func NewClient(logger *log.Logger) client.Service { return &clientsrvc{logger} } // JWTAuth implements the authorization logic for service "client" for the // "jwt" security scheme. func (s *clientsrvc) JWTAuth(ctx context.Context, token string, scheme *security.JWTScheme) (context.Context, error) { claims := make(jwt.MapClaims) // authorize request // 1. parse JWT token, token key is hardcoded to "secret" in this example _, err := jwt.ParseWithClaims(token, claims, func(_ *jwt.Token) (interface{}, error) { return Key, nil }) if err != nil { s.logger.Print("Unable to obtain claim from token, it's invalid") return ctx, client.Unauthorized("invalid token") } s.logger.Print("claims retrieved, validating against scope") s.logger.Print(claims) // 2. validate provided "scopes" claim if claims["scopes"] == nil { s.logger.Print("Unable to get scope since the scope is empty") return ctx, client.InvalidScopes("invalid scopes in token") } scopes, ok := claims["scopes"].([]interface{}) if !ok { s.logger.Print("An error occurred when retrieving the scopes") s.logger.Print(ok) return ctx, client.InvalidScopes("invalid scopes in token") } scopesInToken := make([]string, len(scopes)) for _, scp := range scopes { scopesInToken = append(scopesInToken, scp.(string)) } if err := scheme.Validate(scopesInToken); err != nil { s.logger.Print("Unable to parse token, check error below") return ctx, client.InvalidScopes(err.Error()) } return ctx, nil } // Add implements add. func (s *clientsrvc) Add(ctx context.Context, p *client.AddPayload) (err error) { s.logger.Print("client.add started") newClient := client.ClientManagement{ ClientID: p.ClientID, ClientName: p.ClientName, ContactName: p.ContactName, ContactEmail: p.ContactEmail, ContactMobile: p.ContactMobile, } err = CreateClient(&newClient) if err != nil { s.logger.Print("An error occurred...") s.logger.Print(err) return } s.logger.Print("client.add completed") return } // Get implements get. func (s *clientsrvc) Get(ctx context.Context, p *client.GetPayload) (res *client.ClientManagement, err error) { s.logger.Print("client.get started") result, err := GetClient(p.ClientID) if err != nil { s.logger.Print("An error occurred...") s.logger.Print(err) return } s.logger.Print("client.get completed") return &result, err } // Show implements show. func (s *clientsrvc) Show(ctx context.Context, p *client.ShowPayload) (res client.ClientManagementCollection, err error) { s.logger.Print("client.show started") res, err = ListClients() if err != nil { s.logger.Print("An error occurred...") s.logger.Print(err) return } s.logger.Print("client.show completed") return }
และนั่นแหล่ะ! มาคอมไพล์แอปพลิเคชันใหม่และทดสอบอีกครั้ง รันคำสั่งด้านล่างเพื่อลบไบนารีเก่าและคอมไพล์ใหม่:
rm -f clients clients-cli go build ./cmd/clients go build ./cmd/clients-cli
เรียกใช้ ./clients
อีกครั้งแล้วปล่อยให้ทำงานต่อไป คุณควรเห็นมันทำงานสำเร็จ แต่คราวนี้ ด้วยวิธีการใหม่ที่ใช้:
$ ./clients [clients] 00:00:01 HTTP "Add" mounted on POST /api/v1/client/{ClientID} [clients] 00:00:01 HTTP "Get" mounted on GET /api/v1/client/{ClientID} [clients] 00:00:01 HTTP "Show" mounted on GET /api/v1/client [clients] 00:00:01 HTTP "CORS" mounted on OPTIONS /api/v1/client/{ClientID} [clients] 00:00:01 HTTP "CORS" mounted on OPTIONS /api/v1/client [clients] 00:00:01 HTTP "CORS" mounted on OPTIONS /openapi.json [clients] 00:00:01 HTTP "./gen/http/openapi.json" mounted on GET /openapi.json [clients] 00:00:01 HTTP "Authenticate" mounted on POST /signin/authenticate [clients] 00:00:01 HTTP "CORS" mounted on OPTIONS /signin/authenticate [clients] 00:00:01 HTTP server listening on "localhost:8080"
ในการทดสอบ ให้เรียกใช้เมธอด API ทั้งหมดโดยใช้ cli โดยสังเกตว่าเราใช้ข้อมูลประจำตัวแบบฮาร์ดโค้ด:
$ ./clients-cli signin authenticate \ --username "gopher" --password "academy" { "JWT": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\ eyJleHAiOjE1NzcyMTQxMjEsImlhdCI6MTU3NzIxMzU4 \ MSwibmJmIjoxNDQ0NDc4NDAwLCJzY29wZXMiOlsiY \ XBpOnJlYWQiLCJhcGk6d3JpdGUiXX0.\ tva_E3xbzur_W56pjzIll_pdFmnwmF083TKemSHQkSw" } $ ./clients-cli client add --body \ '{"ClientName": "Cool Company", \ "ContactName": "Jane Masters", \ "ContactEmail": "[email protected]", \ "ContactMobile": 13426547654 }' \ --client-id "1" --token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\ eyJleHAiOjE1NzcyMTQxMjEsImlhdCI6MTU3NzIxMzU4MSwibmJmI\ joxNDQ0NDc4NDAwLCJzY29wZXMiOlsiYXBpOnJlYWQiLCJhcGk6d3JpdGUiXX0.\ tva_E3xbzur_W56pjzIll_pdFmnwmF083TKemSHQkSw" $ ./clients-cli client get --client-id "1" \ --token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\ eyJleHAiOjE1NzcyMTQxMjEsImlhdCI6MTU3NzIxMzU4MSwibmJmI\ joxNDQ0NDc4NDAwLCJzY29wZXMiOlsiYXBpOnJlYWQiLCJhcGk6d3JpdGUiXX0.\ tva_E3xbzur_W56pjzIll_pdFmnwmF083TKemSHQkSw" { "ClientID": "1", "ClientName": "Cool Company", "ContactName": "Jane Masters", "ContactEmail": "[email protected]", "ContactMobile": 13426547654 } $ ./clients-cli client show \ --token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\ eyJleHAiOjE1NzcyMTQxMjEsImlhdCI6MTU3NzIxMzU4MSwibmJmI\ joxNDQ0NDc4NDAwLCJzY29wZXMiOlsiYXBpOnJlYWQiLCJhcGk6d3JpdGUiXX0.\ tva_E3xbzur_W56pjzIll_pdFmnwmF083TKemSHQkSw" [ { "ClientID": "1", "ClientName": "Cool Company", "ContactName": "Jane Masters", "ContactEmail": "[email protected]", "ContactMobile": 13426547654 } ]
และเราไปกันเลย! เรามีแอปพลิเคชั่นที่เรียบง่ายพร้อมการรับรองความถูกต้อง การอนุญาตขอบเขต และห้องสำหรับการเติบโตเชิงวิวัฒนาการ หลังจากนี้ คุณสามารถพัฒนากลยุทธ์การรับรองความถูกต้องของคุณเองโดยใช้บริการคลาวด์หรือผู้ให้บริการข้อมูลประจำตัวอื่นๆ ที่คุณเลือก คุณยังสามารถสร้างปลั๊กอินสำหรับฐานข้อมูลหรือระบบการส่งข้อความที่คุณต้องการ หรือแม้แต่รวมเข้ากับ API อื่นๆ ได้อย่างง่ายดาย
ตรวจสอบโครงการ GitHub ของ Goa เพื่อดูปลั๊กอินเพิ่มเติม ตัวอย่าง (แสดงความสามารถเฉพาะของกรอบงาน) และทรัพยากรที่มีประโยชน์อื่นๆ
แค่นั้นแหละสำหรับวันนี้ ฉันหวังว่าคุณจะสนุกกับการเล่นกับ Goa และอ่านบทความนี้ หากคุณมีข้อเสนอแนะเกี่ยวกับเนื้อหา โปรดติดต่อ GitHub, Twitter หรือ LinkedIn
นอกจากนี้เรายังออกไปเที่ยวในช่อง #goa บน Gophers Slack เข้ามาทักทายกัน!
สำหรับข้อมูลเพิ่มเติมเกี่ยวกับ Golang โปรดดูที่ Go Programming Language: An Introductory Golang Tutorial และ Well-structured Logic: A Golang OOP Tutorial