Cómo crear tu primera aplicación web con Go

Hola Habr! Le presento la traducción del artículo "Cómo construir su primera aplicación web con Go" de Ayooluwa Isaiah.


Esta es la guía para su primera aplicación web Go. Crearemos una aplicación de noticias que use la API de noticias para recibir artículos de noticias sobre un tema específico y la implementaremos en el servidor de producción al final.


Puede encontrar el código completo utilizado para este tutorial en este repositorio de GitHub .


Requisitos


El único requisito para esta tarea es que Go esté instalado en su computadora y esté un poco familiarizado con su sintaxis y sus construcciones. La versión Go que utilicé para crear la aplicación también es la más reciente al momento de escribir: 1.12.9 . Para ver la versión instalada de Go, use el comando go version .


Si encuentra esta tarea demasiado difícil para usted, vaya a mi lección de introducción al idioma anterior, que debería ayudarlo a comenzar.


¡Entonces comencemos!


Clonamos el repositorio de archivos de inicio en GitHub y cd en el directorio creado. Tenemos tres archivos principales: en el archivo main.go escribiremos todo el código Go para esta tarea. El archivo index.html es la plantilla que se enviará al navegador, y los para la aplicación están en assets/styles.css .


Crear un servidor web básico


Comencemos creando un servidor central que envíe el texto "¡Hola Mundo!" Al navegador cuando ejecute una solicitud GET a la raíz del servidor. Cambie su archivo main.go para que se vea así:


 package main import ( "net/http" "os" ) func indexHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte("<h1>Hello World!</h1>")) } func main() { port := os.Getenv("PORT") if port == "" { port = "3000" } mux := http.NewServeMux() mux.HandleFunc("/", indexHandler) http.ListenAndServe(":"+port, mux) } 

La primera línea del package main : declara que el código en el archivo main.go al paquete main. Después de eso, importamos el paquete net/http , que proporciona implementaciones de cliente y servidor HTTP para usar en nuestra aplicación. Este paquete es parte de la biblioteca estándar y se incluye con cada instalación de Go.


En la función main , http.NewServeMux() crea un nuevo multiplexor de solicitud HTTP y lo asigna a la variable mux . Esencialmente, el multiplexor de solicitudes hace coincidir la URL de las solicitudes entrantes con la lista de rutas registradas y llama al controlador apropiado para la ruta cada vez que se encuentra una coincidencia.


A continuación, registramos nuestra primera función de controlador para la ruta raíz / . Esta función de controlador es el segundo argumento para HandleFunc y siempre tiene la func (w http.ResponseWriter, r * http.Request) firma func (w http.ResponseWriter, r * http.Request) .


Si observa la función indexHandler , verá que tiene tal firma, lo que la convierte en un segundo argumento válido para HandleFunc . El parámetro w es la estructura que usamos para enviar respuestas a la solicitud HTTP. Implementa el método Write() , que toma un segmento de bytes y escribe los datos combinados como parte de la respuesta HTTP.


Por otro lado, el parámetro r representa la solicitud HTTP recibida del cliente. Así es como accedemos a los datos enviados por el navegador web en el servidor. Todavía no lo estamos usando aquí, pero definitivamente lo usaremos más tarde.


Finalmente, tenemos el método http.ListenAndServe() , que inicia el servidor en el puerto 3000 si el entorno no establece el puerto. Siéntase libre de usar un puerto diferente si se usa 3000 en su computadora.


Luego compila y ejecuta el código que acabas de escribir:


 go run main.go 

Si va a http: // localhost: 3000 en su navegador, debería ver el texto "¡Hola Mundo!".


Valiente navegador que muestra el texto Hello World


Ir plantillas


Veamos los conceptos básicos de la plantilla en Go. Si está familiarizado con las plantillas en otros idiomas, esto debería ser lo suficientemente fácil de entender.


Las plantillas proporcionan una manera fácil de personalizar la salida de su aplicación web dependiendo de la ruta sin tener que escribir el mismo código en diferentes lugares. Por ejemplo, podemos crear una plantilla para la barra de navegación y usarla en todas las páginas del sitio sin duplicar el código. Además, también tenemos la oportunidad de agregar algo de lógica básica a nuestras páginas web.


Go proporciona dos bibliotecas de plantillas en su biblioteca estándar: text/template y html/template . Ambos proporcionan la misma interfaz, sin embargo, el paquete html/template se usa para generar una salida HTML que está protegida contra la inyección de código, por lo que la usaremos aquí.


Importe este paquete en su archivo main.go y main.go siguiente manera:


 package main import ( "html/template" "net/http" "os" ) var tpl = template.Must(template.ParseFiles("index.html")) func indexHandler(w http.ResponseWriter, r *http.Request) { tpl.Execute(w, nil) } func main() { port := os.Getenv("PORT") if port == "" { port = "3000" } mux := http.NewServeMux() mux.HandleFunc("/", indexHandler) http.ListenAndServe(":"+port, mux) } 

tpl es una variable de nivel de paquete que indica la definición de una plantilla a partir de los archivos proporcionados. La llamada template.ParseFiles analiza el archivo index.html en la raíz de nuestro directorio de proyectos y verifica su validez.


Envolvemos la template.ParseFiles Llamada de template.ParseFiles gruesos en la template.Must modo que el código cause pánico cuando ocurra un error. La razón por la que entramos en pánico aquí en lugar de tratar de manejar el error es porque no tiene sentido continuar ejecutando el código si tenemos una plantilla no válida. Este es un problema que debe solucionarse antes de intentar reiniciar el servidor.


En la función indexHandler ejecutamos la plantilla creada previamente proporcionando dos argumentos: dónde queremos escribir la salida y los datos que queremos pasar a la plantilla.


En el caso anterior, escribimos el resultado en la interfaz ResponseWriter y, dado que no tenemos datos para pasar a nuestra plantilla en este momento, se pasa nil como segundo argumento.


Detenga el proceso de ejecución en su terminal usando Ctrl-C y comience nuevamente con go run main.go , luego actualice su navegador. Debería ver el texto "Demostración de la aplicación de noticias" en la página como se muestra a continuación:


Valiente navegador que muestra el texto de demostración de la aplicación de noticias


Agregar una barra de navegación a la página


Reemplace el contenido de la <body> en su archivo index.html como se muestra a continuación:


 <main> <header> <a class="logo" href="/">News Demo</a> <form action="/search" method="GET"> <input autofocus class="search-input" value="" placeholder="Enter a news topic" type="search" name="q"> </form> <a href="https://github.com/freshman-tech/news" class="button github-button">View on Github</a> </header> </main> 

Luego reinicie el servidor y actualice su navegador. Deberías ver algo similar a esto:


Navegador que muestra la barra de navegación sin estilo


Trabajar con archivos estáticos


Tenga en cuenta que la barra de navegación que agregamos anteriormente no tiene estilos, a pesar de que ya los especificamos en el <head> nuestro documento.


Esto se debe a que la ruta / realmente coincide con todas las rutas que no se procesan en otro lugar. Por lo tanto, si va a http: // localhost: 3000 / assets / style.css , seguirá recibiendo la página de inicio de Demo de noticias en lugar del archivo CSS porque la ruta /assets/style.css no se ha declarado específicamente.


Pero la necesidad de declarar controladores explícitos para todos nuestros archivos estáticos no es realista y no se puede escalar. Afortunadamente, podemos crear un controlador para servir todos los recursos estáticos.


Lo primero que debe hacer es crear una instancia del objeto del servidor de archivos, pasando el directorio en el que se encuentran todos nuestros archivos estáticos:


 fs := http.FileServer(http.Dir("assets")) 

Luego, debemos decirle a nuestro enrutador que use este objeto de servidor de archivos para todas las rutas que comiencen con el /assets/ prefix:


 mux.Handle("/assets/", http.StripPrefix("/assets/", fs)) 

Ahora todos juntos:


 // main.go //   func main() { port := os.Getenv("PORT") if port == "" { port = "3000" } mux := http.NewServeMux() //     fs := http.FileServer(http.Dir("assets")) mux.Handle("/assets/", http.StripPrefix("/assets/", fs)) mux.HandleFunc("/", indexHandler) http.ListenAndServe(":"+port, mux) } 

Reinicie el servidor y actualice el navegador. Los estilos deben activarse como se muestra a continuación:


Valiente navegador que muestra una barra de navegación con estilo



Creemos una ruta que maneje las consultas de búsqueda de artículos de noticias. Utilizaremos la API de Noticias para procesar las solicitudes, por lo que debe registrarse para recibir una clave API gratuita aquí .


Esta ruta espera dos parámetros de consulta: q representa la consulta del usuario y la page utiliza para desplazarse por los resultados. Este parámetro de page es opcional. Si no está incluido en la URL, simplemente asumimos que el número de página de los resultados se establece en "1".


Agregue el siguiente controlador en indexHandler a su archivo main.go :


 func searchHandler(w http.ResponseWriter, r *http.Request) { u, err := url.Parse(r.URL.String()) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Internal server error")) return } params := u.Query() searchKey := params.Get("q") page := params.Get("page") if page == "" { page = "1" } fmt.Println("Search Query is: ", searchKey) fmt.Println("Results page is: ", page) } 

El código anterior extrae los parámetros q y page de la URL de solicitud y los muestra a ambos en el terminal.


Luego, registre la función searchHandler como el manejador de ruta /search , como se muestra a continuación:


 func main() { port := os.Getenv("PORT") if port == "" { port = "3000" } mux := http.NewServeMux() fs := http.FileServer(http.Dir("assets")) mux.Handle("/assets/", http.StripPrefix("/assets/", fs)) // Add the next line mux.HandleFunc("/search", searchHandler) mux.HandleFunc("/", indexHandler) http.ListenAndServe(":"+port, mux) } 

Recuerde importar los fmt y net/url desde arriba:


 import ( "fmt" "html/template" "net/http" "net/url" "os" ) 

Ahora reinicie el servidor, ingrese la consulta en el campo de búsqueda y verifique el terminal. Debería ver su solicitud en el terminal, como se muestra a continuación:




Crea un modelo de datos


Cuando realizamos una solicitud al punto final de News API/everything , esperamos una respuesta json en el siguiente formato:


 { "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..." } ] } 

Para trabajar con estos datos en Go, necesitamos generar una estructura que refleje los datos al decodificar el cuerpo de la respuesta. Por supuesto, puede hacerlo manualmente, pero prefiero usar el sitio web JSON-to-Go , lo que hace que este proceso sea realmente fácil. Genera una estructura Go (con etiquetas) que funcionará para este JSON.


Todo lo que tiene que hacer es copiar el objeto JSON y pegarlo en el campo marcado con JSON , luego copiar el resultado y pegarlo en su código. Esto es lo que obtenemos para el objeto JSON anterior:


 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"` } 

Valiente navegador que muestra la herramienta JSON to Go


Hice varios cambios en la estructura AutoGenerated separando el fragmento de Articles en su propia estructura y actualizando el nombre de la estructura. Pegue la siguiente declaración de variable tpl en main.go y agregue el paquete de time a su importación:


 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"` } 

Como ya sabrá, Go requiere que todos los campos exportados en la estructura comiencen con una letra mayúscula. Sin embargo, es habitual representar los campos JSON usando camelCase o snake_case , que no comienzan con mayúscula.


Por lo tanto, usamos etiquetas de campo de estructura como json:"id" para mostrar explícitamente el campo de estructura en el campo JSON, como se muestra arriba. También le permite usar nombres completamente diferentes para el campo de estructura y el campo json correspondiente, si es necesario.


Finalmente, creemos un tipo diferente de estructura para cada consulta de búsqueda. Agregue esto debajo de la estructura de Results en main.go :


 type Search struct { SearchKey string NextPage int TotalPages int Results Results } 

Esta estructura representa cada consulta de búsqueda realizada por el usuario. SearchKey es la consulta en sí, el campo NextPage permite desplazarse a través de los resultados, TotalPages - el número total de páginas de resultados de la consulta y Results - la página actual de resultados de la consulta.


Envíe una solicitud utilizando la API de noticias y muestre los resultados.


Ahora que tenemos el modelo de datos para nuestra aplicación, continuemos y hagamos solicitudes a la API de Noticias, y luego presentemos los resultados en la página.


Dado que News API requiere una clave API, necesitamos encontrar una manera de pasarla en nuestra aplicación sin codificar en el código. Las variables de entorno son un enfoque común, pero decidí usar banderas de línea de comando en su lugar. Go proporciona un paquete de flag que admite el análisis básico de indicadores de línea de comandos, y esto es lo que vamos a utilizar aquí.


Primero declare una nueva variable apiKey debajo de la variable tpl :


 var apiKey *string 

Luego úselo en la función main siguiente manera:


 func main() { apiKey = flag.String("apikey", "", "Newsapi.org access key") flag.Parse() if *apiKey == "" { log.Fatal("apiKey must be set") } //    } 

Aquí llamamos al método flag.String() , que nos permite definir una bandera de cadena. El primer argumento para este método es el nombre del indicador, el segundo es el valor predeterminado y el tercero es la descripción del uso.


Después de definir todas las banderas, debe llamar a flag.Parse() para flag.Parse() realmente. Finalmente, dado que apikey es un componente requerido para esta aplicación, nos aseguramos de que el programa se bloquee si este indicador no se establece durante la ejecución del programa.


Asegúrese de agregar el paquete de flag a su importación, luego reinicie el servidor y pase la bandera de apikey requerida, como se muestra a continuación:


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

A continuación, continuemos y actualicemos searchHandler para que la consulta de búsqueda del usuario se envíe a newsapi.org y los resultados se muestren en nuestra plantilla.


Reemplace las dos llamadas al método fmt.Println() al final de la función searchHandler siguiente código:


 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 { w.WriteHeader(http.StatusInternalServerError) } } 

Primero, creamos una nueva instancia de la estructura de Search y establecemos el valor del campo SearchKey en el valor del parámetro URL q en la solicitud HTTP.


Después de eso, convertimos la variable de page en un entero y asignamos el resultado al campo NextPage variable de search . Luego creamos la variable pageSize y establecemos su valor en 20. Esta variable pageSize representa el número de resultados que la API de noticias devolverá en su respuesta. Este valor puede variar de 0 a 100.


Luego creamos el punto final usando fmt.Sprintf() y le hacemos una solicitud GET. Si la respuesta de News API no es 200 OK , devolveremos un error general del servidor al cliente. De lo contrario, el cuerpo de la respuesta se analiza en la search.Results . search.Results .


Luego calculamos el número total de páginas dividiendo el campo TotalResults por pageSize . Por ejemplo, si una consulta devuelve 100 resultados y solo vemos 20 a la vez, necesitaremos desplazarnos por cinco páginas para ver los 100 resultados de esa consulta.


Después de eso, representamos nuestra plantilla y pasamos la variable de search como la interfaz de datos. Esto nos permite acceder a los datos de un objeto JSON en nuestra plantilla, como verá.


Antes de pasar a index.html , asegúrese de actualizar sus importaciones como se muestra a continuación:


 import ( "encoding/json" "flag" "fmt" "html/template" "log" "math" "net/http" "net/url" "os" "strconv" "time" ) 

Continuemos y visualicemos los resultados en la página cambiando el archivo index.html la siguiente manera. Agregue esto bajo la <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> 

Para acceder al campo de estructura en la plantilla, utilizamos el operador de punto. Este operador se refiere a un objeto de estructura (en este caso, search ), y luego dentro de la plantilla simplemente especificamos el nombre del campo (como {{.Results}} ).


El bloque de range nos permite iterar sobre un segmento en Go y generar algo de HTML para cada elemento en el segmento. Aquí, iteramos sobre la porción de las estructuras de Article contenidas en el campo Articles y mostramos el HTML en cada iteración.


Reinicie el servidor, actualice el navegador y busque noticias sobre un tema popular. Debería obtener una lista de 20 resultados por página, como se muestra en la captura de pantalla a continuación.


Navegador que muestra listados de noticias


Guardar consulta de búsqueda en el extranjero


Tenga en cuenta que la consulta de búsqueda desaparece de la entrada cuando la página se actualiza con los resultados. Idealmente, la consulta debe mantenerse hasta que el usuario realice una nueva búsqueda. Así es como funciona la Búsqueda de Google, por ejemplo.


Podemos solucionarlo fácilmente actualizando el atributo de value de la etiqueta de input en nuestro archivo index.html la siguiente manera:


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

Reinicie su navegador y realice una nueva búsqueda. La consulta de búsqueda se guardará como se muestra a continuación:



Formato de fecha de publicación


Si observa la fecha en cada artículo, verá que es poco legible. El resultado actual muestra cómo la API de noticias devuelve la fecha de publicación del artículo. Pero podemos cambiar esto fácilmente agregando un método a la estructura del Article y usándolo para formatear la fecha en lugar de usar el valor predeterminado.


main.go el siguiente código justo debajo de la estructura del Article en main.go :


 func (a *Article) FormatPublishedDate() string { year, month, day := a.PublishedAt.Date() return fmt.Sprintf("%v %d, %d", month, day, year) } 

Aquí, FormatPublishedDate crea un nuevo método FormatPublishedDate en la estructura del Article , y este método formatea el campo PublishedAt en el Article y devuelve una cadena en el siguiente formato: 10 2009 .


Para utilizar este nuevo método en su plantilla, reemplace .PublishedAt con .FormatPublishedDate en su archivo index.html . Luego reinicie el servidor y repita la consulta de búsqueda anterior. Esto generará los resultados con un tiempo formateado correctamente, como se muestra a continuación:


Valiente navegador que muestra la fecha formateada correctamente


Muestra el número total de resultados.


Mejoremos la interfaz de usuario de nuestra aplicación de noticias indicando el número total de resultados en la parte superior de la página y luego visualicemos un mensaje en caso de que no se encuentren resultados para una consulta en particular.


Todo lo que tiene que hacer es agregar el siguiente código como elemento secundario del .container , Justo arriba del elemento .search-results en su archivo index.html :


 <div class="result-count"> {{ if (gt .Results.TotalResults 0)}} <p>About <strong>{{ .Results.TotalResults }}</strong> results were found.</p> {{ else if (ne .SearchKey "") and (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».


, . «No results found».


Browser showing no results found message


. , :


Browser showing results count at the top of the page



20 , , .


Next , . , , Search main.go :


 func (s *Search) IsLastPage() bool { return s.NextPage >= s.TotalPages } 

, NextPage , TotalPages Search . , NextPage , . :


 func searchHandler(w http.ResponseWriter, r *http.Request) { //   search.TotalPages = int(math.Ceil(float64(search.Results.TotalResults / pageSize))) //   if  if ok := !search.IsLastPage(); ok { search.NextPage++ } //    } 

, , . .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> 

, Next .


, href /search q , NextPage page .


Previous . , 1. , CurrentPage() Search , . IsLastPage :


 func (s *Search) CurrentPage() int { if s.NextPage == 1 { return s.NextPage } return s.NextPage - 1 } 

NextPage - 1 , , NextPage 1. , 1 . :


 func (s *Search) PreviousPage() int { return s.CurrentPage() - 1 } 

, Previous , 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> 

, , .


Browser showing current page


Heroku


, , Heroku. , , . . freshman-news .


, Heroku . heroku login , Heroku.


, git- . , git init , , heroku git-. freshman-news .


 heroku git:remote -a freshman-news 

Procfile ( touch Procfile ) :


 web: bin/news-demo -apikey $NEWS_API_KEY 

GitHub Go, , go.mod , . , , .


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

Settings Heroku Reveal Config Vars . NEWS_API_KEY , .


Heroku config variables


, Heroku :


 git add . git commit -m "Initial commit" git push heroku master 

https://__.herokuapp.com , .


Conclusión


News Go -. , Heroku.


, . - , , .


Gracias por leer!

Source: https://habr.com/ru/post/475390/


All Articles