LoginSignup
12
10

More than 3 years have passed since last update.

[習作] シングルバイナリでWebサイト作りたかったのでGolangで画像抽出Webアプリ作ってみる

Posted at

こんにちはみなさん

お仕事でWebサイト作るのであれば普通にPHP使うのですが、私のメイン機であるWindowsでかんたんに動かせるWebサイトとなると、環境が心もとないというか、動く環境と作る環境が違うとどうしてもちゃんと動くか不安になるというもの。
そういうときに、環境とか関係なくバイナリで動くWebサイトが作れれば強いなぁって思うわけです。

そんなわけで、Goの練習がてら、Webサイト作ってみます。

課題

まずは、どんなアプリを作るかを考えます。
今回はかんたんだけどいろんな技術を触れそうということで、以下のようなツールを作ってみます。

  • ローカルで動くWebアプリである
  • URLを入力する
  • そのサイトに有る画像を抽出して画面に表示する
  • 気に入った画像を選択してローカルに保存できる
  • シングルバイナリで動く

え、何に使うかって?
そりゃ、あれですよ、あれ。

なんにせよ、作るものを決めたので、実際に作ってみるとしましょう。

成果物

開発環境

まずは開発環境の準備からです。
ちょい前まではmac使ってたんですが、最近は私用PCはもっぱらwindowsであります。
しばらくはinside preview のwsl2のubuntu上で開発してたんですが、最近になってdocker desktopがwindows home に対応してくれたので、それを使って開発しちゃいます。

docker desktop

WSL2を使ってdocker環境を構築できるようになっていて、最近ではwindows home でも動くようになりました。WSL2を有効にした状態でdocker desktopをインストールすればオッケーです。

これの最新のものを使っています。
2.2.2.0以降であれば、homeでも使えます。

VS Code Remote

Golangの開発環境をローカルに直に持つのは、あまり好きではないです。ローカルの環境に依存するコードで書いてしまうと、他のPCで動かそうとして動かないなんてのは多く経験しておりますゆえ。
そこでいつも私がやっているのが、コンテナ環境を通して開発することです。
幸いなことに、VS Codeにはコンテナ環境に潜り込んでの開発が可能な素敵パッケージ(ms-vscode-remote.remote-containers)があります。
今回は開発環境の構築もリポジトリに突っ込むことにしてみましょう。

まず、今回作るリポジトリのルート配下に、.devcontainerというディレクトリを作ります。
ついで、このディレクトリに以下のファイルを設置します。

docker-compose.yml
version: "3"

services:
    workspace:
        image: golang
        command: sleep infinity
        ports:
            - 8081:8080
        volumes:
            - ../:/usr/src/myapp/
devcontainer.json
{
    "name": "image-crawler-with-gin",
    "dockerComposeFile": [
        "docker-compose.yml"
    ],
    "service": "workspace",
    "workspaceFolder": "/usr/src/myapp",
    "settings": {
        "editor.tabSize": 4
    },
    "shutdownAction": "stopCompose"
}

あとは、このディレクトリをVS Code Remote の Remote-container: open folder in containerで開いてあげれば、勝手にdocker-composeでコンテナが立ち上がり、その中に潜り込んでエディタを動かすことができます。

go mod

色んなサイト漁ってみたところ、基本的にgoのモジュール管理はgo modを使えばいいらしい。

$ go mod init github.com/niisan-tokyo/image-crawler

これで準備はオッケーかなとおもいます。

httpサーバの実装

正直なところ、今回はただの習作なので、main.go でだいたい終わらせようと思います。

動作の確認

とりあえず、本当に動くのかだけ確認します。

main.go
package main

import {
    "github.com/gin-gonic/gin"
}

func main() {
    r := gin.Default()
    r.GET("/ping", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "message": "pong",
        })
    })

    r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

こいつを試しに動かしてみます。
動かす前に、webアプリの生成フレームワークとしてginを使っていますので、こいつをダウンロードしておきます。

$ go get github.com/gin-gonic/gin

モジュールがダウンロードされたのを確認したら、以下のコマンドを叩きます。

$ go run main.go

ちょっとトリッキーですが、ポートフォワード使っているので, http://localhost:8081 にアクセスすると、

{"message":"pong"}

こんなのが表示されます。

URL入力ページの作成

それでは本格的に実装していきましょう。
まずはURLを入力するページを作成します。

テンプレートの用意

webページに使うテンプレートを用意します。

templates/form.tmpl
<html>
  <head>
    <meta charset="UTF-8" />
  </head>

<body style="width: 810px;margin: auto;">
<h1>画像スクレイピング</h1>
<form action="scrape" method="GET">
  <input name="url" type="text" />
  <button type="submit">Go!!</button>
</form>
</body
</html>

まあ、なんのひねりもない簡単なhtmlテンプレートですね。

テンプレートファイルをバイナリ化する

今回のアプリはwindows上でシングルバイナリで動いてほしいのですが、このテンプレートを素直に使うと、これらのテンプレートも一緒に持っていく必要があります。
そこで、このテンプレートを予め読み込み済みの状態にしておきます。

今回はrakyll/statikというモジュールを使います。こいつをgo getでインストールすると、statikというコマンドが使えるようになります。
このコマンドを使うことで、特定のディレクトリのファイルをバイナリ(zip)に固めて、プログラムの中で呼び出せるようにしてくれます。

$ statik -src=templates

これでtemplates配下のファイル群を固めたファイルがstatik/statik.goとして出力されます。
このモジュールはinitを実行すればいいだけなので、

main.go
import (
    _ "github.com/niisan-tokyo/image-crawler/statik"
)

こんな感じでアンダースコアを頭につけてimportしておくとその場でinitを実行してくれます。

テンプレートを取り込む

statikは取り込んだファイル群を、仮想のファイルシステム上のファイルとして扱えるようです。
また、html/templateモジュールには、テンプレートファイル名とその内容を登録することができます。これにより、登録済みのテンプレートファイルについては、外部のファイルを見に行かず、登録した内容をそのまま返すようになります。
くどくなりましたが、これを関数化すると次のようになります。

main.go
//...

func loadTemplate() (*template.Template, error) {
    t := template.New("")
    statikFS, err := fs.New()
    if err != nil {
        return nil, err
    }

    // 仮想ファイルシステム上を走査する
    err = fs.Walk(statikFS, "/", func(path string, info os.FileInfo, err error) error {
        // ディレクトリはスキップ
        if info.IsDir() {
            return nil
        }
        r, err := statikFS.Open(path)
        if err != nil {
            return err
        }
        defer r.Close()

        // データ読み出し
        h, err := ioutil.ReadAll(r)
        if err != nil {
            return err
        }

        // テンプレートにpath名で読み出したデータをパースして格納
        t, err = t.New(path).Parse(string(h))
        if err != nil {
            return err
        }

        return nil
    })

    return t, err
}

ここではじめのstatikFSは先程取り込んだtemplates配下のファイル群についてのファイルシステムであり、fs.Walk()で、そのファイルシステムを走査しています。
そして、各ファイルについて、データを取り出し、ファイルのパス名でテンプレートの中身を登録していくという処理になっています。

フォームを表示する

長かったですが、ようやくここでフォームを表示するに至ります。
先程バイナリ化したファイル群を取り込んだテンプレートをginに読み込ませ、レスポンスとして返します。

main.go
import (
    "html/template"
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/rakyll/statik/fs"

    //"fmt"
    "io/ioutil"
    "os"

    _ "github.com/niisan-tokyo/image-crawler/statik"
)

//....中略

     t, err := loadTemplate()
    if err != nil {
        panic(err)
    }
    r.SetHTMLTemplate(t)
    r.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "/form.tmpl", gin.H{})
    })

    r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")

これでgo run main.goを実行してからhttp:/localhost:8081にアクセスすると、以下のようなサイトが表示されます。
scrape.png

画像抽出機の作成

さて、ここまででURLを入力するところまでやりました。
次は入力されたURLのWEBサイトを読み込み、画像部分を抽出して描画する装置を作成します。

クローラー

私はGolangについては素人もいいところなので、今回使うクローラーも、適当に調べて、スター数が多かったものを使うことにしました。
それがこれから使用する gocolly/collyです。
まず、サーバ側の動作ですが、GETパラメータでurlを取得し、そのurlにcollyでアクセスして、画像を引っこ抜いてくるようにします。

main.go
//...
type imageLinks struct {
    Links []string
}
//...

    r.GET("scrape", func(c *gin.Context) {
        url := c.Query("url")
        p := &imageLinks{Links: []string{}}

        coll := colly.NewCollector()
        coll.OnHTML("img", func(e *colly.HTMLElement) {
            src := e.Request.AbsoluteURL(e.Attr("src"))
            if src != "" {
                p.Links = append(p.Links, src)
            }
        })
        coll.Visit(url)
        c.HTML(http.StatusOK, "/scrape.tmpl", gin.H{
            "links": p.Links,
        })
    })

ここで、挙動は以下のとおりです。

  1. URL入力ページからurlを受け取る ( url := c.Query("url") )
  2. collyのクローラーを作成し、取得したHTML上でimgタグの要素を抽出するルールを書く ( coll.OnHTML("img", func... )
  3. imgタグのsrc属性のurlを配列に貯める (p.Links = append(p.Links, src) )
  4. ルールが決まったので、クローラーを指定されたurlに走らせる( coll.Visit(url) )
  5. テンプレートに画像リストを出力する

テンプレートの作成

テンプレートは以下のとおりです

templates/scrape.tmpl
<html>
  <head>
    <meta charset="UTF-8" />
  </head>

<style>
.selected {
  border: solid 4px;
}
</style>
<body style="width: 810px;margin: auto;">
<h1>画像スクレイピング</h1>
<form action="scrape" method="GET">
  <input name="url" type="text" />
  <button type="submit">Go!!</button>
</form>

<form action="save" method="POST">
  <button type="submit">画像を保存する</button>
  {{range .links}}
  <div>
    <div>
      <label class="imgs">
        <input type="checkbox" value="{{ . }}" name="urls[{{ . }}]" />
        {{ . }}<br>
        <img style="max-width: 1000px;" src="{{ . }}" />
      </label>
    </div>
  </div>
  {{ end }}
  <button type="submit">画像を保存する</button>
</form>
<script>
elms = document.getElementsByClassName('imgs')
for (var i=0, len = elms.length|0; i<len; i=i+1|0) {
  var elm = elms[i]
  elm.addEventListener('click', function(e) {
    var t = e.currentTarget.childNodes[1].checked
    console.log(e.currentTarget.childNodes[1])
    if (t) {
      e.currentTarget.parentNode.classList.add('selected')
    } else {
      e.currentTarget.parentNode.classList.remove('selected')
    }
  })
}
</script>
</body>
</html>

...まあ、フロント弱々くんなので、許して。
このテンプレートはhtml/templateというやつで、twigに近い動きをするテンプレートエンジンです。
画像のリンク集を渡して、順次描画するようになっています。
例によってstatik -src=templates -fをやってからgo run main.goでサーバを立ち上げます。
適当なURLとして、ここでは私の書いた記事を使ってみましょう。

すると、こんなページが出ます。

scrape2.png
このページは画像集が表示されており、画像のある領域をクリックすると、枠がついてチェックボックスにチェックが付きます。枠がついているときはクリックするとチェックが外れ、枠も取れます。
ここでチェックしたものが最後の保存動作にて保存される画像となります。

画像保存機能

妙に長引いた実装もこれで最後となります。
前ページより保存したい画像のリストが届きますので、これを適当なディレクトリに放り込みます。
まず、動作を言語化します。

  1. 対象の画像のリストをmap形式で取得する
  2. 各画像urlから画像をhttpで取得
  3. 取得した画像を適当なディレクトリ(dist)に名前をつけて( {ランダム文字列} + {番号} + {mimeタイプごとの拡張子} ) 保存する

これをコード化すると以下のようになりました。

main.go
func SaveImage(imgs map[string]string) {
    os.Mkdir("dist", 0777)
    random := "dist/" + RandomString(10)
    i := 0
    for _, val := range imgs {
        i++
        response, err := http.Get(val)
        if err != nil {
            panic(err)
        }
        defer response.Body.Close()
        ext, _ := mime.ExtensionsByType(response.Header.Get("Content-Type"))
        name := random + strconv.Itoa(i) + ext[0]

        file, err := os.Create(name)
        if err != nil {
            panic(err)
        }
        io.Copy(file, response.Body)
    }
}

const randomLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func RandomString(n int) string {
    b := make([]byte, n)
    for i := range b {
        b[i] = randomLetters[rand.Intn(len(randomLetters))]
    }
    return string(b)
}

みんなもっときれいに書けるんだろうけど、まあ、こんなところでいいでしょう。
あとは、main関数の方にサーバの受け口を用意して完了です。

main.go
    // 画像を保存して戻る
    r.POST("save", func(c *gin.Context) {
        SaveImage(c.PostFormMap("urls"))
        c.HTML(http.StatusOK, "/complete.tmpl", gin.H{})
    })

テンプレートはこんな感じ

templates/complete.tmpl
<html>
  <head>
    <meta charset="UTF-8" />
  </head>

<body style="width: 810px;margin: auto;">
<h1>完了!!</h1>
<h2>更に続ける</h2>
<form action="scrape" method="GET">
  <input name="url" type="text" />
  <button type="submit">Go!!</button>
</form>
</body>
</html>

というわけで、適当な画像を保存しましょう。
例によってstatik -src=templates -fしてgo run main.goやってみます。

scrape3.png

画像も保存されています。

scrape4.png

シングルバイナリでコンパイル!

ここまでは、全てコンテナ上のgoコマンドでの実行になっていました。必要な機能が一通り作られたので、最後にwindows用のバイナリファイルを作ってしまいましょう。

$ GOOS=windows go build -v 

これでwindows用のシングルバイナリwebサーバができました。
image-crawler.exeという実行ファイルができていますので、こいつをwindows上で実行することで、WEBサイトが立ち上がります。
これまではlocalhost:8081 で接続していましたが、シングルバイナリwebサーバは起動したあと、localhost:8080 で接続できます。

まとめ

というわけで、Golangでシングルバイナリの画像抽出Webアプリを作ってみました
いや、軽い気持ちで作ったのですが、かなり覚えることが多かった印象です。
今回の習作でためになったなぁっていうのは以下の項目でしょうか。

  • VS Code Remote Container の環境作成
  • go mod を使ったパッケージ管理
  • html/template の書き方
  • リソースやテンプレートをシングルバイナリに含める方法

importとか端折った部分もありますので、完成品はリポジトリの方を見てください。
https://github.com/niisan-tokyo/image-crawler

今回はこんなところです。

参考

gin (Web フレームワーク)
colly (クローラー)
statik (シングルバイナリ)

12
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
10