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



    短网址介绍

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

    • 添加 (Add): 给定一个较长的 URL,会将其转换成较短的版本,例如:
    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
    
    • 1
    • (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 来定义,因此有定义:

    type URLStore map[string]string
    
    • 1

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

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

    m := make(URLStore)
    
    • 1

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

    m["a"] = "http://google.com/"
    
    • 1

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

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

    url := m["a"]
    
    • 1

    此时 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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在读取值之前,先用 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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    注意在更新后尽早调用 Unlock() 来释放对 URLStore 的锁定。


    使用 defer 简化代码

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

    • Get 可以简化成以下代码(我们消除了本地变量 url):
    func (s *URLStore) Get(key string) string {
        s.mu.RLock()
        defer s.mu.RUnlock()
        return s.urls[key]
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • Set 的逻辑在某种程度上也变得清晰了(我们不用再考虑解锁的事了):
    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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    URLStore 工厂函数

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

    func NewURLStore() *URLStore {
        return &URLStore{ urls: make(map[string]string) }
    }
    
    • 1
    • 2
    • 3

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

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

    var store = NewURLStore()
    
    • 1

    使用 URLStore

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

    if s.Set("a", "http://google.com") {
        // 成功
    }
    
    • 1
    • 2
    • 3

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

    if url := s.Get("a"); url != "" {
        // 重定向到 url
    } else {
        // 键未找到
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

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

    func (s *URLStore) Count() int {
        s.mu.RLock()
        defer s.mu.RUnlock()
        return len(s.urls)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    如何根据给定的长 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 ""
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    for 循环会一直尝试调用 Set 直到成功为止(意味着生成了一个尚未存在的短网址)。现在我们定义好了数据存储,以及配套的可工作的函数。但这本身并不能完成任务,我们还需要开发 web 服务器以交付 Add 和 Redirect 服务。


    此部分完整代码

    • Store接口统一存储方法,以及那些方法暴露给用户,哪些对用户屏蔽
    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)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • RamStore是我们目前提供的基于内存存储映射关系的实现
    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)
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55

    用户界面:web 服务端

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

    http.ListenAndServe(":8080", nil)
    
    • 1

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

    http.HandleFunc("/add", Add)
    
    • 1

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

    程序有两个 HTTP 处理器:

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

    示意图:

    在这里插入图片描述
    最简单的 main() 函数类似这样:

    func main() {
        http.HandleFunc("/", Redirect)
        http.HandleFunc("/add", Add)
        http.ListenAndServe(":8080", nil)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

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

    Add 函数必须做的事有:

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

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

    func Add(w http.ResponseWriter, r *http.Request) {
        url := r.FormValue("url")
        key := store.Put(url)
        fmt.Fprintf(w, "http://localhost:8080/%s", key)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这里 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 = `
    
    URL:
    `
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    在那种情况下,发送字符串常量 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)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    (http.NotFound 和 http.Redirect 是发送通用 HTTP 响应的工具函数。)


    此部分完整代码

    package server
    
    import (
    	"LessUrl/dao"
    	"fmt"
    	"net/http"
    )
    
    const AddForm = `
    
    URL:
    `
    //默认为内存存储 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) }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    添加持久化存储

    持久化存储: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 类型),它是用于读写已打开文件的句柄。

    type FileStore struct {
        RamStore
        file *os.File
    }
    
    • 1
    • 2
    • 3
    • 4

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

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

    var store = NewFileStore("store.gob")
    
    • 1

    接着,调整 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}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    当 err 不为 nil,表示确实发生了错误,那么输出一条消息并停止程序执行。这是处理错误的一种方式,大多数情况下错误应该返回给调用函数,但这种检测错误的模式在 Go 代码中也很普遍。

    打开该文件时启用了写入标志,更精确地说是“追加模式”。每当一对新的短/长 URL 在程序中创建后,我们通过 gob 把它存储到文件 “store.gob” 中。

    为达到目的,定义一个新的结构体类型 record:

    type record struct {
        Key, URL string
    }
    
    • 1
    • 2
    • 3

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

    func (s *FileStore) save(key, url string) error {
    	e := gob.NewEncoder(s.file)
    	return e.Encode(record{key, url})
    }
    
    • 1
    • 2
    • 3
    • 4

    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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    这个新的 load 方法会寻址(Seek)到文件的起始位置,读取并解码(Decode)每一条记录(record),然后用 Set 方法将数据存储到 map 中。再次注意无处不在的错误处理模式。文件的解码由一个无限循环完成,只要没有错误就会一直继续:

    for err == nil {}
    
    • 1
    • 2
    • 3

    如果得到了一个错误,可能是刚解码了最后一条记录,于是产生了 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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    同时在 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
    		}
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    还需要修改server文件中默认创建的store实例:

    //默认为内存存储
    var store = dao.NewFileStore("store.gob")
    
    • 1
    • 2

    完整代码

    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
    		}
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65

    测试

    编译并测试这第二个版本的程序,或直接使用现有的可执行程序,验证关闭服务器(在终端窗口可以按 CTRL/C)并重启后,短 URL 仍然有效。


  • 相关阅读:
    k8s 使用HPA 进行弹性扩容pod节点,
    微软免费录屏软件——Free Screen Recorder Pro
    JDK命令使用总结
    别再用 System.currentTimeMillis 统计耗时了,太垃圾了,这个工具类好用到牛炸天了!
    一文简介,数字时代的数据交易模式
    《HTML5移动网站与App开发实战》简介
    大学生动物介绍网页设计作品 dreamweaver作业静态HTML网页设计模板 保护动物网页作业制作
    JAVA中的垃圾回收器(1)
    diffuser踩坑记录
    解读VideoComposer:多模态融合视频生成
  • 原文地址:https://blog.csdn.net/m0_53157173/article/details/126456104