[Программирование, Совершенный код, Проектирование и рефакторинг, Тестирование веб-сервисов, TypeScript] Инверсия контроля на голом TypeScript без боли
Автор
Сообщение
news_bot ®
Стаж: 6 лет 8 месяцев
Сообщений: 27286
Здравствуйте, меня зовут Дмитрий Карловский и (сколько себя помню) я борюсь со своим окружением. Ведь оно такое костное, дубовое, и никогда не понимает, что я от него хочу. Но в какой-то момент я понял, что хватит это терпеть и надо что-то менять. Поэтому теперь не окружение диктует мне, что я могу и не могу делать, а я диктую окружению каким ему быть.Как вы уже поняли, далее речь пойдёт про инверсию контроля через "контекст окружения". Многим этот подход уже знаком по "переменным окружения" - они задаются при запуске программы и обычно наследуются для всех программ, которые та запускает. Мы же применим эту концепцию для организации нашего кода на TypeScript.
Итак, что мы хотим получить:
- Функции при вызове наследуют контекст у вызвавшей их функции
- Объекты наследуют контекст у их объекта-владельца
- В системе может существовать одновременно множество вариантов контекста
- Изменения в производных контекстах не влияют на исходный
- Изменения в исходном контексте отражаются на производных
- Тесты могут запускаться в изолированном и не изолированном контексте
- Минимум бойлерплейта
- Максимум перфоманса
- Тайпчек всего этого
Давайте, объявим какую-нибудь глобальную константу в глобальном контексте окружения:
namespace $ {
export let $user_name: string = 'Anonymous'
}
Теперь добавим в глобальный контекст какую-нибудь функцию. Например, функцию записи в лог:
namespace $ {
export function $log( this: $, ... params: unknown[] ) {
console.log( ... params )
}
}
Обратите внимание на типизированный this. Он гарантирует, что данную функцию нельзя будет вызвать напрямую так:
$log( 123 ) // Error
Вызвать её можно исключительно из какого-либо контекста окружения. Например, из глобального контекста:
$.$log( 123 ) // OK
Однако, пока что $ у нас - это неймспейс, а не тип. Давайте для простоты создадим и одноимённый тип:
namespace $ {
export type $ = typeof $
}
А раз мы получаем контекст окружения в this, то можем его использовать и для вызова других функций. Например, напишем функцию, которая приветствует пользователя по имени:
namespace $ {
export function $hello( this: $ ) {
this.$log( 'Hello ' + this.$user_name )
}
}
Таким образом мы естественным образом можем передавать контекст окружения по стеку вызовов функций на любую глубину. Но в этом мало смысла, пока контекст всего один. Поэтому добавим фабрику контекстов, которая берёт текущий контекст, наследует от него производный, патчит его переданными ей переопределениями и возвращает:
namespace $ {
export function $ambient(
this: $,
over: Partial< $ >,
): $ {
const context = Object.create( this )
for( const field of Object.getOwnPropertyNames( over ) ) {
const descr = Object.getOwnPropertyDescriptor( over, field )!
Object.defineProperty( context, field, descr )
}
return context
}
}
Object.create мы используем, чтобы создание производного контекста было быстрым, даже если он разрастётся. А вот Object.assign не используется, чтобы в переопределениях можно было задавать не только значения, но и геттеры, и сеттеры. Эта фабрика нам ещё пригодится, а пока давайте напишем наш первый тест:
namespace $.test {
export function $hello_greets_anon_by_default( this: $ ) {
const logs = [] as unknown[]
this.$log = logs.push.bind( logs )
this.$hello()
this.$assert( logs, [ 'Hello Anonymous' ] )
}
}
Тест принимает на вход контекст кружения, и первым делом он настраивает контекст под себя - патчит функцию $log, чтобы та сохраняла все логи в локальную переменную. Потом мы запускаем тестируемую функцию в нашем контексте, и наконец, проверяем, что в логи вывелось именно то, что мы ожидаем. Напишем простейшую функцию для асертов:
namespace $ {
export function $assert< Value >( a: Value, b: Value ) {
const sa = JSON.stringify( a, null, '\t' )
const sb = JSON.stringify( b, null, '\t' )
if( sa === sb ) return
throw new Error( `Not equal\n${sa}\n${sb}`)
}
}
Обратите внимание, что мы поместили тест в отдельный неймспейс $.$test. Это нужно для того, чтобы взять и запустить все тесты скопом:
namespace $ {
export async function $test_run( this: $ ) {
for( const test of Object.values( this.$test ) ) {
await test.call( this.$isolated() )
}
this.$log( 'All tests passed' )
}
}
Каждый тест запускается не в оригинальном контексте, а в изолированном. Это такой производный контекст, где замоканы все сущности, что общаются со внешним миром (сетевые запросы, время, консоль, файлы, рандом и т.д.). Исходно, она просто создаёт новый производный контекст:
namespace $ {
export function $isolated( this: $ ) {
return this.$ambient({})
}
}
Но наша функция $log пишет в реальную консоль, что не очень-то похоже на изоляцию. Поэтому, рядом с ней мы положим переопределение $isolated, которое переопределяет в контексте $log на реализацию без сайд эффектов:
namespace $ {
const base = $isolated
$.$isolated = function( this: $ ) {
return base.call( this ).$ambient({
$log: ()=> {}
})
}
}
Теперь мы уверены, что любые тесты по умолчанию не будут ничего писать в реальную консоль даже если мы не переопределим в них функцию $log.Давайте так же напишем и тест, что наши переопределения контекстов работают исправно:
namespace $.test {
export function $hello_greets_overrided_name( this: $ ) {
const logs = [] as unknown[]
this.$log = logs.push.bind( logs )
const context = this.$ambient({ $user_name: 'Jin' })
context.$hello()
this.$hello()
this.$assert( logs, [ 'Hello Jin', 'Hello Anonymous' ] )
}
}
Теперь перейдём к объектам. Для простоты работы с контекстами введём простой базовый класс для всех наших классов:
namespace $ {
export class $thing {
constructor( private _$: $ ) {}
get $() { return this._$ }
}
}
Тут мы инъектируем контекст окружения через конструктор. И добавляем геттер, позволяющий получать зависимости через контекст минимальным объёмом кода. Геттер нам нужен для того, чтобы можно было переопределять контекст в потомках не потеряв переопределения предков. Для примера, создадим карточку, которая приветствует пользователя, добавляя к имени восклицательный знак:
namespace $ {
export class $hello_card extends $thing {
get $() {
return super.$.$ambient({
$user_name: super.$.$user_name + '!'
})
}
get user_name() {
return this.$.$user_name
}
set user_name( next: string ) {
this.$.$user_name = next
}
run() {
this.$.$hello()
}
}
}
Напишем тест, чтобы удостовериться, что это действительно работает:
namespace $.test {
export function $hello_card_greets_anon_with_suffix( this: $ ) {
const logs = [] as unknown[]
this.$log = logs.push.bind( logs )
const card = new $hello_card( this )
card.run()
this.$assert( logs, [ 'Hello Anonymous!' ] )
}
}
Супер, теперь посмотрим, как выстраивать дерево объектов. Тут основная идея в том, что у каждого объекта есть владелец, который контролирует его время жизни и контекст окружения. Давайте создадим страничку, которая владеет нашей карточкой:
namespace $ {
export class $hello_page extends $thing {
get $() {
return super.$.$ambient({
$user_name: 'Jin'
})
}
@ $mem
get Card() {
return new this.$.$hello_card( this.$ )
}
get user_name() {
return this.Card.user_name
}
set user_name( next: string ) {
this.Card.user_name = next
}
run() {
this.Card.run()
}
}
}
Выносим создание владеимого объекта в отдельное свойство. Инъектим в него текущий контекст. И мемоизируем результат с помощью $mem. Возьмём самую простую его реализацию без реактивности:
namespace $ {
export function $mem(
host: object,
field: string,
descr: PropertyDescriptor,
) {
const store = new WeakMap< object, any >()
return {
... descr,
get() {
let val = store.get( this )
if( val !== undefined ) return val
val = descr.get!.call( this )
store.set( this, val )
return val
}
}
}
}
WeakMap нужен чтобы такое свойство можно было безопасно переопределять в подклассах, не ломая мемоизацию. Что ж, проверим, что имя пользователя действительно поменялось, а восклицательный знак не потерялся:
namespace $.test {
export function $hello_page_greets_overrided_name_with_suffix( this: $ ) {
const logs = [] as unknown[]
this.$log = logs.push.bind( logs )
const page = new $hello_page( this )
page.run()
this.$assert( logs, [ 'Hello Jin!' ] )
}
}
Отлично, работает. Теперь усложняем задачу - переопределяем класс для поддерева объектов. Создадим новый класс для карточки, который позволяет переопределять имя пользователя, сохраняя его в локальное хранилище.
namespace $ {
export class $app_card extends $.$hello_card {
get $() {
const form = this
return super.$.$ambient({
get $user_name() { return form.user_name },
set $user_name( next: string ) { form.user_name = next }
})
}
get user_name() {
return super.$.$storage_local.getItem( 'user_name' ) ?? super.$.$user_name
}
set user_name( next: string ) {
super.$.$storage_local.setItem( 'user_name', next )
}
}
}
Само локальное хранилище - это просто алиас для нативного объекта:
namespace $ {
export const $storage_local: Storage = window.localStorage
}
А раз оно у нас персистится, то нужно нужно рядом положить и мок, который сохраняет данные не в нативное хранилище, а во временный объект:
namespace $ {
const base = $isolated
$.$isolated = function( this: $ ) {
const state = new Map< string, string >()
return base.call( this ).$ambient({
$storage_local: {
getItem( key: string ){ return state.get( key ) ?? null },
setItem( key: string, val: string ) { state.set( key, val ) },
removeItem( key: string ) { state.delete( key ) },
key( index: number ) { return [ ... state.keys() ][ index ] ?? null },
get length() { return state.size },
clear() { state.clear() },
}
})
}
}
Теперь мы, наконец, можем реализовать наше приложение, которое подменяет в контексте исходный класс $hello_card на свой $app_card, и всё поддерево объектов будет инстанцировать именно его.
namespace $ {
export class $app extends $thing {
get $() {
return super.$.$ambient({
$hello_card: $app_card,
})
}
@ $mem
get Hello() {
return new this.$.$hello_page( this.$ )
}
get user_name() {
return this.Hello.user_name
}
rename() {
this.Hello.user_name = 'John'
}
}
}
Напишем пользовательский сценарий, где мы сначала сохраняем состояние локального хранилища и стираем его, потом проверяем, что имя соответствует дефолтному поведению, при переименовывании оно меняется и сохраняется даже при перезапуске приложения, а в конце подчищаем за собой, убеждаемся, что очистка сработала, и восстанавливаем состояние локального хранилища:
namespace $.$test {
export function $changable_user_name_in_object_tree( this: $ ) {
const name_old = this.$storage_local.getItem( 'user_name' )
this.$storage_local.removeItem( 'user_name' )
const app1 = new $app( this )
this.$assert( app1.user_name, 'Jin!' )
app1.rename()
this.$assert( app1.user_name, 'John' )
const app2 = new $app( this )
this.$assert( app2.user_name, 'John' )
this.$storage_local.removeItem( 'user_name' )
this.$assert( app2.user_name, 'Jin!' )
if( name_old !== null ) {
this.$storage_local.setItem( 'user_name', name_old )
}
}
}
Тесты неспроста написаны в таком стиле, чтобы их можно было запускать не только на чистом состоянии, но и на грязном. Это позволит нам гонять одни и те же тесты с разными уровнями изоляции.Запустим тесты в полностью изолированном контексте, чтобы проверить, что реализовали всю нашу логику правильно:
namespace $ {
await $.$test_run()
}
А теперь запустим их же, но без изоляции, чтобы проверить, что наша логика корректно работает со внешними системами. Для этого просто создадим контекст, где $isolated возвращает производный контекст, но без каких-либо переопределений:
$.$ambient({
$isolated: function(){ return $.$ambient({}) }
}).$test_run()
}
Этот второй вариант, если запустить в Сафари в порно режиме, выдаст исключение, так как в нём нельзя обращаться к localStorage, а этот кейс в нашей нативной реализации $storage_local не предусмотрен.Аналогично, запуская тесты в разных контекстах, можно проверять работу вашего кода с публичным сервером, с тестовым сервером, и вообще без сервера.Подробнее об этом подходе к тестированию можно ознакомиться в моём выступлении на TechLeadConf: Фрактальное Тестирование.Разобранный же тут подход к инверсии контроля активно применяется во фреймворке $mol, что даёт ему потрясающую гибкость и простоту кода. Но это уже совсем другая история…Если вас смущает общий неймспейс и отcутствие import/export, то можете ознакомиться с этим анализом: Fully Qualified Names vs Imports. А если смущает именование через подчёркивание, то с этим: PascalCase vs camelCase vs kebab case vs snake_case.TypeScript песочница со всем кодом из статьи.
===========
Источник:
habr.com
===========
Похожие новости:
- [Программирование, Совершенный код, Терминология IT, Управление разработкой] Культ лучших практик (перевод)
- [Программирование, Управление разработкой, Управление продуктом, Микросервисы] Суровая правда о разработчиках и разработке
- [Open source, Программирование, Dart, Flutter] Как создать кастомный плагин для Dart-анализатора
- [Программирование, Разработка мобильных приложений, Разработка под Android, Kotlin] Как заблокировать приложение с помощью runBlocking
- [Программирование] Заметки о codestyle
- [Компьютерная анимация, CGI (графика), История IT, IT-компании] Disney закрывает студию Blue Sky
- [Assembler, Программирование микроконтроллеров, Разработка под Arduino, Электроника для начинающих] Управление LCD и OLED дисплеями на AVR-ассемблере
- [Алгоритмы, Программирование микроконтроллеров, Производство и разработка электроники] Бинарный поиск в микроконтроллере
- [Разработка веб-сайтов, Программирование, Сетевые технологии, Программирование микроконтроллеров] Разрабатываем web-site для микроконтроллера
- [Программирование, DevOps, Kubernetes] Круглый стол «Нужно ли разработчику знать Kubernetes» 11 февраля
Теги для поиска: #_programmirovanie (Программирование), #_sovershennyj_kod (Совершенный код), #_proektirovanie_i_refaktoring (Проектирование и рефакторинг), #_testirovanie_vebservisov (Тестирование веб-сервисов), #_typescript, #_ioc, #_di, #_testing, #_ambient_context, #_programmirovanie (
Программирование
), #_sovershennyj_kod (
Совершенный код
), #_proektirovanie_i_refaktoring (
Проектирование и рефакторинг
), #_testirovanie_vebservisov (
Тестирование веб-сервисов
), #_typescript
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 01-Ноя 07:22
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 8 месяцев |
|
Здравствуйте, меня зовут Дмитрий Карловский и (сколько себя помню) я борюсь со своим окружением. Ведь оно такое костное, дубовое, и никогда не понимает, что я от него хочу. Но в какой-то момент я понял, что хватит это терпеть и надо что-то менять. Поэтому теперь не окружение диктует мне, что я могу и не могу делать, а я диктую окружению каким ему быть.Как вы уже поняли, далее речь пойдёт про инверсию контроля через "контекст окружения". Многим этот подход уже знаком по "переменным окружения" - они задаются при запуске программы и обычно наследуются для всех программ, которые та запускает. Мы же применим эту концепцию для организации нашего кода на TypeScript. Итак, что мы хотим получить:
namespace $ {
export let $user_name: string = 'Anonymous' } namespace $ {
export function $log( this: $, ... params: unknown[] ) { console.log( ... params ) } } $log( 123 ) // Error
$.$log( 123 ) // OK
namespace $ {
export type $ = typeof $ } namespace $ {
export function $hello( this: $ ) { this.$log( 'Hello ' + this.$user_name ) } } namespace $ {
export function $ambient( this: $, over: Partial< $ >, ): $ { const context = Object.create( this ) for( const field of Object.getOwnPropertyNames( over ) ) { const descr = Object.getOwnPropertyDescriptor( over, field )! Object.defineProperty( context, field, descr ) } return context } } namespace $.test {
export function $hello_greets_anon_by_default( this: $ ) { const logs = [] as unknown[] this.$log = logs.push.bind( logs ) this.$hello() this.$assert( logs, [ 'Hello Anonymous' ] ) } } namespace $ {
export function $assert< Value >( a: Value, b: Value ) { const sa = JSON.stringify( a, null, '\t' ) const sb = JSON.stringify( b, null, '\t' ) if( sa === sb ) return throw new Error( `Not equal\n${sa}\n${sb}`) } } namespace $ {
export async function $test_run( this: $ ) { for( const test of Object.values( this.$test ) ) { await test.call( this.$isolated() ) } this.$log( 'All tests passed' ) } } namespace $ {
export function $isolated( this: $ ) { return this.$ambient({}) } } namespace $ {
const base = $isolated $.$isolated = function( this: $ ) { return base.call( this ).$ambient({ $log: ()=> {} }) } } namespace $.test {
export function $hello_greets_overrided_name( this: $ ) { const logs = [] as unknown[] this.$log = logs.push.bind( logs ) const context = this.$ambient({ $user_name: 'Jin' }) context.$hello() this.$hello() this.$assert( logs, [ 'Hello Jin', 'Hello Anonymous' ] ) } } namespace $ {
export class $thing { constructor( private _$: $ ) {} get $() { return this._$ } } } namespace $ {
export class $hello_card extends $thing { get $() { return super.$.$ambient({ $user_name: super.$.$user_name + '!' }) } get user_name() { return this.$.$user_name } set user_name( next: string ) { this.$.$user_name = next } run() { this.$.$hello() } } } namespace $.test {
export function $hello_card_greets_anon_with_suffix( this: $ ) { const logs = [] as unknown[] this.$log = logs.push.bind( logs ) const card = new $hello_card( this ) card.run() this.$assert( logs, [ 'Hello Anonymous!' ] ) } } namespace $ {
export class $hello_page extends $thing { get $() { return super.$.$ambient({ $user_name: 'Jin' }) } @ $mem get Card() { return new this.$.$hello_card( this.$ ) } get user_name() { return this.Card.user_name } set user_name( next: string ) { this.Card.user_name = next } run() { this.Card.run() } } } namespace $ {
export function $mem( host: object, field: string, descr: PropertyDescriptor, ) { const store = new WeakMap< object, any >() return { ... descr, get() { let val = store.get( this ) if( val !== undefined ) return val val = descr.get!.call( this ) store.set( this, val ) return val } } } } namespace $.test {
export function $hello_page_greets_overrided_name_with_suffix( this: $ ) { const logs = [] as unknown[] this.$log = logs.push.bind( logs ) const page = new $hello_page( this ) page.run() this.$assert( logs, [ 'Hello Jin!' ] ) } } namespace $ {
export class $app_card extends $.$hello_card { get $() { const form = this return super.$.$ambient({ get $user_name() { return form.user_name }, set $user_name( next: string ) { form.user_name = next } }) } get user_name() { return super.$.$storage_local.getItem( 'user_name' ) ?? super.$.$user_name } set user_name( next: string ) { super.$.$storage_local.setItem( 'user_name', next ) } } } namespace $ {
export const $storage_local: Storage = window.localStorage } namespace $ {
const base = $isolated $.$isolated = function( this: $ ) { const state = new Map< string, string >() return base.call( this ).$ambient({ $storage_local: { getItem( key: string ){ return state.get( key ) ?? null }, setItem( key: string, val: string ) { state.set( key, val ) }, removeItem( key: string ) { state.delete( key ) }, key( index: number ) { return [ ... state.keys() ][ index ] ?? null }, get length() { return state.size }, clear() { state.clear() }, } }) } } namespace $ {
export class $app extends $thing { get $() { return super.$.$ambient({ $hello_card: $app_card, }) } @ $mem get Hello() { return new this.$.$hello_page( this.$ ) } get user_name() { return this.Hello.user_name } rename() { this.Hello.user_name = 'John' } } } namespace $.$test {
export function $changable_user_name_in_object_tree( this: $ ) { const name_old = this.$storage_local.getItem( 'user_name' ) this.$storage_local.removeItem( 'user_name' ) const app1 = new $app( this ) this.$assert( app1.user_name, 'Jin!' ) app1.rename() this.$assert( app1.user_name, 'John' ) const app2 = new $app( this ) this.$assert( app2.user_name, 'John' ) this.$storage_local.removeItem( 'user_name' ) this.$assert( app2.user_name, 'Jin!' ) if( name_old !== null ) { this.$storage_local.setItem( 'user_name', name_old ) } } } namespace $ {
await $.$test_run() } $.$ambient({
$isolated: function(){ return $.$ambient({}) } }).$test_run() } =========== Источник: habr.com =========== Похожие новости:
Программирование ), #_sovershennyj_kod ( Совершенный код ), #_proektirovanie_i_refaktoring ( Проектирование и рефакторинг ), #_testirovanie_vebservisov ( Тестирование веб-сервисов ), #_typescript |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 01-Ноя 07:22
Часовой пояс: UTC + 5