🚀

【完全解説】GolangとSQLiteで作るシンプルなメモアプリ

に公開

はじめに 🔥

このブログ記事では、GolangとSQLiteを使ってシンプルなコマンドラインメモアプリを作成する方法を、ステップバイステップで解説します。
最終的に完成するアプリは、以下の3つのコマンドで動作します。

  • ./memo add "<メモする内容>" でメモを追加
  • ./memo list でメモの一覧表示とIDの確認
  • ./memo delete <ID> で指定したIDのメモを削除

各ステップごとに小さなコードブロックを提示し、実際に動かして動作を確認しながら進めるので、動いているという実感と作業の進捗を両方感じられる内容になっています。

ステップ1: プロジェクトの準備とDB接続 🔍

まず、SQLiteを使うために、SQLiteドライバ「github.com/mattn/go-sqlite3」をインストールしましょう。

go get github.com/mattn/go-sqlite3

次に、 main.go を作成し、SQLiteデータベースに接続する部分だけを実装します。
ここでは、 memo.db というファイルを開くコードを書いています。まだ何も操作はしませんが、これをビルドして実行すると、DBファイルが作成されるはずです。

package main

import (
	"database/sql"
	"log"
	"os"

	_ "github.com/mattn/go-sqlite3"
)

func main() {
	// コマンドライン引数がなければ終了
	if len(os.Args) < 2 {
		os.Exit(1)
	}

	// SQLite DB を memo.db としてオープン (存在しなければ作成)
	db, err := sql.Open("sqlite3", "memo.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
}

この状態でビルドして実行すると、 memo.db というファイルがプロジェクトフォルダに作成されるはずです。

ステップ2: コマンドライン引数の処理と基本構造 🔧

このステップでは、コマンドラインから渡される引数を解析し、各コマンド(add、list、delete)に応じた処理を実行する基本的な構造を実装します。
最初に、引数が不足している場合は使い方(usage)を表示し、正しい引数が与えられた場合は、switch文を用いてそれぞれの処理に分岐させます。

以下のコードは、DB接続の後にコマンドライン引数の処理を追加した例です。まだ具体的なDB操作(メモの追加、一覧表示、削除)の実装は後のステップで行いますが、今回は各コマンドに対してどの関数を呼び出すかという基本構造だけを実装しています。

package main

import (
	"database/sql"
	"fmt"
	"log"
	"os"
	"strconv"

	_ "github.com/mattn/go-sqlite3"
)

func main() {
	// コマンドライン引数がなければ終了
	if len(os.Args) < 2 {
		os.Exit(1)
	}

	// SQLite DB を memo.db としてオープン (存在しなければ作成)
	db, err := sql.Open("sqlite3", "memo.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// memos テーブルがなければ作成する
	createTableSQL := `
	CREATE TABLE IF NOT EXISTS memos (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		content TEXT NOT NULL);`
	if _, err = db.Exec(createTableSQL); err != nil {
		log.Fatal(err)
	}

	// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
	// ここまで実装済み

	// コマンドライン引数の解析と処理の分岐
	cmd := os.Args[1]
	switch cmd {
	case "add":
		// メモ追加コマンド: 引数が足りなければ使い方を表示して終了
		if len(os.Args) < 3 {
			fmt.Println("Please provide a memo to add")
			usage()
			os.Exit(1)
		}
		memoText := os.Args[2]
		// ここでは後で実装する addMemo 関数を呼び出す
		addMemo(db, memoText)
	case "list":
		// メモ一覧表示コマンド: そのまま listMemos 関数を呼び出す
		listMemos(db)
	case "delete":
		// メモ削除コマンド: 削除するメモのIDが必要
		if len(os.Args) < 3 {
			fmt.Println("Please provide a memo ID to delete")
			usage()
			os.Exit(1)
		}
		idStr := os.Args[2]
		id, err := strconv.Atoi(idStr)
		if err != nil {
			fmt.Println("Invalid ID:", idStr)
			os.Exit(1)
		}
		// ここでは後で実装する deleteMemo 関数を呼び出す
		deleteMemo(db, id)
	default:
		// 未知のコマンドの場合は使い方を表示
		fmt.Println("Invalid command:", cmd)
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Println(`Usage:
	memo add <memo>
	memo list
	memo delete <memo ID>`)
}

// 以下は仮の関数定義 (後で実装する内容に差し替えます)
func addMemo(db *sql.DB, content string) {
	// 後で実装
}

func listMemos(db *sql.DB) {
	// 後で実装
}

func deleteMemo(db *sql.DB, id int) {
	// 後で実装
}

説明

  • 引数のチェック:
    os.Args を使ってコマンドライン引数の数をチェックします。引数が不足している場合は、 usage() 関数で使い方を表示し、プログラムを終了します。

  • DB接続とテーブル作成:
    ステップ1で実装した通り、SQLiteデータベースに接続し、必要なテーブルを作成します。
    (この部分はステップ1で既に確認済みです。)

  • コマンド分岐:
    switch 文を使用して、 addlistdelete の各コマンドに対して適切な関数(後で実装する関数)を呼び出します。
    各コマンドで、必要な引数が渡されているかどうかもチェックしています。

この基本構造を完成させることで、後のステップで各機能の実装(メモの追加、一覧表示、削除)がスムーズに行える基盤が整います。
次のステップでは、具体的なDB操作の実装に進みます。

ステップ3: メモの追加機能 (add) ✏️

このステップでは、ユーザーが入力したメモの内容をデータベースに保存する「追加機能」を実装します。
コマンドラインで ./memo add "<メモする内容>" を実行すると、渡された文字列がSQLiteデータベースの memos テーブルに保存されます。

実装のポイント

  • SQLのINSERT文の利用:
    ユーザーから受け取ったメモ内容を memos テーブルに保存するために、SQLの INSERT 文を使用します。

  • プリペアドステートメント:
    db.Prepare を利用してSQL文を事前に準備し、stmt.Exec でパラメータを渡す形にします。これにより、SQLインジェクション対策にもなりますし、コードがシンプルになります。

  • エラーハンドリングとリソース管理:
    エラーが発生した場合は log.Fatal でプログラムを停止し、defer を用いてステートメントをクローズすることでリソースの解放を確実に行います。

コード例

以下のコードは、メモをDBに追加する addMemo 関数の実装例です。この関数は、前ステップで確立したDB接続を引数に取り、ユーザーから渡されたメモ内容を memos テーブルに保存します。

func addMemo(db *sql.DB, content string) {
	stmt, err := db.Prepare("INSERT INTO memos (content) VALUES (?)")
	if err != nil {
		log.Fatal(err)
	}
	defer stmt.Close()

	_, err = stmt.Exec(content)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Memo added successfully.")
}

動作確認

  1. まず、上記の addMemo 関数を main.go に実装し、ステップ2の基本構造の add ケースに組み込みます。
  2. コマンドラインで以下のように実行して、メモが正しく追加されるか確認しましょう。
./memo add "今日のタスクを確認する"

実行後、「Memo added successfully!」と表示され、 memo.dbmemos テーブルに新しいレコードが追加されていることを確認できれば成功です。

このように、小さなコードブロックを実装して動かしながら、アプリケーションの機能が順次完成していく様子を体感してください。次のステップでは、メモの一覧表示機能(list)を実装していきます。

addMemo 実装後の main.go
package main

import (
	"database/sql"
	"fmt"
	"log"
	"os"
	"strconv"

	_ "github.com/mattn/go-sqlite3"
)

func main() {
	// コマンドライン引数がなければ終了
	if len(os.Args) < 2 {
		os.Exit(1)
	}

	// SQLite DB を memo.db としてオープン (存在しなければ作成)
	db, err := sql.Open("sqlite3", "memo.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// memos テーブルがなければ作成する
	createTableSQL := `
	CREATE TABLE IF NOT EXISTS memos (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		content TEXT NOT NULL);`
	if _, err = db.Exec(createTableSQL); err != nil {
		log.Fatal(err)
	}

	// コマンドライン引数の解析と処理の分岐
	cmd := os.Args[1]
	switch cmd {
	case "add":
		// メモ追加コマンド: 引数が足りなければ使い方を表示して終了
		if len(os.Args) < 3 {
			fmt.Println("Please provide a memo to add")
			usage()
			os.Exit(1)
		}
		memoText := os.Args[2]
		addMemo(db, memoText)
	case "list":
		// メモ一覧表示コマンド: そのまま listMemos 関数を呼び出す
		listMemos(db)
	case "delete":
		// メモ削除コマンド: 削除するメモのIDが必要
		if len(os.Args) < 3 {
			fmt.Println("Please provide a memo ID to delete")
			usage()
			os.Exit(1)
		}
		idStr := os.Args[2]
		id, err := strconv.Atoi(idStr)
		if err != nil {
			fmt.Println("Invalid ID:", idStr)
			os.Exit(1)
		}
		// ここでは後で実装する deleteMemo 関数を呼び出す
		deleteMemo(db, id)
	default:
		// 未知のコマンドの場合は使い方を表示
		fmt.Println("Invalid command:", cmd)
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Println(`Usage:
	memo add <memo>
	memo list
	memo delete <memo ID>`)
}

func addMemo(db *sql.DB, content string) {
	stmt, err := db.Prepare("INSERT INTO memos (content) VALUES (?)")
	if err != nil {
		log.Fatal(err)
	}
	defer stmt.Close()

	_, err = stmt.Exec(content)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Memo added successfully.")
}

func listMemos(db *sql.DB) {
	// 後で実装
}

func deleteMemo(db *sql.DB, id int) {
	// 後で実装
}

ステップ4: メモの一覧表示機能 (list) 📋

このステップでは、SQLiteデータベースから保存されているメモの全件を取得し、IDと内容を一覧表示する機能を実装します。
SQLの SELECT 文を使ってデータを取得し、rows.Next()rows.Scan() を利用して各レコードを読み込む方法を学びます。

実装のポイント

  • SQLのSELECT文:
    SELECT id, content FROM memos で全メモを取得します。

  • 結果セットのイテレーション:
    rows.Next() を用いて各レコードを順に処理し、rows.Scan() でIDとメモ内容を変数に格納します。

  • エラーハンドリング:
    ループ中にエラーが発生した場合は適切に処理し、defer rows.Close() を使ってリソースを解放します。

コード例

以下の listMemos 関数を実装し、DBからメモを一覧表示する処理を行います。

func listMemos(db *sql.DB) {
	rows, err := db.Query("SELECT id, content FROM memos")
	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()

	fmt.Println("Memo list:")
	for rows.Next() {
		var id int
		var content string
		err = rows.Scan(&id, &content)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("%d: %s\n", id, content)
	}

	// ループ後にエラーがないか確認する
	if err = rows.Err(); err != nil {
		log.Fatal(err)
	}
}

動作確認

  1. 先ほどのステップで実装した listMemos 関数を main.go に追加します。
  2. コマンドラインで以下のように実行して、DBに保存されたメモの一覧が表示されるか確認しましょう。
./memo list
Memo list:
1: 今日のタスクを確認する
2: ミーティングの議事録を作成する

このステップにより、データベースから情報を読み出し、ユーザーに一覧として提示する基本的なDB操作を学ぶことができます。

次のステップでは、メモの削除機能 (delete) の実装に進みます。

listMemo 実装後の main.go
package main

import (
	"database/sql"
	"fmt"
	"log"
	"os"
	"strconv"

	_ "github.com/mattn/go-sqlite3"
)

func main() {
	// コマンドライン引数がなければ終了
	if len(os.Args) < 2 {
		os.Exit(1)
	}

	// SQLite DB を memo.db としてオープン (存在しなければ作成)
	db, err := sql.Open("sqlite3", "memo.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// memos テーブルがなければ作成する
	createTableSQL := `
	CREATE TABLE IF NOT EXISTS memos (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		content TEXT NOT NULL);`
	if _, err = db.Exec(createTableSQL); err != nil {
		log.Fatal(err)
	}

	// コマンドライン引数の解析と処理の分岐
	cmd := os.Args[1]
	switch cmd {
	case "add":
		// メモ追加コマンド: 引数が足りなければ使い方を表示して終了
		if len(os.Args) < 3 {
			fmt.Println("Please provide a memo to add")
			usage()
			os.Exit(1)
		}
		memoText := os.Args[2]
		addMemo(db, memoText)
	case "list":
		// メモ一覧表示コマンド: そのまま listMemos 関数を呼び出す
		listMemos(db)
	case "delete":
		// メモ削除コマンド: 削除するメモのIDが必要
		if len(os.Args) < 3 {
			fmt.Println("Please provide a memo ID to delete")
			usage()
			os.Exit(1)
		}
		idStr := os.Args[2]
		id, err := strconv.Atoi(idStr)
		if err != nil {
			fmt.Println("Invalid ID:", idStr)
			os.Exit(1)
		}
		// ここでは後で実装する deleteMemo 関数を呼び出す
		deleteMemo(db, id)
	default:
		// 未知のコマンドの場合は使い方を表示
		fmt.Println("Invalid command:", cmd)
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Println(`Usage:
	memo add <memo>
	memo list
	memo delete <memo ID>`)
}

func addMemo(db *sql.DB, content string) {
	stmt, err := db.Prepare("INSERT INTO memos (content) VALUES (?)")
	if err != nil {
		log.Fatal(err)
	}
	defer stmt.Close()

	_, err = stmt.Exec(content)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Memo added successfully.")
}

func listMemos(db *sql.DB) {
	rows, err := db.Query("SELECT id, content FROM memos")
	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()

	fmt.Println("Memo list:")
	for rows.Next() {
		var id int
		var content string
		err = rows.Scan(&id, &content)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("%d: %s\n", id, content)
	}

	// ループ後にエラーがないか確認する
	if err = rows.Err(); err != nil {
		log.Fatal(err)
	}
}

func deleteMemo(db *sql.DB, id int) {
	// 後で実装
}

ステップ5: メモの削除機能 (delete) 🔪

このステップでは、指定したIDのメモをデータベースから削除する「削除機能」を実装します。
ユーザーは ./memo delete <ID> というコマンドで、該当するメモを削除できるようになります。

実装のポイント

  • SQLのDELETE文:
    DELETE FROM memos WHERE id = ? というSQL文を使用して、指定されたIDのレコードを削除します。

  • プリペアドステートメント:
    db.Prepare を使って削除用のSQL文を事前に準備し、stmt.Exec でIDをパラメータとして渡します。これにより、SQLインジェクション対策も同時に行えます。

  • エラーハンドリングと結果の確認:
    ステートメントの実行時にエラーがないか確認し、削除された行数(RowsAffected)を取得することで、該当するメモが存在したかどうかをチェックします。

コード例

以下の deleteMemo 関数は、渡されたIDに該当するメモを削除する実装例です。

func deleteMemo(db *sql.DB, id int) {
	stmt, err := db.Prepare("DELETE FROM memos WHERE id = ?")
	if err != nil {
		log.Fatal(err)
	}
	defer stmt.Close()

	res, err := stmt.Exec(id)
	if err != nil {
		log.Fatal(err)
	}

	count, err := res.RowsAffected()
	if err != nil {
		log.Fatal(err)
	}

	if count == 0 {
		fmt.Println("No memo found with given ID.")
	}else{
		fmt.Println("Memo deleted successfully.")
	}
}

動作確認

  1. 上記の deleteMemo 関数を main.go に追加し、ステップ2で定義したコマンド分岐の delete ケースから呼び出します。
  2. コマンドラインで以下のように実行して、指定したIDのメモが正しく削除されるか確認しましょう。
./memo delete 1

実行後、メモが削除された場合は「Memo deleted successfully!」と表示され、指定したIDのメモがDBから削除されます。
もし該当するIDが存在しない場合は「No memo found with given ID.」と表示されます。

このステップにより、SQLのDELETE操作を通じたデータの削除方法と、結果の確認方法を学ぶことができます。これで、アプリの基本的なCRUD操作(Create, Read, Delete)がすべて実装されたことになります。

listMemo 実装後の main.go
package main

import (
	"database/sql"
	"fmt"
	"log"
	"os"
	"strconv"

	_ "github.com/mattn/go-sqlite3"
)

func main() {
	// コマンドライン引数がなければ終了
	if len(os.Args) < 2 {
		os.Exit(1)
	}

	// SQLite DB を memo.db としてオープン (存在しなければ作成)
	db, err := sql.Open("sqlite3", "memo.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// memos テーブルがなければ作成する
	createTableSQL := `
	CREATE TABLE IF NOT EXISTS memos (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		content TEXT NOT NULL);`
	if _, err = db.Exec(createTableSQL); err != nil {
		log.Fatal(err)
	}

	// コマンドライン引数の解析と処理の分岐
	cmd := os.Args[1]
	switch cmd {
	case "add":
		// メモ追加コマンド: 引数が足りなければ使い方を表示して終了
		if len(os.Args) < 3 {
			fmt.Println("Please provide a memo to add")
			usage()
			os.Exit(1)
		}
		memoText := os.Args[2]
		addMemo(db, memoText)
	case "list":
		// メモ一覧表示コマンド: そのまま listMemos 関数を呼び出す
		listMemos(db)
	case "delete":
		// メモ削除コマンド: 削除するメモのIDが必要
		if len(os.Args) < 3 {
			fmt.Println("Please provide a memo ID to delete")
			usage()
			os.Exit(1)
		}
		idStr := os.Args[2]
		id, err := strconv.Atoi(idStr)
		if err != nil {
			fmt.Println("Invalid ID:", idStr)
			os.Exit(1)
		}
		deleteMemo(db, id)
	default:
		// 未知のコマンドの場合は使い方を表示
		fmt.Println("Invalid command:", cmd)
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Println(`Usage:
	memo add <memo>
	memo list
	memo delete <memo ID>`)
}

func addMemo(db *sql.DB, content string) {
	stmt, err := db.Prepare("INSERT INTO memos (content) VALUES (?)")
	if err != nil {
		log.Fatal(err)
	}
	defer stmt.Close()

	_, err = stmt.Exec(content)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Memo added successfully.")
}

func listMemos(db *sql.DB) {
	rows, err := db.Query("SELECT id, content FROM memos")
	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()

	fmt.Println("Memo list:")
	for rows.Next() {
		var id int
		var content string
		err = rows.Scan(&id, &content)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("%d: %s\n", id, content)
	}

	// ループ後にエラーがないか確認する
	if err = rows.Err(); err != nil {
		log.Fatal(err)
	}
}

func deleteMemo(db *sql.DB, id int) {
	stmt, err := db.Prepare("DELETE FROM memos WHERE id = ?")
	if err != nil {
		log.Fatal(err)
	}
	defer stmt.Close()

	res, err := stmt.Exec(id)
	if err != nil {
		log.Fatal(err)
	}

	count, err := res.RowsAffected()
	if err != nil {
		log.Fatal(err)
	}

	if count == 0 {
		fmt.Println("No memo found with given ID.")
	}else{
		fmt.Println("Memo deleted successfully.")
	}
}

完成したアプリの全体像 🎉

これまでの各ステップで実装した機能をすべて統合した最終的なコードは以下の通りです。
このコードをビルドすると、memo というバイナリが生成され、次のコマンドでメモの追加、一覧表示、削除が可能になります。

package main

import (
	"database/sql"
	"fmt"
	"log"
	"os"
	"strconv"

	_ "github.com/mattn/go-sqlite3"
)

func main() {
	// コマンドライン引数がなければ終了
	if len(os.Args) < 2 {
		os.Exit(1)
	}

	// SQLite DB を memo.db としてオープン (存在しなければ作成)
	db, err := sql.Open("sqlite3", "memo.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// memos テーブルがなければ作成する
	createTableSQL := `
	CREATE TABLE IF NOT EXISTS memos (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		content TEXT NOT NULL);`
	if _, err = db.Exec(createTableSQL); err != nil {
		log.Fatal(err)
	}

	// コマンドライン引数の解析と処理の分岐
	cmd := os.Args[1]
	switch cmd {
	case "add":
		// メモ追加コマンド: 引数が足りなければ使い方を表示して終了
		if len(os.Args) < 3 {
			fmt.Println("Please provide a memo to add")
			usage()
			os.Exit(1)
		}
		memoText := os.Args[2]
		addMemo(db, memoText)
	case "list":
		// メモ一覧表示コマンド: そのまま listMemos 関数を呼び出す
		listMemos(db)
	case "delete":
		// メモ削除コマンド: 削除するメモのIDが必要
		if len(os.Args) < 3 {
			fmt.Println("Please provide a memo ID to delete")
			usage()
			os.Exit(1)
		}
		idStr := os.Args[2]
		id, err := strconv.Atoi(idStr)
		if err != nil {
			fmt.Println("Invalid ID:", idStr)
			os.Exit(1)
		}
		deleteMemo(db, id)
	default:
		// 未知のコマンドの場合は使い方を表示
		fmt.Println("Invalid command:", cmd)
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Println(`Usage:
	memo add <memo>
	memo list
	memo delete <memo ID>`)
}

func addMemo(db *sql.DB, content string) {
	stmt, err := db.Prepare("INSERT INTO memos (content) VALUES (?)")
	if err != nil {
		log.Fatal(err)
	}
	defer stmt.Close()

	_, err = stmt.Exec(content)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Memo added successfully.")
}

func listMemos(db *sql.DB) {
	rows, err := db.Query("SELECT id, content FROM memos")
	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()

	fmt.Println("Memo list:")
	for rows.Next() {
		var id int
		var content string
		err = rows.Scan(&id, &content)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("%d: %s\n", id, content)
	}

	// ループ後にエラーがないか確認する
	if err = rows.Err(); err != nil {
		log.Fatal(err)
	}
}

func deleteMemo(db *sql.DB, id int) {
	stmt, err := db.Prepare("DELETE FROM memos WHERE id = ?")
	if err != nil {
		log.Fatal(err)
	}
	defer stmt.Close()

	res, err := stmt.Exec(id)
	if err != nil {
		log.Fatal(err)
	}

	count, err := res.RowsAffected()
	if err != nil {
		log.Fatal(err)
	}

	if count == 0 {
		fmt.Println("No memo found with given ID.")
	}else{
		fmt.Println("Memo deleted successfully.")
	}
}

上記のコードを実際に動かすことで、以下の操作が可能になります。

  • ./memo add "メモ内容" でメモが追加される
  • ./memo list で追加したメモの一覧が表示される
  • ./memo delete <ID> で指定したIDのメモが削除される

まとめ 💥

今回の記事では、GolangとSQLiteを使ってシンプルなコマンドラインメモアプリを作成する方法を、ステップごとに実装しながら学びました。

DB接続、テーブル作成、SQLのINSERT・SELECT・DELETE文の実行、コマンドライン引数の処理など、実践的な技術を順次習得できる内容となっています。

このアプローチにより、動作するアプリケーションを作成しながら、GolangとSQLiteの連携方法をしっかりマスターしましょう!

Discussion