服務計算 - 5 | GraphQL簡單web服務與客戶端開發

概述

利用 web 客戶端調用遠端服務是服務開發本實驗的重要內容。其中,要點創建 API First 的開發理念,實現先後端分離,使得團隊協做變得更有效率。html

任務目標

  1. 選擇合適的 API 風格,實現從接口或資源(領域)建模,到 API 設計的過程
  2. 使用 API 工具,編制 API 描述文件,編譯生成服務器、客戶端原型
  3. 使用 Github 創建一個組織,經過 API 文檔,實現 客戶端項目 與 RESTful 服務項目同步開發
  4. 使用 API 設計工具提供 Mock 服務,兩個團隊獨立測試 API
  5. 使用 travis 測試相關模塊

開發環境選取

  1. API使用GraphQL規範進行設計
  2. 客戶端使用Vue框架
  3. 服務器使用GraphQL官方推薦的生成基於 graphql 的服務器的庫GQLGen進行開發。
  4. 數據庫使用BoltDB實現

GITHUB傳送門


項目實現

項目參照星球大戰API SWAPI編寫vue

API設計

客戶端實現

數據庫實現

服務器實現

服務器實現其實並不難,可是理解GraphQL和GQLGEN這兩個預備工做比較困難,須要大量閱讀。git

GraphQL

GraphQL 是一個用於 API 的查詢語言,是一個使用基於類型系統來執行查詢的服務端運行時(類型系統由你的數據定義)。GraphQL 並無和任何特定數據庫或者存儲引擎綁定,而是依靠你現有的代碼和數據支撐。
與Restful相比,GraphQL不會由複雜的URL,請求的Json按照規範被放在數據中。因爲有完備的規範,使用GraphQL構建服務器時不須要自行對每一個請求進行解析,可使用現成的框架,如GQLGen,按規範編寫Schema後便可生成相應的解析函數,最終只須要本身編寫resolve中的查詢函數便可。無需對每一個數據規定複雜的URL,大大簡化了開發流程。github

GraphQL官網
GraphQL核心概念
GQLGEN樣例

GraphQL只是一個規範,具體使用時必須自行實現解析。這裏能夠用各類開源庫來簡化開發流程。web

GQLGEN

使用GQLGEN首先應該編寫schema.graphql文件,其中按照GraphQL規範,定義了全部結構的內容,以及查詢的方法,在這個項目中沒有用到客戶端更新數據,因此沒有使用Mutation。GQLGEN這個組件會根據schema生成對應的請求路徑解析和請求中GraphQL規則的查詢的解析,而且使用者只須要實現每一個請求的處理函數便可,簡化了開發流程。數據庫

  • type Query中定義了全部的查詢查詢方法,在這個類型中的查詢函數會被GQLGEN自動實現解析,並在resolver.go文件中新建空白查詢函數,而咱們的任務就是編寫該文件中的函數,返回對應的數據。json

    """
    The query root, from which multiple types of requests can be made.
    """
    type Query {
        """
        Look up a specific people by its ID.
        """
        people(
            """
            The ID of the entity.
            """
            id: ID!
        ): People
    
        """
        Look up a specific film by its ID.
        """
        film(
            """
            The ID of the entity.
            """
            id: ID!
        ): Film
        
        """
        Look up a specific starship by its ID.
        """
        starship(
            """
            The ID of the entity.
            """
            id: ID!
        ): Starship
        
        """
        Look up a specific vehicle by its ID.
        """
        vehicle(
            """
            The ID of the entity.
            """
            id: ID!
        ): Vehicle
        
        """
        Look up a specific specie by its ID.
        """
        specie(
            """
            The ID of the entity.
            """
            id: ID!
        ): Specie
        
        """
        Look up a specific planet by its ID.
        """
        planet(
            """
            The ID of the entity.
            """
            id: ID!
        ): Planet
    
        """
        Browse people entities.
        """
        peoples (
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): PeopleConnection!
    
        """
        Browse film entities.
        """
        films (
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): FilmConnection!
    
        """
        Browse starship entities.
        """
        starships (
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): StarshipConnection!
    
        """
        Browse vehicle entities.
        """
        vehicles (
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): VehicleConnection!
    
        """
        Browse specie entities.
        """
        species (
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): SpecieConnection!
    
        """
        Browse planet entities.
        """
        planets (
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): PlanetConnection!
    
        """
        Search for people entities matching the given query.
        """
        peopleSearch (
            """
            The search field for name, in Lucene search syntax.
            """
            search: String!
            
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): PeopleConnection
    
        """
        Search for film entities matching the given query.
        """
        filmsSearch (
            """
            The search field for title, in Lucene search syntax.
            """
            search: String!
            
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): FilmConnection
    
        """
        Search for starship entities matching the given query.
        """
        starshipsSearch (
            """
            The search field for name or model, in Lucene search syntax.
            """
            search: String!
            
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): StarshipConnection
    
        """
        Search for vehicle entities matching the given query.
        """
        vehiclesSearch (
            """
            The search field for name or model, in Lucene search syntax.
            """
            search: String!
            
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): VehicleConnection
    
        """
        Search for specie entities matching the given query.
        """
        speciesSearch (
            """
            The search field for name, in Lucene search syntax.
            """
            search: String!
            
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): SpecieConnection
    
        """
        Search for planet entities matching the given query.
        """
        planetsSearch (
            """
            The search field for name, in Lucene search syntax.
            """
            search: String!
            
            """
            The number of entities in the connection.
            """
            first: Int
    
            """
            The connection follows by.
            """
            after: ID
        ): PlanetConnection
    }
  • 其它部分按照GraphQL規範編寫便可,具體能夠查看項目中的schema.graphql文件。這裏的schema.graphql有些臃腫,能夠經過實現共同屬性的interface來減小定義的工做量。
  • 具體設計參閱API文檔

GQLGEN執行流程

在上述生成的文件中,咱們須要更改的文件主要是resolver.go,在介紹此文件以前,咱們須要瞭解如下gengql生成的graphql的服務的運行過程:後端

  • 首先,修改resolver.go文件下的People()函數:
func (r *queryResolver) People(ctx context.Context, id string) (*People, error) {
    return &people{}, nil    // 替換panic(避免運行過程當中退出,利於咱們觀察執行過程)
}
  • 啓動服務
go run server/server.go

訪問127.0.0.1:8080,並進行一次People查詢:api

圖片描述

解析函數的編寫

  1. 對於普通的經過ID查詢的函數,直接經過數據庫提供的方法查詢對應ID的對象。跨域

    func (r *queryResolver) People(ctx context.Context, id string) (*People, error) {
        err, people := GetPeopleByID(id, nil)
        checkErr(err)
        return people, err
    }
  2. 分頁查詢則須要解析須要的元素數量,起始位置即after遊標在數據庫中的位置,是否有先後頁及當前頁開始和結束位置元素的遊標,用於客戶端在須要的時候獲取先後頁。

    func (r *queryResolver) Peoples(ctx context.Context, first *int, after *string) (PeopleConnection, error) {
         from := -1
         if after != nil {
             b, err := base64.StdEncoding.DecodeString(*after)
             if err != nil {
                 return PeopleConnection{}, err
             }
             i, err := strconv.Atoi(strings.TrimPrefix(string(b), "cursor"))
             if err != nil {
                 return PeopleConnection{}, err
             }
             from = i
         }
         count := 0
         startID := ""
         hasPreviousPage := true
         hasNextPage := true
         // 獲取edges
         edges := []PeopleEdge{}
         db, err := bolt.Open("./data/data.db", 0600, nil)
         CheckErr(err)
         defer db.Close()
         db.View(func(tx *bolt.Tx) error {
             c := tx.Bucket([]byte(peopleBucket)).Cursor()
    
             // 判斷是否還有前向頁
             k, v := c.First()
             if from == -1 || strconv.Itoa(from) == string(k) {
                 startID = string(k)
                 hasPreviousPage = false
             }
    
             if from == -1 {
                 for k, _ := c.First(); k != nil; k, _ = c.Next() {
                     _, people := GetPeopleByID(string(k), db)
                     edges = append(edges, PeopleEdge{
                         Node:   people,
                         Cursor: encodeCursor(string(k)),
                     })
                     count++
                     if count == *first {
                         break
                     }
                 }
             } else {
                 for k, _ := c.First(); k != nil; k, _ = c.Next() {
                     if strconv.Itoa(from) == string(k) {
                         k, _ = c.Next()
                         startID = string(k)
                     }
                     if startID != "" {
                         _, people := GetPeopleByID(string(k), db)
                         edges = append(edges, PeopleEdge{
                             Node:   people,
                             Cursor: encodeCursor(string(k)),
                         })
                         count++
                         if count == *first {
                             break
                         }
                     }
                 }
             }
    
             k, v = c.Next()
             if k == nil && v == nil {
                 hasNextPage = false
             }
             return nil
         })
         if count == 0 {
             return PeopleConnection{}, nil
         }
         // 獲取pageInfo
         pageInfo := PageInfo{
             HasPreviousPage: hasPreviousPage,
             HasNextPage:     hasNextPage,
             StartCursor:     encodeCursor(startID),
             EndCursor:       encodeCursor(edges[count-1].Node.ID),
         }
    
         return PeopleConnection{
             PageInfo:   pageInfo,
             Edges:      edges,
             TotalCount: count,
         }, nil
     }
  3. 其次是基於相關字段的分頁查詢,與普通分頁查詢相似,只是多了一個查詢字段的字符串來限定,獲取對應的頁。

    func (r *queryResolver) PeopleSearch(ctx context.Context, search string, first *int, after *string) (*PeopleConnection, error) {
    
        if strings.HasPrefix(search, "Name:") {
            search = strings.TrimPrefix(search, "Name:")
        } else {
            return &PeopleConnection{}, errors.New("Search content must be ' Name:<People's Name you want to get> ' ")
        }
        from := -1
        if after != nil {
            b, err := base64.StdEncoding.DecodeString(*after)
            if err != nil {
                return &PeopleConnection{}, err
            }
            i, err := strconv.Atoi(strings.TrimPrefix(string(b), "cursor"))
            if err != nil {
                return &PeopleConnection{}, err
            }
            from = i
        }
        count := 0
        hasPreviousPage := false
        hasNextPage := false
        // 獲取edges
        edges := []PeopleEdge{}
        db, err := bolt.Open("./data/data.db", 0600, nil)
        CheckErr(err)
        defer db.Close()
        db.View(func(tx *bolt.Tx) error {
            c := tx.Bucket([]byte(peopleBucket)).Cursor()
            k, _ := c.First()
            // 判斷是否還有前向頁
            if from != -1 {
                for k != nil {
                    _, people := GetPeopleByID(string(k), db)
                    if people.Name == search {
                        hasPreviousPage = true
                    }
                    if strconv.Itoa(from) == string(k) {
                        k, _ = c.Next()
                        break
                    }
                    k, _ = c.Next()
                }
            }
    
            // 添加edge
            for k != nil {
                _, people := GetPeopleByID(string(k), db)
                if people.Name == search {
                    edges = append(edges, PeopleEdge{
                        Node:   people,
                        Cursor: encodeCursor(string(k)),
                    })
                    count++
                }
                k, _ = c.Next()
                if first != nil && count == *first {
                    break
                }
            }
    
            // 判斷是否還有後向頁
            for k != nil {
                _, people := GetPeopleByID(string(k), db)
                if people.Name == search {
                    hasNextPage = true
                    break
                }
                k, _ = c.Next()
            }
            return nil
        })
        if count == 0 {
            return &PeopleConnection{}, nil
        }
        // 獲取pageInfo
        pageInfo := PageInfo{
            StartCursor:     encodeCursor(edges[0].Node.ID),
            EndCursor:       encodeCursor(edges[count-1].Node.ID),
            HasPreviousPage: hasPreviousPage,
            HasNextPage:     hasNextPage,
        }
    
        return &PeopleConnection{
            PageInfo:   pageInfo,
            Edges:      edges,
            TotalCount: count,
        }, nil
    
    }

其餘的查詢函數實現和上述People方法的實現基本相同。

項目結構

項目結構以下:

GraphQLdemo
│  dbOp.go
│  generated.go
│  gqlgen.yml
│  models_gen.go
│  resolver.go
│  schema.graphql
│
├─data
│      data.db
│
├─scripts
│      gqlgen.go
│
└─server
        server.go

查詢演示

PIC2

clipboard.png

clipboard.png

JWT 產生 token 實現用戶認證

  • 基於 Token 的身份驗證方法
  • 使用基於 Token 的身份驗證方法,在服務端不須要存儲用戶的登陸記錄。大概的流程是這樣的:

    1. 客戶端使用用戶名跟密碼請求登陸
    2. 服務端收到請求,去驗證用戶名與密碼
    3. 驗證成功後,服務端會簽發一個 Token,再把這個 Token 發送給客戶端
    4. 客戶端收到 Token 之後能夠把它存儲起來,好比放在 Cookie 裏或者 Local Storage 中
    5. 客戶端每次向服務端請求資源的時候須要帶着服務端簽發的 Token
    6. 服務端收到請求,而後去驗證客戶端請求裏面帶着的 Token,若是驗證成功,就向客戶端返回請求的數據
  • JSON Web Token

根據官網的定義,JSON Web Token(如下簡稱 JWT)是一套開放的標準(RFC 7519),它定義了一套簡潔(compact)且 URL 安全(URL-safe)的方案,以安全地在客戶端和服務器之間傳輸 JSON 格式的信息。

  • 優勢

    1. 體積小(一串字符串)。於是傳輸速度快
    2. 傳輸方式多樣。能夠經過 HTTP 頭部(推薦)/URL/POST 參數等方式傳輸
    3. 嚴謹的結構化。它自身(在 payload 中)就包含了全部與用戶相關的驗證消息,如用戶可訪問路由、訪問有效期等信息,服務器無需再去鏈接數據庫驗證信息的有效性,而且 payload 支持應用定製
    4. 支持跨域驗證,多應用於單點登陸

      > 單點登陸(Single Sign On):在多個應用系統中,用戶只需登錄一次,就能夠訪問全部相互信任的應用
  • 實現

    1. 這裏沒有實現帳號密碼的數據庫,只是在路由處理router.go中比對固定密碼,實現登出。
    2. 每次請求到達服務器時,服務器中間件判斷是不是登錄請求。若是不是登錄請求,則獲取保存在請求頭部的Token進行比對,相同則調用正常的HttpHandler,錯誤則返回錯誤回覆。

      func TokenMiddleware(next http.Handler) http.Handler {
          return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
              if r.RequestURI[1:] != "login" {
                  /*
                      // token位於Authorization中,用此方法
                      token, err := request.ParseFromRequest(r, request.AuthorizationHeaderExtractor, func(token *jwt.Token) (interface{}, error) {
                          return []byte(SecretKey), nil
                      })
                  */
                  tokenStr := ""
                  for k, v := range r.Header {
                      if strings.ToUpper(k) == TokenName {
                          tokenStr = v[0]
                          break
                      }
                  }
                  validToken := false
                  for _, token := range tokens {
                      if token.SW_TOKEN == tokenStr {
                          validToken = true
                      }
                  }
                  if validToken {
                      ctx := context.WithValue(r.Context(), TokenName, tokenStr)
                      next.ServeHTTP(w, r.WithContext(ctx))
                  } else {
                      w.WriteHeader(http.StatusUnauthorized)
                      w.Write([]byte("Unauthorized access to this resource"))
                      //fmt.Fprint(w, "Unauthorized access to this resource")
                  }
              } else {
                  next.ServeHTTP(w, r)
              }
          })
      }
    3. jwt.go中實現了建立Token和比對Token的方法,因爲只有一個Token,因此直接比對便可。

      var tokens []Token
      
      const TokenName = "SW-TOKEN"
      const Issuer = "Go-GraphQL-Group"
      const SecretKey = "StarWars"
      
      type Token struct {
          SW_TOKEN string `json:"SW-TOKEN"`
      }
      
      type jwtCustomClaims struct {
          jwt.StandardClaims
      
          Admin bool `json:"admin"`
      }
      
      func CreateToken(secretKey []byte, issuer string, isAdmin bool) (token Token, err error) {
          claims := &jwtCustomClaims{
              jwt.StandardClaims{
                  ExpiresAt: int64(time.Now().Add(time.Hour * 1).Unix()),
                  Issuer:    issuer,
              },
              isAdmin,
          }
      
          tokenStr, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secretKey)
          token = Token{
              tokenStr,
          }
          return
      }
      
      func ParseToken(tokenStr string, secretKey []byte) (claims jwt.Claims, err error) {
          var token *jwt.Token
          token, err = jwt.Parse(tokenStr, func(*jwt.Token) (interface{}, error) {
              return secretKey, nil
          })
          claims = token.Claims
          return
      }
相關文章
相關標籤/搜索