使用 GraphQL Gateway 串接多個 Data Schema

infra

不久之前寫過一篇『從 graphql-go 轉換到 gqlgen』,目前團隊舊有的專案還是繼續用 graphql-go 來撰寫,不過之後需求量越來越大,維護 graphql-go 就越來越困難,故有在想怎麼把 gqlgen 跟 graphql-go 相容在一起,那就是把這兩個套件想成不同的服務,再透過 Gateway 方式完成 single data graph。至於怎麼選擇 GraphQL Gateway 套件,最容易的方式就是使用 @apollo/gateway,但是由於個人比較偏好 Go 語言的解決方案,就稍微找看看有無人用 Go 實現了 Gateway,後來找到 nautilus/gateway,官方有提供文件以及教學 Blog 可以供開發者參考。底下會教大家使用 nautilus/gateway 將兩個不同的服務串接在一起。

線上影片

  • 00:00​ 為什麼有 GraphQL Gateway 需求?
  • 02:16​ 用兩個 Routing 來區分 graphql-go 跟 gqlgen
  • 03:00​ 用 jwt token check
  • 03:40​ 選擇 GraphQL Gateway 套件
  • 04:58​ main.go 撰寫機制介紹
  • 06:05​ 如何將 Token 往後面 Service 發送?
  • 06:58​ 看看完整的程式代碼
  • 07:56​ 最後心得感想

如果對於課程內容有興趣,可以參考底下課程。

如果需要搭配購買請直接透過 FB 聯絡我,直接匯款(價格再減 100

整合 graphql-go + gqlgen

要把兩個不同的套件整合在一起,最簡單的方式就是分不同的 URL 區隔開來,兩邊都是透過 Bearer Token 來進行使用者身份確認。

        g := e.Group("/graphql")
        g.Use(auth.Check())
        {
            g.POST("", graphql.Handler())
            if config.Server.GraphiQL {
                g.GET("", graphql.Handler())
            }
        }
        q := root.Group("/query")
        q.Use(auth.Check())
        {
            q.POST("", gqlgen.SchemaHandler())
            q.GET("", gqlgen.SchemaHandler())
        }

透過 jwt 驗證及讀取使用者資料

// Check user bearer token
func Check() gin.HandlerFunc {
    return func(c *gin.Context) {
        if data, err := jwt.New().GetClaimsFromJWT(c); err != nil {
            c.Next()
        } else if id, ok := data["id"]; ok {
            var userID int64
            switch v := id.(type) {
            case int:
                userID = int64(v)
            case string:
                i, err := strconv.ParseInt(v, 10, 64)
                if err != nil {
                    log.Error().Err(err).Msg("can't convert user id to int64")
                }
                userID = i
            case float64:
                userID = int64(v)
            default:
                log.Info().Msgf("I don't know about user id type %T from token!", v)
            }

            user, err := model.GetUserByID(userID)
            if err != nil {
                log.Error().Err(err).Msg("can't get user data")
            }

            ctx := context.WithValue(
                c.Request.Context(),
                config.ContextKeyUser,
                user,
            )
            c.Request = c.Request.WithContext(ctx)
        }
    }
}

撰寫 graphql-gateway

使用 nautilus/gateway 可以簡單將 Schema 合併成單一 Data,不過此套件尚未支援 subscription

func main() {
    // default port
    port := "3001"
    server := "api:8080"
    if v, ok := os.LookupEnv("APP_PORT"); ok {
        port = v
    }

    if v, ok := os.LookupEnv("APP_SERVER"); ok {
        server = v
    }

    // introspect the apis
    schemas, err := graphql.IntrospectRemoteSchemas(
        "http://"+server+"/graphql",
        "http://"+server+"/query",
    )
    if err != nil {
        panic(err)
    }

    // create the gateway instance
    gw, err := gateway.New(schemas, gateway.WithMiddlewares(forwardUserID))
    if err != nil {
        panic(err)
    }

    // add the playground endpoint to the router
    http.HandleFunc("/graphql", withUserInfo(gw.PlaygroundHandler))

    // start the server
    fmt.Printf("🚀 Gateway is ready at http://localhost:%s/graphql\n", port)
    err = http.ListenAndServe(fmt.Sprintf(":%s", port), nil)
    if err != nil {
        fmt.Println(err.Error())
        os.Exit(1)
    }
}

由於之後要整合進 Docker 內,故透過 LookupEnv 來決定 Server 跟 Port。這樣可以將 /graphql/query 的 Schema 綁定在一起了。另外要解決的就是如何將 Authorization 傳到後面 GraphQL Server 進行認證。

// the first thing we need to define is a middleware for our handler
// that grabs the Authorization header and sets the context value for
// our user id
func withUserInfo(handler http.HandlerFunc) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // look up the value of the Authorization header
        tokenValue := r.Header.Get("Authorization")
        // Allow CORS here By * or specific origin
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "authorization, origin, content-type, accept")
        w.Header().Set("Allow", "HEAD,GET,POST,PUT,PATCH,DELETE,OPTIONS")
        // here is where you would perform some kind of validation on the token
        // but we're going to skip that for this example and just save it as the
        // id directly. PLEASE, DO NOT DO THIS IN PRODUCTION.

        // invoke the handler with the new context
        handler.ServeHTTP(w, r.WithContext(
            context.WithValue(r.Context(), "tokenValue", tokenValue),
        ))
    })
}

// the next thing we need to do is to modify the network requests to our services.
// To do this, we have to define a middleware that pulls the id of the user out
// of the context of the incoming request and sets it as the USER_ID header.
var forwardUserID = gateway.RequestMiddleware(func(r *http.Request) error {
    // the initial context of the request is set as the same context
    // provided by net/http

    // we are safe to extract the value we saved in context and set it as the outbound header
    if tokenValue := r.Context().Value("tokenValue"); tokenValue != nil {
        r.Header.Set("Authorization", tokenValue.(string))
    }

    // return the modified request
    return nil
})

其中上面的 Access-Control 用來解決 CORS 相關問題。前端用各自電腦開發時,就需要此部分。

心得

用 gqlgen 在開發上效率差很多,現在透過這方式,可以保留舊的 Schema 搭配新的 gqlgen 開發模式,未來也可以將共通的功能獨立拆成單一服務,再透過 gateway 方式將多個模組合併。