はじめに
今回2回目となるハッカソンの出場となりました。実際に出場してみて、自分が日頃勉強するよりも、何倍も勉強になって、めちゃくちゃ成長を実感しました。その軌跡を残していきたいと思い、記事を書きました。
どういうハッカソンに参加したの?
Progateが主催する2日間のハッカソンイベントで、なんとAWS様と協力して、無料のAWS環境を提供してくれるという神ハッカソンでした!ちょうどAWSについて勉強していて、その勉強したことを無料で、試せるというのは、ビックチャンスなので「申し込むしかない!!」と思い、申し込みました。元々面識あるチームで参加したので、事前に何を作るかを話し合いました。
作ったものは?
メンバーが、長期インターンで、スクラム開発をしていて、その反省点や改善点をmiroというボードで書くという感じなのですが、何月何日にどの反省をしたのかが、わからない。さらに、なんの反省をしたのかを忘れる。これでは、反省した意味がない、同じ失敗を繰り返す。。ということで、、
- 何月何日の反省点や改善点はこれですと示してくれる機能 + さらにslackの通知機能でも反省点と改善点を示してくれる。
- さらにAIがその反省点や改善点をもとに次何をすべきかを教えてくれる。
これによって、スクラム開発をより効率的に行えるということで、この方針で開発が決まりました。
miroはこれ↓
自分の担当は?
私は、バックエンド担当で、Go言語について、勉強していたので、せっかくなので、Go言語を使って、バックエンド処理を書いてみようと思いました。
自分が作った部分
- miroapiを使って、ボードの付箋情報のテキスト情報を取得する。
- その取得したテキスト情報を、(PostgreSQL)データベースに格納する処理。
- データベースに格納する際のテーブル設計
- AWSのlambda関数を使って、ここの処理を毎時間ごとに自動で行ってくれる機能作成。
これらを作成しました。4については、AWS上のデータベース、RDSに格納するということは、行えましたが、自動化するという機能は作ることができませんでした。
※ Lambda から RDS に直接データを格納する方法は、データベースの負荷が高まり、パフォーマンスの低下や処理落ちのリスクがあるため、適切ではないとご指摘を受けました。
そのため、RDSではなく、DynamoDB を利用する方法を考えなければならないと思いました。
工夫した点
今回ただ、Goの処理を適当に書いただけでなく、将来の拡張・保守性を考慮して、「クリーンアーキテクチャ手法」で設計を行いました。
クリーンアーキテクチャって?
ソフトウェア設計の原則の1つで、依存関係を適切に管理し、テストしやすく、変更に強いシステムを構築することを目的としたアーキテクチャパターンのことです。
- 基本構造
- エンティティ(一番中心側):システムの最も重要なビジネスルールをカプセル化する。
アプリケーションの種類を問わず再利用可能なモデル、変更されることがほぼない層 - ユースケース:ビジネスルールを実行する、エンティティを操作し、具体的なアプリケーションの振る舞いを定義する部分です。
- インターフェースアダプター:データの入出力を行う層で、ユースケース層と一番外側のフレームワーク、ドライバの橋渡しをしてくれる部分です。
- フレームワーク、ドライバ (一番外側):最も外側の層、フレームワークや外部ライブラリとの接続部分です。DB、Webフレームワーク、認証システムなど
- エンティティ(一番中心側):システムの最も重要なビジネスルールをカプセル化する。
重要なポイント
クリーンアーキテクチャで一番重要なことは、「依存関係の方向を制御すること」です。
① 依存関係逆転の原則:外側の層(詳細)→ 内側の層(ビジネスロジック)に依存しないようにする。この依存がないようにinterfaceを活用する。
② ビジネスロジック(エンティティとユースケース):外部技術(DB,Web)に依存しないように!DBやフレームワークを変更しても、ユースケースのロジックは変えないで済むようにする。
③ テストしやすい構造:外部に依存しないため、ユースケースの単体テストが容易になる。
ここで、私は、最初つまづきました。。
- 依存関係の制御にinferfaceが絡んでくる...???
- そもそもinterfaceってなに???
次はここらへんを深掘りしたいと思います。
interfaceについて
interfaceは、簡単に言えば、「抽象化」をおこなっています。
例えば、犬、猫、たぬき、うさぎを抽象化すると、「動物」ですよね?
MySQL、SQLite、PostgreSQLを抽象化すると、「データベース」になると思います。
この抽象化というのが、とても重要です。
先ほど、基本構造で、インターフェースアダプターという層があったと思います。interfaceは、ここに位置しています。ここで、抽象化を行うことでいいことがあります。
抽象化することのメリット
クリーンアーキテクチャにおいて、interface(インターフェース)を使った抽象化は、
ユースケースが、「具体的な技術(データベース、Web API、外部ライブラリなど)に依存しない設計」を可能にします。
具体的には、
① データベースの変更が容易になる:例えば、最初は MySQL を使っていたが、後で PostgreSQL に変更することになった場合、interface を使っていれば、ユースケースのコードを変更せずに済みます。ユースケースは「データを取得する」という抽象的な処理だけを実装すればよく、どの DB から取得するかは意識しなくて済みます。
② ユースケースが影響を受けない(疎結合):具体的な DB の処理が UserRepository インターフェース に隠蔽されるため、「データの取得・保存の処理」が変わっても、ユースケースには影響を受けなくなります。また、新しいデータストレージ(DynamoDB など)を追加する場合も、既存コードをほぼ変えずに対応可能になり、開発の効率が上がります。
Goでクリーンアーキテクチャを取り入れた場合のディレクトリ構成
今回は、以下のようなディレクトリ構成にしました。
/backend
├── cmd/ # 最終的な実行部分を含む
│└── main.go
│
├── config/ # アプリケーションの設定や外部サービスの接続設定
│└── config.go
│
├── controller/
│└── controller.go
│
├── db/ # データベースとの接続を実際にしたり、SQLの実行をする。
│└── database.go
│
├── middleware/ # HTTP通信のリクエストに追加処理を加える部分
│└── middleware.go
│
├── miroapi/ # 外部APIとの通信処理
│└── miroapi.go
│
├── model/ # データベースのテーブル部分を書く
│└── board_summary.go
│
├── repository/
│└── boardrepository.go
│└── stickyrepository.go
│
├── usecase/
│└── usecase.go
│
├── validator/ # 入力データの検証を行う。APIに渡されるデータが適切かどうか、
│└── validator.go
└── go.mod
層ごとに分けると、こうなります。
(最も内側)① エンティティ層 → model(ビジネスルールを表現)
↓
② ユースケース層 → usecase(アプリケーションのビジネスロジック)
↓
③ インターフェース層 → controller, validator (APIの受け口)
↓
(最も外側)④ インフラストラクチャ層 → repository, db, miroapi, middleware, config
(データベースや外部サービス)
依存関係は、controller → Usecase → Repositoryのように(内側から外側)に向けるのが、理想的とされます。これらについて解説します。
Controllerの役割
HTTP通信で、クライアントからのリクエスト(get,POST)を受け取ります。リクエストボディやURLパラメータからデータを取り出し、適切なデータ構造に変換する。そのリクエストに応じて、ユースケースを呼び出して、処理を任せる。ユースケースからの結果を受け取って、HTTPレスポンスとして、適切な形式(JSONなど)で返す。
package controller
import (
"net/http"
"github.com/matthewTechCom/progate_hackathon/usecase"
"github.com/matthewTechCom/progate_hackathon/validator"
"github.com/labstack/echo/v4"
)
type WidgetControllerInterface interface {
ProcessBoard(ctx echo.Context) error
}
type WidgetRequest struct {
BoardID string `json:"boardID" validate:"required"`
AccessToken string `json:"accessToken" validate:"required"`
}
type WidgetController struct {
Usecase usecase.WidgetUsecaseInterface
Validator validator.ValidatorInterface
}
// コンストラクタ
func NewWidgetController(uc usecase.WidgetUsecaseInterface, v validator.ValidatorInterface) WidgetControllerInterface {
return &WidgetController{
Usecase: uc,
Validator: v,
}
}
func (c *WidgetController) ProcessBoard(ctx echo.Context) error {
req := new(WidgetRequest)
if err := ctx.Bind(req); err != nil {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "リクエストのフォーマットが不正です"})
}
if err := c.Validator.Validate(req); err != nil {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "無効なリクエストパラメータ"})
}
// ユースケースの実行:miroのボードから情報を取得してPostgreSQLに保存する
savedIDs, err := c.Usecase.ProcessAndSave(req.BoardID, req.AccessToken)
if err != nil {
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return ctx.JSON(http.StatusOK, map[string]interface{}{
"message": "情報をDBに保存しました",
"ids": savedIDs,
})
}
usecase、ValidatorのinterfaceにControllerを依存させることで、それぞれの具体的な実装に依存せず、メソッドを使えるようにしています。
Usecaseの役割
エンティティと合わせて、アプリケーションの具体的な処理を記述部分です。
repositoryに依存させ、データベースの具体的な処理はrepositoryのinterfaceに書いてあるメソッドを呼び出して、保存処理を行っています。
今回は、「Miroボードから付箋のテキストを取得し、その情報をデータベースに保存する」という処理を記述しました。
// usecase/widget_usecase.go
package usecase
import (
"fmt"
"strings"
"github.com/matthewTechCom/progate_hackathon/model"
"github.com/matthewTechCom/progate_hackathon/miroapi"
"github.com/matthewTechCom/progate_hackathon/repository"
)
type WidgetUsecaseInterface interface {
ProcessAndSave(boardID, accessToken string) ([]int, error)
}
type WidgetUsecase struct {
BoardRepo repository.BoardRepositoryInterface
StickyRepo repository.StickyRepositoryInterface
MiroAPI miroapi.MiroAPIInterface
}
func NewWidgetUsecase(boardRepo repository.BoardRepositoryInterface, stickyRepo repository.StickyRepositoryInterface, miro miroapi.MiroAPIInterface) WidgetUsecaseInterface {
return &WidgetUsecase{
BoardRepo: boardRepo,
StickyRepo: stickyRepo,
MiroAPI: miro,
}
}
func (u *WidgetUsecase) ProcessAndSave(boardID, accessToken string) ([]int, error) {
// DBに存在するかチェックし、なければ新規保存する
board, err := u.BoardRepo.GetByMiroID(boardID)
if err != nil {
// 存在しない場合、新たな board を保存
newBoard := &model.Board{
MiroBoardID: boardID,
Title: "",
Description: "",
}
boardIDInt, err := u.BoardRepo.Save(newBoard)
if err != nil {
return nil, fmt.Errorf("boardの保存に失敗: %v", err)
}
board = &model.Board{ID: boardIDInt, MiroBoardID: boardID}
}
// Miro APIからウィジェット情報を取得
widgets, err := u.MiroAPI.GetWidgets(boardID, accessToken)
if err != nil {
return nil, fmt.Errorf("miro APIから情報取得に失敗: %v", err)
}
var stickies []*model.Sticky
for _, widget := range widgets {
// ここでは widget.Text に「改善」または「反省」が含まれている場合のみ保存する
var category string
if strings.Contains(widget.Text, "改善") {
category = "改善点"
} else if strings.Contains(widget.Text, "反省") {
category = "反省点"
} else {
// 該当しない場合はスキップ
continue
}
sticky := &model.Sticky{
BoardID: board.ID,
MiroStickyID: widget.ID,
Content: widget.Text,
Category: category,
}
stickies = append(stickies, sticky)
}
// 複数の付箋を保存
savedIDs, err := u.StickyRepo.Save(stickies)
if err != nil {
return nil, fmt.Errorf("stickyの保存に失敗: %v", err)
}
return savedIDs, nil
}
Repositoryの役割
データベースに関する具体的な記述を書く部分です。
テーブル設計は以下のように行いました。
package model
import "time"
// ボード情報を表す
type Board struct {
ID int `json:"id"` // 参照されるやつ
MiroBoardID string `json:"miro_board_id"` // MiroのボードID
Title string `json:"title"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
}
// 付箋の詳しい情報を表す
type Sticky struct {
ID int `json:"id"`
BoardID int `json:"board_id"` // BoardのIDを参照する(外部キー)
MiroStickyID string `json:"miro_sticky_id"` // Miroの付箋ID
Content string `json:"content"`
Category string `json:"category"` // 改善 or 反省
CreatedAt time.Time `json:"created_at"`
}
なので、Boardテーブルに関するSQL処理(boardrepository.go)とStickyテーブルに関するSQL処理(stickyrepository.go)の2つを記述しました。
package repository
import (
"database/sql"
"github.com/matthewTechCom/progate_hackathon/model"
)
type BoardRepositoryInterface interface {
Save(board *model.Board) (int, error)
GetByMiroID(miroBoardID string) (*model.Board, error)
}
type BoardRepository struct {
DB *sql.DB
}
// コンストラクタ
func NewBoardRepository(db *sql.DB) BoardRepositoryInterface {
return &BoardRepository{DB: db}
}
// ボード情報を保存する
func (r *BoardRepository) Save(board *model.Board) (int, error) {
query := `
INSERT INTO board (miro_board_id, title, description, created_at)
VALUES ($1, $2, $3, DEFAULT)
RETURNING id
`
var id int
err := r.DB.QueryRow(query, board.MiroBoardID, board.Title, board.Description).Scan(&id)
if err != nil {
return 0, nil
}
return id, nil
}
func (r *BoardRepository) GetByMiroID(miroBoardID string) (*model.Board, error) {
query := `
SELECT id, miro_board_id, title, description, created_at
FROM board
WHERE miro_board_id = $1
`
row := r.DB.QueryRow(query, miroBoardID)
var board model.Board
err := row.Scan(&board.ID, &board.MiroBoardID, &board.Title, &board.Description, &board.CreatedAt)
if err != nil {
return nil, err
}
return &board, nil
}
package repository
import (
"database/sql"
"github.com/matthewTechCom/progate_hackathon/model"
)
type StickyRepositoryInterface interface {
Save(sticky []*model.Sticky) ([]int, error)
}
type StickyRepository struct {
DB *sql.DB
}
func NewStickyRepository(db *sql.DB) StickyRepositoryInterface {
return &StickyRepository{DB: db}
}
func (r *StickyRepository) Save(stickies []*model.Sticky) ([]int, error) {
var savedIDs []int
for _, sticky := range stickies {
query := `
INSERT INTO sticky (board_id, miro_sticky_id, content, category, created_at)
VALUES ($1, $2, $3, $4, DEFAULT)
RETURNING id
`
var id int
err := r.DB.QueryRow(query, sticky.BoardID, sticky.MiroStickyID, sticky.Content, sticky.Category).Scan(&id)
if err != nil {
return nil, err
}
savedIDs = append(savedIDs, id)
}
return savedIDs, nil
}
まとめ
今回、Go言語処理のクリーンアーキテクチャでの実装、lambda関数により自動化処理、外部APIの使用、データベースへの保存処理など、様々なことを学べました。
次なる目標は、
- 自分のアイデアを形にする:すでにアイデアは決まっており、これを実現できるアプリ開発に取り組みます。
- ハッカソンで優秀賞を目指す:完成度の高いアプリケーションを作り、コンペティションで優秀賞を獲得できるように努力します。
ありがとうございました。
作ったやつのコード:https://github.com/annyouu/go-hack-backend/tree/main/backend