🦁

【Golang】存在しない日付も考慮したn年月後の日付を取得する

2023/04/28に公開

はじめに

Goに限らないことですが、何ヶ月や何年後と行った日付を取得する際に、閏年や31日が存在しない月の影響で、月末を取得したかったのに実際は次の月の日付を取得してしまったという経験がある方はいらっしゃるかと思います。
例えば、何ヶ月後にバッチ処理を実行したいといったケースには、実行月がずれてしまい想定していたバッチ処理が行われていなかったとなると大変な問題となると思います。

今回はそういった場合において、日付が存在しない場合にも前の月の末日に繰り下げて、確実に月末を取得する方法を紹介したいと思います。

結論

以下のソースコードで実現可能です。

import "time"

// mヶ月後の日時データを取得。月末を超えた場合は、その月の月末を取得。
// 例: 2020/01/31の1ヶ月後→2020/02/31は存在しないので、2020/02/29を取得。
func GetAdjustedMonthAfterSpecifiedMonths(t time.Time, m int) time.Time {
	year1, month2, day := t.Date()
	// 月初の日付を取得 2020/01/31 → 2020/01/01
	first := time.Date(year1, month2, 1, 0, 0, 0, 0, time.UTC)
	// 月初の日付にちょうどmヶ月を加算 2020/01/01 → 2020/02/01
	year2, month2, _ := first.AddDate(0, m, 0).Date()
	// 加算したものに対し、日付のみをmヶ月前のものに置き換え、日付を取得 2020/02/01 → 2020/02/31
	// 存在しない日付の場合、超過した日付の分のみ翌月の日付として加算 2020/02/31 → 2020/03/02
	nextMonthTime := time.Date(year2, month2, day, 0, 0, 0, 0, time.UTC)
	if month2 != nextMonthTime.Month() {
		// 想定していた月と異なる場合、元の月初日にm+1ヶ月加算し、1日減算することで、前月の月末日を取得
		// 2020/01/01 → 2020/03/01 → 2020/02/29
		return first.AddDate(0, m+1, -1)
	}
	return nextMonthTime
}

// y年後の日時データを取得。月末を超えた場合は、その月の月末を取得。
// 例: 2020/02/29の1年後→2021/02/29は存在しないので、2021/02/28を取得。
func GetAjustedYearAfterSpecifiedYears(t time.Time, y int) time.Time {
	year1, month2, day := t.Date()
	// 月初の日付を取得 2020/02/29 → 2020/02/01
	first := time.Date(year1, month2, 1, 0, 0, 0, 0, time.UTC)
	// 月初の日付にちょうどy年を加算 2020/02/01 → 2021/02/01
	year2, month2, _ := first.AddDate(y, 0, 0).Date()
	// 加算したものに対し、日付のみをy年前のものに置き換え、日付を取得 2021/02/01 → 2021/02/29
	// 存在しない日付の場合、超過した日付の分のみ翌月の日付として加算 2021/02/29 → 2021/03/01
	nextYearTime := time.Date(year2, month2, day, 0, 0, 0, 0, time.UTC)
	if month2 != nextYearTime.Month() {
		// 想定していた月と異なる場合、元の月初日にy年1ヶ月加算し、1日減算することで、前月の月末日を取得
		// 2020/02/01 → 2021/03/01 → 2021/02/29
		return first.AddDate(y, 1, -1)
	}
	return nextYearTime
}

実行例

func main() {
        // 2020/1/31の1ヶ月後として、2020/2/29を取得したいケース
	testDate := time.Date(2020, 1, 31, 0, 0, 0, 0, time.UTC)
        // AddDateだと存在しない日付の分繰り越してしまう
	unexpectedResult := testDate.AddDate(0, 1, 0)
	fmt.Println(unexpectedResult) // 2020-03-02 00:00:00 +0000 UTC
        // 想定している日付を取得できている
	expectedResult := GetAdjustedMonthAfterSpecifiedMonths(testDate, 1)
	fmt.Println(expectedResult) // 2020-02-29 00:00:00 +0000 UTC

        // 2020/2/29の1年後として、2021/2/28を取得したいケース
	testDate2 := time.Date(2020, 2, 29, 0, 0, 0, 0, time.UTC)
         // AddDateだと存在しない日付の分繰り越してしまう
	unexpectedResult2 := testDate2.AddDate(1, 0, 0)
	fmt.Println(unexpectedResult2) // 2021-03-01 00:00:00 +0000 UTC
        // 想定している日付を取得できている
	expectedResult2 := GetAjustedYearAfterSpecifiedYears(testDate2, 1)
	fmt.Println(expectedResult2) // 2021-02-28 00:00:00 +0000 UTC
}

解説

GetAdjustedMonthAfterSpecifiedMonthsとGetAjustedYearAfterSpecifiedYearsは引数として、月を指定するか年を指定するかの違いとなるので、GetAdjustedMonthAfterSpecifiedMonthsに絞って解説をします。

まず、引数で受け取ったtime.Timeの値を年月日に分解します。

year1, month2, day := t.Date()

上で分解した年月を利用し、月初の日付を取得します。(*1)

// 月初の日付を取得 2020/02/29 → 2020/02/01
first := time.Date(year1, month2, 1, 0, 0, 0, 0, time.UTC)

上記で取得した月初の日付に対し、引数で指定した月数を加算し、年月に分解します。

// 月初の日付にちょうどmヶ月を加算 2020/01/01 → 2020/02/01
year2, month2, _ := first.AddDate(0, m, 0).Date()

ここからは少しテクニックを使います。
まず、上で取得した年月に対し、引数で指定した月数を加算する前の日にちを当てはめてみます。

// 加算したものに対し、日付のみをmヶ月前のものに置き換え、日付を取得 2020/02/01 → 2020/02/31
// 存在しない日付の場合、超過した日付の分のみ翌月の日付として加算 2020/02/31 → 2020/03/02
nextMonthTime := time.Date(year2, month2, day, 0, 0, 0, 0, time.UTC)

日にちを当てはめた結果存在する日付であれば、そのまま結果を返し、もし、存在しない日付だとGoの仕様として超過した分翌月の日付として加算がされるので、想定している月に差分が出ます。
その場合は、*1で取得した月初の日付に対し、(引数で指定した月数+1)ヶ月分加算した上で1日分減算することで、日付の超過を無視して想定している月末の日付を取得できます。

if month2 != nextMonthTime.Month() {
    // 想定していた月と異なる場合、元の月初日にm+1ヶ月加算し、1日減算することで、前月の月末日を取得
    // 2020/01/01 → 2020/03/01 → 2020/02/29
    return first.AddDate(0, m+1, -1)
}
return nextMonthTime

GetAjustedYearAfterSpecifiedYearsも同様の要領で、超過した日付を無視して、例えば、閏年の1年後の2月の月末を正常に取得できるようになります。

テストコード

上記のソースコードに対するテストコードは以下のようになります。興味のある方はご覧になってみてください。

import (
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
)

func TestGetAdjustedMonthAfterSpecifiedMonths(t *testing.T) {
	type args struct {
		t time.Time
		m int
	}
	tests := []struct {
		name string
		args args
		want time.Time
	}{
		{
			name: "CASE1:SUCCESS(normal day)",
			args: args{
				t: time.Date(2020, 1, 15, 0, 0, 0, 0, time.UTC),
				m: 1,
			},
			want: time.Date(2020, 2, 15, 0, 0, 0, 0, time.UTC),
		},
		{
			name: "CASE2:SUCCESS(get last day of the month in case of non-existent day)",
			args: args{
				t: time.Date(2020, 1, 31, 0, 0, 0, 0, time.UTC),
				m: 1,
			},
			want: time.Date(2020, 2, 29, 0, 0, 0, 0, time.UTC),
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			got := GetAdjustedMonthAfterSpecifiedMonths(tt.args.t, tt.args.m)
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("got value is mismatch (-GetAdjustedMonthAfterSpecifiedMonths() +want):\n%s", diff)
			}
		})
	}
}

func TestGetAjustedYearAfterSpecifiedYears(t *testing.T) {
	type args struct {
		t time.Time
		y int
	}
	tests := []struct {
		name string
		args args
		want time.Time
	}{
		{
			name: "CASE1:SUCCESS(normal day)",
			args: args{
				t: time.Date(2020, 1, 15, 0, 0, 0, 0, time.UTC),
				y: 1,
			},
			want: time.Date(2021, 1, 15, 0, 0, 0, 0, time.UTC),
		},
		{
			name: "CASE2:SUCCESS(get last day of the month in case of non-existent day)",
			args: args{
				t: time.Date(2020, 2, 29, 0, 0, 0, 0, time.UTC),
				y: 1,
			},
			want: time.Date(2021, 2, 28, 0, 0, 0, 0, time.UTC),
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			got := GetAjustedYearAfterSpecifiedYears(tt.args.t, tt.args.y)
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("got value is mismatch (-GetAjustedYearAfterSpecifiedYears() +want):\n%s", diff)
			}
		})
	}
}

所感

実際私も業務で日付を扱うことが多く、似たような問題に直面する方が結構いらっしゃるのではないかと思い、今回の記事を執筆させていただきました。
少しでも多くの方に役立つことがあれば幸いです。

また、もしかしたら、今回のソースコードに既視感を感じる方もいらっしゃるかもしれません。
実はこちらは実用Go言語の第一章の内容を参考にして、作成させていただいております。
おすすめの書籍ですので、まだ読んだことのない方は是非一度ご覧になってみてください。
https://www.oreilly.co.jp/books/9784873119694/

Discussion