使用Go语言开发Web系统

使用Go语言开发Web系统

我们将构建一个新闻应用程序,该新闻应用程序利用News API来展示有关特定主题的新闻文章,并将其最终部署到生产服务器中。

技术开发 编程 技术框架 技术发展

 

使用Go语言开发Web系统

我们将构建一个新闻应用程序,该新闻应用程序利用News API来展示有关特定主题的新闻文章,并将其最终部署到生产服务器中。

本教程将带您进入第一个使用Go的Web应用程序。我们将构建一个新闻应用程序,该新闻应用程序利用News API来展示有关特定主题的新闻文章,并将其最终部署到生产服务器中。

您可以在GitHub存储库中找到本教程使用的完整代码。

先决条件

本教程的唯一要求是您已经在计算机上安装了Go,并且对它的语法和构造隐约熟悉。我在构建应用程序时使用的Go版本也是撰写本文时的最新版本:1.12.9。要查看已安装的Go版本,请使用go version命令。

如果您觉得本教程对您来说太高级了,请转至我之前的 入门教程,以掌握该语言。

开始吧

在GitHub上克隆启动程序文件仓库,并cd进入创建的目录。我们有三个主要文件:在该main.go文件中,我们将编写本教程的所有Go代码。该index.html文件是将发送到浏览器的模板,而styles该应用程序位于中assets/styles.css。

创建一个基本的Web服务器

首先创建一个发送“ Hello World!”的基本服务器。向服务器根目录发出GET请求时,将文本发送到浏览器。修改您的main.go文件,如下所示:

image.png

第一行package main声明main.go文件中的代码属于主程序包。之后,我们导入了该net/http 软件包,该软件包提供了HTTP客户端和服务器实现,供我们的应用程序使用。该软件包是标准库的一部分,并且随Go的所有安装一起提供。

在该main函数中,http.NewServeMux()创建一个新的HTTP请求多路复用器,并将其分配给该mux变量。本质上,请求多路复用器将传入请求的URL与注册路径列表进行匹配,并在找到匹配项时为该路径调用关联的处理程序。

接下来,我们为根路径注册我们的第一个处理函数/。该处理函数是HandleFunc签名的第二个参数,始终是签名的 func(w http.ResponseWriter, r *http.Request)。

如果您看一下indexHandler函数,您会发现它具有这个确切的签名,从而使其成为的有效第二个参数HandleFunc。该w参数是我们用来发送响应HTTP请求的结构。它强加了一个Write()方法,该方法接受一个字节片段并将数据作为HTTP响应的一部分写入连接。

另一方面,该r参数表示从客户端收到的HTTP请求。这就是我们访问服务器上Web浏览器发送的数据的方式。我们在这里还没有使用它,但是稍后我们一定会使用它。

最后,http.ListenAndServe()如果环境中未设置服务器,则可以使用方法在端口3000上启动服务器。如果您的计算机上使用的是3000,请随意使用其他端口。

接下来,编译并执行您刚编写的代码:

image.png

如果在浏览器中转到http:// localhost:3000,则应该看到文本“ Hello World!”。显示在屏幕上。

image.png

Go中的模板

让我们研究一下Go中的模板基础知识。如果您熟悉其他语言的模板,那么它应该足够容易理解。

模板提供了一种简便的方法,可以根据路由自定义Web应用程序的输出,而不必在许多地方编写相同的代码。例如,我们可以为导航栏创建一个模板,并在网站的所有页面上使用它,而无需重复代码。最重要的是,我们还能够向网页添加一些基本逻辑。

Go在其标准库中提供了两个模板库:text/template和html/template。两者都提供相同的接口,但是该html/template包用于生成可防止代码注入的HTML输出,因此我们将在这里使用它。

将此包导入main.go文件中,并按以下方式使用:

image.png

tpl是程序包级别的变量,指向提供的文件中的模板定义。用来template.ParseFiles解析index.html文件的调用位于项目目录的根目录中,并对其进行验证。

我们将template.ParseFileswith的调用包装起来,以template.Must使代码在出现错误时会慌张。我们在这里恐慌而不是尝试处理该错误的原因是,如果模板无效,继续执行代码毫无意义。在尝试重新启动服务器之前,应该解决此问题。

在该indexHandler函数中,我们通过提供两个参数来执行先前创建的模板:我们要将输出写入到的位置以及要传递给模板的数据。

在上述情况下,我们将输出写入ResponseWriter接口,并且由于此时没有任何数据可传递到模板,nil因此将其作为第二个参数传递。

使用停止终端中正在运行的进程,然后使用ctrl-c再次启动它go run main.go,然后刷新浏览器。您应该在页面上看到文本“ News App Demo”,如下所示:

image.png

在页面上添加导航栏

替换文件中<body>标记的内容,index.html如下所示:

image.png

然后重新启动服务器并刷新浏览器。您应该看到如下所示的内容:

image.png

服务静态文件

请注意,尽管我们已经在<head>文档的中链接了样式表,但上面添加的导航栏并未设置样式。

这是因为该/路径实际上与未在其他位置处理的所有路径匹配。因此,如果您转到http:// localhost:3000 / assets / style.css,则仍会获得News Demo主页,而不是CSS文件,因为该/assets/style.css路由未明确声明。

但是必须为所有静态文件声明显式处理程序是不现实的,并且无法扩展。幸运的是,我们可以创建一个处理程序来处理所有静态资产。

要做的第一件事是通过传递放置所有静态文件的目录来实例化文件服务器对象:

image.png

接下来,我们需要告诉路由器将这个文件服务器对象用于所有以/assets/前缀开头的路径:

image.png

image.png

重新启动服务器,然后刷新浏览器。样式应如下所示:

image.png

创建/搜索路线

让我们创建一条处理新闻搜索请求的路由。我们将使用News API来处理查询,因此您需要在此处注册免费的API密钥。

该路由需要两个查询参数:q代表用户的查询,并 page用于分页搜索结果。此page参数是可选的。如果网址中未包含该网址,则我们仅假设该网页为 1。

indexHandler在main.go文件中添加以下处理程序:

image.png

上面的代码从请求URL中提取q和page参数,并将它们都打印到终端。

接下来,将searchHandler函数注册为/search路径的处理程序,如下所示:

image.png

不要忘记在顶部导入fmt和net/url软件包:

import (

"fmt"

"html/template"

"net/http"

"net/url"

"os"

)

现在重新启动服务器,在搜索输入中键入查询,然后检查终端。您应该以如下所示的方式在终端中看到查询打印:

创建数据模型

当我们向News API的/everything端点发出请求时,我们期望以下格式的json响应:

{

  "status": "ok",

  "totalResults": 4661,

  "articles": [

    {

      "source": {

        "id": null,

        "name": "Gizmodo.com"

      },

      "author": "Jennings Brown",

      "title": "World's Dumbest Bitcoin Scammer Tries to Scam Bitcoin Educator, Gets Scammed in The Process",

      "description": "Ben Perrin is a Canadian cryptocurrency enthusiast and educator who hosts a bitcoin show on YouTube. This is immediately apparent after a quick a look at all his social media. Ten seconds of viewing on of his videos will show that he is knowledgeable about di…",

      "url": "https://gizmodo.com/worlds-dumbest-bitcoin-scammer-tries-to-scam-bitcoin-ed-1837032058",

      "urlToImage": "https://i.kinja-img.com/gawker-media/image/upload/s--uLIW_Oxp--/c_fill,fl_progressive,g_center,h_900,q_80,w_1600/s4us4gembzxlsjrkmnbi.png",

      "publishedAt": "2019-08-07T16:30:00Z",

      "content": "Ben Perrin is a Canadian cryptocurrency enthusiast and educator who hosts a bitcoin show on YouTube. This is immediately apparent after a quick a look at all his social media. Ten seconds of viewing on of his videos will show that he is knowledgeable about..."

    }

  ]

}

为了在Go中使用此数据,我们需要生成一个在解码响应主体时镜像数据的结构。您当然可以手动执行此操作,但是我的首选方法是使用JSON-to-Go网站,该过程非常容易。它将生成适用于该JSON的Go结构(带有标签)。

您需要做的就是复制JSON对象并将其粘贴到标记为JSON的字段中 ,然后复制输出并将其粘贴到您的代码中。这是上面的JSON对象得到的结果:

type AutoGenerated struct {

Status       string `json:"status"`

TotalResults int    `json:"totalResults"`

Articles     []struct {

Source struct {

ID   interface{} `json:"id"`

Name string      `json:"name"`

} `json:"source"`

Author      string    `json:"author"`

Title       string    `json:"title"`

Description string    `json:"description"`

URL         string    `json:"url"`

URLToImage  string    `json:"urlToImage"`

PublishedAt time.Time `json:"publishedAt"`

Content     string    `json:"content"`

} `json:"articles"`

}

勇敢的浏览器显示JSON to Go工具

AutoGenerated通过将的切片分割Articles成自己的结构并更新结构名称,对结构 进行了一些更改。将以下内容粘贴到tpl变量声明下面,main.go然后将time 包添加到导入中:

type Source struct {

ID   interface{} `json:"id"`

Name string      `json:"name"`

}

type Article struct {

Source      Source    `json:"source"`

Author      string    `json:"author"`

Title       string    `json:"title"`

Description string    `json:"description"`

URL         string    `json:"url"`

URLToImage  string    `json:"urlToImage"`

PublishedAt time.Time `json:"publishedAt"`

Content     string    `json:"content"`

}

type Results struct {

Status       string    `json:"status"`

TotalResults int       `json:"totalResults"`

Articles     []Article `json:"articles"`

}

您可能知道,Go要求结构中的所有导出字段以大写字母开头。但是,习惯上用Camel大小写或Snake大小写名称表示JSON字段,这些名称不以大写字母开头。

因此,我们利用结构字段标签,例如json:"id"将结构字段显式映射到JSON字段,如上所示。这也使得有可能在结构字段和相应的json字段中使用竞争不同的名称(如果需要)。

最后,让我们为每个搜索查询创建另一个结构类型。将其添加到以下Results结构中main.go:

type Search struct {

SearchKey  string

NextPage   int

TotalPages int

Results    Results

}

该结构表示用户进行的每个搜索查询。该SearchKey是查询本身的NextPage领域允许我们通过页面的结果, TotalPages是结果页面的查询的总数,Results是结果查询当前页面。

发送请求到News API并呈现结果

现在我们有了应用程序的数据模型,让我们继续向News API发出请求,然后在页面上呈现结果。

由于News API需要API密钥,因此我们需要找出一种无需在代码中进行硬编码即可在应用程序中传递该密钥的方法。环境变量是一种常见的方法,但是我选择使用命令行标志。Go提供了一个flag支持基本命令行标志解析的软件包,这就是我们将在此处使用的软件包。

首先,在apiKey变量下声明一个新tpl变量:

var apiKey *string

然后在main函数中使用它,如下所示:

func main() {

apiKey = flag.String("apikey", "", "Newsapi.org access key")

flag.Parse()


if *apiKey == "" {

log.Fatal("apiKey must be set")

}


// rest of the function

}

在这里,我们调用flag.String()允许我们定义字符串标志的方法。此方法的第一个参数是标志名称,第二个参数是默认值,而第三个参数是用法说明。

定义所有标志后,您需要调用flag.Parse()以实际解析它们。最后,由于apikey是该应用程序的必需组件,因此,如果在执行程序时未设置此标志,我们将确保程序崩溃。

确保已将flag软件包添加到导入中,然后重新启动服务器并传递所需的apikey标志,如下所示:

go run main.go -apikey=<your newsapi access key>

接下来,让我们继续进行更新,searchHandler以便将用户的搜索查询发送到newsapi.org,并将结果呈现在我们的模板中。

用 以下代码替换函数fmt.Println()末尾的两个方法调用searchHandler:

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

// beginning  of the function


search := &Search{}

search.SearchKey = searchKey


next, err := strconv.Atoi(page)

if err != nil {

http.Error(w, "Unexpected server error", http.StatusInternalServerError)

return

}


search.NextPage = next

pageSize := 20


endpoint := fmt.Sprintf("https://newsapi.org/v2/everything?q=%s&pageSize=%d&page=%d&apiKey=%s&sortBy=publishedAt&language=en", url.QueryEscape(search.SearchKey), pageSize, search.NextPage, *apiKey)

resp, err := http.Get(endpoint)

if err != nil {

w.WriteHeader(http.StatusInternalServerError)

return

}


defer resp.Body.Close()


if resp.StatusCode != 200 {

w.WriteHeader(http.StatusInternalServerError)

return

}


err = json.NewDecoder(resp.Body).Decode(&search.Results)

if err != nil {

w.WriteHeader(http.StatusInternalServerError)

return

}


search.TotalPages = int(math.Ceil(float64(search.Results.TotalResults / pageSize)))

err = tpl.Execute(w, search)

if err != nil {

log.Println(err)

}

}

首先,我们创建该Search结构的新实例,并将SearchKey 该实例上的字段设置q为HTTP请求中URL参数的值。

之后,我们将page变量转换为整数,并将结果分配给变量的NextPage字段search。然后,我们创建一个 pageSize变量并将其值设置为20。此pageSize变量表示News API将在其响应中返回的结果数。该值的范围是0到100。

接下来,我们使用构建端点,fmt.Sprintf()并对其进行GET请求。如果News API的响应不是200 OK,我们将向客户端返回一个通用服务器错误。否则,响应主体将解析为search.Results。

然后,我们将TotalResults字段除以来计算总页数pageSize。例如,如果一个查询返回100个结果,而我们一次只查看20个结果,则我们将需要分页浏览五页以查看该查询的所有100个结果。

之后,我们执行模板并将search变量作为数据接口传递。如您所见,这使我们能够从模板中的JSON对象访问数据。

切换到之前index.html,请确保如下所示更新导入:

import (

"encoding/json"

"flag"

"fmt"

"html/template"

"log"

"math"

"net/http"

"net/url"

"os"

"strconv"

"time"

)

让我们继续进行以下修改index.html,以将结果呈现到页面上。将此添加到<header>标签下面:


<section class="container">

  <ul class="search-results">

    {{ range .Results.Articles }}

      <li class="news-article">

        <div>

          <a target="_blank" rel="noreferrer noopener" href="{{.URL}}">

            <h3 class="title">{{.Title }}</h3>

          </a>

          <p class="description">{{ .Description }}</p>

          <div class="metadata">

            <p class="source">{{ .Source.Name }}</p>

            <time class="published-date">{{ .PublishedAt }}</time>

          </div>

        </div>

        <img class="article-image" src="{{ .URLToImage }}">

      </li>

    {{ end }}

  </ul>

</section>

要访问模板中的struct字段,我们使用点运算符。该运算符引用struct对象(search在这种情况下),然后在模板内部,我们仅指定字段名称(如{{ .Results }})。

该range块使我们可以遍历Go中的一个切片,并为该切片中的每个项目输出一些HTML。在这里,我们遍历Article该Articles字段中包含的结构片,并在每次迭代中输出一些HTML。

重新启动服务器,刷新浏览器并搜索有关热门主题的新闻。您应该在页面上获得20条结果的列表,如下面的GIF所示。

浏览器显示新闻列表

在输入中保留搜索查询

请注意,页面刷新结果后,搜索查询如何从输入中消失。理想情况下,查询应一直保留到用户进行新搜索为止。例如,这就是Google搜索的工作方式。

我们可以通过如下方式更新文件中标记的value属性来轻松解决此问题:inputindex.html

<input autofocus class="search-input" value="{{ .SearchKey }}" placeholder="Enter a news topic" type="search" name="q">

重新启动浏览器,然后进行新的搜索。搜索查询将被保留,如下所示:

格式化发布日期

如果您查看每篇文章中的日期,您会发现它的可读性不高。当前输出是News API如何返回文章的发布日期。但是我们可以通过在Article 结构中添加一个方法并使用该方法格式化日期而不是使用默认值来轻松更改此方法。

继续,在Articlestruct中的 下面添加以下代码main.go:

func (a *Article) FormatPublishedDate() string {

year, month, day := a.PublishedAt.Date()

return fmt.Sprintf("%v %d, %d", month, day, year)

}

此处,FormatPublishedDate在Articlestruct上创建了一个新方法,该方法格式化上的PublishedAt字段,Article并以以下格式返回字符串:January 10, 2009。

要在模板中使用此新方法,请在文件中替换.PublishedAt为 。然后重新启动服务器并重复上一个搜索查询。这将以正确的时间格式输出结果,如下所示:.FormatPublishedDateindex.html

显示结果总数

让我们通过在页面顶部显示结果总数来改善新闻应用程序的UI,然后在未发现特定查询结果的情况下显示一条消息。

您需要做的就是将以下代码作为的子代添加.container,.search-results位于index.html文件元素上方:


<div class="result-count">

  {{ if (gt .Results.TotalResults 0)}}

  <p>About <strong>{{ .Results.TotalResults }}</strong> results were found.</p>

  {{ else if and (ne .SearchKey "") (eq .Results.TotalResults 0) }}

  <p>No results found for your query: <strong>{{ .SearchKey }}</strong>.</p>

  {{ end }}

</div>

Go模板支持多种比较功能,上面已使用其中一些功能。该gt函数用于检查结构的TotalResults字段Results是否大于零。如果是,结果总数将打印在页面顶部。

否则,如果SearchKey不等于空字符串((ne .SearchKey ""))且TotalResults等于零((eq .Results.TotalResults 0)),则输出“未找到结果”消息。

重新启动服务器,然后在搜索输入中键入一些乱码,以便找不到您要查询的新闻项目。您应该No results found在屏幕上看到该消息。

浏览器显示未找到结果消息

之后,这次再次搜索热门主题。结果数将在页面顶部输出,如下所示:

浏览器将结果显示在页面顶部

分页

由于我们一次只显示20个结果,因此我们需要一种让用户随时查看结果的下一页或上一页的方法。

首先,如果尚未到达结果的最后一页,我们将在结果底部添加一个“下一步”按钮。要确定是否已到达结果的最后一页,请在Searchstruct定义下创建此新方法main.go:

func (s *Search) IsLastPage() bool {

return s.NextPage >= s.TotalPages

}

此方法检查NextPage字段是否大于实例TotalPages上的字段Search。但是,要使此方法起作用,我们需要在NextPage每次呈现新的结果页面时增加。这就是这样做的方法:

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

// start of the function

search.TotalPages = int(math.Ceil(float64(search.Results.TotalResults / pageSize)))

// Add this if block

if ok := !search.IsLastPage(); ok {

search.NextPage++

}


// rest of the function

}

最后,让我们添加一个按钮,该按钮将允许用户转到结果的下一页。以下内容应放在.search-results您的 index.html文件中。

<div class="pagination">

  {{ if (ne .IsLastPage true) }}

    <a href="/search?q={{ .SearchKey }}&page={{ .NextPage }}" class="button next-page">Next</a>

  {{ end }}

</div>

只要尚未到达该查询的最后一页,“下一步”按钮将呈现在结果列表的底部。

如您所见,href上面的anchor标签的指向/search路线,并在使用q参数中的值的同时 将当前搜索查询保留NextPage在page参数中。

让我们也输入上一个按钮。仅当当前页面大于1时才应显示此按钮。为此,我们需要在其上创建一个新CurrentPage()方法Search来帮助我们做到这一点。在IsLastPage方法下面添加:

func (s *Search) CurrentPage() int {

if s.NextPage == 1 {

return s.NextPage

}


return s.NextPage - 1

}

当前页面仅是NextPage - 1if(如果NextPage为1)。要获得上一页,只需从当前页面减去1。以下方法可以做到这一点:

func (s *Search) PreviousPage() int {

return s.CurrentPage() - 1

}

因此,仅当当前页面大于1时,我们才能添加以下代码以呈现“上一步”按钮。如下修改.pagination您的元素index.html:

<div class="pagination">

  {{ if (gt .NextPage 2) }}

    <a href="/search?q={{ .SearchKey }}&page={{ .PreviousPage }}" class="button previous-page">Previous</a>

  {{ end }}

  {{ if (ne .IsLastPage true) }}

    <a href="/search?q={{ .SearchKey }}&page={{ .NextPage }}" class="button next-page">Next</a>

  {{ end }}

</div>

现在,重新启动服务器,并进行新的搜索查询。您应该能够分页显示结果,如下所示:

显示当前页面

不仅显示查询的结果总数,还有助于用户查看该查询的总页数以及他当前所处的页面。

为此,我们只需要index.html如下修改文件:

<div class="result-count">

  {{ if (gt .Results.TotalResults 0)}}

    <p>About <strong>{{ .Results.TotalResults }}</strong> results were found. You are on page <strong>{{ .CurrentPage }}</strong> of <strong> {{ .TotalPages }}</strong>.</p>

  {{ else if (ne .SearchKey "") and (eq .Results.TotalResults 0) }}

    <p>No results found for your query: <strong>{{ .SearchKey }}</strong>.</p>

  {{ end }}

</div>

重新启动服务器并进行新搜索后,将在页面顶部显示当前页面和总页数以及总结果计数。

浏览器显示当前页面

妥善处理错误

目前,如果对News API的请求失败,我们只会收到500 Internal Server错误,而没有添加任何信息。让我们通过在响应正文中显示从请求接收到的错误消息来解决此问题。

在函数下面添加以下结构indexHandler。此结构表示每当请求失败时从News API接收到的JSON响应。


type NewsAPIError struct {

Status  string `json:"status"`

Code    string `json:"code"`

Message string `json:"message"`

}

让我们继续更新该searchHandler函数中的错误处理 。

查找函数的以下部分:

if resp.StatusCode != 200 {

  w.WriteHeader(http.StatusInternalServerError)

  return

}

并将其替换为以下代码段:

if resp.StatusCode != 200 {

  newError := &NewsAPIError{}

  err := json.NewDecoder(resp.Body).Decode(newError)

  if err != nil {

    http.Error(w, "Unexpected server error", http.StatusInternalServerError)

    return

  }


  http.Error(w, newError.Message, http.StatusInternalServerError)

  return

}

现在,一旦请求失败,我们就会收到一条简短的消息,说明原因。

新闻API错误

部署到Heroku

现在我们的应用程序已经完成功能,让我们继续将其部署到Heroku。注册一个免费帐户,然后点击此链接创建一个新应用。给它起一个唯一的名字。我打电话给我的新生新闻。

接下来,按照此处的说明在计算机上安装Heroku CLI。然后heroku login在终端中运行命令以登录到您的Heroku帐户。

确保已为项目初始化了一个git存储库。如果不是,请git init在您的项目目录的根目录下运行命令,然后运行以下命令以将heroku设置为git repo的远程目录。用freshman-news您的应用程序名称替换。

heroku git:remote -a freshman-news

接下来,在您的项目目录(touch Procfile)的根目录中创建一个Procfile,然后粘贴以下内容:

web: bin/news-demo -apikey $NEWS_API_KEY

然后,为您的项目指定GitHub存储库,并在go.mod文件中指定您正在使用的Go版本,如下所示。如果项目根目录中尚不存在此文件,则创建它。

module github.com/freshman-tech/news-demo

go 1.12.9

在部署应用程序之前,请转到Heroku仪表板中的“设置”标签,然后点击“显示配置变量”。我们需要设置NEWS_API_KEY环境变量,以便在启动服务器时可以将其传递给二进制文件。

Heroku配置变量

最后,使用以下命令提交代码并将其推送到Heroku远程服务器:

git add .

git commit -m "Initial commit"

git push heroku master

部署过程完成后,您可以打开https://<your-app-name>.herokuapp.com以查看和测试项目。

image.png

结论

在本文中,我们成功创建了一个新闻应用程序,并学习了使用Go进行Web开发的基础知识。我们还介绍了如何将完成的应用程序部署到Heroku。

技术开发 编程 技术框架 技术发展