Go 编写 Web 应用

BroQiang 1年前 访问:327 评论:32 关注:19

这是一篇官方的 Writing Web Applications 的翻译

如果 golang.org 打不开的话,可以把所有链接中的 golang.org 更换成 golang.google.cn ,这是一个官方的国内镜像,和 golang.org 的内容是一致的。

简介

本教程包含下面内容

  • 通过 load 与 save 方法创建数据结构

  • 使用 net/http 去构建 web 应用

  • 使用 html/template 去处理 HTML 模板

  • 使用 regexp 包去验证用户的输入

  • 使用闭包

需要已经掌握下面知识

  • 编程经验

  • 了解基本的 web 技术(HTTP,HTML)

  • 会一些简单 UNIX/DOS 命令行

入门

现在,你可以在 FreeBSD, Linux, OS X, 或 Windows 上运行 Go , 我们使用 $ 代表命令行的提示符, $ 开始的内容是输入的命令。

安装 Go (查看 安装文档 )

在 GOPATH 中为这个项目创建一个新的目录,然后切换到这个目录(cd)

$ cd $GOPATH/src
$ mkdir gowiki
$ cd go wiki

创建一个文件 wiki.go , 用你喜欢的编辑器打开它,用添加下面内容

package main

import (
    "fmt"
    "io/ioutil"
)

我们从 Go 的标准库中导入了 fmtio/ioutil 包, 稍后,当我们实现其他功能时,将会在 import 声明中添加更多的包。

数据结构

我们从定义数据结构开始,一个 wiki 由许多相关的页面组成,每一个页面都包含一个 title 和 body (页面的内容)。我们定义一个 Page 结构,包含两个代表 title 和 body 的字段。

type Page struct {
    Title string
    Body []byte
}

类型 []byte 就是一个 byte 切片(关于切片的更多信息,查看: Slices: usage and internals)。 Body 是元素是一个 []byte 而不是 string, 是因为将要使用的 io 库需要这个类型,可以在下面看到。

Page 结构中体现了如何将页面数据保存到内存中,但是如果是持久存储怎么办?我们可以在 Page 上创建一个 save 方法来解决:

func (p *Page) save() error {
    filename := p.Title + ".txt"

    return ioutil.WriteFile(filename, p.Body, 0600)
}

这个方法的签名解读:“这是一个名字叫 save 的方法,它的接收者 p 指向一个 Page 的指针。它不需要参数,并返回一个类型为 error 的值”

这个方法将保存 Page 的 Body 到一个文本文件。为了简单,我们使用 Title 来做它的文件名。

save 方法返回了一个 error 类型的值,是因为它是 WriteFile 的返回类型(一个用于将字节切片写入到文件的标准库函数)。 save 方法返回的是一个 error 类型的值,所以在写入文件出现错误的时候应该去处理它。如果没有出现错误,应该返回一个 nil (指针,接口和一些其他类型的零值)

八进制整数 0600, 是传给 WriteFile 的第三个参数,表示只有当前用于拥有文件的读写权限。(通过 Unix man page open(2)查看详细说明 “译者注: Linux 上可以通过这个命令查看: man 2 open ”)

除了保存页面,我们也需要加载页面:

func loadPage(title string) *Page {
    filename := title + ".txt"
    body, _ := ioutil.ReadFile(filename)

    return &Page{Title: title, Body: body}
}

loadPage 通过参数 title 拼接了文件名,将文件内容读取到一个新的变量 body 中,并且返回了一个通过 title 和 body 构造的指向 Page 字面的指针。

函数可以返回多个值。标准库函数 io.ReadFile 返回了一个 []byte 和一个 error 。在 loadPage 还没有处理错误,通过使用空白符 _ 将返回的错误丢弃(就是将值赋给一个空)。

但是,如果 ReadFile 遇到一个错误会发生什么? 例如,文件不存在。 我们不应该忽略这个错误, 让我们修改函数,来返回 *Page 和 error 。

func loadPage(title string) (*Page, error) {
    filename := title + ".txt"
    body, err := ioutil.ReadFile(filename)

    if err != nil {
        return nil, err
    }

    return &Page{Title: title, Body: body}, nil
}

此函数的调用者现在可以通过检查第二个参数; 如果它是 nil ,表示成功加载了一个 Page 。如果不是,它将是一个 error ,并且可以由调用者处理(详细查看 语言规范 )。

现在,我们有了一个简单的数据结构,可以保存并加载一个文件。让我们写一个 main 函数来测试我们写的东西。

func main() {
    p1 := &Page{Title: "TestPage", Body: []byte("This is a simple Page.")}
    p1.save()
    p2, _ := loadPage("TestPage")
    fmt.Println(string(p2.Body))
}

编译并执行这个代码,一个包含了 p1 的内容的名字是 TestPage.txt 的文件将被创建。这个文件将被读进结构 p2 中,并将它的 Body 元素打印到屏幕上。

你可以像这样编译并运行这个程序:

$ go build wiki.go 
$ ./wiki 
This is a simple Page.

(如果你使用的是 Windows ,你必须输入 wiki ,去掉 ./ 去执行这个程序)

点击这里去查看我们到现在写的代码

net http 包简介

这是一个简单的 web 服务器完整的工作示例:

package main

import (
    "fmt"
    "log"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:])
}

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

main 函数从调用 http.HandleFunc 开始,它告诉 http 包使用 handler 去处理所有访问 web 根目录("/")的请求。

然后它调用 http.ListenAndServe ,指定它在任意接口上监听 8080 端口(“:8080”)。 (现在不用去管它的第二个参数, nil ) 这个函数将被阻塞,知道程序终止。

ListenAndServe 始终返回一个 error ,并且它只有发生意外错误时才会返回。为了记录这个错误,我们在它的外面包裹一个叫 log.Fatal 的函数。

handler 是一个 http.HandlerFunc 类型的函数,它需要两个参数,http.ResponseWriter 和 http.Request 。

http.ResponseWriter 的值集合了 HTTP 服务器的响应。通过写入它,我们将数据发送到 HTTP 客户端。

http.Request 是表示客户端 HTTP 请求的数据结构。 r.URL.Path 是一个请求地址组成的路径。它的后面跟随 [1:] 的意思是: “创建一个从第一个字符到结尾的子切片” 。从路径中删除开头的 /

如果你运行程序,并访问这个地址:

http://localhost:8080/monkeys

程序将会显示包含下面内容的页面:

Hi there, I love monkeys!

使用 net http 去服务 wiki 页面

要想使用 net/http 包,它必须被导入:

import (
"fmt"
"io/ioutil"
"net/http"
)

我们来创建一个叫 viewHandler 的 handler, 用户可以通过它去查看 wiki 页面。它将去处理包含前缀 /view/ 的 URL 。

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
}

再次注意,使用 _ 来忽略 loadPage 返回的错误。这里是为了简单,但这是一个坏的习惯,稍后我们会处理它。

首先,这个函数从请求 URL 的 path 组件 r.URL.Path 中提取页面的标题。再通过切片 [len("/view/"):] 去掉前面路径前面的 /view/ ,这是因为路径总是以 /view/ 开始,它不是页面的一部分。

然后函数去加载页面数据,格式化成一个简单的 HTML 格式的字符串并写入到 http.ResponseWriter w

要使用这个 handler ,我们重写我们的 main 函数,去初始化 http 通过 viewHandler 去处理每一个 /view/ 下面的请求。

func main() {
    http.HandleFunc("/view/", viewHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

点击这里去查看我们到现在写的代码

我们创建一些测试页面数据(如: test.txt),编译我们的代码,并尝试服务器 wiki 页面。

使用编辑器打开 test.txt 文件,写入 Hello world

$ go build wiki.go
$ ./wiki

(如果你使用的是 Windows ,你必须输入 wiki ,去掉 ./ 去执行这个程序)

服务启动后,访问 http://localhost:8080/view/test 将会显示页面,标题是 test ,内容是 Hello world 。

编辑页面

wiki 不是一个不能编辑页面的 wiki 。我们来创建两个新的 handler, 一个叫 editHandler ,用来显示编辑页面的 form 表单,另一个叫 saveHandler ,用来保存 form 表单提交的数据。

首先,我们添加它们到 main() 中:

func main() {
    http.HandleFunc("/view/", viewHandler)
    http.HandleFunc("/edit/", editHandler)
    http.HandleFunc("/save/", saveHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

editHandler 函数加载页面(或者,它不存在时创建一个空的 Page 结构), 并且显示一个 HTML form 表单。

func editHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/edit/"):]
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    fmt.Fprintf(w, "<h1>Editing %s</h1>"+
        "<form action=\"/save/%s\" method=\"POST\">"+
        "<textarea name=\"body\">%s</textarea><br>"+
        "<input type=\"submit\" value=\"Save\">"+
        "</form>",
        p.Title, p.Title, p.Body)
}

这个函数可以很好的工作,但是所有硬编码的 HTML 都是非常丑的,所以还有更好的办法。

html template 包

html/template 是 Go 标准库的一部分。我们使用 html/template ,可以将 HTML 保存到一个单独的文件中,允许我们在不改动底层 Go 代码的情况下改变我们的编辑页面的布局。

首先,我们必须将 html/template 添加到 import 的列表中。我们也不会再使用 fmt 包了,所以将它删除。

import (
    "html/template"
    "io/ioutil"
    "net/http"
)

我们来创建一个包含 HTML 表单的模板文件。 打开一个文件,命名为 edit.html 并且添加下面内容:

<h1>Editing {{.Title}}</h1>

<form action="/save/{{.Title}}" method="POST">
<div><textarea name="body" rows="20" cols="80">{{printf "%s" .Body}}</textarea></div>
<div><input type="submit" value="Save"></div>
</form>

修改 editHandler 去使用模板,替换硬编码的 HTML:

func editHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/edit/"):]
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    t, _ := template.ParseFiles("edit.html")
    t.Execute(w, p)
}

template.ParseFiles 函数将读取 edit.html 的内容,并返回一个 *template.Template

执行模板的 t.Execute 方法,将生成的 HTML 写入到 http.ResponseWriter 。.Title.Body 的点符号参考 p.Titlep.Body

模板指定用双大括号括起来,printf "%s" .Body 是一个函数调用,它将输出的 .Body 的字节流替换成字符串,与调用 fmt.Printf 相同。html/template 可以保证模板操作只有安全的并且正确的 HTML 被生成,它会自动转译大于号符号 >,替换成 &gt;,确保用户数据不会破坏表单数据。

我们现在已经使用模板了,就再创建一个用于 viewHandler 函数调用的 view.html 模板:

<h1>{{.Title}}</h1>

<p>[<a href="/edit/{{.Title}}">edit</a>]</p>

<div>{{printf "%s" .Body}}</div>

修改对应的 viewHandler 函数:

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    t, _ := template.ParseFiles("view.html")
    t.Execute(w, p)
}

注意,我们在两个 handler 中使用了几乎完全一样的模板代码。我们将模板代码放到一个单独的函数中,来去除这个重复:

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
    t, _ := template.ParseFiles(tmpl + ".html")
    t.Execute(w, p)
}

修改连个 handler 来使用这个函数:

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/edit/"):]
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    renderTemplate(w, "edit", p)
}

我们可以先注释掉在 main 函数中注册的未实现的 save handler,然后就可以重新编辑并测试我们的程序了。

点击这里去查看我们到现在写的代码

处理不存在的页面

如果你输入 /view/APageThatDoesntExist ,你将看到一个包含 HTML 的页面,这是因为它忽略了 loadPage error 返回值,并继续去尝试填充了一个没有数据的模板去替换,如果请求的页面不存在,它应该重定向到编辑页面,这样就可以创建内容。

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, err := loadPage(title)
    if err != nil {
        http.Redirect(w, r, "/edit/"+title, http.StatusFound)
        return
    }
    renderTemplate(w, "view", p)
}

http.Redirect 添加一个 http 状态码 http.StatusFound (302) 和 一个地址到 http 响应。

保存页面

saveHandler 函数将处理编辑页面 form 表单提交的数据,在 mian 函数中取消和此相关的注释,然后实现这个 handler 。

func saveHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/save/"):]
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    p.save()
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

这个页面的 title (由 URL 提供)和表单中的唯一字段 Body 被保存到一个新的 Page ,然后调用 save() 方法写入数据到一个文件,并且将客户端重定向到 /view/ 页面。

FormValue 的返回值是一个字符串类型,我们在将它填充到 Page 前必选转换成 []byte,使用 []byte(body) 可以执行转换。

错误处理

在我们的程序中,有几个地方忽略了错误。这种做法非常不好,尤其是当错误发生时,我们的程序会出现意外的行为。一个好的方案是去处理错误,并将错误消息返回给用户。这样,当出现错误的时候,服务器将按照我们想要的方式运行,并且通知给用户。

首先,我们在 renderTemplate 中处理错误:

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
    t, err := template.ParseFiles(tmpl + ".html")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    err = t.Execute(w, p)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
}

http.Error 函数发送一个指定的 HTTP 状态码(示例中是 Internal Server Error)和错误消息。这个抽离出一个单独的函数是一个多么明智的做法,否则要改多个地方了。

现在,我们来修改 saveHandler

func saveHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/save/"):]
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    err := p.save()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

p.save() 时发生任何错误,将会报告给用户。

模板缓存

这是一段低效的代码: 页面每一次展示的时候,renderTemplate 都会调用 ParseFiles 。好的方式是在程序初始化的时候只调用一个 ParseFiles ,解析所有的模板到一个单一的 *Template ,然后我们可以使用 ExecuteTemplate 方法去渲染指定的模板。

首先,创建一个全局变量 templates ,并且通过 ParseFiles 初始化它。

var templates = template.Must(template.ParseFiles("edit.html", "view.html"))

template.Must 函数是一个方便的包装器,当传入一个不是 nilerror 时会产生一个 panic,除此之外,返回的 *Template 不会改变。一个 panic 在这里是合适,如果模板不能被加载只有退出程序才是明智的。

ParseFiles 函数接受任意数量的用来识别我们的模板文件的字符串参数,然后将这些文件解析到 templates,并且通过基础的文件名命名。如果我们要添加更多的模板到我们的程序,我们需要将它们的名字添加到 ParseFiles 的参数中。

然后我们修改 renderTemplate 函数,通过 templates.ExecuteTemplate 去调用与名称相对应的模板。

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
    err := templates.ExecuteTemplate(w, tmpl+".html", p)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
}

注意,模板名称是模板的文件名,所以我们必须在 teml 参数后面添加 .html

验证

可能你已经注意到,这个程序有一个严重的安全漏洞:用户可以通过任意路径在服务器上读/写。为了解决这个,我们可以编写一个函数,通过正则表达式来验证 title 。

首先,在 import 列表添加 regexp ,然后我们可以创建一个全局变量去保存我们的正则表达式。

var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")

函数 regexp.MustCompile 将解析并编译正则表达式,返回一个 regexp.RegexpMustCompile 区别于 Compile 的是,当正则表达式编译失败的时候,它将产生一个 panic, 而 Compile 会通过第二个参数返回一个 error

现在,我们编写一个函数来使用 validPath 来验证路径并提取页面的 title 。

func getTitle(w http.ResponseWriter, r *http.Request) (string, error) {
    m := validPath.FindStringSubmatch(r.URL.Path)
    if m == nil {
        http.NotFound(w, r)
        return "", errors.New("Invalid Page Title")
    }
    return m[2], nil // title 是第二个子表达式
}

如果 title 是有效的,它将和一个值为 nilerror 一起返回。如果 title 是无效的,函数将写入一个 404 Not Found 错误到 HTTP 链接,并且返回一个错误到 handler 。要创建一个新的 error ,我们还必须要导入 errors 包。

我们将 getTitle 调用放到每一个 handler 中:

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title, err := getTitle(w, r)
    if err != nil {
        return
    }
    p, err := loadPage(title)
    if err != nil {
        http.Redirect(w, r, "/edit/"+title, http.StatusFound)
        return
    }
    renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request) {
    title, err := getTitle(w, r)
    if err != nil {
        return
    }
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    renderTemplate(w, "edit", p)
}
func saveHandler(w http.ResponseWriter, r *http.Request) {
    title, err := getTitle(w, r)
    if err != nil {
        return
    }
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    err = p.save()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

函数文字和闭包简介

捕获每一个函数中的错误处理条件会产生大量的冗余代码。如果我们将每一个 handler 包装进一个函数去处理验证和错误检查呢? Go 的函数字面提供了一个强大的抽象功能可以帮助我们实现它。

首先,我们重写每一个 handler 函数的定义,接收一个字符串类型的 title 参数:

func viewHandler(w http.ResponseWriter, r *http.Request, title string)
func editHandler(w http.ResponseWriter, r *http.Request, title string)
func saveHandler(w http.ResponseWriter, r *http.Request, title string)

现在我们定义一个参数是上面函数类型的包装函数,并且返回一个类型为 http.HandlerFunc 的函数( 这个返回值是为了满足 http.HandleFunc 的参数 )。

func makeHandler(fn func (http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // 这里将从 Request 中提取 title
        // 并且调用提供的 handler 'fn'
    }
}

这个返回的函数叫闭包,因为它包含了在它外部定义的值。在这种情况,变量 fn (传给 makeHandler 的单个参数) 包裹在闭包中, 变量 fn 将会是我们的 save,editview handler 中的一个。

现在我们可以从 getTitle 中提取代码,并在此处使用它(稍作修改):

func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        m := validPath.FindStringSubmatch(r.URL.Path)
        if m == nil {
            http.NotFound(w, r)
            return
        }
        fn(w, r, m[2])
    }
}

makeHandler 返回的闭包是一个包含 http.ResponseWriterhttp.Request 参数的函数( 换句话说,一个 http.HandlerFunc )。这个闭包从请求路径中提取 title ,并且通过 title 验证器 regexp 验证它。 如果标题是无效的,一个 error 将通过 http.NotFound 函数写入到 ResponseWriter 。如果 title 是有效的,它包裹的 handler 函数 fn 将传入 ResponseWriter, Request, 和 title 参数被调用。

现在,在 main 函数中,我们可以在 handler 被注册到 http 包之前,通过 makeHandler 来包装它们:

func main() {
    http.HandleFunc("/view/", makeHandler(viewHandler))
    http.HandleFunc("/edit/", makeHandler(editHandler))
    http.HandleFunc("/save/", makeHandler(saveHandler))

    log.Fatal(http.ListenAndServe(":8080", nil))
}

最后,我们从 handler 中删除对 getTitle 的调用,使它们更简单:

func viewHandler(w http.ResponseWriter, r *http.Request, title string) {
    p, err := loadPage(title)
    if err != nil {
        http.Redirect(w, r, "/edit/"+title, http.StatusFound)
        return
    }
    renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request, title string) {
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    renderTemplate(w, "edit", p)
}
func saveHandler(w http.ResponseWriter, r *http.Request, title string) {
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    err := p.save()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

试试看

点击这里可以查看最终的代码

重新编译代码并运行它:

$ go build wiki.go
$ ./wiki

访问 http://localhost:8080/view/ANewPage 将显示页面编辑表单, 你应该可以输入一些文本,点击 save,然后会重定向到新创建的页面。

其他任务

下面是希望你可能希望自己解决的任务清单:

  • 将模板保存到 tmpl/ 目录并且将页面数据保存到 data/

  • 添加一个 handler 将 web 根目录重定向到 /view/FrontPage

  • 完善页面模板,使它们成为一个有效的 HTML 并添加一些 CSS 规则。

  • 通过 [PageName] 实例实现一些页面间的链接,<a href="/view/PageName">PageName</a> (提示: 你可以通过 regexp.ReplaceAllFunc 来实现这个)。

评论
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
重复提交表单
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
xxxie 3周前
111111111111111111111111111111111111111111111111111111
公告
博客正式从原来的 Github Page 迁移到这里,原本的内容可以通过 broqiang.github.io 访问
返回顶部