Laravel: Объекты-Значения повсюду

Источник: «Value Objects Everywhere»
В этой статье я хотел бы поговорить об объектах-значениях. Если вы не знаете, что это такое, вот краткое введение.

Или можете прочитать подробную статью об основах.

Объект-Значение — элементарный класс, содержащий в основном (но не только) скалярные данные. Итак, этот класс-оболочка объединяющий связанную информацию. Вот пример:

class Percent
{
public readonly ?float $value;
public readonly string $formatted;

public function __construct(float $value)
{
$this->value = $value;

if ($value === null) {
$this->formatted = '';
} else {
$this->formatted = number_format($value * 100, 2) . '%';
}
}

public static function from(?float $value): self
{
return new self($value);
}
}

Этот класс представляет процентное значение. Этот простой класс даёт три преимущества:

Важное примечание: бизнес-логика или расчёт не являются частью объекта-значения. Единственное исключение, которое я делаю, это базовое форматирование.

Вот и всё. Это объект-значение. Это объект содержащий некоторые значения. В исходном определении объекта-значения говорится ещё о двух вещах:

Моделирование Данных

Чтобы по настоящему понять объекты-значения, мы реализуем очень простое финансовое приложение. Что-то вроде Seekingalpha, Morningstar, Atom Finance, или Hypercharts. Если вы не знакомы с этими приложениями, вот упрощённое описание:

В примере приложения я реализую только некоторые метрики и буду хранить только отчёты о прибылях и убытках (без балансовых отчётов или финансовых потоков). Этого более чем достаточно для иллюстрации использования объектов-значений.

База данных будет выглядеть так:

База данных

Как видите, это довольно просто. Это пример строки из таблицы companies:

idtickernameprice_per_sharemarket_cap
1AAPLApple Inc.149642420000
2MSFTMicrosoft Inc.273242040000

price_per_share — текущая цена акций компании. Это значение хранится в центах, поэтому 14964 равно $149.64. Это обычная практика позволяющая избежать ошибок округления.

market_cap — текущая рыночная капитализация компании (price_per_share * количество акций). Значение хранится в миллионах, поэтому 2420000 это $2,420,000,000,000 или $2,420B или $2.42T. Хранение огромных финансовых чисел в миллионах (а в некоторых случаях в тысячах) также является обычной практикой в финансовых приложениях.

Теперь давайте рассмотрим таблицу income_statements:

company_idyearrevenuegross_profit
12022386017167231
12021246807167231

Каждая статья в отчёте о прибылях и убытках имеет свой собственный столбец, такой как revenue или gross_profit. Одна строка в таблице описывает год для данной компании. И, как вы, наверное, догадались, эти числа тоже исчисляются миллионами. Таким образом 386017 означает $386,017,000,000 или $386B для краткости.

Если вам интересно, зачем хранить эти числа миллионами, ответ довольно прост: их легче читать. Просто посмотрите, например страницу Apple на Seekingalpha:

Страница Apple на Seekingalpha

Таблица metrics очень похожа на income_statements:

company_idyeargross_marginprofit_marginpe_ratio
120220.430.262432
220220.680.342851

Каждая метрика имеет свой собственный столбец, и каждая строка представляет год для данной компании. Большинство метрик представляют собой процентные значения, хранящиеся в виде десятичных дробей. pe_ratio означает price/earnings ratio соотношение цена/прибыль. Если акции компании торгуются по цене 260 долларов, а её прибыли составляет 20 долларов на акцию, то соотношение P/E — 13,00. Это десятичное число, хранящееся как целое число.

Возможно вы спросите: Почему бы не назвать его price_per_earnings_ratio? Это хороший вопрос! На мой взгляд, наша цель, как разработчиков программного обеспечения должна состоять в том, чтобы писать код, максимально приближённый к бизнес-языку. Но в финансовом секторе никто не называет это отношением цены к прибыли. Это просто соотношение PE. Так что, на самом деле, это правильное название, на мой взгляд.

API

Мы хотим реализовать три API.

GET /companies/{company}

Возвращает базовый профиль компании:

{
"data": {
"id": 1,
"ticker": "AAPL",
"name": "Apple Inc.",
"price_per_share": {
"cent": 14964,
"dollar": 149.64,
"formatted": "$149.64"
},
"market_cap": {
"millions": 2420000,
"formatted": "2.42T"
}
}
}

Также возвращает данные о цене и рыночной капитализации ы удобочитаемом формате.

GET /companies/{company}/income-statements

Возвращает отчёт о прибыли и убытках, сгруппированные по статьям и годам:

{
"data": {
"years": [
2022,
2021
],
"revenue": {
"2022": {
"value": 386017000000,
"millions": 386017,
"formatted": "386,017"
},
"2021": {
"value": 246807000000,
"millions": 246807,
"formatted": "246,807"
}
},
"eps": {
"2022": {
"cent": 620,
"dollar": 6.2,
"formatted": "$6.20"
},
"2021": {
"cent": 620,
"dollar": 6.2,
"formatted": "$6.20"
}
}
}
}

Правильная структура данных будет сильно зависеть от конкретного варианта использования и пользовательского интерфейса. Эта структура довольно хороша для макета, похожего на Seekingalpha (скриншот приводился ранее). Этот API также форматирует значения.

GET /companies/{company}/metrics

Это API возвращающий метрики:

{
"data": {
"years": [
2022
],
"gross_margin": {
"2022": {
"value": 0.43,
"formatted": "43.00%",
"top_line": {
"value": 386017000000,
"millions": 386017,
"formatted": "386,017"
},
"bottom_line": {
"value": 167231000000,
"millions": 167231,
"formatted": "167,231"
}
}
},
"pe_ratio": {
"2022": {
"value": "24.32"
}
}
}
}

Каждое поле также содержит информацию top_line и bottom_line. В случае gross_margin top_line — доход, а bottom_line — валовая прибыль.

Идентификация Объектов-Значений

Теперь, когда мы познакомились с базой данных и API, пришло время определить объекты-значения. Если вы внимательно посмотрите на JSON, сможете определить пять различных типов значений:

Теперь взгляните на эти имена объектов-значений! Мы работаем над финансовым приложением, и у нас будут такие классы, как Millions, Margin или MarketCap.

Это так кодовая база, которая имеет смысл даже спустя пять лет.

Реализация Объектов-Значений

Price

Price кажется наиболее очевидным, поэтому давайте начнём с него. Сам класс довольно прост:

class Price
{
public readonly int $cent;
public readonly float $dollar;
public readonly string $formatted;

public function __construct(int $cent)
{
$this->cent = $cent;

$this->dollar = $cent / 100;

$this->formatted = '$' . number_format($this->dollar, 2);
}

public static function from(int $cent): self
{
return new self($cent);
}
}

Несколько важных вещей:

Этот объект можно использовать следующим образом:

$company = Company::first();

$price = Price::from($company->price_per_share);

Следующий вопрос: как использовать этот объект? Есть два пути:

Приведение в модель

У нас есть как минимум два возможных решения для приведения атрибутов к объектам-значениям в моделях.

Использование аксессоров атрибутов:

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;

class Company extends Model
{
public function pricePerShare(): Attribute
{
return Attribute::make(
get: fn (int $value) => Price::from($value)
);
}
}

Это отличное решение, которое работает в 95% случаев. Однако сейчас мы находимся в оставшихся 5%, потому что у нас есть более 10 атрибутов, которые мы хотим привести. В модели IncomeStatement нужно привести почти каждый атрибут к экземпляру Millions. Просто представьте, как будет выглядеть класс с аксессорами атрибутов:

namespace App\Models;

class IncomeStatement extends Model
{
public function pricePerShare(): Attribute
{
return Attribute::make(
get: fn (int $value) => Millions::from($value)
);
}

/* подобный код здесь */
public function costOfRevenue(): Attribute {}

/* подобный код здесь */
public function grossProfit(): Attribute {}

/* подобный код здесь */
public function operatingExpenses(): Attribute {}

// ещё 8 методов здесь
}

Так что в нашем случае использование аксессоров не оптимально. К счастью, у Laravel есть решение! Можно извлечь логику приведения в отдельный класс Cast:

namespace App\Models\Casts;

use App\ValueObjects\Price;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class PriceCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes)
{
return Price::from($value);
}

public function set($model, $key, $value, $attributes)
{
return $value;
}
}

Этот класс делает то же самое, что и аксессор атрибута:

Последним шагом является фактическое использование этого Cast внутри модели Company:

class Company extends Model
{
use HasFactory;

protected $guarded = [];

protected $casts = [
'price_per_share' => PriceCast::class,
];
}

Теперь мы можем использовать его так:

$company = Company::first();

// После выполнения PriceCast::get()
$pricePerShare = $company->price_per_share;

// $127.89
echo $pricePerShare->formatted;

// 127.89
echo $pricePerShare->dollar;

// 12789
echo $pricePerShare->cent;

Где мы будем это использовать? Например, в ресурсах:

namespace App\Http\Resources;

class CompanyResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'ticker' => $this->ticker,
'name' => $this->name,
'price_per_share' => $this->price_per_share,
'market_cap' => $this->market_cap,
];
}
}

Поскольку эти объекты значений содержат только общедоступные свойства, Laravel автоматически преобразует их в массивы при преобразовании ответа в JSON. Таким образом, этот ресурс приведёт к следующему ответу JSON:

{
"data": {
"id": 1,
"ticker": "AAPL",
"name": "Apple Inc.",
"price_per_share": {
"cent": 14964,
"dollar": 149.64,
"formatted": "$149.64"
},
"market_cap": {
"millions": 2420000,
"formatted": "2.42T"
}
}
}

Так мы можем приводить значения в моделях Eloquent. Но мы можем пропустить эту настройку и привести значение непосредственно к ресурсам.

Приведение в ресурсы

Это намного проще, чем предыдущая часть. Всё, что нам нужно сделать, это создать объект Price внутри ресурса:

namespace App\Http\Resources;

class CompanyResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'ticker' => $this->ticker,
'name' => $this->name,
'price_per_share' => Price::from($this->price_per_share),
'market_cap' => MarketCap::from($this->market_cap),
];
}
}

Теперь у модели Company нет приведения (cast), поэтому мы просто создаём экземпляр объекта Price и MarketCap из целочисленных значений.

Как выбрать между ними?

Поэтому я собираюсь использовать Eloquent Cast для управления приведением.

MarketCap

Как обсуждалось ранее, рыночная капитализация (market cap) немного более уникальна, поэтому у неё есть собственный объект стоимости. Нам нужна эта структура данных:

"market_cap": {
"millions": 2420000,
"formatted": "2.42T"
}

Свойство formatted будет меняться в зависимости от рыночной капитализации компании, например:

"market_cap": {
"millions": 204100,
"formatted": "204.1B"
}

И последний случай:

"market_cap": {
"millions": 172,
"formatted": "172M"
}

Как класс, это выглядит так:

namespace App\ValueObjects;

class MarketCap
{
public readonly int $millions;
public readonly string $formatted;

public function __construct(int $millions)
{
$this->millions = $millions;

// Trillions
if ($millions >= 1_000_000) {
$this->formatted = number_format($this->millions / 1_000_000, 2) . 'T';
}

// Billions
if ($millions < 1_000_000 && $millions >= 1_000) {
$this->formatted = number_format($this->millions / 1_000, 1) . 'B';
}

// Millions
if ($millions < 1_000) {
$this->formatted = number_format($this->millions) . 'M';
}
}

public static function from(int $millions): self
{
return new self($millions);
}
}

Нужно проверить значение $millions, выполнить соответствующее деление и использовать правильный суффикс.

Приведение почти идентично PriceCast:

namespace App\Models\Casts;

class MarketCapCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes)
{
return MarketCap::from($value);
}

public function set($model, $key, $value, $attributes)
{
return $value;
}
}

Опять, не нужно ничего делать в set. Последнее, что нужно использовать это приведение:

namespace App\Models;

class Company extends Model
{
use HasFactory;

protected $guarded = [];

protected $casts = [
'price_per_share' => PriceCast::class,
'market_cap' => MarketCapCast::class,
];
}

Я не буду перечислять другие классы Cast, потому что они одинаковые. Вы можете посмотреть их в репозитории.

Millions

Этот объект-значение довольно прост:

namespace App\ValueObjects;

class Millions
{
public readonly int $value;
public readonly int $millions;
public readonly string $formatted;

public function __construct(int $millions)
{
$this->value = $millions * 1_000_000;

$this->millions = $millions;

$this->formatted = number_format($this->millions, 0, ',');
}

public static function from(int $millions): self
{
return new self($millions);
}
}

У него три свойства:

Как JSON:

"revenue": {
"2022": {
"value": 192557000000,
"millions": 192557,
"formatted": "192,557"
}
}

Millions использует модель IncomeStatement, и именно здесь мы выигрываем от использования Eloquent Casts:

namespace App\Models;

class IncomeStatement extends Model
{
use HasFactory;

protected $guarded = [];

protected $casts = [
'revenue' => MillionsCast::class,
'cost_of_revenue' => MillionsCast::class,
'gross_profit' => MillionsCast::class,
'operating_expenses' => MillionsCast::class,
'operating_profit' => MillionsCast::class,
'interest_expense' => MillionsCast::class,
'income_tax_expense' => MillionsCast::class,
'net_income' => MillionsCast::class,
'eps' => PriceCast::class,
];
}

Margin

Это также довольно простой класс:

namespace App\ValueObjects;

class Margin
{
public readonly float $value;
public readonly string $formatted;
public readonly Millions $top_line;
public readonly Millions $bottom_line;

public function __construct(
float $value,
Millions $topLine,
Millions $bottomLine
) {
$this->value = $value;

$this->top_line = $topLine;

$this->bottom_line = $bottomLine;

$this->formatted = number_format($value * 100, 2) . '%';
}

public static function make(
float $value,
Millions $topLine,
Millions $bottomLine
): self {
return new self($value, $topLine, $bottomLine);
}
}

Он демонстрирует одну замечательную особенность объектов-значений: они могут быть вложены друг в друга. В этом примере атрибуты top_line и bottom_line экземпляры Millions. Эти цифры описывают как рассчитывается прибыль. Например, валовая прибыль рассчитывается путём деления дохода (top_line) на валовую прибыль (bottom_line). В JSON это будет выглядеть так:

"gross_margin": {
"2022": {
"value": 0.68,
"formatted": "68.00%",
"top_line": {
"value": 192557000000,
"millions": 192557,
"formatted": "192,557"
},
"bottom_line": {
"value": 132345000000,
"millions": 132345,
"formatted": "132,345"
}
}
}

Однако если вы посмотрите на метод make вы увидите, что мы ожидаем два дополнительных параметра: $topLine и $bottomLine. Значит, мы можем использовать этот объект следующим образом:

$company = Company::first();

$incomeStatement = $company->income_statements()
->where('year', 2022)
->first();

$metrics = $company->metrics()->where('year', 2022)->first();

$grossMargin = Margin::make(
$metrics->gross_margin,
$incomeStatement->revenue,
$incomeStatement->gross_profit,
);

Поскольку мы используем Eloquent Casts, нам нужен revenue и gross_profit (в этом конкретном примере) в класс MarginCast. Мы можем сделать что-то вроде этого:

namespace App\Models\Casts;

class MarginCast implements CastsAttributes
{
/**
* @param Metric $model
*/

public function get($model, $key, $value, $attributes)
{
$incomeStatement = $model
->company
->income_statements()
->where('year', $model->year)
->first();

[$topLine, $bottomLine] = $model->getTopAndBottomLine(
$incomeStatement,
$key,
);

return Margin::make($value, $topLine, $bottomLine);
}

public function set($model, $key, $value, $attributes)
{
return $value;
}
}

Как видите, модель в данном случае, модель Metric (именно здесь будет использоваться приведение), поэтому мы можем запросить соответствующий отчёт о прибылях и убытках за тот же год. После этого нам нужен метод, который может возвращать top_line и bottom_line для конкретной метрики.

namespace App\Models;

class Metric extends Model
{
public function getTopAndBottomLine(
IncomeStatement $incomeStatement,
string $metricName
): array {
return match ($metricName) {
'gross_margin' => [
$incomeStatement->revenue,
$incomeStatement->gross_profit
],
'operating_margin' => [
$incomeStatement->revenue,
$incomeStatement->operating_profit
],
'profit_margin' => [
$incomeStatement->revenue,
$incomeStatement->net_income
],
};
}
}

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

Вы можете сказать: Подождите минутку… Мы запрашиваем компании и отчёты о прибылях и убытках в MarginCast по каждому атрибуту??? Это примерно 10 дополнительных запросов каждый раз, когда мы запрашиваем просу метрику, верно?

Хороший вопрос! Ответ: нет. Эти приведения выполняются лениво. Это означает, что функция get будет выполняться только тогда, когда вы действительно получите доступ к данному свойству. Но, как вы уже догадались, мы будем обращаться к каждому свойству ресурса, поэтому будет выполнено множество дополнительных запросов. Что можно с этим сделать?

PeRatio

После всех сложностей давайте посмотрим последний и, наверное, самый простой Объект-Значение:

namespace App\ValueObjects;

class PeRatio
{
public readonly string $value;

public function __construct(int $peRatio)
{
$this->value = number_format($peRatio / 100, 2);
}

public static function from(int $peRatio): self
{
return new self($peRatio);
}
}

Этот класс также можно использовать для покрытия других чисел типа ratio, но прямо сейчас PE является единственным, поэтому я решил назвать класс PeRatio.

Сводный отчёт о прибылях и убытках

Теперь, когда у нас есть все объекты значений, мы можем перейти к ресурсу. Наша цель состоит в том, чтобы получить сводное представление о прибылях и убытках компании. Это структура JSON:

"data": {
"years": [
2022,
2021
],
"items": {
"revenue": {
"2022": {
"value": 386017000000,
"millions": 386017,
"formatted": "386,017"
},
"2021": {
"value": 246807000000,
"millions": 246807,
"formatted": "246,807"
}
}
}
}

Есть два способа решить эту проблему:

По динамичным я подразумеваю что-то вроде этого:

class IncomeStatementResource
{
public $preserveKeys = true;

public function toArray(Request $request)
{
$data = [];

// $this is a Company
$data['years'] = $this->income_statements->pluck('year');

foreach ($this->income_statements as $incomeStatement) {
foreach ($incomeStatement->getAttributes() as $attribute => $value) {
$notRelated = [
'id', 'year', 'company_id', 'created_at', 'updated_at',
];

if (in_array($attribute, $notRelated)) {
continue;
}

Arr::set(
$data,
"items.{$attribute}.{$incomeStatement->year}",
$incomeStatement->{$attribute}
);
}
}

return $data;
}
}

Трудно разобраться в происходящем? Это не ваша вина! Это моё. Этот код — отстой. Я имею в виду, что это очень динамично, поэтому оно будет работать независимо от того, есть ли у вас четыре столбца в income_statements или 15. Но кроме этого он кажется немного напуганным. Более того, он не имеет настоящей формы, поэтому очень странно помещать его в ресурс.

Не поймите меня неправильно, иногда вам просто нужны такие решения. Но отчёт о прибылях и убытках имеет конечное количество статей (столбцов), и это не то, что подлежит изменению.

Давайте посмотрим более декларативный подход:

namespace App\Http\Resources;

class IncomeStatementsSummaryResource extends JsonResource
{
public $preserveKeys = true;

public function toArray($request)
{
// $this is a Collection<IncomeStatement>
$years = $this->pluck('year');

return [
'years' => $years,
'items' => [
'revenue' => $this->getItem('revenue', $years),
'cost_of_revenue' => $this->getItem('cost_of_revenue', $years),
'gross_profit' => $this->getItem('gross_profit', $years),
'operating_expenses' => $this->getItem('operating_expenses', $years),
'operating_profit' => $this->getItem('operating_profit', $years),
'interest_expense' => $this->getItem('interest_expense', $years),
'income_tax_expense' => $this->getItem('income_tax_expense', $years),
'net_income' => $this->getItem('net_income', $years),
'eps' => $this->getItem('eps', $years ),
]
];
}

/**
* @return array<int, int>
*/

private function getItem(string $name, Collection $years): array
{
$data = [];

foreach ($years as $year) {
$data[$year] = $this
->where('year', $year)
->first()
->{$name};
}

return $data;
}
}

Вы видите разницу? Он прост для понимания, читабелен, имеет реальную форму и вообще не требует дополнительного кода. Однако он называется IncomeStatementsSummaryResource, и на, то есть причина. Для этого ресурса требуется Collection<IncomeStatement>, поэтому его можно использовать следующим образом:

namespace App\Http\Controllers;

class IncomeStatementController extends Controller
{
public function index(Company $company)
{
return IncomeStatementsSummaryResource::make($company->income_statements);
}
}

Мы передаём все отчёты о прибылях и убытках компании как Collection. Таким образом, эта строка в ресурсе не будет выполнять дополнительные запросы:

// $this->where() is a Collection method
$data[$year] = $this->where('year', $year)->first()->{$name};

Последняя важная вещь — вот эта строка:

public $preserveKeys = true;

Без этого Laravel переопределит ключи массива и преобразует года в стандартные индексы массива с отсчётом от нуля:

"data": {
"years": [
2022,
2021
],
"items": {
"revenue": [
{
"value": 386017000000,
"millions": 386017,
"formatted": "386,017"
},
{
"value": 246807000000,
"millions": 246807,
"formatted": "246,807"
}
]
}
}

Как видите, объект с указанием года становится массивом JSON. Вот почему я использовал свойство $preserveKeys из родительского класса JsonResource.

Сводка Метрик

API сводки метрик в основном такой же, как сводный отчёт о прибылях и убытках. Поэтому не удивительно, что Ресурс выглядит почти так же:

namespace App\Http\Resources;

class MetricsSummaryResource extends JsonResource
{
public $preserveKeys = true;

public function toArray($request)
{
$years = $this->pluck('year');

return [
'years' => $years,
'items' => [
'gross_margin' => $this->getItem('gross_margin', $years),
'operating_margin' => $this->getItem('operating_margin', $years),
'profit_margin' => $this->getItem('profit_margin', $years),
'pe_ratio' => $this->getItem('pe_ratio', $years),
]
];
}

private function getItem(string $name, Collection $years): array
{
$data = [];

foreach ($years as $year) {
$data[$year] = $this
->where('year', $year)
->first()
->{$name};
}

return $data;
}
}

Можно использовать так:

namespace App\Http\Controllers;

class MetricController extends Controller
{
public function index(Company $company)
{
return MetricsSummaryResource::make($company->metrics);
}
}

Заключение

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

На мой взгляд, объекты-значения потрясающие! Я использую их почти в каждом проекте, независимо от того, он новый, DDD или не DDD, легаси или нет. Их довольно легко начать использовать, и у вас будет очень высокоуровневая декларативная кодовая база.

Мне часто задают вопрос: Что ещё можно выразить в виде объекта-значения? Почти всё, назову несколько примеров:

Дополнительные материалы

Предыдущая Статья

Тестирование конечных точек JSON:API с PestPHP

Следующая Статья

Laravel: Что такое Pipeline / Пайплайн