はじめに
こんにちは。むらってぃーです。
チームで開発しているプロダクトのコードがそれなりに大きくなってきました。
メソッドによっては「割と冗長になってきたね」という話が出てきました。
その部分のほとんどが配列操作の部分だったので、コレクションを使ってリファクタリングを行ったのがこの記事のきっかけです。
エンティティのスライス操作のロジックをコレクションに閉じ込めると、可読性が上がったりテストしやすくなったりで幸せになれたので紹介します。
実装例を交えて紹介
今回は、bookというエンティティと、それを扱うリポジトリ、サービスをコンポーネントとして扱います。
今回扱うコンポーネント
エンティティ
bookというエンティティはID、タイトル、著者の3つの値を持ちます。
package entity
type Book struct {
ID uint64
Title string
Author string
}
func NewBook(id uint64, title, author string) *Book {
return &Book{
ID: id,
Title: title,
Author: author,
}
}
リポジトリ
bookエンティティをDBに入れたり取り出したりします。
今回はDB操作にxormというORMを使います。
package gateway
import (
"github.com/go-xorm/xorm"
"go-collection-sample/internal/entity"
)
// bookをDBに入れたり取り出したりするリポジトリのインターフェース
type IBookRepository interface {
FindByAuthor(author string) ([]*entity.Book, error)
}
type BookRepository struct {
engine *xorm.Engine
}
func NewBookRepository(engine *xorm.Engine) *BookRepository {
return &BookRepository{
engine: engine,
}
}
type bookData struct {
Id uint64
Title string `xorm:"varchar(100)"`
Author string `xorm:"varchar(50)"`
}
// 著者名でbookを複数件取得
func (br *BookRepository) FindByAuthor(author string) ([]*entity.Book, error) {
// DBからAuthorを取ってくる
var results []bookData
if err := br.engine.Where("author = ?", author).Find(&results); err != nil {
return []*entity.Book{}, err
}
// エンティティのスライスに詰めて返す(重要)
var books []*entity.Book
for _, result := range results {
book := entity.NewBook(result.Id, result.Title, result.Author)
books = append(books, book)
}
return books, nil
}
サービス
ビジネスロジックにあたる部分です。
ここが今回の本題です。
package service
import (
"go-collection-sample/internal/entity"
"go-collection-sample/internal/gateway"
)
type BookService struct {
bookRepository gateway.IBookRepository
}
func NewBookService(repository gateway.IBookRepository) *BookService {
return &BookService{
bookRepository: repository,
}
}
DB操作にリポジトリを使います。
このままでも問題ないパターン
book_service.goに「著者名で本を検索する」というメソッドがあったら、その実装はどうなるでしょうか。
// 著者名から本を検索して返す
func (b *BookService) SearchBooksByAuthor(author string) ([]*entity.Book, error) {
// DBからBookをまとめて取ってくる
books, err := b.bookRepository.FindByAuthor(author)
if err != nil {
return []*entity.Book{}, err
}
return books, nil
}
DBからbookをまとめて取ってきて、エンティティを返すだけなので非常にシンプルなロジックになりますね。
問題の実装
では、「著者名で本を検索し、そのID配列を取得する」というメソッドがあったら、その実装はどうなるでしょうか。
そのまま書くと下記の形になるのではないでしょうか。
// 著者名からbookを検索し、それらのIDをスライスで返す
func (b *BookService) SearchBookIDsByAuthor(author string) ([]uint64, error) {
// DBからBookをまとめて取ってくる
books, err := b.bookRepository.FindByAuthor(author)
if err != nil {
return []uint64{}, err
}
// IDのリストに詰め直す(重要)
var ids []uint64
for _, book := range books {
ids = append(ids, book.ID)
}
return ids, nil
}
for文でIDを取り出し、IDに詰め直す記述があることがわかります。
ではここで、「著者名で本を検索し、それらの本をお気に入りに登録しているユーザーを一括取得したい」といったメソッドを生やしたい場合はどうでしょうか。
この場合、上記と同じ「IDのリストに詰め直す」処理を再度書かなければなりません。
そこでコレクションの登場です。
コレクションを導入する
コレクションの定義は、
プログラミングの分野で、データやオブジェクトなどをまとめて格納するためのデータ構造やクラスなどの総称をコレクションということがある。
といった感じです。
参考ページ: IT用語辞典
今回は下記のコレクションを用意します。
package collection
import "go-collection-sample/internal/entity"
// bookエンティティを束ねておくコレクション
type Books struct {
books []*entity.Book
}
func NewBooks() *Books {
return &Books{
books: []*entity.Book{},
}
}
// コレクションにbookを1つ追加
func (b *Books) Add(book *entity.Book) {
b.books = append(b.books, book)
}
// コレクションに束ねているbookのIDをスライスで取得
func (b *Books) IDs() []uint64 {
var ids []uint64
for _, book := range b.books {
ids = append(ids, book.ID)
}
return ids
}
で、repositoryを下記のように書き換えます。
package gateway
import (
"github.com/go-xorm/xorm"
"go-collection-sample/internal/collection"
"go-collection-sample/internal/entity"
)
type IBookRepository interface {
FindByAuthor(author string) (*collection.Books, error)
}
type BookRepository struct {
engine *xorm.Engine
}
func NewBookRepository(engine *xorm.Engine) *BookRepository {
return &BookRepository{
engine: engine,
}
}
type bookData struct {
Id uint64
Title string `xorm:"varchar(100)"`
Author string `xorm:"varchar(50)"`
}
// 著者名で検索し、ヒットしたbookエンティティのコレクションを返す
func (br *BookRepository) FindByAuthor(author string) (*collection.Books, error) {
// DBからAuthorを取ってくる
var results []bookData
if err := br.engine.Where("author = ?", author).Find(&results); err != nil {
return collection.NewBooks(), err
}
// bookをコレクションに詰めて返す(変更後)
books := collection.NewBooks()
for _, result := range results {
book := entity.NewBook(result.Id, result.Title, result.Author)
books.Add(book)
}
return books, nil
}
すると、先ほどのメソッドは下記のように書き換えることができます。
func (b *BookService) SearchBookIDsByAuthor(author string) ([]uint64, error) {
// DBからbookのコレクションを取ってくる
books, err := b.bookRepository.FindByAuthor(author)
if err != nil {
return []uint64{}, err
}
// コレクションにIDスライス取得の処理を任せる
return books.IDs(), nil
}
非常にシンプルになったのではないでしょうか。
記述量を減らせただけでなく、コレクションの導入によって「複数のエンティティを束ねる責務」を他のオブジェクトに移譲できています。
よって、SearchBookIDsByAuthor
メソッドの中もサッと読んで何をしているのかを理解しやすくなっているのではないでしょうか。
サービス側でエンティティのスライスを取り回す場合と、コレクションを取り回す場合を再度比較してみましょうk。
エンティティのスライスを取り回す場合
// 著者名からbookを検索し、それらのIDをスライスで返す
func (b *BookService) SearchBookIDsByAuthor(author string) ([]uint64, error) {
// DBからBookをまとめて取ってくる
books, err := b.bookRepository.FindByAuthor(author)
if err != nil {
return []uint64{}, err
}
// IDのリストに詰め直す(重要)
var ids []uint64
for _, book := range books {
ids = append(ids, book.ID)
}
return ids, nil
}
コレクションを取り回す場合
func (b *BookService) SearchBookIDsByAuthor(author string) ([]uint64, error) {
// DBからbookのコレクションを取ってくる
books, err := b.bookRepository.FindByAuthor(author)
if err != nil {
return []uint64{}, err
}
// コレクションにIDスライス取得の処理を任せる
return books.IDs(), nil
}
他のケース
他にも、「サービス内でソートを行いたいとき」にも役立ちます。
RDBだとDBから取ってくる際にソートをかけることができますが、CSVファイルからデータを取得したり、NoSQLを使う場合だとアプリケーション側でソートすることがあります。
「著者名で検索し、タイトル名の昇順でbooksのエンティティ配列を返す」というメソッドを例にしてみます。
こちらもサービス側でエンティティのスライスを取り回す場合と、コレクションを取り回す場合を比較してみましょう。
エンティティのスライスを取り回す場合
// 著者名で検索し、タイトル名の昇順で返す
func (b *BookService) SearchBooksByAuthorOrderByTitleAsc(author string) ([]*entity.Book, error) {
// DBからBookをまとめて取ってくる
books, err := b.bookRepository.FindByAuthor(author)
if err != nil {
return []*entity.Book, err
}
// Titleの昇順にソート
sort.Slice(books, func(i, j int) bool {
return books[i].Title < books[j].Title
})
// エンティティの配列を返す
return books, nil
}
「Titleの昇順にソート」というコメントを書かなかった場合を考えてみましょう。
このコメントがなければ、一度ソートをしている部分で立ち止まって、何をしているか解読するための時間が必要になるのではないでしょうか。
コレクションを取り回す場合
コレクションに下記を追加。
// タイトルの昇順にソートする
func (b *Books) SortAscByTitle() {
sort.Slice(b.books, func(i, j int) bool {
return b.books[i].Title < b.books[j].Title
})
}
// スライスに変換する
func (b *Books) Slice() []*entity.Book {
var eBooks []*entity.Book
for _, book := range b.books {
eBooks = append(eBooks, book)
}
return eBooks
}
サービスを下記のように書き換えます。
// 著者名で検索し、タイトルの昇順で返す
func (b *BookService) SearchBooksByAuthorOrderByTitleAsc(author string) ([]*entity.Book, error) {
books, err := b.bookRepository.FindByAuthor(author)
if err != nil {
return []*entity.Book, err
}
books.SortAscByTitle()
return books.Slice(), nil
}
あえてメソッドの中にコメントを書かずにコードを書いてみました。
コメントがなくとも、「何をしているか」は一目瞭然なのではないでしょうか。
ちなみに各処理を解説すると、
- リポジトリからbooksのコレクションを取ってくる
- booksをタイトルの昇順にソート
- booksをスライスに変換して返す
となります。
その他のコレクションメソッド
下記のような、スライスを扱う操作を閉じ込めることができます。
// 空である
func (b *Books)IsEmpty() bool {
if len(b.books) == 0 {
return true
}
return false
}
// 順番をシャッフルする
func (b *Books) Shuffle() {
rand.Seed(time.Now().UnixNano())
rand.Shuffle(len(b.books), func(i, j int) {
b.books[i], b.books[j] = b.books[j], b.books[i]
})
}
// 指定したインデックスの要素を削除する
func (b *Books) Delete(index int) {
var result []*entity.Book
for i, book := range b.books{
if i == index {
continue
}
result = append(result, book)
}
b.books = result
}
コレクションを使う利点
私としては下記の利点を感じました。
- スライスを扱う汎用的な操作を共通のメソッドに切り出すことができる
- スライスを扱う処理をメソッドに切り出すことで、メソッドを呼び出す部分に意味を持たせることができる
- スライスのソートといった複雑な処理をテストしやすい
最後に
今回はコレクションを導入することで得られる恩恵と、コレクションの使用例について紹介しました。
コレクションを使う分構造体は増えますし、好みはあるかもしれません。
しかしそれによって受けられる恩恵もまた大きいのではないでしょうか。
もしスライス操作のロジックが散らばって可読性が低くなっている場合は、参考にしてみてください。