練習:学生管理システム

練習:学生管理システム

あなたが担任の先生で、手元にノートがあると想像してください。各ページには学生の名前、学籍番号、各科目の成績が記録されています。新しい転入生が来たら、空白のページを開いて登録します。試験が終わったら、対応するページを見つけて成績を記録します。学期末には、合計点順に成績表を印刷します。この一連の流れは「CRUD」操作を行っています。

今日はそのノートをコンピュータに移します — Goでコマンドラインの学生管理システムをゼロから構築し、構造体、メソッド、インターフェース、エラー処理で学んだすべてをつなぎ合わせます。


プロジェクト要件

機能 説明
学生の追加 名前と学籍番号を入力して新しいレコードを作成
科目成績の追加 特定の学生の特定の科目の成績を記録
学生の検索 学籍番号で検索し、学生情報とすべての成績を表示
すべての学生の一覧 記録されたすべての学生と平均点を表示
学生の削除 学籍番号で学生を削除
プログラムの終了 データを保存して終了

システム設計

+---------------------------------------------+
|                  CLIメニュー                  |
+---------------------------------------------+
|              StudentManager                  |
|  (Storerインターフェースを保持、ロジックを実行)  |
+---------------------------------------------+
|              Storer インターフェース           |
|  Save / Load -- 交換可能なストレージバックエンド  |
+-------------------+-------------------------+
|   MemoryStore     |   FileStore(オプション)  |
|   (インメモリマップ) |   (JSONファイル)        |
+-------------------+-------------------------+

コアタイプ:


例 1: 完全なコード

main.go ファイルを作成し、以下のコードをそのままコピーしてください:

GO
package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"os"
	"sort"
	"strconv"
	"strings"
)

// ============================================================
// データ構造定義
// ============================================================

// Course は科目の成績を表す
type Course struct {
	Name  string  `json:"name"`
	Score float64 `json:"score"`
}

// Student は学生を表す
type Student struct {
	ID      string   `json:"id"`
	Name    string   `json:"name"`
	Courses []Course `json:"courses"`
}

// Average は学生の平均点を計算する
func (s Student) Average() float64 {
	if len(s.Courses) == 0 {
		return 0
	}
	total := 0.0
	for _, c := range s.Courses {
		total += c.Score
	}
	return total / float64(len(s.Courses))
}

// AddCourse は学生に科目成績を追加する
func (s *Student) AddCourse(name string, score float64) {
	// すでに科目が存在する場合は成績を更新
	for i, c := range s.Courses {
		if c.Name == name {
			s.Courses[i].Score = score
			return
		}
	}
	s.Courses = append(s.Courses, Course{Name: name, Score: score})
}

// String は fmt.Stringer インターフェースを実装し、表示を簡単にする
func (s Student) String() string {
	return fmt.Sprintf("[%s] %s (平均: %.1f)", s.ID, s.Name, s.Average())
}

// ============================================================
// ストレージインターフェース
// ============================================================

// Storer は抽象的なストレージインターフェースを定義
type Storer interface {
	Save(students map[string]*Student) error
	Load() (map[string]*Student, error)
}

// ------------------------------------------------------------
// メモリストレージの実装
// ------------------------------------------------------------

// MemoryStore はメモリにデータを格納する(プログラム終了時に消える)
type MemoryStore struct{}

func NewMemoryStore() *MemoryStore {
	return &MemoryStore{}
}

func (m *MemoryStore) Save(students map[string]*Student) error {
	// メモリストレージは永続化が不要、nilを返すだけ
	return nil
}

func (m *MemoryStore) Load() (map[string]*Student, error) {
	return make(map[string]*Student), nil
}

// ------------------------------------------------------------
// ファイルストレージの実装
// ------------------------------------------------------------

// FileStore はデータをJSON形式でファイルに保存する
type FileStore struct {
	Path string
}

func NewFileStore(path string) *FileStore {
	return &FileStore{Path: path}
}

func (f *FileStore) Save(students map[string]*Student) error {
	data, err := json.MarshalIndent(students, "", "  ")
	if err != nil {
		return fmt.Errorf("シリアライズに失敗しました: %w", err)
	}
	err = os.WriteFile(f.Path, data, 0644)
	if err != nil {
		return fmt.Errorf("ファイルの書き込みに失敗しました: %w", err)
	}
	return nil
}

func (f *FileStore) Load() (map[string]*Student, error) {
	students := make(map[string]*Student)

	data, err := os.ReadFile(f.Path)
	if err != nil {
		if os.IsNotExist(err) {
			// ファイルが存在しない場合、空のマップを返す
			return students, nil
		}
		return nil, fmt.Errorf("ファイルの読み込みに失敗しました: %w", err)
	}

	err = json.Unmarshal(data, &students)
	if err != nil {
		return nil, fmt.Errorf("データの解析に失敗しました: %w", err)
	}
	return students, nil
}

// ============================================================
// ビジネスロジック層
// ============================================================

// StudentManager はすべての学生データを管理する
type StudentManager struct {
	students map[string]*Student
	store    Storer
	reader  *bufio.Reader
}

// NewStudentManager は新しいマネージャーを作成する
func NewStudentManager(store Storer) (*StudentManager, error) {
	students, err := store.Load()
	if err != nil {
		return nil, fmt.Errorf("データの読み込みに失敗しました: %w", err)
	}
	return &StudentManager{
		students: students,
		store:    store,
		reader:   bufio.NewReader(os.Stdin),
	}, nil
}

// AddStudent は新しい学生を追加する
func (m *StudentManager) AddStudent(id, name string) error {
	if id == "" || name == "" {
		return fmt.Errorf("学籍番号と名前は空にできません")
	}
	if _, exists := m.students[id]; exists {
		return fmt.Errorf("学籍番号 %s はすでに存在します", id)
	}
	m.students[id] = &Student{ID: id, Name: name}
	return m.store.Save(m.students)
}

// AddCourse は学生に科目成績を追加する
func (m *StudentManager) AddCourse(id, courseName string, score float64) error {
	student, err := m.findStudent(id)
	if err != nil {
		return err
	}
	if score < 0 || score > 100 {
		return fmt.Errorf("成績は0から100の間でなければなりません")
	}
	student.AddCourse(courseName, score)
	return m.store.Save(m.students)
}

// GetStudent は学生情報を検索する
func (m *StudentManager) GetStudent(id string) (*Student, error) {
	return m.findStudent(id)
}

// DeleteStudent は学生を削除する
func (m *StudentManager) DeleteStudent(id string) error {
	if _, exists := m.students[id]; !exists {
		return fmt.Errorf("学籍番号 %s は存在しません", id)
	}
	delete(m.students, id)
	return m.store.Save(m.students)
}

// ListStudents は学籍番号順にすべての学生を返す
func (m *StudentManager) ListStudents() []*Student {
	list := make([]*Student, 0, len(m.students))
	for _, s := range m.students {
		list = append(list, s)
	}
	sort.Slice(list, func(i, j int) bool {
		return list[i].ID < list[j].ID
	})
	return list
}

// findStudent はIDで学生を検索する(内部ヘルパー)
func (m *StudentManager) findStudent(id string) (*Student, error) {
	s, exists := m.students[id]
	if !exists {
		return nil, fmt.Errorf("学籍番号 %s は存在しません", id)
	}
	return s, nil
}

// ============================================================
// CLI対話層
// ============================================================

// Run はコマンドライン対話ループを開始する
func (m *StudentManager) Run() {
	for {
		m.printMenu()
		choice := m.readLine("選択肢を入力してください: ")

		switch choice {
		case "1":
			m.handleAddStudent()
		case "2":
			m.handleAddCourse()
		case "3":
			m.handleGetStudent()
		case "4":
			m.handleListStudents()
		case "5":
			m.handleDeleteStudent()
		case "6":
			fmt.Println("\nデータを保存しました。さようなら!")
			return
		default:
			fmt.Println("\n無効な選択肢です。もう一度お試しください")
		}
		fmt.Println()
	}
}

func (m *StudentManager) printMenu() {
	fmt.Println("+------------------------------+")
	fmt.Println("|   学生管理システム v1.0       |")
	fmt.Println("+------------------------------+")
	fmt.Println("|  1. 学生を追加                |")
	fmt.Println("|  2. 科目成績を追加            |")
	fmt.Println("|  3. 学生を検索                |")
	fmt.Println("|  4. すべての学生を表示        |")
	fmt.Println("|  5. 学生を削除                |")
	fmt.Println("|  6. 終了                     |")
	fmt.Println("+------------------------------+")
}

func (m *StudentManager) handleAddStudent() {
	id := m.readLine("学籍番号を入力してください: ")
	name := m.readLine("名前を入力してください: ")
	err := m.AddStudent(id, name)
	if err != nil {
		fmt.Printf("\n追加に失敗しました: %v\n", err)
		return
	}
	fmt.Printf("\n学生 %s が正常に追加されました!\n", name)
}

func (m *StudentManager) handleAddCourse() {
	id := m.readLine("学籍番号を入力してください: ")
	courseName := m.readLine("科目名を入力してください: ")
	scoreStr := m.readLine("成績を入力してください(0-100): ")

	score, err := strconv.ParseFloat(scoreStr, 64)
	if err != nil {
		fmt.Printf("\n成績の形式が無効です: %v\n", err)
		return
	}

	err = m.AddCourse(id, courseName, score)
	if err != nil {
		fmt.Printf("\n追加に失敗しました: %v\n", err)
		return
	}
	fmt.Printf("\n成績が正常に追加されました!\n")
}

func (m *StudentManager) handleGetStudent() {
	id := m.readLine("学籍番号を入力してください: ")
	s, err := m.GetStudent(id)
	if err != nil {
		fmt.Printf("\n検索に失敗しました: %v\n", err)
		return
	}
	fmt.Println("\n--- 学生情報 ---")
	fmt.Printf("学籍番号: %s\n", s.ID)
	fmt.Printf("名前: %s\n", s.Name)
	if len(s.Courses) == 0 {
		fmt.Println("科目成績はまだありません")
	} else {
		fmt.Println("科目成績:")
		for _, c := range s.Courses {
			fmt.Printf("  %s: %.1f\n", c.Name, c.Score)
		}
		fmt.Printf("平均: %.1f\n", s.Average())
	}
}

func (m *StudentManager) handleListStudents() {
	list := m.ListStudents()
	if len(list) == 0 {
		fmt.Println("\n学生レコードがありません")
		return
	}
	fmt.Println("\n--- すべての学生 ---")
	for _, s := range list {
		courseCount := len(s.Courses)
		fmt.Printf("  %s  科目数: %d  平均: %.1f\n", s, courseCount, s.Average())
	}
	fmt.Printf("\n合計: %d名\n", len(list))
}

func (m *StudentManager) handleDeleteStudent() {
	id := m.readLine("削除する学籍番号を入力してください: ")
	err := m.DeleteStudent(id)
	if err != nil {
		fmt.Printf("\n削除に失敗しました: %v\n", err)
		return
	}
	fmt.Printf("\n学籍番号 %s が削除されました\n", id)
}

// readLine は1行のユーザー入力を読み取り、空白をトリムする
func (m *StudentManager) readLine(prompt string) string {
	fmt.Print(prompt)
	line, _ := m.reader.ReadString('\n')
	return strings.TrimSpace(line)
}

// ============================================================
// プログラムエントリー
// ============================================================

func main() {
	// ファイルストレージを使用(データは students.json に保存)
	store := NewFileStore("students.json")

	// メモリストレージを使用する場合(終了時にデータが消える):
	// store := NewMemoryStore()

	mgr, err := NewStudentManager(store)
	if err != nil {
		fmt.Fprintf(os.Stderr, "初期化に失敗しました: %v\n", err)
		os.Exit(1)
	}

	fmt.Println("学生管理システムへようこそ!")
	mgr.Run()
}
▶ 試してみよう

コード解説

1. データ構造の設計

GO
type Student struct {
    ID      string   `json:"id"`
    Name    string   `json:"name"`
    Courses []Course `json:"courses"`
}

2. メソッドとポインタレシーバ

GO
func (s *Student) AddCourse(name string, score float64) { ... }
func (s Student) Average() float64 { ... }

3. インターフェースの抽象化 — Storer

GO
type Storer interface {
    Save(students map[string]*Student) error
    Load() (map[string]*Student, error)
}

4. エラー処理戦略

このプロジェクトでは各レイヤーでエラーを処理します:

レイヤー 戦略
データ検証 学籍番号/名前が空でないか、成績が0-100の範囲かをチェック
ビジネスロジック 学籍番号の存在チェック、重複エラー
ストレージ層 fmt.Errorf("...: %w", err) で基礎エラーをラップ
CLI層 エラーをキャッチし、ユーザーフレンドリーなメッセージを表示
GO
return fmt.Errorf("シリアライズに失敗しました: %w", err)

%w ラッピングにより元のエラー情報が保持され、呼び出し側が errors.Iserrors.As でエラータイプをチェックできます。

5. CLIメニューループ

GO
for {
    m.printMenu()
    choice := m.readLine("選択肢を入力してください: ")
    switch choice { ... }
}

メインループは継続的にメニューを表示し、入力を読み取り、対応するハンドラにディスパッチします。この「読み取り-ディスパッチ」パターンはコマンドラインプログラムの古典的な構造です。

6. 拡張可能なストレージ層

ストレージ方法の切り替えは main() の1行を変更するだけです:

GO
// メモリモード
store := NewMemoryStore()

// ファイルモード
store := NewFileStore("students.json")

StudentManager は下位の変更をまったく認識しません — これがインターフェースの価値です。


実行とテスト

BASH
# コンパイルして実行
go run main.go
学生管理システムへようこそ!
+------------------------------+
|   学生管理システム v1.0       |
+------------------------------+
|  1. 学生を追加                |
|  2. 科目成績を追加            |
|  3. 学生を検索                |
|  4. すべての学生を表示        |
|  5. 学生を削除                |
|  6. 終了                     |
+------------------------------+
選択肢を入力してください: 1
学籍番号を入力してください: 1001
名前を入力してください: Alice

学生 Alice が正常に追加されました!

終了すると、プログラムはカレントディレクトリに students.json ファイルを生成し、次回起動時に自動的に読み込まれます。


❓ よくある質問

質問1:なぜ Average() は値レシーバで AddCourse() はポインタレシーバなの?

Average() は合計を計算するためにデータを読み取るだけで、Student を変更しないため、値レシーバの方が安全です。AddCourse()Courses スライスに要素を追加する必要があるため、呼び出し側の元のデータを変更するためにポインタレシーバが必要です。シンプルな原則:変更するにはポインタ、読み取りには値

質問2:map[string]*Student* は何を意味する?

これはポインタ値を持つマップです。m.students[id]Student のコピーではなく *Student(Studentへのポインタ)を返します。このようにすることで、AddCourse を通じて学生データを変更した際、変更がマップに直接反映され、追加の処理が不要です。

質問3:なぜ Load() はファイルが存在しない場合を特別に処理するの?

初回実行時、students.json はまだ存在せず、os.ReadFile はエラーを返します。エラーを報告して終了するだけでは、ユーザーはプログラムを起動できません。そこで os.IsNotExist(err) でチェックします — ファイルの不在は正常であり、空のマップを返します。

質問4:データベースストレージに拡張するには?

Storer インターフェースを実装するだけです:

GO
type MySQLStore struct {
    db *sql.DB
}

func (m *MySQLStore) Save(students map[string]*Student) error {
    // データベースに INSERT/UPDATE
}

func (m *MySQLStore) Load() (map[string]*Student, error) {
    // データベースから SELECT
}

そして main()&MySQLStore{db: db} を渡すだけで、ビジネスコードへの変更は不要です。


📖 まとめ

このレッスンでは、完全なプロジェクトを通じて第2フェーズのコア知識をつなぎ合わせました:

これがGoエンジニアリングの基本パターンです:構造体でモデル化し、メソッドで振る舞いをカプセル化し、インターフェースで依存関係を分離し、エラーで例外を伝播します。


📝 演習

演習1:平均点によるソート機能の追加

新しいメニューオプション「平均点順ランキング」を追加し、すべての学生を平均点の高い順にソートして出力してください。ヒント:sort.Slice を使用します。

演習2:インポート/エクスポート機能の追加

2つの新しいメソッドを実装してください:

演習3:データ検証ミドルウェアの追加

任意の Storer 実装をラップする ValidatingStore 構造体を作成し、Save 前にすべての学生データを検証してください(学籍番号が空でない、名前が空でない、成績が0-100の範囲)。これはデコレーターパターンの実践です。


次のレッスン:Goroutineと並行処理
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%