shimapapa.io

.NET,VB,C#,AzureなどMS関連中心の技術ブログ

mybatisで値オブジェクト(Value Object)を扱う場合のポイント

前置き

現場で mybatis を使い始めたのですが、値オブジェクト(Value Object)とマッピングさせる際に少しハマったので整理しました。

環境

  • Spirng Boot
  • mybatis
  • h2 DataBase

SELECT の結果をオブジェクト内の Value Object にマッピングさせる

以下のようなUserNameという Value Object クラスがあったとします。

package com.example.demo.domain.model;

public class UserName {
    private final String value;

    public UserName(String value) {
        this.value = value;
    }

    public String getValue() {
        return this.value;
    }
}

UserクラスがUserNameを保持します。
他にUserNameRegisterDateというクラスも保持しています。

package com.example.demo.domain.model;

import lombok.Data;

@Data
public class User {
    private UserId userId;
    private UserName userName;
    private RegisterDate registerDate;
}

schema.sqlにDB定義を書きます。

CREATE TABLE users (
  id int NOT NULL
  , user_name VARCHAR(50)  
  , register_date DATE
);

テスト用のデータ挿入用のdata.sqlです。

INSERT INTO users VALUES (1, 'Nocchi', '2020-02-01');
INSERT INTO users VALUES (2, 'Kashiyuka', '2020-02-02');
INSERT INTO users VALUES (3, 'A-Chan', '2020-02-03');

UserRepositoryにIDからUserを取得するためのメソッドfindByIdを定義します。

package com.example.demo.domain.repository;

import com.example.demo.domain.model.User;
import com.example.demo.domain.model.UserId;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

@Mapper
@Repository
public interface UserRepository {
    User findById(@Param("userId") UserId userId);
}

Mapper は以下のような内容になります。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.domain.repository.UserRepository">
  <select id="findById" resultMap="UserMap" parameterType="map">
    select id, user_name, register_date from users where id = #{userId.value}
  </select>

  <resultMap id="UserMap" type="com.example.demo.domain.model.User">
      <association property="userId" javaType="com.example.demo.domain.model.UserId">
        <constructor>
          <arg name="value" column="id"/>
        </constructor>
      </association>
      <association property="userName" javaType="com.example.demo.domain.model.UserName">
        <constructor>
          <arg name="value" column="user_name"/>
        </constructor>
      </association>
      <association property="registerDate" javaType="com.example.demo.domain.model.RegisterDate">
        <constructor>
          <arg name="value" column="register_date"/>
        </constructor>
      </association>
  </resultMap>

</mapper>

ポイント

  • Value Object を selectの Parameter に使用したい場合@Paramアノテーションをつける
  • @Paramを使用する場合、selectのオプションにparameterType="map"を付与する

上記を行わないと、where id = #{userId.value} のように Value Object の値をselect内で使用できませんでした。

  • SELECT結果のオブジェクト内の Value Object インスタンスを生成するには、associationを使用する
  • Value Object の コンストラクタに値をマップさせるためにconstructorを使用する
  • argのオプションnameで、引数名を指定する

以下の部分です。

<association property="userId" javaType="com.example.demo.domain.model.UserId">
        <constructor>
          <arg name="value" column="id"/>
        </constructor>
</association>

結果

テストコードを書いてブレークさせた結果は以下のように。
Value Object のインスタンスも生成されて、値もマップされています。

f:id:rikupapa-shima:20200206054141p:plain

コード

GitHub に公開しました。

github.com

「Visual Studio Code for Java」で Spring Boot の開発環境を整える【インストール編】

前置き

今年は現場で Spring Boot の開発案件に携わることになりそうなので、勉強中です。
しかし、いかんせん Eclipse が苦手なのでなんとかならないかな・・・というところに、
Visual Studio CodeJava の開発環境向けのインストーラーが公開されていることを知りました。

code.visualstudio.com

インストールしてみて、使えるようであれば現場でも薦めて見ようかなと思います。

手順

※環境はWindows 10 (※ Amazon WorkSpaces 上の環境なのでやや特殊な環境かもです)
上記のページの「Download Visual Studio Code Java Pack Installer」からインストーラーをDLします。

f:id:rikupapa-shima:20200129190728p:plain

手順通りに進めていきます。

f:id:rikupapa-shima:20200129190755p:plain

上記の画面のあとでエラーが発生。
どうやらJDKのインストールでエラーが発生したようです。

[18:33:06] Download JDK
[18:33:38] Download JDK ... [Completed]
[18:33:38] Install JDK
[18:33:40] Error occurred
[18:33:40] Error: Command failed with exit code 1625: C:\Windows\System32\msiexec.exe /i "D:\Users\HOGE\AppData\Local\Temp\1\1WBni7p1RXz2l2Ly16XoZJ5Ip8B\resources\binaries\jdk\11\x64\OpenJDK11U-jdk_x64_windows_hotspot_11.0.6_10.msi" MSIINSTALLPERUSER=1 INSTALLDIR="D:\Users\HOGE\AppData\Local\Programs\AdoptOpenJDK" ADDLOCAL=FeatureJavaHome,FeatureEnvironment,FeatureJarFileRunWith /passive

おそらく管理者権限系のエラーかなと思い、インストーラーを右クリック「管理者として実行」したところ・・・

f:id:rikupapa-shima:20200129191048p:plain

良かった!インストーラー出来ました。
拡張機能に、Java関連の機能が一式インストールされた状態になっています。

f:id:rikupapa-shima:20200131215228p:plain

Spirng Boot でWebアプリケーションを開発したい場合は、以下の拡張機能もインストールしておきます。

  • Spring Boot Tools
  • Spring Initializr Java Support
  • Spring Boot Dashboard

f:id:rikupapa-shima:20200131215330p:plain

Spring Initializr をインストール後、コマンドパレットから Initializr を呼び出せるようになります。

f:id:rikupapa-shima:20200131220303p:plain

f:id:rikupapa-shima:20200131220356p:plain

エクスプローラーに Spring-Boot Dashboard が表示されるようになります。
プロジェクトを右クリック→Start すればもうアプリケーションが起動します。

f:id:rikupapa-shima:20200131220500p:plain

ソースコード上の任意の行をクリックして、ブレークポイントを設定し、debug でアプリケーションを起動すればブレークできます。

f:id:rikupapa-shima:20200131220634p:plain

eclipse同様、パッケージのエラーも表示して自動修正してくれます。

f:id:rikupapa-shima:20200131221241p:plain

もちろん IntelliSense も。

f:id:rikupapa-shima:20200131221524p:plain

感想

以上、インストールとさわりの部分だけですが、やはりずっと .NETVisual Studio で開発してきた人間には elipse より VSCode の方がしっくりくる感じがします。
今後は Java + Spring Boot 関連の記事もアップしていきたいと思います。

【DDD練習】「JR 新幹線 料金ルールを実装してみよう」にチャレンジ(その4)

前置き

以下シリーズ化してきているDDD練習の第4回です。

rikupapa-shima.hatenablog.com

rikupapa-shima.hatenablog.com

rikupapa-shima.hatenablog.com

練習課題

github.com

今回対応する要件

季節(season)による特急指定席料金の変動

季節の区分

  • 通常期(regular)
  • 閑散期(off-peak)
  • 繁忙期(peak)

の三種類

12月から1月の繁忙期と閑散期

  • 繁忙期: 12月25日〜1月10日(年末年始)
  • 閑散期: 1月16日〜30日

料金の変動

通常期の指定席特急券に対して、

  • 閑散期は -200円
  • 繁忙期には +200円

自由席特急券

季節によって変動しない。 通常期の指定席特急券より530円を引いた金額で年間固定。

実装

まずは変動費(-200円、+200円)を表現する値オブジェクトを実装します。

public class ReservedSeatSeasonVariable
    {
        public readonly int value;

        public ReservedSeatSeasonVariable(int value)
        {
            this.value = value;
        }
    }

季節(Season)をインターフェイスで実装します。

public interface Season
    {
        int variableAmount();
    }

続いて、Seasonインターフェイスを実装した季節ごとクラスを作成します。

  • 通常期(regular)
  • 閑散期(off-peak)
  • 繁忙期(peak)

季節の期間は毎年変動すると見越して、DBで定義された値から取得する形が良いかもしれません。
今回は取り急ぎ定数的に実装します。

public class PeakSeason : Season
    {
        public readonly ReservedSeatSeasonVariable reservedSeatSeasonVariable = 
            new ReservedSeatSeasonVariable(200);

        public int variableAmount()
        {
            return reservedSeatSeasonVariable.value;
        }

        public static bool during(BoardingDate boardingDate)
        {
            if (boardingDate.value.Month == 12 && boardingDate.value.Day >= 25) { return true; };
            return boardingDate.value.Month == 1 && boardingDate.value.Day <= 10;
        }

    }
public class OffPeakSeason : Season
    {
        public readonly ReservedSeatSeasonVariable reservedSeatSeasonVariable = 
            new ReservedSeatSeasonVariable(-200);

        public int variableAmount()
        {
            return reservedSeatSeasonVariable.value;
        }

        public static bool during(BoardingDate boardingDate)
        {
            if (boardingDate.value.Month == 1 && boardingDate.value.Day >= 16) { return true; };
            return boardingDate.value.Month == 1 && boardingDate.value.Day <= 30;
        }

    }
public class ReqularSeason : Season
    {
        public int variableAmount()
        {
            return 0;
        }
    }

そして、出発日(BoardingDate)から季節を判定するクラスSeasonTypeです。

public class SeasonType
    {
        public static Season valueOf(BoardingDate boardingDate)
        {
            switch (boardingDate)
            {
                case BoardingDate boarding when PeakSeason.during(boarding):
                    return new PeakSeason();
                case BoardingDate boarding when OffPeakSeason.during(boarding):
                    return new OffPeakSeason();
                default:
                    return new ReqularSeason();
            }
        }
    }

季節ごとの変動は指定席の特急料金だけに対してなので、実装済み(第1回)の指定席(ReservedSeat)クラスに渡せばよいだけです。

f:id:rikupapa-shima:20200129050422p:plain

f:id:rikupapa-shima:20200129050544p:plain

以上で実装完了です。
サービスクラスの差分は以下のようになりました。

f:id:rikupapa-shima:20200129050731p:plain

振り返り

今回の練習課題は業務アプリケーションの開発の実戦に近く、非常に有意義なものであったと思います。
数人でモブプロやライブコーディングしながらやったら面白そうです。
ベテラン+初学者のグループでやったりすると、いろいろな気付きが出てきたりしそうです。

第2回まではクラスの表現に結構悩んだところもあったのですが、第3回、4回では慣れてきたのかスムーズにクラスの表現が出来たと思いました。
間を開けて実装してしまっているので、表現方法に若干統一感がないのと、クラスをもっと集約しても良いのかな、とも思います。
この辺りは今後リファクタリングにも取り組んでみたいです。
あと、リポジトリにはView側のプロジェクトも作っているのにそちらは力尽きてしまっているので・・・

今回の練習結果のリポジトリ

github.com

参考

現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法

現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法

【DDD練習】「JR 新幹線 料金ルールを実装してみよう」にチャレンジ(その3)

前置き

以下シリーズ化してきているDDD練習の第3回です。

rikupapa-shima.hatenablog.com

rikupapa-shima.hatenablog.com

練習課題

github.com

今回対応する要件

団体割引(group discount)

8人以上が同一行程で旅行する場合に適用されます。

  • 12月21日〜1月10日 10%
  • それ以外 15%

31人以上の割引

  • 31〜50人の普通団体は、1人分の運賃と特急料金が無料になります
  • 51人以上の場合は、50人増えるごとに、1人ずつ運賃・特急料金が無料になります。

実装

年末年始の期間は団体割引の率が下がります。
そもそも適用期間内かどうかを判定するために計算に出発日(BoardingDate)が必要だったことがわかりました。
出発日(BoardingDate)を値オブジェクト(ValueObject)で表現し、「12月21日〜1月10日」の期間を「NewYearHolidaysPeriod」と表現しました。
NewYearHolidaysPeriod」は、もしかしたら毎年日付が変わるかもしれないので、年ごとに履歴管理するべきかもしれません。
実戦においてはこうした要件だけから読み取れない部分は、ヒアリングする必要があると思います。
今回はとりあえず定数的に管理することにします。

public class BoardingDate
    {
        public readonly DateTime value;

        public BoardingDate(DateTime value)
        {
            this.value = value;
        }

        public bool duringNewYearHolidaysPeriod()
        {
            return NewYearHolidaysPeriod.during(this);
        }

    }
public class NewYearHolidaysPeriod
    {
        private const int StartMonth = 12;
        private const int StartDay = 21;
        private const int EndMonth = 1;
        private const int EndDay = 10;

        public static bool during(BoardingDate boardingDate)
        {
            if (boardingDate.value.Month == StartMonth && boardingDate.value.Day >= StartDay) { return true; };
            return boardingDate.value.Month == EndMonth && boardingDate.value.Day <= EndDay;
        }

    }

続いて、料金計算には人数が必要だったことも判明したので、利用人数をNumberOfPeopleクラスで表現します。
団体人数の区分をこのクラスで判定します。
要件の中で31〜50人は「普通団体」と記載されていたので、

  • 普通団体(31〜50人)= OrdinaryGroup
  • 大規模団体(51人以上)= LargeScaleGroup

と定義。団体割引の適用可否の判定も実装しました。

public class NumberOfPeople
    {
        public readonly int value;

        public NumberOfPeople(int value)
        {
            this.value = value;
        }

        public bool isGroupDiscountNotApplicable()
        {
            return this.value < 8;
        }

        public bool isGroupDiscountApplicable()
        {
            return this.value >= 8;
        }

        public bool isOrdinaryGroup()
        {
            return this.value >= 31 && this.value <= 50;
        }

        public bool isLargeScaleGroup()
        {
            return this.value >= 51;
        }

    }

続いて団体割引額を表現するクラスGroupDiscountを実装。
引数のTripクラスはその1〜2で実装した運賃、特急料金と片道/往復など各割引を計算するクラスです。
割引率は値オブジェクトGroupDiscountRateで定義しました。

public class GroupDiscount
    {
        private readonly Trip trip;
        private readonly BoardingDate boardingDate;
        private const double roundDownNumber = 0.1;

        public GroupDiscount(Trip trip, BoardingDate boardingDate)
        {
            this.trip = trip;
            this.boardingDate = boardingDate;
        }

        public int value()
        {
            GroupDiscountRate groupDiscountRate = boardingDate.duringNewYearHolidaysPeriod() ? new GroupDiscountRate(0.1) : new GroupDiscountRate(0.15);
            var discounted = (int)Math.Floor((this.trip.value() * groupDiscountRate.value) * roundDownNumber) * 10;
            return discounted;
        }
    }
public class GroupDiscountRate
    {
        public readonly double value;

        public GroupDiscountRate(double value)
        {
            this.value = value;
        }

    }

ここまでは「団体割引」の実装で、ここから「31人以上の割引 」時の無料になる人数の計算の実装になります。
インターフェイスGroupTypeに団体の区分クラスを生成するstaticメソッドを実装。
NumberOfPeopleで実装した判定メソッドから、区分クラスを生成します。
無料となる人数が発生しない区分(30名以下)を、小規模団体SmallScaleGroupと表現しました。

public interface GroupType
    {
        NumberOfPeople groupDiscountApplicableNumber();
        public static GroupType valueOf(NumberOfPeople numberOfPeople)
        {
            if (numberOfPeople.isLargeScaleGroup()) { return new LargeScaleGroup(numberOfPeople); };
            if (numberOfPeople.isOrdinaryGroup()) { return new OrdinaryGroup(); };
            return new SmallScaleGroup();
        }
    }
public class SmallScaleGroup : GroupType
    {
        public NumberOfPeople groupDiscountApplicableNumber()
        {
            return new NumberOfPeople(0);
        }
    }
public class OrdinaryGroup : GroupType
    {
        public NumberOfPeople groupDiscountApplicableNumber()
        {
            return new NumberOfPeople(1);
        }
    }
public class LargeScaleGroup : GroupType
    {
        private readonly NumberOfPeople numberOfPeople;
        public LargeScaleGroup(NumberOfPeople numberOfPeople)
        {
            this.numberOfPeople = numberOfPeople;
        }
        public NumberOfPeople groupDiscountApplicableNumber()
        {
            return new NumberOfPeople(this.numberOfPeople.value / 50);
        }
    }

最後に、ここまでで定義してきたクラスを利用する計算処理ですが、新たにTicket(きっぷ)クラスを定義。
その中に実装することにしました。
ここに来てようやく出てきたTicketという概念。
そもそもこのクラスが今回の集約ルートになりそうな気もします・・・。

public class Ticket
    {
        private readonly Trip trip;
        private readonly BoardingDate boardingDate;
        private readonly NumberOfPeople numberOfPeople;
        public Ticket(Trip trip, BoardingDate boardingDate, NumberOfPeople numberOfPeople)
        {
            this.trip = trip;
            this.boardingDate = boardingDate;
            this.numberOfPeople = numberOfPeople;
        }

        public int Amount()
        {
            if (numberOfPeople.isGroupDiscountNotApplicable())
            {
                return trip.value() * numberOfPeople.value;
            }
            return groupDiscountedAmount();
        }

        private int groupDiscountedAmount()
        {
            GroupDiscount groupDiscount = new GroupDiscount(trip, boardingDate);

            GroupType groupType = GroupType.valueOf(numberOfPeople);
            NumberOfPeople discountApplicableNumber = groupType.groupDiscountApplicableNumber();

            var discountedFare = trip.value() - groupDiscount.value();
            return (discountedFare * numberOfPeople.value) - (discountedFare * discountApplicableNumber.value);
        }

    }

サービスクラスの前回からの差分は以下になります。
サービスクラスから見ると、既存の他クラスを修正せずに団体割引を追加できたことがよくわかります。
つまり、安全に要件追加を出来たことになるのではないかと思います。

f:id:rikupapa-shima:20200128052625p:plain

まとめ

団体割引の要件を文章にするとたった7行ですが、そこから抽出されたクラスは10クラスになりました。
短い要件のなかから、如何にドメインを抽出していけるかを考える。
ソフトウェア設計の面白いところだと思いました。

残る要件は

季節(season)による特急指定席料金の変動

のみです。次回でラストにしたいです。

参考書籍

現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法

現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法

【DDD練習】「JR 新幹線 料金ルールを実装してみよう」にチャレンジ(その2)

前置き

前回からの続きです。
ようやく着手できました・・・
rikupapa-shima.hatenablog.com

嬉しかったこと

『現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法』の著者の増田さんから、リアクションいただけました。

今回実装する要件

往復割引 (round trip discount)

片道の営業キロが601km以上あれば、「ゆき」と「かえり」の運賃がそれぞれ1割引になります。

東京からの営業キロ

  • 新大阪 553km
  • 姫路 644km

例:東京-姫路の往復料金

片道の割引額 10,010円 × 90% = 9,009円 → 10円未満の端数切り捨て 9,000円

割引後の往復運賃 : 9,000円 × 2 = 18,000円

2,020円の割引になる

まずは「営業キロ(RailWayDistance)」を値オブジェクトとして実装。
「601km以上の場合に割引になる」要件をisDiscountDistance関数で表現しました。

public class RailWayDistance
    {
        public readonly int value;

        public RailWayDistance(int value)
        {
            this.value = value;
        }

        public bool isDiscountDistance()
        {
            return this.value >= 601;
        }

    }

往復割引の要件から、運賃(BasicFare)は割引が存在することが判明しました。
割引後の運賃を表現するRoundTripDiscountedOnewayBasicFareクラスを実装。
元になる運賃(BasicFare)と、割引のロジックを実装したクラスです。

public class RoundTripDiscountedOnewayBasicFare : BasicFareWithTripType
    {
        private readonly BasicFare basicFare;
        private const double discountRate = 0.9;
        private const double roundDownNumber = 0.1;

        public RoundTripDiscountedOnewayBasicFare(BasicFare basicFare)
        {
            this.basicFare = basicFare;
        }

        public int value =>  (int) Math.Floor((this.basicFare.value* discountRate) * roundDownNumber) * 10;

    }

割引が適用されない運賃は「片道運賃(OnewayBasicFare)」としました。
BasicFareを内包するだけのクラスです。

public class OnewayBasicFare : BasicFareWithTripType
    {
         public readonly BasicFare basicFare;

        public OnewayBasicFare(BasicFare basicFare)
        {
            this.basicFare = basicFare;
        }

        public int value => basicFare.value;
    }

実装クラスの説明が先になりましたが、運賃と特急料金から料金を計算するFareクラスから両者を扱うために、BasicFareWithTripTypeインターフェイスを定義しました。

public interface BasicFareWithTripType
    {
        int value { get; }
    }

そして、文字列「OnewayTrip(片道)/ RoundTrip(往復)」の判定と、往復だった場合は営業キロが割引適用かを判定し、割引適用後の運賃クラスを生成する「運賃種別(BasicFareType)」クラスを実装しました。

public class BasicFareType
    {
        private readonly BasicFare basicFare;
        private readonly RailWayDistance railWayDistance;

        public BasicFareType(BasicFare basicFare, RailWayDistance railWayDistance)
        {
            this.basicFare = basicFare;
            this.railWayDistance = railWayDistance;
        }

        public BasicFareWithTripType Oneway()
        {
            return new OnewayBasicFare(basicFare);
        }

        public BasicFareWithTripType RoundTrip()
        {
            return createBasicFareForRoundTrip();
        }

        private BasicFareWithTripType createBasicFareForRoundTrip()
        {
            if (this.railWayDistance.isDiscountDistance())
            {
                return new RoundTripDiscountedOnewayBasicFare(this.basicFare);
            }
            return new OnewayBasicFare(this.basicFare);
        }

        public BasicFareWithTripType valueOf(string name)
        {
            var method = typeof(BasicFareType).GetMethod(name);
            return method.Invoke(this, null) as BasicFareWithTripType;
        }
    }

ここまで往復時の運賃割引のロジックは完成ですが、さらに「片道料金」「往復料金」を表現するクラスを実装します。
型はTripとし、「片道OnewayTrip」「往復RoundTrip」クラスとして表現しました。
「往復」は「行き(departingFare)」と「帰り(returningFare)」の金額を合計した額を返します。
(※Fareの参照渡しになってしまっているのが若干モヤモヤしますが)

public interface Trip
    {
        public int value();

    }
public class OnewayTrip : Trip
    {
        private Fare fare;

        public OnewayTrip(Fare fare)
        {
            this.fare = fare;
        }

        public int value() => fare.value();
    }
public class RoundTrip : Trip
    {
        private Fare departingFare;
        private Fare returningFare;

        public RoundTrip(Fare fare)
        {
            this.departingFare = fare;
            this.returningFare = fare;
        }

        public int value() => this.departingFare.value() + this.returningFare.value();

    }

割引運賃と同じく、区分オブジェクトでTripTypeクラスを表現しました。

public class TripType
    {
        private readonly Fare fare;

        public TripType(Fare fare)
        {
            this.fare = fare;
        }

        public OnewayTrip Oneway()
        {
            return new OnewayTrip(fare);
        }

        public RoundTrip RoundTrip()
        {
            return new RoundTrip(fare);
        }

        public Trip valueOf(string name)
        {
            var method = typeof(TripType).GetMethod(name);
            return method.Invoke(this, null) as Trip;
        }

    }

ここまで実装し、最終的にServiceクラスでの前回からの差分は以下のようになりました。
運賃BasicFareクラスを参照していたFareインターフェイスおよびFareの実装クラスは、BasicFareWithTripTypeを参照するように修正が必要となりました。

f:id:rikupapa-shima:20200121054331p:plain

今のところ、ほぼ区分オブジェクトだけで要件を実現できているような形です。
以下のスクショはデバッグでブレークしたところですが、このように保持しているクラスの型で要件をそのまま表現できているかと思います。

f:id:rikupapa-shima:20200121054749p:plain

見ていて思ったのですが、value(金額)はyen(円)という値オブジェクトで表現してもよいかもですね。

前回からの差分コミット

github.com

感想

思ったより時間かかっています・・・第5回くらいまで必要になりそう・・・
牛歩で最後まで頑張りたいと思います。

続きは以下

rikupapa-shima.hatenablog.com

【DDD練習】「JR 新幹線 料金ルールを実装してみよう」にチャレンジ(その1)

前置き

『現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法』の著者の増田さんが、
ワークショップで使用する「JR 新幹線 料金ルールを実装してみよう」というサンプルコードをGitHubで公開されておりました。

今回、この課題に実装例を見ないでチャレンジしてみたいと思います。
※19/12/23に公開しましたが、バグがあったので修正して再度公開しました。

実装・前半戦開始

JR新幹線の料金の要件は以下のようになっています。

  • 料金 = 運賃 + 特急料金

  • 特急料金(super express surcharge)

  • 指定席(ひかり)
  • のぞみ割り増し (additional charge)
  • 自由席特急料金 (free seat)
  • 子供料金 (child)

自分はここから、ルールには以下3つの「区分」があると読み取りました。
自由席割引の要件から、「指定・自由」の「席種」、
のぞみ料金は割り増し、という要件から「ひかり・のぞみ」の「特急料金種」、
子供料金は割引き、という要件から「大人・子供」の「料金」区分を抽出しました。

料金

料金区分(FareType)
大人
子供

特急料金

特急料金種(SuperExpressSurchargeType) 席種(SeatType)
ひかり 指定
のぞみ 自由

料金区分(Fare)の実装

  • 料金 = 運賃 + 特急料金

の計算式から 「Fare(料金)」インターフェイス、「運賃(BasicFare)」クラス、「特急料金(SuperExpressSurcharge)」インターフェイスを実装しました。
「Fare」を実装した「大人料金(AdultFare)」「子供料金(ChildFare)」の具象クラスを実装しました。 「子供料金(ChildFare)」は、「運賃」と「特急料金」を割引するロジックが実装されています。

public class BasicFare
    {
        public readonly int value;

        public BasicFare(int value)
        {
            this.value = value;
        }

    }
public interface SuperExpressSurcharge
    {
        public int value { get; }
    }
public interface Fare
    {
        public BasicFare basicFare { get; }
        public SuperExpressSurcharge superExpressSurcharge { get; }

        int value();
    }
public class AdultFare : Fare
    {
        public BasicFare basicFare { get; }

        public SuperExpressSurcharge superExpressSurcharge { get; }

        public AdultFare(BasicFare basicFare, SuperExpressSurcharge superExpressSurcharge)
        {
            this.basicFare = basicFare;
            this.superExpressSurcharge = superExpressSurcharge;
        }

        public int value()
        {
            return  basicFare.value + superExpressSurcharge.value;
        }
    }
public class ChildFare : Fare
    {
        public BasicFare basicFare { get; }

        public SuperExpressSurcharge superExpressSurcharge { get; }

        private const double discountRate = 0.5;
        private const double roundDownNumber = 0.1;

        public ChildFare(BasicFare basicFare, SuperExpressSurcharge superExpressSurcharge)
        {
            this.basicFare = basicFare;
            this.superExpressSurcharge = superExpressSurcharge;
        }
        public int value()
        {
            return calculateChildFare(basicFare.value) + calculateChildFare(superExpressSurcharge.value);
        }

        public int calculateChildFare(int value)
        {
            return (int)Math.Floor((value * discountRate) * roundDownNumber) * 10;
        }

    }

席種(SeatType)の実装

続いて「席種(SeatType)」の実装です。
「席種(SeatType)」インターフェイスから「指定席(ReservedSeatCharge)」クラスと、「自由席(FreeSeatCharge)」クラスを実装。
「自由席」の料金は「ひかり」の料金から自由席割引額を引いた額になるので、 「自由席割引(FreeSeatDiscount)」クラスを実装。
「自由席(FreeSeat)」クラスは、「ひかり料金(HikariCharge)」クラスと「自由席割引(FreeSeatDiscount)」クラスを内包したクラスとして表しました。
「指定席」は「ひかり」の料金そのままなので、「ひかり料金(HikariCharge)」を内包しただけのクラスとなります。

class HikariCharge
    {
        public readonly int value;

        public HikariCharge(int value)
        {
            this.value = value;
        }
    }
class FreeSeatDiscount
    {
        public int value;

        public FreeSeatDiscount(int value)
        {
            this.value = value;
        }

    }
public interface SeatCharge
    {
        public int value { get; }
    }
public class ReservedSeatCharge :SeatCharge
    {
        private readonly HikariCharge hikariCharge;

        public ReservedSeatCharge(HikariCharge hikariCharge)
        {
            this.hikariCharge = hikariCharge;
        }

        public int value => hikariCharge.value;

    }
public class FreeSeatCharge : SeatCharge
    {
        private readonly HikariCharge hikariCharge;
        private readonly FreeSeatDiscount freeSeatDiscount;
        public FreeSeatCharge(HikariCharge hikariCharge, FreeSeatDiscount freeSeatDiscount)
        {
            this.hikariCharge = hikariCharge;
            this.freeSeatDiscount = freeSeatDiscount;
        }

        public int value => this.hikariCharge.value - freeSeatDiscount.value;

    }

特急料金種(SuperExpressSurchargeType)の実装

続いて、特急料金を表すSuperExpressSurchargeインターフェイスから、「ひかり(Hikari)」クラス、「のぞみ(Nozomi)」クラスを実装しました。
それぞれ、「席種(SeatCharge)」を内包させています。
「席種(SeatCharge)」が「ひかりの料金(HikariCharge)」を内包しているためです。
「のぞみ」の料金は、以下の要件から「ひかり」の「料金」に割増するモノなので、「のぞみ(Nozomi)」は「席種(SeatCharge)」クラスと、「のぞみ割増料金(NozomiAddtionalCharge)」クラスを内包したクラスとしました。

のぞみ割り増し (additional charge)

ひかりの特急料金に、以下の金額を加算する。

  • 新大阪まで 320 円
  • 姫路まで 530 円
public class Hikari : SuperExpressSurcharge
    {
        public int value => seatCharge.value;

        private readonly SeatCharge seatCharge;
        public Hikari(SeatCharge seat)
        {
            this.seatCharge = seat;
        }

    }
public class NozomiAdditionalCharge
    {
        public readonly int value;

        public NozomiAdditionalCharge(int value)
        {
            this.value = value;
        }
    }
public class Nozomi : SuperExpressSurcharge
    {
        private readonly SeatCharge seatCharge;
        private readonly NozomiAdditionalCharge nozomiAdditionalCharge;
        public Nozomi(SeatCharge seatCharge, NozomiAdditionalCharge nozomiAdditionalCharge)
        {
            this.seatCharge = seatCharge;
            this.nozomiAdditionalCharge = nozomiAdditionalCharge;
        }

        public int value => seatCharge.value + nozomiAdditionalCharge.value;

    }

「区分オブジェクト」を使いさらに分かりやすく

上記の例では計算方法ごとに関数を別にしていますが、実際にUIから「大人 or 子供」「のぞみ or ひかり」「指定席 or 自由席」といった条件から各クラスのインスタンスを作成するとなった場合、
ここまでの実装だけだとIf文を使って分岐することになってしまいそうです。
そこで各クラスの生成を「区分オブジェクト」にやらせることでコードをもっと分かりやすくしたいと思います。

public class SuperExpressSurchargeType
    {
        private readonly SeatCharge seatCharge;
        private readonly NozomiAdditionalCharge nozomiAdditionalCharge;

        public SuperExpressSurchargeType(SeatCharge seatCharge, NozomiAdditionalCharge nozomiAdditionalCharge)
        {
            this.seatCharge = seatCharge;
            this.nozomiAdditionalCharge = nozomiAdditionalCharge;
        }

        public  Hikari Hikari()
        {
            return new Hikari(seatCharge);
        }

        public  Nozomi Nozomi()
        {
            return new Nozomi(seatCharge, nozomiAdditionalCharge);
        }

        public SuperExpressSurcharge valueOf(string name)
        {
            var method = typeof(SuperExpressSurchargeType).GetMethod(name);
            return method.Invoke(this, null) as SuperExpressSurcharge;
        }

    }
class SeatType
    {
        private readonly HikariCharge hikariCharge;
        private readonly FreeSeatDiscount freeSeatDiscount;

        public SeatType(HikariCharge hikariCharge, FreeSeatDiscount freeSeatDiscount)
        {
            this.hikariCharge = hikariCharge;
            this.freeSeatDiscount = freeSeatDiscount;
        }

        public ReservedSeat Reserved()
        {
            return new ReservedSeat(hikariCharge);
        }

        public FreeSeat Free()
        {
            return new FreeSeat(new ReservedSeat(hikariCharge), freeSeatDiscount);
        }

        public Seat valueOf(string name)
        {
            var method = typeof(SeatType).GetMethod(name);
            return method.Invoke(this, null) as Seat;
        }

    }
public class FareType
    {
        private readonly BasicFare basicFare;
        private readonly SuperExpressSurcharge superExpressSurcharge;

        public FareType(BasicFare basicFare, SuperExpressSurcharge superExpressSurcharge)
        {
            this.basicFare = basicFare;
            this.superExpressSurcharge = superExpressSurcharge;
        }

        public AdultFare Adult()
        {
            return new AdultFare(basicFare, superExpressSurcharge);
        }

        public ChildFare Child()
        {
            return new ChildFare(basicFare, superExpressSurcharge);
        }

        public Fare valueOf(string name)
        {
            var method = typeof(FareType).GetMethod(name);
            return method.Invoke(this, null) as Fare;
        }

    }

ポイントはvalueOfメソッドです。
各区分オブジェクトクラスに、インスタンスを生成する関数を実装。
.GetMethodにより文字列で対象のクラスを指定し、インスタンスを生成している箇所です。
上記の手法を用いることで、IF文を使って分岐する必要がなくなります。
FareSuperExpressSurchargeSeatTypeを抽象化した実装もここで効果が出ていると感じます。

区分オブジェクト導入後の使用例

料金を計算するサービスクラス「FareSystemService」です。
各料金は出発地・目的地ごとに異なるため、これらはDBなどで定義されていると仮定し、「FaresRepository」というリポジトリークラスを実装。
出発地(Departure)、目的地(Destination)ごとの運賃、特急料金等を保持した「FareByrRoute」オブジェクトを、「FaresRepository」から取得する形にしました。
※自由席割引額(FreeSeatDiscount)もマスターテーブルのような場所で定義するのがよさそうですが、例では省略してハードコーディングしてしまっています。

public class FareSystemService
    {
        private readonly IFaresRepository _faresRepository;

        public FareSystemService(IFaresRepository faresRepository)
        {
           _faresRepository = faresRepository;
        }

        public int calculateFare(Departure departure, 
                                              Destination destination,  
                                              string superExpressName, 
                                              string seatName, 
                                              string fareName)
        {
            FareByRoute fareByRoute = _faresRepository.GetFareByRoute(departure, destination);

            SeatChargeType seatType = new SeatChargeType(fareByRoute.hikariCharge, new FreeSeatDiscount(530));
            SeatCharge seat = seatType.valueOf(seatName);

            SuperExpressSurchargeType superExpressType = new SuperExpressSurchargeType(seat, fareByRoute.nozomiAdditionalCharge);
            SuperExpressSurcharge superExpressSurcharge = superExpressType.valueOf(superExpressName);

            FareType fareType = new FareType(fareByRoute.basicFare, superExpressSurcharge);
            Fare fare = fareType.valueOf(fareName);

            return fare.value();
        }
public interface IFaresRepository
    {
        public FareByRoute GetFareByRoute(Departure departure, Destination destination);
    }
public class Departure
    {
        public readonly string value;

        public Departure(string value)
        {
            this.value = value;
        }
    }
public class Destination
    {
        public readonly string value;

        public Destination(string value)
        {
            this.value = value;
        }

    }
public class FareByRoute
    {
        public readonly Departure departure;
        public readonly Destination destination;
        public readonly BasicFare basicFare;
        public readonly HikariCharge hikariCharge;
        public readonly NozomiAdditionalCharge nozomiAdditionalCharge;

        public FareByRoute(Departure departure,
                                        Destination destination,
                                        BasicFare basicFare,
                                        HikariCharge hikariCharge,
                                        NozomiAdditionalCharge nozomiAdditionalCharge)

        {
            this.departure = departure;
            this.destination = destination;
            this.basicFare = basicFare;
            this.hikariCharge = hikariCharge;
            this.nozomiAdditionalCharge = nozomiAdditionalCharge;
        }

    }

まとめ

前半戦の実装は以上です。 残りは後日「その2」で公開できればと思います。
残る要件は

  • 割引 (discount)
  • 季節(season)による特急指定席料金の変動

です。 今回の練習の結果は以下GitHubリポジトリに公開しています。

github.com

追記:続きは以下

rikupapa-shima.hatenablog.com

2019年の振り返り

今日で仕事納めでした。
今年、取り組んできたことを振り返って来年の行動に繋げていきたいと思います。

新しく始めたこと

このブログ

アウトプットの一環として、技術系の情報と読書感想をまとめた本ブログをはじめました。
本記事を入れて29投稿できたので、だいたい月2ぐらいのペースで投稿し続けることができました。
スターを頂いたり、Qiita上の記事からリンクを貼っていただいたり、リアクションをもらえるようになってきたのがとても嬉しかったです。
来年も引き続きアップし続けられるように頑張りたいと思います。
以下、リンクしていただいた元記事。

qiita.com

qiita.com

社内勉強会

社内で有志を募って社内勉強会「ハンガーフライト」を開催し、現場で実践してきたことなどを発表しました。
自分含めて参加者は5名だけだったので少し寂しかったのですが、入社直後の方が即参加を表明してくださったり、来なかった方からも「あれ、どうだった?次は参加したいな」とリアクションをいただけたりしたので、 まずは継続的に開催していければと考えています。
「ハンガーフライト」については書籍「カイゼン・ジャーニー」からインスパイアを受けました。

www.asakara.net

資格取得(AZ-900)

初めてベンダー系の資格取得に挑戦しました。

rikupapa-shima.hatenablog.com

まだ現場で活かす機会はないのですが、引き続きAzureの知識はインプットしていけたらと思います。
来年は上位試験の「AZ-203」にチャレンジしたいですが、最近全然勉強時間を取れていないので、その辺りが課題です。

Udemy受講

有料のオンライン学習サイトも、今年はじめて体験しました。
DockerとJenkins、AZ-900の試験対策についてのレッスンを受講しました。
1つの動画が長くても15分程度なので、コツコツ勉強するのにも向いているなと思いました。
また利用してみたいです。

rikupapa-shima.hatenablog.com

rikupapa-shima.hatenablog.com

読書

今年読んだ本は以下の記事でまとめました。

rikupapa-shima.hatenablog.com

まとめ

2018年の10月に転職して今の会社に入社。
自社パッケージソフトの開発エンジニアからSESのエンジニアに転身。
2019年1月に参画した現場で引き続き働いています。
新しい環境、新しいシステムに触れることで自分の得意なこと、課題や今後やりたいことがまた見えてきたように思えます。
来年もより成長できるよう、引き続きインプット・アウトプットを続けていきたいと思います。