ginでwebアプリ作った
タイトル思いつかなかったので、適当です。すみません。
今回はgolangの package gin を使用して何かを作った話です。
- gin-gonic/gin: Gin is a HTTP web framework written in Go (Golang).
とはいえ、いろいろなpackageを使って作りました。
(背景長いので興味なければskip推奨ですw)
背景
とある勉強会でプログラミング練習させる何かを企画してと言われたので、まずは問題を考える。
様々なレベルの人がいるので、いろいろな難易度の問題を考えるのは面倒w
ということで、下記のサイトの問題を使用させていただきました。
- yukicoder
難易度別に別れてて、選出しやすい!
yukicoderはその場でコードを試せるのもとても良い。。。
でも今回はせっかく作ってもらったコードを
- その場で収集したい
- できたらテストもしたい
と思ったので、なにか作ることにしました。
なるべく外部のサービスを利用しないように運用したかったので、
ローカルにwebサーバー立てて集めて処理すればいいやんということでgolangで挑戦。
gin というフレームワークも試食してみたかったので使ってみました。
やりたいこと
- 作ってもらったコードの収集・検査・集計
web formからコードをpostしてサーバー側で検査する
そして集計する!
収集
集め方ですが、なんかの端末にサーバー立てて ローカルwifi 経由でアクセスしてページにアクセスしてもらう。
そして web form からコードをpostしてもらうっていう回りくどい方法で運用しました。
サーバー側でデータを貯めるときは・・・データベースっしょ!
ということで今回は sqlite3 を使用しました。
使用したpackage
-
mattn/go-sqlite3: sqlite3 driver for go using database/sql
-
jinzhu/gorm: The fantastic ORM library for Golang, aims to be developer friendly
構造体作ってあとはgormにおまかせ(AutoMigrate)
便利ですね・・・
※goroutineでDB管理する関数を動かしておいてchannel経由でデータを飛ばして追加するようにしているので少し小細工しています
package main
import (
"github.com/jinzhu/gorm"
_ "github.com/mattn/go-sqlite3"
)
// AnswerData : アンサーデータテーブル
type AnswerData struct {
gorm.Model
Name string
QuestionNo int
Language string
SourceCode string
Message string
Result string
Error string
}
func (a *AnswerData) CreateAtTime() string {
return a.CreatedAt.Format("2006/01/02 15:04:05")
}
func openDB(dest string, addDBCh chan *AnswerData) error {
for data := range addDBCh {
db, err := gorm.Open("sqlite3", dest)
if err != nil {
return err
}
// Migrate the schema
db.AutoMigrate(&AnswerData{})
// Create
db.Create(&data)
db.Close()
}
return nil
}
外からこんな感じで動かしておく
// データベース作成
addDBCh := make(chan *AnswerData)
defer close(addDBCh)
go openDB("data.db", addDBCh)
あとはaddDBChにデータ投げつけたら勝手にDB開いて追加してくれる
検査
送られてきたソースコードを動かしてみて結果をテストする。
特定の言語で問題を解いてくれという形式ではないので、様々な言語に対応するのが難しい・・・
ローカル完結と行きたかったのですが、ここは知識があまりなかったので外部サービスで代用w
- Rextester
WebAPIを公開してくれているので、コードを投げると実行結果を返してくれます。ありがたや~。
対応している言語の数も多い。しゅごぃ・・・。
本当はローカル側(ブラウザ)からやらせる方がいいとは思うのですが・・・(サーバーのレスポンス遅くなるし
テストの期待値ですが、先程のyukicoderの問題についています。ありがたや~。
ということで、1問につき数パターンのテストをしていきます。
Rextester使う上で引っかかったポイントとしては以下な感じです。
- Rextesterが指定する雛形からスタートしないといけない
- 書き始めが楽という意見もあるけど、最初から書きたいという人には不評w
- APIに投げるときにコンパイルオプションが必要
- 当日まで気づかず そのまま言語・コードだけ投げてエラーになって あるぇ~? ってなってました orz
構造体とか作っておいて、選ばれた言語のコンパイルオプションとか使ってコードを投げる必要があります。
// Language : 言語情報
type Language struct {
LanguageChoice string
Ext string
CompilerArgs string
}
// LanguageList : http://rextester.com/
var LanguageList = map[string]Language{
"Perl": {
LanguageChoice: "13",
Ext: "pl",
},
"Go": {
LanguageChoice: "20",
Ext: "go",
CompilerArgs: "-o a.out source_file.go",
},
"C (clang)": {
LanguageChoice: "26",
Ext: "c",
CompilerArgs: "-Wall -std=gnu99 -O2 -o a.out source_file.c",
},
}
実装はこんな感じ
lang, exists := LanguageList[ans.language]
if exists == false {
return fmt.Errorf("未対応の言語です(%s)", ans.language)
}
values := url.Values{}
values.Add("LanguageChoice", lang.LanguageChoice)
values.Add("Program", ans.source)
values.Add("Input", tc.Input)
values.Add("CompilerArgs", lang.CompilerArgs)
// POST準備
req, err := http.NewRequest(
"POST",
`https://rextester.com/rundotnet/api`,
strings.NewReader(values.Encode()),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// POST
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 結果取得
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
var result ResultData
err = json.Unmarshal(body, &result)
if err != nil {
return err
}
APIに投げるパラメータ作ってPOSTするとjsonで返ってくるからUnmarshalすると結果を取得できる
テストに使う入力・期待値とかはこちら側で作っておいて、返ってきた結果と比較する
{
"Questions": [
{
"Title": "No.46 はじめのn歩",
"Tests": [
{
"Input": "2 5",
"Expected": "3"
},
{
"Input": "10 100",
"Expected": "10"
},
{
"Input": "123456789 987654321",
"Expected": "9"
}
]
}
]
}
webページ
ここでようやく gin の登場です。
いろいろ設定して、いろいろ置いて、テンプレート作ります。
r := gin.Default()
// ページからアクセスするファイル置き場
r.Static("./assets", "./assets")
// テンプレート
r.LoadHTMLGlob("./templates/*")
// メインページ
r.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "index.tmpl", gin.H{
"title": "提出",
"langage": LanguageList,
"config": config,
})
})
Static には、ページからアクセスするファイルを置いておきます。
複数指定できるので、お好きなフォルダにファイルを入れて使えます。
トップだけ指定しておけば、配下もすべて使えるようになります。
テンプレートは、LoadHTMLGlobにフォルダ指定してファイルを入れるだけ。
使うときにファイルを指定すればOK。お手軽。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="assets/css/bootstrap.min.css">
<title>{{ .title }}</title>
</head>
<body>
<div class="container-fluid my-3">
<div class="col-md-10 offset-md-1">
<div class="card card-outline-secondary">
<div class="card-header">
<h4 class="mb-0">提出</h4>
</div>
<div class="card-body">
<form action="/post" method="POST" class="form" role="form">
<fieldset>
<div class="form-group">
<label for="name" class="mb-0">名前</label> <span class="badge badge-danger mb-2">必須</span>
<input type="text" name="name" id="name" class="form-control" autocomplete="on" value="{{.name}}"
placeholder="山田太郎" required>
</div>
<div class="form-group">
<label for="no" class="mb-0">問題</label>
{{- $selected := .no }}
{{- range $i, $question := .config.Questions}}
{{- $ai := add $i 1}}
<div class="form-check">
{{- if eq $ai 0}}
{{- if eq $ai $selected}}
<input class="form-check-input" type="radio" name="no" id="no{{$ai}}" value="{{$ai}}" required checked>
{{ else }}
<input class="form-check-input" type="radio" name="no" id="no{{$ai}}" value="{{$ai}}" required>
{{- end -}}
{{ else }}
{{- if eq $ai $selected}}
<input class="form-check-input" type="radio" name="no" id="no{{$ai}}" value="{{$ai}}" checked>
{{ else }}
<input class="form-check-input" type="radio" name="no" id="no{{$ai}}" value="{{$ai}}">
{{- end -}}
{{- end -}}
<label class="form-check-label" for="no{{$ai}}">
{{$question.Title}}
</label>
</div>
{{- end}}
</div>
<div class="form-group">
<label for="lang" class="mb-0">言語</label>
<select id="lang" name="lang" class="form-control" size="0">
{{- $selected2 := .lang }}
{{- range $name, $_ := .langage}}
{{- if eq $name $selected2 -}}
<option value="{{$name}}" selected>{{$name}}</option>
{{ else }}
<option value="{{$name}}">{{$name}}</option>
{{- end}}
{{- end}}
</select>
</div>
<div class="form-group">
<label for="source" class="mb-0">ソースコード</label> <span class="badge badge-danger mb-2">必須</span>
<textarea rows="6" name="source" id="source" class="form-control" style="font-family: 'MS ゴシック', Consolas, 'Courier New', Courier, Monaco, monospace;"
required>{{.source}}</textarea>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="unfinished" value="1" id="unfinished">
<label class="form-check-label" for="unfinished">
未完成
</label>
</div>
</div>
<div class="form-group">
<label for="message" class="mb-0">メッセージ</label>
<textarea rows="2" name="message" id="message" class="form-control" placeholder="何か伝えることがあれば記載してください">{{.message}}</textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success btn-lg float-right mt-3 col-lg-2">提出</button>
</div>
</fieldset>
</form>
</div>
</div>
</div>
</div>
<script src="./assets/js/jquery-3.3.1.slim.js"></script>
<script src="./assets/js/popper.min.js"></script>
<script src="./assets/js/bootstrap.min.js"></script>
<script src="./assets/js/autosize.min.js"></script>
<script>
autosize(document.querySelectorAll('textarea'));
$('form').submit(function () {
$(this).find(':submit').attr('disabled', 'disabled');
});
</script>
</body>
</html>
という感じでサクッとテンプレートを作成。
見た目はBootstrapを採用。
レスポンシブデザインなので、どんな端末からも見やすくなる。便利ねぇ~。
テキストフォームのリサイズには、Autosizeを採用。
コード長くなったらスクロールするの面倒ですしお寿司。
動かすとこんな感じになりますです。
集計
結果が一覧に見えるページ作っといたら、管理する側がわかりやすいんじゃないかということでこちらも作っておく。
データベースから随時情報を読み出すことで誰がどの問題までできているかとかも把握できて便利だし。
誰でもアクセスできるのもあれなので・・・Basic認証をつけておく。
// 認証情報を設定
authResult := r.Group("/result", gin.BasicAuth(gin.Accounts{
// id : pass
"akiyama": "mio",
}))
authResult.GET("", func(c *gin.Context) {
c.HTML(http.StatusOK, "result.tmpl", gin.H{
"title": "結果一覧",
"data": revData,
})
})
Basic認証Groupで認証情報が入ったオブジェクト作っておいてそいつからGETとかを伸ばすだけ
/result配下のページにもつけたいときは同じように相対パスで指定して伸ばすだけ
// 個人結果
authResult.GET("/:name", func(c *gin.Context) {
name := c.Param("name")
c.HTML(http.StatusOK, "target.tmpl", gin.H{
"title": "個人結果: " + name,
"name": name,
"data": revData,
})
})
// 結果
authResult.GET("/:name/*id", func(c *gin.Context) {
c.HTML(http.StatusOK, "view.tmpl", gin.H{
"title": "結果: " + c.Param("id"),
"data": rData,
})
})
同じようにテンプレート作って、データベースからデータ引っ張ってきて渡すだけ
下記のように表示されるものを作りました。
ソースコードの表示部分は、highlight.jsとhighlightjs-line-numbers.jsを使用してます。
実行
golangのいいところ、ワンバイナリになっちゃうこと。
今回は使用するファイルが何個かあるので、使うフォルダ・ファイルとビルドしたexeがあれば動いちゃう。
※ファイルもexe内に入れれるので、頑張ればexe1個にできちゃうかもしれませんがw
あとはサクサク軽量に動いてくれます。
golangやginのサポートによって頑張ってさばいてくれます。
あまりまとまっていませんが、webアプリを作った話でした。



