Day 055 — Project: URL Shortener¶
Month 2 · Week 4 · ⬅ Day 054 · Day 056 ➡ · Journal index
🎯 Learning Objective¶
Ship the Week 4 capstone: a concurrency-safe URL shortener using only the standard library (net/http, net/url, sync) — base62 short codes, an RWMutex-guarded store, explicit method checks, and 302 redirects — backed by tests.
📚 Topics¶
net/httpserver:ServeMux,HandleFunc, method dispatch,http.Redirect- Concurrency-safe state with
sync.RWMutex; de-duplicating identical URLs - Base62 encoding of a counter; input validation with
net/url
📖 Reading / Sources¶
📝 Notes¶
- Endpoints:
POST /shorten(formurl=<long>) returns a short URL;GET /{code}issues a 302 redirect to the long URL;GET /healthzreturnsok. The"/"pattern is the catch-all that handles/{code}. → links to [[http-handlers]] and [[concurrency]]. - Method checks are explicit in net/http's classic mux — guard with
if r.Method != http.MethodPost, and on rejection set theAllowheader beforehttp.Error(w, ..., http.StatusMethodNotAllowed). (Go 1.22's method-aware patterns like"POST /shorten"can do this for you; doing it by hand shows the mechanism.) - Validate input with
url.ParseRequestURIand requirescheme == http|https— rejectingjavascript:and relative junk so the redirect target is safe. A bad URL is a 400. - Shared state needs a lock. The store is two maps (
code→long,long→code) plus a counter, guarded by async.RWMutex: writers takeLock(Save), readers takeRLock(Resolve). Maps are not safe for concurrent write — without the mutexgo test -racescreams and you risk a fatal "concurrent map writes". - De-duplicate: the reverse
long→codeindex means saving the same URL twice returns the same code instead of minting a new one — idempotent writes. - Short codes are base62 (
0-9A-Za-z) of a monotonic counter: compact and URL-safe.encode(0) == "0"; build digits least-significant-first, then reverse in place. - Build the absolute short URL from
r.Hostso it works regardless of the bound address. Prove the locking withgo test -race; thehttptestskills from [[testing]] (Week 3) cover the handlers without a real port.
💻 Code Examples¶
// Concurrency-safe save: writers hold the full Lock; identical URLs de-dup.
func (s *store) save(long string) string {
s.mu.Lock()
defer s.mu.Unlock()
if code, ok := s.toCode[long]; ok { // already shortened -> same code
return code
}
s.seq++
code := encode(s.seq) // base62 of the counter
s.toLong[code] = long
s.toCode[long] = code
return code
}
Full example:
examples/month-02/urlshortener/· Run:go run ./examples/month-02/urlshortenerthencurl -s -X POST --data-urlencode 'url=https://go.dev/doc/' localhost:8080/shorten.
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
base62.Encode/Decode with a round-trip property test |
✅ | exercises/month-02/week-4/base62 |
normalizeurl.Normalize (scheme/host/port/fragment canonicalization) |
✅ | exercises/month-02/week-4/normalizeurl |
urlstore.Store RWMutex map + de-dup, tested with -race |
✅ | exercises/month-02/week-4/urlstore |
🐛 Mistakes Made¶
- First version had no mutex;
go test -race ./urlstorereported a data race and the concurrent test occasionally hit "fatal error: concurrent map writes." Addedsync.RWMutex. - Used
url.Parse(accepts relative refs) instead ofurl.ParseRequestURI, so"notaurl"slipped through. Switched and also required an http/https scheme.
❓ Open Questions¶
- Where would I add persistence and rate-limiting? (Swap the map store behind an interface for a DB; wrap handlers with a limiter middleware — both fit the same
http.Handlershape, deferred to Month 3.)
🧠 Active Recall (answer without looking)¶
- Q: Why does the store need a mutex, and why
RWMutexspecifically?A
Built-in maps aren't safe for concurrent writes (the runtime may abort with "concurrent map writes"), and the HTTP server handles requests on many goroutines. RWMutex lets multiple Resolve readers proceed in parallel under RLock while Save takes an exclusive Lock.
2. Q: Which status code redirects a short code to its long URL, and how do you send it? A
302 Found (http.StatusFound), via http.Redirect(w, r, longURL, http.StatusFound), which sets the Location header and writes the status.
🪶 Feynman Reflection¶
A URL shortener is a two-way dictionary behind an HTTP door. POST a long URL and it files it under a short base62 code (reusing the code if it's seen the URL before); GET that code and it sends your browser a 302 "go over there" pointing at the original. Because many visitors knock at once, the dictionary sits behind a read/write lock so lookups run concurrently but writes are exclusive — no torn maps.
🕳️ Knowledge Gaps¶
- Collision-resistant codes (random/hashids) vs sequential counters, and graceful shutdown (
http.Server.Shutdown) — note for the Month 3 service work.
✅ Summary¶
I built a stdlib-only URL shortener: base62 codes, an RWMutex store with de-dup, validated input, explicit method handling with Allow, and 302 redirects — all proven with race-enabled tests.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 056: Month 2 review, closed-book recall, write the week + month reviews, and tag
v0.2.0.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦🟦⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: feat(examples): stdlib URL shortener capstone (day 055)