Merpay Advent Calendar 2020 の3日目です。
メルペイでバックエンドエンジニアをしている iwata です。
メルペイスマート払いの開発をしている Credit Design というチームに所属しています。
私は2019年の入社以来、「メルペイスマート払い(定額払い)」(以下、定額払い)の開発を担当しており、今年の7月にようやくリリースすることができました。
この定額払いの手数料計算のために、「1万分の1を1とする単位」であるベーシスポイントを扱うGo言語のパッケージ go.mercari.io/go-bps を作成しました。
ちょうど1年前に、 mercari.go #12 で「料率計算における小数の扱いについて」として発表しましたが、当時はオープンソースとして公開していませんでした。
今回オープンソースとして公開しましたので、改めてパッケージを紹介します。
料率計算について
お金のやりとりがあると色々と手数料を計算する機会があります。
例を挙げると以下のような手数料が存在します。
- 決済手数料
- 加盟店手数料
- 延滞事務手数料
- 振込手数料
- 遅延損害金
トランザクション単位で固定額が発生する手数料もありますが、多くのものは○%などの料率計算が必要です。
決済事業をやる上では必須といえる機能であり、小数の扱いについて考える必要性がでてきます。
一方でこれらの値は時期によって変動することもありえます。
キャンペーンで一時的に手数料を無料にしたり、料率を変更したりすることは往々にして発生しうるので、要件によっては固定値ではなく、データベースに保存することも考えないといけません。
浮動小数点数の扱いについて
Goで以下のコードを実行するとどうなるでしょうか。
package main
import "fmt"
func main() {
var n float64
for i := 0; i < 1000; i++ {
n += 0.01
}
fmt.Println(n)
}
実際にGo Playgroundで確認してみると以下のように出力され、10
にはなりません。
これは0.01
が2進数で表現できないため、内部的に近似値が使われ、丸め誤差が生じているせいです。
次に以下のコードをみてみます。
package main
import (
"fmt"
)
func main() {
a := float64(1e12)
var b float64 = 1 / 1e6
c := a + b
fmt.Println(c)
if a == c {
fmt.Println("equal")
}
}
c
は大きな値a
と小さな値b
を加算した結果になっています。
このコードをGo Playgroundで実行してみると以下のような出力となります。
これは小数の小さな値に情報落ちが発生して起こる誤差です。
これらの理由から、金銭計算にはfloat64
といった浮動小数点数を使わずに小数点数を扱う必要性がでてきます。
ではどのような方法が考えられるでしょうか?
以降では下に挙げる3つのGoパッケージについて取り上げ、メリット・デメリットをそれぞれ確認します。
math/big.Rat
*big.Rat
は Go の標準パッケージで有理数を表現するための型を提供します。
big.NewRat(1, 3)
で3分の1を表現する、といった具合です。
以下サンプルコード
package main
import (
"fmt"
"math/big"
)
func main() {
a := big.NewRat(1, 3)
b := big.NewRat(2, 3)
fmt.Println(a, b) // 1/3 2/3
fmt.Println(a.FloatString(3), b.FloatString(3)) // 0.333 0.667
}
0.01
を1000回足してみましょう。
package main
import (
"fmt"
"math/big"
)
func main() {
n := new(big.Rat)
for i := 0; i < 1000; i++ {
// big.Rat#Add は 引数 2つ を足した結果を receiver にセット&返す動作
n = new(big.Rat).Add(n, big.NewRat(1, 100))
}
fmt.Println(n.FloatString(1)) // 10.0
}
Go Playgroundで試してみるとちゃんと10.0
となることが分かります。
次に情報落ちの例をみてみます。
package main
import (
"fmt"
"math/big"
)
func main() {
a := big.NewRat(1e12, 1)
b := big.NewRat(1, 1e6)
c := new(big.Rat).Add(a, b)
fmt.Println(c.FloatString(6))
}
Go Playgroundで実行してみると、正しく1000000000000.000001
と出力されます。
このように*big.Rat
型を用いれば誤差なく小数を扱えそうなことが分かります。
但し、*big.Rat
は扱いづらいというデメリットがあります。
以下のサンプルコードをみてください。
package main
import "fmt"
import "math/big"
func main() {
a := big.NewRat(1, 3)
b := big.NewRat(2, 3)
c := a // 1/3
d := b // 2/3
z := a.Add(a, b) // someone might misuse the API
fmt.Println(a, b, z)
fmt.Println(c.Cmp(d) < 0) // they might expect this to print "true"
fmt.Println(c, d) // but instead, c was changed because it points to the same big.Rat as a
}
*big.Rat
型のメソッドはミュータブルであるため、a.Add(a, b)
を実行するとレシーバであるa
自身の値も更新されます。したがって変数a
とz
は同じ値1/1
となります。
またポインタ型となるため変数a
が更新されると、a
が代入れている変数c
も予期せず更新されてしまいます。
冒頭でも述べたように料率をデータベースに保存したい時にはひと工夫が必要になります。
FLOAT
型で保存すると誤差が発生してしまうので、分母と分子で別々のカラムに保存しておくか、STRING型で保存などです。
一方で Google Cloud Spanner であれば先日対応されたNUMERIC型を使用することで*big.Rat
への変換を自動的に行えるようです。
(*big.Rat
の扱いにくさから筆者はまだ試用するに至っていません)
github.com/shopspring/decimal
github.com/shopspring/decimal は固定小数点数を扱うパッケージです。
2の31乗までの精度であれば誤差なく小数点数を扱うことができます。
以下はREADMEに記載されているサンプルコードです。
package main
import (
"fmt"
"github.com/shopspring/decimal"
)
func main() {
price, err := decimal.NewFromString("136.02")
if err != nil {
panic(err)
}
quantity := decimal.NewFromInt(3)
fee, _ := decimal.NewFromString(".035")
taxRate, _ := decimal.NewFromString(".08875")
subtotal := price.Mul(quantity)
preTax := subtotal.Mul(fee.Add(decimal.NewFromFloat(1)))
total := preTax.Mul(taxRate.Add(decimal.NewFromFloat(1)))
fmt.Println("Subtotal:", subtotal) // Subtotal: 408.06
fmt.Println("Pre-tax:", preTax) // Pre-tax: 422.3421
fmt.Println("Taxes:", total.Sub(preTax)) // Taxes: 37.482861375
fmt.Println("Total:", total) // Total: 459.824961375
fmt.Println("Tax rate:", total.Sub(preTax).Div(preTax)) // Tax rate: 0.08875
}
サンプルから分かるようにdecimal.Decimal
型のメソッドは*big.Rat
型とは違い、イミュータブルです。
すなわちレシーバに対する演算結果を返り値として返すので扱い易くなっています。
0.01
を1000回足してみます。
package main
import (
"fmt"
"github.com/shopspring/decimal"
)
func main() {
n := decimal.RequireFromString("0")
for i := 0; i < 1000; i++ {
n = n.Add(decimal.NewFromFloat(.01))
}
fmt.Println(n) // 10
}
Go Playgroundで試すとちゃんと10
が出力されます。
次に情報落ちについてはどうでしょうか。
package main
import (
"fmt"
"github.com/shopspring/decimal"
)
func main() {
a := decimal.NewFromFloat(float64(1e12))
b := decimal.NewFromFloat(1e-6)
fmt.Println(a.Add(b)) // 1000000000000.000001
}
こちらも誤差なく1000000000000.000001
と出力されます。
https://play.golang.org/p/MbsPWk5FD2d
データベースに保存する際はどうでしょうか。
文字列で保存しておいて取得時にはdecimal.NewFromString()
でパースすれば誤差なく保存できそうです。
しかしながらパースに失敗したり、そもそも数値を文字列として保存すると集計時などで後々問題になることは想像に難くありません。
ベーシスポイントとは?
上記のような悩みがあったので社内の Go Friday で小数点数の扱いについて質問したところ教えてもらったのがベーシスポイント(Basis Point)です。
パーセントが100分の1を1とし、百分率(%)と呼ばれるのに対して、ベーシスポイントは1万分の1を1とする単位で万分率(‱)と呼ばれます。
ベーシスポイントを用いると0.01%までを整数として扱うことができるというわけです。
当然0.01%以下の桁がでてくると小数で表現する必要性が生じるため、アプリケーション上小数点以下の桁数をどこまで扱う必要があるかによって基底の単位を決める必要があります。
go.mercari.io/go-bps
go.mercari.io/go-bps はこのベーシスポイントをGoで扱うためのパッケージです。
以下にサンプルコードを挙げてみます。
package main
import (
"fmt"
"go.mercari.io/go-bps/bps"
)
func main() {
var price int64 = 15980
var (
rate1 = bps.NewFromPercentage(8) // rate1 is 8.0%
rate2 = bps.MustFromString(".045") // rate2 is 0.045 = 4.5%
rate3 = bps.NewFromBasisPoint(264) // rate3 is 2.64% = 264 basis points
)
// fee: 15980 * 8% = 1278.4
// Output: 1278 127840
fmt.Println(rate1.Mul(price).Amounts(), rate1.Mul(price).BasisPoints())
// fee: 15980 * 4.5% = 719.1
// Output: 719 71910
fmt.Println(rate2.Mul(price).Amounts(), rate2.Mul(price).BasisPoints())
// fee: 15980 * 2.64 = 421.872
// Output: 421 42187(always rounded down)
fmt.Println(rate3.Mul(price).Amounts(), rate3.Mul(price).BasisPoints())
}
bps.NewXxx()
が*bps.BPS
オブジェクト(rate1
など)を返します。
四則演算はdecimal.Decimal
同様、メソッドとして提供されます。
go-bps
で0.01
を1000回足してみます。
package main
import (
"fmt"
"go.mercari.io/go-bps/bps"
)
func main() {
n := bps.NewFromAmount(0)
for i := 0; i < 1000; i++ {
n = n.Add(bps.MustFromString(".01"))
}
fmt.Println(n.Amounts()) // 10
}
Go Playground で試すと正しく10
が表示されます。
次に大きい数値と小さい数値を足してみます。
package main
import (
"fmt"
"go.mercari.io/go-bps/bps"
)
func main() {
a := bps.NewFromAmount(1e12)
b := bps.MustFromString(".000001")
fmt.Println(a.Add(b)) // 1000000000000000001 ppm
fmt.Println(a.Add(b).Amounts()) // 1000000000000
}
*bps.BPS#Amounts()
は小数点以下を切り捨てるので1000000000000
と表示されますが、インスタンス内では情報落ちせずに値を保持していることがわかります。
https://play.golang.org/p/lb2IcejpqSa
go-bps
ではパーツパーミリオン(ppm)を最小の単位として扱っています。つまり*bps.BPS.valueに*big.Int
として保持している値はパーツパーミリオンです。
したがって100万分の1までであれば整数として処理可能です。
データベースに保存する際は必要な精度に応じて整数で保存できます。
1万分の1の値まででよければ*bps.BPS#BasisPoints()
の返す整数を保存すればよく、100万分の1の値まで必要であれば*bps.BPS#PPMs()
の整数を保存する必要があります。
この辺りはシステムの要件によって決まってくるはずです。
メルペイでは実際にgo-bps
を使って計算した手数料を Cloud Spanner に保存・取得するようなコードが実装され本番環境でも稼動しています。
まとめ
Goでベーシスポイントを扱うためのパッケージgo.mercari.io/go-bps
を紹介しました。
去年のmercari.goでの発表時には本番実績はなく、リポジトリ自体もプライベートリポジトリとなっていましたが晴れてオープンソースとして公開することができました。
mercari.goでの発表時に公開して欲しいという声を少なからずいただいていましたが、定額払いの実装で実用に耐えうることが確認できたタイミングでの公開となりました。
パッケージ内でやっていることは整数の桁上げ、桁下げをしているだけなのですが、単純な整数の扱いであるが故にベーシスポイントの型が定義されていることでコードの可読性が向上できていると感じています。
明日の Merpay Advent Calendar 2020 執筆担当は、 Solutionsチーム の @vvakame さんです。引き続きお楽しみください。