前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go短网址项目实战---上

Go短网址项目实战---上

作者头像
大忽悠爱学习
发布2022-08-23 10:10:41
5450
发布2022-08-23 10:10:41
举报
文章被收录于专栏:c++与qt学习c++与qt学习

Go短网址项目实战---上


短网址介绍

有些浏览器中的地址(称为 URL)非常长且/或复杂,在网上有一些将他们转换成简短 URL 来使用的服务。我们的项目与此类似:它是具有 2 个功能的 web 服务(web service):

  • 添加 (Add): 给定一个较长的 URL,会将其转换成较短的版本,例如:
代码语言:javascript
复制
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
  • (A) 转变为:http://goto/UrcGq
  • (B) 并保存这对数据

  • 重定向 (Redirect)

短网址被请求时,会把用户重定向到原始的长 URL。因此如果你在浏览器输入网址 (B),会被重定向到页面 (A)。


数据结构和前端界面

当程序运行在生产环境时,会收到很多短网址的请求,同时会有一些将长 URL 转换成短 URL 的请求。我们的程序要以什么样的结构存储这些数据呢?

上面给出的(A) 和 (B) 两种 URL 都是字符串,此外,它们相互关联:给定键 (B) 能获取到值 (A),他们互相映射(map)。要将数据存储在内存中,我们需要这种结构,它们几乎存在于所有的编程语言中,只是名称有所不同,例如“哈希表”或“字典”等。

Go 语言就有这种内建的映射(map):map[string]string

因此,对于URL映射存储来说,我们选择map集合,通常我们会为特定类型指定一个别名在严谨的程序中非常实用。Go 语言中通过关键字 type 来定义,因此有定义:

代码语言:javascript
复制
type URLStore map[string]string

它从短 URL 映射到长 URL,两者都是字符串。

要创建那种类型的变量,并命名为 m,使用:

代码语言:javascript
复制
m := make(URLStore)

假设 http://goto/a 映射到 http://google.com/ ,我们要把它们存储到 m 中,可以用如下语句:

代码语言:javascript
复制
m["a"] = "http://google.com/"

(键只是 http://goto/ 的后缀,其前缀总是不变的。)

要获得给定 “a” 对应的长 URL,可以这么写:

代码语言:javascript
复制
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:

代码语言:javascript
复制
import "sync"
type URLStore struct {
    urls map[string]string        // map from short to long URLs
    mu sync.RWMutex
}

RWMutex 有两种锁:分别对应读和写。多个客户端可以同时设置读锁,但只有一个客户端可以设置写锁(以排除所有的读锁),有效地串行化变更,使他们按顺序生效。

我们将在 Get 函数中实现 Redirect 服务的读请求,在 Set 函数中实现 Add 服务的写请求。Get 函数类似下面这样:

代码语言:javascript
复制
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 操作是否成功:

代码语言:javascript
复制
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 的锁定。


使用 defer 简化代码

目前代码还比较简单,容易记得操作完成后调用 Unlock() 解锁。然而在代码更复杂时很容易忘记解锁,或者放置在错误的位置,往往导致问题很难追踪。对于这种情况 Go 提供了一个特殊关键字 defer。在本例中,可以在 Lock 之后立即示意 Unlock,不过其效果是 Unlock() 只会在函数返回之前被调用。

  • Get 可以简化成以下代码(我们消除了本地变量 url):
代码语言:javascript
复制
func (s *URLStore) Get(key string) string {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return s.urls[key]
}
  • Set 的逻辑在某种程度上也变得清晰了(我们不用再考虑解锁的事了):
代码语言:javascript
复制
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 工厂函数

URLStore 结构体中包含 map 类型的字段,使用前必须先用 make 初始化。在 Go 中创建一个结构体实例,一般是通过定义一个前缀为 New,能返回该类型已初始化实例的函数(通常是指向实例的指针)。

代码语言:javascript
复制
func NewURLStore() *URLStore {
    return &URLStore{ urls: make(map[string]string) }
}

在 return 语句中,创建了 URLStore 字面量实例,其中包含初始化了的 map 映射。锁无需特别指明初始化,这是 Go 创建结构体实例的惯例。

& 是取址运算符,它将我们要返回的内容变成指针,因为 NewURLStore 返回类型是 *URLStore。然后调用该函数来创建 URLStore 变量:

代码语言:javascript
复制
var store = NewURLStore()

使用 URLStore

要新增一对短/长 URL 到 map 中,我们只需调用 s 上的 Set 方法,由于返回布尔值,可以把它包裹在 if 语句中:

代码语言:javascript
复制
if s.Set("a", "http://google.com") {
    // 成功
}

要获取给定短 URL 对应的长 URL,调用 s 上的 Get 方法,将返回值放入变量 url:

代码语言:javascript
复制
if url := s.Get("a"); url != "" {
    // 重定向到 url
} else {
    // 键未找到
}

这里我们利用 Go 语言 if 语句的特性,可以在起始部分、条件判断前放置初始化语句。另外还需要一个 Count 方法以获取 map 中键值对的数量,可以使用内建的 len 函数:

代码语言:javascript
复制
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 数据,然后返回这个键:

代码语言:javascript
复制
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 服务。


此部分完整代码

  • Store接口统一存储方法,以及那些方法暴露给用户,哪些对用户屏蔽
代码语言:javascript
复制
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)
}
  • RamStore是我们目前提供的基于内存存储映射关系的实现
代码语言:javascript
复制
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)
}

用户界面:web 服务端

我们尚未编写启动程序的必要函数。它们(总是)类似 C,C++ 或 Java 中的 main() 函数,我们的 web 服务器由它启动,例如用如下命令在本地 8080 端口启动 web 服务器:

代码语言:javascript
复制
http.ListenAndServe(":8080", nil)

web 服务器会在一个无限循环中监听到来的请求,但我们必须定义针对这些请求,服务器该如何响应。可以用被称为 HTTP 处理器的 HandleFunc 函数来办到,例如代码:

代码语言:javascript
复制
http.HandleFunc("/add", Add)

如此,每个以 /add 结尾的请求都会调用 Add 函数(尚未完成)。

程序有两个 HTTP 处理器:

  • Redirect,用于对短 URL 重定向
  • Add,用于处理新提交的 URL

示意图:

在这里插入图片描述
在这里插入图片描述

最简单的 main() 函数类似这样:

代码语言:javascript
复制
func main() {
    http.HandleFunc("/", Redirect)
    http.HandleFunc("/add", Add)
    http.ListenAndServe(":8080", nil)
}

对 /add 的请求由 Add 处理器处理,所有其他请求会被 Redirect 处理器处理。处理函数从到来的请求(一个类型为 *http.Request 的变量)中获取信息,然后产生响应并写入 http.ResponseWriter 类型变量 w。

Add 函数必须做的事有:

  • 读取长 URL,即:用 r.FormValue(“url”) 从 HTML 表单提交的 HTTP 请求中读取 URL
  • 使用 store 上的 Put 方法存储长 URL
  • 将对应的短 URL 发送给用户

每个需求都转化为一行代码:

代码语言:javascript
复制
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 表单:

代码语言:javascript
复制
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” 错误取而代之:

代码语言:javascript
复制
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 响应的工具函数。)


此部分完整代码

代码语言:javascript
复制
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)
}

添加持久化存储

持久化存储:gob

当 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 类型),它是用于读写已打开文件的句柄。

代码语言:javascript
复制
type FileStore struct {
    RamStore
    file *os.File
}

FileStore是对RamStore的扩展,因此我们这里采用匿名字段实现继承

我们把这个文件命名为 store.gob,当初始化 FileStore 时将其作为参数传入:

代码语言:javascript
复制
var store = NewFileStore("store.gob")

接着,调整 NewFileStore函数:

代码语言:javascript
复制
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:

代码语言:javascript
复制
type record struct {
    Key, URL string
}

以及新的 save 方法,将给定的键和 URL 组成 record ,以 gob 编码的形式写入磁盘。

代码语言:javascript
复制
func (s *FileStore) save(key, url string) error {
	e := gob.NewEncoder(s.file)
	return e.Encode(record{key, url})
}

goto 程序启动时,磁盘上存储的数据必须读取到 URLStore 的 map 中。为此,我们编写 load 方法:

代码语言:javascript
复制
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 中。再次注意无处不在的错误处理模式。文件的解码由一个无限循环完成,只要没有错误就会一直继续:

代码语言:javascript
复制
for err == nil {
    …
}

如果得到了一个错误,可能是刚解码了最后一条记录,于是产生了 io.EOF(EndOfFile) 错误。若并非此种错误,表示产生了解码错误,用 return err 来返回它。对该方法的调用必须加入到 NewFileStore 中:

代码语言:javascript
复制
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方法

代码语言:javascript
复制
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实例:

代码语言:javascript
复制
//默认为内存存储
var store = dao.NewFileStore("store.gob")

完整代码

代码语言:javascript
复制
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 仍然有效。


本文参与?腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-08-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客?前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与?腾讯云自媒体分享计划? ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Go短网址项目实战---上
  • 短网址介绍
  • 数据结构和前端界面
    • 使程序线程安全
      • 使用 defer 简化代码
        • URLStore 工厂函数
          • 使用 URLStore
            • 此部分完整代码
            • 用户界面:web 服务端
              • 此部分完整代码
              • 添加持久化存储
                • 持久化存储:gob
                  • 完整代码
                    • 测试
                    相关产品与服务
                    数据保险箱
                    数据保险箱(Cloud Data Coffer Service,CDCS)为您提供更高安全系数的企业核心数据存储服务。您可以通过自定义过期天数的方法删除数据,避免误删带来的损害,还可以将数据跨地域存储,防止一些不可抗因素导致的数据丢失。数据保险箱支持通过控制台、API 等多样化方式快速简单接入,实现海量数据的存储管理。您可以使用数据保险箱对文件数据进行上传、下载,最终实现数据的安全存储和提取。
                    领券
                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
                    http://www.vxiaotou.com