1. 为什么购物车必须依赖session:HTTP无状态下的状态难题
先聊一个很多初学者会卡住的点:HTTP协议本身是无状态的。什么叫无状态?就是浏览器每次请求服务器,服务器都当你是第一次来,它不记得你刚才往购物车放了什么、不记得你是登录状态还是游客身份,所有请求都是独立的事件。如果购物车数据只存在服务端内存里,那用户刷新页面、跳转页面、关闭浏览器再打开,购物车逻辑就不知道用户到底是谁的。
这个时候就需要session登场。session的本质是:服务器为每个浏览器会话创建一个唯一的标识,并且把状态数据挂在这个标识下面,浏览器端只保存一个 "存物牌"(通常就是cookie),每次请求把这个存物牌带回来,服务器根据牌子找到对应的状态数据。这个机制和我去超市存包非常像:我把包存进柜子里,手里拿一张小票,取包的时候把小票交给管理员,管理员凭票找到对应的柜子。你不会希望超市管理员在你每次取包时都问"你的包长什么样、里面装了什么东西"——这正是无状态协议的问题所在,session就是这张小票。
有了这个认知,购物车的技术方案就清晰了。用户把书加入购物车时,服务端在session里维护一个购物车数据结构,用户不需要登录,系统也能记住他的选择。这个方案比起"把购物车写进数据库"最大优势是:不强制要求用户注册登录也能购物,减少了购物摩擦;购物车数据不需要设计复杂的数据表,开发成本低。劣势也很明显:session存在内存里,服务器重启就全丢了;如果有多台应用服务器,session还要考虑共享问题。但对于一个教学项目来说,session方案是理解购物车业务最重要的一课——把业务逻辑和状态管理彻底搞明白之后,后面再换Redis也好、换数据库也好,都是顺手的事情。
具体到我们要做的GO网上书店这个项目,购物车的需求其实非常明确:用户能浏览图书,点击"加入购物车"按钮把书放进去;进购物车页面能看到所有已选的书和总价;能修改某本书的数量;能删除不想买的书;最好还能显示当前购物车里总共有几件商品。代码层面拆解下来其实是三块工作:一是session机制的实现,二是购物车数据模型和增删改查逻辑,三是把整套东西接到HTTP处理器和模板渲染上。
2. session在GO里怎么落地:实现一个带过期和并发保护的会话管理器
在GO里实现session,通常有自研和用第三方库两条路。第三方库比如gorilla/sessions用起来方便,但在这个系列里我建议先自己写一个。原因很简单:只有亲手实现一遍session管理器,你才能真正理解session ID怎么生成、cookie怎么种下去、数据怎么存储、过期时间怎么处理、并发访问怎么保护。这些东西搞明白了,用任何第三方库都是傻瓜式调用。
2.1 先定义清楚接口:SessionStore和Session
我习惯先把接口画清楚,再写实现代码。一个完整的session管理器需要两个层面的抽象:SessionStore是session仓库,负责创建、读取、销毁session;Session是单个会话对象,负责当前会话的数据读写。
// Session 代表一个会话 type Session interface { ID() string Get(key string) interface{} Set(key string, value interface{}) Delete(key string) Clear() Expired() bool // 是否已过期 SetExpiration(seconds int) } // SessionStore 是session仓库 type SessionStore interface { Create() (Session, error) // 新开会话 Read(sessionID string) (Session, error) // 根据ID读取会话 Save(sessionID string, s Session) error // 保存会话 Destroy(sessionID string) error // 销毁会话 Cleanup() // 清理过期会话 }接口先定好的好处是:以后想把内存存储换成Redis或者数据库,只需要换SessionStore的实现,业务代码一行都不用改。我们现在的目标就是实现一个内存版SessionStore,这对教学项目完全够用。
2.2 内存实现:锁、过期时间和ID生成
内存SessionStore最核心的数据结构是一个嵌套map。外层key是session ID,内层是一个map存具体键值。这里面有一个特别容易踩坑的点:GO的map在并发读写时会直接panic,而Web服务器天然是并发的——两个用户同时下单、同一个用户快速点两次按钮,都是再普通不过的场景。所以必须用sync.RWMutex把整个SessionStore包起来。
type memorySession struct { id string data map[string]interface{} expiresAt time.Time } type memorySessionStore struct { mu sync.RWMutex sessions map[string]*memorySession ttl time.Duration } func NewMemorySessionStore(ttl time.Duration) *memorySessionStore { store := &memorySessionStore{ sessions: make(map[string]*memorySession), ttl: ttl, } // 启动后台goroutine定期清理过期session go store.cleanupLoop() return store } func (s *memorySessionStore) Create() (Session, error) { id, err := generateSessionID() if err != nil { return nil, err } sess := &memorySession{ id: id, data: make(map[string]interface{}), expiresAt: time.Now().Add(s.ttl), } s.mu.Lock() s.sessions[id] = sess s.mu.Unlock() return sess, nil }这儿我要提醒一句:session ID的生成绝不能偷懒用时间戳加随机数拼接,也不要用math/rand这种伪随机数生成器。正确做法是用crypto/rand生成足够长的随机字节。购物车虽然不是什么金融级数据,但session ID一旦能被预测,攻击者就可以伪造别人的会话,这是很严重的安全漏洞。
func generateSessionID() (string, error) { b := make([]byte, 32) if _, err := cryptoRand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil }32字节随机数经过hex编码后是64位字符串,碰撞概率几乎可以忽略不计。别嫌多,这个开销完全可以忽略。
2.3 Cookie的种法:HttpOnly、SameSite一个都不能少
session在服务器端造好了,怎么让浏览器保存ID呢?标准做法是种cookie。GO标准库的net/http包提供了现成的http.Cookie结构,但有一些安全属性新手容易漏掉。
func (s *Manager) SetSessionCookie(w http.ResponseWriter, sessionID string, maxAge int) { http.SetCookie(w, &http.Cookie{ Name: "bookstore_session", Value: sessionID, Path: "/", HttpOnly: true, // 禁止JS读取,防止XSS攻击窃取session SameSite: http.SameSiteLaxMode, MaxAge: maxAge, Secure: false, // 生产环境走HTTPS时设为true }) }HttpOnly这个属性非常关键。如果不设置,前端JavaScript就能通过document.cookie读到session ID,一旦网站有任何XSS漏洞,用户的登录态就彻底暴露了。SameSite属性用于防止CSRF攻击,LaxMode在大部分场景下既能保证体验,又有足够的安全防护。Secure属性在生产环境(HTTPS)下一定要开,否则session ID会在网络传输中以明文暴露,但在本地开发环境用HTTP测试时需要关掉,这也算是一个开发环境常识了。
2.4 过期机制:懒过期加后台清理
session是有生命周期的。用户往购物车放了三本书,过了两个小时回来继续挑书,session早就超时了,这时候就应该让他重新开始。过期策略我采用了两层:一是每次访问session时检查expiresAt,过期就算失效并删除,这叫懒过期;二是启动一个后台goroutine定期扫描整个仓库,把过期session清掉,防止过期session堆积占用内存。
func (s *memorySessionStore) cleanupLoop() { ticker := time.NewTicker(time.Minute) for range ticker.C { s.Cleanup() } } func (s *memorySessionStore) Cleanup() { s.mu.Lock() defer s.mu.Unlock() now := time.Now() for id, sess := range s.sessions { if now.After(sess.expiresAt) { delete(s.sessions, id) } } }过期时间设置多长很看业务。网上书店这种场景,用户的浏览时长可能比较长,挑书挑半小时甚至一小时都很正常,所以我默认设成2小时。这个值写在配置里,方便以后调整。有一点要特别说明:goroutine泄漏问题。如果store的生命周期是整个进程的生命周期,这个goroutine永远不退也没问题;但如果在测试里频繁创建store,记得用context来停止后台goroutine,这个是比较常见的进阶点。
3. 购物车核心业务:加购、修改数量、删除、合计金额的完整设计
session管理器就绪了,接下来是购物车本身的业务逻辑。这块设计的好坏直接决定代码好不好维护。很多人上手就写一堆if else,把购物车数据当成普通map塞进去,结果后面改需求时痛苦不堪。咱们从一开始就把结构捋清楚。
3.1 数据模型:Cart、CartItem和书的实体
购物车的数据模型,核心就是两个结构体。CartItem代表购物车里的一个条目,Cart是条目集合加总金额的容器。
// CartItem 购物车条目 type CartItem struct { BookID int64 Title string Author string Price float64 Quantity int Cover string // 封面对应的URL,方便页面展示 } // Cart 购物车本体 type Cart struct { Items map[int64]*CartItem TotalQty int TotalAmount float64 } func NewCart() *Cart { return &Cart{ Items: make(map[int64]*CartItem), } }用map[int64]*CartItem而不是切片,是经过考量的。购物车最频繁的操作是按BookID找条目,map的查找是O(1)级别的;如果用slice就要遍历,性能差一个量级,代码也难看。BookID作为key还有一个好处:同一个书ID天然去重,不需要额外的去重判断。
关于金额精度,这里有个实际项目的经验想分享。网上书店的书价,我们直接用float64存了,教学项目没问题。但在真实电商项目里,金额计算绝对不能用浮点类型,浮点运算会累积误差。业界标准做法是用最小货币单位(分)存整数,或者用decimal类型。这里画个分界线:如果你的代码涉及真实的支付功能,金额一律用整数分;如果只是教学演示,float64能撑住场面,但最好意识到这是个简化。
3.2 加购逻辑:唯一性的关键操作
加购是购物车最核心的操作,逻辑不复杂:如果BookID已存在,就加数量;如果不存在,就新增条目。但有一个非常重要的细节:加购操作的"读-改-写"过程必须在同一个锁保护下完成。原因后面专门讲,先看代码。
func (c *Cart) AddItem(book *Book, qty int) { if qty <= 0 { return } if item, ok := c.Items[book.ID]; ok { item.Quantity += qty } else { c.Items[book.ID] = &CartItem{ BookID: book.ID, Title: book.Title, Author: book.Author, Price: book.Price, Quantity: qty, Cover: book.Cover, } } c.recalculate() } func (c *Cart) UpdateQuantity(bookID int64, qty int) error { if qty < 0 { return errors.New("quantity cannot be negative") } if item, ok := c.Items[bookID]; ok { if qty == 0 { delete(c.Items, bookID) } else { item.Quantity = qty } c.recalculate() return nil } return ErrItemNotFound }recalculate方法每次变更后重新计算总数量和总金额。有人会觉得每次都遍历所有条目扫一遍效率低,但这个数据的量级实在太小了——购物车能有几十种商品顶天了,这点开销完全不用担心,换来的是代码逻辑简单直观,绝对是划算的买卖。
func (c *Cart) recalculate() { totalQty := 0 totalAmount := 0.0 for _, item := range c.Items { totalQty += item.Quantity totalAmount += item.Price * float64(item.Quantity) } c.TotalQty = totalQty c.TotalAmount = totalAmount }删除条目和清空购物车就简单了,一个delete调用、一个重置map,然后同样调recalculate收尾。
3.3 把购物车挂到session上
购物车本身的逻辑写完了,最后一步是把它和session绑定。这里有一个设计决策要讲清楚:购物车对象是存整个对象还是只存BookID加数量?
第一种方案(存整个Cart对象)的好处是写起来简单,读出session直接就是完整的购物车。坏处是图书的价格、书名等信息哪天在后台改了,购物车里存的就是旧数据。第二种方案(只存BookID和数量)更合理——每次展示购物车时根据BookID从数据库重新查一遍图书信息,价格以数据库的最新值为准。购物车这种短期数据,存完整对象图容易踩脏数据坑,但教学项目为了简化,我选择在Cart里冗余了图书信息,同时在代码注释里留了说明,提示以后可以改成只存ID的方案。
const sessionCartKey = "shopping_cart" func getCartFromSession(sess session.Session) *Cart { if v := sess.Get(sessionCartKey); v != nil { if cart, ok := v.(*Cart); ok { return cart } } return NewCart() } func saveCartToSession(sess session.Session, cart *Cart) { sess.Set(sessionCartKey, cart) }这两个辅助函数干的就是一件事:从session里读出来,没有就建一个新的;改完了就写回去。整个流程简单得像存包取包,但背后session管理器在兜底。
4. 从浏览器到GO处理器的完整链路:路由、处理器与模板渲染
数据模型和session机制都准备好了,现在要做的就是把它们串起来,让用户真正能从页面上操作购物车。这一层是网上书店的门面,做得好不好直接决定用户体验——哪怕后台逻辑再漂亮,用户点"加入购物车"没反应或者跳转混乱,前面全白干。
4.1 路由设计:四个端点的语义约定
购物车相关的动作,我规划了四个端点:加购、查看购物车页、修改数量、删除条目。路由注册代码片段如下:
mux.HandleFunc("/cart/add", app.addToCartHandler) mux.HandleFunc("/cart", app.showCartHandler) mux.HandleFunc("/cart/update", app.updateCartItemHandler) mux.HandleFunc("/cart/remove", app.removeCartItemHandler)等一下,有人可能会问:为什么不用RESTful风格,比如PUT /cart/item和DELETE /cart/item?我的理由是:HTML表单原生只支持GET和POST,如果走RESTful就要写JavaScript用fetch请求。这个系列的教学主线是让大家看清请求-响应循环,暂时不引入额外复杂度;同时POST加表单提交的协作方式也降低了前端逻辑的门槛——后面想升级成前后端分离的时候,这一层只是换个调用方式,业务逻辑不用动。
在这种老派但靠谱的表单时代风格下,POST是加购、修改、删除的统一动词,用表单里的action参数区分意图。这样说可能有些保守,但这正是教学项目稳定的底气。
4.2 加购处理器:读参数、查书、更新session
加购处理器是用户第一个接触的入口,写清楚它的逻辑,后面其他处理器都是类似的套路。
func (app *Application) addToCartHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } bookIDStr := r.FormValue("bookID") qtyStr := r.FormValue("quantity") bookID, err := strconv.ParseInt(bookIDStr, 10, 64) if err != nil { http.Error(w, "Invalid book ID", http.StatusBadRequest) return } qty, err := strconv.Atoi(qtyStr) if err != nil || qty <= 0 { qty = 1 } // 查数据库拿图书信息 book, err := app.models.Books.Get(bookID) if err != nil { http.Error(w, "Book not found", http.StatusNotFound) return } sessionItem := app.sessionManager.GetSession(r) cart := getCartFromSession(sessionItem) cart.AddItem(book, qty) saveCartToSession(sessionItem, cart) app.sessionManager.SaveSession(w, sessionItem) http.Redirect(w, r, "/cart", http.StatusSeeOther) }每个细节都值得说两句。第一,方法限制必须放在最前面,别人用GET请求访问加购端点会对服务器状态造成改动,这不是安全漏洞但很烦。第二,参数解析要做健壮性处理:书ID解析失败要清晰报错;数量解析失败直接回退成默认值1,这种容错处理比报错更友好——用户在页面上手滑输入了非法数量,系统自动按一本处理,比弹个"参数错误"让人干瞪眼好得多。第三,每次请求的session都是重新从store里读取的,绝对不能在上一个处理器里保存一个session对象的引用到处乱传,这涉及到session ID和会话状态的绑定问题,多并发场景下会出大漏洞。
4.3 页面渲染和数据传递
showCartHandler的逻辑就是读出Cart对象,传给模板渲染:
func (app *Application) showCartHandler(w http.ResponseWriter, r *http.Request) { sessionItem := app.sessionManager.GetSession(r) cart := getCartFromSession(sessionItem) data := viewData{ Title: "购物车", Cart: cart, } app.renderTemplate(w, "cart", data) }模板层我用的GO标准库html/template,它是Go最正统的模板引擎,对XSS有自动转义保护,对教学项目来说安全性和简洁性都合适。网上书店的购物车页面模板关键部分长这样:
{{define "cart"}} <div class="cart-container"> <h1>我的购物车</h1> {{if .Cart.TotalQty}} <table class="cart-table"> <thead> <tr> <th>图书</th> <th>单价</th> <th>数量</th> <th>小计</th> <th>操作</th> </tr> </thead> <tbody> {{range $item := .Cart.Items}} <tr> <td> <div class="cart-book"> <img src="{{$item.Cover}}" alt="封面" width="60"> <div> <p class="book-title">{{$item.Title}}</p> <p class="book-author">{{$item.Author}}</p> </div> </div> </td> <td>¥{{printf "%.2f" $item.Price}}</td> <td> <form method="POST" action="/cart/update" class="qty-form"> <input type="hidden" name="bookID" value="{{$item.BookID}}"> <input type="number" name="quantity" value="{{$item.Quantity}}" min="0"> <button type="submit">更新</button> </form> </td> <td>¥{{printf "%.2f" (multiply $item.Price $item.Quantity)}}</td> <td> <a href="/book/{{$item.BookID}}" class="btn">查看</a> <form method="POST" action="/cart/remove" style="display:inline;"> <input type="hidden" name="bookID" value="{{$item.BookID}}"> <button type="submit" class="btn-remove">删除</button> </form> </td> </tr> {{end}} </tbody> </table> <div class="cart-summary"> <p>共 <strong>{{.Cart.TotalQty}}</strong> 件商品</p> <p>合计金额: <strong class="total-price">¥{{printf "%.2f" .Cart.TotalAmount}}</strong></p> <a href="/checkout" class="btn-checkout">去结算</a> </div> {{else}} <div class="cart-empty"> <p>购物车还是空的,快去挑几本好书吧</p> <a href="/books">浏览图书</a> </div> {{end}} </div> {{end}}有几个细节值得说明。第一,金额格式化用printf "%.2f"是为了保证两位小数展示,否则6.5会显示成6.5而不是6.50,视觉上没有价格正规感。第二,模板里的小计(单价乘数量)我定义了一个multiply模板函数来算乘法,html/template默认不支持数学运算。第三,数量输入框的min设成0是为了允许用户填0把商品数量改没——这和UpdateQuantity里qty==0删条目的逻辑对应上。
4.4 修改数量与删除条目的处理器
另外两个处理器和加购本质上一样,只是调用的Cart方法不同。这里有一个容易踩的坑是数量改0和直接删除的区别。我倾向于统一语义:数量为0或负数,等同于删除条目。这个决策让所有调用方(表单、JS、HTTP客户端)无需关心"删除和改数量是两套逻辑",接口只有一个。
func (app *Application) updateCartItemHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } bookID, err := strconv.ParseInt(r.FormValue("bookID"), 10, 64) if err != nil { http.Error(w, "Invalid book ID", http.StatusBadRequest) return } qty, err := strconv.Atoi(r.FormValue("quantity")) if err != nil { http.Error(w, "Invalid quantity", http.StatusBadRequest) return } sessionItem := app.sessionManager.GetSession(r) cart := getCartFromSession(sessionItem) err = cart.UpdateQuantity(bookID, qty) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } saveCartToSession(sessionItem, cart) app.sessionManager.SaveSession(w, sessionItem) http.Redirect(w, r, "/cart", http.StatusSeeOther) }删除处理器更简单,调cart.RemoveItem(bookID)之后同样三步走:保存session、跳回购物车页。这里我就不贴完整代码了,逻辑没差别,就是别忘记删除后也要recalculate,否则购物车总量和金额会显示旧数据,这种bug排查起来非常头疼。
5. 并发、Cookie撞车与浏览器测试:购物车上线前的重点排查
功能写完了不等于能上线。购物车是典型的"单机看不出来、一上线就出问题"的功能。这几轮排查和测试的经验,我在交付前必须分享给大家。
5.1 并发问题:为什么加购必须锁整块逻辑
先讲一个真实的排查场景。某次我在本地起服务测试,用脚本模拟三个用户同时对购物车加购。单线程跑没问题,一旦开并发,购物车里突然出现少算数量的情况:比如明明加了5本技术书,购物车只显示3本。排查了很久才发现是session数据读取的问题——多个请求同时读到同一个Cart对象,各自改了Quantity再写回去,最后一个写入方把前面的修改覆盖掉了。
这个问题的根源是:session里的同一个购物车对象被多个请求同时读写,内存里只有一个Cart实例。修复方式就是给购物车操作加锁。更标准的做法是:Cart自身内置一个sync.Mutex,所有修改操作在锁内部执行,然后写回session时保存的是锁保护过的最终结果。
如果是从真实高并发架构考虑的优化方向,锁粒度还可以再细:按BookID分key加锁、甚至用atomic操作做计数器,都是更极端的优化。但购物车场景真的不需要那么激进——只要别出现丢失更新,延迟完全不是问题。
5.2 Cookie调试:为什么浏览器里看不到session?
开发阶段最容易遇到的灵异事件:登录后跳转回首页,马上又变成未登录状态;购物车加了东西,刷新一下就空了。这种问题的排查顺序,我建议从浏览器开发者工具开始。
打开开发者工具的Application面板,看一下Cookie列表。如果里面根本没有名为bookstore_session的cookie,说明set-cookie没有生效或者被浏览器拦截。常见原因有三个:一是Cookie的Domain和Path设置不对——比如端口变了导致scope不匹配;二是浏览器限制了第三方Cookie——本地调试时确实容易踩这个坑,特别是在嵌入iframe的方式下测试时;三是Cookie太大被浏览器拒绝——如果你往session里塞了一大坨数据,cookie的max size有4KB限制,超了就静默丢弃。
如果cookie在,但服务器读不到对应的session,就要在session manager的Read方法里加日志,把收到的session ID打印出来手动比对。有一个经常被忽略的点:开发环境下浏览器的Cookie里可能残留了旧域名下的同名cookie,明明服务器新种了一个,浏览器还带着旧的,导致读到空session。我的建议是开发时给session cookie固定一个测试专用的名字,比如bookstore_dev_session,避免和生产环境的cookie互相干烧。
5.3 浏览器隔离测试:验证每个用户有独立的购物车
session方案最大的优势就是用户隔离。做这个验证最有说服力的方法就是开两个不同的浏览器来测:用Chrome往购物车里加一本A书,用Firefox打开同一家网站,购物车必须是空的;在Firefox里加一本B书,回到Chrome刷新,购物车里的A书不能消失,B书也不能出现在Chrome里。
这个测试通过后,说明session ID的隔离机制工作正常。这也是我强烈建议你在自己项目里手动跑一遍的测试——不要只依赖自动化测试,人肉走一遍主流浏览器的效果,很多东西只有亲手点了才知道问题在哪。
5.4 内存session的边界:重启就丢,怎么办?
内存session方案有一个扎心的特性:服务器一重启,所有session全部失效,所有购物车清空。在开发阶段,这个特性很烦——你刚加了五本书进购物车,改个代码重启服务,购物车空了,调试体验很差。我把session存储单独抽成接口的好处这时候就体现出来了:想保留session,把SessionStore换成一个能持久化的实现即可,业务代码不用动。
如果你希望在开发阶段重启服务后购物车还能留下来,有两个解决办法:一种是用持久化存储,比如把session备份到本地文件或者Redis;另一种是在前端也给一个兜底——把购物车数据同时存在localStorage里,服务端session失效时前端可以提示"您的会话已过期,购物车数据已恢复"之类的文案,但这属于增强功能,不在本系列的范围内。
我目前的做法是:开发环境用内存版加一个启动参数控制是否保留session,生产环境计划迁移到Redis。接口设计的好处就在这里——替换实现时不破坏任何上层逻辑,这就是架构设计的价值。
5.5 一个隐藏的安全细节:session固定攻击与ID轮换
最后讲一个很多人没意识到的安全问题。用户第一次访问网站时,服务器就给他种了一个session cookie。如果这个初始session ID在用户登录前和登录后保持不变,攻击者可以先拿到这个ID,然后诱导用户使用这个ID去登录,登录后攻击者就能用这个ID获取用户的登录态——这就是经典的session固定攻击。
缓解办法很简单:用户状态发生变化时(比如从匿名变成登录),立刻销毁旧session,创建一个新的session ID,然后把购物车数据从旧session迁移到新session里。在购物车场景下,这就意味着用户登录前后还是同一个购物车,但底层session ID换了。这个细节虽然本系列还没涉及到登录模块,但是一旦你开始做用户认证,requireSession和rotateSession这两个方法就派上用场了。提前在session管理器里预留Rotate方法,后面用起来很顺手。
6. 收尾:网上书店购物车模块的完整架构回顾
到这儿,"基于session的网上书店购物车"就完整落地了。回顾整个技术路线:先理解了HTTP无状态和session的本质,然后实现了支持并发与过期清理的session管理器,接着定义了购物车的数据模型和增删改查逻辑,最后通过路由、处理器和html/template把功能跑通了前端页面。每一步之间都靠接口解耦,session存储可以换、购物车存储逻辑可以独立测试、HTTP处理器只和session接口打交道。
按照我个人经验,最后再说两个实际开发中会遇到的点。
第一,测试的时候要注意cookie隔离和路径问题,尤其是你本地跑着多个服务的时候,同一个浏览器下不同端口产生的cookie很容易互相干扰。我踩过最狠的一次坑是:跑前台商城和后台管理两个服务,共用同一个域名localhost但端口不同,cookie没有设置Path区分,结果后台的session把前台的顶掉了,整个调试过程可以用痛苦来形容。
第二,购物车功能看起来简单,但它牵涉的知识点非常全面:状态管理、并发安全、安全Cookie、模板渲染、数据库查询、用户隔离。把它吃透了,后面做登录、下单、订单历史这些功能都会顺手很多——因为它们本质都是"在session之上做业务状态管理"。
如果这篇文章对你有帮助,你可以试着继续扩展:把内存session换成Redis、给购物车加上"猜你喜欢"的推荐逻辑、或者做一个真实的结算页把购物车数据转成订单。每一步都会让你对GO Web开发的理解更深一层。