数日前にコマンドラインのタスク管理ツールを作りました:medum。しばらく記事を投稿していなかったことに気づき、今夜は時間があるので、これを取り出して軽く話してみようと思います。ついでに水を差す記事も 233((
名前の由来#
このプロジェクトはmedum
と呼ばれています。なぜなら、その開発目的は私がddl
を忘れないようにするためで、最初からメモ(memorandum)として設計されていました。memorandumという単語から最初の 2 文字と最後の 3 文字を組み合わせて、medum
が誕生しました。
コード構造#
具体的な開発の意図はREADME.md
に書かれているので、次はコード構造について話しましょう。
以下はすべてのコードファイルです:
.
├── config
│ └── config.go
├── main.go
├── output
│ └── output.go
├── path
│ └── path.go
├── public
│ └── public.go
├── sqlite
│ └── sqlite.go
└── text
└── text.go
各モジュールの機能は以下の通りです:
path.go
:設定ファイルフォルダのパス、設定ファイルのパス、sqlite データベースのパスを取得する役割。config.go
:設定ファイルを読み込み、設定ファイルが存在しない場合はデフォルト設定を書き込む役割。public.go
:複数のファイルで参照される公共の構造体を格納する役割、Configuration
(設定ファイル)、Event
(タスク)を含む。sqlite.go
:sqlite データベースのオープン、挿入、更新、クエリ、削除機能の実装。text.go
:このプログラムのほぼすべてのテキスト情報(エラーメッセージ、通知、SQL 文を含む)を格納する役割。output.go
:設定ファイルの色を関数にマッピングし、カラフルな出力関数の反射呼び出しを一層封装する役割。
設計思路#
-
まず機能から出発します。ファイルを読み込む必要があるので、パスを取得することが必須です。そこで最初に
path.go
を実装することを考えました:// path.go func GetPath() string { //フォルダのパスを取得、パスは~/.medum path, err := homedir.Dir() if err != nil { fmt.Printf(text.HomedirError, err) os.Exit(1) } return filepath.Join(path, ".medum") } // その後、設定ファイルとデータのパスを取得するのは簡単で、フォルダのパスの後にファイル名を追加するだけです。
-
パスができたら、次はそのパスからファイルを読み込む必要がありますが、ファイルはどこに読み込むのでしょうか?内容を格納する場所を確保するために、まず設定ファイルの構造体を実装する必要があります。設定ファイルは色をカスタマイズするため、出力モジュールが読み取る必要があるため、
public.go
に分離して配置し、その後config.go
を記述します:// public.go type Configuration struct { NumberColor string EventColor string TimeColor string }
// config.go func ReadConfig() *public.Configuration { configPath := path.GetConfigPath() // 設定ファイルが存在するか確認し、存在しない場合はデフォルト設定を書き込む if _, err := os.Stat(configPath); err != nil && !os.IsExist(err) { writeInitConfig(configPath) } // ... decoder := json.NewDecoder(file) conf := new(public.Configuration) // ファイルをconfに読み込む err = decoder.Decode(conf) // ... return conf func writeInitConfig(configPath string) { // まずフォルダが存在するか確認し、存在しない場合は新しく作成 tmp := path.GetPath() if _, err := os.Stat(tmp); !os.IsExist(err) { os.Mkdir(tmp, os.FileMode(0777)) } // 次に設定ファイルを新しく作成 file, err := os.Create(configPath) // デフォルト設定を作成 conf := public.Configuration{ NumberColor: "red", EventColor: "blue", TimeColor: "yellow", } // 書き込み encoder := json.NewEncoder(file) encoder.Encode(conf) }
-
設定ファイルの読み込みを実装したら、次はデータの操作です。
sqlite.go
を実装することを考えますが、変更を容易にするために、すべてのテキスト内容とロジックを分離する必要があります。まずtext.go
を記述しますが、その前にイベント構造体の内容を考える必要があります。したがって、最終的な順序はpublic.go
->text.go
->sqlite.go
です:// public.go // id、イベント内容、開始終了日、現在の状態を含む type Event struct { ID int EventContent string BeginDate int64 EndDate int64 IsEnd uint8 }
// text.go const ( // テーブルを作成する。idは自動増分、eventはイベント名、開始日と終了日はタイムスタンプで保存。 // 状態マークについては、イベントには未開始、進行中、終了の3つの状態があるため、isEndは0,1,2を取ることができ、対応する3つの状態を示す。 CreateTable = `create table if not exists eventList( id integer primary key autoincrement, event varchar(100) not null, beginDate unsigned bigint not null, endDate unsigned bigint not null, isEnd smallint default 1 not null );` // 行を挿入 InsertRow = `insert into eventList (event,beginDate,endDate) values (?,?,?);` // 現在の時間が終了時間を超えた場合、超過をマーク MarkOutdate = `update eventList set isEnd=2 where ?>endDate;` // 現在の時間が開始時間よりも早い場合、未開始をマーク MarkNotStart = `update eventList set isEnd=0 where ?<beginDate;` // 開始と終了の間にある場合、進行中をマーク MarkInProgress = `update eventList set isEnd=1 where ? between beginDate and endDate;` // すべてのイベントを取得し、イベントの状態、終了日でソート QueryRow = `select * from eventList order by isEnd desc,endDate` // 超過したイベントを削除 DeleteOutDate = `delete from eventList where isEnd=2` // 完了したイベントをidで削除 DeleteID = `delete from eventList where id=?` )
// sqlite.go // データベースを開く func openSqliteDB() *sql.DB { db, err := sql.Open("sqlite3", path.GetDataPath()) if err != nil { fmt.Printf(text.OpenDBError, err) os.Exit(1) } db.Exec(text.CreateTable) return db } // 残りの関数ロジックは類似しており、そのうちの1つを例として挙げます: func InsertSqliteDB(eventContent string, beginDate, endDate time.Time) error { // データベースを開く db := openSqliteDB() // 終了時に閉じる defer db.Close() // ステートメントを実行 _, err := db.Exec(text.InsertRow, eventContent, beginDate.Unix(), endDate.Unix()) return err }
-
次に
output.go
を実装することを考えます:// output.go // サポートされているすべての色(map[string]interface{}で保存) var funcs = map[string]interface{}{ "red": color.New(color.FgRed), "blue": color.New(color.FgBlue), "cyan": color.New(color.FgCyan), "green": color.New(color.FgGreen), "yellow": color.New(color.FgYellow), "magenta": color.New(color.FgMagenta), "white": color.New(color.FgWhite), "black": color.New(color.FgBlack), "hired": color.New(color.FgHiRed), "hiblue": color.New(color.FgHiBlue), "hicyan": color.New(color.FgHiCyan), "higreen": color.New(color.FgHiGreen), "hiyellow": color.New(color.FgHiYellow), "himagenta": color.New(color.FgHiMagenta), "hiwhite": color.New(color.FgHiWhite), "hiblack": color.New(color.FgHiBlack), } // reflectパッケージを使用して動的に呼び出す func call(m map[string]interface{}, color string, params ...interface{}) { function := reflect.ValueOf(m[color]).MethodByName("Printf") in := make([]reflect.Value, len(params)) for index, param := range params { in[index] = reflect.ValueOf(param) } function.Call(in) } // callを一層封装し、外部にCall関数を公開 func Call(color string, params ...interface{}) { call(funcs, color, params...) }
-
すべてが整ったので、最後にメイン関数で呼び出しロジックを完成させるだけです(もちろん、エラーメッセージやユーザーへのヒントなども補完する必要がありますが、それらはすでに簡単です):
// main.go // 受け取るコマンドライン引数 var ( begin string end string name string remove bool done int ) func main() { app := &cli.App{ // 引数のバインディングなどのプロセスは省略 Action: func(c *cli.Context) error { // 設定ファイルを読み込む conf := *config.ReadConfig() if remove { // -r:まず期限切れのイベントをマークし、その後削除 sqlite.UpdateSqliteDB(text.MarkOutdate) err := sqlite.DeleteOutDate() if err != nil { fmt.Printf(text.DeleteOutdateError, err) os.Exit(1) } fmt.Println(text.DeleteOutdateSuccess) } else if done != 0 { // -d int:指定されたidを直接削除 err := sqlite.DeleteID(done) if err != nil { fmt.Printf(text.DeleteIDError, err) os.Exit(1) } fmt.Println(text.DeleteIDSuccess) } else { if len(end) == 0 { // -dがない場合、イベントリストを表示 // ここでは進行中と期限切れをマークするだけです。なぜなら、イベントを挿入する際に未開始がすでにマークされているからです。時間の流れに従って、開始から未開始に変わることはありません。 sqlite.UpdateSqliteDB(text.MarkInProgress) sqlite.UpdateSqliteDB(text.MarkOutdate) // ...出力部分は省略 } } else { // -eが存在する場合、終了時間を解析し、開始イベントがあれば解析し、なければ現在の時間をデフォルトとします。 var beginTime, endTime time.Time endTime = handleString(end) if len(begin) == 0 { beginTime = time.Now() } else { beginTime = handleString(begin) } if beginTime.Unix() >= endTime.Unix() { fmt.Println(text.TimeError) os.Exit(1) } // 入力イベントを挿入 err := sqlite.InsertSqliteDB(name, beginTime, endTime) if err != nil { fmt.Printf(text.InsertDBError, err) } // 未開始をマーク sqlite.UpdateSqliteDB(text.MarkNotStart) fmt.Println(text.InsertSuccess) } } return nil }} err := app.Run(os.Args) if err != nil { log.Fatal(err) } } func handleString(tm string) time.Time { // 時間文字列をtime.Timeに変換、month.day.hour.minute形式のみ受け付ける tmp := strings.Split(tm, ".") // 長さが4でない場合は終了 if len(tmp) != 4 { fmt.Println(text.LengthError) os.Exit(1) } else { // 0を補完する、例えば5.20.12.0を05.20.12.00に補完し、解析エラーを防ぐ for index := range tmp { if len(tmp[index]) == 1 { tmp[index] = "0" + tmp[index] } } stdString := fmt.Sprintf(text.MyTime, strconv.Itoa(time.Now().Year()), tmp[0], tmp[1], tmp[2], tmp[3]) result, err := time.ParseInLocation(text.StandardTime, stdString, time.Now().Local().Location()) if err != nil { fmt.Printf(text.ParamError, err) os.Exit(1) } return result } //無駄な行、警告を防ぐためだけ return time.Now() } func formatTime(begin, end int64, IsEnd uint8) string { // 時間を%s time remaining/%s time beginning形式に変換し、%sは以下のformatTimeString関数を呼び出して取得 now := time.Now() if IsEnd == 0 { beginTime := time.Unix(begin, 0) dur := beginTime.Sub(now) return fmt.Sprintf(text.TimeBeginning, formatTimeString(dur.Minutes())) } else if IsEnd == 1 { endTime := time.Unix(end, 0) dur := endTime.Sub(now) return fmt.Sprintf(text.TimeRemaining, formatTimeString(dur.Minutes())) } else { return fmt.Sprintf(text.TimeRemaining, "no time") } } func formatTimeString(min float64) string { // 日/時間/分を取得 var tmp string if min > 1440 { tmp = strconv.Itoa(int(min/1440)) + " days" } else if min > 60 { tmp = strconv.Itoa(int((min / 60))) + " hours" } else { tmp = strconv.Itoa(int(min)) + " minutes" } return tmp }
こうして、想定していた機能はほぼ実現できました。タスクは成功裏に完了しました。
終わりの言葉#
okk、これで終了です。小さなまとめを書いたようなものです。
コードには基本的に全体にコメントがあります(ただし、golang
の規範に従うために、プラスチック英語を使用しています XD)。自分のコードはかなり読みやすいと思うので、皆さんのご意見をお待ちしています!