0
Help us understand the problem. What are the problem?

posted at

Go言語と抽象化

はじめに

オブジェクト指向的な言語をはじめとして、大抵の言語は抽象化を行うための方法を有すると思います。

Go言語では、オブジェクト指向的な言語ではありませんが、interfaceという型により抽象化を行います。

例えば、package ioが定義しているReader インターフェースについて見てみましょう。

type Reader interface {
	Read(p []byte) (n int, err error)
}

このコードは、Readという名前のメソッドを持ち、かつ引数として[]byteを取り、かつ戻り値として(int, error)を持つ型は、Reader interfaceにより抽象化できるということを主張しています。
(ちなみに、errorはビルトインで定義されているinterfaceです。)

具体例を見てみましょう。

osパッケージにて定義されている型Fileは、以下のメソッドを持ちます。

func (f *File) Read(b []byte) (n int, err error)

io.Readerと同じメソッドを持っていますね。

よって、os.Fileは、io.Reader インターフェースにより抽象化できます。

言い換えると、io.Reader は、os.Fileにより実装されているわけです。

抽象化すると何が嬉しいのか

あるポートで動作する、ウェブサーバーを実装することを考えてみます。

とりあえず、ウェブサーバーの設定を記述したconfig.jsonを用意してから、以下のようなコードを書いてみました。

config.go
package config

import (
	"encoding/json"
	"os"
)

type ServerConfig struct {
	Port string `json:"port"`
}

func GetConfigFile() (*os.File, error) {
	return os.Open("config.json")
}
func GetConfig(file *os.File) (*ServerConfig, error) {
	var dec = json.NewDecoder(file)
	var conf ServerConfig
	if err := dec.Decode(&conf); err != nil {
		return nil, err
	}
	return &conf, nil
}
main.go
package main

import (
	"fmt"
	"net/http"
	"qiita/config"
)

func main() {
	var file, err = config.GetConfigFile()
	if err != nil {
		panic(err)
	}
	conf, err := config.GetConfig(file)
	if err != nil {
		panic(err)
	}
	http.HandleFunc("/qiita", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "hello, world!")
	})
	http.ListenAndServe(":"+conf.Port, nil)
}

ごく普通のコードですが、このコードには以下の欠点があります。

  • config.jsonを実際に用意しないと、main()や、config.GetConfig()を単体テストできない
  • サーバーの設定を、ファイルではなく、別の手段で与えるよう変更するとき、コードの修正量が多くなる可能性がある

特に1点目は、ちょっと深刻な問題です。

例えば、サーバーに与える設定の値を変えて単体テストしたくなったとき、この場合だとconfig.jsonに記述する値を変更せざるを得ません。変更したあと、元に戻すのが面倒ですし、変更したままリモートリポジトリにプッシュしてしまったりするかもしれません。

このように、main.goconfig.goは、単体テストがしづらいコードとなっています。

この問題を解決するため、以下のようにコードをリファクタリングしてみましょう。

main.go
package main

import (
	"fmt"
	"net/http"
	"qiita/config"
)

type Server struct {
	conf Config
}

type Config interface {
	GetPort() string
}

func main() {
	var file, err = config.GetConfigFile()
	if err != nil {
		panic(err)
	}
	conf, err := config.GetConfig(file)
	if err != nil {
		panic(err)
	}
	var server = newServer(conf)
	server.serve()
}

func newServer(conf Config) *Server {
	return &Server{conf: conf}
}

func (s *Server) serve() {
	http.HandleFunc("/qiita", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "hello, world!")
	})
	http.ListenAndServe(":"+s.conf.GetPort(), nil)
}

Configインターフェースを定義し、サーバーが動作するポート番号を返す存在として抽象化してみました。

こうしてConfigを抽象化することで、mainパッケージはConfigの実体を知る必要がなくなりました。

また、構造体Serverを定義し、ServerConfigを持つようにしてみました。

Serverを生成し、そのポインタを返すnewServer()にも注意してください。

引数をinterfaceとすることで、GetPort() stringを持つ型なら何でも引数に渡せるようにしてあります。

これは"accept interface, return struct"と呼ばれるGoのパターンの1つです。

config.goを見てみましょう。

config.go
package config

import (
	"encoding/json"
	"io"
	"os"
)

type ServerConfig struct {
	Port string `json:"port"`
}

func (sc *ServerConfig) GetPort() string {
	return sc.Port
}

func GetConfigFile() (*os.File, error) {
	return os.Open("config.json")
}
func GetConfig(reader io.Reader) (*ServerConfig, error) {
	var dec = json.NewDecoder(reader)
	var conf ServerConfig
	if err := dec.Decode(&conf); err != nil {
		return nil, err
	}
	return &conf, nil
}

GetConfig()の引数として、ファイルの実体であるos.Fileでなく、それを抽象化したインターフェースであるio.Readerを渡すことで、GetConfig()の単体テストを楽にできるようになりました。

config_test.go
package config_test

import (
	"io"
	"qiita/config"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"
)

func TestGetConfig(t *testing.T) {
	type args struct {
		reader io.Reader
	}
	tests := []struct {
		name    string
		args    args
		want    *config.ServerConfig
		wantErr bool
	}{
		{
			name: "successful",
			args: args{
				reader: strings.NewReader(`{"port":"8080"}`),
			},
			want: &config.ServerConfig{
				Port: "8080",
			},
			wantErr: false,
		},
		{
			name: "fail case (wrong json syntax)",
			args: args{
				reader: strings.NewReader(`{port:8080}`),
			},
			want:    nil,
			wantErr: true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := config.GetConfig(tt.args.reader)
			if (err != nil) != tt.wantErr {
				t.Errorf("GetConfig() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("GetConfig() = %v, want %v", got, tt.want)
			}
		})
	}
}

config_test.goでは、以下をテストしています。

  • 正しい文法のjsonを渡すと、デコードが行われ、デコードの結果と期待した構造体の値が等しいこと
  • 文法に誤りを含むjsonを渡すと、デコードが行われず、エラーを返すこと

リファクタリング前のコードだと、テストの度にconfig.jsonを手作業で修正するしかありませんでした。

それに対し、リファクタリング後は、コマンド1つで複数のテストケースをテストできるようになりました。

終わりに

テストしやすいコードを書くと、コードの保守性や、変更への柔軟性が向上すると思います。

今回のコードを置いておくので、よければご参照ください。

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
0
Help us understand the problem. What are the problem?