Goの文字列と日付・時刻の扱い

文字列と日付は、あらゆるプログラムにおける2つの基本的なデータ型です。Goの標準ライブラリは、サードパーティの依存関係を必要とせずに、これら両方に対して包括的なツールセットを提供しています。

strings パッケージには 40 以上の関数が含まれており、strconv パッケージはすべての型変換を処理し、time パッケージは時間に関連するすべての操作を網羅しています。このレッスンでは、Go での文字列および時間の処理に必要な一連のツールをすべて習得します。

1. 学習内容


2. 運用エンジニアの実話

(1) 課題:タイムスタンプを手動で解析すると、ログのすべての行でエラーが発生してしまう

ボブは運用エンジニアです。彼はQPSを算出するために、サーバーのログからタイムスタンプを抽出する必要があります:

「1日あたり50万行のログがあり、タイムスタンプは2026-07-08T10:00:00Z07/08/2026 10:00:00 AMの2つの形式で記録されています。Pythonのdatetimeモジュールを使って20行のコードを書きましたが、同僚にはその内容が理解できませんでした。そこでGoに切り替えたのですが、結局、日付・時刻のフォーマット文字列を理解するだけでも、ドキュメントを10回も参照しなければなりませんでした。」

彼が最初に書いたバージョンは次のとおりです:

GO
// 不良コード:時刻形式のハードコーディング,柔軟性に欠ける
func parseTimestamp(raw string) (time.Time, error) {
    // 忘れる Go 時刻の形式は 2006-01-02 15:04:05,適当に書いたわけではない
    return time.Parse("2006-01-02 15:04:05", raw)
}

同僚たちは、Goのタイムスタンプ形式(「2006-01-02 15:04:05」)にしばしば戸惑っています。

(2) Goソリューション:標準ライブラリの完全網羅

GO
// log_parser.go
パッケージ main

import (
    "fmt"
    "strings"
    "time"
)

// ログ行の削除 + タイムスタンプの抽出
func extractTimestamp(logLine 文字列) (time.Time, エラー) {
    // 1. strings パッケージ:TrimSpace 空白を削除
    line := strings.TrimSpace(logLine)
    if line == "" {
        return time.Time{}, fmt.Errorf("empty log line")
    }

    // 2. strings パッケージ:Split 最初のフィールドを取得する
    parts := strings.Fields(line)
    if len(parts) < 1 {
        return time.Time{}, fmt.Errorf("no fields")
    }

    // 3. strconv / strings:時間フィールドの抽出
    rawTime := strings.Trim(parts[0], "[]")

    // 4. time パッケージ:解析時間
    formats := []文字列{
        time.RFC3339,
        "01/02/2006 03:04:05 PM",
        "2006-01-02 15:04:05",
    }
    for _, f := range formats {
        if t, err := time.Parse(f, rawTime); err == nil {
            return t, nil
        }
    }
    return time.Time{}, fmt.Errorf("unrecognized time format: %s", rawTime)
}

func main() {
    lines := []文字列{
        "2026-07-08T10:00:00Z [INFO] Server started",
        "07/08/2026 10:05:00 AM [ERROR] Connection timeout",
    }

    for _, line := range lines {
        t, err := extractTimestamp(line)
        if err != nil {
            fmt.Printf("Error: %v\n", err)
            continue
        }
        fmt.Printf("Parsed: %s -> %s\n", line, t.Format(time.RFC822))
    }
}

出力:

TEXT
Parsed: 2026-07-08T10:00:00Z [INFO] Server started -> 08 Jul 26 10:00 UTC
Parsed: 07/08/2026 10:05:00 AM [ERROR] Connection timeout -> 08 Jul 26 10:05 UTC

(3) 利点:標準ライブラリの網羅性

パッケージ 主な機能 サードパーティ製ライブラリの代替
文字列 40以上の文字列関数 lodashやunderscoreは不要
strconv 型変換 手動での解析は不要
strings.Builder 効率的な連結 bytes.Buffer
時間 時間の解析・書式設定・タイマー moment.js / date-fns
💡 ヒント: Go言語では「参照時間」の形式 Mon Jan 2 15:04:05 MST 2006(01/02/03/04/05/6はそれぞれ月/日/時/分/秒/年に相当)が使用されるため、2006-01-02 15:04:05 を覚えておけば大丈夫です。


3. strings パッケージの主要機能

▶ サンプル:12の一般的な文字列関数

GO
package main

import (
    "fmt"
    "strings"
)

func main() {
    s := "  Hello, Go World!  "

    // トリミング
    fmt.Printf("TrimSpace: [%s]\n", strings.TrimSpace(s))
    fmt.Printf("Trim: [%s]\n", strings.Trim(s, " !"))
    fmt.Printf("TrimPrefix: %s\n", strings.TrimPrefix(s, "  Hello"))

    // 分割と統合
    parts := strings.Split("a,b,c", ",")
    fmt.Printf("Split: %v\n", parts)
    fmt.Printf("Join: %s\n", strings.Join(parts, "-"))

    // 検索
    fmt.Printf("Contains: %v\n", strings.Contains(s, "Go"))
    fmt.Printf("Index: %d\n", strings.Index(s, "Go"))
    fmt.Printf("Count: %d\n", strings.Count(s, "o"))

    // 置換
    fmt.Printf("Replace: %s\n", strings.Replace(s, "o", "0", 1))
    fmt.Printf("ReplaceAll: %s\n", strings.ReplaceAll(s, "o", "0"))

    // 大文字・小文字
    fmt.Printf("ToUpper: %s\n", strings.ToUpper(s))
    fmt.Printf("ToLower: %s\n", strings.ToLower(s))

    // フィールドの分割(空白の自動処理)
    fields := strings.Fields("  hello   go  world  ")
    fmt.Printf("Fields: %v\n", fields)
}
▶ 試してみよう

出力:

TEXT
TrimSpace: [Hello, Go World!]
Trim: [Hello, Go World]
TrimPrefix: , Go World!  
Split: [a b c]
Join: a-b-c
Contains: true
Index: 8
Count: 3
Replace: Hell0, Go World!  
ReplaceAll: Hell0, G0 W0rld!  
ToUpper:   HELLO, GO WORLD!  
ToLower:   hello, go world!  
Fields: [hello go world]

(1) strings 関数のクイックリファレンス

カテゴリ 機能 目的
トリミング TrimSpace / Trim / TrimPrefix / TrimSuffix 空白文字または指定した文字を削除
分割 Split / SplitN / Fields スライスに分割
マージ Join スライスを文字列に結合
検索 Contains / Index / LastIndex / Count 部分文字列チェック
置換 Replace / ReplaceAll 部分文字列の置換
ケース ToUpper / ToLower / Title 大文字・小文字の変換
構築 Builder / Repeat 効率的な文字列連結 / 文字列の繰り返し

4. strconv による型変換

(1) 文字列 ↔ 数値

GO
パッケージ main

import (
    "fmt"
    "strconv"
)

func main() {
    // 文字列 → int
    n, err := strconv.Atoi("42")
    fmt.Printf("Atoi: %d, err=%v\n", n, err)

    // int → 文字列
    s := strconv.Itoa(42)
    fmt.Printf("Itoa: %s\n", s)

    // ParseInt(進法と桁数)
    v, _ := strconv.ParseInt("FF", 16, 64)
    fmt.Printf("ParseInt(hex): %d\n", v)

    // ParseFloat
    f, _ := strconv.ParseFloat("3.14", 64)
    fmt.Printf("ParseFloat: %f\n", f)

    // FormatInt / FormatFloat
    fmt.Printf("FormatInt(hex): %s\n", strconv.FormatInt(255, 16))
}

▶ サンプル:strconv と型変換の違い

GO
package main

import (
    "fmt"
    "strconv"
)

func main() {
    // int → float64(型変換を使用する)
    var age int = 28
    fAge := float64(age)
    fmt.Printf("型変換: %f\n", fAge)

    // string → int(で strconv)
    numStr := "42"
    if num, err := strconv.Atoi(numStr); err == nil {
        fmt.Printf("strconv: %d\n", num)
    }

    // int → string(で strconv)
    s := strconv.Itoa(42)
    fmt.Printf("Itoa: %s\n", s)

    // 型変換を使って string 転 int(コンパイルエラー)
    // n := int(numStr)  ❌ コンパイルエラー
}
▶ 試してみよう
シナリオ 型変換の使用 strconv の使用
int ↔ float64 float64(n)
int ↔ 文字列 strconv.Itoa / Atoi
文字列 ↔ float64 ParseFloat / FormatFloat
基数解析 ParseInt("FF", 16, 64)
整数 ↔ uint/int32

5. strings.Builder を使った効率的な文字列連結

(1) なぜビルダーを使うのか?

GO
package main

import (
    "fmt"
    "strings"
)

func main() {
    // 非効率的な方法:毎回 + すべて新しい文字列を作成する
    s1 := ""
    for i := 0; i < 1000; i++ {
        s1 += "a"  // O(n²) のパフォーマンス
    }

    // 効率的な方法:Builder 内部バッファ
    var sb strings.Builder
    for i := 0; i < 1000; i++ {
        sb.WriteString("a")  // O(n) のパフォーマンス
    }
    s2 := sb.String()

    fmt.Printf("len=%d, equal=%v\n", len(s2), s1 == s2)
}

▶ サンプル:Builderの完全な使い方

GO
パッケージ main

import (
    "fmt"
    "strings"
)

func buildCSV(data [][]文字列) 文字列 {
    var sb strings.Builder
    sb.Grow(1024)  // メモリの事前割り当て

    for i, row := range data {
        rowStr := strings.Join(row, ",")
        sb.WriteString(rowStr)
        if i < len(data)-1 {
            sb.WriteByte('\n')  // 1バイトを書き込む
        }
    }

    return sb.String()
}

func main() {
    data := [][]文字列{
        {"Name", "Age", "City"},
        {"Alice", "28", "Shanghai"},
        {"Bob", "32", "Beijing"},
    }

    csv := buildCSV(data)
    fmt.Println(csv)
    fmt.Printf("Length: %d\n", len(csv))
}
▶ 試してみよう

出力:

TEXT
Name,Age,City
Alice,28,Shanghai
Bob,32,Beijing

Length: 52
🔥 よくある間違い: + を使用して文字列を連結すると、その都度新しい文字列が生成されるため、パフォーマンスは O(n²) になります。一方、strings.Builder は内部で可変バッファを保持しているため、パフォーマンスは O(n) になります。連結を多数行う場合、パフォーマンスの差は最大で 1,000 倍にも達することがあります。


6. time パッケージ:時刻の処理

(1) 時間。「今」と時間の表記形式

GO
package main

import (
    "fmt"
    "time"
)

func main() {
    // 現在の時刻
    now := time.Now()
    fmt.Printf("Now: %v\n", now)

    // よく使われる書式設定
    fmt.Printf("RFC3339: %s\n", now.Format(time.RFC3339))
    fmt.Printf("RFC822: %s\n", now.Format(time.RFC822))
    fmt.Printf("カスタマイズ: %s\n", now.Format("2006-01-02 15:04:05"))
    fmt.Printf("日付: %s\n", now.Format("2006-01-02"))
    fmt.Printf("時間: %s\n", now.Format("15:04:05"))
}

▶ サンプル:時間分析

GO
パッケージ main

import (
    "fmt"
    "time"
)

func main() {
    // 標準フォーマットの解析
    t1, _ := time.Parse(time.RFC3339, "2026-07-08T10:00:00Z")
    fmt.Printf("RFC3339: %v\n", t1)

    // カスタム形式の解析(2006-01-02 15:04:05 = 参考時間)
    t2, _ := time.Parse("2006-01-02 15:04:05", "2026-07-08 10:30:00")
    fmt.Printf("Custom: %v\n", t2)

    // タイムゾーンを含む解析
    t3, _ := time.Parse("2006-01-02T15:04:05-07:00", "2026-07-08T10:00:00+08:00")
    fmt.Printf("With TZ: %v\n", t3)

    // 月と日付の英語表記の解析
    t4, _ := time.Parse("January 2, 2006", "July 8, 2026")
    fmt.Printf("English: %v\n", t4)
}
▶ 試してみよう

出力:

TEXT
RFC3339: 2026-07-08 10:00:00 +0000 UTC
Custom: 2026-07-08 10:30:00 +0000 UTC
With TZ: 2026-07-08 10:00:00 +0800 CST
English: 2026-07-08 00:00:00 +0000 UTC

(2) time.Duration による時間の計算

GO
package main

import (
    "fmt"
    "time"
)

func main() {
    // Duration を作成
    d1 := 5 * time.Second
    d2 := 100 * time.Millisecond
    d3 := 2*time.Hour + 30*time.Minute

    fmt.Printf("5s = %d ns\n", d1.Nanoseconds())
    fmt.Printf("100ms = %v\n", d2)
    fmt.Printf("2h30m = %v\n", d3)

    // 時間演算
    now := time.Now()
    later := now.Add(2 * time.Hour)
    duration := later.Sub(now)
    fmt.Printf("差額: %v\n", duration)

    // 比較
    fmt.Printf("5s > 100ms? %v\n", d1 > d2)
    fmt.Printf("d1.String(): %s\n", d1)
}

(3) 期間単位の一覧表

定数 意味
time.Nanosecond 1 ns
time.Microsecond 1 µs = 1000 ns
time.Millisecond 1 ms = 1000 µs
time.Second 1 s = 1000 ms
time.Minute 60秒
time.Hour 60分

7. タイマー/ティッカー

(1) time.Timer: 1回限りのタイマー

GO
パッケージ main

import (
    "fmt"
    "time"
)

func main() {
    timer := time.NewTimer(2 * time.Second)

    fmt.Println("待つ 2 秒...")
    <-timer.C  // タイムアウトになるまでブロックする
    fmt.Println("時間切れ!")

    // 以下のように使うこともできます time.After(より簡潔に,ただし、対応していません Stop)
    fmt.Println("もう少し待つ 1 秒...")
    <-time.After(1 * time.Second)
    fmt.Println("完了!")
}

▶ サンプル:time.Ticker:周期的なタイマー

GO
package main

import (
    "fmt"
    "time"
)

func main() {
    ticker := time.NewTicker(1 * time.Second)
    done := make(chan bool)

    go func() {
        time.Sleep(5 * time.Second)
        done <- true
    }()

    for count := 1; ; count++ {
        select {
        case t := <-ticker.C:
            fmt.Printf("Tick %d at %s\n", count, t.Format("15:04:05"))
        case <-done:
            ticker.Stop()  // ticker を停止
            fmt.Println("Done!")
            return
        }
    }
}
▶ 試してみよう

出力:

TEXT
Tick 1 at 10:00:01
Tick 2 at 10:00:02
Tick 3 at 10:00:03
Tick 4 at 10:00:04
Tick 5 at 10:00:05
Done!

(2) タイマーとティッカー

特集 タイマー ティッカー
トリガー型 1回限り 定期
停止 timer.Stop() ticker.Stop()
信号チャンネル .C .C
リセット timer.Reset(d)
一般的なシナリオ タイムアウト制御/実行遅延 ハートビート/スケジュールされたタスク

8. タイムゾーンの取り扱い

GO
package main

import (
    "fmt"
    "time"
)

func main() {
    // タイムゾーンを読み込み中
    loc, _ := time.LoadLocation("America/New_York")

    // 指定されたタイムゾーンでの解析
    t := time.Date(2026, 7, 8, 10, 0, 0, 0, loc)
    fmt.Printf("ニューヨーク時間: %s\n", t.Format(time.RFC3339))

    // 他のタイムゾーンに変換する
    shanghai := t.In(time.FixedZone("CST", 8*3600))
    fmt.Printf("上海時間: %s\n", shanghai.Format(time.RFC3339))

    // UTC
    utc := t.UTC()
    fmt.Printf("UTC時間: %s\n", utc.Format(time.RFC3339))
}
方法 説明
time.LoadLocation("Asia/Shanghai") IANA タイムゾーン名
time.FixedZone("CST", 8*3600) オフセットを修正
t.In(loc) 対象のタイムゾーンに変換
t.UTC() UTCに変換
t.Local() 現地タイムゾーンに変換

9. 完全な例:ログタイムスタンプパーサー

GO
// log_analyzer.go
package main

import (
    "fmt"
    "sort"
    "strings"
    "time"
)

// LogEntry ログエントリ
type LogEntry struct {
    Timestamp time.Time
    Level     string  // INFO / ERROR / WARN
    Message   string
}

// LogParser ログ解析ツール
type LogParser struct {
    timeFormats []string
}

func NewLogParser() *LogParser {
    return &LogParser{
        timeFormats: []string{
            time.RFC3339,
            "2006-01-02 15:04:05",
            "01/02/2006 03:04:05 PM",
            "2006/01/02 15:04:05",
            "Jan 2 15:04:05",
        },
    }
}

// Parse 1行のログの解析
func (p *LogParser) Parse(line string) (LogEntry, error) {
    line = strings.TrimSpace(line)
    if line == "" {
        return LogEntry{}, fmt.Errorf("empty line")
    }

    parts := strings.Fields(line)
    if len(parts) < 3 {
        return LogEntry{}, fmt.Errorf("too few fields")
    }

    // タイムスタンプの解析を試みる(複数の形式に対応)
    var ts time.Time
    var tsLen int

    for i := 0; i < len(parts); i++ {
        candidate := strings.Trim(parts[i], "[]")
        for _, fmt := range p.timeFormats {
            if t, err := time.Parse(fmt, candidate); err == nil {
                ts = t
                tsLen = i + 1
                break
            }
        }
        if !ts.IsZero() {
            break
        }
    }

    if ts.IsZero() {
        return LogEntry{}, fmt.Errorf("no timestamp found in: %s", line)
    }

    remaining := parts[tsLen:]
    if len(remaining) < 2 {
        return LogEntry{}, fmt.Errorf("no level/message after timestamp")
    }

    level := strings.Trim(remaining[0], "[]")
    message := strings.Join(remaining[1:], " ")

    return LogEntry{
        Timestamp: ts,
        Level:     level,
        Message:   message,
    }, nil
}

// Analyze 分析ログの集計
func (p *LogParser) Analyze(lines []string) {
    var entries []LogEntry
    var errors int

    // で strings.Builder エラー報告の作成
    var errBuf strings.Builder
    errBuf.Grow(1024)

    for i, line := range lines {
        entry, err := p.Parse(line)
        if err != nil {
            errors++
            errBuf.WriteString(fmt.Sprintf(" 了解 %d: %v\n", i+1, err))
            continue
        }
        entries = append(entries, entry)
    }

    // 時間順に並べ替える
    sort.Slice(entries, func(i, j int) bool {
        return entries[i].Timestamp.Before(entries[j].Timestamp)
    })

    // 統計
    var infoCount, errorCount, warnCount int
    for _, e := range entries {
        switch e.Level {
        case "INFO":
            infoCount++
        case "ERROR":
            errorCount++
        case "WARN":
            warnCount++
        }
    }

    // レポートを出力する
    var report strings.Builder
    report.Grow(2048)

    report.WriteString("=== ログ分析レポート ===\n")
    report.WriteString(fmt.Sprintf("総行数: %d\n", len(lines)))
    report.WriteString(fmt.Sprintf("解析に成功しました: %d\n", len(entries)))
    report.WriteString(fmt.Sprintf("解析に失敗しました: %d\n", errors))
    report.WriteString(fmt.Sprintf("INFO: %d, ERROR: %d, WARN: %d\n",
        infoCount, errorCount, warnCount))
    report.WriteString(fmt.Sprintf("期間: %s ~ %s\n",
        entries[0].Timestamp.Format(time.RFC3339),
        entries[len(entries)-1].Timestamp.Format(time.RFC3339)))

    if errors > 0 {
        report.WriteString("\n=== エラーの詳細 ===\n")
        report.WriteString(errBuf.String())
    }

    fmt.Print(report.String())
}

func main() {
    logLines := []string{
        "2026-07-08T10:00:00Z [INFO] Server started",
        "2026-07-08T10:01:15Z [ERROR] Connection timeout to db",
        "2026-07-08T10:02:30Z [WARN] Memory usage 85%",
        "2026-07-08T10:03:45Z [INFO] Request processed in 120ms",
        "invalid line without timestamp",
        "2026-07-08T10:05:00Z [ERROR] Disk space low",
    }

    parser := NewLogParser()
    parser.Analyze(logLines)
}

期待される出力:

TEXT
=== ログ分析レポート ===
総行数: 6
解析に成功しました: 5
解析に失敗しました: 1
INFO: 2, ERROR: 2, WARN: 1
期間: 2026-07-08T10:00:00Z ~ 2026-07-08T10:05:00Z

=== エラーの詳細 ===
 了解 5: no timestamp found in: invalid line without timestamp
100%
graph TB
    now[time.Now] --> format[Format]
    now --> sub[Sub/Duration]
    now --> add[Add]
    parse[time.Parse] --> t[time.Time]
    t --> format
    t --> sub
    t --> add
    sub --> duration[time.Duration]
    duration --> hours[Hours/Minutes/Seconds]
    timer[time.Timer] --> C[.C channel]
    ticker[time.Ticker] --> C
    style now fill:#e1f5fe
    style t fill:#e1f5fe
    style duration fill:#f3e5f5
🔥 よくある間違い: Goの日付と時刻の形式は、参照時刻 Mon Jan 2 15:04:05 MST 2006 の数値パターンに基づいています。2006 = 年、01 = 月、02 = 日、15 = 時間(24時間制)、03 = 時間(12時間制)、04 = 分、05 = 秒。この設計は独特ですが、慣れれば自然に感じられるようになります。


❓ よくある質問

Q strings パッケージでよく使われる関数にはどのようなものがありますか?
A トリミング(Trim/TrimSpace)、分割(Split/Fields)、結合(Join)、検索(Contains/Index/Count)、置換(Replace/ReplaceAll)、大文字小文字の変換(ToUpper/ToLower)、および生成(Builder/Repeat)――これら15の関数で、一般的なニーズの90%をカバーできます。
Q strconvと型変換の違いは何ですか?
A 型変換は同じデータ型内での変換(例:intからfloat64)に使用されますが、strconvは文字列と数値間の変換に使用されます。文字列int、あるいはfloatの間では型キャストを使用できず、strconvを使用する必要があります。
Q time.Parse のフォーマット文字列はどのように記述すればよいですか?
A 基準時刻 2006-01-02 15:04:05 (月/日/時/分/秒/年の順)を覚えておいてください。2006 = 年、01 = 月、02 = 日、15 = 24時間制、03 = 12時間制、04 = 分、05 = 秒。
Q Duration はどのように使用しますか?
A time.Duration は、ナノ秒単位の数値を表す int64 値です。作成: 5 * time.Second; 演算: t.Add(d), t.Sub(t2); 取得: d.Hours(), d.Minutes(), d.Seconds().
Q タイマーとティッカーの違いは何ですか?
A タイマーは1回だけ実行される(実行の遅延/タイムアウト制御)のに対し、ティッカーは繰り返し実行されます(スケジュールされたタスク/ハートビート)。どちらも.Cチャネルを介してシグナルを受信し、実行を停止するための.Stop()メソッドをサポートしています。
Q Goには、Pythonのdatetime.timedeltaに似たものはありますか?
A はい、time.Durationがまさにそれです。作成:d := 2*time.Hour + 30*time.Minute;加算と減算:t.Add(d)t.Add(-d);2つの時刻の差:t2.Sub(t1)
Q 異なるタイムゾーンの時刻をどのように処理すればよいですか?
A time.LoadLocation("Asia/Shanghai") を使用してタイムゾーンを読み込み、t.In(loc) で変換してください。時刻は内部で UTC として保存されており、出力時に目的のタイムゾーンに変換されます。
Q strings.Builder+に比べてどれくらい高速ですか?
A +は毎回新しい文字列を作成するため => O(n²)、一方、Builderの内部バッファを使用するため => O(n)となります。10,000回の連結を行う場合、Builderの方が1,000倍以上高速です。

📖 まとめ


📝 練習問題

  1. 基本問題(難易度 ⭐)strings パッケージを使用して、文字列内の各単語の出現回数を数える wordCount(s 文字列) map[文字列]int 関数を実装してください。Fields、ループ、およびマップを使用する必要があります。

  2. 上級問題(難易度 ⭐⭐):人間が読みやすい説明(「3分前」/「2時間前」/「昨日」/「3日前」)を返す timeAgo(t time.Time) 文字列 関数を実装してください。time.Since()Duration の条件分岐を組み合わせて使用する必要があります。

  3. 課題(難易度 ⭐⭐⭐)ログ集計ツールを実装してください。文字列入力(1行につきタイムスタンプ1つ+レベル+メッセージ)が与えられた場合、strings.Splittime.Parsestrings.Builder、およびsort.Sliceを使用して、以下の処理を行ってください: (1) ERRORレベルのログを解析して除外する;(2) それらを時間順に並べ替える;(3) Builder を使用して、集計統計情報を含むレポートを生成する。このツールは、少なくとも3つの時刻形式に対応していなければならない。

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%