创建一个Dockerized Go应用程序 - 带有Cron作业,松弛消息并测试整个过程
#go #docker #自动化 #slack

在过去的几年中,我一直在使用越来越多的人来编写后端软件。我一直想在Go上写一篇更深入的文章已经有一段时间了,并想到了一个有趣的小应用程序,我们可以共同努力说明我编写现实世界中的生产准备就绪应用程序的一些方式。

本文与the e-book "Go for Real World Applications" on Gumroad

相对应

以及full course on Udemy

和课程预览on YouTube (first 9 of 19 lessons)

example repository, one branch per lesson, is on GitHub

本文只是整个课程和书籍中所有细节的概述,其中不包括:

- 重新编码变量并将其移至环境文件

- 为Slack Messages添加更多的精美格式和样式

- 用圆CI

创建自动CI/CD管道

插头已经完成,让我们进入完整的教程!

为什么要去?

如果您已经阅读了我的博客或其他一些文章,您可能已经意识到我不是任何一种语言或框架传教士 - 实际上,我尝试保持语言或框架不可知:选择正确的工具为了合适的工作。但是,有许多诱人的理由考虑添加添加到您的堆栈中:

1. GO非常表现

读取文件,数学操作,您就是这样说的 - 我经常对操作的速度给我留下深刻的印象,到目前为止,我从来不必深入研究我的代码来提高性能 - 甚至某些某些我为The Wheel Screener和其他Finance应用程序构建的工作,我正在做10,000多种选项合同计算每秒,在相当平均的4个核心机器上几乎没有放缓或潜伏期。

2. GO非常紧凑

GO Projects汇编为单个二进制文件。这意味着没有怪物外部文件,一个无头的软件包系统全部保存在go.mod中,仅此而已。为了对这些应用程序进行扩展,正如我们在本文稍后看到的那样,它是完美的场景。

3. GO是极其可测试的

GO具有内置内置的测试框架。仅此一项就会向您发出什么样的语言,是一种可以做好工作并有效地完成工作的方法。

4. GO非常友好

go还提供了JSON支持类型,序列化和挑剔(被称为“在Go World”中的编组和拆除)的支持。 GO是我知道可以做到这一点的唯一语言,如果您在没有客户库库时完成了诸如JavaScript或C#之类的API操作,那么您就会知道可以正确序列化并选择JSON有效载荷所带来的痛苦。 (也许Rust或其他一些较新的语言也具有内置的JSON支持。

我们要建造什么

所以,我患有花粉症。如果您以这种烦人的过敏症是世界各地数百万人之一,那么您也可能知道我的痛苦:)。在北半球,这对我来说是一场斗争,从大约五月中旬到每年6月中旬。幸运的是,我居住在奥地利,MedizinischeUniversitétient(维也纳医科大学)的网站上有一个整洁的网站,可以预测当天的“过敏风险”,即花粉在空中有多少天。 (在给定的一天中,诸如雨水和盛行的风实际上可以减少或增加空气中的花粉)。我在他们的网站上嗅了一下,发现了一些提供此信息的API电话。第一个URL在给定的一天中提供小时的风险水平:

https://www.pollenwarndienst.at/index.php?eID=appinterface&action=getHourlyLoadData&type=zip&value=6800&country=AT&lang_id=0&pure_json=1&day=0

,响应看起来像这样:

{
    "success": 1,
    "result": {
        "total": 8,
        "dayrisk_personalized": false,
        "hourly": [
            5,
            4,
            4,
            3,
            5,
            4,
            3,
            2,
            3,
            6,
            8,
            8,
            8,
            8,
            8,
            8,
            8,
            8,
            8,
            8,
            8,
            8,
            8,
            8
        ]
    }
}

,第二天给出了历史平均水平和实际花粉量:

https://www.pollenwarndienst.at/index.php?eID=appinterface&action=getCurrentChartData&poll_id=5&region_id=&zip=6800&season=2&lang_id=1&pure_json=1

这个人的响应形状看起来像这样:

{
    "success": 1,
    "results": [
    {
        "date": "2023-04-05",
        "current": 0.4,
        "average": 0.6,
        "season": "false",
        "datetime": 1680652800000
    },
    {
        "date": "2023-04-06",
        "current": 0.5,
        "average": 0.7,
        "season": "false",
        "datetime": 1680739200000
    },
    {
        "date": "2023-04-07",
        "current": 0.6,
        "average": 0.7,
        "season": "false",
        "datetime": 1680825600000
    },
    {
        "date": "2023-04-08",
        "current": 0.6,
        "average": 0.7,
        "season": "false",
        "datetime": 1680912000000
    },
    {
        "date": "2023-04-09",
        "current": 0.5,
        "average": 0.7,
        "season": "false",
        "datetime": 1680998400000
    },
    {
        "date": "2023-04-10",
        "current": 0.9,
        "average": 0.8,
        "season": "false",
        "datetime": 1681084800000
    }
    ]
}

但是,似乎“当前”值通常延迟了4-5天,因此,对于实时更新,我们必须依靠小时端点。

要求

我们将构建一个执行以下操作的应用程序:

  1. 在指定的时间,请致电先前显示的过敏API端点

  2. 解析每个呼叫的响应,并将其格式化为一个不错的人类可读消息

  3. 通过Slack

  4. 发送该消息

都清楚吗?让我们构建这个东西!

编写应用程序

安装去

首先,您应该确保已安装在系统上。您可能已经拥有了,您可以通过发行来检查:

go version

,您应该得到类似的输出:

go version go1.20.2 darwin/amd64

如果您还没有安装,you can install it for Mac, Linux, or Windows by following the documenation on the official Go site.

现在让我们开始构建我们的应用!

脚手架

我几乎总是忘记如何在Go中做到这一点,但仍然认为这很奇怪,所以我将其添加到本文中作为参考。我们将在一个名为allergycron/的新文件夹中创建GO应用程序。然后,我们只需要初始化我们的模块,我们还会创建一个新的main.go文件:

mkdir allergycron
cd allergycron
go mod init allergycron
touch main.go

我可能会在Visual Studio代码中打开整个项目:

open .

并打开main.go准备编写代码。

克朗工作

让我们首先将cron作业添加到我们的main.go文件中。对于GO中的Cron Jobs,我使用了流行的github.com/robfig/cron/v3库。为特定时区添加一个cron(我想在维也纳时间,又名中欧时间或CET,在上午08:00:00),可以这样做:

package main

import (
    "time"

    "github.com/robfig/cron"
)

func main() {
    // be sure to modify to run your desired timezone!
    loc, err := time.LoadLocation("Europe/Vienna")
    if err != nil {
    panic(err)
    }

    cronJob := cron.NewWithLocation(loc)

    cronJob.AddFunc("0 0 8 * * *", func() {

    })

    cronJob.Start()

    // run forever
    select {}
}

从IANA时区数据库查找时区。您可以查看the names of all of them on Noda Time。通过以这种方式配置的Cron作业,无论它在世界范围内部署在哪里(就像云一样,它将在您所需的时区中运行,就像我们稍后会看到的)。 cron字符串本身由六个单独的元素组成:秒,几分钟,小时,每月,每月和一周的一天,as specified in the cron godoc

HTTP实用程序

我将使用my generic HTTP function调用过敏API并发送Slack消息。此通用功能非常灵活,可重复使用,并且几乎是我所有GO代码库中的主食。我们将制作一个新的文件夹utils/并添加文件make_http_request.go

package utils

import (
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "strings"
)

// in the case of GET, the parameter queryParameters is transferred to the URL as query parameters
// in the case of POST, the parameter body, an io.Reader, is used
func MakeHTTPRequest[T any](fullUrl string, httpMethod string, headers map[string]string, queryParameters url.Values, body io.Reader, responseType T) (T, error) {
    client := http.Client{}
    u, err := url.Parse(fullUrl)
    if err != nil {
    return responseType, err
    }

    // if it's a GET, we need to append the query parameters.
    if httpMethod == "GET" {
    q := u.Query()

    for k, v := range queryParameters {
    // this depends on the type of api, you may need to do it for each of v
    q.Set(k, strings.Join(v, ","))
    }
    // set the query to the encoded parameters
    u.RawQuery = q.Encode()
    }

    // regardless of GET or POST, we can safely add the body
    req, err := http.NewRequest(httpMethod, u.String(), body)
    if err != nil {
    return responseType, err
    }

    // for each header passed, add the header value to the request
    for k, v := range headers {
    req.Header.Set(k, v)
    }

    // optional: log the request for easier stack tracing
    log.Printf("%s %s\n", httpMethod, req.URL.String())

    // finally, do the request
    res, err := client.Do(req)
    if err != nil {
    return responseType, err
    }

    if res == nil {
    return responseType, fmt.Errorf("error: calling %s returned empty response", u.String())
    }

    responseData, err := io.ReadAll(res.Body)
    if err != nil {
    return responseType, err
    }

    defer res.Body.Close()

    if res.StatusCode != http.StatusOK {
    return responseType, fmt.Errorf("error calling %s:\nstatus: %s\nresponseData: %s", u.String(), res.Status, responseData)
    }

    var responseObject T
    err = json.Unmarshal(responseData, &responseObject)

    if err != nil {
    log.Printf("error unmarshaling response: %+v", err)
    return responseType, err
    }

    return responseObject, nil
}

我不会在此处进入此功能的步骤 - 如果您有兴趣,您可以在my other article published under Better Programming

中详细了解此事

API响应解析

解析响应有效负载也很少麻烦,正如我们上面提到的,我们只需要在定义响应类型时使用GO的内置JSON功能即可。我们首先制作一个名为allergy_api/的新文件夹,并创建一个名为call_allergy_api.go的新文件。让我们首先定义所需的响应类型:

package allergy_api

type AllergyAPIResult struct {
    Total int `json:"total"`
    Hourly []int `json:"hourly"`
}

type AllergyAPIResponse struct {
    Success int `json:"success"`
    Result AllergyAPIResult `json:"result"`
}

有趣的是,在大学的网站上仅显示从0到4的“风险”水平,但是API数字范围为0到8,因此网站上的UI正在对数据进行某种规范化。目前,我们将假设“归一化”只是将2除以2并舍入到最近的整数:

func GetHourlyLoadData() (*string, error) {
    queryParameters := url.Values{}
    queryParameters.Add("eID", "appinterface")
    queryParameters.Add("action", "getHourlyLoadData")
    queryParameters.Add("type", "zip")
    queryParameters.Add("zip", "6800")
    queryParameters.Add("country", "AT")
    queryParameters.Add("pure_json", "1")

    response, err := utils.MakeHTTPRequest("https://www.pollenwarndienst.at/index.php", "GET", nil, queryParameters, nil, HourlyLoadResponse{})
    if err != nil {
    return nil, err
    }

    // loop over hours and calculate the average
    averageLoad := 0
    for _, hour := range response.Result.Hourly {
    averageLoad += hour
    }
    averageLoad = averageLoad / len(response.Result.Hourly)

    scaledAverageLoad := averageLoad / 2

    formattedMessage := fmt.Sprintf("The average pollen load for today is %d", scaledAverageLoad)

    return &formattedMessage, nil

}

以及当前图表数据终点:

func GetCurrentChartData() (*string, error) {
    queryParameters := url.Values{}
    queryParameters.Add("eID", "appinterface")
    queryParameters.Add("action", "getCurrentChartData")
    queryParameters.Add("poll_id", "5")
    queryParameters.Add("zip", "6800")
    queryParameters.Add("season", "2")
    queryParameters.Add("pure_json", "1")

    // use the utils.MakeHTTPRequest function to call the allergy api and return the result
    response, err := utils.MakeHTTPRequest("https://www.pollenwarndienst.at/index.php", "GET", nil, queryParameters, nil, CurrentChartDataResponse{})
    if err != nil {
    panic(err)
    }

    // get the average historical by matching the YYYY-MM-DD value
    currentYYYYMMDD := time.Now().Format("2006-01-02")
    averageHistorical := 0.0
    for _, result := range response.Results {
    if result.Date == currentYYYYMMDD {
    averageHistorical = result.Average
    }
    }

    scaledAverageHistorical := int(math.Round(averageHistorical / 2.0))

    formattedMessage := fmt.Sprintf("Historically, the average pollen load for today is %d", scaledAverageHistorical)

    return &formattedMessage, nil
}

松弛消息

现在让我们迈出最后一步,向预测的“过敏风险”传达给当天的“过敏风险”。我们将在称为send_slack_message.goutils/文件夹下创建一个新文件:

cd utils
touch send_slack_message.go

将以下内容添加到send_slack_message.go:

package utils

import (
    "bytes"
    "encoding/json"
    "os"
)

func SendSlackMessage(message string) error {
    body, err := json.Marshal(map[string]string{"text": message})
    if err != nil {
    return err
    }

    MakeHTTPRequest(os.Getenv("SLACK_WEBHOOK_URL"), "POST", nil, nil, bytes.NewBuffer(body), "")

    return nil
}

此代码要求我们在环境中定义了Slack_webhook_url。您可以在api.slack.com

创建并设置Slack应用程序

将它们挂在一起

让我们回到main.go并连接我们编写的所有功能。在Cron功能中,添加以下内容:

dailyAverageMessage, err := allergy_api.GetHourlyLoadData()
if err != nil {
    panic(err)
}

historicalAverageMessage, err := allergy_api.GetCurrentChartData()
if err != nil {
    panic(err)
}

slackMessage := *dailyAverageMessage + "\n" + *historicalAverageMessage
err = utils.SendSlackMessage(slackMessage)
if err != nil {
    panic(err)
}
// log the message to the console as well
println("Successfully sent Slack message: " + slackMessage)

请注意,我们还将构造的Slack消息记录到控制台进行调试。

运行!

让我们尝试运行到目前为止建造的内容!要测试“立即”,您可以修改cron消息以包括下一分钟和小时,即10:35:00 am:

cronJob.AddFunc("0 35 10 * * *", func() {
    // ...
})

然后运行主脚本:

go run main.go

几乎立即在我们指定的时区中登录10:35:00之后,我们应该在控制台中看到我们的松弛消息,并在Slack上收到我们的Slack消息!

2023/05/12 10:47:04 Successfully sent Slack message: The average pollen load for today is 3
Historically, the average pollen load for today is 4

测试

让我们为每个功能写一个测试。我喜欢将所有测试放在单独的文件夹中

mkdir tests

在此文件夹中,我们将对所做的每个功能进行测试:

touch allergy_api_test.go
touch send_slack_message_test.go

请注意,文件名必须以_test.go结尾。

我们不会为MakeHTTPRequest明确地制作测试功能,因为我们正在测试的其他功能调用了该功能。

如前所述,GO具有内置测试,这只是导入测试包并将其传递给我们的测试功能的问题:

package tests

import "testing"

func TestAllergyApi(t *testing.T) {

}

和:

package tests

import "testing"

func TestSendSlackMessage(t *testing.T) {

}

请注意,该功能的名称必须以大写字母“测试”开头,才能被GO视为有效的测试功能。

现在,在我们的测试文件中,我们调用该功能,看看是否存在错误:

func TestAllergyApi(t *testing.T) {
    message, err := allergy_api.GetHourlyLoadData()
    if err != nil {
    t.Errorf("Error getting hourly load data: %s", err)
    }
    if message == nil {
    t.Errorf("Error getting hourly load data: message is nil")
    }
    if *message == "" {
    t.Errorf("Error getting hourly load data: message is empty")
    }

    message, err = allergy_api.GetCurrentChartData()
    if err != nil {
    t.Errorf("Error getting current chart data: %s", err)
    }
    if message == nil {
    t.Errorf("Error getting current chart data: message is nil")
    }
    if *message == "" {
    t.Errorf("Error getting current chart data: message is empty")
    }
}

和发送松弛消息:

func TestSendSlackMessage(t *testing.T) {
    err := utils.SendSlackMessage("Test message!")
    if err != nil {
    t.Errorf("Error sending Slack message: %s", err)
    }
}

我们的测试完成了。要运行我们的测试,我们可以使用GO的内置测试命令:

go test -p 1 -v ./tests

这里的标志如下:

  • -p 1:将并行测试工人的数量设置为1。这意味着GO一次只能进行一个测试。如果您有多个测试,它们将在串行中进行一次接一个。

  • -v:启用详细输出。当您使用此标志运行测试时,GO将打印有关每个测试的详细信息,包括其名称,状态(通过或失败)和任何错误消息。

希望,如果我们擅长工作,您应该看到以下输出:

=== RUN   TestAllergyApi
--- PASS: TestAllergyApi (1.17s)
=== RUN   TestSendSlackMessage
--- PASS: TestSendSlackMessage (0.00s)
PASS
ok      allergycron/tests        1.498s

停靠应用程序

将我们的整个应用程序放在Docker容器中就像定义Adockerfile和Docker-Compose.yml:
一样容易

FROM golang:1.20-alpine

WORKDIR /app
COPY . .

RUN go build -o /allergycron

CMD [ "/allergycron" ]

和:

    version: "3.9"
    services:
      allergycron:
        build: .
        restart: unless-stopped

使用创建的这些文件,您可以使用以下任意

docker compose build --no-cache && docker compose up -d

或:

docker-compose build --no-cache && docker-compose up -d

取决于您是否已安装了Docker或Docker-Compose。经过一些构建输出后,您最终应该看到这样的东西:

[+] Running 2/2
    ⠿ Network "allergycron_default"  Created                                                                     4.8s
    ⠿ Container allergycron_allergycron_1    Started

检查您的应用程序是否正在运行:

docker ps -a

您应该看到这样的东西:

CONTAINER ID   IMAGE                           COMMAND                  CREATED          STATUS                      PORTS                    NAMES
b35e7c28d564   alergycron_allergycron          "/allergycron"           21 seconds ago   Up 18 seconds                                        alergycron_allergycron_1

完毕!鳍!完成的!

在短短20分钟内,您就有一个已建立的GO应用程序,而Cron的作业发送了拨打API,解析响应并向您发送摘要的Slack Messages的Slack消息!哦,去的奇迹!希望正如我在整篇文章中所指出的那样,此应用程序很容易重构以ping其他API URL并发送其他类型的Slack消息。

谢谢!

我希望这篇文章能够诱使您尝试自己的力量,并以我们可以创建dockerift,eststed and toct of toctust的后端应用程序的轻松性向您展示!

-CHRISð»

渴望获得更多深入的软件教程,演练,课程和书籍,从我在大型和小型公司到初创公司的现实世界经验中收集的书籍?我的任务是教授1,000,000个新兴开发人员现实世界中的软件! Check out my blog for more!

我的Udemy个人资料和课程:

https://www.udemy.com/user/chris-frewin/

我的技能展示资料和课程:

https://www.skillshare.com/en/user/christopherfrewin