├── front ├── webpack.config.dev.js ├── src │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── reportWebVitals.ts │ ├── index.tsx │ ├── components │ │ ├── Content.tsx │ │ ├── Header.tsx │ │ └── Navigator.tsx │ └── pages │ │ └── room │ │ └── Index.tsx ├── public │ └── index.html ├── .gitignore ├── tsconfig.json ├── .eslintcache ├── package.json └── README.md ├── .gitignore ├── doc ├── images │ ├── 依存関係.png │ ├── クリーンアーキテクチャ.png │ ├── 典型的なシナリオ.jpeg │ ├── Controller拡大図.png │ ├── DBHandlerの拡大図.png │ └── HttpHandlerの拡大図.png └── 依存関係図.drawio ├── worker └── Dockerfile ├── app ├── interface │ ├── adapter │ │ ├── http_context.go │ │ ├── db_handler.go │ │ └── http_error.go │ ├── repository │ │ ├── room_repository.go │ │ └── room_repository_test.go │ └── controller │ │ ├── room_controller.go │ │ └── room_controller_test.go ├── usecase │ ├── dao │ │ └── room_dao.go │ ├── input │ │ └── room_input.go │ └── service │ │ ├── room_service.go │ │ └── room_service_test.go ├── domain │ └── room.go ├── go.mod ├── Dockerfile ├── main.go ├── testdata │ └── mocks │ │ ├── http_context_mock.go │ │ └── db_handler_mock.go ├── infra │ ├── http_handler.go │ ├── router.go │ ├── http_context.go │ ├── db_handler.go │ ├── http_handler_test.go │ ├── http_context_test.go │ └── db_handler_test.go └── di │ └── container.go ├── bin └── migrate.sh ├── db └── mysql │ ├── dbconfig.yml │ ├── init │ └── setup.sql │ └── migrations │ └── 20210110062359-create_rooms.sql ├── env.development ├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── docker-compose.yml ├── LICENSE └── README.md /front/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /front/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.exe 3 | .env 4 | /app/coverage.out 5 | /app/app 6 | -------------------------------------------------------------------------------- /doc/images/依存関係.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/依存関係.png -------------------------------------------------------------------------------- /doc/images/クリーンアーキテクチャ.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/クリーンアーキテクチャ.png -------------------------------------------------------------------------------- /doc/images/典型的なシナリオ.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/典型的なシナリオ.jpeg -------------------------------------------------------------------------------- /doc/images/Controller拡大図.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/Controller拡大図.png -------------------------------------------------------------------------------- /doc/images/DBHandlerの拡大図.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/DBHandlerの拡大図.png -------------------------------------------------------------------------------- /doc/images/HttpHandlerの拡大図.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/HttpHandlerの拡大図.png -------------------------------------------------------------------------------- /worker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.15.3 2 | COPY . /simple-chat 3 | WORKDIR /simple-chat 4 | RUN go get -v github.com/rubenv/sql-migrate/... 5 | -------------------------------------------------------------------------------- /app/interface/adapter/http_context.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | type HttpContext interface { 4 | Bind(i interface{}) error 5 | JSON(code int, i interface{}) error 6 | } 7 | -------------------------------------------------------------------------------- /bin/migrate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd `dirname $0` 4 | export $(cat ../.env | grep -v ^# | xargs); 5 | 6 | OPTIONS="-config=../db/mysql/dbconfig.yml -env=$GO_ENV" 7 | sql-migrate ${@} $OPTIONS 8 | -------------------------------------------------------------------------------- /app/interface/adapter/db_handler.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | type DBHandler interface { 4 | Find(dest interface{}, conds ...interface{}) (err error) 5 | Create(value interface{}) (err error) 6 | } 7 | -------------------------------------------------------------------------------- /db/mysql/dbconfig.yml: -------------------------------------------------------------------------------- 1 | development: 2 | dialect: mysql 3 | datasource: ${MYSQL_USER}:${MYSQL_PASSWORD}@tcp(${MYSQL_HOST}:${MYSQL_PORT})/${DATABASE_NAME}?parseTime=true 4 | dir: ../db/mysql/migrations 5 | -------------------------------------------------------------------------------- /env.development: -------------------------------------------------------------------------------- 1 | GO_ENV=development 2 | HTTP_PORT=5050 3 | MYSQL_USER=simple_chat_dev 4 | MYSQL_PASSWORD=simple_chat_pw 5 | MYSQL_HOST=simple_chat_mysql 6 | MYSQL_PORT=3306 7 | DATABASE_NAME=simple_chat_dev 8 | -------------------------------------------------------------------------------- /app/usecase/dao/room_dao.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import "github.com/ryoutaku/simple-chat/app/domain" 4 | 5 | type RoomRepository interface { 6 | All() (rooms domain.Rooms, err error) 7 | Create(room *domain.Room) (err error) 8 | } 9 | -------------------------------------------------------------------------------- /app/domain/room.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Room struct { 8 | ID int 9 | Name string 10 | CreatedAt time.Time 11 | UpdatedAt time.Time 12 | } 13 | 14 | type Rooms []Room 15 | -------------------------------------------------------------------------------- /app/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ryoutaku/simple-chat/app 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/DATA-DOG/go-sqlmock v1.5.0 7 | github.com/gorilla/mux v1.8.0 8 | gorm.io/driver/mysql v1.0.3 9 | gorm.io/gorm v1.20.11 10 | ) 11 | -------------------------------------------------------------------------------- /db/mysql/init/setup.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE IF NOT EXISTS simple_chat_dev; 2 | CREATE USER IF NOT EXISTS 'simple_chat_dev'@'%' IDENTIFIED BY 'simple_chat_pw'; 3 | GRANT ALL PRIVILEGES ON simple_chat_dev.* TO 'simple_chat_dev'@'%'; 4 | FLUSH PRIVILEGES; -------------------------------------------------------------------------------- /front/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | simple chat 7 | 8 | 9 |
10 | 11 | -------------------------------------------------------------------------------- /front/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.15.3 AS builder 2 | COPY . /app 3 | WORKDIR /app 4 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ 5 | go build -o /go/bin/app -ldflags '-s -w' 6 | 7 | FROM scratch 8 | COPY --from=builder /go/bin/app /go/bin/app 9 | ENTRYPOINT ["/go/bin/app"] 10 | -------------------------------------------------------------------------------- /app/interface/adapter/http_error.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | type HttpError struct { 4 | Message string 5 | Code int 6 | } 7 | 8 | func NewHttpError(str string, code int) *HttpError { 9 | return &HttpError{ 10 | Message: str, 11 | Code: code, 12 | } 13 | } 14 | func (e *HttpError) Error() string { 15 | return e.Message 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Run Lint 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Run Test 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: golangci-lint 13 | uses: golangci/golangci-lint-action@v2 14 | with: 15 | version: v1.29 16 | working-directory: ./app -------------------------------------------------------------------------------- /app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/ryoutaku/simple-chat/app/di" 7 | 8 | "github.com/ryoutaku/simple-chat/app/infra" 9 | ) 10 | 11 | func main() { 12 | db := infra.NewDBHandler() 13 | container := di.NewContainer(db) 14 | router := infra.NewRouter(container) 15 | 16 | port := ":" + os.Getenv("HTTP_PORT") 17 | router.Start(port) 18 | } 19 | -------------------------------------------------------------------------------- /db/mysql/migrations/20210110062359-create_rooms.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +migrate Up 3 | CREATE TABLE rooms ( 4 | id INT AUTO_INCREMENT NOT NULL, 5 | name VARCHAR(255) NOT NULL, 6 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 8 | PRIMARY KEY (id) 9 | ); 10 | 11 | -- +migrate Down 12 | DROP TABLE rooms; -------------------------------------------------------------------------------- /front/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.tc.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /app/usecase/input/room_input.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type RoomService interface { 8 | All() (outData RoomsOutputData, err error) 9 | Create(inData RoomInputData) (outData RoomOutputData, err error) 10 | } 11 | 12 | type RoomInputData struct { 13 | ID int 14 | Name string 15 | } 16 | 17 | type RoomOutputData struct { 18 | ID int 19 | Name string 20 | CreatedAt time.Time 21 | UpdatedAt time.Time 22 | } 23 | 24 | type RoomsOutputData []RoomOutputData 25 | -------------------------------------------------------------------------------- /front/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /app/testdata/mocks/http_context_mock.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "github.com/ryoutaku/simple-chat/app/interface/adapter" 5 | ) 6 | 7 | type FakeHttpContext struct { 8 | adapter.HttpContext 9 | FakeBind func(i interface{}) error 10 | FakeJSON func(code int, i interface{}) error 11 | } 12 | 13 | func (port FakeHttpContext) Bind(i interface{}) error { 14 | return port.FakeBind(i) 15 | } 16 | 17 | func (port FakeHttpContext) JSON(code int, i interface{}) error { 18 | return port.FakeJSON(code, i) 19 | } 20 | -------------------------------------------------------------------------------- /app/testdata/mocks/db_handler_mock.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "github.com/ryoutaku/simple-chat/app/interface/adapter" 5 | ) 6 | 7 | type FakeDBHandler struct { 8 | adapter.DBHandler 9 | FakeFind func(dest interface{}, conds ...interface{}) (err error) 10 | FakeCreate func(value interface{}) (err error) 11 | } 12 | 13 | func (h FakeDBHandler) Find(dest interface{}, conds ...interface{}) (err error) { 14 | return h.FakeFind(dest, conds) 15 | } 16 | 17 | func (h FakeDBHandler) Create(value interface{}) (err error) { 18 | return h.FakeCreate(value) 19 | } 20 | -------------------------------------------------------------------------------- /app/infra/http_handler.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/ryoutaku/simple-chat/app/interface/adapter" 8 | ) 9 | 10 | type httpHandler func(adapter.HttpContext) *adapter.HttpError 11 | 12 | func (fn httpHandler) run(w http.ResponseWriter, r *http.Request) { 13 | context := NewHttpContext(w, r) 14 | defer func() { 15 | if err := recover(); err != nil { 16 | log.Println(err) 17 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 18 | } 19 | }() 20 | 21 | if err := fn(context); err != nil { 22 | http.Error(w, err.Error(), err.Code) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /front/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Test 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Run Test 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Set up Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: 1.15 17 | 18 | - name: Build 19 | working-directory: ./app 20 | run: go build -v ./... 21 | 22 | - name: Test 23 | working-directory: ./app 24 | run: go test -v -race -cover -coverprofile=coverage.out ./... 25 | 26 | - name: Upload coverage report 27 | uses: codecov/codecov-action@v1 28 | with: 29 | file: ./app/coverage.out 30 | -------------------------------------------------------------------------------- /app/infra/router.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/ryoutaku/simple-chat/app/di" 8 | 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | type Router struct { 13 | Engine *mux.Router 14 | } 15 | 16 | func NewRouter(c *di.Container) *Router { 17 | r := mux.NewRouter() 18 | 19 | r.HandleFunc("/", httpHandler(c.Room.Index).run).Methods("GET") 20 | r.HandleFunc("/rooms", httpHandler(c.Room.Index).run).Methods("GET") 21 | r.HandleFunc("/rooms", httpHandler(c.Room.Create).run).Methods("POST") 22 | 23 | return &Router{Engine: r} 24 | } 25 | 26 | func (r *Router) Start(port string) error { 27 | log.Println("HTTPサーバーを起動します。ポート", port) 28 | return http.ListenAndServe(port, r.Engine) 29 | } 30 | -------------------------------------------------------------------------------- /app/di/container.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "github.com/ryoutaku/simple-chat/app/interface/adapter" 5 | "github.com/ryoutaku/simple-chat/app/interface/controller" 6 | "github.com/ryoutaku/simple-chat/app/interface/repository" 7 | "github.com/ryoutaku/simple-chat/app/usecase/service" 8 | ) 9 | 10 | type Container struct { 11 | Room *controller.RoomController 12 | } 13 | 14 | func NewContainer(db adapter.DBHandler) *Container { 15 | return &Container{ 16 | Room: newRoomController(db), 17 | } 18 | } 19 | 20 | func newRoomController(db adapter.DBHandler) *controller.RoomController { 21 | r := repository.NewRoomRepository(db) 22 | s := service.NewRoomService(r) 23 | c := controller.NewRoomController(s) 24 | return c 25 | } 26 | -------------------------------------------------------------------------------- /app/interface/repository/room_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/ryoutaku/simple-chat/app/interface/adapter" 7 | 8 | "github.com/ryoutaku/simple-chat/app/domain" 9 | ) 10 | 11 | type RoomRepository struct { 12 | DBHandler adapter.DBHandler 13 | } 14 | 15 | func NewRoomRepository(db adapter.DBHandler) *RoomRepository { 16 | return &RoomRepository{DBHandler: db} 17 | } 18 | 19 | func (r *RoomRepository) All() (rooms domain.Rooms, err error) { 20 | err = r.DBHandler.Find(&rooms) 21 | if err != nil { 22 | err = errors.New("not found") 23 | } 24 | return 25 | } 26 | 27 | func (r *RoomRepository) Create(room *domain.Room) (err error) { 28 | err = r.DBHandler.Create(room) 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /front/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import RoomIndex from './pages/room/Index'; 4 | import reportWebVitals from './reportWebVitals'; 5 | import { BrowserRouter as Router, Route } from "react-router-dom"; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | 11 | 12 | 13 | , 14 | document.getElementById('root') 15 | ); 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | reportWebVitals(); 21 | -------------------------------------------------------------------------------- /app/infra/http_context.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | ) 8 | 9 | type HttpContext struct { 10 | Writer http.ResponseWriter 11 | Request *http.Request 12 | } 13 | 14 | func NewHttpContext(w http.ResponseWriter, r *http.Request) *HttpContext { 15 | return &HttpContext{ 16 | Writer: w, 17 | Request: r, 18 | } 19 | } 20 | 21 | func (c *HttpContext) Bind(i interface{}) (err error) { 22 | err = json.NewDecoder(c.Request.Body).Decode(i) 23 | if err != nil { 24 | return errors.New("invalid request") 25 | } 26 | return err 27 | } 28 | 29 | func (c *HttpContext) JSON(code int, i interface{}) (err error) { 30 | res, err := json.Marshal(i) 31 | if err != nil { 32 | return errors.New("internal server error") 33 | } 34 | 35 | c.Writer.Header().Set("Content-Type", "application/json") 36 | c.Writer.WriteHeader(code) 37 | c.Writer.Write(res) 38 | return err 39 | } 40 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | app: 5 | build: 6 | context: app 7 | env_file: 8 | - .env 9 | container_name: simple_chat_app 10 | volumes: 11 | - .:/simple_chat_app:cached 12 | tty: true 13 | stdin_open: true 14 | ports: 15 | - 5050:5050 16 | depends_on: 17 | - mysql 18 | mysql: 19 | image: mysql:5.7 20 | container_name: simple_chat_mysql 21 | restart: always 22 | command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci 23 | environment: 24 | MYSQL_ROOT_PASSWORD: password 25 | volumes: 26 | - mysql_data:/var/lib/mysql 27 | - ./db/mysql/init:/docker-entrypoint-initdb.d 28 | ports: 29 | - 3306:3306 30 | - 13306:3306 31 | woker: 32 | build: 33 | context: . 34 | dockerfile: worker/Dockerfile 35 | container_name: simple_chat_woker 36 | tty: true 37 | stdin_open: true 38 | 39 | volumes: 40 | mysql_data: 41 | driver: local 42 | -------------------------------------------------------------------------------- /front/.eslintcache: -------------------------------------------------------------------------------- 1 | [{"/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/pages/room/Index.tsx":"1","/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/components/Header.tsx":"2","/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/components/Navigator.tsx":"3"},{"size":4398,"mtime":1610582547280,"results":"4","hashOfConfig":"5"},{"size":5182,"mtime":1610485101531,"results":"6","hashOfConfig":"5"},{"size":5467,"mtime":1610489288160,"results":"7","hashOfConfig":"5"},{"filePath":"8","messages":"9","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"6v7j9e",{"filePath":"10","messages":"11","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"12","messages":"13","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/pages/room/Index.tsx",[],"/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/components/Header.tsx",[],"/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/components/Navigator.tsx",[]] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 ryotaku nakagawa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/infra/db_handler.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | _ "time/tzdata" 9 | 10 | "gorm.io/driver/mysql" 11 | "gorm.io/gorm" 12 | ) 13 | 14 | type DBHandler struct { 15 | DB *gorm.DB 16 | } 17 | 18 | func NewDBHandler() *DBHandler { 19 | user := os.Getenv("MYSQL_USER") 20 | password := os.Getenv("MYSQL_PASSWORD") 21 | host := os.Getenv("MYSQL_HOST") 22 | port := os.Getenv("MYSQL_PORT") 23 | dbName := os.Getenv("DATABASE_NAME") 24 | 25 | dsn := fmt.Sprintf( 26 | "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=%s", 27 | user, password, host, port, dbName, "Asia%2FTokyo", 28 | ) 29 | db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) 30 | if err != nil { 31 | log.Panic(err) 32 | } 33 | return &DBHandler{DB: db} 34 | } 35 | 36 | func (h *DBHandler) Find(dest interface{}, conds ...interface{}) (err error) { 37 | result := new(gorm.DB) 38 | if len(conds) == 0 { 39 | result = h.DB.Find(dest) 40 | } else { 41 | result = h.DB.Find(dest, conds) 42 | } 43 | err = result.Error 44 | return 45 | } 46 | 47 | func (h *DBHandler) Create(value interface{}) (err error) { 48 | result := h.DB.Create(value) 49 | err = result.Error 50 | return 51 | } 52 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "front", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.11.2", 7 | "@material-ui/icons": "^4.11.2", 8 | "@testing-library/jest-dom": "^5.11.4", 9 | "@testing-library/react": "^11.1.0", 10 | "@testing-library/user-event": "^12.1.10", 11 | "@types/jest": "^26.0.15", 12 | "@types/node": "^12.0.0", 13 | "@types/react": "^16.9.53", 14 | "@types/react-dom": "^16.9.8", 15 | "@types/react-router": "^5.1.11", 16 | "@types/react-router-dom": "^5.1.7", 17 | "axios": "^0.21.1", 18 | "react": "^17.0.1", 19 | "react-dom": "^17.0.1", 20 | "react-router": "^5.2.0", 21 | "react-router-dom": "^5.2.0", 22 | "react-scripts": "4.0.1", 23 | "typescript": "^4.0.3", 24 | "web-vitals": "^0.2.4" 25 | }, 26 | "scripts": { 27 | "start": "react-scripts start", 28 | "build": "react-scripts build", 29 | "test": "react-scripts test", 30 | "eject": "react-scripts eject" 31 | }, 32 | "eslintConfig": { 33 | "extends": [ 34 | "react-app", 35 | "react-app/jest" 36 | ] 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | }, 50 | "devDependencies": { 51 | "webpack-dev-server": "^3.11.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/usecase/service/room_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/ryoutaku/simple-chat/app/domain" 5 | "github.com/ryoutaku/simple-chat/app/usecase/dao" 6 | "github.com/ryoutaku/simple-chat/app/usecase/input" 7 | ) 8 | 9 | type RoomService struct { 10 | Repository dao.RoomRepository 11 | } 12 | 13 | func NewRoomService(repo dao.RoomRepository) *RoomService { 14 | return &RoomService{Repository: repo} 15 | } 16 | 17 | func (s *RoomService) All() (outData input.RoomsOutputData, err error) { 18 | rooms, err := s.Repository.All() 19 | if err != nil { 20 | return 21 | } 22 | 23 | outData = convertToRoomsOutputData(&rooms) 24 | return 25 | } 26 | 27 | func (s *RoomService) Create(inData input.RoomInputData) (outData input.RoomOutputData, err error) { 28 | room := convertToRoomDomain(&inData) 29 | err = s.Repository.Create(&room) 30 | if err != nil { 31 | return 32 | } 33 | 34 | outData = convertToRoomOutputData(&room) 35 | return 36 | } 37 | 38 | func convertToRoomDomain(inData *input.RoomInputData) (room domain.Room) { 39 | room.ID = inData.ID 40 | room.Name = inData.Name 41 | return 42 | } 43 | 44 | func convertToRoomOutputData(room *domain.Room) (outData input.RoomOutputData) { 45 | outData.ID = room.ID 46 | outData.Name = room.Name 47 | outData.CreatedAt = room.CreatedAt 48 | outData.UpdatedAt = room.UpdatedAt 49 | return 50 | } 51 | 52 | func convertToRoomsOutputData(rooms *domain.Rooms) (outData input.RoomsOutputData) { 53 | for _, room := range *rooms { 54 | var data input.RoomOutputData 55 | data = convertToRoomOutputData(&room) 56 | outData = append(outData, data) 57 | } 58 | return 59 | } 60 | -------------------------------------------------------------------------------- /app/interface/controller/room_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/ryoutaku/simple-chat/app/interface/adapter" 8 | 9 | "github.com/ryoutaku/simple-chat/app/usecase/input" 10 | ) 11 | 12 | type roomResponse struct { 13 | ID int `json:"id"` 14 | Name string `json:"name"` 15 | CreatedAt time.Time `json:"createdAt"` 16 | UpdatedAt time.Time `json:"updatedAt"` 17 | } 18 | 19 | type roomsResponse []roomResponse 20 | 21 | type RoomController struct { 22 | Service input.RoomService 23 | } 24 | 25 | func NewRoomController(s input.RoomService) *RoomController { 26 | return &RoomController{Service: s} 27 | } 28 | 29 | func (c *RoomController) Index(hc adapter.HttpContext) *adapter.HttpError { 30 | rooms, err := c.Service.All() 31 | if err != nil { 32 | return adapter.NewHttpError(err.Error(), http.StatusBadRequest) 33 | } 34 | 35 | respBody := convertRoomsResponse(&rooms) 36 | if err := hc.JSON(http.StatusCreated, respBody); err != nil { 37 | return adapter.NewHttpError(err.Error(), http.StatusInternalServerError) 38 | } 39 | return nil 40 | } 41 | 42 | func (c *RoomController) Create(context adapter.HttpContext) *adapter.HttpError { 43 | var inputData input.RoomInputData 44 | if err := context.Bind(&inputData); err != nil { 45 | return adapter.NewHttpError(err.Error(), http.StatusBadRequest) 46 | } 47 | 48 | room, err := c.Service.Create(inputData) 49 | if err != nil { 50 | return adapter.NewHttpError(err.Error(), http.StatusBadRequest) 51 | } 52 | 53 | respBody := convertRoomResponse(&room) 54 | if err = context.JSON(http.StatusCreated, respBody); err != nil { 55 | return adapter.NewHttpError(err.Error(), http.StatusInternalServerError) 56 | } 57 | return nil 58 | } 59 | 60 | func convertRoomResponse(room *input.RoomOutputData) (resp roomResponse) { 61 | resp.ID = room.ID 62 | resp.Name = room.Name 63 | resp.CreatedAt = room.CreatedAt 64 | resp.UpdatedAt = room.UpdatedAt 65 | return 66 | } 67 | 68 | func convertRoomsResponse(rooms *input.RoomsOutputData) (resp roomsResponse) { 69 | for _, room := range *rooms { 70 | var r roomResponse 71 | r = convertRoomResponse(&room) 72 | resp = append(resp, r) 73 | } 74 | return 75 | } 76 | -------------------------------------------------------------------------------- /front/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React Index 2 | 3 | This project was bootstrapped with [Create React Index](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React Index documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /app/infra/http_handler_test.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/ryoutaku/simple-chat/app/interface/adapter" 9 | ) 10 | 11 | func newFakeHttpHandler(code int, message string) httpHandler { 12 | return func(c adapter.HttpContext) *adapter.HttpError { 13 | switch code { 14 | case 500: 15 | panic("panic test") 16 | case 400, 403, 404: 17 | return adapter.NewHttpError(message, code) 18 | default: 19 | return nil 20 | } 21 | } 22 | } 23 | 24 | func TestRun(t *testing.T) { 25 | testCases := []struct { 26 | name string 27 | handler httpHandler 28 | expectedBody string 29 | expectedCode int 30 | }{ 31 | { 32 | name: "normal", 33 | handler: newFakeHttpHandler(200, ""), 34 | expectedBody: "", 35 | expectedCode: 200, 36 | }, 37 | { 38 | name: "recover panic", 39 | handler: newFakeHttpHandler(500, ""), 40 | expectedBody: "Internal Server Error\n", 41 | expectedCode: 500, 42 | }, 43 | { 44 | name: "error handling 400", 45 | handler: newFakeHttpHandler(400, "test error 400"), 46 | expectedBody: "test error 400\n", 47 | expectedCode: 400, 48 | }, 49 | { 50 | name: "error handling 403", 51 | handler: newFakeHttpHandler(403, "test error 403"), 52 | expectedBody: "test error 403\n", 53 | expectedCode: 403, 54 | }, 55 | { 56 | name: "error handling 404", 57 | handler: newFakeHttpHandler(404, "test error 404"), 58 | expectedBody: "test error 404\n", 59 | expectedCode: 404, 60 | }, 61 | } 62 | 63 | for _, tc := range testCases { 64 | tc := tc 65 | t.Run(tc.name, func(t *testing.T) { 66 | t.Parallel() 67 | r := httptest.NewRequest("Post", "/dummy", nil) 68 | w := httptest.NewRecorder() 69 | tc.handler.run(w, r) 70 | 71 | resp := w.Result() 72 | if resp.StatusCode != tc.expectedCode { 73 | t.Errorf("%v: context.JSON StatusCode expected = %v, got = %v", tc.name, tc.expectedCode, resp.StatusCode) 74 | } 75 | 76 | body, _ := ioutil.ReadAll(resp.Body) 77 | if string(body) != tc.expectedBody { 78 | t.Errorf("%v: context.JSON ResponseBody expected = %v, got = %v", 79 | tc.name, tc.expectedBody, string(body)) 80 | } 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/infra/http_context_test.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "math" 7 | "net/http" 8 | "net/http/httptest" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | type dummyInputData struct { 15 | ID int 16 | Name string 17 | } 18 | 19 | type dummyResponseBody struct { 20 | ID int `json:"id"` 21 | Name string `json:"name"` 22 | } 23 | 24 | func TestBind(t *testing.T) { 25 | testCases := []struct { 26 | name string 27 | body string 28 | expectedErr error 29 | expectedData *dummyInputData 30 | }{ 31 | { 32 | name: "normal", 33 | body: `{"id":1,"name":"test"}`, 34 | expectedErr: nil, 35 | expectedData: &dummyInputData{ID: 1, Name: "test"}, 36 | }, 37 | { 38 | name: "invalid request", 39 | body: `{"id":"test","name":1}`, 40 | expectedErr: errors.New("invalid request"), 41 | expectedData: &dummyInputData{}, 42 | }, 43 | } 44 | 45 | for _, tc := range testCases { 46 | tc := tc 47 | t.Run(tc.name, func(t *testing.T) { 48 | t.Parallel() 49 | body := strings.NewReader(tc.body) 50 | r := httptest.NewRequest("Post", "/dummies", body) 51 | w := httptest.NewRecorder() 52 | 53 | context := NewHttpContext(w, r) 54 | data := new(dummyInputData) 55 | 56 | err := context.Bind(&data) 57 | if !reflect.DeepEqual(tc.expectedErr, err) { 58 | t.Errorf("context.Bind expected = %v, got = %v", tc.expectedErr, err) 59 | } 60 | if !reflect.DeepEqual(tc.expectedData, data) { 61 | t.Errorf("context.Bind expected = %v, got = %v", tc.expectedData, data) 62 | } 63 | }) 64 | } 65 | } 66 | 67 | func TestJSON(t *testing.T) { 68 | testCases := []struct { 69 | name string 70 | body interface{} 71 | code int 72 | expectedErr error 73 | expectedBody string 74 | }{ 75 | { 76 | name: "normal", 77 | body: dummyResponseBody{ID: 1, Name: "test"}, 78 | code: http.StatusOK, 79 | expectedErr: nil, 80 | expectedBody: "{\"id\":1,\"name\":\"test\"}", 81 | }, 82 | { 83 | name: "json.Marshal Error", 84 | body: math.Inf(0), 85 | code: http.StatusOK, 86 | expectedErr: errors.New("internal server error"), 87 | expectedBody: "", 88 | }, 89 | } 90 | 91 | for _, tc := range testCases { 92 | tc := tc 93 | t.Run(tc.name, func(t *testing.T) { 94 | t.Parallel() 95 | r := httptest.NewRequest("Get", "/dummy", nil) 96 | w := httptest.NewRecorder() 97 | context := NewHttpContext(w, r) 98 | 99 | err := context.JSON(tc.code, tc.body) 100 | if !reflect.DeepEqual(tc.expectedErr, err) { 101 | t.Errorf("context.JSON expected = %v, got = %v", tc.expectedErr, err) 102 | } 103 | 104 | resp := w.Result() 105 | if resp.StatusCode != tc.code { 106 | t.Errorf("context.JSON StatusCode expected = %v, got = %v", tc.code, resp.StatusCode) 107 | } 108 | 109 | body, _ := ioutil.ReadAll(resp.Body) 110 | if string(body) != tc.expectedBody { 111 | t.Errorf("context.JSON ResponseBody expected = %v, got = %v", tc.expectedBody, string(body)) 112 | } 113 | }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /front/src/components/Content.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AppBar from '@material-ui/core/AppBar'; 3 | import Toolbar from '@material-ui/core/Toolbar'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import Paper from '@material-ui/core/Paper'; 6 | import Grid from '@material-ui/core/Grid'; 7 | import Button from '@material-ui/core/Button'; 8 | import TextField from '@material-ui/core/TextField'; 9 | import Tooltip from '@material-ui/core/Tooltip'; 10 | import IconButton from '@material-ui/core/IconButton'; 11 | import { createStyles, Theme, withStyles, WithStyles } from '@material-ui/core/styles'; 12 | import SearchIcon from '@material-ui/icons/Search'; 13 | import RefreshIcon from '@material-ui/icons/Refresh'; 14 | 15 | const styles = (theme: Theme) => 16 | createStyles({ 17 | paper: { 18 | maxWidth: 936, 19 | margin: 'auto', 20 | overflow: 'hidden', 21 | }, 22 | searchBar: { 23 | borderBottom: '1px solid rgba(0, 0, 0, 0.12)', 24 | }, 25 | searchInput: { 26 | fontSize: theme.typography.fontSize, 27 | }, 28 | block: { 29 | display: 'block', 30 | }, 31 | addUser: { 32 | marginRight: theme.spacing(1), 33 | }, 34 | contentWrapper: { 35 | margin: '40px 16px', 36 | }, 37 | }); 38 | 39 | export interface ContentProps extends WithStyles {} 40 | 41 | function Content(props: ContentProps) { 42 | const { classes } = props; 43 | 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 61 | 62 | 63 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
76 | 77 | No users for this project yet 78 | 79 |
80 |
81 | ); 82 | } 83 | 84 | export default withStyles(styles)(Content); 85 | -------------------------------------------------------------------------------- /app/interface/repository/room_repository_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "testing" 8 | "time" 9 | 10 | "github.com/ryoutaku/simple-chat/app/testdata/mocks" 11 | 12 | "github.com/ryoutaku/simple-chat/app/domain" 13 | ) 14 | 15 | var dummyRoom = domain.Room{ 16 | ID: 1, 17 | Name: "テストルーム1", 18 | } 19 | 20 | var expectedRoom = domain.Room{ 21 | ID: 1, 22 | Name: "テストルーム1", 23 | CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local), 24 | UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local), 25 | } 26 | 27 | var dummyRooms = domain.Rooms{ 28 | domain.Room{ 29 | ID: 1, 30 | Name: "テストルーム1", 31 | CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local), 32 | UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local), 33 | }, 34 | } 35 | 36 | func TestAll(t *testing.T) { 37 | testCases := []struct { 38 | name string 39 | dbHandlerErr error 40 | expectedReturn domain.Rooms 41 | expectedError error 42 | }{ 43 | { 44 | name: "normal", 45 | dbHandlerErr: nil, 46 | expectedReturn: dummyRooms, 47 | expectedError: nil, 48 | }, 49 | { 50 | name: "dbHandler.Find Error", 51 | dbHandlerErr: errors.New("dbHandler.Find Error"), 52 | expectedReturn: domain.Rooms{}, 53 | expectedError: errors.New("not found"), 54 | }, 55 | } 56 | 57 | for _, tc := range testCases { 58 | tc := tc 59 | t.Run(tc.name, func(t *testing.T) { 60 | t.Parallel() 61 | fakeDBHandler := mocks.FakeDBHandler{ 62 | FakeFind: func(dest interface{}, conds ...interface{}) error { 63 | if tc.dbHandlerErr == nil { 64 | pv := reflect.ValueOf(dest) 65 | vv := reflect.ValueOf(dummyRooms) 66 | pv.Elem().Set(vv) 67 | } 68 | return tc.dbHandlerErr 69 | }, 70 | } 71 | repository := NewRoomRepository(fakeDBHandler) 72 | 73 | rooms, err := repository.All() 74 | if !reflect.DeepEqual(tc.expectedError, err) { 75 | t.Errorf("%v: repository.All error expected = %v, got = %v", tc.name, tc.expectedError, err) 76 | } 77 | if err == nil && !reflect.DeepEqual(tc.expectedReturn, rooms) { 78 | t.Errorf("%v: repository.All return expected = %v, got = %v", tc.name, tc.expectedReturn, rooms) 79 | } 80 | }) 81 | } 82 | } 83 | 84 | func TestCreate(t *testing.T) { 85 | testCases := []struct { 86 | name string 87 | room domain.Room 88 | dbHandlerErr error 89 | expectedError error 90 | }{ 91 | { 92 | name: "normal", 93 | room: dummyRoom, 94 | dbHandlerErr: nil, 95 | expectedError: nil, 96 | }, 97 | { 98 | name: "dbHandler.Find Error", 99 | room: dummyRoom, 100 | dbHandlerErr: errors.New("dbHandler.Create Error"), 101 | expectedError: errors.New("dbHandler.Create Error"), 102 | }, 103 | } 104 | 105 | for _, tc := range testCases { 106 | tc := tc 107 | t.Run(tc.name, func(t *testing.T) { 108 | t.Parallel() 109 | fakeDBHandler := mocks.FakeDBHandler{ 110 | FakeCreate: func(value interface{}) error { 111 | if tc.dbHandlerErr == nil { 112 | pv := reflect.ValueOf(value) 113 | vv := reflect.ValueOf(expectedRoom) 114 | pv.Elem().Set(vv) 115 | } 116 | return tc.dbHandlerErr 117 | }, 118 | } 119 | repository := NewRoomRepository(fakeDBHandler) 120 | 121 | err := repository.Create(&tc.room) 122 | if !reflect.DeepEqual(tc.expectedError, err) { 123 | fmt.Println(tc.expectedError) 124 | fmt.Println(err) 125 | t.Errorf("repository.All error expected = %v, got = %v", tc.expectedError, err) 126 | } 127 | if err == nil && !reflect.DeepEqual(expectedRoom, tc.room) { 128 | t.Errorf("repository.All return expected = %v, got = %v", expectedRoom, tc.room) 129 | } 130 | }) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /app/infra/db_handler_test.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "database/sql" 5 | "reflect" 6 | "regexp" 7 | "testing" 8 | "time" 9 | 10 | "github.com/ryoutaku/simple-chat/app/interface/adapter" 11 | 12 | "github.com/DATA-DOG/go-sqlmock" 13 | "gorm.io/driver/mysql" 14 | "gorm.io/gorm" 15 | 16 | _ "time/tzdata" 17 | ) 18 | 19 | type dummy struct { 20 | ID int 21 | Name string 22 | CreatedAt time.Time 23 | UpdatedAt time.Time 24 | } 25 | 26 | func newDummyDBHandler() (adapter.DBHandler, sqlmock.Sqlmock, *sql.DB) { 27 | db, mock, _ := sqlmock.New() 28 | gdb, _ := gorm.Open(mysql.Dialector{ 29 | Config: &mysql.Config{ 30 | DriverName: "mysql", Conn: db, SkipInitializeWithVersion: true, 31 | }, 32 | }, &gorm.Config{}) 33 | dbHandler := DBHandler{DB: gdb} 34 | return &dbHandler, mock, db 35 | } 36 | 37 | func TestFind(t *testing.T) { 38 | testCases := []struct { 39 | name string 40 | targetID int 41 | targetName string 42 | conditions interface{} 43 | expectedTime time.Time 44 | }{ 45 | { 46 | name: "normal", 47 | targetID: 1, 48 | targetName: "test", 49 | conditions: nil, 50 | expectedTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local), 51 | }, 52 | { 53 | name: "normal conditions", 54 | targetID: 1, 55 | targetName: "test", 56 | conditions: 10, 57 | expectedTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local), 58 | }, 59 | } 60 | 61 | for _, tc := range testCases { 62 | tc := tc 63 | t.Run(tc.name, func(t *testing.T) { 64 | t.Parallel() 65 | handler, mock, db := newDummyDBHandler() 66 | defer db.Close() 67 | 68 | mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `dummies`")). 69 | WillReturnRows(sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at"}). 70 | AddRow(tc.targetID, tc.targetName, tc.expectedTime, tc.expectedTime)) 71 | 72 | dummyData := &dummy{ 73 | ID: tc.targetID, 74 | Name: tc.targetName, 75 | } 76 | expectedDummy := &dummy{ 77 | ID: tc.targetID, 78 | Name: tc.targetName, 79 | CreatedAt: tc.expectedTime, 80 | UpdatedAt: tc.expectedTime, 81 | } 82 | 83 | var err error 84 | if tc.conditions != nil { 85 | err = handler.Find(&dummyData, tc.conditions) 86 | } else { 87 | err = handler.Find(&dummyData) 88 | } 89 | if err != nil { 90 | t.Errorf("error '%s' was not expected", err) 91 | } 92 | if !reflect.DeepEqual(dummyData, expectedDummy) { 93 | t.Errorf("dummyData expected = %v, got = %v", dummyData, expectedDummy) 94 | } 95 | }) 96 | } 97 | } 98 | 99 | func TestCreate(t *testing.T) { 100 | testCases := []struct { 101 | name string 102 | id int 103 | targetName string 104 | }{ 105 | { 106 | name: "normal", 107 | id: 10, 108 | targetName: "AAAA", 109 | }, 110 | } 111 | 112 | for _, tc := range testCases { 113 | tc := tc 114 | t.Run(tc.name, func(t *testing.T) { 115 | t.Parallel() 116 | handler, mock, db := newDummyDBHandler() 117 | defer db.Close() 118 | 119 | mock.ExpectExec(regexp.QuoteMeta( 120 | "INSERT INTO `dummies` (`name`,`created_at`,`updated_at`)")). 121 | WillReturnResult(sqlmock.NewResult(int64(tc.id), 1)) 122 | 123 | dummyData := &dummy{Name: tc.targetName} 124 | beforeTime := time.Now() 125 | 126 | err := handler.Create(dummyData) 127 | if err != nil { 128 | t.Errorf("error '%s' was not expected", err) 129 | } 130 | if dummyData.CreatedAt.Before(beforeTime) { 131 | t.Errorf("dummyData.CreatedAt expected higher than beforeTime: %v, CreatedAt = %v", beforeTime, dummyData.CreatedAt) 132 | } 133 | if dummyData.UpdatedAt.Before(beforeTime) { 134 | t.Errorf("dummyData.UpdatedAt expected higher than beforeTime: %v, UpdatedAt = %v", beforeTime, dummyData.UpdatedAt) 135 | } 136 | }) 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /app/usecase/service/room_service_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/ryoutaku/simple-chat/app/usecase/dao" 10 | 11 | "github.com/ryoutaku/simple-chat/app/domain" 12 | "github.com/ryoutaku/simple-chat/app/usecase/input" 13 | ) 14 | 15 | type fakeRoomRepository struct { 16 | dao.RoomRepository 17 | fakeAll func() (rooms domain.Rooms, err error) 18 | fakeCreate func(room *domain.Room) (err error) 19 | } 20 | 21 | func (r fakeRoomRepository) All() (rooms domain.Rooms, err error) { 22 | return r.fakeAll() 23 | } 24 | 25 | func (r fakeRoomRepository) Create(room *domain.Room) (err error) { 26 | return r.fakeCreate(room) 27 | } 28 | 29 | var roomDomain = domain.Room{ 30 | ID: 1, 31 | Name: "テストルーム1", 32 | CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local), 33 | UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local), 34 | } 35 | 36 | var roomsDomain = domain.Rooms{ 37 | roomDomain, 38 | } 39 | 40 | var roomOutputData = input.RoomOutputData{ 41 | ID: 1, 42 | Name: "テストルーム1", 43 | CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local), 44 | UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local), 45 | } 46 | 47 | var roomsOutputData = input.RoomsOutputData{ 48 | roomOutputData, 49 | } 50 | 51 | var roomInputData = input.RoomInputData{ 52 | Name: "テストルーム1", 53 | } 54 | 55 | func TestAll(t *testing.T) { 56 | testCases := []struct { 57 | name string 58 | repositoryReturn domain.Rooms 59 | repositoryErr error 60 | expectedReturn input.RoomsOutputData 61 | expectedError error 62 | }{ 63 | { 64 | name: "normal", 65 | repositoryReturn: roomsDomain, 66 | repositoryErr: nil, 67 | expectedReturn: roomsOutputData, 68 | expectedError: nil, 69 | }, 70 | { 71 | name: "Repository Error", 72 | repositoryReturn: domain.Rooms{}, 73 | repositoryErr: errors.New("repository error"), 74 | expectedReturn: input.RoomsOutputData{}, 75 | expectedError: errors.New("repository error"), 76 | }, 77 | } 78 | 79 | for _, tc := range testCases { 80 | tc := tc 81 | t.Run(tc.name, func(t *testing.T) { 82 | t.Parallel() 83 | fakeRepository := fakeRoomRepository{ 84 | fakeAll: func() (rooms domain.Rooms, err error) { 85 | return tc.repositoryReturn, tc.repositoryErr 86 | }, 87 | } 88 | service := NewRoomService(fakeRepository) 89 | 90 | outputData, err := service.All() 91 | if !reflect.DeepEqual(tc.expectedError, err) { 92 | t.Errorf("service.All error expected = %v, got = %v", tc.expectedError, err) 93 | } 94 | if err == nil && !reflect.DeepEqual(tc.expectedReturn, outputData) { 95 | t.Errorf("service.All return expected = %v, got = %v", tc.expectedReturn, outputData) 96 | } 97 | }) 98 | } 99 | } 100 | 101 | func TestCreate(t *testing.T) { 102 | testCases := []struct { 103 | name string 104 | repositoryErr error 105 | expectedReturn input.RoomOutputData 106 | expectedError error 107 | }{ 108 | { 109 | name: "normal", 110 | repositoryErr: nil, 111 | expectedReturn: roomOutputData, 112 | expectedError: nil, 113 | }, 114 | { 115 | name: "Repository Error", 116 | repositoryErr: errors.New("repository error"), 117 | expectedReturn: input.RoomOutputData{Name: "テストルーム1"}, 118 | expectedError: errors.New("repository error"), 119 | }, 120 | } 121 | 122 | for _, tc := range testCases { 123 | tc := tc 124 | t.Run(tc.name, func(t *testing.T) { 125 | t.Parallel() 126 | fakeRepository := fakeRoomRepository{ 127 | fakeCreate: func(room *domain.Room) (err error) { 128 | if tc.repositoryErr == nil { 129 | room.ID = 1 130 | room.CreatedAt = time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) 131 | room.UpdatedAt = time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) 132 | } 133 | return tc.repositoryErr 134 | }, 135 | } 136 | service := NewRoomService(fakeRepository) 137 | 138 | outData, err := service.Create(roomInputData) 139 | if !reflect.DeepEqual(tc.expectedError, err) { 140 | t.Errorf("service.Create error expected = %v, got = %v", tc.expectedError, err) 141 | } 142 | if err == nil && !reflect.DeepEqual(tc.expectedReturn, outData) { 143 | t.Errorf("service.Create return expected = %v, got = %v", tc.expectedReturn, outData) 144 | } 145 | }) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /app/interface/controller/room_controller_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/ryoutaku/simple-chat/app/interface/adapter" 10 | 11 | "github.com/ryoutaku/simple-chat/app/testdata/mocks" 12 | 13 | "github.com/ryoutaku/simple-chat/app/usecase/input" 14 | ) 15 | 16 | type fakeRoomService struct { 17 | input.RoomService 18 | fakeAll func() (outData input.RoomsOutputData, err error) 19 | fakeCreate func(inData input.RoomInputData) (outData input.RoomOutputData, err error) 20 | } 21 | 22 | func (i fakeRoomService) All() (outData input.RoomsOutputData, err error) { 23 | return i.fakeAll() 24 | } 25 | 26 | func (i fakeRoomService) Create(inData input.RoomInputData) (outData input.RoomOutputData, err error) { 27 | return i.fakeCreate(inData) 28 | } 29 | 30 | var roomOutputData = input.RoomOutputData{ 31 | ID: 1, 32 | Name: "テストルーム1", 33 | CreatedAt: time.Now(), 34 | UpdatedAt: time.Now(), 35 | } 36 | 37 | var roomsOutputData = input.RoomsOutputData{ 38 | roomOutputData, 39 | } 40 | 41 | func TestRoomControllerIndex(t *testing.T) { 42 | testCases := []struct { 43 | name string 44 | serviceReturn input.RoomsOutputData 45 | serviceErr error 46 | jsonErr error 47 | expected *adapter.HttpError 48 | }{ 49 | { 50 | name: "normal", 51 | serviceReturn: roomsOutputData, 52 | serviceErr: nil, 53 | jsonErr: nil, 54 | expected: nil, 55 | }, 56 | { 57 | name: "serviceError", 58 | serviceReturn: input.RoomsOutputData{}, 59 | serviceErr: errors.New("serviceError"), 60 | jsonErr: nil, 61 | expected: adapter.NewHttpError("serviceError", 400), 62 | }, 63 | { 64 | name: "jsonError", 65 | serviceReturn: roomsOutputData, 66 | serviceErr: nil, 67 | jsonErr: errors.New("jsonError"), 68 | expected: adapter.NewHttpError("jsonError", 500), 69 | }, 70 | } 71 | 72 | for _, tc := range testCases { 73 | tc := tc 74 | t.Run(tc.name, func(t *testing.T) { 75 | t.Parallel() 76 | context := mocks.FakeHttpContext{ 77 | FakeJSON: func(int, interface{}) error { return tc.jsonErr }, 78 | } 79 | 80 | service := fakeRoomService{ 81 | fakeAll: func() (outData input.RoomsOutputData, err error) { 82 | return tc.serviceReturn, tc.serviceErr 83 | }, 84 | } 85 | controller := NewRoomController(service) 86 | 87 | if err := controller.Index(context); !reflect.DeepEqual(tc.expected, err) { 88 | t.Errorf("%v: controller.Index expected = %v, got = %v", tc.name, tc.expected, err) 89 | } 90 | }) 91 | } 92 | } 93 | 94 | func TestRoomControllerCreate(t *testing.T) { 95 | testCases := []struct { 96 | name string 97 | bindErr error 98 | serviceReturn input.RoomOutputData 99 | serviceErr error 100 | jsonErr error 101 | expected *adapter.HttpError 102 | }{ 103 | { 104 | name: "normal", 105 | bindErr: nil, 106 | serviceReturn: roomOutputData, 107 | serviceErr: nil, 108 | jsonErr: nil, 109 | expected: nil, 110 | }, 111 | { 112 | name: "bindError", 113 | bindErr: errors.New("bindError"), 114 | serviceReturn: input.RoomOutputData{}, 115 | serviceErr: nil, 116 | jsonErr: nil, 117 | expected: adapter.NewHttpError("bindError", 400), 118 | }, 119 | { 120 | name: "serviceError", 121 | bindErr: nil, 122 | serviceReturn: input.RoomOutputData{}, 123 | serviceErr: errors.New("serviceError"), 124 | jsonErr: nil, 125 | expected: adapter.NewHttpError("serviceError", 400), 126 | }, 127 | { 128 | name: "JSONError", 129 | bindErr: nil, 130 | serviceReturn: roomOutputData, 131 | serviceErr: nil, 132 | jsonErr: errors.New("JSONError"), 133 | expected: adapter.NewHttpError("JSONError", 500), 134 | }, 135 | } 136 | 137 | for _, tc := range testCases { 138 | tc := tc 139 | t.Run(tc.name, func(t *testing.T) { 140 | t.Parallel() 141 | context := mocks.FakeHttpContext{ 142 | FakeBind: func(i interface{}) error { return tc.bindErr }, 143 | FakeJSON: func(int, interface{}) error { return tc.jsonErr }, 144 | } 145 | 146 | service := fakeRoomService{ 147 | fakeCreate: func(input.RoomInputData) (input.RoomOutputData, error) { 148 | return tc.serviceReturn, tc.serviceErr 149 | }, 150 | } 151 | controller := NewRoomController(service) 152 | 153 | if err := controller.Create(context); !reflect.DeepEqual(tc.expected, err) { 154 | t.Errorf("%v: controller.Index expected = %v, got = %v", tc.name, tc.expected, err) 155 | } 156 | }) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /front/src/pages/room/Index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | createMuiTheme, 4 | createStyles, 5 | ThemeProvider, 6 | withStyles, 7 | WithStyles, 8 | } from '@material-ui/core/styles'; 9 | import CssBaseline from '@material-ui/core/CssBaseline'; 10 | import Hidden from '@material-ui/core/Hidden'; 11 | import Typography from '@material-ui/core/Typography'; 12 | import Link from '@material-ui/core/Link'; 13 | import Navigator from '../../components/Navigator'; 14 | import Content from '../../components/Content'; 15 | import Header from '../../components/Header'; 16 | 17 | function Copyright() { 18 | return ( 19 | 20 | {'Copyright © '} 21 | 22 | Your Website 23 | {' '} 24 | {new Date().getFullYear()} 25 | {'.'} 26 | 27 | ); 28 | } 29 | 30 | let theme = createMuiTheme({ 31 | palette: { 32 | primary: { 33 | light: '#63ccff', 34 | main: '#009be5', 35 | dark: '#006db3', 36 | }, 37 | }, 38 | typography: { 39 | h5: { 40 | fontWeight: 500, 41 | fontSize: 26, 42 | letterSpacing: 0.5, 43 | }, 44 | }, 45 | shape: { 46 | borderRadius: 8, 47 | }, 48 | props: { 49 | MuiTab: { 50 | disableRipple: true, 51 | }, 52 | }, 53 | mixins: { 54 | toolbar: { 55 | minHeight: 48, 56 | }, 57 | }, 58 | }); 59 | 60 | theme = { 61 | ...theme, 62 | overrides: { 63 | MuiDrawer: { 64 | paper: { 65 | backgroundColor: '#18202c', 66 | }, 67 | }, 68 | MuiButton: { 69 | label: { 70 | textTransform: 'none', 71 | }, 72 | contained: { 73 | boxShadow: 'none', 74 | '&:active': { 75 | boxShadow: 'none', 76 | }, 77 | }, 78 | }, 79 | MuiTabs: { 80 | root: { 81 | marginLeft: theme.spacing(1), 82 | }, 83 | indicator: { 84 | height: 3, 85 | borderTopLeftRadius: 3, 86 | borderTopRightRadius: 3, 87 | backgroundColor: theme.palette.common.white, 88 | }, 89 | }, 90 | MuiTab: { 91 | root: { 92 | textTransform: 'none', 93 | margin: '0 16px', 94 | minWidth: 0, 95 | padding: 0, 96 | [theme.breakpoints.up('md')]: { 97 | padding: 0, 98 | minWidth: 0, 99 | }, 100 | }, 101 | }, 102 | MuiIconButton: { 103 | root: { 104 | padding: theme.spacing(1), 105 | }, 106 | }, 107 | MuiTooltip: { 108 | tooltip: { 109 | borderRadius: 4, 110 | }, 111 | }, 112 | MuiDivider: { 113 | root: { 114 | backgroundColor: '#404854', 115 | }, 116 | }, 117 | MuiListItemText: { 118 | primary: { 119 | fontWeight: theme.typography.fontWeightMedium, 120 | }, 121 | }, 122 | MuiListItemIcon: { 123 | root: { 124 | color: 'inherit', 125 | marginRight: 0, 126 | '& svg': { 127 | fontSize: 20, 128 | }, 129 | }, 130 | }, 131 | MuiAvatar: { 132 | root: { 133 | width: 32, 134 | height: 32, 135 | }, 136 | }, 137 | }, 138 | }; 139 | 140 | const drawerWidth = 256; 141 | 142 | const styles = createStyles({ 143 | root: { 144 | display: 'flex', 145 | minHeight: '100vh', 146 | }, 147 | drawer: { 148 | [theme.breakpoints.up('sm')]: { 149 | width: drawerWidth, 150 | flexShrink: 0, 151 | }, 152 | }, 153 | app: { 154 | flex: 1, 155 | display: 'flex', 156 | flexDirection: 'column', 157 | }, 158 | main: { 159 | flex: 1, 160 | padding: theme.spacing(6, 4), 161 | background: '#eaeff1', 162 | }, 163 | footer: { 164 | padding: theme.spacing(2), 165 | background: '#eaeff1', 166 | }, 167 | }); 168 | 169 | export interface PaperbaseProps extends WithStyles {} 170 | 171 | function RoomIndex(props: PaperbaseProps) { 172 | const { classes } = props; 173 | const [mobileOpen, setMobileOpen] = React.useState(false); 174 | 175 | const handleDrawerToggle = () => { 176 | setMobileOpen(!mobileOpen); 177 | }; 178 | 179 | return ( 180 | 181 |
182 | 183 | 196 |
197 |
198 |
199 | 200 |
201 |
202 | 203 |
204 |
205 |
206 |
207 | ); 208 | } 209 | 210 | export default withStyles(styles)(RoomIndex); 211 | -------------------------------------------------------------------------------- /front/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AppBar from '@material-ui/core/AppBar'; 3 | import Avatar from '@material-ui/core/Avatar'; 4 | import Button from '@material-ui/core/Button'; 5 | import Grid from '@material-ui/core/Grid'; 6 | import HelpIcon from '@material-ui/icons/Help'; 7 | import Hidden from '@material-ui/core/Hidden'; 8 | import IconButton from '@material-ui/core/IconButton'; 9 | import Link from '@material-ui/core/Link'; 10 | import MenuIcon from '@material-ui/icons/Menu'; 11 | import NotificationsIcon from '@material-ui/icons/Notifications'; 12 | import Tab from '@material-ui/core/Tab'; 13 | import Tabs from '@material-ui/core/Tabs'; 14 | import Toolbar from '@material-ui/core/Toolbar'; 15 | import Tooltip from '@material-ui/core/Tooltip'; 16 | import Typography from '@material-ui/core/Typography'; 17 | import { createStyles, Theme, withStyles, WithStyles } from '@material-ui/core/styles'; 18 | 19 | const lightColor = 'rgba(255, 255, 255, 0.7)'; 20 | 21 | const styles = (theme: Theme) => 22 | createStyles({ 23 | secondaryBar: { 24 | zIndex: 0, 25 | }, 26 | menuButton: { 27 | marginLeft: -theme.spacing(1), 28 | }, 29 | iconButtonAvatar: { 30 | padding: 4, 31 | }, 32 | link: { 33 | textDecoration: 'none', 34 | color: lightColor, 35 | '&:hover': { 36 | color: theme.palette.common.white, 37 | }, 38 | }, 39 | button: { 40 | borderColor: lightColor, 41 | }, 42 | }); 43 | 44 | interface HeaderProps extends WithStyles { 45 | onDrawerToggle: () => void; 46 | } 47 | 48 | function Header(props: HeaderProps) { 49 | const { classes, onDrawerToggle } = props; 50 | 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | Go to docs 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 96 | 97 | 98 | 99 | 100 | Authentication 101 | 102 | 103 | 104 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | ); 134 | } 135 | 136 | export default withStyles(styles)(Header); 137 | -------------------------------------------------------------------------------- /front/src/components/Navigator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import { createStyles, Theme, withStyles, WithStyles } from '@material-ui/core/styles'; 4 | import Divider from '@material-ui/core/Divider'; 5 | import Drawer, { DrawerProps } from '@material-ui/core/Drawer'; 6 | import List from '@material-ui/core/List'; 7 | import ListItem from '@material-ui/core/ListItem'; 8 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 9 | import ListItemText from '@material-ui/core/ListItemText'; 10 | import HomeIcon from '@material-ui/icons/Home'; 11 | import PeopleIcon from '@material-ui/icons/People'; 12 | import DnsRoundedIcon from '@material-ui/icons/DnsRounded'; 13 | import PermMediaOutlinedIcon from '@material-ui/icons/PhotoSizeSelectActual'; 14 | import PublicIcon from '@material-ui/icons/Public'; 15 | import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet'; 16 | import SettingsInputComponentIcon from '@material-ui/icons/SettingsInputComponent'; 17 | import TimerIcon from '@material-ui/icons/Timer'; 18 | import SettingsIcon from '@material-ui/icons/Settings'; 19 | import PhonelinkSetupIcon from '@material-ui/icons/PhonelinkSetup'; 20 | import { Omit } from '@material-ui/types'; 21 | 22 | const categories = [ 23 | { 24 | id: 'Develop', 25 | children: [ 26 | { id: 'Authentication', icon: , active: true }, 27 | { id: 'Database', icon: }, 28 | { id: 'Storage', icon: }, 29 | { id: 'Hosting', icon: }, 30 | { id: 'Functions', icon: }, 31 | { id: 'ML Kit', icon: }, 32 | ], 33 | }, 34 | { 35 | id: 'Quality', 36 | children: [ 37 | { id: 'Analytics', icon: }, 38 | { id: 'Performance', icon: }, 39 | { id: 'Test Lab', icon: }, 40 | ], 41 | }, 42 | ]; 43 | 44 | const styles = (theme: Theme) => 45 | createStyles({ 46 | categoryHeader: { 47 | paddingTop: theme.spacing(2), 48 | paddingBottom: theme.spacing(2), 49 | }, 50 | categoryHeaderPrimary: { 51 | color: theme.palette.common.white, 52 | }, 53 | item: { 54 | paddingTop: 1, 55 | paddingBottom: 1, 56 | color: 'rgba(255, 255, 255, 0.7)', 57 | '&:hover,&:focus': { 58 | backgroundColor: 'rgba(255, 255, 255, 0.08)', 59 | }, 60 | }, 61 | itemCategory: { 62 | backgroundColor: '#232f3e', 63 | boxShadow: '0 -1px 0 #404854 inset', 64 | paddingTop: theme.spacing(2), 65 | paddingBottom: theme.spacing(2), 66 | }, 67 | firebase: { 68 | fontSize: 24, 69 | color: theme.palette.common.white, 70 | }, 71 | itemActiveItem: { 72 | color: '#4fc3f7', 73 | }, 74 | itemPrimary: { 75 | fontSize: 'inherit', 76 | }, 77 | itemIcon: { 78 | minWidth: 'auto', 79 | marginRight: theme.spacing(2), 80 | }, 81 | divider: { 82 | marginTop: theme.spacing(2), 83 | }, 84 | }); 85 | 86 | export interface NavigatorProps extends Omit, WithStyles {} 87 | 88 | function Navigator(props: NavigatorProps) { 89 | const { classes, ...other } = props; 90 | 91 | return ( 92 | 93 | 94 | 95 | Simple Chat 96 | 97 | 98 | 99 | 100 | 101 | 106 | ホーム 107 | 108 | 109 | {categories.map(({ id, children }) => ( 110 | 111 | 112 | 117 | {id} 118 | 119 | 120 | {children.map(({ id: childId, icon, active }) => ( 121 | 126 | {icon} 127 | 132 | {childId} 133 | 134 | 135 | ))} 136 | 137 | 138 | ))} 139 | 140 | 141 | ); 142 | } 143 | 144 | export default withStyles(styles)(Navigator); 145 | -------------------------------------------------------------------------------- /doc/依存関係図.drawio: -------------------------------------------------------------------------------- 1 | 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 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Run Test](https://github.com/ryoutaku/simple-chat/actions/workflows/test.yml/badge.svg)](https://github.com/ryoutaku/simple-chat/actions/workflows/test.yml) 2 | [![codecov](https://codecov.io/gh/ryoutaku/simple-chat/branch/main/graph/badge.svg?token=NPN5TAC98Q)](https://codecov.io/gh/ryoutaku/simple-chat) 3 | 4 | # Go + CleanArchitectureでのREST API設計 5 | 6 | ## 概要 7 | CleanArchitectureに基づいて設計されたGoによるREST API 8 | 9 | ### CleanArchitectureとは 10 | 11 | [【書籍】Clean Architecture 達人に学ぶソフトウェアの構造と設計](https://tatsu-zine.com/books/clean-architecture) 12 | で提唱されたソフトウェアアーキテクチャの実装例です。 \ 13 | 以下の図のようにソースコードをいくつかの領域に分割し、依存性が内側(上位レベルの方針)にだけ向くように設計します。 14 | 15 | ![依存関係](doc/images/クリーンアーキテクチャ.png) 16 | > Clean Architecture 達人に学ぶソフトウェアの構造と設計(p.200)より抜粋 17 | 18 | このアーキテクチャは以下の特性を有しています。 19 | 20 | > ・フレームワーク非依存:アーキテクチャは,機能満載のソフトウェアのライブラリに依存していない.これにより,システムをフレームワークの制約で縛るのではなく,フレームワークをツールとして使用できる。 \ 21 | > ・テスト可能:ビジネスルールは,UI,データベース,ウェブサーバー,その他の外部要素がなくてもテストできる。 \ 22 | > ・UI非依存:UIは,システムのほかの部分を変更することなく,簡単に変更できる.たとえば,ビジネスルールを変更することなく,ウェブUIはコンソールUIに置き換えることができる。 \ 23 | > ・データベース非依存:OracleやSQL ServerをMongo,BigTable,CouchDBなどに置き換えることができる.ビジネスルールはデータベースに束縛されていない。 \ 24 | > ・外部エージェント非依存:ビジネスルールは外界のインターフェースについて何も知らない。 25 | > 26 | > Clean Architecture 達人に学ぶソフトウェアの構造と設計(p.200)より抜粋 27 | 28 | 以下の図は、データベースを使ったウェブベースのJavaシステムをクリーンアーキテクチャで実装した典型的な例として紹介されていたものです。\ 29 | 今回は、この図を参考に実装を行っています。 30 | 31 | ![典型的なシナリオ](doc/images/典型的なシナリオ.jpeg) 32 | > Clean Architecture 達人に学ぶソフトウェアの構造と設計(p.204)より抜粋 33 | 34 | ## 詳細 35 | 36 | ### API仕様 37 | 38 | - GET /rooms:Roomの全件取得 39 | - responseBody: 40 | ```json 41 | [ 42 | { 43 | "id": 1, 44 | "name": "room_name1", 45 | "createdAt": "2021-02-13T18:38:51+09:00", 46 | "updatedAt": "2021-02-13T18:38:51+09:00" 47 | }, 48 | { 49 | "id": 2, 50 | "name": "room_name2", 51 | "createdAt": "2021-02-13T18:38:51+09:00", 52 | "updatedAt": "2021-02-13T18:38:51+09:00" 53 | } 54 | ] 55 | - POST /rooms:Roomの作成 56 | - requestBody: 57 | ```json 58 | {"name": "room_name"} 59 | - responseBody: 60 | ```json 61 | { 62 | "id": 1, 63 | "name": "room_name", 64 | "createdAt": "2021-02-13T18:38:51+09:00", 65 | "updatedAt": "2021-02-13T18:38:51+09:00" 66 | } 67 | 68 | ※アーキテクチャの構成を明確に示すためにAPI自体はシンプルな機能のみにしています。 69 | 70 | ### 開発環境 71 | - アプリケーション 72 | - Go v1.15.3 73 | - gorilla/mux v1.8.0(ルーティング) 74 | - gorm.io/driver/mysql v1.0.3(MySQLドライバ) 75 | - gorm.io/gorm v1.20.11(ORM) 76 | - DATA-DOG/go-sqlmock v1.5.0(データベースのモック生成) 77 | - データベース 78 | - MySQL v5.7(マイグレーション:sql-migrate) 79 | - コンテナ 80 | - Docker 81 | - docker-compose 82 | 83 | ### ディレクトリ構成と各ファイルの役割 84 | ※テストコードなどアーキテクチャと直接関係ないファイルは省略しています 85 | ``` 86 | app 87 | ├── di 88 | │ └── container.go ・ ・ ・ 「DIコンテナ(依存性の注入)」 89 | │ 90 | ├── domain(エンティティ) 91 | │ └── room.go ・ ・ ・ 「テーブル設計と一致させたデータ構造体」 92 | │ 93 | ├── infra(フレームワーク/ドライバ) 94 | │ ├── db_handler.go ・ ・ ・ 「DBへの接続、クエリ実行、ORMの呼び出し」 95 | │ ├── http_context.go ・ ・ ・ 「リクエストボディのパース処理、レスポンスの生成」 96 | │ ├── http_handler.go ・ ・ ・ 「Controllerのラッパー、HttpContextの初期化、エラーハンドリング」 97 | │ └── router.go ・ ・ ・ 「ルーティング」 98 | │ 99 | ├── interface(インターフェイスアダプター) 100 | │ ├── adapter ・ ・ ・ 「infra ↔︎ interfaceの境界線」 101 | │ │ ├── db_handler.go ・ ・ ・ 「DBHandlerのインターフェイス」 102 | │ │ ├── http_context.go ・ ・ ・ 「HttpContextのインターフェイス」 103 | │ │ ├── http_handler.go ・ ・ ・ 「HttpHandlerのインターフェイス」 104 | │ │ └── http_error.go ・ ・ ・ 「HttpHandlerへエラー内容を伝える際のデータ型」 105 | │ ├── controller ・ ・ ・ 「リクエストをBindしてServiceへ処理を依頼し、処理結果をもとにレスポンスを生成」 106 | │ │ └── room_controller.go 107 | │ └── repository ・ ・ ・ 「永続層への操作を組み立てる」 108 | │ └── room_repository.go 109 | │ 110 | ├── usecase(ユースケース) 111 | │ ├── input ・ ・ ・ 「controller ↔︎ serviceの境界線」 112 | │ │ └── room_input.go ・ ・ ・ 「Serviceのインターフェイス、Controllerとやり取りする際のデータ型」 113 | │ ├── service ・ ・ ・ 「domainやrepositoryを適宜呼び出してユースケースを実現」 114 | │ │ └── room_controller.go 115 | │ └── dao ・ ・ ・ 「repository ↔︎ serviceの境界線」 116 | │ └── room_dao.go ・ ・ ・ 「Repositoryのインターフェイス」 117 | │ 118 | └── main.go ・ ・ ・ 「各コンポーネントの初期化、サーバー起動」 119 | ``` 120 | 121 | ### コードの依存関係を表した図 122 | 123 | ![依存関係](doc/images/依存関係.png) 124 | 125 | 実際に実装したコードの依存関係を簡易的に表した図です。 \ 126 | 右側が円の内側に相当し、各レイヤーの境界を越える際は、依存性の向きが内側に向くようになっています。\ 127 | 円の外側に向かう制御が必要な箇所(Usecase層からのDBアクセス)は、依存性の注入(DI)を使って対処しています。 128 | 129 | なお、[Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments#interfaces) では構造体を利用する側のパッケージにインターフェースを定義することが推奨されています。\ 130 | 但し、本実装では以下のインターフェイスに関しては、例外的に別パッケージに分けています。 131 | 132 | - `Service`のインターフェイス(`定義場所:input`パッケージ)→パッケージ単位で依存性の向きが内側に向くようにしたかったため 133 | - `DBHandler`と`HttpContext`インターフェイス(定義場所:`adapter`パッケージ)→ファイルの見通しを良くするため 134 | - `Repository`のインターフェイス(定義場所:`dao`パッケージ)→ 一つのServiceから複数のRepositoryを呼び出すケースも想定されるため 135 | 136 | インターフェイスの命名については、実装と一対一の関係(同じ役割)なので、実装と同じ名前にしています。 137 | [Effective Go #interface-names](https://golang.org/doc/effective_go#interface-names) 138 | 139 | DIコンテナ(依存性の注入)については、Wireやdigなどのツールを試しましたが、現状の構成では学習コストや外部ツール依存のリスクを上回るメリットを感じられなかったので自作しています。 140 | 141 | ### 設計のポイント 142 | 143 | 本実装において、設計をどうするかで苦慮した箇所を3点ピックアップしています。 144 | 1. infra層の改修のみでWEBフレームワークやORMなどの置換が可能 145 | 2. 各層をインターフェイスで結ぶことでモックを利用した単体テストを容易化 146 | 3. レイヤー間のデータのやり取りに専用のデータ構造体を利用して責務を明確化 147 | 148 | #### 1. infra層の改修のみでWEBフレームワークやORMなどの置換が可能 149 | 150 | ルーティングで設定するハンドラ関数の定義は、以下のように利用するWEBフレームワーク・ライブラリによって異なります。 151 | 152 | - net/http:`func(http.ResponseWriter, *http.Request)` 153 | - Gin:`func(*gin.Context)` 154 | - Echo:`func(echo.Context) error` 155 | 156 | ハンドラ関数をこれらの定義に合わせて実装すると、もしWEBフレームワークを別のものに切り替えたい場合に、全てのハンドラ関数の修正が必要になります。 \ 157 | (WEBフレームワークのメソッドをハンドラ関数内で呼び出していて、そのメソッドのシグネチャがフレームワーク変更で変わる場合、その箇所の修正も必要) 158 | 159 | そこで、本実装ではハンドラ関数(controller)をラップする`HttpHandler`を用意し、そこでフレームワークの差異を吸収することで、修正箇所を1箇所に集約しています。 \ 160 | WEBフレームワークのメソッド呼び出しに関しては、`HttpContext`のインターフェイスを介すことで、メソッド呼び出しの修正箇所も集約しています。 \ 161 | `HttpContext`は、ルーティング時にフレームワークから引数で渡される`http.ResponseWriter`や`*http.Request`などをラップして、controllerから隠蔽します。 162 | 163 | ![HttpHandlerの拡大図](doc/images/HttpHandlerの拡大図.png) 164 | 165 | 本実装では`net/http`とルーティングに`gorilla/mux`を利用しています。 166 | 167 | ```go 168 | // app/infra/router.go 169 | func NewRouter(c *di.Container) *Router { 170 | r := mux.NewRouter() 171 | 172 | r.HandleFunc("/", httpHandler(c.Room.Index).run).Methods("GET") 173 | r.HandleFunc("/rooms", httpHandler(c.Room.Index).run).Methods("GET") 174 | r.HandleFunc("/rooms", httpHandler(c.Room.Create).run).Methods("POST") 175 | 176 | return &Router{Engine: r} 177 | } 178 | ``` 179 | `httpHandler`では他にも、controllerからの戻り値`HttpError`を用いたエラーハンドリングの共通化と、予期せぬpanicからの復旧も担っています。 \ 180 | このあたりは、フレームワークに備わっているミドルウェアを利用した方が良いかもしれないと考えています。(実装が容易、責務の分割) 181 | 182 | ```go 183 | // app/infra/http_handler.go 184 | type httpHandler func(adapter.HttpContext) *adapter.HttpError 185 | 186 | func (fn httpHandler) run(w http.ResponseWriter, r *http.Request) { 187 | context := NewHttpContext(w, r) 188 | defer func() { 189 | if err := recover(); err != nil { 190 | log.Println(err) 191 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 192 | } 193 | }() 194 | 195 | if err := fn(context); err != nil { 196 | http.Error(w, err.Error(), err.Code) 197 | } 198 | } 199 | ``` 200 | 以下は、各フレームワークのメソッド(リクエストのBind等)を呼び出す際の`HttpContext`のインターフェイスと実装部分です。 201 | 202 | ```go 203 | // app/interface/adapter/http_context.go 204 | package adapter 205 | 206 | type HttpContext interface { 207 | Bind(i interface{}) error 208 | JSON(code int, i interface{}) error 209 | } 210 | ``` 211 | ```go 212 | // app/infra/http_context.go 213 | func NewHttpContext(w http.ResponseWriter, r *http.Request) adapter.HttpContext { 214 | return &httpContext{ 215 | Writer: w, 216 | Request: r, 217 | } 218 | } 219 | 220 | func (c *httpContext) Bind(i interface{}) (err error) { 221 | err = json.NewDecoder(c.Request.Body).Decode(i) 222 | if err != nil { 223 | return errors.New("invalid request") 224 | } 225 | return err 226 | } 227 | 228 | func (c *httpContext) JSON(code int, i interface{}) (err error) { 229 | res, err := json.Marshal(i) 230 | if err != nil { 231 | return errors.New("internal server error") 232 | } 233 | 234 | c.Writer.Header().Set("Content-Type", "application/json") 235 | c.Writer.WriteHeader(code) 236 | c.Writer.Write(res) 237 | return err 238 | } 239 | ``` 240 | 241 | ORMについても、interface層から利用する際は`DBHandler`を経由することで、依存関係の逆転と修正箇所を集約しています。 242 | 243 | ![DBHandlerの拡大図](doc/images/DBHandlerの拡大図.png) 244 | 245 | これら実装のメリットは、interface層以下がフレームワークについて何も知らなくて良い様にすることで、infra層に修正箇所を集約できることです。 246 | 247 | デメリットは、インターフェイスを含めてファイル数が多くなり、管理が煩雑になることが懸念されます。 \ 248 | また、フレームワークの機能を呼び出す為に、わざわざインターフェイスの実装を用意するのも冗長なように感じます。 249 | 250 | 今回はクリーンアーキテクチャの原則に則ることを重視して、infraとinterfaceの依存関係逆転が達成できる本実装を採用しましたが、interfaceが直接フレームワークを使う構成でも良いかもしれません。 \ 251 | (ビジネスロジックであるUsecase層に、WEBの都合が侵食しなければ良いという方針) 252 | 253 | なお、フルスタックなフレームワーク(Beegoなど)やコード生成フレームワーク(goaなど)などは、今回の実装では置換出来ません。 254 | 255 | #### 2. 各層をインターフェイスで結ぶことでモックを利用した単体テストを容易化 256 | 257 | インターフェイスを用いることでgomockなどの外部ツールに頼らなくても、実装をモックに置き換えることができます。\ 258 | controllerの単体テストを例に説明します。 259 | 260 | ![Controller拡大図](doc/images/Controller拡大図.png) 261 | 262 | テスト対象のメソッドは、Roomを全件取得するためのハンドラ関数`Index`です。 \ 263 | 今回は、内部で呼び出している`Service.All()`をモックに置き換えます。 264 | 265 | ```go 266 | // app/interface/controller/room_controller.go 267 | type RoomController struct { 268 | Service input.RoomService 269 | } 270 | 271 | func (c *RoomController) Index(hc adapter.HttpContext) *adapter.HttpError { 272 | rooms, err := c.Service.All() 273 | if err != nil { 274 | return adapter.NewHttpError(err.Error(), http.StatusBadRequest) 275 | } 276 | 277 | respBody := convertRoomsResponse(&rooms) 278 | if err := hc.JSON(http.StatusCreated, respBody); err != nil { 279 | return adapter.NewHttpError(err.Error(), http.StatusInternalServerError) 280 | } 281 | return nil 282 | } 283 | ``` 284 | 285 | controllerでは、`input`パッケージ内で定義されている`Service`のインターフェイスを利用しています。 286 | ```go 287 | // app/usecase/input/room_input.go 288 | package input 289 | 290 | type RoomService interface { 291 | All() (outData RoomsOutputData, err error) 292 | Create(inData RoomInputData) (outData RoomOutputData, err error) 293 | } 294 | ``` 295 | 296 | `Service`の実装は、`service`パッケージ内で定義されています。 \ 297 | ここで定義されたコンストラクタはDI(依存性の注入)の際に呼び出されて、controllerに渡されます。\ 298 | コンストラクタは「Accept interfaces, Return structs」という広く浸透しているGoのInterfaceのお作法に従って、引数はインターフェイスで受け取って具象型(ポインタ)を返しています。 299 | 300 | ```go 301 | // app/usecase/service/room_service.go 302 | type RoomService struct { 303 | Repository dao.RoomRepository 304 | } 305 | 306 | func NewRoomService(repo dao.RoomRepository) *RoomService { 307 | return &RoomService{Repository: repo} 308 | } 309 | 310 | func (s *RoomService) All() (outData input.RoomsOutputData, err error) { 311 | rooms, err := s.Repository.All() 312 | if err != nil { 313 | return 314 | } 315 | 316 | outData = convertToRoomsOutputData(&rooms) 317 | return 318 | } 319 | ``` 320 | 321 | ```go 322 | // app/usecase/dao/room_dao.go 323 | package dao 324 | 325 | type RoomRepository interface { 326 | All() (rooms domain.Rooms, err error) 327 | Create(room *domain.Room) (err error) 328 | } 329 | ``` 330 | 331 | Serviceのモック`fakeRoomService`には、`input.RoomService`(インターフェイス)を埋め込んでいます。 \ 332 | これにより、まだテストコードを実装していないメソッドをわざわざ定義しなくても、`fakeRoomService`はインターフェイスを満たすことが出来る。 \ 333 | (例えば、RoomServiceには`Create`メソッドも定義されていますが、`Create`メソッドを`fakeRoomService`に定義しなくてもインターフェイスを満たせる) 334 | 335 | モックするメソッド`All()`を定義し直して、実装内部で同じシグネチャのモック用のメソッド`fakeAll()`を返します。\ 336 | `fakeAll()`は、構造体で定義された関数で、構造体を初期化する際に関数の振る舞いを変更することで、モックの挙動を制御します。 337 | 338 | ```go 339 | // app/interface/controller/room_controller_test.go 340 | type fakeRoomService struct { 341 | input.RoomService 342 | fakeAll func() (outData input.RoomsOutputData, err error) 343 | } 344 | 345 | func (i fakeRoomService) All() (outData input.RoomsOutputData, err error) { 346 | return i.fakeAll() 347 | } 348 | ``` 349 | 350 | 実際のテストコードではテーブル駆動テストで指定した戻り値でテストケースごとにモック生成しています。\ 351 | これにより、様々なテストケースに対応できるようにしています。 352 | 353 | ```go 354 | 355 | func TestRoomControllerIndex(t *testing.T) { 356 | testCases := []struct { 357 | name string 358 | serviceReturn input.RoomsOutputData 359 | serviceErr error 360 | jsonErr error 361 | expected *adapter.HttpError 362 | }{ 363 | { 364 | name: "normal", 365 | serviceReturn: roomsOutputData, 366 | serviceErr: nil, 367 | jsonErr: nil, 368 | expected: nil, 369 | }, 370 | { 371 | name: "serviceError", 372 | serviceReturn: input.RoomsOutputData{}, 373 | serviceErr: errors.New("serviceError"), 374 | jsonErr: nil, 375 | expected: adapter.NewHttpError("serviceError", 400), 376 | }, 377 | { 378 | name: "jsonError", 379 | serviceReturn: roomsOutputData, 380 | serviceErr: nil, 381 | jsonErr: errors.New("jsonError"), 382 | expected: adapter.NewHttpError("jsonError", 500), 383 | }, 384 | } 385 | 386 | for _, tc := range testCases { 387 | tc := tc 388 | t.Run(tc.name, func(t *testing.T) { 389 | t.Parallel() 390 | context := mocks.FakeHttpContext{ 391 | FakeJSON: func(int, interface{}) error { return tc.jsonErr }, 392 | } 393 | 394 | service := fakeRoomService{ 395 | fakeAll: func() (outData input.RoomsOutputData, err error) { 396 | return tc.serviceReturn, tc.serviceErr 397 | }, 398 | } 399 | controller := NewRoomController(service) 400 | 401 | if err := controller.Index(context); !reflect.DeepEqual(tc.expected, err) { 402 | t.Errorf("%v: controller.Index expected = %v, got = %v", tc.name, tc.expected, err) 403 | } 404 | }) 405 | } 406 | } 407 | ``` 408 | 409 | #### 3. レイヤー間のデータのやり取りに専用のデータ構造体を利用して責務を明確化 410 | 411 | 本実装では、リクエストボディを直接Domain(Entity層)にバインドせず、`input`パッケージのデータ構造体`InputData`にバインドしてServiceに渡しています。 \ 412 | 更に、Service層からの戻り値は`input`パッケージのデータ構造体`OutputData`を指定し、レスポンスを生成する際も`controller`のデータ構造体`Response`に詰め替えています。 413 | 414 | リクエストボディを直接Domain(Entity)にバインドして、全ての処理で使い回し、レスポンスもDomain(Entity)の構造体で返した方が構造体の変換処理が省けてシンプルになります。 \ 415 | 但し、レイヤーを超えた構造体の引渡しを行えば、構造体の責務が曖昧になり、以下のような問題の発生が懸念されます。 416 | 417 | - どのレイヤーにレスポンスのフォーマットとなるデータ構造体が定義されているか判断しづらい 418 | - レスポンスのフォーマットを変更するのに、Entity層の修正が必要になる \ 419 | (Entity層がレスポンスの形式を知っている) 420 | - Entityのデータをそのまま返すので、意図せぬ情報漏えいが発生するリスクがある \ 421 | (例えば、User構造体に外部に漏れてはいけないユーザーのIPアドレスが含まれており、その構造体でレスポンスを生成したことでIPアドレスが漏えいした等) 422 | 423 | その為、各レイヤーにはそれぞれの関心のある項目だけで定義した構造体を利用しています。 424 | 425 | なお、Domain(Entity層)をテーブル設計と同じデータ構造にしているので、`Repository`だけ例外的にレイヤーを飛び越えてDomain(Entity層)のデータ構造体を利用しています。 426 | 427 | ### 全体的な所感 428 | 429 | #### 単体テストの実装のしやすさ 430 | 実際に全てのコードの単体テストを実装してみましたが、モックを作りやすいことはメリットとして大きいと感じました。 \ 431 | モックを使えばテストのスコープを狭められて、テストが壊れにくく、TDDも実践しやすくなるので、しっかりテストを書いて開発を進める場合には有効な構成だと考えています。 432 | 433 | 一方で、単体テストを書かないのであれば、インターフェイスは冗長なように感じます。 \ 434 | (Usecase層からのDBアクセスなど円の外側に向かう制御は依存性注入で解消できているため) 435 | 436 | #### Usecase層の肥大化が懸念 437 | MVCではファットモデルが問題になるケースが多いですが、MVCのモデルに相当する部分がService(Usecase層)とRepository(interface層)に分かれるので、MVCよりはコード肥大化が緩和されるのではないかと感じます。\ 438 | 但し複数テーブルにまたがる様な処理や複雑なデータ加工を必要とする処理が増えると、処理を組み立てるUsecase層の肥大化が問題なって来ると思われます。 439 | 440 | Domain(Entity層)にビジネスロジックを置くことで緩和できるでしょうが、Usecase層に置くアプリケーション固有のロジックとどの様に線引きするかは判断が非常に難しく、結局Usecase層にロジックが集中してしまいそうな印象を受けます。 \ 441 | シンプルに永続層のデータを返すだけのAPIであれば、この心配は無さそうですが、そうなるとクリーンアーキテクチャをわざわざ採用する必要も無いと考えています。 442 | 443 | #### DDDを採用する場合の構成見直し 444 | `Repository`・`Service`・`Domain`といったDDDの構成要素と同じ名称を採用していますが、DDDを本格的に導入する場合は、大きな構成の見直しが必要になると考えています。 \ 445 | 理由としては、現状Domain(Entity層)とテーブルが一対一の関係になっている点や、Usecase層が手続き型な処理でユースケースを実現している点などがあげられます。 \ 446 | (DDDに関しては「ドメイン駆動設計入門」を一読した程度の理解度なので、どの様にすることが最適なのか現状不明確です) 447 | 448 | DDDもそうですがGoの言語仕様やお作法も含めて、まだまだ全体的に理解が浅いと実感します。\ 449 | ただ設計を見直す度に新しい気づきがあるので、今後もより良い設計を検討して行きたいです。 450 | 451 | ## 参考情報 452 | - [【書籍】Clean Architecture 達人に学ぶソフトウェアの構造と設計](https://tatsu-zine.com/books/clean-architecture) 453 | - [世界一わかりやすいClean Architecture](https://www.nuits.jp/entry/easiest-clean-architecture-2019-09) 454 | - [実践クリーンアーキテクチャ](https://nrslib.com/clean-architecture/#outline__7) 455 | - [Goのサーバサイド実装におけるレイヤ設計とレイヤ内実装について考える](https://www.slideshare.net/pospome/go-80591000) 456 | - [マイクロサービスにクリーンアーキテクチャを採用する上で考えたこと](https://engineering.mercari.com/blog/entry/2019-12-19-100000/) 457 | - [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments#interfaces) 458 | 459 | ## 開発ドキュメント 460 | 461 | ### コマンドリファレンス 462 | 463 | #### セットアップ 464 | ``` 465 | # リポジトリクローン 466 | git clone git@github.com:ryoutaku/simple-chat.git 467 | cd simple-chat 468 | 469 | # envコピー 470 | cp env.development .env 471 | 472 | # コンテナ起動 473 | docker-compose up -d --build 474 | 475 | # 作業用コンテナへbashで入る(マイグレーション実行時など) 476 | docker exec -i -t simple_chat_woker bash 477 | ``` 478 | 479 | #### マイグレーション 480 | ``` 481 | # マイグレーションファイル生成 482 | bin/migrate.sh new create_rooms 483 | 484 | # マイグレーション実行 485 | bin/migrate.sh up [-limit=0] [-dryrun] 486 | 487 | # ロールバック実行 488 | bin/migrate.sh down [-limit=0] [-dryrun] 489 | 490 | # マイグレーションの適用状況を確認 491 | bin/migrate.sh status 492 | 493 | # 直近のマイグレーションを再適用 494 | bin/migrate.sh redo 495 | ``` 496 | ※sql-migrateを利用 https://github.com/rubenv/sql-migrate 497 | 498 | #### APIリクエスト 499 | ``` 500 | # Roomの全件取得 501 | curl -H "Content-Type: application/json" localhost:5050/rooms 502 | 503 | # Roomの作成 504 | curl -X POST -H "Content-Type: application/json" -d '{"name":"テストルーム"}' localhost:5050/rooms 505 | ``` 506 | 507 | ### 公式ドキュメント 508 | - [Golang](https://golang.org/doc/) 509 | - [GORM](https://gorm.io/ja_JP/) 510 | - [sql-migrate](https://github.com/rubenv/sql-migrate) 511 | --------------------------------------------------------------------------------