Шаблон Строитель в Scala 3
По определению шаблон Строитель (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))