This is Qiita's first post. It's been half a year since I changed jobs without any experience in the IT industry, so I created a web application to output the results of Go language learning so far. In this article, I will introduce this WEB application. (Because I am a beginner, if you have any mistakes, please let me know in the comments: bow_tone1:)
-** WEB application url ** https://workbook-292312.an.r.appspot.com/
-** WEB application repository ** https://github.com/Gompei/WorkBookApp
-** Create ** Based on the account information obtained from the form, the data is registered with the primary key issued in the Datastore. Also, considering the password reissue function, we have implemented so that the e-mail address is not registered when logging in with an external account. By doing so, the email address in the Datastore is always unique.
handler.go
/*CreateAccount is a function that creates a user structure and stores it in the Datastore (automatically generated for the primary key).*/
func (a *App) CreateAccount(w http.ResponseWriter, r *http.Request) {
password := r.FormValue(password)
user := UserAccount{
Name: r.FormValue(userName),
OauthFlg: false,
Mail: r.FormValue(email),
ProfileImg: Environment.ImgUrl + "NoImage.jpg ",
CreateTimeStamp: time.Now(),
UpdateTimeStamp: time.Now(),
}
(abridgement)
//Password is encrypted (hash value)
hash, err := HashFiled(password)
if err != nil {
a.WriteLog(ErrLog, err)
SetMessage(errorMessage, nil)
a.ReadTemplate(w, PageAccountCreate, showAccountCreate, SendDate)
return
}
user.HashPassword = hash
//Issue user ID on the data store side ・ Register data as primary key
err = a.DB.InsertUserAccount(user)
if err != nil || SendDate["message"] != "" {
a.WriteLog(ErrDBProcessing, err)
a.ReadTemplate(w, PageAccountCreate, showAccountCreate, SendDate)
return
}
}
(abridgement)
-** Login ** The password obtained from the form and the hash value are compared using the bcrypto package.
hadler.go
/*ValidateLoginData is a function that retrieves an entity from a Datastore using an email address as the primary key and checks if it matches the password.*/
func (a *App) ValidateLoginData(w http.ResponseWriter, r *http.Request) {
user := UserAccount{
Mail: r.FormValue(email),
}
password := r.FormValue(password)
(abridgement)
//The email address is registered in a unique state
err, user := a.DB.CheckUserLogin(user, password)
if err != nil {
a.ReadTemplate(w, PageLogin, showLogin, SendDate)
return
}
//Acquire the session and store the user information acquired from the database in the session memory.
session, err := GetSession(r)
if err != nil {
a.WriteLog(ErrLog, err)
SetMessage(errorMessage, nil)
a.ReadTemplate(w, PageLogin, showLogin, SendDate)
return
}
CreateSession(w, r, session, user)
(abridgement)
}
-** Update ** Since I am constantly replacing the data in the session with the map, I get the id (primary key) from it and update the account. By the way, input check is carried out on both the front side and the server side. (Same as when creating or logging in)
handler.go
/*UpdateAccount is a function that updates the data in the Datastore based on the entered user information.*/
func (a *App) UpdateAccount(w http.ResponseWriter, r *http.Request) {
user := UserAccount{
Name: r.FormValue(userName),
Mail: r.FormValue(email),
UpdateTimeStamp: time.Now(),
}
if !NameValidate(user.Name) || !MailValidate(user.Mail) {
(abridgement)
}
if r.FormValue(password) != "" || PasswordValidate(password) {
(abridgement)
}
id := SendDate["user"].(map[string]interface{})["id"].(int64)
err, tmp := a.DB.UpdateUserAccount(id, user)
if err != nil {
a.WriteLog(ErrDBProcessing, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
}
session, err := GetSession(r)
if err != nil {
a.WriteLog(ErrLog, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
CreateSession(w, r, session, tmp)
(abridgement)
}
handler.go
/*DeleteAccount is a function that deletes the user account registered in the Datastore.*/
func (a *App) DeleteAccount(w http.ResponseWriter, r *http.Request) {
id := SendDate["user"].(map[string]interface{})["id"].(int64)
err := a.DB.DeleteUserAccount(id)
if err != nil {
a.WriteLog(ErrDBProcessing, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
}
(abridgement)
}
handler.go
/*ExternalAuthenticationFaceBook is a function that acquires a facebook account, registers it in the database, and logs in.*/
func (a *App) ExternalAuthenticationFaceBook(w http.ResponseWriter, r *http.Request) {
user, err := a.Facebook.FetchFacebookAccount(r)
if err != nil {
a.WriteLog(ErrLog, err)
SetMessage(errorMessage, nil)
a.ReadTemplate(w, PageLogin, showLogin, SendDate)
return
}
user, err = a.CheckDBOauthAccount("Facebook", user)
if err != nil {
a.WriteLog(ErrDBProcessing, err)
SetMessage(errorMessage, nil)
a.ReadTemplate(w, PageLogin, showLogin, SendDate)
return
}
(abridgement)
}
auth.go
/*FetchHttpClient is a function that returns the client information required for external authentication*/
func FetchHttpClient(conf *oauth2.Config, r *http.Request) (*http.Client, error) {
code := r.URL.Query()["code"]
if code == nil {
err := errors.New("External authentication error")
return nil, err
}
ctx := context.Background()
tok, err := conf.Exchange(ctx, code[0])
if err != nil {
return nil, err
}
return conf.Client(ctx, tok), nil
}
/*Each func Fetch~Is a function that gets the user account information of each application from the url, refills it into a structure, and returns it.*/
func (f *FacebookClient) FetchFacebookAccount(r *http.Request) (UserAccount, error) {
client, err := FetchHttpClient(f.Conf, r)
if err != nil {
return UserAccount{}, err
}
(abridgement)
res, err := session.Get("/me?fields=id,name,email,picture", nil)
if err != nil {
return UserAccount{}, err
}
//Cast to ini64 type so that it can be registered in the Datastore
id := res["id"]
userId, err := strconv.ParseInt(id.(string), 10, 64)
if err != nil {
return UserAccount{}, err
}
(abridgement)
user := UserAccount{
FacebookId: userId,
OauthFlg: true,
Name: res["name"].(string),
ProfileImg: pictureUrl,
CreateTimeStamp: time.Now(),
UpdateTimeStamp: time.Now(),
}
return user, nil
}
-** JWT issuance / verification / email transmission ** This article explains JWT in an easy-to-understand manner. ↓ I have summarized what I researched about JWT. Using this technology, JWT sends an email set in the query parameter to the email address registered in Datastoren. The contents of JWT only include the user id, creation time stamp, and expiration date so that the user can see it. If JWT has been modified, an error screen will be displayed. By the way, mail transmission is also implemented in Go.
handler.go
/*SendReissueEmail is a function that sends a password reissue email to an already registered email address.*/
func (a *App) SendReissueEmail(w http.ResponseWriter, r *http.Request) {
searchMail := r.FormValue(email)
(abridgement)
//Return account based on email address
id, err := a.DB.SelectUserAccountMail(searchMail)
if err != nil {
(abridgement)
}
//Send an email to the email address of the searched user
err = gmailSend(searchMail, id)
if err != nil {
(abridgement)
}
(abridgement)
}
func (a *App) ShowRecoverPasswordPage(w http.ResponseWriter, r *http.Request) {
/*The contents of the token
1 User ID
2 Creation time stamp
3 Expiration date
*/
tokenString := r.URL.Query().Get("token")
if tokenString == "" {
SetMessage(errorTokenMessage, nil)
a.ReadTemplate(w, PageAccountCreate, showAccountCreate, SendDate)
return
}
claims := jwt.MapClaims{}
_, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return []byte(Environment.JwtSing), nil
})
if err != nil {
(abridgement)
}
//Do not enter user ID in session
SendDate["id"] = claims["id"]
(abridgement)
}
mail.go
/*body is a function that creates the content of an email*/
func (m mail) body() string {
return "To: " + m.to + "\r\n" +
"Subject: " + m.sub + "\r\n\r\n" +
m.msg + "\r\n"
}
/*gmailSend is a function that sends Gmail mail to the argument mail address*/
func gmailSend(send string, id int64) error {
token, err := CreateToken(id)
if err != nil {
return err
}
m := mail{
from: Environment.MailAddress,
username: Environment.MailName,
password: Environment.MailPassWord,
to: send,
sub: "Workbook |Password reset",
}
m.msg = "To complete the reset of the workbook login password" + "\r\n" +
"Please access the URL below and set a new password." + "\r\n" +
"https://workbook-292312.an.r.appspot.com/login/recover-password/page?token=" + token + "\r\n" +
"* The password reset URL is valid for one hour after it is issued. Please note that you will not be able to reset from the above URL after the expiration date." + "\r\n" +
"―――――――――――――――――――――――――――――――――――" + "\r\n" +
"This message is a login password reset procedure from the workbook" + "\r\n" +
"It has been sent to the customer who made it." + "\r\n" +
"If you don't know, someone else registered your email address incorrectly." + "\r\n" +
"There is a possibility." + "\r\n" +
"In that case, sorry to trouble you." + Environment.MailAddress + "Please contact us." + "\r\n" +
"―――――――――――――――――――――――――――――――――――"
smtpSvr := "smtp.gmail.com:587"
auth := smtp.PlainAuth("", m.username, m.password, "smtp.gmail.com")
if err := smtp.SendMail(smtpSvr, auth, m.from, []string{m.to}, []byte(m.body())); err != nil {
return err
}
return nil
}
auth.go
/*CreateToken is a function that issues a JWT with a user ID for password reissue.*/
func CreateToken(id int64) (string, error) {
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
claims["id"] = strconv.FormatInt(id, 10)
claims["iat"] = time.Now()
claims["exp"] = time.Now().Add(time.Hour * 1).Unix()
tokenString, err := token.SignedString([]byte(Environment.JwtSing))
return tokenString, err
}
-** Create ** You can create up to 20 questions. For questions, the data is held in a slice of the structure. Also, when getting image data from form at the same time, it is necessary to use ParseMultipartForm method.
entity.go
//Problem collection (user ID+title+Question array)
type WorkbookContent struct {
UserId int64
BookId int64
ShareId int64
Author string
Category string
Title string
Image string
Contents []Content
CreateTimeStamp time.Time
UpdateTimeStamp time.Time
}
//question
type Content struct {
ProblemNumber string
ProblemStatement string
Choice1 string
Choice2 string
Choice3 string
Choice4 string
Answer string
Explanation string
}
handler.go
/*CreateWorkBook is a function that registers the problem collection information obtained from the form in the Datastore based on the bookId.*/
func (a *App) CreateWorkBook(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(32 << 20)
if err != nil {
a.WriteLog(ErrLog, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
mf := r.MultipartForm.Value
if len(mf) == 0 {
a.WriteLog(ErrLog, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
var UpFileName string
file, fileHeader, err := r.FormFile(image)
if err != nil {
a.WriteLog(ErrLog, err)
UpFileName = "NoImage.jpg "
} else {
UpFileName = fileHeader.Filename
}
workbook := WorkbookContent{
UserId: SendDate["user"].(map[string]interface{})["id"].(int64),
Author: SendDate["user"].(map[string]interface{})["name"].(string),
Image: Environment.ImgUrl + UpFileName,
CreateTimeStamp: time.Now(),
UpdateTimeStamp: time.Now(),
}
workbook.Title = mf["title"][0]
workbook.Category = mf["category"][0]
(abridgement)
workbook.Contents = make([]Content, 0)
for i := 1; i <= total; i++ {
s := strconv.Itoa(i)
content := Content{
ProblemNumber: mf["problem"+s][0],
ProblemStatement: mf["statement"+s][0],
Choice1: mf["choices"+s+"-1"][0],
Choice2: mf["choices"+s+"-2"][0],
Choice3: mf["choices"+s+"-3"][0],
Choice4: mf["choices"+s+"-4"][0],
Answer: mf["answer"+s][0],
Explanation: mf["commentary"+s][0],
}
workbook.Contents = append(workbook.Contents, content)
}
(abridgement)
err = a.DB.InsertWorkbook(workbook)
if err != nil {
a.WriteLog(ErrDBProcessing, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
(abridgement)
}
handler.go
/*DeleteWorkBook is a function that deletes the data corresponding to the bookId obtained from the form.*/
func (a *App) DeleteWorkBook(w http.ResponseWriter, r *http.Request) {
id := r.FormValue(bookId)
if id == "" {
SetMessage("The deletion could not be executed.", "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
bookId, err := strconv.ParseInt(id, 10, 64)
if err != nil {
a.WriteLog(ErrLog, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
userId := SendDate["user"].(map[string]interface{})["id"].(int64)
err = a.DB.DeleteWorkBook(bookId, userId)
if err != nil {
a.WriteLog(ErrSTProcessing, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
(abridgement)
}
-** Learning ** Regarding the learning screen, the display / non-display processing is implemented in JavaScript without page transition for each question.
handler.go
/*CheckAnswerWorkBook is a function that answers based on the value obtained from form and creates the result with map.*/
func (a *App) CheckAnswerWorkBook(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
a.WriteLog(ErrLog, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
questionTotal := r.FormValue("question-total")
total, err := strconv.Atoi(questionTotal)
if err != nil {
a.WriteLog(ErrLog, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
//Extract only the problem number(As long as you know the checked value, you can answer.)
//Go1.12+Use the mechanism that the map key is automatically sorted from
answer := make(map[string]string)
choice := make(map[string]string)
for k, v := range r.Form {
if v[0] == "on" {
checked := strings.Replace(k, "check", "", 1)[0:1]
answer[checked] = k
choice[checked] = k
}
}
//When the user forcibly submits without selecting
if total != len(choice) {
a.WriteLog(ErrLog, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
for i := 1; i <= total; i++ {
s := strconv.Itoa(i)
//If the answer is incorrect, remove the value from the map
if answer[s][7:] != r.FormValue("Answer"+s) {
delete(answer, s)
}
}
bookId := r.FormValue(bookId)
err, book := a.DB.SelectWorkbook(bookId)
if err != nil {
a.WriteLog(ErrDBProcessing, err)
SetMessage(errorMessage, "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
kind := r.FormValue("kind")
SendDate["learningBook"] = book
SendDate["checkBook"] = answer
SendDate["choiceBook"] = choice
SendDate["kind"] = kind
(abridgement)
}
handler.go
/*Upload WorkBook is a share in the Datastore_A collection of questions obtained from the form in the book (kind)(After searching based on bookId)Function to register*/
func (a *App) UploadWorkBook(w http.ResponseWriter, r *http.Request) {
bookId := r.FormValue(bookId)
if bookId == "" {
SetMessage("Sharing could not be performed.", "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
err := a.DB.InsertShareWorkbook(bookId)
if err != nil {
a.WriteLog(ErrSTProcessing, err)
SetMessage("Already uploaded", "block")
a.ReadTemplate(w, PageHome, showHome, SendDate)
return
}
(abridgement)
}
database.go
/*InsertShareWorkbook shares the information obtained based on the bookID._function to register in book Kind*/
func (c *Client) InsertShareWorkbook(bookId string) error {
ctx := context.Background()
err, book := c.SelectWorkbook(bookId)
if err != nil {
return err
}
var workbook api.WorkbookContent
query := datastore.NewQuery("share_workbook").Filter("BookId =", book.BookId)
it := c.DataStore.Run(ctx, query)
_, err = it.Next(&workbook)
if err == nil {
return errors.New("It has already been uploaded.")
}
parentKey := datastore.IDKey("user_account", book.UserId, nil)
childKey := datastore.IncompleteKey("share_workbook", parentKey)
childKey.ID = book.BookId
_, err = c.DataStore.Put(ctx, childKey, &book)
if err != nil {
return err
}
return nil
}
workbook_share.html
<div class="article-cta workbook_learning_start">
{{if checkOwner $book.UserId}}
<a href="#" class="btn btn-danger" data-toggle="modal"data-target="#modal-danger">Delete</a>
{{end}}
<a href="#" class="btn btn-primary" data-toggle="modal" data-target="#modal-default">start</a>
<input type="hidden" class="book-id" name="" value="{{$book.BookId}}">
</div>
data.go
FuncMap = template.FuncMap{
//Used to display a deleteable button by the author on a shared page
"checkOwner": func(checkId interface{}) bool {
userId := SendDate["user"].(map[string]interface{})["id"].(int64)
checkUserId := checkId.(int64)
if checkUserId == userId {
return true
}
return false
},
}
(1)Go Since Java was originally created, I chose Go as the next language to learn because it allows similar development. At the beginning of development, I didn't understand how to use the structure / interface, so I implemented it as follows, but I think I was able to write Go-like code at the end of development.
database.go(initial)
//NewClient is a function that creates a Datastore client
func NewClient(ctx context.Context) (*datastore.Client, bool) {
var client *datastore.Client
client, err := datastore.NewClient(ctx, project_id)
if err != nil {
return nil, false
}
return client, true
}
//CheckUserLogin is a function that compares email addresses and passwords and returns boolean and user account information.
func CheckUserLogin(user UserAccount, password string) (bool, UserAccount) {
ctx := context.Background()
client, flg := NewClient(ctx)
if flg == false {
return false, user
}
defer client.Close()
(abridgement)
}
datbase.go(Current)
/*NewClient is a function that creates a Datastore client*/
func NewClient(ctx context.Context) (*Client, error) {
client, err := datastore.NewClient(ctx, api.Environment.ProjectId)
if err != nil {
return nil, err
}
return &Client{
DataStore: client,
}, nil
}
/*CheckUserLogin is a function that queries based on an email address and returns a user account when an error and password match.*/
func (c *Client) CheckUserLogin(user api.UserAccount, password string) (error, api.UserAccount) {
ctx := context.Background()
(abridgement)
}
DB, ST, LD are interfaces and implement the methods required for Datastore and Cloud Storage. Also, the contents of handler.go are mostly App (structure) methods.
handler.go(Current)
/*NewApp is DB,Storage,Logging,A function that combines external account connection information into one*/
func NewApp(d Repository, s Storage, l Logging, google GoogleClient, facebook FacebookClient, github GithubClient) *App {
return &App{
DB: d,
ST: s,
LD: l,
Google: google,
Facebook: facebook,
Github: github,
}
}
main.go(Current)
/*run is a function that creates each client and starts the server based on the registered handler function.*/
func run() error {
ctx := context.Background()
d, err := database.NewClient(ctx)
if err != nil {
return err
}
s, err := api.NewStorageClient(ctx)
if err != nil {
return err
}
l, err := api.NewLoggingClient(ctx)
if err != nil {
return err
}
google := api.NewGoogleClient()
facebook := api.NewFacebookClient()
github := api.NewGithubClient()
app := api.NewApp(d, s, l, google, facebook, github)
router := api.Route(app)
(abridgement)
}
entity.go(Current)
//DB repository
type Repository interface {
CheckUserLogin(user UserAccount, password string) (error, UserAccount)
InsertUserAccount(user UserAccount) error
(abridgement)
}
//Storage and logging repositories
type Client struct {
CloudStorage *storage.Client
Logging *logging.Client
}
//Logging connection information
type Logging interface {
WriteStackDriverLog(appErr *ApplicationError)
}
//Storage connection information
type Storage interface {
UploadImg(file multipart.File, fileHeader *multipart.FileHeader) error
}
//Application information
type App struct {
DB Repository
ST Storage
LD Logging
Google GoogleClient
Facebook FacebookClient
Github GithubClient
}
(2)Datastore This is because we followed the storage option flow provided by GCP. I also wanted to try NoSQL. (Currently this image is not on the official website ... right?) When actually using it, there are some inconveniences such as partial match search (LIKE) is not provided, but I feel that Datastore is more convenient than SQL for development that uses only simple data structures. .. (Next, I will choose Cloud SQL ...)
If you would like to know more about the database provided by GCP, please click here. ↓ Google Cloud Database
(3)CloudStorage This also follows GCP best practices. You can easily upload files and it is very convenient, so I would like to continue using it in the future.
(4)GoogleAppEngine This is also because we follow GCP best practices. (The reason for choosing GCP is because I like Google ʕ◔ϖ◔ʔ. Readers should think carefully and choose a cloud environment) I'm deploying to GAE SE because Cloud Functions can't deploy the entire application. GAE features the ability to split requests by version and the free use of HTTPS. (There are more features!)
Please refer to here for the application execution environment selection criteria ↓ Hosting apps on Google Cloud Which should I use, GCE or GAE? GCP operation diagnosis for those who say GCP --GAE, GCE, GKE decision tree
Since it was developed on GCP, the main will be Cloud Build. .Circleci / config.yml has been created so that it can be tested and deployed on CircleCI. This time, I chose CloudBuild, which makes it easy to create configuration files, but I would like to utilize CircleCI in the future. I also recommend this article because it explains the benefits of CloudBuild in an easy-to-understand manner. ↓ If you're using GCP, why not Cloud Build?
(6)Docker I'm developing on windows, but I've containerized it so that it can be run on other than windows. It is very convenient to be able to run the application independently of the environment with just Dockerfile and docker-compose.yml. (I want you to incorporate it at the current site ...)
--Utilization of structures and interfaces I mentioned this in the technology selection, so I won't write it in detail, but I think I was able to write Go-like code.
--Building a CI / CD pipeline What is CI / CD? Since it was developed from such a situation, it took time to build it, but I managed to automate it. The test code is still far from complete, but the benefits of automated deployment were huge. Next, I would like to write more test code and develop it so that I can make the most of CI / CD. (I want you to incorporate it at the current site ...)
What are Go, GCP, Docker, CI / CD? It was very good that I managed to complete it in the state from the beginning and had the experience of making something. In the future, in order to improve the basic skills of programming, I would like to take on the challenge of learning algorithms and developing levels that can actually be provided as services.
-[Go / Development environment construction] [Use Goland Winter 2019](https://qiita.com/junpayment/items/f66e85af6a854ca2a296#go%E3%82%92%E3%82%A4%E3%83%B3%E3%82%B9% E3% 83% 88% E3% 83% BC% E3% 83% AB% E3% 81% 97% E3% 81% BE% E3% 81% 97% E3% 82% 87% E3% 81% 86)
-[Go / Grammar] [Go] Basic Grammar ① (Basic)
-[Go / Package Management] Go seems to have a standard directory structure. [Go go lang] import your own package
-[Go / Session Management] Sample user login function using gorilla / mux, gorilla / context, gorilla / sessions in Go language
-[Go / External account authentication] Until you get an email address by Oauth authentication of Google, Twitter, Facebook in Go language
-[Go / JWT] JWT authentication implementation hands-on understood in Go language
【Datastore】 Documents about Firestore in Datastore mode Hanashi doing his best with Cloud Datastore queries
【CloudStorage】 Getting files from Google Cloud Storage in Go language (golang) Upload files to Google Cloud Storage in Go language (golang)
【GAE】 Build Go apps on App Engine (https://cloud.google.com/appengine/docs/standard/go/building-app?hl=ja)
【Cloud Build】 If you're using GCP, why not Cloud Build?
【Docker】 Building a Go development environment with Docker Remote debugging of Go web application on Docker
-[Qiita article writing reference] [Python + Flask] Simple book management application using Web API [Vue.js, Elasticsearch]
Recommended Posts