What to do?
Local Message, Local Database
ⓐ 기기에 저장할 메세지 형태를 Local Message로 정의
ⓑ Sqflite를 사용해 로컬데이터 베이스에 접근하는 코드 작성
ⓒ 테스트 코드 작성
Reference
Refactoring
기존에 작성했던 파일들을 전부 chat이라는 폴더로 때려넣음
chat/lib/chat.dart
기작성한 모델과 서비스 코드를 export하는 파일 작성
(interface는 export하지 않음)
library chat;
export './model/receipt_model.dart';
export './model/message_model.dart';
export './model/typing_event_model.dart';
export './model/user_model.dart';
export './service/encryption/encryption_service.dart';
export './service/message/message_service.dart';
export './service/receipt/receipt_service.dart';
export './service/typing/typing_notification_service.dart';
export './service/user/user_service.dart';
chat/pubspec.yaml
dependency 수정
- rethink_db에서 rethink_db_ns로 수정
- → service 코드에서 import 하는 부분 전부 수정
name: chat
description: A new Flutter package project.
version: 0.0.1
environment:
sdk: ">=2.7.0 <3.0.0"
dependencies:
encrypt: ^5.0.1
logger: ^1.0.2
flutter:
sdk: flutter
rethink_db_ns: ^0.0.4
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.3.2
flutter: null
pubspec.yaml
pubspec.yaml 파일에서 chat 라이브러리를 사용할 수 있도록 설정
dependencies:
flutter:
sdk: flutter
chat:
path: ./chat
cupertino_icons: ^1.0.5
sqflite: ^2.2.5
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.3.2
Local Message Model
기기(로컬 데이터베이스)에 저장된 메세지를 정의하기 위해 Lcoal Messsage라는 모델을 정의
- chatId : 채팅방 아이디 → 메세지 보낸 유저의 아이디
- message : Message 객체
- receiptStatus : 메세지를 읽었는지 여부
- sent : 내가 보낸 메세지
- delivered : 전달되었지만 아지 읽지 않은 메세지
- read : 이미 읽은 메세지
/model/local_message_model.dart
import 'package:chat/chat.dart';
class LocalMessage {
String _id;
String get id => _id;
String chatId;
Message message;
ReceiptStatus status;
LocalMessage(this.chatId, this.message, this.status);
Map<String, dynamic> toMap() => {
'chat_id': chatId,
'id': message.id,
'status': status.value(),
...message.toJson()
};
factory LocalMessage.fromMap(Map<String, dynamic> json) {
final message = Message(
from: json['from'],
to: json['to'],
timestamp: json['timestamp'],
contents: json['contents']);
final localMessage =
LocalMessage(json['chat_id'], message, EnumParsing.fromString(json['status']));
localMessage._id = json['id'];
return localMessage;
}
}
└ EnumParsing.fromString이라는 코드는 이전 포스팅에서 ReceiptStatus를 enum으로 정의할 때 작성한 class
Chat Model
카톡 채팅방을 생각해보면 △ 읽지 않은 메세지 수 △ 가장 최근 메세지를 보여준다.
이와 유사하게, 다음과 같은 필드를 정의
- id : 메세지를 보낸 유저의 id
- unread : 아직 읽지 않은 메세지 개수
- messages : 메세지 list
- mostRecent : 가장 최근에 온 메세지
model/chat_model.dart
import 'local_message_model.dart';
class Chat {
String id;
int unread = 0;
List<LocalMessage> messages = [];
LocalMessage mostRecent;
Chat(this.id, {this.messages, this.mostRecent});
toMap() => {'id': id};
factory Chat.fromMap(Map<String, dynamic> json) => Chat(json['id']);
}
DataSource Interface
datasource 구현에 앞서, interface를 정의
채팅서비스에 필요한 CRUD 메써드 정의
- Create
- 채팅방 생성
- 메세지 생성
- Read
- 특정 채팅방 조회
- 전체 채팅방 조회
- Update
- 메세지 수정
- Delete
- 메세지 삭제
import 'package:flutter_prj/model/chat_model.dart';
import 'package:flutter_prj/model/local_message_model.dart';
abstract class IDataSource {
/// create
Future<void> addChat(Chat chat);
Future<void> addMessage(LocalMessage message);
/// read
Future<Chat> findChat(String chatId);
Future<List<Chat>> findAllChat();
Future<List<LocalMessage>> findMessages(String chatId);
/// update
Future<void> updateMessage(LocalMessage message);
/// delete
Future<void> deleteChat(String chatId);
}
DataSource Service
datasource 코드의 일부 메서드에서는 rawQuery를 사용해 데이터를 가져온다.
- 각 채팅방에서 읽지 않은 메세지 수를 구하는 쿼리
SELECT CHAT_ID, COUNT(*) AS UNREAD FROM MESSAGES
WHERE RECEIPT_STATUS = DELIVERED
GROUP BY CHAT_ID
- 각 채팅방의 가장 최근 메세지를 가져오는 쿼리
SELECT MESSAGES.* FROM (
SELECT CHAT_ID, MAX(CREATED_AT) AS CREATED_AT
FROM MESSAGES
GROUP BY CHAT_ID
) AS LATEST_MESSAGES
INNER JOIN MESSAGES
ON MESSAGES.CHAT_ID = LATEST_MESSAGES.CHAT_ID
AND MESSAGE.CREATED_AT = LATEST_MESSAGES.CREATED_AT
datasource/sqflite_datasource.dart
import 'package:flutter_prj/data/datasource/datasource_interface.dart';
import 'package:flutter_prj/model/chat_model.dart';
import 'package:flutter_prj/model/local_message_model.dart';
import 'package:sqflite/sqflite.dart';
class SqfliteDataSource implements IDataSource {
final Database _db;
const SqfliteDataSource(this._db);
final queryForUnread = '''
SELECT CHAT_ID, COUNT(*) AS UNREAD FROM MESSAGES
WHERE RECEIPT_STATUS = ?
GROUP BY CHAT_ID
''';
final queryForLatestMessage = '''
SELECT MESSAGES.* FROM (
SELECT CHAT_ID, MAX(CREATED_AT) AS CREATED_AT
FROM MESSAGES
GROUP BY CHAT_ID
) AS LATEST_MESSAGES
INNER JOIN MESSAGES
ON MESSAGES.CHAT_ID = LATEST_MESSAGES.CHAT_ID
AND MESSAGE.CREATED_AT = LATEST_MESSAGES.CREATED_AT
''';
@override
Future<void> addChat(Chat chat) async {
await _db.insert('chats', chat.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
@override
Future<void> addMessage(LocalMessage message) async {
await _db.insert('messages', message.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
@override
Future<List<Chat>> findAllChat(String chatId) {
return _db.transaction((txn) async {
final chatsWithLatestMessage = await txn.rawQuery(queryForLatestMessage);
if (chatsWithLatestMessage.isEmpty) return [];
final chatWithUnreadMessages =
await txn.rawQuery(queryForUnread, ['delivered']);
return chatsWithLatestMessage.map<Chat>((row) {
final int unread = int.tryParse(chatWithUnreadMessages.firstWhere(
(element) => (row['chat_id'] == element['chat_id']),
orElse: () => {'unread': 0})['unread']);
final chat = Chat.fromMap(row);
chat.unread = unread;
chat.mostRecent = LocalMessage.fromMap(row);
return chat;
}).toList();
});
}
@override
Future<Chat> findChat(String chatId) async {
return await _db.transaction((txn) async {
final listOfChatMaps =
await txn.query('chat', where: 'CHAT_ID = ?', whereArgs: [chatId]);
if (listOfChatMaps.isNotEmpty) return null;
final unread = Sqflite.firstIntValue(
await txn.rawQuery(queryForUnread, [chatId, 'delivered']));
final mostRecentMessage = await txn.query('messages',
where: 'CHAT_ID = ?',
whereArgs: [chatId],
orderBy: 'CREATED_AT DESC',
limit: 1);
final chat = Chat.fromMap(listOfChatMaps.first);
chat.unread = unread;
chat.mostRecent = LocalMessage.fromMap(mostRecentMessage.first);
return chat;
});
}
@override
Future<List<LocalMessage>> findMessages() async {
final listOfMaps =
await _db.query('messages', where: 'CHAT_ID = ?', whereArgs: [chatId]);
return listOfMaps
.map<LocalMessage>((map) => LocalMessage.fromMap(map))
.toList();
}
@override
Future<void> updateMessage(LocalMessage localMessage) async {
_db.update('messages', localMessage.toMap(),
where: 'ID = ?',
whereArgs: [localMessage.message.id],
conflictAlgorithm: ConflictAlgorithm.replace);
}
@override
Future<void> deleteChat(String chatId) async {
final batch = _db.batch();
batch.delete('messages', where: 'CHAT_ID = ?', whereArgs: [chatId]);
batch.delete('chats', where: 'ID = ?', whereArgs: [chatId]);
await batch.commit(noResult: true);
}
}
테스트 코드 설명
데이터베이스와 배치를 Mocking하기 위해 Mockito 라이브러리를 사용해 테스트코드 작성
test/sqflite_datasource_test.dart
class MockSqfliteDatabase extends Mock implements Database {}
class MockBatch extends Mock implements Batch {}
void main() {
SqfliteDataSource sut;
MockSqfliteDatabase db;
MockBatch batch;
setUp(() {
db = MockSqfliteDatabase();
batch = MockBatch();
sut = SqfliteDataSource(db);
});
test코드...
}
- 채팅방 추가
- chats 테이블에 레코드 추가
서비스코드
@override
Future<void> addChat(Chat chat) async {
await _db.insert('chats', chat.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
테스트코드
test('when insert a chat data into chat table successfully, then return 1',
() async {
final chat = Chat('1234');
when(db.insert('chat', chat.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace))
.thenAnswer((_) async => 1);
await sut.addChat(chat);
verify(db.insert('chats', chat.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace))
.called(1);
});
- 메세지 추가
- addMessage : 로컬 메세지를 받아서, messages 테이블에 레코드 추가
서비스 코드
@override
Future<void> addMessage(LocalMessage message) async {
await _db.insert('messages', message.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
테스트코드
test(
'when insert a local message into message table successfully, then return 1',
() async {
final localMessage = LocalMessage('1234', message, ReceiptStatus.delivered);
when(db.insert('messages', localMessage.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace))
.thenAnswer((_) async => 1);
await sut.addMessage(localMessage);
verify(db.insert('messages', localMessage.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace))
.called(1);
});
- 메세지 조회
- findMessages : 채팅방 아이디를 전달받아서, 메세지들을 list 반환
서비스코드
@override
Future<List<LocalMessage>> findMessages(String chatId) async {
final listOfMaps =
await _db.query('messages', where: 'CHAT_ID = ?', whereArgs: [chatId]);
return listOfMaps
.map<LocalMessage>((map) => LocalMessage.fromMap(map))
.toList();
}
테스트코드
test('find message by chat id', () async {
//arrange
final messagesMap = [
{
'chat_id': '111',
'id': '4444',
'from': '111',
'to': '222',
'contents': 'hey',
'status': 'sent',
'timestamp': DateTime.parse("2021-04-01"),
}
];
when(db.query(
'messages',
where: anyNamed('where'),
whereArgs: anyNamed('whereArgs'),
)).thenAnswer((_) async => messagesMap);
//act
var messages = await sut.findMessages('111');
//assert
expect(messages.length, 1);
expect(messages.first.chatId, '111');
verify(db.query(
'messages',
where: anyNamed('where'),
whereArgs: anyNamed('whereArgs'),
)).called(1);
});
'Flutter' 카테고리의 다른 글
[Flutter] 채팅앱 만들기 #7 (0) | 2023.03.05 |
---|---|
[Flutter] 채팅앱 만들기 #6 (0) | 2023.03.05 |
[Flutter] 채팅앱 만들기 #4 (0) | 2023.03.01 |
[Flutter] 채팅앱 만들기 #3 (0) | 2023.03.01 |
[Flutter] 채팅앱 만들기 #2 (0) | 2023.03.01 |