Шаблон Строитель в Scala 3

Статья вышла на хабре

По определению шаблон Строитель (Builder) отделяет конструирование сложного объекта от его представления, что особенно хорошо, когда нужно провести валидацию параметров перед получением итогового экземпляра. Особенно удобно комбинировать шаблон Строитель с уточняющими типами.

Рассмотрим использование Строителя на Scala версии "3.2.2".

Представим, что у нас есть конфиг:

final case class ConnectionConfig (
    host: String,
    port: Int,
    user: String,
    password: String
)

И мы хотим предоставить пользователю возможность создавать конфиг различными способами, но при этом валидировать значения перед формированием итогового результата. Например, по следующим правилам:

Весьма удобно использовать для этого уточняющие типы:

final case class ConnectionConfig(
    host: Host,
    port: Port,
    user: User,
    password: Password
)

object ConnectionConfig:
  opaque type Host     = String :| MinLength[4]
  opaque type Port     = Int :| GreaterEqual[1024] & LessEqual[65535]
  opaque type User     = String :| Alphanumeric & MinLength[1]
  opaque type Password = String :| Alphanumeric & MinLength[8] & MaxLength[16]

У case class-а ConnectionConfig конструктор можно определить как приватный, чтобы ограничить создание конфига только по шаблону.

Тогда сам шаблон Строитель можно определить вот так:


object ConnectionConfig:
  ...

  def builder(): ConnectionConfigBuilder = ConnectionConfigBuilder()

  final case class ConnectionConfigBuilder private (
      private val host: String,
      private val port: Int,
      private val user: String,
      private val password: String
  ):
    def withHost(host: String): ConnectionConfigBuilder =
      copy(host = host)

    def withPort(port: Int): ConnectionConfigBuilder =
      copy(port = port)

    def withUser(user: String): ConnectionConfigBuilder =
      copy(user = user)

    def withPassword(password: String): ConnectionConfigBuilder =
      copy(password = password)

    def build(): ConnectionConfig =
      new ConnectionConfig(
        host = ???,
        port = ???,
        user = ???,
        password = ???
      )
  end ConnectionConfigBuilder

  private object ConnectionConfigBuilder:
    def apply(): ConnectionConfigBuilder =
      new ConnectionConfigBuilder(
        host = "localhost",
        port = 8080,
        user = "root",
        password = "root"
      )
  end ConnectionConfigBuilder
end ConnectionConfig

Здесь есть несколько моментов, на которые стоит обратить внимание:

Таким образом построить ConnectionConfig по шаблону можно так:

ConnectionConfig
  .builder()
  .withHost("localhost")
  .withPort(9090)
  .withUser("user")
  .withPassword("12345")
  .build()

Другие способы создания ConnectionConfig недоступны, как нет и других методов работы с ConnectionConfigBuilder.

А как же валидация параметров?

Как уже упоминалось в статье об уточняющих типах желательно сохранять все ошибки валидации, а затем либо выдавать корректный результат, либо - список ошибок. Поэтому пойдем по тому же пути, что и в указанной статье.

Из типа Host выделим тип, описывающий уточняющие правила и, если необходимо, переопределим сообщение об ошибке:

opaque type HostRule = MinLength[4] DescribedAs "Invalid host"
opaque type Host     = String :| HostRule

В конструкторе ConnectionConfigBuilder заменим тип параметра host на ValidatedNel[String, Host] и переименуем его в validatedHost. Тогда метод установки значения можно заменить на:

def withHost(host: String): ConnectionConfigBuilder =
  copy(validatedHost = host.refineValidatedNel[HostRule])

Проделаем точно такие же изменения для остальных параметров.

Builder примет следующий вид:

final case class ConnectionConfigBuilder private (
    private val validatedHost: ValidatedNel[String, Host],
    private val validatedPort: ValidatedNel[String, Port],
    private val validatedUser: ValidatedNel[String, User],
    private val validatedPassword: ValidatedNel[String, Password]
)

Конфиг по умолчанию станет равным:

def apply(): ConnectionConfigBuilder =
  new ConnectionConfigBuilder(
    validatedHost = Validated.Valid("localhost"),
    validatedPort = Validated.Valid(8080),
    validatedUser = Validated.Valid("root"),
    validatedPassword = Validated.Valid("password")
  )

При этом в конфиге по умолчанию также можно указать и невалидные значения, если для заданного параметра значение по умолчанию отсутствует и требуется его установка пользователем.

Например:

validatedPassword = Validated.Invalid(NonEmptyList.one("Invalid password"))

Или:

validatedPassword = "".refineValidatedNel[PasswordRule]

Остается только определить метод build():

def build(): ValidatedNel[String, ConnectionConfig] =
  (
    validatedHost,
    validatedPort,
    validatedUser,
    validatedPassword
  ).mapN(ConnectionConfig.apply)

В результате использования паттерна Строитель будет выведены либо список всех ошибок:

val invalidConfig = ConnectionConfig
  .builder()
  .withHost("")
  .withPort(-1)
  .withUser("")
  .withPassword("")
  .build()

// Invalid(NonEmptyList(Invalid host, Invalid port, Invalid user, Invalid password))

Либо корректный конфиг:

val validConfig = ConnectionConfig
  .builder()
  .withHost("127.0.0.1")
  .withPort(8081)
  .withUser("user")
  .withPassword("password")
  .build()

// Valid(ConnectionConfig(127.0.0.1,8081,user,password))

Полный пример доступен на Scastie