ryota21silvaの技術ブログ

Funna(ふんな)の技術ブログ

これまで学んだ技術の備忘録。未来の自分が救われることを信じて

【Stripe】off_sessionの指定漏れによってサブスクの自動請求で毎回3Dセキュア認証を要求されてハマった件

3Dセキュア認証の義務化

2024年3月15日、クレジットカード取引に関わる事業者向けの「クレジットカード・セキュリティガイドライン」が改訂されました。
このガイドラインでは、オンライン販売を行う加盟店(Stripeユーザー)については2025年3月末までに3Dセキュア認証(3DS)の導入が必須と定められていました。

2024 年 3 月 15 日にクレジットカード取引に関わる事業者が実施すべきセキュリティ対策を定めた「クレジットカード・セキュリティガイドライン」が改訂されました ( クレジットカード・セキュリティガイドライン【6.0版】をご参照ください。) クレジットカード・セキュリティガイドラインには、オンライン販売を展開する加盟店 (Stripe ユーザー) は不正利用への対抗措置として 2025 年 3 月末までに 3D セキュア認証を導入しなければならないと規定されています。 3D セキュア認証 (3DS) とは、クレジットカード取引に認証レイヤー追加して、不正なカード支払いに対する賠償責任から企業を保護する仕組みです。 support.stripe.com

期待する処理の流れ

今回はサブスクリプションによる自動請求を前提としていました。
この場合、初回の決済時のみ3Dセキュア認証を実施し、それ以降の請求では3Dセキュア認証をスキップするのが一般的だと思います。

docs.stripe.com

具体的には、以下のような手順を想定していました。

  1. SetupIntentを作成し、カード情報を保存。
  2. confirm Card Setupを実行し、3Dセキュア認証を実施。
  3. Subscriptionを作成。
  4. 以後は、サブスクリプションの請求サイクルに応じて自動でオフセッション請求が行われ、3Dセキュア認証はスキップされる。

off_session の指定漏れ

この流れの中でハマったのがoff_session の設定まわりでした。

Stripeの Create Subscription API では、デフォルトで off_session: false(オンセッション扱い)になっており、これを明示的に true で指定しないと3DS認証が毎回求められてしまいます。

👉公式ドキュメント: https://docs.stripe.com/api/subscriptions/create#create_subscription-off_session

一方で、Create a SetupIntent APIusage パラメーターがデフォルトで off_session になっており、こちらは特に何も指定しなくても問題ありませんでした。

👉 公式ドキュメント: https://docs.stripe.com/api/setup_intents/create#create_setup_intent-usage

この仕様の違いに気づかず、
「SetupIntentがデフォルトでoff_sessionならSubscriptionも同じだろう。というかそもそもサブスクリプションなんだからオフセッションが前提のはず。」
と思い込んでおり苦労しました。

まとめ

このように似たような処理でも仕様が異なる場合があるので「ちゃんとドキュメント読もうな!!」と自戒しました()
とくに実現したい挙動に影響する部分についてはドキュメントをしっかり確認し、意図する動作を明示的に指定することの大切さを改めて実感しました。

【書籍】クリーンアーキテクチャのあのドーナツ状の図は、本書で伝えたいことの詳細・手段にすぎなかった

はじめに

エンジニアの方であれば、「Clean Architecture 達人に学ぶソフトウェアの構造と設計」という有名な本をご存知なのではないかと思います。

私は過去に一度この本を読んだことがありましたが、正直内容をあまり覚えていなかったのでもう一度読み直したいと考えてました。
そんな中、所属する会社では輪読会が活発に行われており、今年の2月頃から本書を輪読会のテーマとして読み直すことができました。 tatsu-zine.com

本書を読んだ結果、「Clean Architecture」とは「システムを簡単に変更できる設計の原則」を指しているのであって、(何となくは知ってたけど)あの有名なドーナツ状(同心円状)の図は書籍で伝えたいことの詳細・手段にすぎないことを理解しました。

ソフトウェアで作る目的・ソフトウェアアーキテクチャの価値

本書ではシステムをソフトウェアで作る目的は「振る舞いを簡単に変更できるようにするため」と述べられています。
ステークホルダーが振る舞いを変更したいと思えば簡単に変更できなればいけません。

そしてそのような目的を達成するためにはソフトウェアアーキテクチャ(設計)が重要となります。
ソフトウェアアーキテクチャは変更容易性・将来の拡張性といった価値をもたらしてくれるものであり、柔軟なアーキテクチャを選択できればシステムの開発・デプロイ・運用・保守が容易となり、振る舞いを簡単に変更することができます。

本書で重要なこと

実際に本書を読めば分かりますが、「Clean Architecture」はクリーンなアーキテクチャを実現するための原則(Why, What)をまとめたものであり、ドーナツ状の図はクリーンなアーキテクチャを実現するための方法論(How)の1つでしかありません。

書籍の前半では「クリーンなコードを書く」ためのSOLID原則について解説されており、この原則の理解が本書を理解する上で一番重要なのではないかと感じました。
なぜなら、これより後の章で続く「クリーンなコンポーネントを作る・組み合わせる」「クリーンなアーキテクチャを設計する」といった更に上位レベルのアーキテクチャにおいても同じ観点を持った原則が登場しており、これらの理解がクリーンなアーキテクチャを実現するために重要となってくるからです。

ドーナツ状の図はいつ登場するの?

ドーナツ状の図は第22章で数ページしか触れられておらず、これより前の章で説明されたアーキテクチャの重要な原則を実現するための方法論として解説されています。

最後に

本書ではソフトウェアを方針(重要)と詳細(重要ではない)の2つの要素に分けられると説明がありました。
このソフトウェアの2つの要素に当てはめると、あの有名なドーナツ状の図は書籍で伝えたいことの詳細にすぎないことが分かりました。

「ウチのプロダクトはクリーンアーキテクチャで作っています!」「クリーンアーキテクチャはやめておけ」みたいな話では、ドーナツ状図を指しているケースが多いと思います。
もちろんクリーンアーキテクチャ = ドーナツ状の図ではないことを理解した上で便宜的にその名称を使っているケースもあると思いますが、本書の本質的な内容を理解した上で図の話をしなければ誤解が生まれてしまうなと思いました。

自宅Wi-Fiの設定から無線LAN通信のセキュリティについて学んだ

はじめに

自宅のWi-Fiを設定した際に、Wi-Fiルーターのセキュリティ設定で推奨されているものと避けるべきものがあることが分かりました。

Wi-Fi ルーターと Wi-Fi アクセスポイントの推奨設定 - Apple サポート (日本)

このときに「WPA3とかWPAとか、WEPって何や、、?」となったので、Wi-Fiに関する前提知識や セキュリティについて調べたことを以下にまとめました。

Wi-Fiについて

Wi-Fiとは、無線LANの規格の一種です。

Wi-Fi(ワイファイ)とは、無線LANに関する登録商標である[1]。Wi-Fi Alliance(アメリカ合衆国に本拠を置く業界団体)によって、国際標準規格であるIEEE 802.11規格を使用したデバイス間の相互接続が認められたことを示す名称。 Wi-Fi - Wikipedia

無線LANについて

無線LANとは、優先ケーブルを使わずに、電波などで無線通信を行うことができるLAN(※)のことです。 電波を使って通信するわけなので、通信ルール(規格)が決まっていないと上手く通信できないなど問題があるので、周波数や通信速度などにルールを設ける必要があり、そのルールの一種がWi-Fiというわけです。

※: LAN(Local Area Network)とは、自宅やオフィスなど同じ建物内の小さな狭い範囲のネットワークのこと。

アクセスポイントとルーター無線LANルーターについて

アクセスポイントとは、クライアント機種を無線(ワイヤレス)接続できるようにするための機器です。
アクセスポイントが、Wi-Fiを使用したLANを構築してくれるイメージかな。
また無線LANはアクセスポイントからの電波が届く範囲であれば、どこからでも接続できます。自宅のリビングでもトイレでも玄関でも基本的にWi-Fi接続できますよね。

アクセスポイント(wireless LAN access point)とは、ノートパソコン・スマートフォンなどの無線LANクライアント(無線端末)を、Wi-FiBluetoothで相互に接続したり、他のネットワーク(有線LAN等)に接続したりする無線機の一種。 アクセスポイント (無線LAN) - Wikipedia

ルーターとは、ネットワークの中継機能を提供する装置のことです。
異なるネットワーク(LAN)同士を接続するためにルーターが必要であり、ルーターが担当するネットワーク内からルーターへデータが届くと、送られてきたパケットの宛先IPアドレスを確認して、それが自分の担当外のネットワークの場合は最適な経路を選択し、そのネットワークを担当するルーターへパケットを転送してくれる感じです。

そして無線LANルーターとは、アクセスポイントとルーターの機能を兼ね備えた機器を指します。
自宅に置いてるやつも無線LANルーターにあたる。大体の家庭が無線LANルーターのはず。

無線LAN通信の暗号化規格について

無線LANを使うと電波上にパケットが流れるわけなので、通信を暗号化しておく必要があります。
以下で代表的な暗号化規格を挙げていきます。

WEP(Wired Equivalent Privacy)

WEPは1999年に策定された無線LAN通信の暗号化規格です。
WEPには共通鍵暗号方式が用いられており、クライアント端末とアクセスポイントに共通の暗号鍵を設定する必要があります。
また暗号アルゴリズムにはRC4が用いられてます。

WEP(Wired Equivalent Privacy)とは、無線LAN(Wi-Fi)の通信を暗号化する方式の一つ。1999年に策定された規格で、Wi-Fi暗号化の最初の標準として広まったが、現在では十分な安全性が確保できなくなっている。 WEPとは - 意味をわかりやすく - IT用語辞典 e-Words

WEPの仕組みと危険性については、Wi-FiのセキュリティはなぜWEPでは駄目なのか | サイバーセキュリティ情報局に詳しく書かれているので、そちらを一部以下に抜粋しておきます。

WEPの暗号化の仕組みは、

  1. 送信データにICV(Integrity Check Value)というチェック値をくっつける。
  2. 共有鍵(WEPキー)にIV(Initialization Vector)というWEPキーの値に基づいて毎回生成される24ビットの乱数を、WEPキーに付加する(これが実際に使用される暗号鍵)。
  3. 2で生成された暗号鍵とRC4アルゴリズムを用いて、1のデータを暗号化する。

となります。

Wi-FiのセキュリティはなぜWEPでは駄目なのか | サイバーセキュリティ情報局]

WEPの危険性は以下の通りです。

ところが、実はこのIVの値は、あまり役に立たないことが後になって判明する。暗号解読の難易度は、暗号鍵の長さと暗号化するアルゴリズムの複雑さによって決まるのだが、WEPの場合、
1)もともとの秘密鍵がいつも同じであること、
2)しかも、それが場合によっては40ビットしかなく解読されやすいこと、そして、
3)IVの数値が暗号化されずにデータに付加されて送られること、さらに、
4)IVに対して実際に毎回異なる数値を使用していたとしても、通常のデータのやりとりならば6時間程度で一巡してしまうので、また同じ数値を使わねばならないこと、
この4点によって、送信データが傍受された場合、いともたやすくデータを第三者に読まれてしまうのである。

なるほど確かに、秘密鍵が固定で、その秘密鍵をもとに生成されるIVの数値が暗号化されていないとなると、それだけでも秘密鍵を特定できてしまうような気が?

WPA(Wi-Fi Protected Access

WEPの危険性が指摘された結果生まれたのがWPAです。

Wi-Fiのセキュリティ方式として最初に採用されたWEPの危険性が知られるようになり、代わってWPAが策定された。認証方式をPSK、暗号化方式をTKIP、完全性方式をMICに変えたことによって大幅に安全性を高めると同時に、既存製品を考慮してWEPとの互換性が確保されている。 当初はWEPがWi-Fiのセキュリティ方式として採用されていたが、暗号化研究者たちから極めて危険性が高いという指摘が次々となされ、市場から迅速な対応が求められた。その結果、2002年に新たに策定されたのがWPA(Wi-Fi Protected Access)である。 WPA(Wi-Fi Protected Access) | サイバーセキュリティ情報局

WEPからの主な変更点は以下の通り。

このようにセキュリティ面が強化されたものの、現在、WPAに使用されている暗号化アルゴリズムRC4はわずか数時間程度で破られてしまうことが判明しており、WPAの利用は推奨されていません。

WPA2

WPAに次いで生まれたのがWPA2です。

Wi-Fiのセキュリティ方式として、WEP、WPAに続いて3番目に策定された。暗号化方式には「AES」が採用されており、セキュリティがより堅固になっている。データを順番に暗号化していく「ストリーム暗号」ではなく、データをブロック化してその順番を管理する「カウンター」とともに暗号化する「ブロック暗号」を用いている点に特徴がある。 1970年代以降の米国の正式な標準暗号化方式は「DES」(Data Encryption Standard)と言い、WPAで使用している「TKIP」(Temporal Key Integrity Protocol)もこれに準拠していた。ところが「DES」が老朽化し信頼性が低下してきたため、代わりに2002年に新たに「AES」(Advanced Encryption Standard)を策定した。

WPAからの主な変更点は以下の通り。

  • AES(Advanced Encryption Standard)として採用されたRijndael(ラインダール)が用いられている。
    • AESとは共通鍵暗号アルゴリズムの標準規格のこと。
    • Rijndaelとはデータを128バイトや256バイトなどのブロックに分けて暗号化するブロック暗号の一種である。

Rijndaelの仕組みについては、WPA2(Wi-Fi Protected Access 2) | サイバーセキュリティ情報局が分かりやすいと思います。

PSK(Pre-Shared Key)とMITM攻撃について

またWPA2にはPSK(Pre-Shared Key)という事前共有鍵が使用されています。

以下画像の暗号化キー(初期値)にあたる部分がPSKです。

Windows 10で無線LANに接続する設定方法 | よくある質問(FAQ) | BIGLOBE会員サポート

以下のようにルーターの設定画面にアクセスすると、手動でPSKを変更することも可能です。

【簡単4ステップ】NURO光の設定変更/WIFIパスワード変更してセキュリテイを強化する手順 | 田舎に住みたいエンジニアの日記

このPSKを用いたWPA2にはMITM(Man in the middle)攻撃、すなわち中間者攻撃を用いて、盗聴や改ざんが行われてしまう脆弱性があります。
MITM攻撃とは、二者の通信に第三者が割り込み、送信者に対しては受信者のように、受信者に対しては送信者のようになりすます攻撃のことです。
MITM攻撃はあらゆる公開鍵暗号を用いた通信に対して攻撃が可能であり、WPA2も同様です。
後述のWPA3で触れますが、このMITM攻撃の対策として認証が必要であり、公開鍵の証明書が使われます。

KRACK攻撃とは?| KRACK攻撃から保護する方法

Rijndaelに対する攻撃可能性

またRijndaelは今のところは大丈夫だけど将来的に欠陥が見つかる可能性が示唆されているようです。
自宅Wi-FiにWPA2を使うのであれば、暗号化方式としてAESを利用しておくべきでしょう。

Rindaelに対しては、これまでに存在しなかった新しい種類の攻撃が懸念されています。 本で触れることはできませんが、Rindaelのアルゴリズムの背後には数学的構造があり、平文から暗号文を作り出す計算を数式で表現することが可能なのです。これは、過去の略号アルゴリズムにはなかった性質です。もしも、Rindaelの数式を数学的な操作によって解くことができるなら、Rindaelを数学的に解読できることになります。これは、過去になかった攻撃の可能性を示唆しています。ただしこれはあくまで可能性の話であり、Rijndaelに対する有効な攻撃は、現在のところ見つかっていません。 第3版 暗号技術入門 秘密の国のアリス | 結城浩

WPA3

WPA2の後継として生まれたのがWPA3です。

2018年6月に業界団体Wi-Fi Allianceから発表された、無線LANWi-Fi)のためのセキュリティプロトコルで、「WPA2」の後継として、より強固な規格として策定された。Wi-Fi Allianceでは、世代が異なるWi-Fiの技術に関して、世代という概念で名称を付けており、順に「Wi-Fi 4」、「Wi-Fi 5」、そして現時点で最新の「Wi-Fi 6」となっている。この「Wi-Fi 6」では、「WPA3」に準拠していることがセキュリティ要件として定められている。 > WPA3 | サイバーセキュリティ情報局

主な変更点は以下の通り。

  • SAE(Simultaneous Authentication of Equals)ハンドシェイクの採用によって、MITM攻撃に伴う脆弱性に対処することができた。
    • SAEはパスワードキー、すなわちPSKを使わない方式である。
    • ここで証明書による認証を行う。
  • 192bitの暗号化方式が実装された。
    • 暗号アルゴリズム強度を従来の128ビットよりも更にセキュリティ強度が高い192ビット長の鍵を用いた暗号化技術CNSA(Commercial National Security Algorithm)を採用している。

従来の規格が持つ脆弱性の対策だけでなく、以下のような機能も追加されました。

  • 安全で容易なWi-Fiへの接続を実現する「Easy Connect」機能
    • ディスプレイを持たないIoT端末などを無線LANへ接続する際に、安全かつ容易に接続する機能が「Easy Connect」だ。例えば、タブレットスマートフォンなどで、IoT端末に貼付されたQRコードを読み込むことでネットワーク情報がWi-Fiクライアントに登録され、接続が可能になるといった具合だ。
  • 公衆Wi-Fiでのセキュリティを強化する「Enhanced Open」機能
    • 一般的に、安全性の面で推奨されない公衆Wi-Fiに接続する際の安全性を高める機能が「Enhanced Open」だ。誰もが接続可能な公衆Wi-Fiのスポットで生じ得る、中間者(MITM)攻撃のようなリスクに対して、「OWE(Opportunistic Wireless Encryption)」と呼ばれる暗号化方式を用いることで、ユーザーの認証は不要でありながら、内容を暗号化した通信を行うことが可能となっている。 WPA3 | サイバーセキュリティ情報局



現在はこのWPA3が最も安全なプロトコルであり、これを利用することが推奨されているようです。



ザッと調べたのはこんな感じです。
仕組みを理解したし、Wi-Fiの設定も完了したしスッキリ!

フリーWi-Fiは何が危険なんだろうか?

これまで学んだことを踏まえて、フリーWi-Fiの安全性についても調べてみました。

フリーWi-FiではPSK(Pre-Shared Key)、つまり事前共有鍵が使用されているパターンが多く、このPSKが公開されています。 それゆえに通信を暗号化していても盗聴される恐れがあります。 たとえば偽のWi-Fi接続(別名: 悪魔の双子(Evel Twin))によってスタバのWi-Fiになりすませば、間違って偽のWi-Fiに接続してきた人が入力したクレカ情報やログイン情報などを盗み取ることが可能になります。

https://www.jtc-i.co.jp/glossary/evil-twin.html


悪魔の双子(あくまのふたご)、Evil Twin(いびるついん)とは、無線通信を盗聴するために設定された、正規のアクセスポイントを偽装した不正なWi-Fiアクセスポイントのことである。
悪魔の双子攻撃は中間者攻撃の一種であり、標的の通信を傍受し、改ざんすることができる。 ja.wikipedia.org

とはいえ、HTTPSTLS)が使われているサイトを利用するのであれば、基本的に通信内容を盗聴することは難しいはずなので、どうしてもフリーWi-Fiを使いたい場合はHTTPSが使われているサイトだけを利用するのが望ましいと思いました。

以下でフリーWi-Fiの危険性やフリーWi-Fiを使う上での必要な対策が詳しく言及されているので、こちらを見るのが良さそうです。

qiita.com

【Go】Goでドメインオブジェクトをどのようにして生成すべきか

ゴール

  • ドメインルールを守ったオブジェクトであることを保証したい。
  • Goの言語使用上外部からの変更(同一パッケージ内からの変更)を完全に防ぐことは難しいが、それでもなるべくドメインオブジェクトの不変性を保ちたい。

方針その1. 構造体のフィールドをパッケージ内でのみアクセス可能にする

以下の手順で実装する。

  • 構造体のフィールドの先頭を小文字にする
  • コンストラクタを用意する
  • パッケージ内でのみアクセス可能なフィールドを変更するためのメソッドを用意する
  • Getterを用意する
type User struct {
    // フィールドを小文字にすることで、パッケージ内でしか取得・変更できないようにする
    id int
    name string
    email string
    password string
    createdAt time.Time
    updatedAt time.Time
}

// コンストラクタを用意する
func NewUser(id int, name string, email string, password string, createdAt time.Time) *User {
    return &User{
        id:        id,
        name:      name,
        email:     email,
        password:  password,
        createdAt: createdAt,
        updatedAt: createdAt,
    }
}

// 「パッケージ内でのみアクセス可能なフィールド」を変更するためのメソッド
func (u *User) ChangeName(name string) {
    u.name = name
}

// Getterを用意する
func (u *User) CreatedAt() time.Time {
    return u.createdAt 
}
// 外部packageのコード
user, err := NewUser(1, "John")
user.ChangeName("Bob") // 変更可能
user.userName = "Mike" // コンパイルエラー

デメリット

Package内では破壊的変更が可能

  • いわゆるOOPにおけるProtectedでの制限はできるが、Privateでの制限はできない。
  • 以下のようなパッケージ構成をすれば破壊的変更を防げるが、管理が大変かつDDDのパッケージ構成にそぐわないと思う。

      ├── domain
      │   ├── user
      │   │   └── user.go
      │   ├── userid
      │   │   └── user_id.go
      │   └── username
      │       └── user_name.go
    

Getterを用意するのが面倒

フィールドごとにGetterを用意する必要がある。

type User struct {
  id int
  userName string
}

func (u *User) ID() int {
  return u.id
}

func (u *User) UserName() string {
  return u.userName
}

方針その2. インターフェイスを用いることでコンストラクタの使用を強制する

インターフェイスを用いてエンティティや値オブジェクトを生成すればコンストラクタの使用を強制することができるため、ドメインルールを守ったオブジェクトであることを保証できそう。

// インターフェイス
type UserNameInterface interface {
    Get() string
}

// 実装側の構造体を先頭小文字にする
type userNameImpl struct {
    UserNameInterface  // 省略可能だがどのインターフェイスを実装しているかあえて明示している
    username string
}

// インターフェイスを満たすためのメソッド
// Goでは「インターフェースの中にある同じ名前のメソッドを全て定義すれば、自動的にインターフェイスを実装したことになる
func (i *userNameImpl) Get() string {
    return i.username
}

// コンストラクタ
func NewUserName(s string) (UserNameInterface, error) {
    if err := validateUsername(s); err != nil {
          return nil, err
    }

    return &userNameImpl{username: s}, nil
}

// バリデーション
func validateUsername(s string) error {
    s = strings.Trim(s, " ")
    c := utf8.RuneCountInString(s) // 日本語(マルチバイト)の文字列を数える
    switch {
    case c == 0:
        return fmt.Errorf("名前を入力してください")
    case c > 10:
        return fmt.Errorf("名前は10文字以内で入力してください")
    }
    return nil
}
u := UserNameInterface{} // エラー
u := userName{username: "hoge"} // エラー
u, err := NewUserName("hoge") // OK
if err != nil {
  // エラーハンドリング
}

結論

方針その2「インターフェイスを用いることでコンストラクタの使用を強制する」が良さげ。

実装例

コードの例があまり良くないかもだが、備忘録として実装したコードを書き記しておく。

  • UserBookエンティティ
package userbook

import (
    "github.com/ryota1116/stacked_books/domain/model/book"
    "time"
)

type UserBookInterface interface {
    UserId() UserIdInterface
    BookId() BookIdInterface
    Status() StatusInterface
    Memo() MemoInterface

    ChangeMemo(value *string) error
}

type userBook struct {
    userId    UserIdInterface
    bookId    BookIdInterface
    status    StatusInterface
    memo      MemoInterface
}

func NewUserBook(
    userId int,
    bookId int,
    status int,
    memo *string,
) (UserBookInterface, error) {
    s, err := NewStatus(status)
    if err != nil {
        return &userBook{}, err
    }

    m, err := NewMemo(memo)
    if err != nil {
        return &userBook{}, err
    }

    return &userBook{
        userId: NewUserId(userId),
        bookId: NewBookId(bookId),
        status: s,
        memo:   m,
    }, nil
}

func (ub *userBook) UserId() UserIdInterface {
    return ub.userId
}

func (ub *userBook) BookId() BookIdInterface {
    return ub.bookId
}

func (ub *userBook) Status() StatusInterface {
    return ub.status
}

func (ub *userBook) Memo() MemoInterface {
    return ub.memo
}

func (ub *userBook) ChangeMemo(value *string) error {
    return ub.memo.changeMemo(value)
}
  • 値オブジェクトであるMemo
package userbook

import (
    "fmt"
    "unicode/utf8"
)

const maxCount = 255 // メモの最大文字数

type MemoInterface interface {
    Value() *string
    changeMemo(value *string) error
}

type memo struct {
    value *string // NOTE: ポインタ型にすることでnilを許容している
}

func NewMemo(value *string) (MemoInterface, error) {
    if err := validate(value); err != nil {
        return nil, err
    }

    // &でポインタ型を生成
    return &memo{value}, nil
}

func (m *memo) Value() *string {
    return m.value
}

func (m *memo) changeMemo(value *string) error {
    if err := validate(value); err != nil {
        return err
    }

    m.value = value
    return nil
}

func validate(value *string) error {
    // nilではない場合、値をチェックする
    if value != nil {
        memoCount := utf8.RuneCountInString(*value)

        if memoCount > maxCount {
            return fmt.Errorf("メモは255文字以下で入力ください。")
        }
    }

    return nil
}
  • 呼び出し元はこんな感じにする。
// エンティティの取得
ub, err := userBookRepository.FindOne(id)
if err != nil {
    // エラーハンドリング
}

// エンティティの更新
value := "更新後のメモの内容"
if err := ub.ChangeMemo(&value); err != nil {
    // エラーハンドリング
}


u, err := userBookRepository.Save(ub)
if err != nil {
    // エラーハンドリング
}

2023/03/26 追記 : ChatGPTさんに考えてもらう

ChatGPTさんに何回質問しても堂々巡りになったので(私のプロンプトが悪いやんなごめんな😇)端的に回答してくれたモノだけ載せておく。 主にChatGPTさんが考えてくれたことは

  1. 構造体のフィールドを小文字にして、同一パッケージ内からのアクセスしかできないように制限する。
  2. コンストラクタを用意して、ドメインルールを保証したオブジェクトを生成する。
  3. Getter、Setterを用意する
  4. Go言語にはアクセス修飾子が無く外部からのアクセスを完全に制限することはできないので、、コーディングルールを設けてチーム内に浸透させる。

ということで「方針その1. 構造体のフィールドをパッケージ内でのみアクセス可能にする」と同じ方法でした。

参考

NoSQLの種類・特徴・良記事をまとめる

仕事でNoSQLに触れる機会がちょくちょくあるので、NoSQLの種類・特徴・良記事を簡単にまとめてみた。

NoSQLとは

特徴

  • 拡張性と分散処理に優れている
  • 大容量データの高速処理が可能
  • データの整合性が緩い

種類

  • キーバリュー型
    • キーとバリューの単純な1対1管理。
    • Dynamo,Redis
  • 列指向型(カラム指向型)

    • キーバリュー型に「列(カラム)」の概念を追加したもの。 行に付与されたキーが複数のカラムを保持しており、必要に応じてカラムを追加することができる。つまり行キーごとのカラム数を動的に増やすことが可能となる。
    • RDBMSがレコード(行)を一塊のデータとして扱うのに対して、列(カラム)方向にデータを扱う。
      • 特定の列の値をまとめて処理することに長けており、列単位での大量集計、大量更新が得意(ある列の値を一斉に更新する etc.)。
      • 一方で複雑な検索・集計、特定の行を抜き出して更新・削除したりするのは苦手。
    • Cassandra,Bigtable,HBase
      • Cassandraについてはこの辺りの記事が良かったです。

        Cassandraでは、A・P(可用性・分断耐性)を担保するのに対し、冗長構成をとったRDBMSではC・A(整合性・可用性)を担保するように設計されています。
        techblog.yahoo.co.jp

      • Cassandraの設計ポイントが分かりやすい。 qiita.com

    www.publickey1.jp

  • ドキュメント型

    • JSON」「XML」といったドキュメントでデータを管理する。1レコード内に複雑な階層関係を入れることができる。ドキュメントはユニークIDで特定できる。
    • 個々のドキュメントのデータ構造が自由なので、事前にテーブルの構造を厳密に決めておく必要がない(スキーマレス)。ある程度決めておかんと地獄になるやろうけど。
    • MongoDB, CouchDB, FireStore
  • グラフ型
    • グラフ構造を備えたデータベースで、ノード(頂点)、エッジ(辺)、プロパティ(属性)の3つの要素から構成され、ノード間の関係性を表現できる。データの構造が従来のリレーショナルではなくネットワーク状になっている場合に、格納・検索の面で威力を発揮する。
    • Neo4j, InfiniteGraph

    グラフデータベースとは何か ~ネットワーク状のデータ構造から瞬時に情報を検索するDBを解説 - アイマガジン|i Magazine|IS magazine

NoSQL図
NoSQL基礎の基礎 - Qiita

雑感

  • Dynamo、Redisは触れた機会が多いけど使っててめっちゃ速い〜てなる。あとドキュメント型はFireStoreで触れたことあったけど、Mongoがドキュメント型であること、AWSにMongo互換のサービスがあることは知らんかった。
  • 逆に列指向型(カラム指向型)のように行キーごとにカラム数を動的に増やせるデータモデルが存在することは仕事で調べるまで知らんかった。
  • とりあえず思うことは、要件定義~設計するときにRDBで何とかしようとしがちやけど、NoSQLを選択肢に入れることでハッピーになれないか、はたまたログを吐き出すだけで要件を満たせないかなど柔軟に考えていきたい。まあ経験がモノを言うんかもしれんけど、、😡

他に参考にした記事たち

【MySQL】VARCHARの型サイズによってパフォーマンスが落ちることは無さそう

テーブル定義でVARCHARの型サイズをどこまで厳密に設定するべきなのか悩んだことがある。
どうやらLENGTHの長さはパフォーマンスに影響を与えないらしい。
無理にLENGTHを短くする必要は無さそう(ただのメモ記事でごめんなさい)。

qiita.com

qiita.com

【TypeScript】Non-null assertion operator(変数の末尾に!)

  • 「この変数はnullやundefinedではないよ〜」とコンパイラに教えてくれる。つまり、 <T | undefined | null> 型の変数xがあったとして、x! って書いたらnull および undefinedを除外した<T>を生成してくれる。
  • 実行時(JSに変換された後)にこのコードは削除される。
    • !オペレーターはあくまで型チェックをスキップするだけで、実行時に null などが入る可能性は依然としてあることに注意。
      • なのでjestを書く場合などは expect(...).not.toBeNull() などでチェックしてあげる必要がある。

www.typescriptlang.org

【Go】Enumを扱う

Enum(列挙型)とは

列挙型とは、プログラミング言語やデータベース管理システムなどにおけるデータ型の一つで、複数の異なる定数を一つの集合として定義するもの。多くの言語では “enum” の略号で示される。 列挙型(集合型)とは - 意味をわかりやすく - IT用語辞典 e-Words

とあるように、複数の定数を1つのクラス(型)としてまとめて管理できるもの。

Javaであれば

public enum Fruits{
    ORANGE,
    APPLE,
    BANANA
};

のように書けるみたい。 引数や戻り値の型をEnumに限定できるのでプログラムの堅牢性が増すし、可読性が上がったりする。

qiita.com

www.modis.co.jp

GoでEnumを扱うには?

GoにはJavaにおけるEnum(列挙型)のような機能が無い。
そのため、const定義とその中に iota (※1)を使うことでEnumを扱うのが一般的?かもしれない。

ただし、Goは変数を初期化する際に明示的に値を代入しない場合に、デフォルトで割り振られる値が決まっているので注意。
これをZero Value(ゼロ値)(※2)といい、int型の場合は0で、string型の場合は“”で初期化されるようになっている。

※1: iotaとは定数宣言(const)内で使用される識別子のことで、0から始まる型なしの整数連番を生成してくれる。
※2: Zero Value(ゼロ値)については、ゼロ値を使おう #golang - Qiitaが分かりやすい。



この仕様から、Goでは以下のように定数の割り当てを1からにすればいい。

type Fruit int

const (
    ORANGE Fruit = iota + 1 // 1
    APPLE                   // 2
    BANANA                  // 3
)

以下のfruits := Fruits{}は明示的に値を代入していないので、Zero Valueの仕様により、fruits.Fにはint型のデフォルト値の0が入ってしまう。

そのため iota + 1としておけば、意図した挙動になる。

type Fruits struct {
    F Fruit
}

func main() {
    fruits := Fruits{} // 変数初期化時に値を代入していないので、0が割り振られる

    switch fruits.F {
    case ORANGE:
        fmt.Println(“オレンジ”) // (+1していないと、この分岐を通ってしまう)
    case APPLE:
        fmt.Println(“りんご”)
    case BANANA:
        fmt.Println(“バナナ”)
    default:
        fmt.Println(“エラー”) // この分岐を通ってくれる
    }
}

公式パッケージの例

Goの標準パッケージtimeに用意されている構造体time.Time型を返すtime.Date関数は第2引数にtime.Month型を求めており、

func Date(year int, month Month, day int, hour int, min int, sec int, nsec int, loc *Location) Time

以下のようにtime.Month型はconst定義と iota を使って表現されており、これによって引数の型をEnumであるtime.Monthに限定しようとしていることが分かる(intをベタ書きしてもコンパイル通るとは思うけど)。

(time/time.go)
// A Month specifies a month of the year (January = 1, ...).
type Month int

const (
    January Month = 1 + iota
    February
    March
    April
    May
    June
    July
    August
    September
    October
    November
    December
)

実際使う場合はこんな感じ。

time.Date(2022, time.August, 10, 12, 0, 0, 0, time.UTC)

参考記事

【Go】JSONの入れ子を構造体に変換する

■ はじめに

APIから返ってくるJSON文字列をGolangの構造体に変換したいと思います。

JSONを構造体に変換する基本形

例えば以下JSONを構造体にマッピングしたい場合

{
    "book":{
        "google_books_id": "Wx1dLwEACAAJ",
        "title": "リーダブルコード",
        "authors": ["Dustin Boswell","Trevor Foucher"],
        "description": "読んでわかるコードの重要性と方法について解説",
        "isbn_10": "4873115655",
        "isbn_13": "9784873115658",
        "page_count": 237,
        "published_year": 2012,
        "published_month": 6
    }
}

以下のように記述すればOKです。

type BookRequestParameter struct {
    GoogleBooksId string    `json:"google_books_id"`
    Title         string    `json:"title"`
        Authors   []string      `json:"authors"`
    Description   string    `json:"description"`
    Isbn_10       string    `json:"isbn_10"`
    Isbn_13       string    `json:"isbn_13"`
    PageCount     int       `json:"page_count"`
    PublishedYear   int     `json:"published_year"`
    PublishedMonth   int    `json:"published_month"`
}

JSON入れ子を構造体に変換する

以下のようなJSON入れ子を構造体にマッピングする場合

{
    "book":{
        "google_books_id": "Wx1dLwEACAAJ",
        "title": "リーダブルコード",
        "authors": ["Dustin Boswell","Trevor Foucher"],
        "description": "読んでわかるコードの重要性と方法について解説",
        "isbn_10": "4873115655",
        "isbn_13": "9784873115658",
        "page_count": 237,
        "published_year": 2012,
        "published_month": 6
    },
    "memo": {
        "body": "メモです。"
    }
}

以下のどちらかの方法で構造体をフィールドに持つ構造体を定義してあげれば良いです。

type RegisterUserBookRequestParameter struct {
    Book BookRequestParameter `json:"book"`
    Memo MemoRequestParameter `json:"memo"`
}

type BookRequestParameter struct {
    GoogleBooksId string    `json:"google_books_id"`
    Title         string    `json:"title"`
    Description   string    `json:"description"`
    Isbn_10       string    `json:"isbn_10"`
    Isbn_13       string    `json:"isbn_13"`
    PageCount     int       `json:"page_count"`
    PublishedYear   int  `json:"published_year"`
    PublishedMonth   int     `json:"published_month"`
    PublishedDate   int  `json:"published_date"`
}

type MemoRequestParameter struct {
    Body          string    `json:"body"`
}
type RegisterUserBookRequestParameterr struct {
    Book struct  {
        GoogleBooksId string    `json:"google_books_id"`
        Title         string    `json:"title"`
        Description   string    `json:"description"`
        Isbn_10       string    `json:"isbn_10"`
        Isbn_13       string    `json:"isbn_13"`
        PageCount     int       `json:"page_count"`
        PublishedYear   int  `json:"published_year"`
        PublishedMonth   int     `json:"published_month"`
        PublishedDate   int  `json:"published_date"`
    } `json:"book"`
    Memo struct {
        Body          string    `json:"body"`
    }`json:"user_book"`
}

実際にAPIを叩いて受け取ったレスポンスを構造体に変換してみる

今回はGoogle Books APIを題材にしてみます。 Google Books APIhttps://www.googleapis.com/books/v1/volumes?q=検索したい書籍名の形式でリクエストを投げられます。

以下のコードはGoogle Books APIから受け取ったJSON形式のレスポンスをResponseBodyFromGoogleBooksApi構造体に変換しています。

// ResponseBodyFromGoogleBooksApi : GoogleBooksAPIを叩いた時のJSONレスポンスを格納する構造体
type ResponseBodyFromGoogleBooksApi struct {
    Items []Item `json:"items"`
}

type Item struct {
    ID         string     `json:"id"`
    VolumeInfo VolumeInfo `json:"volumeInfo"`
}

type VolumeInfo struct {
    Title               string               `json:"title"`
    Authors             []string             `json:"authors"`
    PublishedDate       string               `json:"publishedDate"`
    Description         string               `json:"description"`
    IndustryIdentifiers []IndustryIdentifier `json:"industryIdentifiers"`
    PageCount           int                  `json:"pageCount"`
}

type IndustryIdentifier struct {
    Type       string `json:"type"`
    Identifier string `json:"identifier"`
}

type GoogleBooksApiClientInterface interface {
    SendRequest(searchWord string) (ResponseBodyFromGoogleBooksApi, error)
}


func (client googleBooksApiClient) SendRequest(searchWord string) (model.ResponseBodyFromGoogleBooksApi, error) {
  searchURL := "https://www.googleapis.com/books/v1/volumes?q=" + searchWord

  // GoogleBooksAPIを叩く
  res, err := http.Get(searchURL)
  if err != nil {
    // エラーハンドリング
  }

  defer res.Body.Close()

  body, err := ioutil.ReadAll(res.Body)
  if err != nil {
    // エラーハンドリング
  }

  // JSONエンコードされたデータをparseして、構造体の変数responseBodyFromGoogleBooksApiに格納する
  var responseBodyFromGoogleBooksApi model.ResponseBodyFromGoogleBooksApi
  if err := json.Unmarshal(body, &responseBodyFromGoogleBooksApi); err != nil {
    // エラーハンドリング
  }

  return responseBodyFromGoogleBooksApi, nil
}

JSONを構造体に変換する便利ツール

JSONを構造体の形式にマッピングするためには、json-to-goを使うと便利です。

json-to-go

【Git】git rebase -iでpush済みのコミットまとめたれ

コミットをまとめる

コミットBとCをまとめたい

$ git log -n 3                                                                                                                                                              
commit 78dbdc4439854253a4c65a23aad687488bcf2051 (HEAD -> feature/hogehoge)
Author: ryota1116
Date:   Tue Sep 21 01:27:14 2021 +0900

    コミットC

commit 185f507a6a32fd93505db1cd05b6effa23f45532
Author: ryota1116
Date:   Tue Sep 21 01:14:21 2021 +0900

    コミットB

commit f19845709c26dea827c6097279472ecc3ddda491
Author: ryota1116
Date:   Tue Sep 21 01:10:07 2021 +0900

    コミットA

git rebase -iでコミットをまとめる。

$ git rebase -i HEAD~3

pick b536dec90 コミットA
pick 7fc1e2c81 コミットB
pick 3c98d9014 コミットC

# Rebase 185f507a6..3c98d9014 onto 185f507a6 (3 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified). Use -c <commit> to reword the commit message.
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

コミットC(3c98d9014)を、1つ前のコミットB(7fc1e2c81)にまとめたい場合以下のように書き換える。 squash(押し潰す)されたコミットCをコミットBが拾う(pick)。

$ git rebase -i HEAD~3

pick b536dec90 コミットA
pick 7fc1e2c81 コミットB
s 3c98d9014 コミットC

:wqでファイルを保存しviを終了すると、以下の画面になる。

# This is a combination of 2 commits.
# This is the 1st commit message:

コミットB

# This is the commit message #2:

コミットC

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Fri Sep 24 16:49:38 2021 +0900
#
# interactive rebase in progress; onto 185f507a6
# Last commands done (3 commands done):
#    pick 7fc1e2c81 コミットB
#    squash 3c98d9014 コミットC
# No commands remaining.
# You are currently rebasing branch 'feature/hogehoge
#
# Changes to be committed:
#       modified:   src/hogehoge.php
#       modified:   src/fuga.php
#       modified:   src/piyo.php
#

コミットメッセージをまとめてあげて、:wq

# This is a combination of 2 commits.
# This is the 1st commit message:

コミットB+

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Fri Sep 24 16:49:38 2021 +0900
#
# interactive rebase in progress; onto 185f507a6
# Last commands done (3 commands done):
#    pick 7fc1e2c81 コミットB
#    squash 3c98d9014 コミットC
# No commands remaining.
# You are currently rebasing branch 'feature/hogehoge
#
# Changes to be committed:
#       modified:   src/hogehoge.php
#       modified:   src/fuga.php
#       modified:   src/piyo.php
#

以下の画面に戻る。

$ git rebase -i HEAD~3
[detached HEAD 51ffdbb8d] コミットB+
 Date: Fri Sep 24 16:49:38 2021 +0900
 3 files changed, 11 insertions(+), 3 deletions(-)
Successfully rebased and updated refs/heads/feature/hogehoge

コミットが纏まったことが分かる。

$ git log -n 3                                                                                                                                                              
commit 51ffdbb8dc3f2b5336f5394739599313c25022a0 (HEAD -> feature/hogehoge)
Author: ryota1116
Date:   Fri Sep 24 16:49:38 2021 +0900

    コミットB+

commit b536dec90d6505bcfc6ae2f3f74ba03a5eeca8b3
Author: ryota1116
Date:   Tue Sep 21 01:27:14 2021 +0900

    コミットA

commit 185f507a6a32fd93505db1cd05b6effa23f45532
Author: ryota1116
Date:   Tue Sep 21 01:14:21 2021 +0900

    コミットα

問題無ければforce push。 同じブランチで他の人が一緒に作業してたりするなら、force pushは避けましょうね。

$ git push -f

まとめたコミットを戻したい時

git logではsquash後のコミットしか見れないが、

$ git log -n 3                                                                                                                                                              
commit 51ffdbb8dc3f2b5336f5394739599313c25022a0 (HEAD -> feature/hogehoge)
Author: ryota1116
Date:   Fri Sep 24 16:49:38 2021 +0900

    コミットB+

commit b536dec90d6505bcfc6ae2f3f74ba03a5eeca8b3
Author: ryota1116
Date:   Tue Sep 21 01:27:14 2021 +0900

    コミットA

commit 185f507a6a32fd93505db1cd05b6effa23f45532
Author: ryota1116
Date:   Tue Sep 21 01:14:21 2021 +0900

    コミットα

git reflogでHEADの移動履歴を確認できる。
3c98d9014まで戻せば良さそう。

$ git reflog -n 11
78dbdc443 (HEAD -> feature/hogehoge) HEAD@{0}: rebase -i (finish): returning to refs/heads/feature/hogehoge
78dbdc443 (HEAD -> feature/hogehoge) HEAD@{1}: rebase -i (squash): コミットB+
549583fe1 HEAD@{2}: rebase -i (squash): # This is a combination of 2 commits.
b536dec90 HEAD@{3}: rebase -i (start): checkout HEAD~3
3c98d9014 HEAD@{4}: rebase -i (abort): updating HEAD
3c98d9014 HEAD@{5}: rebase -i (abort): updating HEAD
bdd5515b2 HEAD@{6}: rebase -i (squash): # This is a combination of 2 commits.
b536dec90 HEAD@{7}: rebase -i (start): checkout HEAD~3
3c98d9014 HEAD@{8}: commit: コミットC
7fc1e2c81 HEAD@{9}: commit: コミットB

git reset --hard で戻す。

$ git reset --hard 3c98d9014                                                                                                                                           
HEAD is now at 3c98d9014 コミットC

reflog見ると、HEADの位置が移動していることが分かる。

$ git reflog -n 12
3c98d9014 (HEAD -> feature/hogehoge) HEAD@{0}: reset: moving to 3c98d9014
78dbdc443 HEAD@{1}: rebase -i (finish): returning to refs/heads/feature/hogehoge
78dbdc443 HEAD@{2}: rebase -i (squash): コミットB+
549583fe1 HEAD@{2}: rebase -i (squash): # This is a combination of 2 commits.
b536dec90 HEAD@{3}: rebase -i (start): checkout HEAD~3
3c98d9014 HEAD@{4}: rebase -i (abort): updating HEAD
3c98d9014 HEAD@{5}: rebase -i (abort): updating HEAD
bdd5515b2 HEAD@{6}: rebase -i (squash): # This is a combination of 2 commits.
b536dec90 HEAD@{7}: rebase -i (start): checkout HEAD~3
3c98d9014 HEAD@{8}: commit: コミットC
7fc1e2c81 HEAD@{9}: commit: コミットB

参考記事