Go言語の並行処理に関する公式ドキュメント

背景

goroutineなんもわからん。書くことができても使いこなすことができてるとは言い難いなあと日々感じている。Goは並行処理の理解の助けのため、リポジトリwikiにたくさんの資料がある。

資料を全部読んだ上で解説したいものだが、一部しか読めていない。今回はwikiにある資料を眺めてその傾向がわかったので、Goの並行処理関連資料のガイドラインとして活用していきたい。

wiki

資料がレベルで分かれているため、各レベルがどの程度の内容を扱っているのか確認しようと思う。

LearnConcurrency · golang/go Wiki · GitHub

Beginner

ざっと眺めたところ公式ドキュメント詰め合わせパックって感じだった。普段Goを書いている人でもこれらの資料を全部理解している人は少なそう。きちんと読めば他のGopherと比べて頭一つ抜けそうな気がした。

言語仕様からGoの並行性へのコンセプト、あとはgoroutineが何であるか、という話が多い。Beginnerレベルの資料を読み込めばgroutineの輪郭が掴めるだろうと思う。

Do not communicate by sharing memory; instead, share memory by communicating.

これを言えるとカッコいい。ここぞというタイミングで詠唱しよう。

Intermediate

goroutineでの実装パターンが多い印象。下記に記載されていたポストをいくつか載せておく。

これらの実装パターンを読めば並行処理の引き出しが増えそう。また、他人の書いた並行処理に関して良い悪いの分別もつけられるようになりそう。

さらに、並行処理を使った際に必ず付き合うことになる競合のサポートツールであるrace-detectorの資料もあった。

Introducing the Go Race Detector - The Go Blog

かなり実践的。このレベルを押さえると普段の業務で並行処理の実装で困ることはほとんどなさそう。

Advanced

メモリモデルやスケジューラ、チャンネルについて、それぞれどのようなメカニズムなのか解説している。

ここまでいくとメモリ上でGoの並行性がどのように動いているか理解できる。ただ資料を読めていないので何も言うことがない。内容は難しそうだけど面白そうなので時間を作って読みたい。

Additional Go Programming Wikis

wikiに「mutexかchannelか」というページがある。この話題はよく行われるし実装時にも意識すべきことなのでしっかりと理解したい。

下記に引用するが、楽しいからってchannelやgoroutineを多用するな、という点は本質的だと思う。

A common Go newbie mistake is to over-use channels and goroutines just because it's possible, and/or because it's fun.

そして、どちらを使うのかの判断基準はsimpleさである、という点も常に頭に入れておきたい。

Use whichever is most expressive and/or most simple.

MutexOrChannel · golang/go Wiki · GitHub

まとめ

wikiにある各レベルを整理するとこんな感じだろうか。

  • Beginner:Goの並行性は何なのか
  • Intermediate:Goの並行性をどのように使うか
  • Advanced:Goの並行性はどのように動くか

Goの並行処理について何か知りたいことが生まれた場合、上記に照らし合わせてwikiに貼ってあるリンクを読んでいくと良いのかなと思った。

雑談

タイミング的に2021年からになってしまったけれど、これからは目に見えるアウトプットをしっかりと残していきたいなと思ってブログの投稿を再開した。
ただ、普段から書いていたわけじゃないので生産体制が整わない中、Notionを使って投稿タイトルと簡単な骨子、公開スケジュールなどを組んでいくと自然と逆算して資料読んだり検証できるようになった。Notionは偉大。

サーバーサイドGo入門 #4 ~test~

前回はこちら prelude.hatenablog.jp

今回作るもの

gingormを使ったTODOアプリが完成しました。今回はこれらのテストを行います。

基本的には公式のtestingパッケージを使いますが、DBのモック用にgo-sqlmockを使用します。
また、net/httpのテスト用のUtilであるnet/test/httptestも使用します。

testing

標準パッケージであるtestingは非常に便利ですが、アサーションやutilityがあまり充実していません。これは未開発なのではなく、意図的に開発していない機能になります。

理由には、アサーションやUtility、フレームワークなどに頼ると、正しいエラーハンドリングや正しいエラー報告について考慮せずにテストを書いてしまうということです。さらに、テストフレームワークは独自の発展によりミニ言語のようなものが生まれてしまう傾向にある、という厳しい言及もあります。

以上のことは下記のリンクにて説明されています。

アサーションがないことによって記述が冗長になると懸念されるかもしれません。しかしGoではテスト対象の入力値と期待値をデータ構造として定義し、そのリストを作成してループで回してテストを行うテーブルドリブンテストというものが紹介されています。

TableDrivenTests · golang/go Wiki · GitHub

HTTPサーバーのテスト

net/http/httptest

net/http/httptestには下記のような定義があります。

func NewRequest(method, target string, body io.Reader) *http.Request
type ResponseRecorder struct
type Server struct { ... }

1つ目のNewRequestと3つ目のServerはなんとなくわかります。では、ResponseRecorderとは何でしょうか?

定義を見てみると下記のようになっています。

func NewRecorder() *ResponseRecorder
func (rw *ResponseRecorder) Flush()
func (rw *ResponseRecorder) Header() http.Header
func (rw *ResponseRecorder) Result() *http.Response
func (rw *ResponseRecorder) Write(buf []byte) (int, error)
func (rw *ResponseRecorder) WriteHeader(code int)
func (rw *ResponseRecorder) WriteString(str string) (int, error)

これらの関数によりhttp.ResponseWriterinterfaceを満たしています。テストで使う際にはこのResponseRecorderを通じて結果を書き込んでもらい、それを検証すれば良いだろうということがわかります。

改めてnet/http/httptestの全体観としては、RequestResponseServer、というすごくシンプルなものであるとわかります。

httptest - The Go Programming Language

DBのテスト

DATA-DOG/go-sqlmock

これはsql-driverのモックを提供してくれます。

github.com

モックの初期化のI/Fは下記のようになっています。

func New(options ...func(*sqlmock) error) (*sql.DB, Sqlmock, error)
func NewWithDSN(dsn string, options ...func(*sqlmock) error) (*sql.DB, Sqlmock, error)

戻り値のモックを利用して期待するSQLを記述しておき、最後にExpectationsWereMetで検証します。
また、下記のサンプルの通り、トランザクションの開始・終了も記述可能です。

// Open new mock database
db, mock, err := New()
if err != nil {
    fmt.Println("error creating mock database")
    return
}

// columns to be used for result
columns := []string{"id", "status"}

// expect transaction begin
mock.ExpectBegin()

// expect query to fetch order, match it with regexp
mock.ExpectQuery("SELECT (.+) FROM orders (.+) FOR UPDATE").
    WithArgs(1).
    WillReturnRows(NewRows(columns).AddRow(1, 1))

// expect transaction rollback, since order status is "cancelled"
mock.ExpectRollback()

// run the cancel order function
someOrderID := 1

// call a function which executes expected database operations
err = cancelOrder(db, someOrderID)
if err != nil {
    fmt.Printf("unexpected error: %s", err)
    return
}

// ensure all expectations have been met
if err = mock.ExpectationsWereMet(); err != nil {
    fmt.Printf("unmet expectation error: %s", err)
}

テスト

では前述のパッケージを用いて、さっそく書いてみましょう。ちなみに今回は異常系を省略してます。

// main_test.go

package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"

    "github.com/DATA-DOG/go-sqlmock"
)

func setupDB() (*gorm.DB, sqlmock.Sqlmock, error) {
    sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
    if err != nil {
        return nil, nil, err
    }

    gormdb, err := gorm.Open(mysql.Dialector{
        Config: &mysql.Config{
            DriverName:                "mysql",
            Conn:                      sqlDB,
            SkipInitializeWithVersion: true,
        },
    }, &gorm.Config{})
    if err != nil {
        return nil, nil, err
    }

    return gormdb, mock, nil
}

func TestList(t *testing.T) {
    _gormDB, mock, err := setupDB()
    if err != nil {
        t.Fatal(err.Error())
    }
    gormDB = _gormDB

    expect := TODO{
        Author:      "author",
        Name:        "name",
        Description: "description",
        DueDate:     time.Now(),
        Model:       gorm.Model{ID: 1},
    }
    mock.ExpectQuery("SELECT * FROM `todos` WHERE `todos`.`deleted_at` IS NULL").
        WillReturnRows(sqlmock.NewRows([]string{"id", "author", "name", "description", "due_date"}).
            AddRow(expect.ID, expect.Author, expect.Name, expect.Description, expect.DueDate))

    request := httptest.NewRequest(http.MethodGet, "http://localhost:8080/todos", nil)
    recorder := httptest.NewRecorder()
    setupRouter().ServeHTTP(recorder, request)

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Fatal(err.Error())
    }

    response := recorder.Result()
    defer response.Body.Close()

    var responseTODOs []TODO
    if err := json.NewDecoder(response.Body).Decode(&responseTODOs); err != nil {
        t.Fatal(err.Error())
    }

    if len(responseTODOs) != 1 {
        t.Errorf("got response count = %d. want = %d", len(responseTODOs), 1)
    }
    if responseTODOs[0].ID != expect.ID {
        t.Errorf("got ID = %d. want = %d", responseTODOs[0].ID, expect.ID)
    }
    if responseTODOs[0].Author != expect.Author {
        t.Errorf("got Author = %s. want = %s", responseTODOs[0].Author, expect.Author)
    }
    if responseTODOs[0].Name != expect.Name {
        t.Errorf("got Name = %s. want = %s", responseTODOs[0].Name, expect.Name)
    }
    if responseTODOs[0].Description != expect.Description {
        t.Errorf("got Description = %s. want = %s", responseTODOs[0].Description, expect.Description)
    }
    if responseTODOs[0].DueDate.Unix() != expect.DueDate.Unix() {
        t.Errorf("got Description = %d. want = %d", responseTODOs[0].DueDate.Unix(), expect.DueDate.Unix())
    }
}

func TestAdd(t *testing.T) {
    _gormDB, mock, err := setupDB()
    if err != nil {
        t.Fatal(err.Error())
    }
    gormDB = _gormDB

    expect := TODO{
        Author:      "author",
        Name:        "name",
        Description: "description",
        DueDate:     time.Now(),
        Model:       gorm.Model{ID: 1},
    }

    mock.ExpectBegin()
    mock.ExpectExec("INSERT INTO `todos` (`created_at`,`updated_at`,`deleted_at`,`author`,`name`,`description`,`due_date`,`id`) VALUES (?,?,?,?,?,?,?,?)").WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), expect.Author, expect.Name, expect.Description, sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectCommit()

    b, err := json.Marshal(expect)
    if err != nil {
        t.Fatal(err.Error())
    }

    request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/todo", bytes.NewBuffer(b))
    recorder := httptest.NewRecorder()
    setupRouter().ServeHTTP(recorder, request)

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Fatal(err.Error())
    }

    response := recorder.Result()
    defer response.Body.Close()

    var responseTODO TODO
    if err := json.NewDecoder(response.Body).Decode(&responseTODO); err != nil {
        t.Fatal(err.Error())
    }

    if responseTODO.ID != expect.ID {
        t.Errorf("got ID = %d. want = %d", responseTODO.ID, expect.ID)
    }
    if responseTODO.Author != expect.Author {
        t.Errorf("got Author = %s. want = %s", responseTODO.Author, expect.Author)
    }
    if responseTODO.Name != expect.Name {
        t.Errorf("got Name = %s. want = %s", responseTODO.Name, expect.Name)
    }
    if responseTODO.Description != expect.Description {
        t.Errorf("got Description = %s. want = %s", responseTODO.Description, expect.Description)
    }
    if responseTODO.DueDate.Unix() != expect.DueDate.Unix() {
        t.Errorf("got Description = %d. want = %d", responseTODO.DueDate.Unix(), expect.DueDate.Unix())
    }
}

補足

setupDB内のモックの初期化にてsqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)を渡しています。これはSQLアサーションのために利用されます。

type QueryMatcher interface {
    // Match expected SQL query string without whitespace to
    // actual SQL.
    Match(expectedSQL, actualSQL string) error
}

Match(expectedSQL, actualSQL string) errorを満たしていれば自作のMatcherも作ることが可能です。今回はgo-sqlmockで提供されているQueryMatcherEqualを使用しました。

var QueryMatcherEqual QueryMatcher = QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
    expect := stripQuery(expectedSQL)
    actual := stripQuery(actualSQL)
    if actual != expect {
        return fmt.Errorf(`actual sql: "%s" does not equal to expected "%s"`, actual, expect)
    }
    return nil
})

まとめ

今回はロジックが非常に簡単なため、テストコードも非常に簡単なものでした。しかし、testingによるテストは公式で言及されているように考えることがとても多いパッケージです。使用する際は公式のドキュメントやテストコードを見て肌感を掴むと良い気がしています。

サーバーサイドGo入門 #3 ~gin, gormを使う~

前回はこちら prelude.hatenablog.jp

今回作るもの

前回まででnet/httpでリクエストを受け取りdatabase/sqlでDBを扱うところまで書きました。これでアプリケーションとして成立しますが、今回はアプリケーションの振る舞いは変えずに有名なライブラリで書き替えていこうと思います。

使うかどうかに関わらずライブラリの列挙をします。詳細はREADMEを読んで頂ければと思います。スター数は2021/01/10現在の数字です。

net/http関連のライブラリ

軽量なルーターライブラリからフレームワークまで存在します。

ベンチマーク

Webフレームワークのベンチマークを計測しているリポジトリもあります。 github.com

database/sql関連のライブラリ

database/sqlの軽量ラッパーやORMなどなど。

ライブラリを使って書き換え

今回はスター数の多いgingormを使おうと思います。どちらもREADME以外に公式サイトが存在します。

ginの使用

まずはnet/httpginを使ったものに書き替えます。

これまでhttp.HandleFuncに関数を渡して処理を行っていました。ここをgin.Defaultを呼び出して返ってくる*gin.Engineに関数を渡すように変更します。
それに伴い、渡す関数のシグネチャfunc (c *gin.Context)になります。listaddはその通りに変更します。
また、APIのレスポンスは引数のresponse http.ResponseWriterを使っていましたが、ginではc *gin.Contextになるためc.JSONとしてレスポンスを返します。書き替えはこれだけです。

ちなみに、今回は使いませんがパスパラメータやクエリパラメータを受け取る場合は下記のように書きます。

func main() {
    router := gin.Default()

    // This handler will match /user/john but will not match /user/ or /user
    router.GET("/user/:name", func(c *gin.Context) {
        name := c.Param("name")
        c.String(http.StatusOK, "Hello %s", name)
    })

    // However, this one will match /user/john/ and also /user/john/send
    // If no other routers match /user/john, it will redirect to /user/john/
    router.GET("/user/:name/*action", func(c *gin.Context) {
        name := c.Param("name")
        action := c.Param("action")
        message := name + " is " + action
        c.String(http.StatusOK, message)
    })

    // For each matched request Context will hold the route definition
    router.POST("/user/:name/*action", func(c *gin.Context) {
        c.FullPath() == "/user/:name/*action" // true
    })

    router.Run(":8080")
}
func main() {
    router := gin.Default()

    // Query string parameters are parsed using the existing underlying request object.
    // The request responds to a url matching:  /welcome?firstname=Jane&lastname=Doe
    router.GET("/welcome", func(c *gin.Context) {
        firstname := c.DefaultQuery("firstname", "Guest")
        lastname := c.Query("lastname") // shortcut for c.Request.URL.Query().Get("lastname")

        c.String(http.StatusOK, "Hello %s %s", firstname, lastname)
    })
    router.Run(":8080")
}

また、パスのグルーピングもnet/httpと異なり簡便です。

func main() {
    router := gin.Default()

    // Simple group: v1
    v1 := router.Group("/v1")
    {
        v1.POST("/login", loginEndpoint)
        v1.POST("/submit", submitEndpoint)
        v1.POST("/read", readEndpoint)
    }

    // Simple group: v2
    v2 := router.Group("/v2")
    {
        v2.POST("/login", loginEndpoint)
        v2.POST("/submit", submitEndpoint)
        v2.POST("/read", readEndpoint)
    }

    router.Run(":8080")
}

他にもバリデーションやリダイレクトなど多くの機能が提供されています。READMEにサンプルコードが書いてありますので詳細はそちらを参照してください。

今回はかなり簡単なAPIでかつ細かく設定していないため、簡単に書き替えることができます。

// main.go

package main

import (
    "database/sql"
    "encoding/json"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    _ "github.com/go-sql-driver/mysql"
)

var DB *sql.DB

type MetaData struct {
    ID        int       `json:"id"`
    CreatedAt time.Time `json:"createdAt"`
}

type TODO struct {
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

type TODOObject struct {
    MetaData
    TODO
}

func list(c *gin.Context) {
    rows, err := DB.Query("SELECT * FROM todos")
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    defer rows.Close()

    todos := make([]TODOObject, 0)
    for rows.Next() {
        var todo TODOObject
        if err := rows.Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
            return
        }
        todos = append(todos, todo)
    }

    c.JSON(http.StatusOK, todos)
}

func add(c *gin.Context) {
    if c.Request.Body == http.NoBody {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is required"})
        return
    }

    var todo TODO
    if err := json.NewDecoder(c.Request.Body).Decode(&todo); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is invalid"})
        return
    }

    object := TODOObject{
        TODO: todo,
        MetaData: MetaData{
            CreatedAt: time.Now(),
        },
    }
    result, err := DB.Exec("INSERT INTO todos (author, name, description, due_date, created_at) VALUES (?, ?, ?, ?, ?)", object.Author, object.Name, object.Description, object.DueDate.Format("2006-01-02 15:03:04"), object.CreatedAt.Format("2006-01-02 15:03:04"))
    if err != nil {
        panic(err)
    }

    lastInsertID, err := result.LastInsertId()
    if err != nil {
        panic(err)
    }
    var responseData TODOObject
    if err := DB.QueryRow("SELECT * FROM todos WHERE id=?", lastInsertID).Scan(&responseData.ID, &responseData.Author, &responseData.Name, &responseData.Description, &responseData.DueDate, &responseData.CreatedAt); err != nil {
        panic(err)
    }

    c.JSON(http.StatusOK, responseData)
}

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/todos", list)
    router.POST("/todo", add)
    return router
}

func main() {
    var err error
    DB, err = sql.Open("mysql", "root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true")
    if err != nil {
        panic(err)
    }
    defer DB.Close()
    if err := DB.Ping(); err != nil {
        panic(err)
    }

    if _, err := DB.Exec("CREATE TABLE IF NOT EXISTS todos (id INTEGER AUTO_INCREMENT, author VARCHAR(32), name VARCHAR(32), description VARCHAR(64), due_date DATETIME, created_at DATETIME, PRIMARY KEY (id))"); err != nil {
        panic(err)
    }

    log.Println(setupRouter().Run(":8080"))
}

ライブラリがnet/httpを意識したものなのか、パスと関数を渡すようになっているため直感的に移行できました。

gormの使用

続いてgormを使用してdatabase/sqlを書き替えます。

DBとして*sql.DB型のグローバル変数を保持していましたが、gormで定義されている*gorm.DB型の変数を宣言するように変更しています。それに伴いOpenSQLの書き方が若干変わります。
IDや作成時間を保持していたTODOObjectも廃し、gorm.Modelを利用するように変更しています。 gorm.Modelは下記のような定義になっています。これをTODOに埋め込んで定義します。

type Model struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
}

gormのタグを含めたモデル周りの扱いは下記のドキュメントに記載されています。

Declaring Models | GORM - The fantastic ORM library for Golang, aims to be developer friendly.

// main.go

package main

import (
    "encoding/json"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var gormDB *gorm.DB

type TODO struct {
    gorm.Model
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

func list(c *gin.Context) {
    todos := make([]TODO, 0)
    if err := gormDB.WithContext(c).Find(&todos).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
        return
    }

    c.JSON(http.StatusOK, todos)
}

func add(c *gin.Context) {
    if c.Request.Body == http.NoBody {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is required"})
        return
    }

    var todo TODO
    if err := json.NewDecoder(c.Request.Body).Decode(&todo); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is invalid"})
        return
    }

    if err := gormDB.WithContext(c).Create(&todo).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todo)
}

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/todos", list)
    router.POST("/todo", add)
    return router
}

func main() {
    _gormDB, err := gorm.Open(mysql.Open("root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true"), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    db, err := _gormDB.DB()
    if err != nil {
        panic(err)
    }
    defer db.Close()

    if !_gormDB.Migrator().HasTable(&TODO{}) {
        if err := _gormDB.Migrator().CreateTable(&TODO{}); err != nil {
            panic(err)
        }
    }

    gormDB = _gormDB

    log.Println(setupRouter().Run(":8080"))
}

database/sqlと異なりRowsをループしてScanする必要がなくなり、記述量がかなり減ったことがわかります。また、gormはdatabase/sqlを内部的に利用しているため、CloseするためにDBという関数でdatabase/sql*sql.DBを取得しています。

curlで確認

$ curl -X POST http://localhost:8080/todo -d \
'{
  "author": "Rob",
  "name": "Develop Generics",
  "description": "Add feature of generic type system",
  "dueDate": "2022-12-31T00:00:00Z"
}' | jq
{
  "ID": 1,
  "CreatedAt": "2021-01-11T12:40:00.365172+09:00",
  "UpdatedAt": "2021-01-11T12:40:00.365172+09:00",
  "DeletedAt": null,
  "author": "Rob",
  "name": "Develop Generics",
  "description": "Add feature of generic type system",
  "dueDate": "2022-12-31T00:00:00Z"
}

$ curl http://localhost:8080/todos | jq
[
  {
    "ID": 1,
    "CreatedAt": "2021-01-11T03:40:00.365Z",
    "UpdatedAt": "2021-01-11T03:40:00.365Z",
    "DeletedAt": null,
    "author": "Rob",
    "name": "Develop Generics",
    "description": "Add feature of generic type system",
    "dueDate": "2022-12-31T00:00:00Z"
  }
]

gorm.ModelによってIDCreatedAtだけでなくUpdatedAtDeletedAtも追加されました。

まとめ

今回は有名なライブラリをいくつか紹介して実際に使ってみました。標準パッケージを置き換えるようなライブラリを使いましたが、どれも基礎となるのはnet/httpdatabase/sqlの知識です。ライブラリ選定時には何が土台となっているのかも確認できると良いですね。

雑談

体重が増えてしまったので散歩をするようにしたら毎日8,000歩程度歩くようになった。けどこのくらいってリモート前なら割と歩いていたような気もするからいかに日常的に歩かないようになってしまったのかがわかる。筋トレもして基礎代謝上げつつもっと歩かないと...。
散歩のお供は主にPodcast。英語の勉強時間を兼ねられると効率的なので歩きながらできるやり方を考えないと。 rebuild.fm misreading.chat

サーバーサイドGo入門 #2 ~database/sqlでDBを扱う~

前回はこちら prelude.hatenablog.jp

今回作るもの

今回は、インメモリに作成したデータをDBに永続化します。 MySQLをDockerで動かしGoから接続、前回作成したTODOを格納してWebサーバのレスポンスとして扱うところまで書きます。

Dockerで動かす

コンテナを立てる

Dockerイメージを取得してください。Dockerの慣れている方はお好みの手順でセットアップしてもらえればと思います。

$ docker pull mysql

MySQLのイメージを実行します。

$ docker run --rm --name mysql -e MYSQL_ROOT_PASSWORD=mysql -e MYSQL_DATABASE=todo -p 3306:3306 mysql

MySQLの中に入る

実行中のコンテナの一覧を取得します。

$ docker container ls

コンテナのIDを用いてコンテナ内のシェルを立ち上げます。

$ docker exec -it ${↑で確認したmysqlのコンテナID} bash
root@container_id:/# mysql -p
Enter password: ここに先ほどMYSQL_ROOT_PASSWORDに渡した値を入力(今回はmysql)
mysql> SHOW DATABASES;

最後にデータベースの一覧を表示して、MYSQL_DATABASEに設定したtodoが存在することを確認してください。

GoからDBを扱う

さっそく接続してみましょう。

// main.go

package main

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "net/http"
    "time"

    _ "github.com/go-sql-driver/mysql"
)

// 前回書いたコード
// ...
// ...

func main() {
    db, err := sql.Open("mysql", "root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true")
    if err != nil {
        panic(err)
    }
    defer db.Close()
    if err := db.Ping(); err != nil {
        panic(err)
    }
    fmt.Printf("%+v\n", db)

    // 前回書いたコード
    // ...
    // ...
}

実行し標準出力にDBの情報が出力されれば疎通に成功しています。

ではコードについて補足をします。
まず、importにてgithub.com/go-sql-driver/mysql_という記述があります。import時にはpackage名の前に別名をつけることができます。その別名に_を用いるとpackage名を破棄します。そのため、packageにアクセスすることはできなくなります(厳密には識別子がないためアクセスする方法がなくなります)。 _blank identifierと呼びます。

Effective Go - The Go Programming Language

次に、initについても説明する必要があります。initはimportされた際に実行される特別な関数です。

Effective Go - The Go Programming Language

つまり、blank identifierを用いたimportはinitのみを行うということです。

では、go-sql-driver/mysqlがinitで何をしているのか読んでみましょう。

func init() {
    sql.Register("mysql", &MySQLDriver{})
}

mysql/driver.go at master · go-sql-driver/mysql · GitHub

database/sqlRegisterを呼び出し、引数にMySQLDriverを渡しています。Registerの第2引数の型はdatabase/sql/driverDriver interfaceです。このinterfaceは何ができるのか見てみましょう。

type Driver interface {
    Open(name string) (Conn, error)
}

go/driver.go at 59bfc18e3441d9cd0b1b2f302935403bbf52ac8b · golang/go · GitHub

ここまで読むとdatabase/sqlOpenが呼ばれるとMySQLDriverを使ったMySQLの接続情報をConn型として返却していそうなことがわかります。では最後にmain.goで読んでいるsql.Openを読んでみましょう。

func Open(driverName, dataSourceName string) (*DB, error) {
    driversMu.RLock()
    driveri, ok := drivers[driverName]
    driversMu.RUnlock()
    if !ok {
        return nil, fmt.Errorf("sql: unknown driver %q (forgotten import?)", driverName)
    }

    if driverCtx, ok := driveri.(driver.DriverContext); ok {
        connector, err := driverCtx.OpenConnector(dataSourceName)
        if err != nil {
            return nil, err
        }
        return OpenDB(connector), nil
    }

    return OpenDB(dsnConnector{dsn: dataSourceName, driver: driveri}), nil
}

go/sql.go at master · golang/go · GitHub

このOpenDBを読んでいくと設定したDriverを使ってコネクションを作成していることがわかると思います。

見てきたようにblank importは設定を隠蔽する時に使っているものをよく見かけます。blank importinitも使い方によっては非常に便利ですが、濫用するとコードから副作用を追うことが難しくなるため注意が必要です。

SQLを渡す

ではTODOObject用のテーブルを作成します。TODOObjectをそのままカラムにしているだけです。

if _, err := db.Exec("CREATE TABLE IF NOT EXISTS todos (id INTEGER AUTO_INCREMENT, author VARCHAR(32), name VARCHAR(32), description VARCHAR(64), due_date DATETIME, created_at DATETIME, PRIMARY KEY (id))"); err != nil {
    panic(err)
}
object := TODOObject{
    MetaData: MetaData{
        ID:        1,
        CreatedAt: time.Now(),
    },
    TODO: TODO{
        Author:      "author",
        Name:        "name",
        Description: "description",
        DueDate:     time.Now(),
    },
}
if _, err := db.Exec("INSERT INTO todos (author, name, description, due_date, created_at) VALUES (?, ?, ?, ?, ?)", object.Author, object.Name, object.Description, object.DueDate.Format("2006-01-02 15:03:04"), object.CreatedAt.Format("2006-01-02 15:03:04")); err != nil {
    panic(err)
}

MySQLにて実際にテーブルが作成されたことを確認しましょう。

mysql> SHOW CREATE TABLE todos;
mysql> SELECT * FROM todos;

見てわかる通り、database/sqlSQLを渡すだけです。

DBを読み込む

カラムに対応したフィールドのポインタを渡すとそこに書き込んでくれます。

var todo TODOObject
if err := db.QueryRow("SELECT * FROM todos WHERE id=?", 1).Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
    panic(err)
}

Queryから始まる関数はRowもしくはRowsを返します。いま使っているQueryRowはクエリにマッチした1行を返却するためRowを返します。
登録した全てのTODOを取得したい場合には下記のように書きます。

rows, err := db.Query("SELECT * FROM todos")
if err != nil {
    panic(err)
}
defer rows.Close()

todos := make([]TODOObject, 0)
for rows.Next() {
    var todo TODOObject
    if err := db.QueryRow("SELECT * FROM todos WHERE id=?", 1).Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
        panic(err)
    }
    todos = append(todos, todo)
}
fmt.Printf("%+v\n", todos)

他の関数についてはドキュメントを参照してください。

sql - The Go Programming Language

TODOをインメモリからDBへ

さて、DBの読み書きがわかったので、http経由でTODOをDBを扱ってみましょう。

// main.go

package main

import (
    "database/sql"
    "encoding/json"
    "net/http"
    "time"

    _ "github.com/go-sql-driver/mysql"
)

var DB *sql.DB

type MetaData struct {
    ID        int       `json:"id"`
    CreatedAt time.Time `json:"createdAt"`
}

type TODO struct {
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

type TODOObject struct {
    MetaData
    TODO
}

func list(response http.ResponseWriter, request *http.Request) {
    rows, err := DB.Query("SELECT * FROM todos")
    if err != nil {
        panic(err)
    }
    defer rows.Close()

    todos := make([]TODOObject, 0)
    for rows.Next() {
        var todo TODOObject
        if err := rows.Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
            panic(err)
        }
        todos = append(todos, todo)
    }

    _ = json.NewEncoder(response).Encode(todos)
}

func add(response http.ResponseWriter, request *http.Request) {
    encoder := json.NewEncoder(response)
    if request.Body == http.NoBody {
        _ = encoder.Encode("request body is nil")
        return
    }

    var todo TODO
    if err := json.NewDecoder(request.Body).Decode(&todo); err != nil {
        _ = encoder.Encode(err.Error())
        return
    }

    object := TODOObject{
        TODO: todo,
        MetaData: MetaData{
            CreatedAt: time.Now(),
        },
    }
    result, err := DB.Exec("INSERT INTO todos (author, name, description, due_date, created_at) VALUES (?, ?, ?, ?, ?)", object.Author, object.Name, object.Description, object.DueDate.Format("2006-01-02 15:03:04"), object.CreatedAt.Format("2006-01-02 15:03:04"))
    if err != nil {
        panic(err)
    }

    lastInsertID, err := result.LastInsertId()
    if err != nil {
        panic(err)
    }
    var responseData TODOObject
    if err := DB.QueryRow("SELECT * FROM todos WHERE id=?", lastInsertID).Scan(&responseData.ID, &responseData.Author, &responseData.Name, &responseData.Description, &responseData.DueDate, &responseData.CreatedAt); err != nil {
        panic(err)
    }

    _ = encoder.Encode(responseData)
}

func main() {
    var err error
    DB, err = sql.Open("mysql", "root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true")
    if err != nil {
        panic(err)
    }
    defer DB.Close()
    if err := DB.Ping(); err != nil {
        panic(err)
    }

    if _, err := DB.Exec("CREATE TABLE IF NOT EXISTS todos (id INTEGER AUTO_INCREMENT, author VARCHAR(32), name VARCHAR(32), description VARCHAR(64), due_date DATETIME, created_at DATETIME, PRIMARY KEY (id))"); err != nil {
        panic(err)
    }

    http.HandleFunc("/todo/list", list)
    http.HandleFunc("/todo/add", add)
    _ = http.ListenAndServe(":8080", nil)
}

curlのコマンドとレスポンスのJSONも全く同じなため、省略します。(前回を参照してください。)

まとめ

database/sqlSQLをそのまま渡せるため、そこまでわからないところは多くなかったのではないでしょうか?単にライブラリのどの関数を呼べばよいのかの紹介程度になっているかと思います。
次回はこれら標準ライブラリではなくプロダクションで使われることの多いライブラリに書き換えていこうと思います。

雑談

こないだ初めて検便をした。

サーバーサイドGo入門 #1 ~net/httpでサーバーを立てる~

背景・モチベーション

前職の同僚が、Goでサーバーサイド開発をすることになったけれど文法の後に何をやるか困っていそうだったので簡単なものを作りここに置いておこうと思います。
前提として他の言語でサーバーサイド開発を行った経験がありGoの文法は学習済みの人を対象としています。また、示すコードの他にもっと良い書き方があるとは思うので公式ドキュメントなどを参照して何かわかったらぜひコメント欄にて教えてほしいです。

作るもの

todoアプリを作ります。サーバーを立てtodoをDBに永続化します。
まずは標準ライブラリを用いて開発し、その後に標準ライブラリ以外のライブラリを用いてリプレイスする予定です。
その後、テストとgoroutineによる非同期処理を書いて終わろうと思います。

今回はインメモリの配列にTODOを追加することをゴールとします。

プロジェクトを作る

Goではモジュールという単位でプロジェクト的なものを作ります。下記のコマンドを叩いてtodoというモジュールを作成します。 main.goも作成しておきます。

$ go mod init todo
$ touch main.go
$ tree .
.
├- go.mod
└- main.go

サーバーを立てる

サーバーを立てる事自体は非常に簡単です。

// main.go

package main
import "net/http"

func main() {
    _ = http.ListenAndServe(":8080", nil)
}

これだけです。
ではサーバーを立ち上げてみましょう。

$ go run main.go

立ち上げたサーバーに対してcurlを叩きます。

$ curl http://localhost:8080
404 page not found

何も処理を入れていないため404になってしまっていますがサーバーからレスポンスが返却されています。net/httpパッケージはGoの標準ライブラリにあるネットワーク関連のパッケージです。net/httpと並んでnet/mailnet/rpcなどがあります。net/http配下にはServerClientだけではなく、RequestResponseなども定義されています。

go/src/net/http at master · golang/go · GitHub

処理を加える

404ではつまらないのでアクセスが来たら何か処理をさせてみましょう。http.HandleFuncを用いて特定のパスに対して処理を仕込むことができます。ここでは標準出力に"Hello World"を出力しています。

// main.go

package main
import (
    "fmt"
    "net/http"
)

func handler(response http.ResponseWriter, request *http.Request) {
    fmt.Println("Hello World")
}

func main() {
    http.HandleFunc("/", handler)
    _ = http.ListenAndServe(":8080", nil)
}
$ curl http://localhost:8080
Hello World

JSONを返却する

標準出力だけでは業務で使えないと思うので、JSONをクライアントに返してみましょう。ここで返却するものはなんでも良いのでリクエストに送られてきたヘッダをそのまま返してみます。

// main.go

package main
import (
    "encoding/json"
    "net/http"
)

func handler(response http.ResponseWriter, request *http.Request) {
    _ = json.NewEncoder(response).Encode(request.Header)
}

func main() {
    http.HandleFunc("/", handler)
    _ = http.ListenAndServe(":8080", nil)
}
$ curl http://localhost:8080 | jq
{
  "Accept": [
    "*/*"
  ],
  "User-Agent": [
    "curl/7.64.1"
  ]
}

ヘッダをそのまま返しただけですが、これでクライアントにJSONを返却することができました。後は構造を定めてドメインに応じて処理を挟んであげれば良いですね。

モデルを定義する

ではTODOのモデルを定義しましょう。

// main.go

type TODO struct {
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

jsonタグ(json:"xxx")に応じてstructがEncoding/Decodingされます。タグはフィールド名に対応している必要はなく自由に設定できるため、例えばクライアントがスネークケースだった場合にはjsonタグにスネークケースをつけ、Go側の定義ではキャメルケースにするということができます。 タグを省略した場合はフィールド名がキーになります。また、タグにjson:"-"と設定するとそのフィールドは無視されます。 jsonに関する実装はencoding/jsonに定義されています。encoding/jsonと並んでencoding/csvencoding/xmlencoding/pemなどがあります。encoding/json配下にはEncoderDecoderの定義があります。

go/src/encoding/json at master · golang/go · GitHub

レスポンスを返却する

では、定義したモデルを返却してみましょう。ここではクライアントから登録はせずに予めデータを作ってそれを返却します。

// main.go

package main

import (
    "encoding/json"
    "net/http"
    "time"
)

type TODO struct {
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

func list(response http.ResponseWriter, request *http.Request) {
    encoder := json.NewEncoder(response)

    due, err := time.Parse("2006/01/02", "2022/12/31")
    if err != nil {
        _ = encoder.Encode(err.Error())
        return
    }
    todo := TODO{
        Author:      "Rob",
        Name:        "Develop Generics",
        Description: "Add feature of generic type system",
        DueDate:     due,
    }
    _ = encoder.Encode(todo)
}

func main() {
    http.HandleFunc("/todo/list", list)
    _ = http.ListenAndServe(":8080", nil)
}
$ curl http://localhost:8080/todo/list | jq
{
  "author": "Rob",
  "name": "Develop Generics",
  "description": "Add feature of generic type system",
  "dueDate": "2022-12-31T00:00:00Z"
}

http.HandleFuncに渡している処理の中身が変わりました。今回は新たにtimeパッケージをimportしています。timeパッケージは名前の通り時間関連のパッケージです。指定するフォーマットにひと癖あるので気をつけてください。time.Parseの引数として"2006-01-02"を渡していますが、これを"2041-05-22"などにするとtime.Parseはエラーを返します。Goではtimeのフォーマットは定数で決められているためです。詳細はリンク先を見てみてください。

go/format.go at master · golang/go · GitHub

RFC3339などの規格は定数としてきちんと定義されているので必要であれば提供されている定数を使いましょう。

TODOを登録する

最後に登録処理をします。今回はDBへの登録ではなくメモリ上の配列に追加することにします。

// main.go

package main

import (
    "encoding/json"
    "net/http"
    "time"
)

var todos []TODOObject = []TODOObject{}

type MetaData struct {
    ID        int       `json:"id"`
    CreatedAt time.Time `json:"createdAt"`
}

type TODO struct {
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

type TODOObject struct {
    MetaData
    TODO
}

func list(response http.ResponseWriter, request *http.Request) {
    _ = json.NewEncoder(response).Encode(todos)
}

func add(response http.ResponseWriter, request *http.Request) {
    encoder := json.NewEncoder(response)
    if request.Body == http.NoBody {
        _ = encoder.Encode("request body is nil")
        return
    }
    var todo TODO
    if err := json.NewDecoder(request.Body).Decode(&todo); err != nil {
        _ = encoder.Encode(err.Error())
        return
    }
    metadata := MetaData{
        ID:        len(todos) + 1,
        CreatedAt: time.Now(),
    }
    object := TODOObject{
        TODO:     todo,
        MetaData: metadata,
    }
    todos = append(todos, object)
    _ = encoder.Encode(object)
}

func main() {
    http.HandleFunc("/todo/list", list)
    http.HandleFunc("/todo/add", add)
    _ = http.ListenAndServe(":8080", nil)
}
$ curl -X POST http://localhost:8080/todo/add -d \
'{
  "author": "Rob",
  "name": "Develop Generics",
  "description": "Add feature of generic type system",
  "dueDate": "2022-12-31T00:00:00Z"
}' | jq
{
  "id": 1,
  "createdAt": "2021-01-03T23:21:00.101796+09:00",
  "author": "Rob",
  "name": "Develop Generics",
  "description": "Add feature of generic type system",
  "dueDate": "2022-12-31T00:00:00Z"
}

$ curl http://localhost:8080/todo/list | jq       
[
  {
    "id": 1,
    "createdAt": "2021-01-03T23:21:00.101796+09:00",
    "author": "Rob",
    "name": "Develop Generics",
    "description": "Add feature of generic type system",
    "dueDate": "2022-12-31T00:00:00Z"
  }
]

TODOにメタ情報としてIDと作成時間を付与したTODOObjectを作成しました。embedすることで既存のモデルに手を入れることなく構造を定義することができます。また、embedでの定義にjsonタグをつけないことによりフラットな構造を作ることができます。(逆にjsonタグをつけると構造化できます) ついでにlistの処理でtodosを返却するようにしてあります。

まとめ

文法を把握した人にとってはライブラリの使い方以外にもエラー処理やembed、jsonタグなど知識の整理もできたんじゃないかなと思います。importしている標準ライブラリのパッケージのリンクを張ってきましたが、今回使った関数だけではなく並列して存在しているパッケージや同パッケージ内の他のファイルや似たような関数なども流し読みすると新たな発見があると思います。しれっと便利な定数が定義されていたりするので、探してみてください。(私はhttp.NoBodyを今回初めて知りました)

ちなみに、embedする必要がないように思った方もいるかもしれません。実は、ORMのライブラリで今回のようにembedしてメタ情報を付与する実装もあるので今回はそのようにしてみました。また、このembedによる実装はリポジトリ層で抽象化させるなど設計においても多く考慮することのできる言語仕様なので、実際の開発をイメージしてもらえればと思います。

次回はTODOをメモリではなくDBに登録するところを書こうと思います。

雑談

今年からiDecoやつみたてNISAを始めようと思っているので投資関連の情報ばかり見ていた。何も知識がないところから投資信託ETFの区別ができたりアセットアロケーションについて考えられる程度にはなったので知識って大事だなぁと改めて思った。ただ、まだ身銭を切っていないので投資をしてからどう感じるようになるかわからない。毎秒5,000兆円欲しい。

iDeCoを考察する

年末年始は投資について調べていた。

prelude.hatenablog.jp

iDeCoの考察をしたので整理をしたい。

iDeCoとは

iDeCoとは「個人型確定拠出年金」のこと。詳しくは公式サイトがあるのでそちらをあたると良い。温かみのあるドメインだ。

www.ideco-koushiki.jp

ざっと調べるとiDeCoには下記の特徴がある。

  • 積み立てたお金を投資に回せる
  • 元本含めて原則60歳まで引き下ろせない
  • 投資の運用益は課税されない
  • 積み立てたお金は所得から控除される
  • 本人の状況次第で毎月の積立金額の上限が変わる

これらのことは少し調べればわかるため、これ以上の説明は省略する。

リスク

1番のリスクは流動性リスクである。これが最大のデメリットでありリスクであると言っていい。もし30歳から始めた場合、60歳になるまでの30年間は引き下ろせなくなる。

また、将来を楽にするために現在持っているお金を積み立てるわけなので、60歳までキャッシュフローは悪化する。
さらに、運用益は課税されないとあるが受取時には課税される。受取方法によって計算が変わるがとにかく課税される。

加えて、iDeCoであろうがファンドに投資する以上は信託報酬が発生する。この信託報酬は大きな額ではないが、iDeCoだからといって無視されるわけではないということは、いざ徴収されて驚かないためにも知っておきたい。

リターン

一方で、銀行口座への貯金と異なりファンドへ投資を行うためそこには運用益が発生する。楽天証券iDeCo対象商品であるたわらノーロード日経225を見ると直近5年間の年率リターンの平均は9.46%である。

さらに、iDeCoには所得控除がある。iDeCoのリスクとしてキャッシュフローの悪化を挙げたが、所得控除による節税のインパクトも考慮しなければフェアではない。

例えば、年収500万円の会社員が毎月2.3万円を積み立てたとする。その場合、500万円から2.3万円✕12ヶ月の27.6万円を差し引いた472.4万円が課税対象となる。詳しくは後述するが、この場合の節税効果は年間およそ8.8万円である。

つまり、キャッシュフローの悪化は27.6万円ではなく、そこから8.8万円を差し引いた18.8万円である(厳密には控除額は年収が確定してから決定するため、iDeCoを利用した翌年以降に適用できる論理である)。

シミュレーション

iDeCoによるリスクとリターンの整理はできた。ここでは具体的な数字を当てはめ、30歳から60歳まで積み立てた会社員をシミュレートする。また、節税効果を投資の配当と捉えてその効果を考えてみる。

設定

  • 勤務先に企業年金がなく、企業型確定拠出年金に加入していない(つまり毎月の積立上限は2.3万円)
  • 毎月2.3万円を積み立てる
    • 簡略化のため年間27.6万円の一括投資とする
  • 30歳現在の年収は400万円、60歳時に1,000万円とする
  • 年収は線形に増加し、年20万円増加するものとする
  • 同じ年収でも人によって手取りの金額は変わるため、簡略化のため下記のサイトから得られた情報をもとに計算を行う

www.musashi-corporation.com

投資としてのiDeCo

設定の通り、毎月2.3万円を30年間積み立てたとする。その合計は828万円である。この全てが投資になるため運用益が発生する。
先ほどのたわらノーロード日経225の年率9.46%に今後30年の不確実性を考慮して0.7を掛けた年率6.6%で計算を行った。30年間の投資額828万円は6.6%の複利により2,426万円になった。 f:id:TakumiKaribe:20210106190201p:plain

節税としてのiDeCo

続いてiDeCoの節税効果について考える。
節税の計算のために手取率を計算し控除後の節税効果を確認する。その後に投資観点でのメリットについて見る。

手取率

先ほどの手取り早見表にある年収の額面と手取りから、手取率の関数を得る。
スプレッドシートで今回の設定範囲である400万円から1,000万円についての手取率をグラフにするとこのようになる。

近似ではあるが手取率は下記の関数であるとわかる。

y = -1.34 / 100000000 * x + 0.847

f:id:TakumiKaribe:20210105230125p:plain

節税効果

手取率の関数がわかったので、年収がわかれば手取がわかるようになった。
この関数を使ってiDeCo利用時の節税効果を確認する。

グラフを見ると年収が高くなるにつれ節税効果が大きくなっている。それは年収に応じて税率が高くなるため年収が高いほど課税対象額の減少による節税効果が大きくなるからだ。
今回の設定上では節税金額の合計は約281万円だった。

f:id:TakumiKaribe:20210108201335p:plain

ちなみに、投資額合計の828万円から281万円を差し引いた547万円を投資した資産で保証できれば元本100%となる。
ただ、元本100%とはいえ30年間の流動性リスクを受け入れ、かつキャッシュフローを毎月2.3万円分圧迫したことは変わらない。むしろリスクのみを受け入れたことになるため、あまりよろしくはない。

利率

iDeCoでどの程度の金額が節税されるのかイメージできた。では、この金額は投資の観点ではどうなのだろうか。

グラフを見ると合計投資額が増えるに従って、利率が小さくなっている。iDeCoは所得控除であり合計の投資額に関係ないからだ。

f:id:TakumiKaribe:20210106190609p:plain

受取

受け取り方法には年金として一定額を受け取る方法と一括で受け取る方法がある。それぞれ控除があり計算方法が異なる。

年金で受け取る場合

数年に渡り受け取る場合は、年金として受け取ることになる。年収から年金控除を行いそこから所得税を差し引く。ここでは年金は想定していないため、iDeCoのみで計算を行うことにする。
年金控除による課税対象の減少は一定率ではなく、130万円以上410万円未満は75%が課税対象になるなど固定の年収の範囲によって課税率が決まる。課税率を掛けた後に年金控除を差し引くのだが、それも同様に年収がどの範囲に収まるかによって決まる。国税庁のサイトが非常にわかりやすいためこれ以上の説明は下記を参照されたい。

www.nta.go.jp

また、所得税についても課税対象額がどの範囲に収まるかによって税率が決まる。これまた国税庁のサイトが非常にわかりやすい。

www.nta.go.jp

なぜこの税率について細かに調べる必要があったのかと言うと、年金として受け取る場合は、5~20年の間で受取期間を選ぶことができる。つまり、年収を自分で決めることができるため、税率をコントロールできるのだ。

今回のシミュレーションの金額の場合で、受取を5~20年間に変化させた時の税率の変化を表にしてみた。

2,426万円を受取年数で割り年収を得る。それに控除を適用し所得税を差し引く。これが年間の「手取り」である。
興味深いのはグラフの形である。固定の範囲で年金控除や所得税の税率が変化するからだ。20年間の受取と13年間の受取の税率は大きく違わないことがわかる。

f:id:TakumiKaribe:20210108221318p:plain

5年間で受け取る場合は344万円の税金を支払うのに対し、20年間で受け取る場合は61万円である。税率を考慮した受取戦略の必要性がよくわかる。

一括で受け取る場合

一括で受け取る場合のことを「一時金」と言う。iDeCoを一時金で受け取る場合は退職金とみなされる。退職金には退職所得控除という控除があり計算方法がある。
こちらも国税庁のサイトが非常にわかりやすいため、ここでの解説は省く。

www.nta.go.jp

今回のシミュレーションだと30年間の積立で退職金の合計は2,426万円である。課税対象額の計算式に数字を当てはめると下記のようになる。

(退職金 - ( 800 + 70 ( 勤続年数 - 20 ))) / 2
↓ 具体的に数字を当てはめると
(2426 - ( 800 + 70 ( 30 - 20 ))) / 2

これを計算すると課税対象額は463万円になる。これに税率20%を掛けて427,500円を控除として差し引くと、徴収される税金は498,500円だとわかる。

一時金で受け取る場合は、2,426万円から498,500円を引いた約2,376万円になる。

所感

iDeCoの所得控除は正しいのか?

計算していて気になることがあった。本来であればキャッシュフローに影響を与えるような"配当"は投資額に対して利率を掛けたものである。しかし、iDeCoの場合は所得控除である。そのため、年収が高い人の方が積み立てた金額に関係なく配当が大きくなっている。本来は、年収が低い人の方が流動性リスクを取っているはずであり、より多くの配当をもらって然るべきなのだが、そうはなっていなかった。
もちろん所得控除を配当と捉えたこと自体が誤っている可能性はある。きちんと金融工学を使ったわけではない。だが、毎月2.3万円を支払う余力のある高所得者の節税効果が大きくなる一方で、流動性リスクを受け入れづらい低所得者ほど節税効果を期待できないというのは非常に世知辛い。
累進課税を行い高所得者ほど税金を高くするのであれば、節税に関する制度は所得が小さくなるほどその効果が大きくなるようにしても良かったのではないだろうか。高所得者であっても運用益が非課税になるのであれば、市場にお金を回してくれることは十分期待できたのではないだろうか。

受取の戦略

これは一括だろうと思う。受け取ったらさっさとETFなどに全額投資し、複利で膨らませながら数年に渡り切り崩す。どういった生活をしたいかによるが、これなら切り崩す割合を調整して税金が高くなりすぎないようコントロールできる上に資産も増えるはずだ。リスクを嫌うならETFではなくETNなどにしても良いと思う。

やるか、やらないか

おそらく下記の事項を意思決定時に考慮すべきだろう。

  • 流動性リスクとキャッシュフローの悪化を受け入れることができる財務状態であるか
  • 他の投資商品と比較して運用益が節税効果を上回るかどうか
    • 828万円を積み立てずとも支払えるのであれば、他の投資商品に回してしまえば運用益が節税効果を上回るだろう

つまり、投資にバンバンお金をつぎ込めるほどではないがある程度の資金力がある高所得者にメリットが最大化する。あとは生活と相談といったところだろう。

リスクゼロというリスク

投資一般に言えることであるが、意思決定の基準は「どこを目標に、どの程度のリスクまで受け入れることが可能なのか」ということである。とにかく多くリターンが欲しいというものではない。
iDeCoは運用益の非課税だけでなく所得控除も発生するため、比較的リスクの小さい仕組みであると言える。ただ60歳まで引き下ろせない流動性リスクをどう捉えるかである。リスクの捉え方・付き合い方をハッキリさせることが非常に重要になるだろう。
この点について、iDeCoを含めた少額投資を行わないというリスク判断はある意味リスクでもある。リスクゼロというのはリターンもゼロなわけだが、それだけではなく資本主義社会においては期待損失が大きい。

数字

計算シート f:id:TakumiKaribe:20210111210214p:plain f:id:TakumiKaribe:20210111210241p:plain

参考

金融工学入門 第2版

金融工学入門 第2版

投資の勉強をした

背景

漠然と将来の不安があって投資を検討したというわけではなく、コロナを機に実家に戻って経済的に楽になったので、資産を銀行口座に眠らせておくより投資して増やしたいなと思い調べ始めた。
実際に給料から毎月いくら何年続けるとどうなるのか、というシミュレーション(皮算用)をしていたら楽しくなってしまった。生活の固定費を不労所得でまかないたいという欲がドバドバ出てきた。(いま実家だから固定費なんてほぼないけどな!!)

投資商品

証券会社のサイトを見るとそこには色んな商品があるが、調べるまでよくわかってなかった。投資と言うと株と債券と金くらいしか知らなかった。

www.rakuten-sec.co.jp

ちなみに自分が知っていた"株"というのは"現物取引"と言うらしい。他にもよくわからない商品があったので調べた。

REITというのは「Real Estate Investment Trust」の略で不動産の投資商品。
ETFは「Exchange Traded Fund」の略で上場投資信託という意味。通常の投資信託との違いは商品が市場に上場しているためリアルタイムに売買ができたりするっぽい。他にも違いがあるけれど、あまりよくわかっていない。ETFは投信の上位互換だという解釈で自分の中では落ち着いている。
他には上場予定株を購入できるIPOや借金してレバレッジを高める悪名高いFXなど。

私はETFをメインに購入する予定なのでここからはETFを中心にわかったことを整理する。

ファンド

お金を集めて運用して利益を分配している業者をファンド(投資信託)と呼ぶ。証券会社はファンドの作った商品を販売しているという形らしい。
ファンドには商品の取引時に発生する「取引手数料」と運用手数料としての「信託報酬」というものがある。前者は名前の通り取引都度、後者は資産に対して決まった年率で定期的に支払う。 これらの「取引手数料」や「信託報酬」はファンドの規模が大きくなると安くなる傾向がある。なぜかというと運用額が桁違いに大きいため手数料が安くても全く問題にならないのである。むしろ「取引手数料」や「信託報酬」が高いところは小規模なファンドであったり社員を養うための企業体力がないところが多いらしい。

大手ファンド

ちなみに2020/11/30での東証一部の時価総額合計は661兆円であるため、これらのファンドを合わせるとは東証一部3つ分を運用していることになる。すごい。

様々なインデックス

ETF投資信託には下記のような指標に従って投資を行うことで、その指標に沿った結果になることを期待するような商品もある。 ただそのインデックスが何のことを言っているのかよくわからなかったので調べた。

東証1部上場銘柄のうち、代表的な225銘柄をもとに計算している。

東証1部上場銘柄のうち、全銘柄をもとに計算している。

  • S&P500(えすぴーごひゃく)

ニューヨーク証券取引所NASDAQに上場している銘柄から代表的な500銘柄の株価をもとに計算している。選定基準は時価総額

  • ダウ平均

ニューヨーク証券取引所NASDAQに上場している銘柄から代表的な30銘柄の株価をもとに計算している。選定基準は株価の平均。

投資戦略

ざっと調べて投資商品の得体が知れたので、私にとっての投資というものを考えたい。5,000兆円を作る必要があるわけではなく、固定費を楽にしつつ将来的に困らない額の資産を作りたいというのが目的である。投資には各人の目的やリスク感度にあった戦略を練る必要があるだろう。

時間軸でアセットアロケーションを調整する

度重なるが、固定費を不労所得によって楽にしたいというのがある。そのため当面は高配当株の比率を高めにした投資をしていく予定である。目標は毎月5万円の配当を得ること。(年率5%だとすると1,500万円)
ちなみに株と債券の比率は「株:債券 = 100 - 年齢:年齢」が良いと言われる。債権はリスクが低いので年齢が高くなるに従い購入比率を高めようということだそうだ。しかし、初めからこの比率に固執せず、固定費が楽になったと感じてから債券を買い足そうかなと考えている。

高配当株ETFによるキャッシュフローの改善

ただ資産価値が高まりやすい銘柄を購入しても現実のキャッシュフローには全く影響がない。むしろ毎月の生活は投資した分だけ苦しくなる。
他の商品も検討したが素人がキャッシュフロー改善をするための投資で手堅く勝つには高配当ETFが良いという結論になった。
獲得したい配当額の目標も定まっているので、それを達成してからポートフォリオを組み替えても全く遅くないかなと思っている。

買いたい銘柄

購入予定の銘柄はState StreetのSPYDとBlackRockのHDVである。これらは高配当株ETFとして投資対象が選定されており、かつ選定企業の業界が互いに補完しているため、どちらも買っておけばリスク分散になる。
iDeCoやつみたてNISAでは上記の銘柄は購入できず決められた投資信託から購入することになる。私はiDeCoでは世界株、つみたてNISAではS&P500のETFを購入しようかなと思っている。
理由としては、世界株は米国以外の国にも投資するため、よりリスク分散が可能な商品である。なので、60歳まで引き下ろせないという流動性リスクを抱えるiDeCoとの相性の良さを感じたからだ。
一方、つみたてNISAは米国株を買う予定だ。その理由は、米国株は安定して高い水準の成長率を誇っており、国内株を買うよりは為替リスクを負ってでも米国株を買った方が良いと思ったからである。

素人考えゆえの漠然とした不安

このあたりプロはどう考えるんだろう。投資する本人が資産をどの程度作りたいのかという目標やリスク感度によって変わるところであるので、色んな人の話を聞いてみたいなと思った。
ただ、流されるよりは自分で調べて銘柄選定までできたので、まずは上々かなとも思っている。変なものを掴まされないようにしたい。

いつから

これだけ書いておいてまだ1円も買っていない。年末年始に調べて証券口座の開設申請などを行っていたためiDeCoは早くて3月から、つみたてNISAは今月からできるのかな?
偉そうなことを書き綴るよりまずは身銭を切りたいところである。

楽天経済圏

ちなみに、証券口座は楽天証券で作成した。楽天の銀行口座と証券口座を紐付けておくと銀行口座の金利が上がるなどといった優遇がある。さらに楽天カードも作ってエポスカードから移行した。
楽天証券ではポイントを投資資金として利用できるため、ポイントが溜まって使いみちに困ることはない。Amazonの方が楽天市場より好みなのだが、甘んじて受け入れてポイ活に努めようと思う。

参考

www.rakuten-sec.co.jp

Amazonでいくつか本を買って読んでみたが、現代の資産形成における投資の答えはインデックスっぽい。アクティブ運用は厳しいから指標に従ったパッシブ運用しか勝たん、とのこと。

雑談

相場の上昇・強気のトレンドのことをBull、相場の下落・弱気のトレンドのことをBearと呼ぶらしい。また、ビジネス用語ではあるが稼ぎ頭をCash Cowと言う。そしてなんと今年は丑年だ。ここから導き出される結論は今年に限り上限いっぱいキャッシングした上で資産全額投資すると5,000兆儲かるということである。なるほどなぁ。