有些浏览器中的地址(称为 URL)非常长且/或复杂,在网上有一些将他们转换成简短 URL 来使用的服务。我们的项目与此类似:它是具有 2 个功能的 web 服务(web service):
http://maps.google.com/maps?f=q&source=s_q&hl=en&geocode=&q=tokyo&sll=37.0625,-95.677068&sspn=68.684234,65.566406&ie=UTF8&hq=&hnear=Tokyo,+Japan&t=h&z=9
短网址被请求时,会把用户重定向到原始的长 URL。因此如果你在浏览器输入网址 (B),会被重定向到页面 (A)。
当程序运行在生产环境时,会收到很多短网址的请求,同时会有一些将长 URL 转换成短 URL 的请求。我们的程序要以什么样的结构存储这些数据呢?
上面给出的(A) 和 (B) 两种 URL 都是字符串,此外,它们相互关联:给定键 (B) 能获取到值 (A),他们互相映射(map)。要将数据存储在内存中,我们需要这种结构,它们几乎存在于所有的编程语言中,只是名称有所不同,例如“哈希表”或“字典”等。
Go 语言就有这种内建的映射(map):map[string]string
。
因此,对于URL映射存储来说,我们选择map集合,通常我们会为特定类型指定一个别名在严谨的程序中非常实用。Go 语言中通过关键字 type 来定义,因此有定义:
type URLStore map[string]string
它从短 URL 映射到长 URL,两者都是字符串。
要创建那种类型的变量,并命名为 m,使用:
m := make(URLStore)
假设 http://goto/a 映射到 http://google.com/ ,我们要把它们存储到 m 中,可以用如下语句:
m["a"] = "http://google.com/"
(键只是 http://goto/ 的后缀,其前缀总是不变的。)
要获得给定 “a” 对应的长 URL,可以这么写:
url := m["a"]
此时 url 的值等于 http://google.com/。
这里,变量 URLStore 是中心化的内存存储。当收到网络流量时,会有很多 Redirect 服务的请求。这些请求其实只涉及读操作:以给定的短 URL 作为键,返回对应的长 URL 的值。
然而,对 Add 服务的请求则大不相同,它们会更改 URLStore,添加新的键值对。当在瞬间收到大量更新请求时,可能会产生如下问题:添加操作可能被另一个同类请求打断,写入的长 URL 值可能会丢失;另外,读取和更改同时进行,导致可能读到脏数据。
代码中的 map 并不保证当开始更新数据时,会彻底阻止另一个更新操作的启动。也就是说,map 不是线程安全的,goto 会并发地为很多请求提供服务。因此必须使 URLStore 是线程安全的,以便可以从不同的线程访问它。最简单和经典的方法是为其增加一个锁,它是 Go 标准库 sync 包中的 Mutex 类型,必须导入到我们的代码中
现在,我们把 URLStore 类型的定义更改为一个结构体(就是字段的集合,类似 C 或 Java ),它含有两个字段:map 和 sync 包的 RWMutex:
import "sync"
type URLStore struct {
urls map[string]string // map from short to long URLs
mu sync.RWMutex
}
RWMutex 有两种锁:分别对应读和写。多个客户端可以同时设置读锁,但只有一个客户端可以设置写锁(以排除所有的读锁),有效地串行化变更,使他们按顺序生效。
我们将在 Get 函数中实现 Redirect 服务的读请求,在 Set 函数中实现 Add 服务的写请求。Get 函数类似下面这样:
func (s *URLStore) Get(key string) string {
s.mu.RLock()
url := s.urls[key]
s.mu.RUnlock()
return url
}
在读取值之前,先用 s.mu.RLock() 放置一个读锁,这样就不会有更新操作妨碍读取。数据读取后撤销锁定,以便挂起的更新操作可以开始。
如果键不存在于 map 中会怎样?会返回字符串的零值(空字符串)。
Set 函数同时需要 URL 的键值对,且必须放置写锁 Lock() 来排除同一时刻任何其他更新操作。函数返回布尔值 true 或 false 来表示 Set 操作是否成功:
func (s *URLStore) Set(key, url string) bool {
s.mu.Lock()
_, present := s.urls[key]
if present {
s.mu.Unlock()
return false
}
s.urls[key] = url
s.mu.Unlock()
return true
}
注意在更新后尽早调用 Unlock() 来释放对 URLStore 的锁定。
目前代码还比较简单,容易记得操作完成后调用 Unlock() 解锁。然而在代码更复杂时很容易忘记解锁,或者放置在错误的位置,往往导致问题很难追踪。对于这种情况 Go 提供了一个特殊关键字 defer。在本例中,可以在 Lock 之后立即示意 Unlock,不过其效果是 Unlock() 只会在函数返回之前被调用。
func (s *URLStore) Get(key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.urls[key]
}
func (s *URLStore) Set(key, url string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, present := s.urls[key]
if present {
return false
}
s.urls[key] = url
return true
}
URLStore 结构体中包含 map 类型的字段,使用前必须先用 make 初始化。在 Go 中创建一个结构体实例,一般是通过定义一个前缀为 New,能返回该类型已初始化实例的函数(通常是指向实例的指针)。
func NewURLStore() *URLStore {
return &URLStore{ urls: make(map[string]string) }
}
在 return 语句中,创建了 URLStore 字面量实例,其中包含初始化了的 map 映射。锁无需特别指明初始化,这是 Go 创建结构体实例的惯例。
& 是取址运算符,它将我们要返回的内容变成指针,因为 NewURLStore 返回类型是 *URLStore。然后调用该函数来创建 URLStore 变量:
var store = NewURLStore()
要新增一对短/长 URL 到 map 中,我们只需调用 s 上的 Set 方法,由于返回布尔值,可以把它包裹在 if 语句中:
if s.Set("a", "http://google.com") {
// 成功
}
要获取给定短 URL 对应的长 URL,调用 s 上的 Get 方法,将返回值放入变量 url:
if url := s.Get("a"); url != "" {
// 重定向到 url
} else {
// 键未找到
}
这里我们利用 Go 语言 if 语句的特性,可以在起始部分、条件判断前放置初始化语句。另外还需要一个 Count 方法以获取 map 中键值对的数量,可以使用内建的 len 函数:
func (s *URLStore) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.urls)
}
如何根据给定的长 URL 计算出短 URL 呢?
为此我们创建一个函数 genKey(n int) string {…},将 s.Count() 的当前值作为其整型参数传入。
这里如何生成短URL的算法不重要
现在,我们可以创建一个 Put 方法,接收一个长 URL,用 genKey 生成其短 URL 键,调用 Set 方法在此键下存储长 URL 数据,然后返回这个键:
func (s *URLStore) Put(url string) string {
for {
key := genKey(s.Count())
if s.Set(key, url) {
return key
}
}
// shouldn’t get here
return ""
}
for 循环会一直尝试调用 Set 直到成功为止(意味着生成了一个尚未存在的短网址)。现在我们定义好了数据存储,以及配套的可工作的函数。但这本身并不能完成任务,我们还需要开发 web 服务器以交付 Add 和 Redirect 服务。
type Store interface {
//Get 通过短URL得到长URL---用于重定向
Get(smallUrl string) string
//Put 传入长URL生成短URL
Put(longUrl string) string
//set 设置映射关系
set(smallUrl, longUrl string) bool
//genKey 传入一个整型,生成短URL返回
genKey(key int) string
//Count 计算当前保存的映射数量
count() (mappingNums int)
}
package dao
import (
"strconv"
"sync"
)
type RamStore struct {
urls map[string]string
mu sync.RWMutex
}
func NewRamStore() *RamStore {
return &RamStore{urls: make(map[string]string)}
}
func (s *RamStore) Get(smallUrl string) string {
s.mu.RLock()
defer s.mu.RUnlock()
url := s.urls[smallUrl]
return url
}
func (s *RamStore) Put(longUrl string) string {
for {
key := s.genKey(s.count())
//如果存在竞争,当前设置失败,那么就继续重试直到成功
if s.set(key, longUrl) {
return key
}
}
}
func (s *RamStore) set(smallUrl, longUrl string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, present := s.urls[smallUrl]
if present {
s.mu.Unlock()
return false
}
s.urls[smallUrl] = longUrl
return true
}
func (s *RamStore) count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.urls)
}
func (s *RamStore) genKey(key int) string {
return strconv.Itoa(key)
}
我们尚未编写启动程序的必要函数。它们(总是)类似 C,C++ 或 Java 中的 main() 函数,我们的 web 服务器由它启动,例如用如下命令在本地 8080 端口启动 web 服务器:
http.ListenAndServe(":8080", nil)
web 服务器会在一个无限循环中监听到来的请求,但我们必须定义针对这些请求,服务器该如何响应。可以用被称为 HTTP 处理器的 HandleFunc 函数来办到,例如代码:
http.HandleFunc("/add", Add)
如此,每个以 /add 结尾的请求都会调用 Add 函数(尚未完成)。
程序有两个 HTTP 处理器:
示意图:
最简单的 main() 函数类似这样:
func main() {
http.HandleFunc("/", Redirect)
http.HandleFunc("/add", Add)
http.ListenAndServe(":8080", nil)
}
对 /add 的请求由 Add 处理器处理,所有其他请求会被 Redirect 处理器处理。处理函数从到来的请求(一个类型为 *http.Request 的变量)中获取信息,然后产生响应并写入 http.ResponseWriter 类型变量 w。
Add 函数必须做的事有:
每个需求都转化为一行代码:
func Add(w http.ResponseWriter, r *http.Request) {
url := r.FormValue("url")
key := store.Put(url)
fmt.Fprintf(w, "http://localhost:8080/%s", key)
}
这里 fmt 包的 Fprintf 函数用来替换字符串中的关键字 %s,然后将结果作为响应发送回客户端。
注意 Fprintf 把数据写到了 ResponseWriter 中,其实 Fprintf 可以将数据写到任何实现了 io.Writer 的数据结构,即该结构实现了 Write 方法。
Go 中 io.Writer 称为接口,可见 Fprintf 利用接口变得十分通用,可以对很多不同的类型写入数据。Go 中接口的使用十分普遍,它使代码更通用。
还需要一个表单,仍然可以用 Fprintf 来输出,这次将常量写入 w。让我们来修改 Add,当未指定 URL 时显示 HTML 表单:
func Add(w http.ResponseWriter, r *http.Request) {
url := r.FormValue("url")
if url == "" {
fmt.Fprint(w, AddForm)
return
}
key := store.Put(url)
fmt.Fprintf(w, "http://localhost:8080/%s", key)
}
const AddForm = `
<form method="POST" action="/add">
URL: <input type="text" name="url">
<input type="submit" value="Add">
</form>
`
在那种情况下,发送字符串常量 AddForm 到客户端,它是 html 表单,包含一个 url 输入域和一个提交按钮,点击后发送 POST 请求到 /add。这样 Add 处理函数被再次调用,此时 url 的值来自文本域。(`` 用来创建原始字符串,否则按惯例 “” 将成为字符串边界。)
Redirect 函数在 HTTP 请求路径中找到键(短 URL 的键是请求路径去除首字符,在 Go 中可以写为 [1:]。例如请求 “/abc”,键就是 “abc”),用 Get 函数从 store 检索到对应的长 URL,对用户发送 HTTP 重定向。如果没找到 URL,发送 404 “Not Found” 错误取而代之:
func Redirect(w http.ResponseWriter, r *http.Request) {
key := r.URL.Path[1:]
url := store.Get(key)
if url == "" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
(http.NotFound 和 http.Redirect 是发送通用 HTTP 响应的工具函数。)
package server
import (
"LessUrl/dao"
"fmt"
"net/http"
)
const AddForm = `
<form method="POST" action="/add">
URL: <input type="text" name="url">
<input type="submit" value="Add">
</form>
`
//默认为内存存储
var store = dao.NewRamStore()
func Start() {
http.HandleFunc("/", redirect)
http.HandleFunc("/add", add)
http.ListenAndServe(":8080", nil)
}
func add(w http.ResponseWriter, r *http.Request) {
url := r.FormValue("url")
if url == "" {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, AddForm)
return
}
key := store.Put(url)
fmt.Fprintf(w, "http://localhost:8080/%s", key)
}
func redirect(w http.ResponseWriter, r *http.Request) {
key := r.URL.Path[1:]
url := store.Get(key)
if url == "" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
当 goto 进程(监听在 8080 端口的 web 服务器)终止,这迟早会发生,内存 map 中缩短的 URL 就会丢失。要保留这些数据,就得将其保存到磁盘文件中
我们将新增一个FileStore,使它可以保存数据到文件,且在 goto 启动时还原这些数据。为此我们使用 Go 标准库的 encoding/gob 包:它用于序列化和反序列化,将数据结构转换为字节数组(确切地说是切片),反之亦然。
通过 gob 包的 NewEncoder 和 NewDecoder 函数,可以指定数据要写入或读取的位置。返回的 Encoder 和 Decoder 对象提供了 Encode 和 Decode 方法,用于对文件写入和从中读取 Go 数据结构。
提示:Encoder 实现了 Writer 接口,同样 Decoder 实现了 Reader 接口。我们在 FileStore 上增加一个新的 file 字段(*os.File 类型),它是用于读写已打开文件的句柄。
type FileStore struct {
RamStore
file *os.File
}
FileStore是对RamStore的扩展,因此我们这里采用匿名字段实现继承
我们把这个文件命名为 store.gob,当初始化 FileStore 时将其作为参数传入:
var store = NewFileStore("store.gob")
接着,调整 NewFileStore函数:
func NewFileStore(filename string) *FileStore {
f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
log.Fatal("FileStore:", err)
}
return &FileStore{RamStore: NewRamStore(), file: f}
}
当 err 不为 nil,表示确实发生了错误,那么输出一条消息并停止程序执行。这是处理错误的一种方式,大多数情况下错误应该返回给调用函数,但这种检测错误的模式在 Go 代码中也很普遍。
打开该文件时启用了写入标志,更精确地说是“追加模式”。每当一对新的短/长 URL 在程序中创建后,我们通过 gob 把它存储到文件 “store.gob” 中。
为达到目的,定义一个新的结构体类型 record:
type record struct {
Key, URL string
}
以及新的 save 方法,将给定的键和 URL 组成 record ,以 gob 编码的形式写入磁盘。
func (s *FileStore) save(key, url string) error {
e := gob.NewEncoder(s.file)
return e.Encode(record{key, url})
}
goto 程序启动时,磁盘上存储的数据必须读取到 URLStore 的 map 中。为此,我们编写 load 方法:
func (s *FileStore) load() error {
if _, err := s.file.Seek(0, 0); err != nil {
return err
}
d := gob.NewDecoder(s.file)
var err error
for err == nil {
var r record
if err = d.Decode(&r); err == nil {
s.set(r.Key, r.URL)
}
}
if err == io.EOF {
return nil
}
return err
}
这个新的 load 方法会寻址(Seek)到文件的起始位置,读取并解码(Decode)每一条记录(record),然后用 Set 方法将数据存储到 map 中。再次注意无处不在的错误处理模式。文件的解码由一个无限循环完成,只要没有错误就会一直继续:
for err == nil {
…
}
如果得到了一个错误,可能是刚解码了最后一条记录,于是产生了 io.EOF(EndOfFile) 错误。若并非此种错误,表示产生了解码错误,用 return err 来返回它。对该方法的调用必须加入到 NewFileStore 中:
func NewFileStore(filename string) *FileStore {
f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
log.Fatal("RamStore:", err)
}
fileStore := &FileStore{RamStore: NewRamStore(), file: f}
err = fileStore.load()
if err != nil {
log.Println("error loading data in fileStore: ", err)
}
return fileStore
}
同时在 Put 方法中,当新的 URL 对加入到 map 中,也应该立即将它们保存到数据文件中:
这里FIleStore需要重写父类RamStore的Put方法
func (s *FileStore) Put(url string) string {
for {
key := s.genKey(s.count())
if s.set(key, url) {
if err := s.save(key, url); err != nil {
log.Println("Error saving to FileStore:", err)
}
return key
}
}
}
还需要修改server文件中默认创建的store实例:
//默认为内存存储
var store = dao.NewFileStore("store.gob")
package dao
import (
"encoding/gob"
"io"
"log"
"os"
)
type FileStore struct {
*RamStore
file *os.File
}
type record struct {
Key, URL string
}
func NewFileStore(filename string) *FileStore {
f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
log.Fatal("FileStore:", err)
}
fileStore := &FileStore{RamStore: NewRamStore(), file: f}
err = fileStore.load()
if err != nil {
log.Println("error loading data in fileStore: ", err)
}
return fileStore
}
func (s *FileStore) save(key, url string) error {
e := gob.NewEncoder(s.file)
return e.Encode(record{key, url})
}
func (s *FileStore) load() error {
if _, err := s.file.Seek(0, 0); err != nil {
return err
}
d := gob.NewDecoder(s.file)
var err error
for err == nil {
var r record
if err = d.Decode(&r); err == nil {
s.set(r.Key, r.URL)
}
}
if err == io.EOF {
return nil
}
return err
}
func (s *FileStore) Put(url string) string {
for {
key := s.genKey(s.count())
if s.set(key, url) {
if err := s.save(key, url); err != nil {
log.Println("Error saving to FileStore:", err)
}
return key
}
}
}
编译并测试这第二个版本的程序,或直接使用现有的可执行程序,验证关闭服务器(在终端窗口可以按 CTRL/C)并重启后,短 URL 仍然有效。