Go+Echoで簡単なWeb APIを作ってみる
こんにちは、株式会社SHIFT、自動化エンジニアの水谷です。
Go言語は、いろいろな用途に使われるプログラミング言語なのですが、最近はWebアプリのバックエンド、つまりWeb APIの実装に使われることも多いようです。
Go用のWebアプリケーションフレームワーク(WAP)にもいくつか種類があるのですが、今回は軽量でネット上にも情報がたくさんあるEchoを使って簡単なWeb APIを作ってみたいと思います。
※SHIFTエンジニアが学びながら解説するGo言語というマガジンができました。過去の記事もこちらから読めますので、もし本記事に興味をもっていただけましたら、マガジンの方も是非見ていただければと思います。
マンデルブロー集合の画像を返すAPI
前回の記事で、Fyneとゴルーチンを使って、下のようなマンデルブロー集合の画像を表示するアプリを作りましたが、今回はそのコードを流用して、マンデルブロー集合の画像をPNG形式で返すAPIを作成してみたいと思います。
APIは、マンデルブロー集合の計算範囲(実数部および虚数部)をパラメーターで指定できるようにし、出来上がった画像はPNG形式にエンコードし(JPEGでも良かったのですが、ちょっと細部が崩れるのでPNGにしました)、それをBASE64でテキスト化して返す、という仕様にします。
Echoのインストール
では、作っていきましょう。まずは適当なディレクトリを作成し、"go mod init"を実行します(名前も適当に付けます)。
> go mod init gomandel_backend
続いて、Echoを"go get"コマンドでインストールします。
> go get -u github.com/labstack/echo
最小のWeb APIからスタート
では、まずHello World的なコードから始めましょう。ルート(/)をGETでアクセスすると、"Hello from Go Echo"という文字列を返すAPIのコードは下のようになります(ポートは8080を使っていますが、もちろん他の番号でもOKです)。なお、ファイル名はbackmain.goとしています。
package main
import (
"net/http"
"github.com/labstack/echo"
)
func rootHandler(c echo.Context) error {
return c.String(http.StatusOK, "Hello from Go Echo")
}
func main() {
e := echo.New()
e.GET("/", rootHandler)
e.Logger.Fatal(e.Start(":8080"))
}
main()関数の中もシンプルで分かりやすく、簡単ですね。echo.New()でEchoのインスタンスを作成し、e.GET()でハンドラーを定義して、あとはハンドラーの内容を記述していけばOK、ということになります。また、最後の行は、Webサーバーを指定したポートで実行し、エラーが発生したらログを記録する、という動作になります。
ビルドして、curlでアクセスして見ると、下のように期待通りの文字列が返ってきました。
> go build backmain.go
> curl http://localhost:8080
Hello from Go Echo
マンデルブロー集合APIの実装
では、このコードに/GetMapをGETでアクセスされたときのハンドラーを追加します。このハンドラーがマンデルブロー集合の計算を行って、画像データを返すAPIとなります。
まずは、main()に下の行を追加
e.GET("/GetMap", getMapHandler)
そして、getMapHandlerという関数を作って実装していきます。
func getMapHandler(c echo.Context) error {
}
まずはパラメーター(Form Value)の受け取り部分ですが、このAPIは4つのパラメーターを受け取ります。
これを受け取るコードは下のようになります。
realMin, err1 := strconv.ParseFloat(c.FormValue("realmin"), 64)
realMax, err2 := strconv.ParseFloat(c.FormValue("realmax"), 64)
imaginaryMin, err3 := strconv.ParseFloat(c.FormValue("imaginarymin"), 64)
imaginaryMax, err4 := strconv.ParseFloat(c.FormValue("imaginarymax"), 64)
if err1 != nil || err2 != nil || err3 != nil || err4 != nil ||
realMin >= realMax || imaginaryMin >= imaginaryMax {
return c.String(http.StatusBadRequest, "incorrect parameter")
}
パラメーターは、c.FormValue()で取得できますので、それをstrconv.ParseFloat()に渡して、64bitの浮動小数点数にコンバートしています。
エラーチェックはちょっと横着(?)をして、4つのパラメーター全部が揃ってからまとめて行っています。何らかの問題があれば、ステータスコード400と"incorrect parameter"を返します。
そして、マンデルブロー集合の計算を行って画像を作成します。
img := image.NewRGBA(image.Rect(0, 0, 800, 800))
nGR := 4
fchannel := make(chan int)
defer close(fchannel)
r := make([]int, nGR)
for i := range r {
go CalcGR(img, realMin, realMax, imaginaryMin, imaginaryMax, i, nGR, fchannel)
}
for range r {
<-fchannel
}
今回は簡単のため、ゴルーチンの数は4で固定としています。
そのゴルーチンはこんな感じになります(前回の記事で作ったものと同じです)。
func CalcGR(img *image.RGBA, rmin float64, rmax float64, imin float64, imax float64, offset int, nGR int, fchannel chan int) {
for y := 0; y < 800; y++ {
for x := offset; x < 800; x += nGR {
var C = complex(rmin+(rmax-rmin)*float64(x)/800, imin+(imax-imin)*float64(y)/800)
var V = C
var it uint8 = 0
for ; it <= 100; it++ {
V = V*V + C
if cmplx.Abs(V) > 2 {
break
}
}
img.Set(x, y, color.RGBA{uint8(cmplx.Abs(V) * 40), uint8(cmplx.Abs(V) * 120), it * 2, 255})
}
}
fchannel <- 1
}
計算が終わればfchannelを通して通知しています。
あとは、PNG形式にして、それをさらにBASE64でエンコードして返す部分ですが、このような一見手間がかかりそうな操作も、下のように簡単に実装できます。
var buff bytes.Buffer
png.Encode(&buff, img)
encodedString := base64.StdEncoding.EncodeToString(buff.Bytes())
return c.String(http.StatusOK, encodedString)
テスト実行
では、ビルドして実行してみます。
> go build backmain.go
> curl http://localhost:8080/GetMap
incorrect parameter
> curl http://localhost:8080/GetMap?realmin=-1&realmax=1&imaginarymin=-1&imaginarymax=1
iVBORw0KGgoAAAANSUhEUgAAAyAAAAMgCAIAAABUEpE/AACAAElEQVR4nOS9B5gb1fX/fXRUV1u93l2ve8G9m2Zs
bLCNAYMpxjTTCRgIvYZOQpJfEhIgCQRICBASCB1M79U2trFxw717vfb2rlXX6M77TL8zc2c00q4N+b/7zLPPnas7
o9FImvnoe773HM8v70PggecBiPhfXHgCcmcOq4ZdUWMybJVbv9X+mQ3TgXWp4WxvAHaPPnAP8hzwaeA5IEqDucqn
xR7Dapo1hu6kGjYP6R41PWTZY7VtTotwPm0GMB8ltnugVu+4GNNR4KKQFhehERPbMdMSF/6TuNKOi221kVD+i4uu
kRTbSWVJAEkpbbHBp4TGKTsx2QrJNki1QapDXELCwnUCFxaXCKQjyhHGlIOJw7T3MbofYjUQr4N4AySaINkC0q6m
...
パラメーターなしでcurlした場合は、期待通りに"incorrect parameter"が返ってきて、パラメーターを設定してcurlした場合は、BASE64っぽい文字列が返ってきたことがわかります。うまく動いていそうですね。
CORS対応
同一生成元制限を緩和するためにCORSの設定をしたい場合が多いと思いますので、少し触れておきます。Echoにおいてこれを行うには、まず"github.com/labstack/echo/middleware"をインポートします。
import (
...
"github.com/labstack/echo/middleware"
)
そして、main()に以下の行を追加してください。
e.Use(middleware.CORS())
これで別プロジェクトで作ったフロントエンドから(ローカルで)アクセスした場合でもエラーが出なくなります。
フロントエンドを作る
この記事の本題はAPIの実装なので、ここまでで終わりという感じなのですが、やっぱりAPIを呼び出して画像を表示するフロントエンドが欲しい、ということで、JavascriptとReactでチャチャっと作りました。
ポイントだけ書いておきますと、まず<img>タグを下のように用意しておきます。
<img width="800" height="800" src={image} alt="no image..." />
ここで、imageはReact HookのuseStateを使って定義しておきます。
const [image, setImage] = useState("");
そして、4つのパラメーターを入力できるフォームを作っておき、そのSubmitハンドラーでAPIのURLをfetchします。
const queryParams = new URLSearchParams({realmin: realMin, realmax: realMax, imaginarymin: imaginaryMin, imaginarymax: imaginaryMax})
fetch('http://localhost:8080/GetMap?' + queryParams , {method: 'GET', mode: 'cors'})
.then((res) => {
return res.text()
})
.then((data) => {
setImage("data:image/png;base64," + data)
})
.catch((reason) => {
console.log(reason);
})
これで、APIが正常に実行されれば、SetImageが呼ばれてimageに値がセットされるので、自動的に画像が表示されます。
ということで、こんなWebアプリになりました。
パラメーターはデフォルトで実数部が-2.0~1.0、虚数部が-1.5~1.5となるようにしています。このパラーメーターを変更して「GENERATE」ボタンを押すと、APIがコールされて、下のように画像が変わります。
――――――――――――――――――――――――――――――――――
最後にコード全体を載せておきますので、参考にしていただければ幸いです。
package main
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/png"
"math/cmplx"
"net/http"
"strconv"
"github.com/labstack/echo"
"github.com/labstack/echo/middleware"
)
func CalcGR(img *image.RGBA, rmin float64, rmax float64, imin float64, imax float64, offset int, nGR int, fchannel chan int) {
for y := 0; y < 800; y++ {
for x := offset; x < 800; x += nGR {
var C = complex(rmin+(rmax-rmin)*float64(x)/800, imin+(imax-imin)*float64(y)/800)
var V = C
var it uint8 = 0
for ; it <= 100; it++ {
V = V*V + C
if cmplx.Abs(V) > 2 {
break
}
}
img.Set(x, y, color.RGBA{uint8(cmplx.Abs(V) * 40), uint8(cmplx.Abs(V) * 120), it * 2, 255})
}
}
fchannel <- 1
}
func getMapHandler(c echo.Context) error {
realMin, err1 := strconv.ParseFloat(c.FormValue("realmin"), 64)
realMax, err2 := strconv.ParseFloat(c.FormValue("realmax"), 64)
imaginaryMin, err3 := strconv.ParseFloat(c.FormValue("imaginarymin"), 64)
imaginaryMax, err4 := strconv.ParseFloat(c.FormValue("imaginarymax"), 64)
if err1 != nil || err2 != nil || err3 != nil || err4 != nil ||
realMin >= realMax || imaginaryMin >= imaginaryMax {
return c.String(http.StatusBadRequest, "incorrect parameter")
}
img := image.NewRGBA(image.Rect(0, 0, 800, 800))
nGR := 4
fchannel := make(chan int)
defer close(fchannel)
r := make([]int, nGR)
for i := range r {
go CalcGR(img, realMin, realMax, imaginaryMin, imaginaryMax, i, nGR, fchannel)
}
for range r {
<-fchannel
}
var buff bytes.Buffer
png.Encode(&buff, img)
encodedString := base64.StdEncoding.EncodeToString(buff.Bytes())
return c.String(http.StatusOK, encodedString)
}
func rootHandler(c echo.Context) error {
return c.String(http.StatusOK, "Hello from Go Echo")
}
func main() {
e := echo.New()
e.Use(middleware.CORS())
e.GET("/", rootHandler)
e.GET("/GetMap", getMapHandler)
e.Logger.Fatal(e.Start(":8080"))
}
また、フロントエンドは、create-react-appで作成し、App.jsを下のように変更しました。
import './App.css';
import React, { useState } from "react";
import Box from "@material-ui/core/Box";
import Button from '@material-ui/core/Button';
import Grid from "@material-ui/core/Grid";
import TextField from "@material-ui/core/TextField";
function App() {
const [image, setImage] = useState("");
const [realMin, setRealMin] = useState("-2.0");
const [realMax, setRealMax] = useState("1.0");
const [imaginaryMin, setImaginaryMin] = useState("-1.5");
const [imaginaryMax, setImaginaryMax] = useState("1.5");
const [isFirstTime, setIsFirstTime] = useState(true)
function handleSubmit(e) {
console.log("calling API")
const queryParams = new URLSearchParams({realmin: realMin, realmax: realMax, imaginarymin: imaginaryMin, imaginarymax: imaginaryMax})
fetch('http://localhost:8080/GetMap?' + queryParams , {method: 'GET', mode: 'cors'})
.then((res) => {
return res.text() })
.then((data) => {
setImage("data:image/png;base64," + data)
})
.catch((reason) => {
console.log(reason);
})
}
if (isFirstTime) {
setIsFirstTime(false)
handleSubmit(null)
}
return (
<div className="App">
<Box m={2} pt={2} mx="auto" width={816} height={816} border={1} bgcolor="#c0c0c0" boxShadow={3}>
<img width="800" height="800" src={image} alt="no image..." />
</Box>
<Box>
<form>
<Grid container justify="center">
<Grid item xs={2}>
<TextField
name="RealMin"
onChange={(e) => { setRealMin(e.target.value) }}
label="real_min"
variant="outlined"
autoComplete="off"
value={realMin}
/>
</Grid>
<Grid item xs={2}>
<TextField
name="RealMax"
onChange={(e) => { setRealMax(e.target.value) }}
label="real_max"
variant="outlined"
autoComplete="off"
value={realMax}
/>
</Grid>
<Grid item xs={2}>
<TextField
name="ImaginaryMin"
onChange={(e) => { setImaginaryMin(e.target.value) }}
label="imaginary_min"
variant="outlined"
autoComplete="off"
value={imaginaryMin}
/>
</Grid>
<Grid item xs={2}>
<TextField
name="ImaginaryMax"
onChange={(e) => { setImaginaryMax(e.target.value) }}
label="imaginary_max"
variant="outlined"
autoComplete="off"
value={imaginaryMax}
/>
</Grid>
<Box pt={1} pl={1}>
<Grid item xs={2}>
<Button
color="primary"
variant="contained"
onClick={handleSubmit}
>
GENERATE
</Button>
</Grid>
</Box>
</Grid>
</form>
</Box>
</div>
);
}
export default App;
――――――――――――――――――――――――――――――――――
★ 過去記事をまとめたマガジン「SHIFTエンジニアが学びながら解説するGo言語」もご参照ください。