模板户:专注于dede模板,织梦源码,织梦模板,网站模板,dedecms模板,网站源码,dedecms教程以及各类手机网站模板和企业网站模板分享.

织梦模板

VIP

CMS教程

站长学院

随机织梦教程

最新织梦教程

织梦模板随机Tags

关键词排名 原创 用户体验 玩具外贸网站源码 关键词优化 搜索引擎 汽车配件织梦模板 网站title 太阳能光伏网站源码 网站 财务会计网站源码 餐饮管理织梦模板 餐饮加盟网站源码 关键词 汽车润滑油网站源码 蜘蛛 优化 织梦伪静态 高亮 个人网站

《Go Web编程》之ChitChat论坛-文末有福利

www.mobanhu.com / 2019-01-03 08:49:09
Go中国 Go中国

本文主要内容

  • 使用Go进行Web编程的方法

  • 设计一个典型的Go Web应用

  • 编写一个完整的Go Web应用

  • 了解Go Web应用的各个组成部分

本文我们将会构建一个简单的网上论坛Web应用,这个应用同样非常基础,但是却有用得多:它允许用户登录到论坛里面,然后在论坛上发布新帖子,又或者回复其他用户发表的帖子。在阅读完这一文之后,你将进一步地了解到使用Go进行Web应用开发的相关方法。

ChitChat简介

网上论坛无处不在,它们是互联网上最受欢迎的应用之一,与旧式的电子公告栏(BBS)、新闻组(Usenet)和电子邮件一脉相承。雅虎公司和Google公司的群组(Groups)都非常流行,雅虎报告称,他们总共拥有1000万个群组以及1.15亿个群组成员,其中每个群组都拥有一个自己的论坛;而全球最具人气的网上论坛之一——Gaia在线——则拥有2300万注册用户以及接近230亿张帖子,并且这些帖子的数量还在以每天上百万张的速度持续增长。尽管现在出现了诸如Facebook这样的社交网站,但论坛仍然是人们在网上进行交流时最为常用的手段之一。作为例子,图1展示了GoogleGroups的样子。

图1 一个网上论坛示例:GoogleGroups里面的Go编程语言论坛

从本质上来说,网上论坛就相当于一个任何人都可以通过发帖来进行对话的公告板,公告板上面可以包含已注册用户以及未注册的匿名用户。论坛上的对话称为帖子(thread),一个帖子通常包含了作者想要讨论的一个主题,而其他用户则可以通过回复这个帖子来参与对话。比较复杂的论坛一般都会按层级进行划分,在这些论坛里面,可能会有多个讨论特定类型主题的子论坛存在。大多数论坛都会由一个或多个拥有特殊权限的用户进行管理,这些拥有特殊权限的用户被称为版主(moderator)。

在本文中,我们将会开发一个名为ChitChat的简易网上论坛。为了让这个例子保持简单,我们只会为ChitChat实现网上论坛的关键特性:在这个论坛里面,用户可以注册账号,并在登录之后发表新帖子又或者回复已有的帖子;未注册用户可以查看帖子,但是无法发表帖子或是回复帖子。现在,让我们首先来思考一下如何设计ChitChat这个应用。

应用设计

Web应用的一般工作流程是客户端向服务器发送请求,然后服务器对客户端进行响应(如图2所示),ChitChat应用的设计也遵循这一流程。

图2 Web应用的一般工作流程,客户端向服务器发送请求,然后等待接收响应

ChitChat的应用逻辑会被编码到服务器里面。服务器会向客户端提供HTML页面,并通过页面的超链接向客户端表明请求的格式以及被请求的数据,而客户端则会在发送请求时向服务器提供相应的数据,如图3所示。

图3 HTTP请求的URL格式

请求的格式通常是由应用自行决定的,比如,ChitChat的请求使用的是以下格式:http://<服务器名><处理器名>?<参数>

服务器名(server name)是ChitChat服务器的名字,而处理器名(handler name)则是被调用的处理器的名字。处理器的名字是按层级进行划分的:位于名字最开头是被调用模块的名字,而之后跟着的则是被调用子模块的名字,以此类推,位于处理器名字最末尾的则是子模块中负责处理请求的处理器。比如,对/thread/read这个处理器名字来说,thread是被调用的模块,而read则是这个模块中负责读取帖子内容的处理器。

该应用的参数(parameter)会以URL查询的形式传递给处理器,而处理器则会根据这些参数对请求进行处理。比如说,假设客户端要向处理器传递帖子的唯一ID,那么它可以将URL的参数部分设置成id=123,其中123就是帖子的唯一ID。

如果chitchat就是ChitChat服务器的名字,那么根据上面介绍的URL格式规则,客户端发送给ChitChat服务器的URL可能会是这样的:http://chitchat/thread/read?id=123。

当请求到达服务器时,多路复用器(multiplexer)会对请求进行检查,并将请求重定向至正确的处理器进行处理。处理器在接收到多路复用器转发的请求之后,会从请求中取出相应的信息,并根据这些信息对请求进行处理。在请求处理完毕之后,处理器会将所得的数据传递给模板引擎,而模板引擎则会根据这些数据生成将要返回给客户端的HTML,整个过程如图4所示。

图4 服务器在典型Web应用中的工作流程

数据模型

绝大多数应用都需要以某种方式与数据打交道。对ChitChat来说,它的数据将被存储到关系式数据库PostgreSQL里面,并通过SQL与之交互。

ChitChat的数据模型非常简单,只包含4种数据结构,它们分别是:

  • User——表示论坛的用户信息;

  • Session——表示论坛用户当前的登录会话;

  • Thread——表示论坛里面的帖子,每一个帖子都记录了多个论坛用户之间的对话;

  • Post——表示用户在帖子里面添加的回复。

以上这4种数据结构都会被映射到关系数据库里面,图5展示了这4种数据结构是如何与数据库交互的。

ChitChat论坛允许用户在登录之后发布新帖子或者回复已有的帖子,未登录的用户可以阅读帖子,但是不能发布新帖子或者回复帖子。为了对应用进行简化,ChitChat论坛没有设置版主这一职位,因此用户在发布新帖子或者添加新回复的时候不需要经过审核。

图5 Web应用访问数据存储系统的流程

在了解了ChitChat的设计方案之后,现在可以开始考虑具体的实现代码了。在开始学习ChitChat的实现代码之前,请注意,如果你在阅读本章展示的代码时遇到困难,又或者你是刚开始学习Go语言,那么为了更好地理解本章介绍的内容,你可以考虑先花些时间阅读一本Go语言的编程入门书,比如,由William Kennedy、Brian Ketelsen和Erik St. Martin撰写的《Go语言实战》就是一个很不错的选择。

除此之外,在阅读本章时也请尽量保持耐性:本章只是从宏观的角度展示Go Web应用的样子,并没有对Web应用的细节作过多的解释,而是将这些细节留到之后的章节再进一步说明。在有需要的情况下,本章也会在介绍某种技术的同时,说明在哪一章可以找到这一技术的更多相关信息。

请求的接收与处理

请求的接收和处理是所有Web应用的核心。正如之前所说,Web应用的工作流程如下。

(1)客户端将请求发送到服务器的一个URL上。

(2)服务器的多路复用器将接收到的请求重定向到正确的处理器,然后由该处理器对请求进行处理。

(3)处理器处理请求并执行必要的动作。

(4)处理器调用模板引擎,生成相应的HTML并将其返回给客户端。

让我们先从最基本的根URL(/)来考虑Web应用是如何处理请求的:当我们在浏览器上输入地址http://localhost的时候,浏览器访问的就是应用的根URL。在接下来的几个小节里面,我们将会看到ChitChat是如何处理发送至根URL的请求的,以及它又是如何通过动态地生成HTML来对请求进行响应的。

1 多路复用器

因为编译后的二进制Go应用总是以main函数作为执行的起点,所以我们在对Go应用进行介绍的时候也总是从包含main函数的主源码文件(main source code file)开始。ChitChat应用的主源码文件为main.go,代码清单1展示了它的一个简化版本。

代码清单1 main.go文件中的main函数,函数中的代码经过了简化

package main
import (
 "net/http"
)

func main()
{
 mux := http.NewServeMux()
 files := http.FileServer(http.Dir("/public"))
 mux.Handle("/static/", http.StripPrefix("/static/", files))
  mux.HandleFunc("/", index)
 server := &http.Server{
  Addr: "0.0.0.0:8080",
  Handler: mux,
 }
 server.ListenAndServe()
}

main.go``中``首先创建了一个多路复用器,然后通过一些代码将接收到的请求重定向到处理器。net/http标准库提供了一个默认的多路复用器,这个多路复用器可以通过调用NewServeMux函数来创建:

mux := http.NewServeMux()

为了将发送至根URL的请求重定向到处理器,程序使用了HandleFunc函数:

mux.HandleFunc("/", index)

HandleFunc函数接受一个URL和一个处理器的名字作为参数,并将针对给定URL的请求转发至指定的处理器进行处理,因此对上述调用来说,当有针对根URL的请求到达时,该请求就会被重定向到名为index的处理器函数。此外,因为所有处理器都接受一个ResponseWriter和一个指向Request结构的指针作为参数,并且所有请求参数都可以通过访问Request结构得到,所以程序并不需要向处理器显式地传入任何请求参数。

需要注意的是,前面的介绍模糊了处理器以及处理器函数之间的区别:我们刚开始谈论的是处理器,而现在谈论的却是处理器函数。这是有意而为之的——尽管处理器和处理器函数提供的最终结果是一样的,但它们实际上并不相同。本书的第3章将对处理器和处理器函数之间的区别做进一步的说明,但是现在让我们暂时先忘掉这件事,继续研究ChitChat应用的代码实现。

2 服务静态文件

除负责将请求重定向到相应的处理器之外,多路复用器还需要为静态文件提供服务。为了做到这一点,程序使用FileServer函数创建了一个能够为指定目录中的静态文件服务的处理器,并将这个处理器传递给了多路复用器的Handle函数。除此之外,程序还使用StripPrefix函数去移除请求URL中的指定前缀:

files := http.FileServer(http.Dir("/public"))
mux.Handle("/static/", http.StripPrefix("/static/", files))

当服务器接收到一个以/static/开头的URL请求时,以上两行代码会移除URL中的/static/字符串,然后在public目录中查找被请求的文件。比如说,当服务器接收到一个针对文件http://localhost/static/css/bootstrap.min.css的请求时,它将会在public目录中查找以下文件:

<application root>/css/bootstrap.min.css

当服务器成功地找到这个文件之后,会把它返回给客户端。

3 创建处理器函数

正如之前的小节所说,ChitChat应用会通过HandleFunc函数把请求重定向到处理器函数。正如代码清单2所示,处理器函数实际上就是一个接受ResponseWriterRequest指针作为参数的Go函数。

代码清单2 main.go文件中的index处理器函数

func index(w http.ResponseWriter, r *http.Request) {
 files := []string{"templates/layout.html",
          "templates/navbar.html",
          "templates/index.html",}
 templates := template.Must(template.ParseFiles(files...))
 threads, err := data.Threads(); if err == nil {
  templates.ExecuteTemplate(w, "layout", threads)
 }
}

index函数负责生成HTML并将其写入ResponseWriter中。因为这个处理器函数会用到html/template标准库中的Template结构,所以包含这个函数的文件需  要在文件的开头导入html/template库。之后的小节将对生成HTML的方法做进一步的介绍。

除了前面提到过的负责处理根URL请求的index处理器函数,main.go文件实际上还包含很多其他的处理器函数,如代码清单3所示。

代码清单3 ChitChat应用的main.go源文件

package main
import (
 "net/http"
)

func main()
{
 mux := http.NewServeMux()
 files := http.FileServer(http.Dir(config.Static))
 mux.Handle("/static/", http.StripPrefix("/static/", files))
 mux.HandleFunc("/", index)
 mux.HandleFunc("/err", err)
 mux.HandleFunc("/login", login)
 mux.HandleFunc("/logout", logout)
 mux.HandleFunc("/signup", signup)
 mux.HandleFunc("/signup_account", signupAccount)
 mux.HandleFunc("/authenticate", authenticate)
 mux.HandleFunc("/thread/new", newThread)
 mux.HandleFunc("/thread/create", createThread)
 mux.HandleFunc("/thread/post", postThread)
 mux.HandleFunc("/thread/read", readThread)
 server := &http.Server{
  Addr:      "0.0.0.0:8080",
  Handler:    mux,
 }
 server.ListenAndServe()
}

main函数中使用的这些处理器函数并没有在main.go文件中定义,它们的定义在其他文件里面,具体请参考ChitChat项目的完整源码。

为了在一个文件里面引用另一个文件中定义的函数,诸如PHP、Ruby和Python这样的语言要求用户编写代码去包含(include)被引用函数所在的文件,而另一些语言则要求用户在编译程序时使用特殊的链接(link)命令。

但是对Go语言来说,用户只需要把位于相同目录下的所有文件都设置成同一个包,那么这些文件就会与包中的其他文件分享彼此的定义。又或者,用户也可以把文件放到其他独立的包里面,然后通过导入(import)这些包来使用它们。比如,ChitChat论坛就把连接数据库的代码放到了独立的包里面,我们很快就会看到这一点。

4 使用cookie进行访问控制

跟其他很多Web应用一样,ChitChat既拥有任何人都可以访问的公开页面,也拥有用户在登录账号之后才能看见的私人页面。

当一个用户成功登录以后,服务器必须在后续的请求中标示出这是一个已登录的用户。为了做到这一点,服务器会在响应的首部中写入一个cookie,而客户端在接收这个cookie之后则会把它存储到浏览器里面。代码清单4展示了authenticate处理器函数的实现代码,这个函数定义在route_auth.go文件中,它的作用就是对用户的身份进行验证,并在验证成功之后向客户端返回一个cookie。

代码清单4 ``route_auth.go``文件中的authenticate处理器函数

func authenticate(w http.ResponseWriter, r *http.Request) {
 r.ParseForm()
 user, _ := data.UserByEmail(r.PostFormValue("email"))
 if user.Password == data.Encrypt(r.PostFormValue("password")) {
  session := user.CreateSession()
  cookie := http.Cookie{
   Name: "_cookie",
   Value: session.Uuid,
   HttpOnly: true,
  }
  http.SetCookie(w, &cookie)
  http.Redirect(w, r, "/", 302)
 } else {
  http.Redirect(w, r, "/login", 302)
 }
}

注意,代码清单4中的authenticate函数使用了两个我们尚未介绍过的函数,一个是data.Encrypt,而另一个则是data.UserbyEmail。因为本节关注的是ChitChat论坛的访问控制机制而不是数据处理方法,所以本节将不会对这两个函数的实现细节进行解释,但这两个函数的名字已经很好地说明了它们各自的作用:data.UserByEmail函数通过给定的电子邮件地址获取与之对应的User结构,而data.Encrypt函数则用于加密给定的字符串。本章稍后将会对data包作更详细的介绍,但是在此之前,让我们回到对访问控制机制的讨论上来。

在验证用户身份的时候,程序必须先确保用户是真实存在的,并且提交给处理器的密码在加密之后跟存储在数据库里面的已加密用户密码完全一致。在核实了用户的身份之后,程序会使用User结构的CreateSession方法创建一个Session结构,该结构的定义如下:

type Session struct {
 Id    int
 Uuid   string
 Email   string
 UserId  int
 CreatedAt time.Time
}

Session结构中的Email字段用于存储用户的电子邮件地址,而UserId字段则用于记录用户表中存储用户信息的行的ID。Uuid字段存储的是一个随机生成的唯一ID,这个ID是实现会话机制的核心,服务器会通过cookie把这个ID存储到浏览器里面,并把Session结构中记录的各项信息存储到数据库中。

在创建了Session结构之后,程序又创建了Cookie结构:

cookie := http.Cookie{
 Name:   "_cookie",
 Value:   session.Uuid,
 HttpOnly: true,
}

cookie的名字是随意设置的,而cookie的值则是将要被存储到浏览器里面的唯一ID。因为程序没有给cookie设置过期时间,所以这个cookie就成了一个会话cookie,它将在浏览器关闭时自动被移除。此外,程序将HttpOnly字段的值设置成了true,这意味着这个cookie只能通过HTTP或者HTTPS访问,但是却无法通过JavaScript等非HTTP API进行访问。

在设置好cookie之后,程序使用以下这行代码,将它添加到了响应的首部里面:

http.SetCookie(writer, &cookie)

在将cookie存储到浏览器里面之后,程序接下来要做的就是在处理器函数里面检查当前访问的用户是否已经登录。为此,我们需要创建一个名为session的工具(utility)函数,并在各个处理器函数里面复用它。代码清单5展示了session函数的实现代码,跟其他工具函数一样,这个函数也是在util.go文件里面定义的。再提醒一下,虽然程序把工具函数的定义都放在了util.go文件里面,但是因为util.go文件也隶属于main包,所以这个文件里面定义的所有工具函数都可以直接在整个main包里面调用,而不必像data.Encrypt函数那样需要先引入包然后再调用。

代码清单5 util.go文件中的session工具函数

func session(w http.ResponseWriter, r *http.Request)(sess data.Session, err
 error)
{
 cookie, err := r.Cookie("_cookie")
 if err == nil {
  sess = data.Session{Uuid: cookie.Value}
  if ok, _ := sess.Check(); !ok {
   err = errors.New("Invalid session")
  }
 }
 return
}

为了从请求中取出cookie,session函数使用了以下代码:

cookie, err := r.Cookie("_cookie")

如果cookie不存在,那么很明显用户并未登录;相反,如果cookie存在,那么session函数将继续进行第二项检查——访问数据库并核实会话的唯一ID是否存在。第二项检查是通过data.Session函数完成的,这个函数会从cookie中取出会话并调用后者的Check方法:

sess = data.Session{Uuid: cookie.Value}
if ok, _ := sess.Check(); !ok {
 err = errors.New("Invalid session")
}

在拥有了检查和识别已登录用户和未登录用户的能力之后,让我们来回顾一下之前展示的index处理器函数,代码清单6中被加粗的代码行展示了这个处理器函数是如何使用session函数的。

{--:}代码清单6 index处理器函数

func index(w http.ResponseWriter, r *http.Request) {

 threads, err := data.Threads(); if err == nil
{
  
, err := session(w, r)

  public_tmpl_files := []string{"templates/layout.htm
l",
                 "templates/public.navbar.ht
ml",
                 "templates/index.h
tml"}
  private_tmpl_files := []string{"templates/layout.
html",
                  "templates/private.navbar
.html",
                  "templates/inde
x.html"}
  var templates *template
.Template
  if er
r != nil {
   templates = template.Must(temp
late.Parse-
 Files(private_tmp
l_files...
))
  } else {
   templates = template.Must(template.ParseFiles(public_t
mpl
_files...))
  }
  templates.ExecuteTemplate(w, "l
ay
out", threads)
 }
}

通过调用session函数可以取得一个存储了用户信息的Session结构,不过因为index函数目前并不需要这些信息,所以它使用空白标识符(blank identifier)(_)忽略了这一结构。index函数真正感兴趣的是err变量,程序会根据这个变量的值来判断用户是否已经登录,然后以此来选择是使用public导航条还是使用private导航条。

好的,关于ChitChat应用处理请求的方法就介绍到这里了。本章接下来会继续讨论如何为客户端生成HTML,并完整地叙述之前没有说完的部分。

使用模板生成HTML响应

index处理器函数里面的大部分代码都是用来为客户端生成HTML的。首先,函数把每个需要用到的模板文件都放到了Go切片里面(这里展示的是私有页面的模板文件,公开页面的模板文件也是以同样方式进行组织的):

private_tmpl_files := []string{"templates/layout.html",
                "templates/private.navbar.html",
                "templates/index.html"}

跟Mustache和CTemplate等其他模板引擎一样,切片指定的这3个HTML文件都包含了特定的嵌入命令,这些命令被称为动作(action),动作在HTML文件里面会被{{符号和}}符号包围。

接着,程序会调用ParseFiles函数对这些模板文件进行语法分析,并创建出相应的模板。为了捕捉语法分析过程中可能会产生的错误,程序使用了Must函数去包围ParseFiles函数的执行结果,这样当ParseFiles返回错误的时候,Must函数就会向用户返回相应的错误报告:

templates := template.Must(template.ParseFiles(private_tmpl_files...))

好的,关于模板文件的介绍已经足够多了,现在是时候来看看它们的庐山真面目了。

ChitChat论坛的每个模板文件都定义了一个模板,这种做法并不是强制的,用户也可以在一个模板文件里面定义多个模板,但模板文件和模板一一对应的做法可以给开发带来方便,我们在之后就会看到这一点。代码清单7展示了layout.html模板文件的源代码,源代码中使用了define动作,这个动作通过文件开头的{{ define "layout" }}和文件末尾的{{ end }},把被包围的文本块定义成了layout模板的一部分。

代码清单7 layout.html模板文件

{{ define "layout" }}
<!DOCTYPE html>
<html lang="en">
 <head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=9">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ChitChat</title>
  <link href="/static/css/bootstrap.min.css" rel="stylesheet">
  <link href="/static/css/font-awesome.min.css" rel="stylesheet">
 </head>
 <body>
  {{ template "navbar" . }}
  <div class="container">
   {{ template "content" . }}
  </div> <!-- /container -->
  <script src="/static/js/jquery-2.1.1.min.js"></script>
  <script src="/static/js/bootstrap.min.js"></script>
 </body>
</html>
{{ end }}

除了define动作之外,layout.html模板文件里面还包含了两个用于引用其他模板文件的template动作。跟在被引用模板名字之后的点(.)代表了传递给被引用模板的数据,比如{{ template "navbar" . }}语句除了会在语句出现的位置引入navbar模板之外,还会将传递给layout模板的数据传递给navbar模板。

代码清单8展示了public.navbar.html模板文件中的navbar模板,除了定义模板自身的define动作之外,这个模板没有包含其他动作(严格来说,模板也可以不包含任何动作)。

代码清单8 public.navbar.html模板文件

{{ define "navbar" }}
<div class="navbar navbar-default navbar-static-top" role="navigation">
 <div class="container">
  <div class="navbar-header">
   <button type="button" class="navbar-toggle collapsed"
   ➥ data-toggle="collapse" data-target=".navbar-collapse">

    <span class="sr-only">Toggle navigation</span>
    <span class="icon-bar"></span>
    <span class="icon-bar"></span>
    <span class="icon-bar"></span>
   </button>
   <a class="navbar-brand" href="/">
    <i class="fa fa-comments-o"></i>
    ChitChat
   </a>
  </div>
  <div class="navbar-collapse collapse">
   <ul class="nav navbar-nav">
    <li><a href="/">Home</a></li>
   </ul>
   <ul class="nav navbar-nav navbar-right">
    <li><a href="/login">Login</a></li>
   </ul>
  </div>
 </div>
</div>
{{ end }}

最后,让我们来看看定义在index.html模板文件中的content模板,代码清单9展示了这个模板的源代码。注意,尽管之前展示的两个模板都与模板文件拥有相同的名字,但实际上模板和模板文件分别拥有不同的名字也是可行的。

代码清单9 index.html模板文件

{{ define "content" }}
<p class="lead">
 <a href="/thread/new">Start a thread</a> or join one below!
</p>
{{ range . }}
 <div class="panel panel-default">
  <div class="panel-heading">
   <span class="lead"> <i class="fa fa-comment-o"></i> {{ .Topic }}</span>
  </div>
  <div class="panel-body">
   Started by {{ .User.Name }} - {{ .CreatedAtDate }} - {{ .NumReplies }}
 posts.
   <div class="pull-right">
    <a href="/thread/read?id={{.Uuid }}">Read more</a>
   </div>
  </div>
 </div>
{{ end }}
{{ end }}

index.html文件里面的代码非常有趣,特别值得一提的是文件里面包含了几个以点号(.)开头的动作,比如{{ .User.Name }}{{ .CreatedAtDate }},这些动作的作用和之前展示过的index处理器函数有关:

threads, err := data.Threads(); if err == nil {
 templates.ExecuteTemplate(writer, "layout", threads)
}

在以下这行代码中:

templates.ExecuteTemplate(writer, "layout", threads)

程序通过调用ExecuteTemplate函数,执行(execute)已经经过语法分析的layout模板。执行模板意味着把模板文件中的内容和来自其他渠道的数据进行合并,然后生成最终的HTML内容,具体过程如图6所示。

图6 模板引擎通过合并数据和模板来生成HTML

程序之所以对layout模板而不是navbar模板或者content模板进行处理,是因为layout模板已经引用了其他两个模板,所以执行layout模板就会导致其他两个模板也被执行,由此产生出预期的HTML。但是,如果程序只执行navbar模板或者content模板,那么程序最终只会产生出预期的HTML的一部分。

现在,你应该已经明白了,点号(.)代表的就是传入到模板里面的数据(实际上还不仅如此,接下来的小节会对这方面做进一步的说明)。图7展示了程序根据模板生成的ChitChat论坛的样子。

图7 ChitChat Web应用示例的主页

整理代码

因为生成HTML的代码会被重复执行很多次,所以我们决定对这些代码进行一些整理,并将它们移到代码清单10所示的generateHTML函数里面。

代码清单10 generateHTML函数

func generateHTML(w http.ResponseWriter, data interface{}, fn ...string) {
 var files []string
 for _, file := range fn {
  files = append(files, fmt.Sprintf("templates/%s.html", file))
 }
 templates := template.Must(template.ParseFiles(files...))
 templates.ExecuteTemplate(writer, "layout", data)
}

generateHTML函数接受一个ResponseWriter、一些数据以及一系列模板文件作为参数,然后对给定的模板文件进行语法分析。data参数的类型为空接口类型(empty interface type),这意味着该参数可以接受任何类型的值作为输入。刚开始接触Go语言的人可能会觉得奇怪——Go不是静态编程语言吗,它为什么能够使用没有类型限制的参数?

但实际上,Go程序可以通过接口(interface)机制,巧妙地绕过静态编程语言的限制,并藉此获得接受多种不同类型输入的能力。Go语言中的接口由一系列方法构成,并且每个接口就是一种类型。一个空接口就是一个空集合,这意味着任何类型都可以成为一个空接口,也就是说任何类型的值都可以传递给函数作为参数。

generateHTML函数的最后一个参数以3个点(...)开头,它表示generateHTML函数是一个可变参数函数(variadic function),这意味着这个函数可以在最后的可变参数中接受零个或任意多个值作为参数。generateHTML函数对可变参数的支持使我们可以同时将任意多个模板文件传递给该函数。在Go语言里面,可变参数必须是可变参数函数的最后一个参数。

在实现了generateHTML函数之后,让我们回过头来,继续对index处理器函数进行整理。代码清单11展示了经过整理之后的index处理器函数,现在它看上去更整洁了。

代码清单11 index处理器函数的最终版本

func index(writer http.ResponseWriter, request *http.Request) {
 threads, err := data.Threads(); if err == nil {
  _, err := session(writer, request)
  if err != nil {
   generateHTML(writer, threads, "layout", "public.navbar", "index")
  } else {
   generateHTML(writer, threads, "layout", "private.navbar", "index")
  }
 }
}

在这一节中,我们学习了很多关于模板的基础知识,之后的第5章将对模板做更详细的介绍。但是在此之前,让我们先来了解一下ChitChat应用使用的数据源(data source),并藉此了解一下ChitChat应用的数据是如何与模板一同生成最终的HTML的。

安装PostgreSQL

在本章以及后续几章中,每当遇到需要访问关系数据库的场景,我们都会使用PostgreSQL。在开始使用PostgreSQL之前,我们首先需要学习的是如何安装并运行PostgreSQL,以及如何创建本章所需的数据库。

1 在Linux或FreeBSD系统上安装

www.postgresql.org/download为各种不同版本的Linux和FreeBSD都提供了预编译的二进制安装包,用户只需要下载其中一个安装包,然后根据指示进行安装就可以了。比如说,通过执行以下命令,我们可以在Ubuntu发行版上安装Postgres:

sudo apt-get install postgresql postgresql-contrib

这条命令除了会安装postgres包之外,还会安装附加的工具包,并在安装完毕之后启动PostgreSQL数据库系统。

在默认情况下,Postgres会创建一个名为postgres的用户,并将其用于连接服务器。为了操作方便,你也可以使用自己的名字创建一个Postgres账号。要做到这一点,首先需要登入Postgres账号:

sudo su postgres

接着使用createuser命令创建一个PostgreSQL账号:

createuser –interactive

最后,还需要使用createdb命令创建以你的账号名字命名的数据库:

createdb <YOUR ACCOUNT NAME>

2 在Mac OS X系统上安装

要在Mac OS X上安装PostgreSQL,最简单的方法是使用PostgresApp.com提供的Postgres应用:你只需要把网站上提供的zip压缩包下载下来,解压它,然后把Postgres.app文件拖曳到自己的Applications文件夹里面就可以了。启动Postgres.app的方法跟启动其他Mac OS X应用的方法完全一样。Postgres.app在初次启动的时候会初始化一个新的数据库集群,并为自己创建一个数据库。因为命令行工具psql也包含在了Postgres.app里面,所以在设置好正确的路径之后,你就可以使用psql访问数据库了。设置路径的工作可以通过在你的~/.profile文件或者~/.bashrc文件中添加以下代码行来完成[1]

export PATH=$PATH:/Applications/Postgres.app/Contents/Versions/9.4/bin

3 在Windows系统上安装

因为Windows系统上的很多PostgreSQL图形安装程序都会把一切安装步骤布置妥当,用户只需要进行相应的设置就可以了,所以在Windows系统上安装PostgreSQL也是非常简单和直观的。其中一个流行的安装程序是由Enterprise DB提供的:www.enterprisedb.com/products- services-training/pgdownload。

除了PostgreSQL数据库本身之外,安装包还会附带诸如pgAdmin等工具,以便用户通过这些工具进行后续的配置。

连接数据库

本章前面在展示ChitChat应用的设计方案时,曾经提到过ChitChat应用包含了4种数据结构。虽然把这4种数据结构放到主源码文件里面也是可以的,但更好的办法是把所有与数据相关的代码都放到另一个包里面——ChitChat应用的data包也因此应运而生。

为了创建data包,我们首先需要创建一个名为data的子目录,并创建一个用于保存所有帖子相关代码的thread.go文件(在之后的小节里面,我们还会创建一个用于保存所有用户相关代码的user.go文件)。在此之后,每当程序需要用到data包的时候(比如处理器需要访问数据库的时候),程序都需要通过import语句导入这个包:

import (
 "github.com/sausheong/gwp/Chapter_2_Go_ChitChat/chitchat/data"
)

代码清单12展示了定义在thread.go文件里面的Thread结构,这个结构存储了与帖子有关的各种信息。

{--:}代码清单12 定义在thread.go文件里面的Thread结构

package data


impo
r
t(
 "ti

m
e"
)
type Thread st
r
uct {
 Id 
   int
 Uuid  
 string
 Topic 
  string
 Us
erId  int
 CreatedAt
time.Time
}

正如代码清单12中加粗显示的代码行所示,文件的包名现在是data而不再是main了,这个包就是前面小节中我们曾经见到过的data包。data包除了包含与数据库交互的结构和代码,还包含了一些与数据处理密切相关的函数。隶属于其他包的程序在引用data包中定义的函数、结构或者其他东西时,必须在被引用元素的名字前面显式地加上data这个包名。比如说,引用Thread结构就需要使用data.Thread这个名字,而不能仅仅使用Thread这个名字。

Thread结构应该与创建关系数据库表threads时使用的数据定义语言(Data Definition Language,DDL)保持一致。因为threads表目前尚未存在,所以我们必须创建这个表以及容纳该表的数据库。创建chitchat数据库的工作可以通过执行以下命令来完成:

createdb chitchat

在创建数据库之后,我们就可以通过代码清单13展示的setup.sql文件为ChitChat论坛创建相应的数据库表了。

代码清单13 用于在PostgreSQL里面创建数据库表的setup.sql文件

create table users (
 id     serial primary key,
 uuid    varchar(64) not null unique,
 name    varchar(255),
 email   varchar(255) not null unique,
 password  varchar(255) not null,
 created_at timestamp not null
);
create table sessions (
 id     serial primary key,
 uuid    varchar(64) not null unique,
 email   varchar(255),
 user_id  integer references users(id),
 created_at timestamp not null
);
create table threads (
 id     serial primary key,
 uuid    varchar(64) not null unique,
 topic   text,
 user_id  integer references users(id),
 created_at timestamp not null
);
create table posts (
 id     serial primary key,
 uuid    varchar(64) not null unique,
 body    text,
 user_id  integer references users(id),
 thread_id integer references threads(id),
 created_at timestamp not null
);

运行这个脚本需要用到psql工具,正如上一节所说,这个工具通常会随着PostgreSQL一同安装,所以你只需要在终端里面执行以下命令就可以了:

psqlf setup.sqld chitchat

如果一切正常,那么以上命令将在chitchat数据库中创建出相应的表。在拥有了表之后,程序就必须考虑如何与数据库进行连接以及如何对表进行操作了。为此,程序创建了一个名为Db的全局变量,这个全局变量是一个指针,指向的是代表数据库连接池的sql.DB,而后续的代码则会使用这个Db变量来执行数据库查询操作。代码清单14展示了Db变量在data.go文件中的定义,此外还展示了一个用于在Web应用启动时对Db变量进行初始化的init函数。

代码清单14 ``data.go``文件中的Db全局变量以及init函数

Var Db *sql.DB
func init()
{
 var err error
 Db, err = sql.Open("postgres", "dbname=chitchat sslmode=disable")
 if err != nil {
  log.Fatal(err)
 }
 return
}

现在程序已经拥有了结构、表以及一个指向数据库连接池的指针,接下来要考虑的是如何连接(connect)Thread结构和threads表。幸运的是,要做到这一点并不困难:跟ChitChat应用的其他部分一样,我们只需要创建能够在结构和数据库之间互动的函数就可以了。例如,为了从数据库里面取出所有帖子并将其返回给index处理器函数,我们可以使用thread.go文件中定义的Threads函数,代码清单15给出了这个函数的定义。

代码清单15 threads.go文件中定义的Threads函数

func Threads() (threads []Thread, err error){
 rows, err := Db.Query("SELECT id, uuid, topic, user_id, created_at FROM
 threads ORDER BY created_at DESC"
)
 if err != nil {
  return
 }
 for rows.Next() {
  th := Thread{}
  if err = rows.Scan(&th.Id, &th.Uuid, &th.Topic, &th.UserId,
  ➥&th.CreatedAt); err != nil {
   return
  }
  threads = append(threads, th)
 }
 rows.Close()
 return
}

简单来讲,Threads函数执行了以下工作:

(1)通过数据库连接池与数据库进行连接;

(2)向数据库发送一个SQL查询,这个查询将返回一个或多个行作为结果;

(3)遍历行,为每个行分别创建一个Thread结构,首先使用这个结构去存储行中记录的帖子数据,然后将存储了帖子数据的Thread结构追加到传入的threads切片里面;

(4)重复执行步骤3,直到查询返回的所有行都被遍历完毕为止。

在了解了如何将数据库表存储的帖子数据提取到Thread结构里面之后,我们接下来要考虑的就是如何在模板里面展示Thread结构存储的数据了。在代码清单9中展示的index.html模板文件,有这样一段代码:

{{ range . }}
 <div class="panel panel-default">
  <div class="panel-heading">
   <span class="lead"> <i class="fa fa-comment-o"></i> {{ .Topic }}</span>
  </div>
  <div class="panel-body">
   Started by {{ .User.Name }} - {{ .CreatedAtDate }} - {{ .NumReplies }}
 posts.
   <div class="pull-right">
    <a href="/thread/read?id={{.Uuid }}">Read more</a>
   </div>
  </div>
 </div>
{{ end }}

正如之前所说,模板动作中的点号(.)代表传入模板的数据,它们会和模板一起生成最终的结果,而{{ range . }}中的.号代表的是程序在稍早之前通过Threads函数取得的threads变量,也就是一个由Thread结构组成的切片。

range动作假设传入的数据要么是一个由结构组成的切片,要么是一个由结构组成的数组,这个动作会遍历传入的每个结构,而用户则可以通过字段名访问结构里面的字段,比如,动作{{ .Topic }}访问的是Thread结构的Topic字段。注意,在访问字段时必须在字段名的前面加上点号,并且字段名的首字母必须大写。

用户除可以在字段名的前面加上点号来访问结构中的字段以外,还可以通过相同的方法调用一种名为方法(method)的特殊函数。比如,在上面展示的代码中,{{ .User.Name }}{{ .CreatedAtDate }}{{ .NumReplies }}这些动作的作用就是调用结构中的同名方法,而不是访问结构中的字段。

方法是隶属于特定类型的函数,指针、接口以及包括结构在内的所有具名类型都可以拥有自己的方法。比如说,通过将函数与指向Thread结构的指针进行绑定,可以创建出一个针对Thread结构的方法,而传入方法里面的Thread结构则称为接收者(receiver):方法可以访问接收者,也可以修改接收者。

作为例子,代码清单16展示了NumReplies方法的实现代码。

代码清单16 ``thread.go``文件中的NumReplies方法

func (thread *Thread) NumReplies() (count int) {
 rows, err := Db.Query("SELECT count(*) FROM posts where thread_id = $1",
 thread.Id)
 if err != nil {
  return
 }
 for rows.Next() {
  if err = rows.Scan(&count); err != nil {
   return
  }
 }
 rows.Close()
 return
}

NumReplies方法首先打开一个指向数据库的连接,接着通过执行一条SQL查询来取得帖子的数量,并使用传入方法里面的count参数来记录这个值。最后,NumReplies方法返回帖子的数量作为方法的执行结果,而模板引擎则使用这个值去代替模板文件中出现的{{ .NumReplies }}动作。

通过为UserSessionThreadPost这4种数据结构创建相应的函数和方法,ChitChat最终在处理器函数和数据库之间构建起了一个数据层,以此来避免处理器函数直接对数据库进行访问,图8展示了这个数据层和数据库以及处理器函数之间的关系。虽然有很多库都可以达到同样的效果,但亲自构建数据层能够帮助我们学习如何对数据库进行基本的访问,并藉此了解到实现这种访问并不困难,只需要用到一些简单直接的代码,这一点是非常有益的。


图8 通过结构模型连接数据库和处理器

启动服务器

在本章的最后,让我们来看一下ChitChat应用是如何启动服务器并将多路复用器与服务器进行绑定的。执行这一工作的代码是在main.go文件里面定义的:

server := &http.Server{
 Addr:   "0.0.0.0:8080",
 Handler: mux,
}
server.ListenAndServe()

这段代码非常简单,它所做的就是创建一个Server结构,然后在这个结构上调用ListenAndServe方法,这样服务器就能够启动了。

现在,我们可以通过执行以下命令来编译并运行ChitChat应用:

go build

这个命令会在当前目录以及$GOPATH/bin目录中创建一个名为chitchat的二进制可执行文件,它就是ChitChat应用的服务器。接着,我们可以通过执行以下命令来启动这个服务器:

./chitchat

如果你已经按照之前所说的方法,在数据库里面创建了ChitChat应用所需的数据库表,那么现在你只需要访问http://localhost:8080/并注册一个新账号,然后就可以使用自己的账号在论坛上发布新帖子了。

Web应用运作流程回顾

在本文的各节中,我们对一个Go Web应用的不同组成部分进行了初步的了解和观察。图9对整个应用的工作流程进行了介绍,其中包括:

(1)客户端向服务器发送请求;

(2)多路复用器接收到请求,并将其重定向到正确的处理器;

(3)处理器对请求进行处理;

(4)在需要访问数据库的情况下,处理器会使用一个或多个数据结构,这些数据结构都是根据数据库中的数据建模而来的;

(5)当处理器调用与数据结构有关的函数或者方法时,这些数据结构背后的模型会与数据库进行连接,并执行相应的操作;

(6)当请求处理完毕时,处理器会调用模板引擎,有时候还会向模板引擎传递一些通过模型获取到的数据;

(7)模板引擎会对模板文件进行语法分析并创建相应的模板,而这些模板又会与处理器传递的数据一起合并生成最终的HTML;

(8)生成的HTML会作为响应的一部分回传至客户端。

图9 Web应用工作流程概览

主要的步骤大概就是这些。在接下来的几章中,我们会更加深入地学习这一工作流程,并进一步了解该流程涉及的各个组件。

小结

  • 请求的接收和处理是所有Web应用的核心。

  • 多路复用器会将HTTP请求重定向到正确的处理器进行处理,针对静态文件的请求也是如此。

  • 处理器函数是一种接受ResponseWriterRequeest指针作为参数的Go函数。

  • cookie可以用作一种访问控制机制。

  • 对模板文件以及数据进行语法分析会产生相应的HTML,这些HTML会被用作返回给浏览器的响应数据。

  • 通过使用sql包以及相应的SQL语句,用户可以将数据持久地存储在关系数据库中。


[1]   在安装Postgres.app时,你可能需要根据Postgres.app的版本对路径的版本部分做相应的修改,比如,将其中的9.4修改为9.5或者9.6,诸如此类。——译者注 

本文摘自《Go Web编程


无论是经验老到的gopher,还是刚开始接触Go语言的Web开发者,这都是必不可少的一本书,本书囊括了关于Go Web应用的开发和部署的全部知识


本书目录:(滑动手机查看)

第一部分 Go与Web应用 
第1章 Go与Web应用  3 
1.1 使用Go语言构建Web应用  3 

1.1.1 Go与可扩展Web应用  4 
1.1.2 Go与模块化Web应用  4 
1.1.3 Go与可维护的Web应用  5 
1.1.4 Go与高性能Web应用  5 
1.2 Web应用的工作原理  6 
1.3 HTTP简介  7 
1.4 Web应用的诞生  8 
1.5 HTTP请求  9 
1.5.1 请求方法  10 
1.5.2 安全的请求方法  11 
1.5.3 幂等的请求方法  11 
1.5.4 浏览器对请求方法的支持  11 
1.5.5 请求首部  12 
1.6 HTTP响应  13 
1.6.1 响应状态码  13 
1.6.2 响应首部  14 
1.7 URI  15 
1.8 HTTP/2简介  16 
1.9 Web应用的各个组成部分  16 
1.9.1 处理器  17 
1.9.2 模板引擎  18 
1.10 Hello Go  18 
1.11  小结  21 

第2章  ChitChat论坛  23 
2.1 ChitChat简介  23 
2.2 应用设计  24 
2.3 数据模型  26 
2.4 请求的接收与处理  27 
2.4.1 多路复用器  27 
2.4.2 服务静态文件  29 
2.4.3 创建处理器函数  29 
2.4.4 使用cookie进行访问控制  30 
2.5 使用模板生成HTML响应  33 
2.6 安装PostgreSQL  38 
2.6.1 在Linux或FreeBSD系统上安装  38 
2.6.2 在Mac OS X系统上安装  39 
2.6.3 在Windows系统上安装  39 
2.7 连接数据库  39 
2.8 启动服务器  44 
2.9 Web应用运作流程回顾  45 
2.10 小结  46 

第二部分 Web应用的基本组成部分 
第3章  接收请求  49 
3.1 Go的net/http标准库  49 
3.2 使用Go构建服务器  51 
3.2.1 Go Web服务器  51 
3.2.2 通过HTTPS提供服务  53 
3.3 处理器和处理器函数  56 
3.3.1 处理请求  56 
3.3.2 使用多个处理器  58 
3.3.3 处理器函数  59 
3.3.4 串联多个处理器和处理器函数  61 
3.3.5 ServeMux和DefaultServeMux  65 
3.3.6 使用其他多路复用器  66 
3.4 使用HTTP/2  68 
3.5 小结  70 

第4章  处理请求  72 
4.1 请求和响应  72 
4.1.1 Request结构  73 
4.1.2 请求URL  73 
4.1.3 请求首部  74 
4.1.4 请求主体  76 
4.2 Go与HTML表单  77 
4.2.1 Form字段  79 
4.2.2 PostForm字段  80 
4.2.3 MultipartForm字段  81 
4.2.4 文件  83 
4.2.5 处理带有JSON主体的POST请求  85 
4.3 ResponseWriter  86 
4.4 cookie  91 
4.4.1 Go与cookie 91 
4.4.2 将cookie发送至浏览器  92 
4.4.3 从浏览器里面获取cookie  94 
4.4.4 使用cookie实现闪现消息  96 
4.5 小结  99 

第5章  内容展示  100 
5.1 模板引擎  100 
5.2 Go的模板引擎  102 
5.2.1 对模板进行语法分析  104 
5.2.2 执行模板  105 
5.3 动作  106 
5.3.1 条件动作  106 
5.3.2 迭代动作  108 
5.3.3 设置动作  109 
5.3.4 包含动作  111 
5.4 参数、变量和管道  113 
5.5  函数  114 
5.6 上下文感知  116 
5.6.1 防御XSS攻击  119 
5.6.2 不对HTML进行转义  121 
5.7 嵌套模板  122 
5.8 通过块动作定义默认模板  126 
5.9 小结  127 

第6章  存储数据  128 
6.1 内存存储  128 
6.2 文件存储  131 
6.2.1 读取和写入CSV文件  133 
6.2.2 gob包  135 
6.3 Go与SQL  137 
6.3.1 设置数据库  138 
6.3.2 连接数据库  140 
6.3.3 创建帖子  142 
6.3.4 获取帖子  144 
6.3.5 更新帖子  145 
6.3.6 删除帖子  145 
6.3.7 一次获取多篇帖子  146 
6.4 Go与SQL的关系  147 
6.4.1 设置数据库  147 
6.4.2 一对多关系  150 
6.5 Go与关系映射器  152 
6.5.1 Sqlx  152 
6.5.2 Gorm  154 
6.6 小结  157 

第三部分 实战演练 
第7章  Go Web服务  161 
7.1 Web服务简介  161 
7.2 基于SOAP的Web服务简介  163 
7.3 基于REST的Web服务简介  166 
7.3.1 将动作转换为资源  168 
7.3.2 将动作转换为资源的属性  169 
7.4 通过Go分析和创建XML  169 
7.4.1 分析XML  169 
7.4.2 创建XML  177 
7.5 通过Go分析和创建JSON  180 
7.5.1 分析JSON  181 
7.5.2 创建JSON  184 
7.6 创建Go Web服务  187 
7.7 小结  194 

第8章  应用测试  196 
8.1 Go与测试  196 
8.2 使用Go进行单元测试  197 
8.2.1 跳过测试用例  201 
8.2.2 以并行方式运行测试  202 
8.2.3 基准测试  203 
8.3 使用Go进行HTTP测试  206 
8.4 测试替身以及依赖注入  210 
8.5 第三方Go测试库  217 
8.5.1 Gocheck测试包简介  217 
8.5.2 Ginkgo测试框架简介  223 
8.6 小结  228 

第9章  发挥Go的并发优势  230 
9.1 并发与并行的区别  230 
9.2 goroutine  232 
9.2.1 使用goroutine  232 
9.2.2 goroutine与性能  235 
9.2.3 等待goroutine  238 
9.3 通道  239 
9.3.1 通过通道实现同步  240 
9.3.2 通过通道实现消息传递  242 
9.3.3 有缓冲通道  243 
9.3.4 从多个通道中选择  244 
9.4 在Web应用中使用并发  247 
9.4.1 创建马赛克图片  248 
9.4.2 马赛克图片Web应用  251 
9.4.3 并发版马赛克图片生成Web应用  254 
9.5 小结  262 

第10章  Go的部署  263 
10.1 将应用部署到独立的 服务器  264 
10.2 将应用部署到 Heroku  270 
10.3 将应用部署到Google App Engine  273 
10.4 将应用部署到 Docker  278 
10.4.1 什么是Docker  278 
10.4.2 安装Docker  279 
10.4.3 Docker的理念与 组件  280 
10.4.4 Docker化一个Go Web 应用  281 
10.4.5 将Docker容器推送至 互联网  283 
10.5 部署方法之间的 对比  286 
10.6 小结  287 
附录  安装和设置Go  288 

粉丝福利〜〜〜

评论获得点赞最多的前5名,将获得本书一册

截图发送截止15日下午18:00



    发送中

    本文由AB模板网:织梦模板整理发布, 转载请说明出处:https://www.mobanhu.com
    随机站长资讯
    高级精美的家私企业网站源码 家私家纺类网
    农业绿色生态水果企业dede源码
    驾校类企业网站源码 驾校网站模板
    绿色LED灯管类织梦源码 照明企业网站模板
    蓝色通用织梦博客模板 资讯文章类网站源码
    红色大气装饰公司织梦源码 建筑装修装潢企
    网站建设企业模板 互联网网络营销类网站源
    html5响应式手机自适应网站模板(兼容手机
    黑色HTML5工作室网络公司网站整站模板(适
    简洁大气网站建设网络设计类企业织梦模板
    html5+css3高端网站建设工作室源码 酷炫的h
    红灰色大气网络工作室织梦模板
    织梦dedecms幻灯片模糊解决办法
    织梦dedecms巧用标签实现图片自动Alt功能,
    织梦dedecms文章列表循环标签增加notypeid
    织梦dedecms专题模板应用分析及使用案例
    dedecms织梦让二级栏目标题去除“/”斜杠和
    最新站长资讯
    高级精美的家私企业网站源码 家私家纺类网
    农业绿色生态水果企业dede源码
    驾校类企业网站源码 驾校网站模板
    绿色LED灯管类织梦源码 照明企业网站模板
    蓝色通用织梦博客模板 资讯文章类网站源码
    红色大气装饰公司织梦源码 建筑装修装潢企
    网站建设企业模板 互联网网络营销类网站源
    html5响应式手机自适应网站模板(兼容手机
    黑色HTML5工作室网络公司网站整站模板(适
    简洁大气网站建设网络设计类企业织梦模板
    html5+css3高端网站建设工作室源码 酷炫的h
    红灰色大气网络工作室织梦模板
    织梦dedecms幻灯片模糊解决办法
    织梦dedecms巧用标签实现图片自动Alt功能,
    织梦dedecms文章列表循环标签增加notypeid
    织梦dedecms专题模板应用分析及使用案例
    dedecms织梦让二级栏目标题去除“/”斜杠和
    关于出现“对不起,您安装的不是正版应用..
    Discuz(Can not write to cache files)有关
    Discuz通过修改文章标题更好的实现SEO的方
    DiscuzX中存在不合法的文件被上传的修复方
    关于Discuz x3.1页面空白解决方法
    首页 免费源码 VIP专区 会员中心
    收缩