Flutter + Firebaseでトランザクションをやってみた!
対象者
- FlutterとFirebaseの経験がある人.
- トランザクションに興味がある人.
やること/やらないこと
今回は、チャットアプリを使ってトランザクションなるものを実行するのをやってみようと思います。
ログインしてないので、誰でも投稿できて、ハードコーディングして定義したcurrentUserId
というドキュメントIDが存在するusersコレクションのmessageCount
フィールド1個しかないのですが、これがチャットの投稿をするたびに更新されていく処理です。
イメージとしては、20回投稿したらmessageCount
フィールドの値は20になるってことです。
やること:
コードを書いて投稿するのを繰り返す。
プログラムを実行した後にFireStoreの画面を見るとなんとなくわかると思います。チャットのメッセージは増えていく。これは普通...
usersコレクションのmessageCount
は更新処理によって増えていく。
やらないこと:
今回はバッチ処理なるものをやらない。
今のところ参考になりそうなものを思いつかなかったので作りませんでした。
プロジェクトの説明
今回プロジェクトで使ったトランザクションとは?
トランザクション: トランザクションとは、1 つ以上のドキュメントに対して読み取り / 書き込みを行う一連のオペレーションのことです。
🔥今回作成してトランザクションの解説
このコードは、ユーザーがメッセージを送信するたびにFirestoreのmessagesコレクションに新しいドキュメントを追加し、同時にusersコレクションの特定のユーザーのドキュメントを更新するものです。
具体的には、以下の処理を行っています:
メッセージが空でないことを確認します。空であれば、エラーダイアログを表示します。
メッセージが空でなければ、Firestoreのmessagesコレクションに新しいドキュメントを追加します。このドキュメントには、メッセージの内容とサーバーのタイムスタンプが含まれます。
次に、Firestoreのusersコレクションから特定のユーザーのドキュメントを取得します。このドキュメントは、トランザクション内で更新されます。
トランザクション内では、まずドキュメントが存在するかどうかを確認します。存在する場合は、messageCountフィールドの値を1増やします。存在しない場合は、新しいドキュメントを作成し、messageCountを1に設定します。
最後に、メッセージ入力フィールドをクリアします。
このコードは、Firestoreのトランザクションを使用しているため、messageCountの更新は原子的に行われます。つまり、複数のクライアントが同時に同じユーザーのmessageCountを更新しようとした場合でも、それぞれの更新が正しく反映されます。
ただし、このコードではcurrentUserIdという文字列を直接使用してユーザーのドキュメントを参照しています。これはおそらくプレースホルダーで、実際のユーザーIDに置き換える必要があります。
なので、ログインする機能をつけて、uidを使った方がいいですね。今回は省きました🙇
// DocumentReferenceでユーザーのドキュメントを参照
DocumentReference userDoc =
FirebaseFirestore.instance.collection('users').doc('currentUserId');
// ユーザーのドキュメントをトランザクションで更新
await FirebaseFirestore.instance.runTransaction((Transaction tx) async {
DocumentSnapshot doc = await tx.get(userDoc);
// ドキュメントが存在するかチェック
if (doc.exists) {
// 存在する場合は、メッセージカウントを更新
Map<String, dynamic> data = doc.data() as Map<String, dynamic>;
int currentCount = data['messageCount'];
tx.update(userDoc, {'messageCount': currentCount + 1});
} else {
// 存在しない場合は、ドキュメントを作成
tx.set(userDoc, {'messageCount': 1});
}
});
controller.clear();
} else {
// 空ならpopupを表示
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Error!'),
content: const Text('何か入力してください!'),
actions: <Widget>[
TextButton(
child: const Text('Close'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
ロジックの全体のコード:
Chatの投稿と表示
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
class ChatView extends StatelessWidget {
const ChatView({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: [
// ログアウトボタン
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await FirebaseAuth.instance.signOut();
if (context.mounted) {
Navigator.of(context).pop();
}
},
),
],
title: const Text('Chat'),
),
body: Column(
children: <Widget>[
Expanded(
child: StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('messages')
.orderBy('timestamp', descending: true)
.snapshots(),
builder: (BuildContext context,
AsyncSnapshot<QuerySnapshot> snapshot) {
if (!snapshot.hasData) return const Text('Loading...');
final int messageCount = snapshot.data!.docs.length;
return ListView.builder(
itemCount: messageCount,
reverse: true, // 新しいメッセージを下に表示
itemBuilder: (_, int index) {
final DocumentSnapshot document =
snapshot.data!.docs[index];
final dynamic message = document['message'];
// メッセージの向きと色をインデックスによって変える
return Container(
alignment: index % 2 == 0
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 10.0, horizontal: 20.0),
margin: const EdgeInsets.only(
top: 10.0, bottom: 10.0, left: 20.0, right: 20.0),
decoration: BoxDecoration(
color: index % 2 == 0
? Colors.green[100]
: Colors.blue[100],
borderRadius: BorderRadius.circular(15.0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
message != null
? message.toString()
: '<No message retrieved>',
style: const TextStyle(
color: Colors.black87,
fontSize: 16.0,
),
),
const SizedBox(height: 5.0),
Text(
'投稿された順番 ${index + 1} 投稿された回数 $messageCount',
style: const TextStyle(
color: Colors.black45,
fontSize: 12.0,
),
),
],
),
),
);
},
);
},
),
),
const MessageInput(),
const SizedBox(height: 20.0),
],
),
);
}
}
class MessageInput extends StatefulWidget {
const MessageInput({super.key});
_MessageInputState createState() => _MessageInputState();
}
class _MessageInputState extends State<MessageInput> {
final TextEditingController controller = TextEditingController();
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: TextField(
controller: controller,
decoration: const InputDecoration(hintText: '投稿するメッセージを入力してください'),
),
),
// ボタン
SendButton(
text: '投稿',
callback: callback,
),
],
);
}
void callback() async {
// 入力されたメッセージが空でないかチェック
if (controller.text.length > 0) {
// 空でなければ、Firestoreにメッセージを追加
await FirebaseFirestore.instance.collection('messages').add({
'timestamp': FieldValue.serverTimestamp(),
'message': controller.text,
});
// DocumentReferenceでユーザーのドキュメントを参照
DocumentReference userDoc =
FirebaseFirestore.instance.collection('users').doc('currentUserId');
// ユーザーのドキュメントをトランザクションで更新
await FirebaseFirestore.instance.runTransaction((Transaction tx) async {
DocumentSnapshot doc = await tx.get(userDoc);
// ドキュメントが存在するかチェック
if (doc.exists) {
// 存在する場合は、メッセージカウントを更新
Map<String, dynamic> data = doc.data() as Map<String, dynamic>;
int currentCount = data['messageCount'];
tx.update(userDoc, {'messageCount': currentCount + 1});
} else {
// 存在しない場合は、ドキュメントを作成
tx.set(userDoc, {'messageCount': 1});
}
});
controller.clear();
} else {
// 空ならpopupを表示
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Error!'),
content: const Text('何か入力してください!'),
actions: <Widget>[
TextButton(
child: const Text('Close'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
}
}
// ボタンのコンポーネント
class SendButton extends StatelessWidget {
const SendButton({super.key, required this.text, required this.callback});
final String text; // タイトル
final VoidCallback callback; // メソッドを引数で受け取るcallback
Widget build(BuildContext context) {
return ElevatedButton(onPressed: callback, child: Text(text));
}
}
main.dartでimportしてビルドしてください。
main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:offline_app/chat_view.dart';
import 'package:offline_app/firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const ChatView(),
);
}
}
チャットの投稿するとこんな感じになります:
トランザクションが実行されると、usersコレクションのフィールドが更新されます。カウントがされ続けて、20になりました。
感想
SQLでもトランザクションってありますけど、あれとは違うような気がしますね。
AIに聞いてみた🤖
はい、その通りです。SQLのトランザクションとFirestoreのトランザクションは、基本的な概念は同じですが、実装や動作は異なります。
SQLのトランザクションは、一連のデータベース操作を一つの単位として扱います。これにより、すべての操作が成功するか、一つでも失敗すればすべてロールバック(取り消し)されることを保証します。これは、データの整合性を保つために重要な機能です。
Firestoreのトランザクションも同様に、一連の読み書き操作を一つの単位として扱います。しかし、Firestoreのトランザクションは、ネットワークの中断や他の問題が発生した場合でも自動的に再試行されます。また、Firestoreのトランザクションは、同時に複数のドキュメントを読み書きすることができます。
また、SQLとFirestoreではデータモデルが異なります。SQLはリレーショナルデータベースで、テーブルと行を使用します。一方、FirestoreはNoSQLデータベースで、コレクションとドキュメントを使用します。これらの違いにより、トランザクションの使用方法や動作も異なります。
したがって、SQLのトランザクションとFirestoreのトランザクションは、基本的な概念は同じですが、具体的な動作や使用方法は異なります。
データの整合性とは?
データベースの状態が一貫していること、つまりデータが互いに矛盾しない状態を保つことを指します。これはデータベースシステムの基本的な要件の一つで、データの信頼性を保つために重要です。
例えば、銀行のシステムでは、ある口座から別の口座への送金を考えてみましょう。この操作は2つのステップから成り立ちます:1つ目の口座からお金を引き出し、2つ目の口座にそのお金を預ける。これらの操作は一緒に行われ、どちらか一方だけが行われることはありません。もし一方だけが行われた場合、データの整合性が損なわれ、お金が消失したり二重に増えたりする可能性があります。
このような整合性を保つために、データベースシステムではトランザクションという概念を用いて、一連の操作を一つの単位として扱います。トランザクション内の全ての操作が成功するか、一つでも失敗すれば全ての操作がロールバック(取り消し)されることで、データの整合性が保たれます。
また、データの整合性には他にも種類があり、例えば参照整合性では、外部キー制約を通じて、存在しないデータを参照することを防ぎます。これら全てが、データベースの信頼性を保つために重要な役割を果たしています。
Discussion