По определению шаблон Строитель (Builder) отделяет конструирование сложного объекта от его представления, что особенно хорошо, когда нужно провести валидацию параметров перед получением итогового экземпляра. Особенно удобно комбинировать шаблон Строитель с уточняющими типами.
Рассмотрим использование Строителя на Scala версии 3.2.2.
Представим, что у нас есть конфиг:
final case class ConnectionConfig ( host: String, port: Int, user: String, password: String )
И мы хотим предоставить пользователю возможность создавать конфиг различными способами, но при этом валидировать значения перед формированием итогового результата. Например, по следующим правилам:
host- строка от 4 символовport- число от 1024 до 65535user- непустая строка, содержащая только буквы и цифры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))
