練習:学生管理システム
練習:学生管理システム
あなたが担任の先生で、手元にノートがあると想像してください。各ページには学生の名前、学籍番号、各科目の成績が記録されています。新しい転入生が来たら、空白のページを開いて登録します。試験が終わったら、対応するページを見つけて成績を記録します。学期末には、合計点順に成績表を印刷します。この一連の流れは「CRUD」操作を行っています。
今日はそのノートをコンピュータに移します — Goでコマンドラインの学生管理システムをゼロから構築し、構造体、メソッド、インターフェース、エラー処理で学んだすべてをつなぎ合わせます。
プロジェクト要件
| 機能 | 説明 |
|---|---|
| 学生の追加 | 名前と学籍番号を入力して新しいレコードを作成 |
| 科目成績の追加 | 特定の学生の特定の科目の成績を記録 |
| 学生の検索 | 学籍番号で検索し、学生情報とすべての成績を表示 |
| すべての学生の一覧 | 記録されたすべての学生と平均点を表示 |
| 学生の削除 | 学籍番号で学生を削除 |
| プログラムの終了 | データを保存して終了 |
システム設計
+---------------------------------------------+
| CLIメニュー |
+---------------------------------------------+
| StudentManager |
| (Storerインターフェースを保持、ロジックを実行) |
+---------------------------------------------+
| Storer インターフェース |
| Save / Load -- 交換可能なストレージバックエンド |
+-------------------+-------------------------+
| MemoryStore | FileStore(オプション) |
| (インメモリマップ) | (JSONファイル) |
+-------------------+-------------------------+
コアタイプ:
- Student — 学生情報 + 科目成績
- Course — 単一の科目成績
- Storer — ストレージ抽象化インターフェース
- StudentManager — ビジネスロジック層
例 1: 完全なコード
main.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. データ構造の設計
type Student struct {
ID string `json:"id"`
Name string `json:"name"`
Courses []Course `json:"courses"`
}
StudentはCourseのスライスを持ち、各コースには科目名と成績が含まれます。jsonタグにより、構造体を直接JSONにシリアライズしてファイルに保存できます。
2. メソッドとポインタレシーバ
func (s *Student) AddCourse(name string, score float64) { ... }
func (s Student) Average() float64 { ... }
AddCourseはスライスを変更するため、ポインタレシーバ*Studentを使用します。Averageはデータの読み取りのみのため、値レシーバStudentで十分です。AddCourseは「科目名が存在する場合は更新する」ロジックを実装し、重複を防ぎます。
3. インターフェースの抽象化 — Storer
type Storer interface {
Save(students map[string]*Student) error
Load() (map[string]*Student, error)
}
StudentManagerはStorerインターフェースのみに依存し、具体的なストレージ方法には依存しません。MemoryStore(インメモリ)とFileStore(JSONファイル)の2つの実装を提供します。- データベースに切り替えたい場合は?
Storerを実装する新しい型を追加するだけ — ビジネスコードへの変更はゼロです。
4. エラー処理戦略
このプロジェクトでは各レイヤーでエラーを処理します:
| レイヤー | 戦略 |
|---|---|
| データ検証 | 学籍番号/名前が空でないか、成績が0-100の範囲かをチェック |
| ビジネスロジック | 学籍番号の存在チェック、重複エラー |
| ストレージ層 | fmt.Errorf("...: %w", err) で基礎エラーをラップ |
| CLI層 | エラーをキャッチし、ユーザーフレンドリーなメッセージを表示 |
return fmt.Errorf("シリアライズに失敗しました: %w", err)
%w ラッピングにより元のエラー情報が保持され、呼び出し側が errors.Is や errors.As でエラータイプをチェックできます。
5. CLIメニューループ
for {
m.printMenu()
choice := m.readLine("選択肢を入力してください: ")
switch choice { ... }
}
メインループは継続的にメニューを表示し、入力を読み取り、対応するハンドラにディスパッチします。この「読み取り-ディスパッチ」パターンはコマンドラインプログラムの古典的な構造です。
6. 拡張可能なストレージ層
ストレージ方法の切り替えは main() の1行を変更するだけです:
// メモリモード
store := NewMemoryStore()
// ファイルモード
store := NewFileStore("students.json")
StudentManager は下位の変更をまったく認識しません — これがインターフェースの価値です。
実行とテスト
# コンパイルして実行
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 インターフェースを実装するだけです:
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フェーズのコア知識をつなぎ合わせました:
- 構造体 —
StudentとCourseがデータモデルを定義。jsonタグがシリアライズをサポート。 - メソッド — 読み取り専用操作には値レシーバ、データ変更にはポインタレシーバ。
- インターフェース —
Storerインターフェースがビジネスロジックとストレージ実装を分離し、Goのインターフェース指向プログラミングを実演。 - エラー処理 — 各レイヤーでの検証とエラーラッピング。CLI層がユーザーフレンドリーなメッセージを表示。
- パッケージ構成 — すべてのコードが1つのパッケージにありますが、型とメソッドによる明確な責務分離。
これがGoエンジニアリングの基本パターンです:構造体でモデル化し、メソッドで振る舞いをカプセル化し、インターフェースで依存関係を分離し、エラーで例外を伝播します。
📝 演習
演習1:平均点によるソート機能の追加
新しいメニューオプション「平均点順ランキング」を追加し、すべての学生を平均点の高い順にソートして出力してください。ヒント:sort.Slice を使用します。
演習2:インポート/エクスポート機能の追加
2つの新しいメソッドを実装してください:
ExportCSV(filename string) error— すべての学生をCSVファイルにエクスポート(学籍番号、名前、科目、成績)。ImportCSV(filename string) error— CSVファイルから学生と成績を一括インポート。
演習3:データ検証ミドルウェアの追加
任意の Storer 実装をラップする ValidatingStore 構造体を作成し、Save 前にすべての学生データを検証してください(学籍番号が空でない、名前が空でない、成績が0-100の範囲)。これはデコレーターパターンの実践です。



