lxzan / gws

simple, fast, reliable websocket server & client, supports running over tcp/kcp/unix domain socket. keywords: ws, proxy, chat, go, golang...
https://pkg.go.dev/github.com/lxzan/gws
Apache License 2.0
1.34k stars 84 forks source link

How to access request object or context #56

Closed Goldziher closed 10 months ago

Goldziher commented 10 months ago

Hi there,

First off - thanks for this great package. I started building using it, and it works very well so far.

Now, my issue is - how can I access the request context inside a handler.

Lets say I followed the example in the readme:

package main

import (
    "github.com/lxzan/gws"
    "net/http"
    "time"
)

const (
    PingInterval = 10 * time.Second
    PingWait     = 5 * time.Second
)

func main() {
    upgrader := gws.NewUpgrader(&Handler{}, &gws.ServerOption{
        ReadAsyncEnabled: true,
        CompressEnabled:  true,
        Recovery:         gws.Recovery,
    })
    http.HandleFunc("/connect", func(writer http.ResponseWriter, request *http.Request) {
        socket, err := upgrader.Upgrade(writer, request)
        if err != nil {
            return
        }
        go func() {
            // Blocking prevents the context from being GC.
            socket.ReadLoop()
        }()
    })
    http.ListenAndServe(":6666", nil)
}

type Handler struct{}

func (c *Handler) OnOpen(socket *gws.Conn) {
    _ = socket.SetDeadline(time.Now().Add(PingInterval + PingWait))
}

func (c *Handler) OnClose(socket *gws.Conn, err error) {}

func (c *Handler) OnPing(socket *gws.Conn, payload []byte) {
    _ = socket.SetDeadline(time.Now().Add(PingInterval + PingWait))
    _ = socket.WritePong(nil)
}

func (c *Handler) OnPong(socket *gws.Conn, payload []byte) {}

func (c *Handler) OnMessage(socket *gws.Conn, message *gws.Message) {
    defer message.Close()
    socket.WriteMessage(message.Opcode, message.Bytes())
}

Now, I have logic implemented inside my OnMessage receiver. I can access the socket and the message, but I cannot access the original request and any data attached to it. I also cannot access the context propagated from elsewhere.

In my case, I am mounting the WebSocket from this library on a route on an existing HTTP server. Specifically, using go-chi as a router, but it doesn't matter.

I have values injected into the request via middleware, such as user session from the database, parsed URL parameters, etc. I want to be able to pass these into the WebSocket handler in some way - best by using context.

Is there any such way or is it perhaps a feature that will be implemented in the near future?

lxzan commented 10 months ago

Take a look at gws.Conn{}.Session()

Goldziher commented 10 months ago

gws.Conn{}.Session()

Thanks.

Do you mean as an alternative to using context?

I can store in the concurrent map values using session - to save the session object into the session storage, etc.

It will work as a workaround, but I was hoping for a solution more in line with the conventional use of context.

lxzan commented 10 months ago

gws encapsulates the readMessage loop, which can only read data from *gws.Conn in the event callback method. I wouldn't consider exporting the readMessage method, the disruption to the existing API design would be too great.

lxzan commented 10 months ago

Too many ways to accomplish the same thing can cause problems for some users.

Goldziher commented 10 months ago

Well, that may be the case. But context is a part of the standard library and API for Golang these days.

Anyhow, passing context via session storage causes an issue - namely, the context is canceled:

func (handler) OnMessage(socket *gws.Conn, message *gws.Message) {
    defer func() {
        if err := message.Close(); err != nil {
            log.Error().Err(err).Msg("failed to close message")
        }
    }()
    if message.Data != nil && message.Data.Len() > 0 && message.Opcode == gws.OpcodeText {
        // We are retrieving the request context we passed into the socket session.
        value, exists := socket.Session().Load("context")
        if !exists {
            log.Error().Msg("failed to load context from session")
            socket.WriteClose(statusWSServerError, []byte("invalid context"))
            return
        }
        ctx := value.(context.Context)

        if ctx.Err() != nil {
            panic(ctx.Err())
        }
                // .... code will not reach here because the context is cancelled. 
}

func promptTestingWebsocketHandler(w http.ResponseWriter, r *http.Request) {
    socket, err := upgrader.Upgrade(w, r)
    if err != nil {
        log.Error().Err(err).Msg("failed to upgrade connection")
        apierror.InternalServerError().Render(w, r)
        return
    }

    socket.Session().Store("context", r.Context())
    go socket.ReadLoop()
}
lxzan commented 10 months ago

You are talking about context.Context, sorry for my mistake.

Gws pursues extreme performance and won't support context.Context, it has too much overhead. You can use SetDeadline instead.

Goldziher commented 10 months ago

ok, thanks for clarifying this

lxzan commented 10 months ago

If you don't care about performance loss, you can write your own.

func IsCanceled(ctx context.Context) bool {
    select {
    case <-ctx.Done():
        return true
    default:
        return false
    }
}

func WriteWithContext(ctx context.Context, socket *gws.Conn, op gws.Opcode, p []byte) error {
    if IsCanceled(ctx) {
        return ctx.Err()
    }

    ech := make(chan error)

    go func() {
        ech <- socket.WriteMessage(op, p)
    }()

    select {
    case err := <-ech:
        return err
    case <-ctx.Done():
        return ctx.Err()
    }
}
Goldziher commented 10 months ago

If you don't care about performance loss, you can write your own.

func IsCanceled(ctx context.Context) bool {
  select {
  case <-ctx.Done():
      return true
  default:
      return false
  }
}

func WriteWithContext(ctx context.Context, socket *gws.Conn, op gws.Opcode, p []byte) error {
  if IsCanceled(ctx) {
      return ctx.Err()
  }

  ech := make(chan error)

  go func() {
      ech <- socket.WriteMessage(op, p)
  }()

  select {
  case err := <-ech:
      return err
  case <-ctx.Done():
      return ctx.Err()
  }
}

Thanks, for now, I simply extract the values I need from context and use session storage to pass them. This, though, is also not so performant in the end. I don't need to use cancellation - rather, context is used to pass data from middleware etc. The websocket is mounted on a sub path inside an existing application, and there are layers of authentication and authorization that inject values from the DB into the context.