Ever wondered how services like Bitly turn long, ugly URLs into short, shareable links?
In this tutorial, I’ll walk you through building a URL Shortener from scratch using Go, Redis and Docker. If you’re a budding developer, this project is a fantastic way to level up your skills and learn practical web development.
What You’ll Learn
- Setting up a Go project with modules.
- Building RESTful APIs in Go.
- Using Redis for quick key-value storage.
- Organizing Go code for readability and scalability.
Prerequisites / Installation:
- Docker
- Docker Desktop ( Install Docker Desktop on your system)
Step 1: Initialize Your Project
Start by creating a directory for your project and initializing a Go module:
mkdir url-shortener
cd url-shortener
go mod init github.com/<username>/url-shortener
This will create a go.mod
file to manage dependencies.
Step 2: Set Up the Project Structure
To keep things clean and modular, create the following folder structure:
url-shortener/
├── handlers/ # API logic for handling requests
│ └── handlers.go
├── models/ # Data models
│ └── url.go
├── router/ # Routing setup
│ └── router.go
├── storage/ # Redis interactions
│ └── redis-store.go
├── main.go # Application entry point
├── Dockerfile # Docker configuration
├── docker-compose.yml
└── go.mod # Go module file
Step 3: Install Dependencies
We’ll use the following Go packages:
-
github.com/go-redis/redis/v8
for Redis interactions. -
github.com/gorilla/mux
for routing.
Install them using:
go get github.com/go-redis/redis/v8
go get github.com/gorilla/mux
Step 4: Define Data Models
Create models/url.go
to define the data structures for request and response:
package models
type ShortenRequest struct {
URL string `json:"url"` // URL to be shortened
}
type ShortenResponse struct {
ShortURL string `json:"short_url"` // Generated short URL
}
The ShortenRequest
struct represents the input, and ShortenResponse
defines the output format.
Step 5: Configure Redis Storage
In storage/redis-store.go
, create a RedisStore
struct to handle Redis operations:
package storage
import (
"context"
"fmt"
"hash/fnv"
"net/url"
"strings"
"github.com/go-redis/redis/v8"
)
// Context for Redis operations
var ctx = context.Background()
type RedisStore struct {
Client *redis.Client
// client *redis.Client
}
// NewRedisStore initializes a new Redis client and returns a RedisStore
func NewRedisStore() *RedisStore {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
DB: 0,
})
return &RedisStore{Client: rdb}
}
func NewTestRedisStore() *RedisStore {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
DB: 1,
})
return &RedisStore{Client: rdb}
}
// SaveURL stores the original URL and its shortened version in Redis
func (s *RedisStore) SaveURL(originalURL string) (string, error) {
// Check if the URL already exists
shortURL, err := s.Client.Get(ctx, originalURL).Result()
if err == redis.Nil {
// URL not found, generate a new short URL
shortURL = s.generateShortURL(originalURL)
err = s.Client.Set(ctx, originalURL, shortURL, 0).Err()
if err != nil {
return "", err
}
// Store the original URL and the short URL in Redis
err = s.Client.Set(ctx, shortURL, originalURL, 0).Err()
if err != nil {
return "", err
}
// Increment the domain count in Redis
domain, err := s.getDomain(originalURL)
if err != nil {
return "", err
}
err = s.Client.Incr(ctx, fmt.Sprintf("domain:%s", domain)).Err()
if err != nil {
return "", err
}
} else if err != nil {
return "", err
}
return shortURL, nil
}
// GetOriginalURL retrieves the original URL from Redis using the short URL
func (s *RedisStore) GetOriginalURL(shortURL string) (string, error) {
originalURL, err := s.Client.Get(ctx, shortURL).Result()
if err == redis.Nil {
return "", fmt.Errorf("URL not found")
} else if err != nil {
return "", err
}
return originalURL, nil
}
// GetDomainCounts retrieves the counts of shortened URLs per domain from Redis
func (s *RedisStore) GetDomainCounts() (map[string]int, error) {
keys, err := s.Client.Keys(ctx, "domain:*").Result()
if err != nil {
return nil, err
}
domainCounts := make(map[string]int)
for _, key := range keys {
count, err := s.Client.Get(ctx, key).Int()
if err != nil {
return nil, err
}
domain := strings.TrimPrefix(key, "domain:")
domainCounts[domain] = count
}
return domainCounts, nil
}
// generateShortURL creates a shortened URL string using a hash function
func (s *RedisStore) generateShortURL(originalURL string) string {
h := fnv.New32a()
h.Write([]byte(originalURL))
return fmt.Sprintf("%x", h.Sum32())
}
// getDomain extracts the domain name from a URL
func (s *RedisStore) getDomain(originalURL string) (string, error) {
parsedURL, err := url.Parse(originalURL)
if err != nil {
return "", err
}
return strings.TrimPrefix(parsedURL.Host, "www."), nil
}
func (s *RedisStore) FlushTestDB() {
s.Client.FlushDB(ctx)
}
-
SaveURL
: checks if a URL exists and generates a short URL if it doesn’t. -
GetOriginalURL
: Retrieves the original URL from the short URL. -
GetDomainCounts
: Fetches statistics for the top domains. -
generateShortURL
: hashes the original URL using FNV-32. (fnv.New32a()
generates a 32-bit hash for a given URL.) -
getDomain
: Extracts the domain name from a URL.
Why FNV-1a?
- It’s a fast, non-cryptographic hash function.
- Generates a short, deterministic hash for a given input.
- Perfect for URL shortening since we don’t need cryptographic security.
- Reduces collisions compared to basic modulo-based hashing.
Step 6: Build Handlers
Create handlers/handlers.go
to implement API logic:
package handlers
import (
"encoding/json"
"net/http"
"sort"
"github.com/Siddheshk02/url-shortener/models"
"github.com/Siddheshk02/url-shortener/storage"
"github.com/gorilla/mux"
)
// Initialize the store as a RedisStore
var store = storage.NewRedisStore()
func ShortenURL(w http.ResponseWriter, r *http.Request) {
// Decode the request body into a ShortenRequest struct
var req models.ShortenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Save the URL using the store
shortURL, err := store.SaveURL(req.URL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
// Respond with the shortened URL
res := models.ShortenResponse{ShortURL: shortURL}
json.NewEncoder(w).Encode(res)
}
func RedirectURL(w http.ResponseWriter, r *http.Request) {
// Get the short URL from the request variables
vars := mux.Vars(r)
shortURL := vars["shortURL"]
// Get the original URL from the store
originalURL, err := store.GetOriginalURL(shortURL)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Redirect to the original URL
http.Redirect(w, r, originalURL, http.StatusMovedPermanently)
}
func GetTopDomains(w http.ResponseWriter, r *http.Request) {
// Get the domain counts from the store
domainCounts, err := store.GetDomainCounts()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Sort the domains by count
type kv struct {
Key string
Value int
}
var sortedDomains []kv
for k, v := range domainCounts {
sortedDomains = append(sortedDomains, kv{k, v})
}
sort.Slice(sortedDomains, func(i, j int) bool {
return sortedDomains[i].Value > sortedDomains[j].Value
})
// Prepare the top 3 domains to return
topDomains := make(map[string]int)
for i, domain := range sortedDomains {
if i >= 3 {
break
}
topDomains[domain.Key] = domain.Value
}
// Respond with the top 3 domains
json.NewEncoder(w).Encode(topDomains)
}
-
ShortenURL
: Handles URL shortening requests. -
RedirectURL
: Redirects users to the original URL. -
GetTopDomains
: Returns the most frequently shortened domains.
Step 7: Set Up Routes
In router/router.go
, define all the API routes:
package router
import (
"github.com/Siddheshk02/url-shortener/handlers"
"github.com/gorilla/mux"
)
func SetupRouter() *mux.Router {
// Initialize a new router
r := mux.NewRouter()
// Define route for getting the top domains
r.HandleFunc("/metrics", handlers.GetTopDomains).Methods("GET")
// Define route for shortening URLs
r.HandleFunc("/shorten", handlers.ShortenURL).Methods("POST")
// Define route for redirecting to the original URL
r.HandleFunc("/{shortURL}", handlers.RedirectURL).Methods("GET")
return r
}
-
/shorten
(POST): Shortens a given URL. -
/{shortURL}
(GET): Redirects to the original URL using the short URL. -
/metrics
(GET): Returns the top 3 most-used domains.
Step 8: Main Application Entry Point
In main.go
, initialize the router and start the HTTP server:
package main
import (
"log"
"net/http"
"github.com/Siddheshk02/url-shortener/router"
"github.com/gorilla/mux"
)
func main() {
// Setup the router from the router package
r := router.SetupRouter()
// Log all registered routes
r.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
path, err := route.GetPathTemplate()
if err != nil {
return err
}
log.Println("Registered route:", path)
return nil
})
log.Println("Starting server on 8080")
log.Fatal(http.ListenAndServe(":8080", r))
}
-
router.SetupRouter
: Initializes all routes. - Route Logging: Lists all registered routes for debugging.
- HTTP Server: Runs the app on port 8080.
Step 9: Dockerize the Application
Create a Dockerfile
to containerize the app:
# Start with the official Golang image
FROM golang:1.22-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy the Go modules files first and download dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy the rest of the application code
COPY . .
# Build the application
RUN go build -o url-shortener
# Expose the port on which the app will run
EXPOSE 8080
# Command to run the executable
CMD ["./url-shortener"]
The Dockerfile
builds the Go application into a Docker image and sets it up to run on port 8080
.
Step 10: Create a docker-compose.yml File
Use docker-compose.yml
to define both the app and the Redis service:
version: '3'
services:
url-shortener:
image: siddheshk02/url-shortener:latest
ports:
- "8080:8080"
depends_on:
- redis
environment:
- REDIS_HOST=redis
networks:
- app-network
redis:
image: redis
ports:
- "6379:6379"
networks:
- app-network
networks:
app-network:
driver: bridge
-
url-shortener
Service: Runs the Go app and depends on Redis. -
redis
Service: Provides the Redis database for storage. -
network
: Ensures communication between the two services.
Step 11: Run the Application
Build and run the application using Docker Compose:
docker-compose up --build
Output:
The Go app runs on http://localhost:8080
.
Redis is available on port 6379
.
Step 12: Test the API Endpoints
Use Postman, cURL, or a browser to test the service:
- Shorten a URL:
curl -X POST -H "Content-Type: application/json" \
-d '{"url": "https://www.example.com"}' \
http://localhost:8080/shorten
Response:
{
"short_url": "5c285a56"
}
Redirect a Shortened URL: Visit http://localhost:8080/5c285a56 in your browser.
It redirects to https://www.example.com.Get Top Domains:
curl http://localhost:8080/metrics
Response :
{
"example.com": 5,
"github.com": 3,
"google.com": 2
}
Conclusion
Congratulations! 🎉 You’ve built a fully functional URL Shortening Service in Go. This project covered:
- URL Shortening: Generating short URLs.
- Redirection: Navigating users from short to original URLs.
- Domain Tracking: Analyzing the most-used domains.
- Dockerization: Deploying the app in a containerized environment.
You can get the complete code repository here.
Next Steps
Here are some ways to enhance the project:
- Custom Short URLs: Allow users to create custom short links.
- Analytics: Track the number of visits per short URL.
- Expiration: Add expiration times for short URLs.
That’s it for this Tutorial. If this blog was helpful to you do share it with other and to get more information about Golang concepts, projects, etc. and to stay updated on the Tutorials do follow Siddhesh on Twitter and GitHub.
Until then Keep Learning, Keep Building 🚀🚀
Top comments (0)