在生产中使用 Golang 并发
应用程序概述。
采取的步骤
步骤2:客户端实现(用于HTTP调用)
结论
在本文中,我们将了解如何使用实际应用程序在 Golang 中实现异步编程。
异步
我们的日常琐事充满了并发(异步)活动。
例如,煮咖啡时,你先烧开水,把咖啡倒进杯子里,再添加其他需要的材料,最后把烧开的水倒进杯子里。这样,你的咖啡就煮好了。
同步
从上面的例子来看,同步执行意味着你必须等待一个任务完成才能执行另一个任务。也就是说,把水放到加热器上,直到水沸腾才执行其他操作。当然,我们认为这种方法完全是在浪费时间,而且效率很低。
因此,同步实现一个本质上是异步的功能是低效的。
我编写了一个实际程序:用户名查找应用程序,它演示了如何利用异步编程同时向不同端点发出 HTTP 调用并检索数据。它的工作原理是:您提供一个要查找的用户名,然后在您指定的帐户(例如 Twitter、Instagram、Github 等)中检查该用户名。点击此处访问该应用程序,您也可以在Github
上获取代码。
应用程序概述。
后端使用Golang,前端使用
VueJS ,
Docker用于在Heroku上部署,
Travis用于持续集成
采取的步骤
步骤1:基本设置
为项目创建根文件夹,标题为:username_across_platforms
mkdir username_across_platforms
使用上面创建的文件夹名称初始化go 模块:
go mod init username_across_platforms
步骤2:客户端实现(用于HTTP调用)
创建服务器包:
mkdir server
由于我们将向不同的 URL 发出 HTTP 请求,因此需要一个客户端。注意,这里的客户端并非前端,而是用于服务器端的 HTTP 调用。
在服务器包(文件夹)中,创建客户端包(目录):
cd server && mkdir client
然后在客户端包内创建client.go:
cd client && touch client.go
| package client | |
| import ( | |
| "net/http" | |
| ) | |
| var ( | |
| ClientCall HTTPClient = &clientCall{} | |
| ) | |
| type HTTPClient interface { | |
| GetValue(url string) (*http.Response, error) | |
| } | |
| type clientCall struct { | |
| Client http.Client | |
| } | |
| func (ci *clientCall) GetValue(url string) (*http.Response, error) { | |
| res, err := ci.Client.Get(url) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return res, nil | |
| } |
| package client | |
| import ( | |
| "net/http" | |
| ) | |
| var ( | |
| ClientCall HTTPClient = &clientCall{} | |
| ) | |
| type HTTPClient interface { | |
| GetValue(url string) (*http.Response, error) | |
| } | |
| type clientCall struct { | |
| Client http.Client | |
| } | |
| func (ci *clientCall) GetValue(url string) (*http.Response, error) { | |
| res, err := ci.Client.Get(url) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return res, nil | |
| } |
从上面的文件中,你可能会好奇为什么我们使用了接口之类的东西,当你看到测试文件时,你就会明白了。我们需要模拟GetValue方法。除非该方法定义在接口中,否则我们无法做到这一点。
我还想让你观察一下我们是如何实现这个接口的。
我们定义了一个clientCall结构体,GetValue方法“属于”它。然后,该结构体在以下代码行中实现了该接口:
ClientCall HTTPClient = &clientCall{}
该结构体还包含http.Client。这将帮助我们用一个伪造的 **http.Client 替换实际的 **http.Client,这样我们在编写测试用例时就不会进行真正的 http 调用。
仍然在同一个包中,创建测试文件:
touch client_test.go
| package client | |
| import ( | |
| "errors" | |
| "github.com/stretchr/testify/assert" | |
| "net/http" | |
| "testing" | |
| ) | |
| //USING HTTP TRANSPORT | |
| // RoundTripFunc . | |
| type RoundTripFunc func(req *http.Request) (*http.Response, error) | |
| // RoundTrip . //this method is from the RoundTripper interface | |
| func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |
| return f(req) | |
| } | |
| //NewFakeClient returns *http.Client with Transport replaced to avoid making real calls | |
| func NewFakeClient(fn RoundTripFunc) *http.Client { | |
| return &http.Client{ | |
| Transport: fn, | |
| } | |
| } | |
| func TestGetWithRoundTripper_Success(t *testing.T) { | |
| client := NewFakeClient(func(req *http.Request) (*http.Response, error) { | |
| //return the response we want | |
| return &http.Response{ | |
| StatusCode: 200, | |
| // Must be set to non-nil value or it panics | |
| Header: make(http.Header), | |
| }, nil | |
| }) | |
| api := clientCall{*client} | |
| url := "https://twitter.com/stevensunflash" //this url can be anything | |
| body, err := api.GetValue(url) | |
| assert.Nil(t, err) | |
| assert.NotNil(t, body) | |
| assert.EqualValues(t, http.StatusOK, body.StatusCode) | |
| } | |
| func TestGetWithRoundTripper_No_Match(t *testing.T) { | |
| client := NewFakeClient(func(req *http.Request) (*http.Response, error) { | |
| //return the response we want | |
| return &http.Response{ | |
| StatusCode: 404, //the real api status code may be 404, 422, 500. But we dont care | |
| // Must be set to non-nil value or it panics | |
| Header: make(http.Header), | |
| }, nil | |
| }) | |
| api := clientCall{*client} | |
| url := "https://twitter.com/no_match_random" //we passed in a user that is not found | |
| body, err := api.GetValue(url) | |
| assert.Nil(t, err) | |
| assert.NotNil(t, body) | |
| assert.EqualValues(t, http.StatusNotFound, body.StatusCode) | |
| } | |
| func TestGetWithRoundTripper_Failure(t *testing.T) { | |
| client := NewFakeClient(func(req *http.Request) (*http.Response, error) { | |
| return nil, errors.New("we couldn't access the url provided") //the response we want | |
| }) | |
| api := clientCall{*client} | |
| url := "https://invalid_url/stevensunflash" //we passed an invalid url | |
| body, err := api.GetValue(url) | |
| assert.NotNil(t, err) | |
| assert.Nil(t, body) | |
| assert.EqualValues(t, "Get https://invalid_url/stevensunflash: we couldn't access the url provided", err.Error()) | |
| } |
| package client | |
| import ( | |
| "errors" | |
| "github.com/stretchr/testify/assert" | |
| "net/http" | |
| "testing" | |
| ) | |
| //USING HTTP TRANSPORT | |
| // RoundTripFunc . | |
| type RoundTripFunc func(req *http.Request) (*http.Response, error) | |
| // RoundTrip . //this method is from the RoundTripper interface | |
| func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |
| return f(req) | |
| } | |
| //NewFakeClient returns *http.Client with Transport replaced to avoid making real calls | |
| func NewFakeClient(fn RoundTripFunc) *http.Client { | |
| return &http.Client{ | |
| Transport: fn, | |
| } | |
| } | |
| func TestGetWithRoundTripper_Success(t *testing.T) { | |
| client := NewFakeClient(func(req *http.Request) (*http.Response, error) { | |
| //return the response we want | |
| return &http.Response{ | |
| StatusCode: 200, | |
| // Must be set to non-nil value or it panics | |
| Header: make(http.Header), | |
| }, nil | |
| }) | |
| api := clientCall{*client} | |
| url := "https://twitter.com/stevensunflash" //this url can be anything | |
| body, err := api.GetValue(url) | |
| assert.Nil(t, err) | |
| assert.NotNil(t, body) | |
| assert.EqualValues(t, http.StatusOK, body.StatusCode) | |
| } | |
| func TestGetWithRoundTripper_No_Match(t *testing.T) { | |
| client := NewFakeClient(func(req *http.Request) (*http.Response, error) { | |
| //return the response we want | |
| return &http.Response{ | |
| StatusCode: 404, //the real api status code may be 404, 422, 500. But we dont care | |
| // Must be set to non-nil value or it panics | |
| Header: make(http.Header), | |
| }, nil | |
| }) | |
| api := clientCall{*client} | |
| url := "https://twitter.com/no_match_random" //we passed in a user that is not found | |
| body, err := api.GetValue(url) | |
| assert.Nil(t, err) | |
| assert.NotNil(t, body) | |
| assert.EqualValues(t, http.StatusNotFound, body.StatusCode) | |
| } | |
| func TestGetWithRoundTripper_Failure(t *testing.T) { | |
| client := NewFakeClient(func(req *http.Request) (*http.Response, error) { | |
| return nil, errors.New("we couldn't access the url provided") //the response we want | |
| }) | |
| api := clientCall{*client} | |
| url := "https://invalid_url/stevensunflash" //we passed an invalid url | |
| body, err := api.GetValue(url) | |
| assert.NotNil(t, err) | |
| assert.Nil(t, body) | |
| assert.EqualValues(t, "Get https://invalid_url/stevensunflash: we couldn't access the url provided", err.Error()) | |
| } |
从上面的文件中,我们借助RoundTripFunc伪造了真实的 http 调用,你也可以考虑使用httptest.Server。
你可以看到在NewFakeClient函数中, http.Client的Transport是如何与 RoundTripFunc 进行交换的。
步骤 3:提供商实施
由于我们的客户端已经准备就绪,并且已经完成了足够的单元测试,接下来让我们创建一个提供程序,它调用客户端的GetValue方法,并将获得的响应传递给通道。
在服务器包(目录)中,创建提供程序包,然后创建 provider.go 文件:
mkdir provider
cd provider && touch provider.go
| package provider | |
| import "username_across_platforms/server/client" | |
| type checkInterface interface { | |
| CheckUrl(string, chan string) | |
| } | |
| type checker struct {} | |
| var Checker checkInterface = &checker{} | |
| func (check *checker) CheckUrl(url string, c chan string){ | |
| resp, err := client.ClientCall.GetValue(url) | |
| //we could not access that endpoint | |
| if err != nil { | |
| c <- "cant_access_resource" | |
| return | |
| } | |
| //the response code may be 404, 422, etc | |
| if resp.StatusCode > 299 { | |
| c <- "no_match" | |
| } | |
| if resp.StatusCode == 200 { | |
| c <- url | |
| } | |
| } |
| package provider | |
| import "username_across_platforms/server/client" | |
| type checkInterface interface { | |
| CheckUrl(string, chan string) | |
| } | |
| type checker struct {} | |
| var Checker checkInterface = &checker{} | |
| func (check *checker) CheckUrl(url string, c chan string){ | |
| resp, err := client.ClientCall.GetValue(url) | |
| //we could not access that endpoint | |
| if err != nil { | |
| c <- "cant_access_resource" | |
| return | |
| } | |
| //the response code may be 404, 422, etc | |
| if resp.StatusCode > 299 { | |
| c <- "no_match" | |
| } | |
| if resp.StatusCode == 200 { | |
| c <- url | |
| } | |
| } |
如文件所示,CheckUrl方法定义在一个接口中(因为我们将来编写单元测试时需要模拟它)。在方法实现中,我们传递了要查找的 URL 以及用于发送响应(如果不可用则发送错误)的通道。我们在这里使用通道的主要原因是,在实现服务时, checkUrl方法将在不同的goroutine中被调用。 简而言之,checkUrl方法检查 URL,例如https://twitter.com/stevensunflash,如果 URL 不存在,则向通道发送cant_access_resource 消息。如果 URL 存在但未找到用户名stevensunflash ,则向通道发送no_match 消息;如果找到了所需的用户名,则将URL发送到通道。
现在让我们测试一下实现。
创建provider_test.go文件:
touch provider_test.go
| package provider | |
| import ( | |
| "errors" | |
| "github.com/stretchr/testify/assert" | |
| "net/http" | |
| "testing" | |
| "username_across_platforms/server/client" | |
| ) | |
| var ( | |
| getRequestFunc func(url string) (*http.Response, error) | |
| ) | |
| type clientMock struct {} | |
| //mocking the client call: | |
| func (cm *clientMock) GetValue(url string) (*http.Response, error) { | |
| return getRequestFunc(url) | |
| } | |
| //When the api call is successful and the desired result is gotten | |
| func TestCheckUrls_Success(t *testing.T) { | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return &http.Response{ | |
| StatusCode: http.StatusOK, | |
| }, nil | |
| } | |
| client.ClientCall = &clientMock{} | |
| url := "https://twitter.com/stevensunflash" | |
| ch := make(chan string) | |
| go Checker.CheckUrl(url, ch) | |
| result := <-ch | |
| assert.NotNil(t, result) | |
| assert.EqualValues(t, "https://twitter.com/stevensunflash", result) | |
| } | |
| //When the api call is not successful, maybe there is no internet connection | |
| func TestCheckUrls_Not_Existent_Url(t *testing.T) { | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return nil, errors.New("there is an error here") | |
| } | |
| client.ClientCall = &clientMock{} | |
| url := "https://invalid_url/stevensunflash" | |
| ch := make(chan string) | |
| go Checker.CheckUrl(url, ch) | |
| err := <-ch | |
| assert.NotNil(t, err) | |
| assert.EqualValues(t, "cant_access_resource", err) | |
| } | |
| //When the api call is successful, but the desire result is not produced | |
| func TestCheckUrls_Username_Dont_Exist(t *testing.T) { | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return &http.Response{ | |
| StatusCode: http.StatusNotFound, | |
| }, nil | |
| } | |
| client.ClientCall = &clientMock{} | |
| url := "https://twitter.com/random_xxxxx_yyyy" | |
| ch := make(chan string) | |
| go Checker.CheckUrl(url, ch) | |
| result := <-ch | |
| assert.NotNil(t, result) | |
| assert.EqualValues(t, "no_match", result) | |
| } |
| package provider | |
| import ( | |
| "errors" | |
| "github.com/stretchr/testify/assert" | |
| "net/http" | |
| "testing" | |
| "username_across_platforms/server/client" | |
| ) | |
| var ( | |
| getRequestFunc func(url string) (*http.Response, error) | |
| ) | |
| type clientMock struct {} | |
| //mocking the client call: | |
| func (cm *clientMock) GetValue(url string) (*http.Response, error) { | |
| return getRequestFunc(url) | |
| } | |
| //When the api call is successful and the desired result is gotten | |
| func TestCheckUrls_Success(t *testing.T) { | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return &http.Response{ | |
| StatusCode: http.StatusOK, | |
| }, nil | |
| } | |
| client.ClientCall = &clientMock{} | |
| url := "https://twitter.com/stevensunflash" | |
| ch := make(chan string) | |
| go Checker.CheckUrl(url, ch) | |
| result := <-ch | |
| assert.NotNil(t, result) | |
| assert.EqualValues(t, "https://twitter.com/stevensunflash", result) | |
| } | |
| //When the api call is not successful, maybe there is no internet connection | |
| func TestCheckUrls_Not_Existent_Url(t *testing.T) { | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return nil, errors.New("there is an error here") | |
| } | |
| client.ClientCall = &clientMock{} | |
| url := "https://invalid_url/stevensunflash" | |
| ch := make(chan string) | |
| go Checker.CheckUrl(url, ch) | |
| err := <-ch | |
| assert.NotNil(t, err) | |
| assert.EqualValues(t, "cant_access_resource", err) | |
| } | |
| //When the api call is successful, but the desire result is not produced | |
| func TestCheckUrls_Username_Dont_Exist(t *testing.T) { | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return &http.Response{ | |
| StatusCode: http.StatusNotFound, | |
| }, nil | |
| } | |
| client.ClientCall = &clientMock{} | |
| url := "https://twitter.com/random_xxxxx_yyyy" | |
| ch := make(chan string) | |
| go Checker.CheckUrl(url, ch) | |
| result := <-ch | |
| assert.NotNil(t, result) | |
| assert.EqualValues(t, "no_match", result) | |
| } |
仔细观察,我们在这里模拟了客户端的GetValue方法,这是在客户端包的接口中定义该方法的用途之一。您可以看到我们如何在不访问真实端点的情况下从客户端返回所需的响应。这也帮助我们实现了对提供程序的单元测试,而无需从客户端包中调用真实的GetValue方法。真是太棒了!😴
步骤 4:服务实现(启动一些 Goroutines🚀)
现在让我们启动一些 goroutine 来同时获取多个 URL 响应。
从服务器包(目录)创建服务包(目录),然后创建service.go文件:
mkdir service
cd service && touch service.go
| package service | |
| import ( | |
| "username_across_platforms/server/provider" | |
| ) | |
| type usernameCheck struct {} | |
| type usernameService interface { | |
| UsernameCheck(urls []string) []string | |
| } | |
| var ( | |
| //This is where the usernameCheck struct implements the usernameService interface | |
| UsernameService usernameService = &usernameCheck{} | |
| ) | |
| func (u *usernameCheck) UsernameCheck(urls []string) []string { | |
| c := make(chan string) | |
| var links []string | |
| matchingLinks := []string{} | |
| for _, url := range urls { | |
| go provider.Checker.CheckUrl(url, c) | |
| } | |
| for i := 0; i < len(urls); i++ { | |
| links = append(links, <-c) | |
| } | |
| //Remove the "no_match" and "cant_access_resource" values from the links array: | |
| for _, v := range links { | |
| if v == "cant_access_resource" { | |
| continue | |
| } | |
| if v == "no_match" { | |
| continue | |
| } | |
| matchingLinks = append(matchingLinks, v) | |
| } | |
| return matchingLinks | |
| } |
| package service | |
| import ( | |
| "username_across_platforms/server/provider" | |
| ) | |
| type usernameCheck struct {} | |
| type usernameService interface { | |
| UsernameCheck(urls []string) []string | |
| } | |
| var ( | |
| //This is where the usernameCheck struct implements the usernameService interface | |
| UsernameService usernameService = &usernameCheck{} | |
| ) | |
| func (u *usernameCheck) UsernameCheck(urls []string) []string { | |
| c := make(chan string) | |
| var links []string | |
| matchingLinks := []string{} | |
| for _, url := range urls { | |
| go provider.Checker.CheckUrl(url, c) | |
| } | |
| for i := 0; i < len(urls); i++ { | |
| links = append(links, <-c) | |
| } | |
| //Remove the "no_match" and "cant_access_resource" values from the links array: | |
| for _, v := range links { | |
| if v == "cant_access_resource" { | |
| continue | |
| } | |
| if v == "no_match" { | |
| continue | |
| } | |
| matchingLinks = append(matchingLinks, v) | |
| } | |
| return matchingLinks | |
| } |
UsernameCheck方法接收一组 URL 切片进行处理,我们已经有了checkUrl方法可以用来检查 URL,该方法定义在提供程序的包中。现在,我们循环遍历给定的 URL,并为每个 URL 启动一个Goroutine。记住,任何获得的响应或错误都会发送到通道。然后,我们从通道中获取每个 URL 的值,并将其放入links切片中。
结果集可以分为三种情况:
- 无法访问资源
- 不匹配
- 有效结果(url)我们进一步过滤链接切片以仅获取有效的 url。
现在,让我们编写一些测试来证明我们的代码可以正常工作。
创建service_test.go文件:
touch service_test.go
| package service | |
| import ( | |
| "errors" | |
| "github.com/stretchr/testify/assert" | |
| "net/http" | |
| "testing" | |
| "username_across_platforms/server/client" | |
| ) | |
| var ( | |
| getRequestFunc func(url string) (*http.Response, error) | |
| ) | |
| type clientMock struct {} | |
| //mocking the client call, so we dont hit the real endpoint: | |
| func (cm *clientMock) GetValue(url string) (*http.Response, error) { | |
| return getRequestFunc(url) | |
| } | |
| func TestUsernameCheck_Success(t *testing.T) { | |
| urls := []string{ | |
| "http://twitter.com/stevensunflash", | |
| "http://instagram.com/stevensunflash", | |
| "http://dev.to/stevensunflash", | |
| } | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return &http.Response{ | |
| StatusCode: http.StatusOK, | |
| }, nil | |
| } | |
| client.ClientCall = &clientMock{} | |
| result := UsernameService.UsernameCheck(urls) | |
| assert.NotNil(t, result) | |
| assert.EqualValues(t, len(result), 3) | |
| } | |
| func TestUsernameCheck_No_Match(t *testing.T) { | |
| urls := []string{ | |
| "http://twitter.com/no_match_username", | |
| "http://instagram.com/no_match_username", | |
| "http://dev.to/no_match_username", | |
| } | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return &http.Response{ | |
| StatusCode: http.StatusNotFound, //it can be 404, 422 or 500 depending the response from the endpoint | |
| }, nil | |
| } | |
| client.ClientCall = &clientMock{} | |
| result := UsernameService.UsernameCheck(urls) | |
| assert.EqualValues(t, len(result), 0) | |
| } | |
| func TestUsernameCheck_Url_Invalid(t *testing.T) { | |
| urls := []string{ | |
| "http://wrong.com/stevensunflash", | |
| "http://wrong.com/stevensunflash", | |
| "http://wrong.to/stevensunflash", | |
| } | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return nil, errors.New("cant_access_resource") | |
| } | |
| client.ClientCall = &clientMock{} | |
| result := UsernameService.UsernameCheck(urls) | |
| assert.EqualValues(t, len(result), 0) | |
| } |
| package service | |
| import ( | |
| "errors" | |
| "github.com/stretchr/testify/assert" | |
| "net/http" | |
| "testing" | |
| "username_across_platforms/server/client" | |
| ) | |
| var ( | |
| getRequestFunc func(url string) (*http.Response, error) | |
| ) | |
| type clientMock struct {} | |
| //mocking the client call, so we dont hit the real endpoint: | |
| func (cm *clientMock) GetValue(url string) (*http.Response, error) { | |
| return getRequestFunc(url) | |
| } | |
| func TestUsernameCheck_Success(t *testing.T) { | |
| urls := []string{ | |
| "http://twitter.com/stevensunflash", | |
| "http://instagram.com/stevensunflash", | |
| "http://dev.to/stevensunflash", | |
| } | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return &http.Response{ | |
| StatusCode: http.StatusOK, | |
| }, nil | |
| } | |
| client.ClientCall = &clientMock{} | |
| result := UsernameService.UsernameCheck(urls) | |
| assert.NotNil(t, result) | |
| assert.EqualValues(t, len(result), 3) | |
| } | |
| func TestUsernameCheck_No_Match(t *testing.T) { | |
| urls := []string{ | |
| "http://twitter.com/no_match_username", | |
| "http://instagram.com/no_match_username", | |
| "http://dev.to/no_match_username", | |
| } | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return &http.Response{ | |
| StatusCode: http.StatusNotFound, //it can be 404, 422 or 500 depending the response from the endpoint | |
| }, nil | |
| } | |
| client.ClientCall = &clientMock{} | |
| result := UsernameService.UsernameCheck(urls) | |
| assert.EqualValues(t, len(result), 0) | |
| } | |
| func TestUsernameCheck_Url_Invalid(t *testing.T) { | |
| urls := []string{ | |
| "http://wrong.com/stevensunflash", | |
| "http://wrong.com/stevensunflash", | |
| "http://wrong.to/stevensunflash", | |
| } | |
| getRequestFunc = func(url string) (*http.Response, error) { | |
| return nil, errors.New("cant_access_resource") | |
| } | |
| client.ClientCall = &clientMock{} | |
| result := UsernameService.UsernameCheck(urls) | |
| assert.EqualValues(t, len(result), 0) | |
| } |
从测试中可以看出,我们还模拟了客户端,这样我们就不会碰到实际的端点。
步骤5:控制器实现(将响应返回给调用者)
现在,让我们向调用者发送一个 HTTP 响应。
从服务器包(目录)创建控制器包(目录),然后创建controller.go文件。
mkdir controller
cd controller && controller.go
| package controller | |
| import ( | |
| "errors" | |
| "github.com/gin-gonic/gin" | |
| "net/http" | |
| "username_across_platforms/server/service" | |
| ) | |
| func Username(c *gin.Context) { | |
| var urls []string | |
| if err := c.ShouldBindJSON(&urls); err != nil { | |
| c.JSON(http.StatusUnprocessableEntity, errors.New("invalid json body")) | |
| return | |
| } | |
| matchedUrls := service.UsernameService.UsernameCheck(urls) | |
| c.JSON(http.StatusOK, matchedUrls) | |
| } |
| package controller | |
| import ( | |
| "errors" | |
| "github.com/gin-gonic/gin" | |
| "net/http" | |
| "username_across_platforms/server/service" | |
| ) | |
| func Username(c *gin.Context) { | |
| var urls []string | |
| if err := c.ShouldBindJSON(&urls); err != nil { | |
| c.JSON(http.StatusUnprocessableEntity, errors.New("invalid json body")) | |
| return | |
| } | |
| matchedUrls := service.UsernameService.UsernameCheck(urls) | |
| c.JSON(http.StatusOK, matchedUrls) | |
| } |
没什么特别的,控制器从调用者接收请求并将其传递给服务(同时使用提供者的checkUrls方法),服务将其可以处理的 URL 传回给控制器,然后控制器将 URL 发送给调用者。
我们还来测试一下控制器,创建controller_test.go文件
touch controller_test.go
如上所示,为了实现单元测试,我们必须模拟服务的UsernameCheck方法,并返回任何我们想要返回的值。借助usernameService接口,我们可以轻松地模拟该服务。
从测试中观察到的另一件事是,从调用者传递的json具有以下格式:
`["url1","url2","url3"]`
任何不符合此格式的内容都无法正常工作。我们上面的测试可以证明这一点。
步骤 6:连接应用程序
虽然我们已经有单元测试来证明我们的应用程序可以正常运行,但我们仍然需要在浏览器上进行测试。
从服务器包(目录)创建应用程序包(目录),
mkdir app
然后创建两个文件:
-app.go
-route.go
a. app.go
cd app && touch app.go
| package app | |
| import ( | |
| "github.com/gin-gonic/gin" | |
| "os" | |
| ) | |
| var ( | |
| router = gin.Default() | |
| ) | |
| func StartApp() { | |
| route() | |
| port := os.Getenv("PORT") //using heroku host | |
| if port == "" { | |
| port = "8888" //localhost | |
| } | |
| router.Run(":"+port) | |
| } |
| package app | |
| import ( | |
| "github.com/gin-gonic/gin" | |
| "os" | |
| ) | |
| var ( | |
| router = gin.Default() | |
| ) | |
| func StartApp() { | |
| route() | |
| port := os.Getenv("PORT") //using heroku host | |
| if port == "" { | |
| port = "8888" //localhost | |
| } | |
| router.Run(":"+port) | |
| } |
由于我们稍后会将其部署到heroku,因此我们检查了Heroku端口。
b.route.go
touch route.go
| package app | |
| import ( | |
| "username_across_platforms/server/controller" | |
| "username_across_platforms/server/middleware" | |
| ) | |
| func route() { | |
| router.Use(middleware.CORSMiddleware()) //to enable api request between client and server | |
| router.POST("/username", controller.Username) | |
| } |
| package app | |
| import ( | |
| "username_across_platforms/server/controller" | |
| "username_across_platforms/server/middleware" | |
| ) | |
| func route() { | |
| router.Use(middleware.CORSMiddleware()) //to enable api request between client and server | |
| router.POST("/username", controller.Username) | |
| } |
从路由中可以看到,我们调用了一个尚未定义的中间件。该中间件将使我们能够在服务器和客户端(前端)之间进行 API 调用,我们稍后将对其进行定义。
中间件
从服务器包中创建中间件包(目录),然后创建cors.go文件:
mkdir middleware && touch cors.go
| package middleware | |
| import "github.com/gin-gonic/gin" | |
| func CORSMiddleware() gin.HandlerFunc { | |
| return func(c *gin.Context) { | |
| c.Writer.Header().Set("Access-Control-Allow-Origin", "*") | |
| c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") | |
| c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") | |
| c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH, DELETE") | |
| if c.Request.Method == "OPTIONS" { | |
| c.AbortWithStatus(204) | |
| return | |
| } | |
| c.Next() | |
| } | |
| } |
| package middleware | |
| import "github.com/gin-gonic/gin" | |
| func CORSMiddleware() gin.HandlerFunc { | |
| return func(c *gin.Context) { | |
| c.Writer.Header().Set("Access-Control-Allow-Origin", "*") | |
| c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") | |
| c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") | |
| c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH, DELETE") | |
| if c.Request.Method == "OPTIONS" { | |
| c.AbortWithStatus(204) | |
| return | |
| } | |
| c.Next() | |
| } | |
| } |
运行应用程序
我们现在需要在服务器目录中创建main.go文件:
touch main.go
| package main | |
| import "username_across_platforms/server/app" | |
| func main() { | |
| app.StartApp() | |
| } |
| package main | |
| import "username_across_platforms/server/app" | |
| func main() { | |
| app.StartApp() | |
| } |
我们调用了在应用程序包中定义的StartApp函数。
从路径运行应用程序:username_across_platforms/server
go run main.go
因此,运行应用程序并使用Postman进行测试,或者直接跳到下一步,使用VueJS作为前端。
请记住,如果您想使用Postman或您喜欢的测试工具,
请按如下方式传递 JSON:
步骤 7:客户端(前端)
到目前为止,我们做的都是服务器端的事情。现在让我们来看看我们辛勤工作的成果💪。
我们将使用Vue.js,如果你愿意,也可以使用React.js。毕竟,这只是一个 SPA(单页应用)而已。
首先要做的是安装https://cli.vuejs.org/(如果您之前已经安装过)。
从项目的根目录(路径:“username_across_platforms/”)创建一个名为client 的新Vue项目。
vue create client
它会提示你输入一些内容,选择所有默认设置。
完成后,进入客户端(前端)目录:
cd client
💥非常重要💥
刚刚安装的应用程序已经初始化了git,请删除.git文件。在终端中,在路径:username_across_platforms/client中执行:
rm -rf .git
下一步,安装我们将用于 UI 的vuetify
vue add vuetify
由于我们将进行 API 调用,因此需要安装axios
yarn add axios --save
伟大的!
接下来,在src目录中找到App.vue文件并将内容替换为:
| <template> | |
| <div id="app"> | |
| <v-app id="inspire"> | |
| <v-content class="mt-6"> | |
| <v-container> | |
| <h3 class="text-center" style="color: indigo">Username Lookup App</h3> | |
| <v-card | |
| class="mx-auto mt-4" | |
| max-width="344" | |
| outlined | |
| > | |
| <form @submit.prevent="checkUsername"> | |
| <v-list-item> | |
| <v-list-item-content> | |
| <v-list-item-title class="align-center"> | |
| <div> | |
| <v-col cols="12" sm="12" md="12"> | |
| <v-text-field | |
| label="Enter username" | |
| v-model="username" | |
| ></v-text-field> | |
| </v-col> | |
| </div> | |
| <div style="font-weight: bold">Choose the Application to lookup</div> | |
| <v-checkbox value="twitter" v-model="checkAccounts" :label="`Twitter`"></v-checkbox> | |
| <v-checkbox value="instagram" v-model="checkAccounts" :label="`Instagram`"></v-checkbox> | |
| <v-checkbox value="github" v-model="checkAccounts" :label="`Github`"></v-checkbox> | |
| <v-checkbox value="dev.to" v-model="checkAccounts" :label="`Dev.to`"></v-checkbox> | |
| <v-checkbox value="bitbucket" v-model="checkAccounts" :label="`BitBucket`"></v-checkbox> | |
| </v-list-item-title> | |
| </v-list-item-content> | |
| </v-list-item> | |
| <div class="text-center mb-3"> | |
| <v-btn :disabled="disabled" type="submit" class="ma-2 style-btn" tile color="indigo"> | |
| <span v-if="loading">Checking Username</span> | |
| <span v-else>Check Username</span> | |
| </v-btn> | |
| </div> | |
| </form> | |
| <div> | |
| <div v-if="matchValues.length > 0 && !loading && !notMatched" class="pa-2 mb-2"> | |
| <h4 class="text-center">The username matched these accounts: </h4> | |
| <div v-for="(result, index) in matchValues" :key="index" class="text-center"> | |
| <a :href="result.url" target="_blank"> {{ result.account }}</a> | |
| </div> | |
| </div> | |
| <div v-if="!loading && notMatched" class="text-center mb-4"> | |
| <h4>No account match this username</h4> | |
| </div> | |
| </div> | |
| </v-card> | |
| </v-container> | |
| </v-content> | |
| <v-footer height="auto" color="indigo" dark> | |
| <v-layout justify-center row wrap> | |
| <v-flex color="indigo" dark py-3 text-xs-center text-md-center text-sm-center white--text xs12> | |
| <strong>Developed with ❤️ by <a target="_blank" href="https://twitter.com/stevensunflash"><span class=" style-name">@stevensunflash</span></a> © {{ new Date().getFullYear() }}</strong> | |
| </v-flex> | |
| </v-layout> | |
| </v-footer> | |
| </v-app> | |
| </div> | |
| </template> | |
| <script> | |
| import axios from 'axios'; | |
| import API_ROUTE from "./env"; | |
| export default { | |
| data: () => ({ | |
| drawer: null, | |
| checkAccounts: [], | |
| matchedAccount: [], | |
| notMatched: false, | |
| username: '', | |
| loading: false | |
| }), | |
| computed: { | |
| disabled() { | |
| return this.loading === true || this.username === "" || this.checkAccounts.length === 0 | |
| }, | |
| checkUrl() { | |
| let arr = [] | |
| this.checkAccounts.map(val => { | |
| if (val === "twitter") { | |
| arr.push(`https://twitter.com/${this.username}`) | |
| } else if (val === "instagram") { | |
| arr.push(`https://instagram.com/${this.username}`) | |
| } else if (val === "github") { | |
| arr.push(`https://github.com/${this.username}`) | |
| } else if (val === "dev.to") { | |
| arr.push(`https://dev.to/${this.username}`) | |
| } else if (val === "bitbucket") { | |
| arr.push(`https://bitbucket.com/${this.username}`) | |
| } | |
| }); | |
| return arr | |
| }, | |
| matchValues() { | |
| let finalMatch = [] | |
| this.matchedAccount.map(oneMatch => { | |
| if (oneMatch.includes("twitter")){ | |
| finalMatch.push({ "url": oneMatch, "account": "Twitter"}) | |
| } else if (oneMatch.includes("instagram")){ | |
| finalMatch.push({ "url": oneMatch, "account": "Instagram"}) | |
| } else if (oneMatch.includes("github")){ | |
| finalMatch.push({ "url": oneMatch, "account": "Github"}) | |
| } else if (oneMatch.includes("dev.to")){ | |
| finalMatch.push({ "url": oneMatch, "account": "Dev.to"}) | |
| } else if (oneMatch.includes("bitbucket")){ | |
| finalMatch.push({ "url": oneMatch, "account": "Bitbucket"}) | |
| } | |
| }); | |
| return finalMatch | |
| }, | |
| }, | |
| methods: { | |
| checkUsername() { | |
| this.loading = true | |
| axios.post(`${API_ROUTE}/username`, this.checkUrl) | |
| .then(res => { | |
| console.log("this is our response: ", res) | |
| if (res.data.length > 0) { | |
| this.matchedAccount = res.data | |
| this.notMatched = false | |
| } else { | |
| this.notMatched = true | |
| } | |
| this.loading = false | |
| }).catch(err => { | |
| this.loading = false | |
| this.notMatched = false | |
| this.matchedAccount = [] | |
| console.log("This is the error: ", err) | |
| }) | |
| } | |
| } | |
| }; | |
| </script> | |
| <style scoped> | |
| button { | |
| color: white!important; | |
| } | |
| a { | |
| text-decoration: none; | |
| cursor: pointer; | |
| } | |
| .style-name { | |
| color: white!important; | |
| } | |
| </style> |
| <template> | |
| <div id="app"> | |
| <v-app id="inspire"> | |
| <v-content class="mt-6"> | |
| <v-container> | |
| <h3 class="text-center" style="color: indigo">Username Lookup App</h3> | |
| <v-card | |
| class="mx-auto mt-4" | |
| max-width="344" | |
| outlined | |
| > | |
| <form @submit.prevent="checkUsername"> | |
| <v-list-item> | |
| <v-list-item-content> | |
| <v-list-item-title class="align-center"> | |
| <div> | |
| <v-col cols="12" sm="12" md="12"> | |
| <v-text-field | |
| label="Enter username" | |
| v-model="username" | |
| ></v-text-field> | |
| </v-col> | |
| </div> | |
| <div style="font-weight: bold">Choose the Application to lookup</div> | |
| <v-checkbox value="twitter" v-model="checkAccounts" :label="`Twitter`"></v-checkbox> | |
| <v-checkbox value="instagram" v-model="checkAccounts" :label="`Instagram`"></v-checkbox> | |
| <v-checkbox value="github" v-model="checkAccounts" :label="`Github`"></v-checkbox> | |
| <v-checkbox value="dev.to" v-model="checkAccounts" :label="`Dev.to`"></v-checkbox> | |
| <v-checkbox value="bitbucket" v-model="checkAccounts" :label="`BitBucket`"></v-checkbox> | |
| </v-list-item-title> | |
| </v-list-item-content> | |
| </v-list-item> | |
| <div class="text-center mb-3"> | |
| <v-btn :disabled="disabled" type="submit" class="ma-2 style-btn" tile color="indigo"> | |
| <span v-if="loading">Checking Username</span> | |
| <span v-else>Check Username</span> | |
| </v-btn> | |
| </div> | |
| </form> | |
| <div> | |
| <div v-if="matchValues.length > 0 && !loading && !notMatched" class="pa-2 mb-2"> | |
| <h4 class="text-center">The username matched these accounts: </h4> | |
| <div v-for="(result, index) in matchValues" :key="index" class="text-center"> | |
| <a :href="result.url" target="_blank"> {{ result.account }}</a> | |
| </div> | |
| </div> | |
| <div v-if="!loading && notMatched" class="text-center mb-4"> | |
| <h4>No account match this username</h4> | |
| </div> | |
| </div> | |
| </v-card> | |
| </v-container> | |
| </v-content> | |
| <v-footer height="auto" color="indigo" dark> | |
| <v-layout justify-center row wrap> | |
| <v-flex color="indigo" dark py-3 text-xs-center text-md-center text-sm-center white--text xs12> | |
| <strong>Developed with ❤️ by <a target="_blank" href="https://twitter.com/stevensunflash"><span class=" style-name">@stevensunflash</span></a> © {{ new Date().getFullYear() }}</strong> | |
| </v-flex> | |
| </v-layout> | |
| </v-footer> | |
| </v-app> | |
| </div> | |
| </template> | |
| <script> | |
| import axios from 'axios'; | |
| import API_ROUTE from "./env"; | |
| export default { | |
| data: () => ({ | |
| drawer: null, | |
| checkAccounts: [], | |
| matchedAccount: [], | |
| notMatched: false, | |
| username: '', | |
| loading: false | |
| }), | |
| computed: { | |
| disabled() { | |
| return this.loading === true || this.username === "" || this.checkAccounts.length === 0 | |
| }, | |
| checkUrl() { | |
| let arr = [] | |
| this.checkAccounts.map(val => { | |
| if (val === "twitter") { | |
| arr.push(`https://twitter.com/${this.username}`) | |
| } else if (val === "instagram") { | |
| arr.push(`https://instagram.com/${this.username}`) | |
| } else if (val === "github") { | |
| arr.push(`https://github.com/${this.username}`) | |
| } else if (val === "dev.to") { | |
| arr.push(`https://dev.to/${this.username}`) | |
| } else if (val === "bitbucket") { | |
| arr.push(`https://bitbucket.com/${this.username}`) | |
| } | |
| }); | |
| return arr | |
| }, | |
| matchValues() { | |
| let finalMatch = [] | |
| this.matchedAccount.map(oneMatch => { | |
| if (oneMatch.includes("twitter")){ | |
| finalMatch.push({ "url": oneMatch, "account": "Twitter"}) | |
| } else if (oneMatch.includes("instagram")){ | |
| finalMatch.push({ "url": oneMatch, "account": "Instagram"}) | |
| } else if (oneMatch.includes("github")){ | |
| finalMatch.push({ "url": oneMatch, "account": "Github"}) | |
| } else if (oneMatch.includes("dev.to")){ | |
| finalMatch.push({ "url": oneMatch, "account": "Dev.to"}) | |
| } else if (oneMatch.includes("bitbucket")){ | |
| finalMatch.push({ "url": oneMatch, "account": "Bitbucket"}) | |
| } | |
| }); | |
| return finalMatch | |
| }, | |
| }, | |
| methods: { | |
| checkUsername() { | |
| this.loading = true | |
| axios.post(`${API_ROUTE}/username`, this.checkUrl) | |
| .then(res => { | |
| console.log("this is our response: ", res) | |
| if (res.data.length > 0) { | |
| this.matchedAccount = res.data | |
| this.notMatched = false | |
| } else { | |
| this.notMatched = true | |
| } | |
| this.loading = false | |
| }).catch(err => { | |
| this.loading = false | |
| this.notMatched = false | |
| this.matchedAccount = [] | |
| console.log("This is the error: ", err) | |
| }) | |
| } | |
| } | |
| }; | |
| </script> | |
| <style scoped> | |
| button { | |
| color: white!important; | |
| } | |
| a { | |
| text-decoration: none; | |
| cursor: pointer; | |
| } | |
| .style-name { | |
| color: white!important; | |
| } | |
| </style> |
注意到上面我们导入了一个尚未定义的文件(env.js)。为了能够在本地和生产环境中进行测试,我们需要随时告知应用程序要使用的 URL。在与App.vue
相同的目录路径下,创建env.js文件:
| let API_ROUTE | |
| process.env.NODE_ENV === 'development' | |
| ? API_ROUTE = 'http://127.0.0.1:8888' | |
| : API_ROUTE = 'https://username-across.herokuapp.com' | |
| export default API_ROUTE |
| let API_ROUTE | |
| process.env.NODE_ENV === 'development' | |
| ? API_ROUTE = 'http://127.0.0.1:8888' | |
| : API_ROUTE = 'https://username-across.herokuapp.com' | |
| export default API_ROUTE |
现在,让我们启动前端应用程序:
从路径:username_across_platforms/client
运行:
npm run serve
现在启动浏览器并访问:http://localhost:8080
噢😍。别客气!
步骤 8:托管
我们将免费将这个出色的应用程序部署到Heroku 。我们可以使用Docker轻松实现。
在项目根目录(路径:username_across_platforms/)创建Dockerfile
| # Build the Go API | |
| FROM golang:latest AS builder | |
| ADD . /app | |
| WORKDIR /app/server | |
| RUN go mod download | |
| RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-w" -a -o /main . | |
| # Build the Vue application | |
| FROM node:alpine AS node_builder | |
| COPY --from=builder /app/client ./ | |
| RUN npm install | |
| RUN npm run build | |
| #FInal build for production | |
| FROM alpine:latest | |
| RUN apk --no-cache add ca-certificates | |
| COPY --from=builder /main ./ | |
| #When build is run on a vue file, the dist folder is created, copy it to web | |
| COPY --from=node_builder /dist ./web | |
| RUN chmod +x ./main | |
| EXPOSE 8080 | |
| CMD ./main |
| # Build the Go API | |
| FROM golang:latest AS builder | |
| ADD . /app | |
| WORKDIR /app/server | |
| RUN go mod download | |
| RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-w" -a -o /main . | |
| # Build the Vue application | |
| FROM node:alpine AS node_builder | |
| COPY --from=builder /app/client ./ | |
| RUN npm install | |
| RUN npm run build | |
| #FInal build for production | |
| FROM alpine:latest | |
| RUN apk --no-cache add ca-certificates | |
| COPY --from=builder /main ./ | |
| #When build is run on a vue file, the dist folder is created, copy it to web | |
| COPY --from=node_builder /dist ./web | |
| RUN chmod +x ./main | |
| EXPOSE 8080 | |
| CMD ./main |
由于使用heroku进行部署,因此创建heroku.yml文件,该文件告诉 Heroku 我们正在对应用程序进行 docker 化:
从根目录:
touch heroku.yml
| build: | |
| docker: | |
| web: Dockerfile | |
| worker: | |
| dockerfile: Dockerfile | |
| build: | |
| docker: | |
| web: Dockerfile | |
| worker: | |
| dockerfile: Dockerfile | |
如果您按照以下步骤操作,请将您的代码推送到 github,记得从根目录初始化 git(路径:username_across_platforms/)。
推送至 Heroku。
从根目录
- 安装heroku-cli
- 登录 Heroku:
heroku login
- 创建 heroku 应用程序:
heroku create
- 告诉 heroku 我们正在将容器部署到这个堆栈:
heroku stack:set container
- 推送至 Heroku:
git add .
git commit -m "Heroku deployment"
git push heroku master
部署完成后,使用以下命令访问应用程序:
heroku open
查看应用程序🔥
奖金
- 我为服务器实现添加了集成测试
- 我还使用 Travis CI 进行持续集成
从存储库获取所有这些:
https://github.com/victorsteven/Username-Across-Platforms
结论
就这样!一个功能齐全、使用了 Golang 强大并发特性的应用程序就完成了。你也可以在这里或我的Medium 账户
上阅读其他文章。 别忘了关注我后续的文章。
在Github上获取完整代码
祝您编码愉快。
鏂囩珷鏉ユ簮锛�https://dev.to/stevensunflash/using-golang-concurrency-in-product-3ma4
后端开发教程 - Java、Spring Boot 实战 - msg200.com


