scalabook

Форк
0
/
builder-pattern.md 
232 строки · 9.1 Кб

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

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

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

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

.

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

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

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

  • host
    - строка от 4 символов
  • port
    - число от 1024 до 65535
  • user
    - непустая строка, содержащая только буквы и цифры
  • password
    - строка, содержащая только буквы и цифры, длиной от 8 до 16 символов

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

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

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

  • В сопутствующем объекте ConnectionConfigBuilder
    определен конфиг по умолчанию
  • Метод builder()
    создает конструктор из конфига по умолчанию
  • Сопутствующий объект приватный для того, чтобы доступ к конфигу по умолчанию осуществлялся только через builder()
  • В конструкторе ConnectionConfigBuilder
    объявлены методы with...
    для установки каждого параметра
  • Метод build()
    отдает итоговый конфиг
  • У ConnectionConfigBuilder
    приватные параметры конструктора в первую очередь для того, чтобы пользователь "видел" только методы установки значений with...
    , а итоговое состояние конфига получал только через build()
  • Метод copy
    недоступен за пределами case class ConnectionConfigBuilder
    из-за приватного конструктора, что опять же позволяет задавать параметры только через with...

Таким образом построить 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

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.