ログイン機能をもったTODOのCRUDサンプルをFlutter×Express.js(ts)で作成したい。
セッションはExpress-sessionを使用すること。
DBはPostgresです。
DB情報は.envファイルに書き込みその情報を読み取ってDB接続をして生のSQLでCRUDします。
PrismaなどのORマッパーは使いません。
Express.js(ts)のアーキテクチャはオニオンアーキテクチャです。
要件
■ Flutter
■ Express.js + TypeScript
■ オニオンアーキテクチャ
■ express-session
■ PostgreSQL
■ .envからDB接続情報取得
■ pgライブラリ使用
■ ORM未使用(生SQL)
■ ログイン機能
■ TODO CRUD
■ Session認証
ディレクトリ構成
Backend
backend/
├── src/
│ ├── domain/
│ │ ├── entities/
│ │ │ ├── User.ts
│ │ │ └── Todo.ts
│ │ └── repositories/
│ │ ├── IUserRepository.ts
│ │ └── ITodoRepository.ts
│ │
│ ├── application/
│ │ ├── services/
│ │ │ ├── AuthService.ts
│ │ │ └── TodoService.ts
│ │
│ ├── infrastructure/
│ │ ├── db/
│ │ │ └── postgres.ts
│ │ └── repositories/
│ │ ├── UserRepository.ts
│ │ └── TodoRepository.ts
│ │
│ ├── presentation/
│ │ ├── controllers/
│ │ │ ├── AuthController.ts
│ │ │ └── TodoController.ts
│ │ └── routes/
│ │ ├── authRoutes.ts
│ │ └── todoRoutes.ts
│ │
│ ├── middleware/
│ │ └── authMiddleware.ts
│ │
│ ├── types/
│ │ └── session.d.ts
│ │
│ └── app.ts
│
├── .env
├── package.json
├── tsconfig.json
└── database.sql
package.json
下記の通りインストールします。
npm install express express-session pg dotenv cors bcrypt
npm install -D typescript
npm install -D ts-node-dev
npm install -D @types/express
npm install -D @types/express-session
npm install -D @types/node
npm install -D @types/cors
npm install -D @types/bcrypt
npm i @types/pg
npm i @types/express-session
npm i @types/express
.env
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_NAME=todo_db
DB_USER=postgres
DB_PASSWORD=password
SESSION_SECRET=my-secret-key
PostgreSQL
database.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL
);
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
completed BOOLEAN DEFAULT FALSE,
user_id INTEGER NOT NULL REFERENCES users(id)
);
Domain
User.ts
export interface User {
id: number;
email: string;
password: string;
}
Todo.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
user_id: number;
}
Repository Interface
IUserRepository.ts
import { User } from "../entities/User";
export interface IUserRepository {
findByEmail(email: string): Promise<User | null>;
create(email: string, password: string): Promise<User>;
}
ITodoRepository.ts
import { Todo } from "../entities/Todo";
export interface ITodoRepository {
findAll(userId: number): Promise<Todo[]>;
create(title: string, userId: number): Promise<Todo>;
update(
id: number,
title: string,
completed: boolean
): Promise<void>;
delete(id: number): Promise<void>;
}
PostgreSQL接続
postgres.ts
import { Pool } from "pg";
import dotenv from "dotenv";
dotenv.config();
export const pool = new Pool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
});
Infrastructure
UserRepository.ts
import { pool } from "../db/postgres";
import { IUserRepository } from "../../domain/repositories/IUserRepository";
import { User } from "../../domain/entities/User";
export class UserRepository implements IUserRepository {
async findByEmail(email: string): Promise<User | null> {
const result = await pool.query(
`SELECT * FROM users WHERE email = $1`,
[email]
);
return result.rows[0] || null;
}
async create(email: string, password: string): Promise<User> {
const result = await pool.query(
`
INSERT INTO users(email,password)
VALUES($1,$2)
RETURNING *
`,
[email,password]
);
return result.rows[0];
}
}
TodoRepository.ts
import { pool } from "../db/postgres";
export class TodoRepository {
async findAll(userId:number) {
const result = await pool.query(
`
SELECT *
FROM todos
WHERE user_id=$1
ORDER BY id
`,
[userId]
);
return result.rows;
}
async create(title:string,userId:number) {
const result = await pool.query(
`
INSERT INTO todos(title,user_id)
VALUES($1,$2)
RETURNING *
`,
[title,userId]
);
return result.rows[0];
}
async update(
id:number,
title:string,
completed:boolean
) {
await pool.query(
`
UPDATE todos
SET title=$1,
completed=$2
WHERE id=$3
`,
[title,completed,id]
);
}
async delete(id:number) {
await pool.query(
`
DELETE FROM todos
WHERE id=$1
`,
[id]
);
}
}
Application Layer
AuthService.ts
import bcrypt from "bcrypt";
import { UserRepository } from "../../infrastructure/repositories/UserRepository";
export class AuthService {
constructor(
private userRepo: UserRepository
) {}
async register(
email:string,
password:string
) {
const hash = await bcrypt.hash(password,10);
return await this.userRepo.create(
email,
hash
);
}
async login(
email:string,
password:string
) {
const user =
await this.userRepo.findByEmail(email);
if(!user) return null;
const ok =
await bcrypt.compare(
password,
user.password
);
return ok ? user : null;
}
}
TodoService.ts
import { TodoRepository } from "../../infrastructure/repositories/TodoRepository";
export class TodoService {
constructor(
private repo: TodoRepository
) {}
getTodos(userId:number) {
return this.repo.findAll(userId);
}
createTodo(
title:string,
userId:number
) {
return this.repo.create(title,userId);
}
updateTodo(
id:number,
title:string,
completed:boolean
) {
return this.repo.update(
id,
title,
completed
);
}
deleteTodo(id:number) {
return this.repo.delete(id);
}
}
Session型拡張
session.d.ts
import "express-session";
declare module "express-session" {
interface SessionData {
userId?: number;
}
}
Middleware
authMiddleware.ts
import { Request, Response, NextFunction } from "express";
export const authMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.session.userId) {
return res.status(401).json({
message: "Unauthorized"
});
}
next();
};
Controller
AuthController.ts
import { Request, Response } from "express";
export class AuthController {
constructor(
private authService:any
) {}
register = async (
req:Request,
res:Response
) => {
const {email,password}=req.body;
const user =
await this.authService.register(
email,
password
);
res.json(user);
};
login = async (
req:Request,
res:Response
) => {
const {email,password}=req.body;
const user =
await this.authService.login(
email,
password
);
if(!user){
return res.status(401).json({
message:"login failed"
});
}
req.session.userId = user.id;
res.json({
message:"login success"
});
};
logout = (
req:Request,
res:Response
) => {
req.session.destroy(() => {
res.json({message:"logout"});
});
};
}
TodoController.ts
import { Request, Response } from "express";
export class TodoController {
constructor(
private service:any
) {}
getAll = async (
req:Request,
res:Response
) => {
const todos =
await this.service.getTodos(
req.session.userId
);
res.json(todos);
};
create = async (
req:Request,
res:Response
) => {
const todo =
await this.service.createTodo(
req.body.title,
req.session.userId
);
res.json(todo);
};
update = async (
req:Request,
res:Response
) => {
await this.service.updateTodo(
Number(req.params.id),
req.body.title,
req.body.completed
);
res.json({message:"updated"});
};
delete = async (
req:Request,
res:Response
) => {
await this.service.deleteTodo(
Number(req.params.id)
);
res.json({message:"deleted"});
};
}
Routes
authRoutes.ts
import { Router } from "express";
const router = Router();
router.post("/register", controller.register);
router.post("/login", controller.login);
router.post("/logout", controller.logout);
export default router;
todoRoutes.ts
import { Router } from "express";
const router = Router();
router.get("/", controller.getAll);
router.post("/", controller.create);
router.put("/:id", controller.update);
router.delete("/:id", controller.delete);
export default router;
app.ts
import express from "express";
import session from "express-session";
import dotenv from "dotenv";
import cors from "cors";
dotenv.config();
const app = express();
app.use(express.json());
app.use(cors({
origin:"http://localhost:3001",
credentials:true
}));
app.use(
session({
secret:
process.env.SESSION_SECRET!,
resave:false,
saveUninitialized:false,
cookie:{
secure:false,
httpOnly:true
}
})
);
app.use("/auth", authRoutes);
app.use(
"/todos",
authMiddleware,
todoRoutes
);
app.listen(
process.env.PORT,
() => {
console.log("server start");
}
);
Flutter側
依存
dependencies:
flutter:
sdk: flutter
dio: ^5.4.0
APIクライアント
import 'package:dio/dio.dart';
class ApiClient {
final dio = Dio(
BaseOptions(
baseUrl: "http://localhost:3000",
),
);
}
Login
await dio.post(
"/auth/login",
data: {
"email": email,
"password": password
},
options: Options(
extra: {
"withCredentials": true
}
)
);
Todo取得
final res =
await dio.get("/todos");
final todos =
List<Map<String,dynamic>>
.from(res.data);
API一覧
POST /auth/register
POST /auth/login
POST /auth/logout
GET /todos
POST /todos
PUT /todos/:id
DELETE /todos/:id
Flutterディレクトリ
Flutter側もオニオンアーキテクチャ(または Clean Architecture)に寄せるなら、以下の構成がおすすめです。
flutter_todo/
├── lib/
│
│ ├── core/
│ │ ├── constants/
│ │ │ └── api_constants.dart
│ │ │
│ │ ├── network/
│ │ │ ├── dio_client.dart
│ │ │ └── cookie_interceptor.dart
│ │ │
│ │ ├── error/
│ │ │ ├── app_exception.dart
│ │ │ └── failure.dart
│ │ │
│ │ └── utils/
│ │ └── validator.dart
│ │
│ ├── domain/
│ │ ├── entities/
│ │ │ ├── user.dart
│ │ │ └── todo.dart
│ │ │
│ │ ├── repositories/
│ │ │ ├── auth_repository.dart
│ │ │ └── todo_repository.dart
│ │ │
│ │ └── usecases/
│ │ ├── login_usecase.dart
│ │ ├── logout_usecase.dart
│ │ ├── get_todos_usecase.dart
│ │ ├── create_todo_usecase.dart
│ │ ├── update_todo_usecase.dart
│ │ └── delete_todo_usecase.dart
│ │
│ ├── infrastructure/
│ │ ├── models/
│ │ │ ├── login_request.dart
│ │ │ ├── todo_model.dart
│ │ │ └── user_model.dart
│ │ │
│ │ ├── datasources/
│ │ │ ├── auth_remote_datasource.dart
│ │ │ └── todo_remote_datasource.dart
│ │ │
│ │ └── repositories/
│ │ ├── auth_repository_impl.dart
│ │ └── todo_repository_impl.dart
│ │
│ ├── presentation/
│ │ ├── pages/
│ │ │ ├── login/
│ │ │ │ └── login_page.dart
│ │ │ │
│ │ │ └── todo/
│ │ │ └── todo_page.dart
│ │ │
│ │ ├── providers/
│ │ │ ├── auth_provider.dart
│ │ │ └── todo_provider.dart
│ │ │
│ │ └── widgets/
│ │ ├── todo_tile.dart
│ │ └── loading_widget.dart
│ │
│ ├── di/
│ │ └── dependency_injection.dart
│ │
│ └── main.dart
│
├── pubspec.yaml
└── .env
レイヤーごとの責務
Domain
業務ロジックのみ。
domain/
├── entities
├── repositories
└── usecases
例
class Todo {
final int id;
final String title;
final bool completed;
Todo({
required this.id,
required this.title,
required this.completed,
});
}
Repositoryはインターフェースのみ
abstract class TodoRepository {
Future<List<Todo>> getTodos();
Future<Todo> createTodo(String title);
Future<void> updateTodo(
int id,
String title,
bool completed,
);
Future<void> deleteTodo(int id);
}
Infrastructure
API通信担当。
infrastructure/
├── datasources
├── models
└── repositories
DataSource
class TodoRemoteDataSource {
final Dio dio;
TodoRemoteDataSource(this.dio);
Future<List<dynamic>> getTodos() async {
final response = await dio.get("/todos");
return response.data;
}
}
Repository実装
class TodoRepositoryImpl
implements TodoRepository {
final TodoRemoteDataSource datasource;
TodoRepositoryImpl(this.datasource);
@override
Future<List<Todo>> getTodos() async {
final data =
await datasource.getTodos();
return data
.map((e) => TodoModel.fromJson(e))
.toList();
}
}
Presentation
画面専用。
presentation/
├── pages
├── providers
└── widgets
Provider
class TodoProvider
extends ChangeNotifier {
final GetTodosUseCase getTodosUseCase;
List<Todo> todos = [];
TodoProvider(
this.getTodosUseCase,
);
Future<void> loadTodos() async {
todos =
await getTodosUseCase();
notifyListeners();
}
}
Session(Cookie)対応
Express-sessionを使う場合、
dependencies:
dio: ^5.4.0
dio_cookie_manager: ^3.1.1
cookie_jar: ^4.0.8
final dio = Dio();
final cookieJar = CookieJar();
dio.interceptors.add(
CookieManager(cookieJar),
);
これで
Set-Cookie
を自動保存できます。
ログイン後の
GET /todos
にSession Cookieが自動送信されます。
DI設定
例えば Provider を使う場合
final dio = Dio();
final authDatasource =
AuthRemoteDataSource(dio);
final todoDatasource =
TodoRemoteDataSource(dio);
final authRepository =
AuthRepositoryImpl(
authDatasource,
);
final todoRepository =
TodoRepositoryImpl(
todoDatasource,
);
final loginUseCase =
LoginUseCase(
authRepository,
);
final getTodosUseCase =
GetTodosUseCase(
todoRepository,
);
これを
lib/di/dependency_injection.dart
にまとめます。
おまけ
小規模TODOアプリなら
実務でよく使う構成は以下です。
lib/
├── core/
├── features/
│ ├── auth/
│ └── todo/
├── di/
└── main.dart
Feature First構成です。
features/
├── auth/
│ ├── domain/
│ ├── infrastructure/
│ └── presentation/
│
└── todo/
├── domain/
├── infrastructure/
└── presentation/
Flutterではこちらの方が保守しやすく、Express側のオニオンアーキテクチャとも相性が良いです。
個人的には、ログイン+TODO程度なら Feature First + Clean Architecture の構成をおすすめします。
サイト