ZhangZhihui's Blog  

Stateful HTTP

A nice touch to improve our user experience would be to display a one-time confirmation message which the user sees after they’ve added a new snippet. Like so:

A confirmation message like this should only show up for the user once (immediately after creating the snippet) and no other users should ever see the message. If you’ve been programming for a while already, you might know this type of functionality as a flash message or a toast.

To make this work, we need to start sharing data (or state) between HTTP requests for the same user. The most common way to do that is to implement a session for the user.

 

Choosing a session manager

There are a lot of security considerations when it comes to working with sessions, and proper implementation is not trivial. Unless you really need to roll your own implementation, it’s a good idea to use an existing, well-tested, third-party package here.

I recommend using either gorilla/sessions , or alexedwards/scs , depending on your project’s needs.

  • gorilla/sessions is the most established and well-known session management package for Go. It has a simple and easy-to-use API, and let’s you store session data client-side (in signed and encrypted cookies) or server-side (in a database like MySQL, PostgreSQL or Redis). However — importantly — it doesn’t provide a mechanism to renew session IDs (which is necessary to reduce risks associated with session fixation attacks if you’re using one of the server-side session stores).
  • alexedwards/scs lets you store session data server-side only. It supports automatic loading and saving of session data via middleware, has a nice interface for type-safe manipulation of data, and does allow renewal of session IDs. Like gorilla/sessions , it also supports a variety of databases (including MySQL, PostgreSQL and Redis).

In summary, if you want to store session data client-side in a cookie then gorilla/sessions is a good choice, but otherwise alexedwards/scs is generally the better option due to the ability to renew session IDs.

For this project we’ve already got a MySQL database set up, so we’ll opt to use alexedwards/scs and store the session data server-side in MySQL.

zzh@ZZHPC:/zdata/Github/snippetbox$ go get github.com/alexedwards/scs/v2
go: downloading github.com/alexedwards/scs/v2 v2.8.0
go: added github.com/alexedwards/scs/v2 v2.8.0
zzh@ZZHPC:/zdata/Github/snippetbox$ go get github.com/alexedwards/scs/mysqlstore
go: downloading github.com/alexedwards/scs/mysqlstore v0.0.0-20240316134038-7e11d57e8885
go: added github.com/alexedwards/scs/mysqlstore v0.0.0-20240316134038-7e11d57e8885

 

Setting up the session manager

I’ll run through the basics of setting up and using the alexedwards/scs package, but if you’re going to use it in a production application I recommend reading the documentation and API reference to familiarize yourself with the full range of features.

The first thing we need to do is create a sessions table (the table name must be sessions) in our MySQL database to hold the session data for our users. Start by connecting to MySQL from your terminal window as the root user and execute the following SQL statement to setup the sessions table:

USE snippetbox;

CREATE TABLE sessions (
    token  CHAR(43)     PRIMARY KEY,
    data   BLOB         NOT NULL,
    expiry TIMESTAMP(6) NOT NULL
);

CREATE INDEX idx_sessions_expiry ON sessions (expiry);

In this table:

  • The token field will contain a unique, randomly-generated, identifier for each session.
  • The data field will contain the actual session data that you want to share between HTTP requests. This is stored as binary data in a BLOB (binary large object) type.
  • The expiry field will contain an expiry time for the session. The scs package will automatically delete expired sessions from the sessions table so that it doesn’t grow too large.

The next thing we need to do is establish a session manager in our main.go file and make it available to our handlers via the application struct. The session manager holds the configuration settings for our sessions, and also provides some middleware and helper methods to handle the loading and saving of session data.

type application struct {
    logger         *slog.Logger
    snippet        *models.SnippetModel
    templateCache  map[string]*template.Template
    formDecoder    *form.Decoder
    sessionManager *scs.SessionManager
}

func main() {
    addr := flag.String("addr", ":4000", "HTTP network address")
    dbDriver := flag.String("dbdriver", "mysql", "Database driver name")
    dsn := flag.String("dsn",
        "zeb:zebpwd@tcp(localhost:3306)/snippetbox?parseTime=true",
        "MySQL data source name")
    flag.Parse()

    logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

    db, err := openDB(*dbDriver, *dsn)
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }
    defer db.Close()

    templateCache, err := newTemplateCache()
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }

    formDecoder := form.NewDecoder()

    // Use the scs.New() function to initialize a new session manager. Then we configure it to use
    // our MySQL database as the session store, and set a lifetime of 12 hours (so that sessions
    // automatically expire 12 hours afer first being created).
    sessionManager := scs.New()
    sessionManager.Store = mysqlstore.New(db)
    sessionManager.Lifetime = 12 * time.Hour

    app := &application{
        logger:         logger,
        snippet:        &models.SnippetModel{DB: db},
        templateCache:  templateCache,
        formDecoder:    formDecoder,
        sessionManager: sessionManager,
    }

    logger.Info("starting server", "addr", *addr)

    err = http.ListenAndServe(*addr, app.routes())
    logger.Error(err.Error())
    os.Exit(1)
}

Note: The scs.New() function returns a pointer to a SessionManager struct which holds the configuration settings for your sessions. In the code above we’ve set the Store and Lifetime fields of this struct, but there’s a range of other fields that you can and should configure depending on your application’s needs.

For the sessions to work, we also need to wrap our application routes with the middleware provided by the SessionManager.LoadAndSave() method. This middleware automatically loads and saves session data with every HTTP request and response.

It’s important to note that we don’t need this middleware to act on all our application routes. Specifically, we don’t need it on the GET /static/ route, because all this does is serve static files and there is no need for any stateful behavior.

So, because of that, it doesn’t make sense to add the session middleware to our existing standard middleware chain.

Instead, let’s create a new dynamic middleware chain containing the middleware appropriate for our dynamic application routes only.

Open the routes.go file and update it like so:

package main

import (
    "net/http"

    "github.com/justinas/alice"
)

func (app *application) routes() http.Handler {
    mux := http.NewServeMux()

    // Leave the static files route unchanged.
    fileServer := http.FileServer(http.Dir("./ui/static/"))
    mux.Handle("GET /static/", http.StripPrefix("/static", fileServer))

    // Create a new middleware chain containing the middleware specific to our dynamic application 
    // routes. For now, this chain will only contain the LoadAndSave session middleware but we'll 
    // add more to it later.
    dynamic := alice.New(app.sessionManager.LoadAndSave)

    // Update these routes to use the new dynamic middleware chain followed by the appropriate 
    // handler function. Note that because the alice ThenFunc() method returns an http.Handler 
    // (rather than an http.HandlerFunc) we also need to switch to registering the route using 
    // the mux.Handle() method.
    mux.Handle("GET /{$}", dynamic.ThenFunc(app.home))
    mux.Handle("GET /snippet/view/{id}", dynamic.ThenFunc(app.snippetView))
    mux.Handle("GET /snippet/create", dynamic.ThenFunc(app.snippetCreate))
    mux.Handle("POST /snippet/create", dynamic.ThenFunc(app.snippetCreatePost))

    standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders)

    // Return the 'standard' middleware chain followed by the servermux.
    return standard.Then(mux)
}

 

Working with session data

Let’s put the session functionality to work and use it to persist the confirmation flash message between HTTP requests that we discussed earlier.

We’ll begin in our cmd/web/handlers.go file and update our snippetCreatePost method so that a flash message is added to the user’s session data if — and only if — the snippet was created successfully. Like so:

func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) {
    var form snippetCreateForm

    err := app.decodePostForm(r, &form)
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    form.CheckField(validator.NotEmpty(form.Title), "title", "This field cannot be empty.")
    form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 characters long.")
    form.CheckField(validator.NotEmpty(form.Content), "content", "This field cannot be empty.")
    form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "This field must equal 1, 7, or 365.")

    if !form.Valid() {
        data := app.newTemplateData(r)
        data.Form = form
        app.render(w, r, http.StatusUnprocessableEntity, "create.html", data)
        return
    }

    id, err := app.snippet.Insert(form.Title, form.Content, form.Expires)
    if err != nil {
        app.serverError(w, r, err)
        return
    }

    // Use the Put() method to add a string value and the corresponding key to the session data.
    app.sessionManager.Put(r.Context(), "flash", "Snippet successfully created!")

    http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther)
}

That’s nice and simple, but there are a couple of things to point out:

  • The first parameter that we pass to app.sessionManager.Put() is the current requestcontext. 
  • The second parameter (in our case the string "flash" ) is the key for the specific message that we are adding to the session data. We’ll subsequently retrieve the message from the session data using this key too.
  • If there’s no existing session for the current user (or their session has expired) then a new, empty, session for them will automatically be created by the session middleware.

Next up we want our snippetView handler to retrieve the flash message (if one exists in the session for the current user) and pass it to the HTML template for subsequent display.

Because we want to display the flash message once only, we actually want to retrieve and remove the message from the session data. We can do both these operations at the same time by using the PopString() method.

Add a Flash field to the templateData struct.

type templateData struct {
    CurrentYear int
    Snippet     models.Snippet
    Snippets    []models.Snippet
    Form        any
    Flash       string
}

 

func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil || id < 1 {
        http.NotFound(w, r)
        return
    }

    snippet, err := app.snippet.Get(id)
    if err != nil {
        if errors.Is(err, models.ErrNoRecord) {
            http.NotFound(w, r)
        } else {
            app.serverError(w, r, err)
        }
        return
    }

    // Use the PopString() method to retrieve the value for the "flash" key. PopString() also 
    // deletes the key and value from the session data, so it acts like a one-time fetch. If there 
    // is no matching key in the session data this will return the empty string.
    flash := app.sessionManager.PopString(r.Context(), "flash")

    data := app.newTemplateData(r)
    data.Snippet = snippet

    // Pass the flash message to the template.
    data.Flash = flash

    app.render(w, r, http.StatusOK, "view.html", data)
}

Info: If you want to retrieve a value from the session data only (and leave it in there) you can use the GetString() method instead. The scs package also provides methods for retrieving other common data types, including GetInt() , GetBool()GetBytes() and GetTime() .

We update our base.html file to display the flash message, if one exists.

{{define "base"}}
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>{{template "title" .}} - Snippetbox</title>
        <link rel="stylesheet" href="/static/css/main.css">
        <link rel="shortcut icon" href="/static/img/favicon.ico" type="image/x-icon">
        <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700">
    </head>

    <body>
        <header>
            <h1><a href="/">Snippetbox</a></h1>
        </header>
        {{template "nav" .}}   <!-- every template action or function automatically adds a blank line before and after its block-->
        <main>
            {{with .Flash}}
            <div class="flash">{{.}}</div>
            {{end}}
            {{template "main" .}}
        </main>

        <footer>
            Powered by <a href="https://golang.org/">Go</a> in {{.CurrentYear}}
        </footer>

        <script src="/static/js/main.js" type="text/javascript"></script>
    </body>
</html>
{{end}}

 

If you try refreshing the page, you can confirm that the flash message is no longer shown — it was a one-off message for the current user immediately after they created the snippet.

 

Auto-displaying flash messages

A little improvement we can make (which will save us some work later in the project) is to automate the display of flash messages, so that any message is automatically included the next time any page is rendered.

We can do this by adding any flash message to the template data via the newTemplateData() helper method that we made earlier, like so:

func (app *application) newTemplateData(r *http.Request) templateData {
    return templateData{
        CurrentYear: time.Now().Year(),
        // Add the flash message to the template data, if one exists.
        Flash: app.sessionManager.PopString(r.Context(), "flash"),
    }
}

Making that change means that we no longer need to check for the flash message within the snippetView handler, and the code can be reverted to look like this:

func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil || id < 1 {
        http.NotFound(w, r)
        return
    }

    snippet, err := app.snippet.Get(id)
    if err != nil {
        if errors.Is(err, models.ErrNoRecord) {
            http.NotFound(w, r)
        } else {
            app.serverError(w, r, err)
        }
        return
    }

    data := app.newTemplateData(r)
    data.Snippet = snippet

    app.render(w, r, http.StatusOK, "view.html", data)
}

 

Behind the scenes of session management

I’d like to take a moment to unpack some of the ‘magic’ behind session management and explain how it works behind the scenes.

If you like, open up the developer tools in your web browser and take a look at the cookie data for one of the pages. You should see a cookie named session in the request data, similar to this:

This is the session cookie, and it will be sent back to the Snippetbox application with every request that your browser makes.

The session cookie contains the session token — also sometimes known as the session ID. The session token is a high-entropy random string, which in my case is the value y9y1-mXyQUoAM6V5s9lXNjbZ_vXSGkO7jy-KL-di7A4 (yours will be different).

It’s important to emphasize that the session token is just a random string. In itself, it doesn’t carry or convey any session data (like the flash message that we set in this chapter). 

Next, you might like to open up a terminal to MySQL and run a SELECT query against the sessions table to lookup the session token that you see in your browser. Like so:

This should return one record. The data value here is the thing that actually contains the user’s session data. Specifically, what we’re looking at is a MySQL BLOB (binary large object) containing a gob-encoded representation of the session data.

Each and every time we make a change to our session data, this data value will be updated to reflect the changes.

Lastly, the final column in the database is the expiry time, after which the session will no longer be considered valid.

So, what happens in our application is that the LoadAndSave() middleware checks each incoming request for a session cookie. If a session cookie is present, it reads the session token from the cookie and retrieves the corresponding session data from the database (while also checking that the session hasn’t expired). It then adds the session data to the request context so it can be used in your handlers.

Any changes that you make to the session data in your handlers are updated in the request context, and then the LoadAndSave() middleware updates the database with any changes to the session data before it returns.

 

posted on 2024-09-05 15:17  ZhangZhihuiAAA  阅读(5)  评论(0编辑  收藏  举报