20190625のGoに関する記事は6件です。

MongoDBに挿入された時刻の仕様調査: MongoDB内の時刻はUTC(タイムゾーンなし)・msecまで

はじめに

GoでMongo周りのコーディングをしてた際に、「TimestampをMongoDBに詰めると値が変わってしまう」って話が出てたので、仕様を確認しました。

Mongoの時刻に関する挙動: time.Timeと情報が異なる

例えば以下のようなMongoDBにtime.Timeで時刻を取って、Mongoにinsert, findしてみます。

main.go
package main

import (
        "fmt"
        "time"

        "github.com/globalsign/mgo"
        "github.com/globalsign/mgo/bson"
        "github.com/google/uuid"
)

type MyDoc struct {
        ID   uuid.UUID
        Time time.Time
}

func main() {
        dbname := "sampleDocument"
        connection, _ := mgo.Dial("mongodb://localhost/" + dbname)
        db := connection.DB(dbname)

        //元ネタとなる時刻取得
        doc := MyDoc{uuid.New(), time.Now()}
        fmt.Printf("data in time.Time:[%v]\n", doc)
        //insert
        collection := db.C("doc")
        collection.Insert(doc)

        //中身の確認
        var getFromMongo MyDoc
        collection.Find(bson.M{"id": doc.ID}).One(&getFromMongo)
        fmt.Printf("data in Mongo:[%v]\n", getFromMongo)
}

上記の実行結果はこんな感じ。

項目 time.Time.Now() Mongo
精度 nanosecond millisecond
タイムゾーン JST UTC
$ go run main.go
data in time.Time:[{b7350e5d-05d0-429a-9d51-1eb365e71d76 2019-06-25 23:11:36.706508427 +0900 JST m=+0.001236469}]
data in Mongo:[{b7350e5d-05d0-429a-9d51-1eb365e71d76 2019-06-25 14:11:36.706 +0000 UTC}]

実際のMongoDBに入っている情報を確認しても情報が消えています。つまりmongo自体の仕様か、可能性は低そうですがGolangのinsert部分に関するmongo用ドライバの問題である可能性があります。
ISODateという形式で入っていることから、mongoの仕様として変換してるんだろうなということが予想されます。

> db.doc.find()
{ "_id" : ObjectId("5d122aa6bf374055deac672e"), "id" : BinData(0,"ugCCZy0ERyWRZRk+V2fxIw=="), "time" : ISODate("2019-06-25T14:07:34.331Z") }

Mongoの時刻仕様

Mongoでは、BSONと呼ばれるデータフォーマットでデータを保存しています。その仕様を確認すると以下のようになっています。
下記で分かるように1. タイムゾーンはUTC, 2. データの精度はmillisecondとなります。

UTC datetime - The int64 is UTC milliseconds since the Unix epoch.

MongoDBのDate仕様も確認。Date()の記載より

new Date("YYYY-mm-ddTHH:MM:ss") specifies the datetime in the client’s local timezone and returns the ISODate with the specified datetime in UTC.

実行時にタイムゾーン指定がない場合は、マシンのタイムゾーンで計算⇒UTC時刻を返却としていますね。

同様にBehaviorより、millisecondなのが分かります。

Internally, Date objects are stored as a signed 64-bit integer representing the number of milliseconds since the Unix epoch (Jan 1, 1970).

というわけで、以下が仕様であることが分かりました。

項目 time.Time.Now() Mongo
精度 nanosecond millisecond
タイムゾーン JST UTC

参考

BSON Specification Version 1.1
MongoDB Date()
Stack overflow: Nanoseconds lost coming from MongoDB ISODate Object
mongoのISODateのtimezone問題に対処する

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

go moduleを導入した時にinttelijで設定する事

背景

gomoduleを入れてからintellijで「モジュールが見つかりません」的なエラーが出るようになった。
go getをしても下記のようなエラーが出る。

$ go get github.com/xxxxd/xxxxxd
go get: warning: modules disabled by GO111MODULE=auto in GOPATH/src;
        ignoring go.mod;
        see 'go help modules'

どうすれば良いか

go moduleでインストールされたパッケージは$GOPATH/pkg/に置かれるらしい。

なのでパッケージを読み込んでいる場所を変えてあげる必要がある。

preferences > 言語 & フレームワーク > Go > Go module(vgo)
で下記のチェックボックスを入れるとちゃんと読み込んでくれる様になりました。

image.png

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

インメモリDBを使ってRDBアクセスのあるコードをユニットテストする(Golang+Gormを例に)

はじめに

Webアプリ等のコードをユニットテストする際に、RDBアクセスがあるロジックをどのようにテストするかは、チームにより方針が分かれがちな問題だと思います。

いろいろなやり方があり、それぞれに賛否両論があるらしいのはなんとなく知っています。
その中で、主に機能性をテストしたいユニットテストにおいては、私個人的にはインメモリDBを使ってやる方法がよい(場合が多い)と思っています。インメモリDBとは、その名の通りメモリ内だけで動くDBのことです。

理由としては、

  • 1つのテストに閉じた世界でデータを扱うことができる
    • テストの内容が1箇所にまとまっていて見やすい
    • 他のテストとの兼ね合いを気にしなくてよい
  • 環境構築不要
  • 早い?(たぶん)

と考えるからです。

これをどのように実現するかについて、この記事で書いていこうと思います。

SQLiteのインメモリDBを使う

私自身、最近まで知らなかったのですが、SQLiteにはインメモリDBの機能があります。

SQLite公式ドキュメントより引用

If the filename is ":memory:", then a private, temporary in-memory database is created for the connection. This in-memory database will vanish when the database connection is closed.

この文章に、すべての説明が凝縮されています。

これを試すために、Golang+Gormを題材にして簡単なCRUDを書き、そのインメモリDBを使ってユニットテストを書いてみようと思います。

サンプルアプリ:Golang+GormでのシンプルCRUD

まず、テーブルのレコードをマッピングするための構造体を定義します。

crud.go
type Todo struct {
    ID       int    `gorm:"AUTO_INCREMENT;primary_key"`
    Category string `gorm:"not null;size:8"`
    Content  string `gorm:"not null;size:255"`
}

シンプルなCRUD用の関数を作ります。
短くするために、ここではCreate/Deleteを省略してRead/Updateのみ記載します。

crud.go
func Read(db *gorm.DB, todo *Todo, id int) error {
    return db.Find(todo, "id = ?", id).Error
}

func Update(db *gorm.DB, id int, category string, content string) error {
    return db.Model(&Todo{}).Where("id = ?", id).Updates(&Todo{Category: category, Content: content}).Error
}

ここでは、RDBアクセスをするための *gorm.DB 型の変数を引数で受け取るようにしています。

*gorm.DB 型の変数を手に入れる方法についても色々あるとは思います。
ただ、DI(Dependency Injection)的に受け取れるようにしておき、具体的なRDBや接続先などは外から指定できるようにしておくほうが、少なくともユニットテストの観点からは楽だと思います。

サンプルのユニットテスト

まず、インメモリDBを作成する関数です。
SQLiteドキュメントの指示どおりにファイル名の部分を:memory:と指定します。

crud_test.go
func createInMemoryDb(t *testing.T) *gorm.DB {
    db, err := gorm.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatalf("db open error: %v", err)
    }
    return db
}

このようにして具体性を与えた*gorm.DB型の変数を、テスト対象の関数に食わせてユニットテストをしていきます。

Read/Updateに対するユニットテストです。

crud_test.go
func TestRead(t *testing.T) {
    db := createInMemoryDb(t)
    defer db.Close()
    fatalIfError(t, db.AutoMigrate(&Todo{}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "first", Content: "first todo"}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "second", Content: "second todo"}).Error)

    var todo Todo
    fatalIfError(t, Read(db, &todo, 1))

    assertEqual(t, "first", todo.Category)
    assertEqual(t, "first todo", todo.Content)
}

func TestUpdate(t *testing.T) {
    db := createInMemoryDb(t)
    defer db.Close()
    fatalIfError(t, db.AutoMigrate(&Todo{}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "first", Content: "first todo"}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "second", Content: "second todo"}).Error)

    fatalIfError(t, Update(db, 1, "first:updated", "first todo:updated"))

    var first, second Todo
    fatalIfError(t, db.Find(&first, "id = 1").Error)
    fatalIfError(t, db.Find(&second, "id = 2").Error)
    assertEqual(t, "first:updated", first.Category)
    assertEqual(t, "first todo:updated", first.Content)
    assertEqual(t, "second", second.Category)
}

func fatalIfError(t *testing.T, err error) {
    if err != nil {
        t.Fatalf("fatal: %v\n", err)
    }
}

func assertEqual(t *testing.T, expected interface{}, actual interface{}) {
    if expected != actual {
        t.Errorf("test failure. expected %v, actual: %v\n", expected, actual)
    }
}

テスト関数には、コードの塊が3つあり、

  • DB/テーブルの準備
  • テスト対象の関数の実行
  • 実行結果の確認

をやっているのが見て取れると思います。

また、最初に挙げた、

  • テストに閉じた世界でデータを扱うことができる
    • テストの内容が1箇所にまとまっていて見やすい
    • 他のテストとの兼ね合いを気にしなくてよい
  • 環境構築不要

も感じることができると思います。

個人的な見解

この方法はもちろん万能ではない、です。

SQLiteを使っているので、RDBのとあるプロダクトにしかない機能や仕様に依存している場合には、この方法は使うことはできません。
また、プロジェクトによっては、ローカルPC上に1つ、ユニットテスト用のDBを立ててしまう方が楽な場合もあるかもしれません。
テストの興味の対象を考えると、モックを使ってやってしまったほうが断然早いこともあるでしょう。

しかし、必ずしもすべてを統一方針でやらなくてよい場合がほとんどだと思います。
その際は、適材適所の1つとして選択するのもありです。

また、これはGolang特有のものではなく、SQLiteの機能なのでいろいろな言語から使えるはずです。

最後に

最後まで読んでいただき、ありがとうございました!

今回のサンプルコードの全体はGithubに置いてあります。よろしければ参考にしてください。

また、この点がよくない、もっと良い方法がある等ありましたら、コメント歓迎です!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SQLiteのインメモリDBを使ってユニットテストする(Golang+Gormを例に)

はじめに

Webアプリ等のコードをユニットテストする際に、RDBアクセスがあるロジックをどのようにテストするかは、チームにより方針が分かれがちな問題だと思います。

いろいろなやり方があり、それぞれに賛否両論があるらしいのはなんとなく知っています。
その中で、主に機能性をテストしたいユニットテストにおいては、私個人的にはインメモリDBを使ってやる方法がよい(場合が多い)と思っています。インメモリDBとは、その名の通りメモリ内だけで動くDBのことです。

理由としては、

  • 1つのテストに閉じた世界でデータを扱うことができる
    • テストの内容が1箇所にまとまっていて見やすい
    • 他のテストとの兼ね合いを気にしなくてよい
  • 環境構築不要
  • 早い?(たぶん)

と考えるからです。

これをどのように実現するかについて、この記事で書いていこうと思います。

SQLiteのインメモリDBを使う

私自身、最近まで知らなかったのですが、SQLiteにはインメモリDBの機能があります。

SQLite公式ドキュメントより引用

If the filename is ":memory:", then a private, temporary in-memory database is created for the connection. This in-memory database will vanish when the database connection is closed.

この文章に、すべての説明が凝縮されています。

これを試すために、Golang+Gormを題材にして簡単なCRUDを書き、そのインメモリDBを使ってユニットテストを書いてみようと思います。

サンプルアプリ:Golang+GormでのシンプルCRUD

まず、テーブルのレコードをマッピングするための構造体を定義します。

crud.go
type Todo struct {
    ID       int    `gorm:"AUTO_INCREMENT;primary_key"`
    Category string `gorm:"not null;size:8"`
    Content  string `gorm:"not null;size:255"`
}

シンプルなCRUD用の関数を作ります。
短くするために、ここではCreate/Deleteを省略してRead/Updateのみ記載します。

crud.go
func Read(db *gorm.DB, todo *Todo, id int) error {
    return db.Find(todo, "id = ?", id).Error
}

func Update(db *gorm.DB, id int, category string, content string) error {
    return db.Model(&Todo{}).Where("id = ?", id).Updates(&Todo{Category: category, Content: content}).Error
}

ここでは、RDBアクセスをするための *gorm.DB 型の変数を引数で受け取るようにしています。

*gorm.DB 型の変数を手に入れる方法についても色々あるとは思います。
ただ、DI(Dependency Injection)的に受け取れるようにしておき、具体的なRDBや接続先などは外から指定できるようにしておくほうが、少なくともユニットテストの観点からは楽だと思います。

サンプルのユニットテスト

まず、インメモリDBを作成する関数です。
SQLiteドキュメントの指示どおりにファイル名の部分を:memory:と指定します。

crud_test.go
func createInMemoryDb(t *testing.T) *gorm.DB {
    db, err := gorm.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatalf("db open error: %v", err)
    }
    return db
}

このようにして具体性を与えた*gorm.DB型の変数を、テスト対象の関数に食わせてユニットテストをしていきます。

Read/Updateに対するユニットテストです。

crud_test.go
func TestRead(t *testing.T) {
    db := createInMemoryDb(t)
    defer db.Close()
    fatalIfError(t, db.AutoMigrate(&Todo{}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "first", Content: "first todo"}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "second", Content: "second todo"}).Error)

    var todo Todo
    fatalIfError(t, Read(db, &todo, 1))

    assertEqual(t, "first", todo.Category)
    assertEqual(t, "first todo", todo.Content)
}

func TestUpdate(t *testing.T) {
    db := createInMemoryDb(t)
    defer db.Close()
    fatalIfError(t, db.AutoMigrate(&Todo{}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "first", Content: "first todo"}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "second", Content: "second todo"}).Error)

    fatalIfError(t, Update(db, 1, "first:updated", "first todo:updated"))

    var first, second Todo
    fatalIfError(t, db.Find(&first, "id = 1").Error)
    fatalIfError(t, db.Find(&second, "id = 2").Error)
    assertEqual(t, "first:updated", first.Category)
    assertEqual(t, "first todo:updated", first.Content)
    assertEqual(t, "second", second.Category)
}

func fatalIfError(t *testing.T, err error) {
    if err != nil {
        t.Fatalf("fatal: %v\n", err)
    }
}

func assertEqual(t *testing.T, expected interface{}, actual interface{}) {
    if expected != actual {
        t.Errorf("test failure. expected %v, actual: %v\n", expected, actual)
    }
}

テスト関数には、コードの塊が3つあり、

  • DB/テーブルの準備
  • テスト対象の関数の実行
  • 実行結果の確認

をやっているのが見て取れると思います。

また、最初に挙げた、

  • テストに閉じた世界でデータを扱うことができる
    • テストの内容が1箇所にまとまっていて見やすい
    • 他のテストとの兼ね合いを気にしなくてよい
  • 環境構築不要

も感じることができると思います。

個人的な見解

この方法はもちろん万能ではない、です。

SQLiteを使っているので、RDBのとあるプロダクトにしかない機能や仕様に依存している場合には、この方法は使うことはできません。
また、プロジェクトによっては、ローカルPC上に1つ、ユニットテスト用のDBを立ててしまう方が楽な場合もあるかもしれません。
テストの興味の対象を考えると、モックを使ってやってしまったほうが断然早いこともあるでしょう。

しかし、必ずしもすべてを統一方針でやらなくてよい場合がほとんどだと思います。
その際は、適材適所の1つとして選択するのもありです。

また、これはGolang特有のものではなく、SQLiteの機能なのでいろいろな言語から使えるはずです。

最後に

最後まで読んでいただき、ありがとうございました!

今回のサンプルコードの全体はGithubに置いてあります。よろしければ参考にしてください。

また、この点がよくない、もっと良い方法がある等ありましたら、コメント歓迎です!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[備忘録]初めてGo言語を触ってみた(導入から初めてのHelloworldへ)

参考にさせて頂いた資料
スターティングGo言語 著:松尾愛賀 翔泳社(Amazonページ)
初めてのGo!インストールまで
Go + Visual Studio Codeの開発環境を作る
Goでpackageのimportのやり方(自分用に整理)

現在プログラミングスクールに通っているのですが、今回個人アプリ作成という期間があったので、前々から興味のあったGoに触れてみようと思いました。(まだ何作るか決めてません)
※尚自分向けアウトプットがメインなので、情報が間違いだらけの可能性ありと思ってください。
僕の頭の中ではこんな感じだよ位のイメージです。

最初は「スターティングGo言語」を見ながら、進めていたのですが、qiitaで調べた所、カリキュラムで既に導入していたHomebrewを使えば簡単にGoのインストールが出来るということでやってみました。

Homebrewを使用したGoインストール

$ brew install go    //Homebrewを使ってGoをインストール
$ go version        //Goが正常にインストールされていれば、これでバージョン確認が出来ます

Hellowworldの流れ

sample.go
package main    //Goでは1ディレクトリに1パッケージ、1パッケージの中に関数や変数は属すルールがあります。
                //なのでまずパッケージを宣言しないことには始まりません。
import(
  "fmt"         //importでは公式のパッケージや別ディレクトリで宣言したパッケージをimportします。
)               //別のパッケージの関数・変数を使いたい時にimportします。          
                //今回はPrintln関数を使うのでfmtパッケージをimportしてます。               

func main(){              //func + 関数名(){ } == 関数定義
  Println("hello world")  //Println("文字列") => "文字列"の出力
}

go run sample.go  //Goの起動コマンド

// => hello workd

次にGOPATHを通します。

GOPATHとは&GOPATH設定

Goを使った開発をする際の作業ディレクトリのこと
(つまり"GOPATH"に設定したフォルダ内にアプリケーションのデータを置いて、コーディングしましょうということ)
これがないと使えるツールが少ないそうで、とにかく100%の力を使いたいので導入します。
設定するにあたり、環境変数を初めてイジりました(恐かったぁ)

GOPATHとして設定するフォルダの場所はどこでもいいらしい(という話をよく聞く)
自分は無難にホームディレクトリにgoフォルダ作成しようとしましたが、既にありました。
自分はVSCでコーディングしているのですが、HelloworldをGoで作成した際、Go書くときに便利なアドインあるよ!みたいな表示が出たので「install」押したんですよね。多分それの影響で生成されてる。
ということで既にあるフォルダをGOPATHに設定しました。

vi ~/.bash_profile              //環境変数をファイルを開きます
export GOPATH=$HOME/go           //ホームディレクトリ内goフォルダをGOPATHへ代入(定義) 
export PATH=$PATH:$HOME/go/bin   //ホームディレクトリ内goフォルダ内binフォルダをパスへ追加

これで取り敢えず準備できました。
次は基礎文法を勉強していきます。
(revelに挑んだんですが、ほぼ読めなかったので)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初心者卒業試験のブラックジャック作成に挑戦してみました(Go編)

はじめに

「プログラミング入門者からの卒業試験は『ブラックジャック』を開発すべし」という記事を見て、勉強中のGo言語で試してみました。
※本業は今時珍しいコボラーです。

1 カードの扱い

元の記事、Haskellで実装した方Kotlinで実装した方などとは変えています。
・カードは0~51で表す
・13で割った商をカードの種類に割り当て
・13で割った余り+1を数字に割り当てました。
こうすることでカード自体を1次元で割り当て可能です。
またネックになりやすい重複がない等の確認がしやすい他、処理も簡単になると考えました。
また0~51(以降カード番号)のカード番号からカードの種類、数字は容易に求めることができます。
なお、商とカード種類は
・0:ハート
・1:ダイヤ
・2:スペード
・3:グラブ
に割り当てました(根拠はありません。適当です。)
ここまでの実装は以下の通り。

/* カードの数字を取得 */
func getNumber(cardNo uint8) uint8 {
    ret := cardNo%13 + 1
    return ret
}

/* カードのマークを取得 */
// 0:ハート
// 1:ダイヤ
// 2:スペード
// 3:グラブ
func getMarkNo(cardNo uint8) uint8 {
    ret := cardNo / 13
    return ret
}

/* カードのマーク(名称)を取得 */
func getMarkName(markNo uint8) string {

    var ret string = ""
    switch markNo {
    case 0:
        ret = "ハート"
    case 1:
        ret = "ダイヤ"
    case 2:
        ret = "スペード"
    case 3:
        ret = "クラブ"
    }
    return ret
}

2 手札の点数確認

エースを1、11どちらでもできるようにしないといけません。
実装はいったん11として加算し、21を超えた場合に最大エースの枚数分まで10を引くループで実現しました。

/* 手札から得点を取得 */
func getPoint(tefuda []uint8) uint8 {
    var cntAce uint8 = 0
    var sumP uint8 = 0
    for i := 0; i < len(tefuda); i++ {

        switch {
        case getNumber(tefuda[i]) == 1:
            sumP += 11
            cntAce++
        case getNumber(tefuda[i]) >= 2 && getNumber(tefuda[i]) <= 10:
            sumP += getNumber(tefuda[i])
        case getNumber(tefuda[i]) >= 11:
            sumP += 10
        }
    }

    // エース独自処理。いったん11とカウントしておき、
    // 最大でエースの枚数分10を引けるようにする
    for i := 0; i < int(cntAce); i++ {
        if sumP > 21 {
            sumP = sumP - 10
        }
    }
    return sumP
}

3 山積みカードの作成

やり方をさんざん考えて(カード番号、ランダム数)のスライスを作り
そのあとランダム数でソートしカード番号をランダムに並べました。
ランダム数は適当に10000程度としました。このくらいあればまず重複しないだろうと。
Sortは・・・はまりました。
sort.Sortで実行しようとして何度やってもエラー・・・
結局sort.Sliceという方法があると知り試したらできました。
Sortができたらそれを1次元のスライスに渡して山積みの完成です。

// カードをシャッフルさせるために使用
// 独自の構造体
type Card struct {
    idx       uint8
    intRandum int32
}
type allCard []Card


/* カードをシャッフルした山積みを取得 */
func shufleCard() []uint8 {

    wkyama := make([]Card, 52)
    var i uint8
    retyama := make([]uint8, 52)

    // idxには連番を(0~51)を設定する
    // これをカードと見立てる。
    // intRandumにはランダムの数字を設定する。
    rand.Seed(time.Now().UnixNano())
    for i = 0; i < 52; i++ {
        wkyama[i].idx = i
        wkyama[i].intRandum = rand.Int31n(10000)
    }

    //ソート
    sort.Slice(wkyama, func(i, j int) bool { return wkyama[i].intRandum < wkyama[j].intRandum })
    for j := 0; j < 52; j++ {
        //  fmt.Printf("idx-%d  val-%d\n", j, wkyama[j].idx)
        retyama[j] = wkyama[j].idx
    }
    return retyama
}

4 カードをめくる処理

カードをめくる処理は山積みからめくった場合、スライスを削除するのではなくめくられた箇所のカード番号に99を入れる方法としました。
(例)

山積み (13,12,44,・・・) (99,12,44,・・・)
手札 (05,40) (05,40,13)

実際のソースはこちら

/* 山積みからカードを1枚取得し手札に加える */
func getCard(tefuda []uint8, yama []uint8) ([]uint8, []uint8) {
    // 引いたあとは99としておく。
    for i := range yama {
        if yama[i] == 99 {
            continue
        } else {
            tefuda = append(tefuda, yama[i])
            yama[i] = 99
            break
        }
    }
    return tefuda, yama
}

5 その他

全体のソースはこちら

6 感想

ソートでかなり時間がかかりました。
非に少しづつだったにせよ全体で20時間くらいかかったかもしれません。
最低でも1/3はソートです。

カードの扱いは多分他の人のやり方より楽だと思っています。

7 参考

「プログラミング入門者からの卒業試験は『ブラックジャック』を開発すべし」
初心者卒業試験のブラックジャック作成に挑戦してみました(Haskell編)
Kotlinでブラックジャック作ってみた
ランダム数の取得
スライスソート

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む