🐒

[Flutter] バックエンドもDartで書ける! Serverpodを用いてTodoアプリを作ってみた

2023/02/17に公開

最初に

Serverpodとは

ServerpodはFlutter用に作られた、dart言語で書かれたオープンソースのバックエンドで、最近メジャーバージョン1.0.0がリリースされました。それに伴って、こちらの記事では、公式ドキュメントを参考にServerpodを用いてTodoアプリを作成していきます。

この記事の目的

Serverpodにはずっと興味があり、ついに安定バージョンがリリースされたため、自分の勉強も含め記事にしていきます。Serverpodの認知度が上がり、サーバサイドもDartで書かれるようになればと思っています。

ServerpodのHello Worldまで

Serverpodのインストール

Serverpodを利用するには、Flutter3.0.0以上とDockerのインストールが必要です。
https://docs.flutter.dev/get-started/install
https://docs.docker.com/desktop/mac/install/
これらのインストール方法は割愛します。
FlutterとDockerのインストールが終わったら下記のコマンドでServerpodをインストールします。

dart pub global activate serverpod_cli

インストールが完了したら下記のコマンドを打って、ヘルプが表示されるか確認します。

serverpod

自分の場合ではfvmでFlutterのバージョン管理をしていて、pathが通っていなかったので、.zshrcに以下を追記することでヘルプが表示されました。

export PATH="$PATH":"$HOME/fvm/default/bin"

プロジェクトの作成と確認

ローカルサーバーを動かすには、Dockerが起動していないといけないので、Dockerの起動を確認し、以下のコマンドでServerpodのプロジェクトを作成します。

serverpod create [プロジェクト名]

今回プロジェクト名はtodo_podとします。
作成が完了すると、todo_podというフォルダの中に、todo_pod_server, todo_pod_client, todo_pod_flutterという名前のフォルダが作成されています。

todo_pod_server

このフォルダにはサーバーサイドのコードが含まれて、新しいエンドポイントやサーバーに必要な他の機能を追加するときはこのフォルダ内のファイルをいじります。

mypod_client

このフォルダはサーバーと通信するために必要なコードが含まれていて、通常、このフォルダのすべてのコードは自動的に生成されるので、このフォルダに含まれるファイルを編集することはありません。

mypod_flutter

このフォルダに通常のFlutterでファイルが含まれていて、ローカルサーバーに接続するように予め設定されています。

サーバーの起動

以下のコマンドでtodo_pod_serverに移動します。

cd todo_pod/todo_pod_server

次に、以下のコマンドでDockerのコンテナを起動します。

docker-compose up --build --detach

以下のコードを実行して、サーバーを起動します。

dart bin/main.dart

うまく起動できていると以下のように表示されます。これでローカルサーバーの起動まで来ました。

SERVERPOD version: 1.0.1 mode: development time: 2023-02-15 10:43:22.711464Z
Insights listening on port 8081
Server default listening on port 8080
Webserver listening on port 8082

Exampleの確認

create serverpodで作成されたデモアプリが実際にどのように動くのか確認します。
todo_pod_flutterをビルドすると、以下のようなアプリが立ち上がります。

入力欄にテキストを入力しSend to Serverボタンを押すとHelloが入力した前にくっついて表示されます。
このときサーバーでは以下のような出力されており、しっかりとサーバー経由でこの処理が行われていることがわかります。

METHOD CALL: example.hello duration: 0ms numQueries: 0 authenticatedUser: null

main.dartの以下の部分で実際にexample.helloというサーバーサイドで実装している処理を叩いており、それを表示するだけのシンプルなアプリです。

void _callHello() async {
  try {
    final result = await client.example.hello(_textEditingController.text);
    setState(() {
      _resultMessage = result;
    });
  } catch (e) {
    setState(() {
      _errorMessage = '$e';
    });
  }
}

Todoアプリの作成

前置きが長くなってしましたが、ここから実際にTodoアプリを作成していきます。

サーバーサイド(todo_pod_server)

クラスの作成

まずは、Todoクラスを定義します。Serverpodには強力な自動生成機能が備わっているので、それを利用して、Todoクラスを作成します。lib/src/protocolフォルダで、todo_class.yamlを作成し、フィールドの定義をします。ここで注意なのが、データベースで必要になってくるidは定義しないでください。そこはServerpodが勝手にやってくれます。

公式ドキュメントより

When you add a table to a serializable class, Serverpod will automatically add an id field of type int? to the class. You should not define this field yourself. The id is set when you insert or select a row from the database. The id field allows you to do updates and reference the rows from other objects and tables.

テーブルをシリアライズ可能なクラスに追加すると、Serverpodは自動的にint?型のidフィールドをクラスに追加します。このフィールドを自分で定義するべきではありません。id は、データベースから行を挿入したり選択したりするときに設定されます。このidフィールドにより、更新を行ったり、他のオブジェクトやテーブルから行を参照したりすることができるようになります。

class: Todo # クラス名
table: todo # データベースのテーブル名
fields:
  name: String
  isDone: bool

あとは、serverpod generateでgeneratedフォルダに新しくtodo_class.dartが生成されています。また、todo_pod_server直下のgeneratedフォルダにあるtables.pgsqlにも新しい書き込みを確認できます。

serverpod generate
tables.pgsql
--
-- Class Todo as table todo
--

CREATE TABLE "todo" (
  "id" serial,
  "name" text NOT NULL,
  "isDone" boolean NOT NULL
);

ALTER TABLE ONLY "todo"
  ADD CONSTRAINT todo_pkey PRIMARY KEY (id);
todo_class.dart
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/*   To generate run: "serverpod generate"    */

// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: public_member_api_docs
// ignore_for_file: implementation_imports

// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _i1;

class Todo extends _i1.TableRow {
  Todo({
    int? id,
    required this.name,
    required this.isDone,
  }) : super(id);

  factory Todo.fromJson(
    Map<String, dynamic> jsonSerialization,
    _i1.SerializationManager serializationManager,
  ) {
    return Todo(
      id: serializationManager.deserialize<int?>(jsonSerialization['id']),
      name: serializationManager.deserialize<String>(jsonSerialization['name']),
      isDone:
          serializationManager.deserialize<bool>(jsonSerialization['isDone']),
    );
  }

  static final t = TodoTable();

  String name;

  bool isDone;

  @override
  String get tableName => 'todo';
  @override
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'isDone': isDone,
    };
  }

  @override
  Map<String, dynamic> toJsonForDatabase() {
    return {
      'id': id,
      'name': name,
      'isDone': isDone,
    };
  }

  @override
  Map<String, dynamic> allToJson() {
    return {
      'id': id,
      'name': name,
      'isDone': isDone,
    };
  }

  @override
  void setColumn(
    String columnName,
    value,
  ) {
    switch (columnName) {
      case 'id':
        id = value;
        return;
      case 'name':
        name = value;
        return;
      case 'isDone':
        isDone = value;
        return;
      default:
        throw UnimplementedError();
    }
  }

  static Future<List<Todo>> find(
    _i1.Session session, {
    TodoExpressionBuilder? where,
    int? limit,
    int? offset,
    _i1.Column? orderBy,
    List<_i1.Order>? orderByList,
    bool orderDescending = false,
    bool useCache = true,
    _i1.Transaction? transaction,
  }) async {
    return session.db.find<Todo>(
      where: where != null ? where(Todo.t) : null,
      limit: limit,
      offset: offset,
      orderBy: orderBy,
      orderByList: orderByList,
      orderDescending: orderDescending,
      useCache: useCache,
      transaction: transaction,
    );
  }

  static Future<Todo?> findSingleRow(
    _i1.Session session, {
    TodoExpressionBuilder? where,
    int? offset,
    _i1.Column? orderBy,
    bool orderDescending = false,
    bool useCache = true,
    _i1.Transaction? transaction,
  }) async {
    return session.db.findSingleRow<Todo>(
      where: where != null ? where(Todo.t) : null,
      offset: offset,
      orderBy: orderBy,
      orderDescending: orderDescending,
      useCache: useCache,
      transaction: transaction,
    );
  }

  static Future<Todo?> findById(
    _i1.Session session,
    int id,
  ) async {
    return session.db.findById<Todo>(id);
  }

  static Future<int> delete(
    _i1.Session session, {
    required TodoExpressionBuilder where,
    _i1.Transaction? transaction,
  }) async {
    return session.db.delete<Todo>(
      where: where(Todo.t),
      transaction: transaction,
    );
  }

  static Future<bool> deleteRow(
    _i1.Session session,
    Todo row, {
    _i1.Transaction? transaction,
  }) async {
    return session.db.deleteRow(
      row,
      transaction: transaction,
    );
  }

  static Future<bool> update(
    _i1.Session session,
    Todo row, {
    _i1.Transaction? transaction,
  }) async {
    return session.db.update(
      row,
      transaction: transaction,
    );
  }

  static Future<void> insert(
    _i1.Session session,
    Todo row, {
    _i1.Transaction? transaction,
  }) async {
    return session.db.insert(
      row,
      transaction: transaction,
    );
  }

  static Future<int> count(
    _i1.Session session, {
    TodoExpressionBuilder? where,
    int? limit,
    bool useCache = true,
    _i1.Transaction? transaction,
  }) async {
    return session.db.count<Todo>(
      where: where != null ? where(Todo.t) : null,
      limit: limit,
      useCache: useCache,
      transaction: transaction,
    );
  }
}

typedef TodoExpressionBuilder = _i1.Expression Function(TodoTable);

class TodoTable extends _i1.Table {
  TodoTable() : super(tableName: 'todo');

  /// The database id, set if the object has been inserted into the
  /// database or if it has been fetched from the database. Otherwise,
  /// the id will be null.
  final id = _i1.ColumnInt('id');

  final name = _i1.ColumnString('name');

  final isDone = _i1.ColumnBool('isDone');

  @override
  List<_i1.Column> get columns => [
        id,
        name,
        isDone,
      ];
}

@Deprecated('Use TodoTable.t instead.')
TodoTable tTodo = TodoTable();

エンドポイント

次はCRUDの実装を行っていきます。endpointsフォルダにtodo_endpoint.dartを作成し、以下の内容を書きます。

import 'package:serverpod/serverpod.dart';

import '../generated/protocol.dart';

class TodoEndpoint extends Endpoint {
  Future<List<Todo>> getAllTodos(Session session) async {
    return Todo.find(session);
  }

  Future<void> addTodo(Session session, {required Todo todo}) async {
    await Todo.insert(session, todo);
  }

  Future<bool> updateTodo(Session session, {required Todo todo}) async {
    return Todo.update(session, todo);
  }

  Future<bool> deleteTodo(Session session, {required int id}) async {
    final result = await Todo.delete(session, where: (t) => t.id.equals(id));
    return result == 1;
  }
}

Endpointクラスを継承して、そこに各処理を関数に書いていきます。第一引数は必ずSessionを受け取る必要がありますが、これはフロント側では渡す必要がなく、実際にフロントエンドから渡される値は第2引数以降に書いていきます。ここでもう一度serverpod generateを実行し、コード生成を行います。何度もserveerpod generateを打つのがめんどくさいときは以下のコマンドを打つとコードの編集を検知して、、serverpod generateが自動で行われるようになります。

serverpod generate --watch

これでサーバーサイドの実装は終了ですが、最後の1つデータベースのテーブルの登録をしなければいけません。自分はこれをpsticoというアプリを用いて行いますが、PostgreSQLをいじれるものなら何でも構いません。普段サーバーサイドをあまり触らない人はよくわからないと思うので、同じアプリで同じように勧めてみてください。
https://eggerapps.at/postico2/
アプリを開くと以下のような画面が表示されます。

Untitled Serverをクリックして内容を埋めていきます。埋めるべきものはServerpodのコードに全て書いてあります。
Nicknameとcolorは自由ですので好きに決めてください。今回はtodo_podで赤色にしました。ここからは、todo_pod_server/configにあるdevelopment.yamlを見て勧めていきます。このフォルダには、各環境ごとの設定が記されており、今回は開発環境なので、development.ymalを参照していきます。

# This is the database setup for your server.
database:
  host: localhost
  port: 8090
  name: todo_pod
  user: postgres

同じフォルダにあるpassword.yamlの中に以下のような記述があります。このdatabaseの部分がパスワードになるのでこちらをposticoにコピペしてください。

# These are passwords used when running the server locally in development mode
development:
  database: '_4cOpCdjNnsi_M5uthC9h5e0_d8TVlPU'
  redis: 'gmahKU6repwoRcjVAGoXAuzRh6j6CVw7'

  # The service secret is used to communicate between servers and to access the
  # service protocol.
  serviceSecret: 'suTvEXGYIJo0hm1CmWVSGZu_qxSaiXHC'

これらを以下のように入力しconnectを押すことで、データベースに接続することができます。

接続が完了したら、todo_pod_server/generatedのtables.pgsqlファイルの中身をすべてコピーし、以下のように貼り付けてすべてを選択した状態で、execute statementを押してください。これをすることで、テーブルを定義することができます。定義用のファイルまで自動生成されているのは楽ちんですね。

最後にMacの方はcmd+r, Windowsの方はctr+rでスキーマをリロードして完了です。これでTodoテーブルが作成されました。

フロントサイドの実装(todo_pod_flutter)

フロントサイドとサーバサイドをつないでいきます。以下のように非常に簡単なTodoアプリを実装します。UIなどはかなり適当です😅

import 'package:todo_pod_client/todo_pod_client.dart';
import 'package:flutter/material.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';

var client = Client('http://localhost:8080/')
  ..connectivityMonitor = FlutterConnectivityMonitor();

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'todo pod',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Serverpod Example'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  
  MyHomePageState createState() => MyHomePageState();
}

class MyHomePageState extends State<MyHomePage> {
  String _errorMessage = '';
  List<Todo> todos = [];
  int? selectedTodoId;

  final _textEditingController = TextEditingController();

  
  void initState() {
    super.initState();
    getAllTodo();
  }

  Future<void> getAllTodo() async {
    try {
      final res = await client.todo.getAllTodos();
      setState(() {
        todos = [...res];
      });
    } catch (e) {
      setState(() {
        _errorMessage = '$e';
      });
    }
  }

  Future<void> addTask() async {
    try {
      await client.todo.addTodo(
        todo: Todo(
          name: _textEditingController.text,
          isDone: false,
        ),
      );

      await getAllTodo();
    } catch (e) {
      setState(() {
        _errorMessage = '$e';
      });
    }
  }

  Future<void> updateTodo(Todo todo) async {
    try {
      await client.todo.updateTodo(
        todo: Todo(
          id: todo.id,
          name: todo.name,
          isDone: !todo.isDone,
        ),
      );
      await getAllTodo();
    } catch (e) {
      setState(() {
        _errorMessage = '$e';
      });
    }
  }

  Future<void> deleteTodo(int id) async {
    try {
      await client.todo.deleteTodo(id: id);
      setState(() {
        selectedTodoId = null;
      });
      await getAllTodo();
    } catch (e) {
      setState(() {
        _errorMessage = '$e';
      });
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.only(bottom: 16.0),
              child: TextField(
                controller: _textEditingController,
                decoration: const InputDecoration(
                  hintText: 'Enter your name',
                ),
              ),
            ),
            if (_errorMessage.isNotEmpty)
              Text(
                _errorMessage,
                style: const TextStyle(color: Colors.red),
              ),
            Padding(
              padding: const EdgeInsets.only(bottom: 16.0),
              child: ElevatedButton(
                onPressed: addTask,
                child: const Text('新しいタスクを作成'),
              ),
            ),
            Flexible(
              child: ListView.builder(
                itemCount: todos.length,
                itemBuilder: (context, i) {
                  final todo = todos[i];
                  return Padding(
                    padding: const EdgeInsets.only(bottom: 8),
                    child: ListTile(
                      onTap: () {
                        setState(() {
                          selectedTodoId = todo.id;
                        });
                      },
                      title: Text(todo.name),
                      tileColor: Colors.white70,
                      selected: todo.id == selectedTodoId,
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(12),
                        side: const BorderSide(color: Colors.black12),
                      ),
                      trailing: IconButton(
                        onPressed: () {
                          updateTodo(todo);
                        },
                        icon: Icon(
                          Icons.check_box,
                          color: todo.isDone ? Colors.blue : Colors.grey,
                        ),
                      ),
                    ),
                  );
                },
              ),
            ),
            if (selectedTodoId != null)
              Padding(
                padding: const EdgeInsets.only(bottom: 16.0),
                child: ElevatedButton(
                  onPressed: () {
                    deleteTodo(selectedTodoId!);
                  },
                  child: const Text('タスクの削除'),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

テキストフォームの中身をタイトルとして新しくTodoを作成し、もう一度すべてのTodoを取ってきます。

まとめ

サーバサイドと連携するTodoアプリを簡単に作ることができました。Serverpodには、まだまだ紹介しきれていないすごい機能(log, cache, auth...)がたくさんあります。これから新しくアプリを作る方はサーバーサイドに検討して欲しいです。
使ってみた感想としては、やはり楽だなと思いました。同じ言語でサーバーサイドが書けて、渡す値も同じクラスを用いることができるので、記述量もかなり減ります。
質問や、間違い、指摘など何でもコメントにお願いします!

https://github.com/nicky-t/todo_pod/tree/main

Discussion