Русский
Русский
English
Статистика
Реклама

Инверсия контроля на голом TypeScript без боли

Здравствуйте, меня зовут Дмитрий Карловский и (сколько себя помню) я борюсь со своим окружением. Ведь оно такое костное, дубовое, и никогда не понимает, что я от него хочу. Но в какой-то момент я понял, что хватит это терпеть и надо что-то менять. Поэтому теперь не окружение диктует мне, что я могу и не могу делать, а я диктую окружению каким ему быть.

Как вы уже поняли, далее речь пойдёт про инверсию контроля через "контекст окружения". Многим этот подход уже знаком по "переменным окружения" - они задаются при запуске программы и обычно наследуются для всех программ, которые та запускает. Мы же применим эту концепцию для организации нашего кода на 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
К списку статей
Опубликовано: 10.02.2021 18:20:17
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Программирование

Совершенный код

Проектирование и рефакторинг

Тестирование веб-сервисов

Typescript

Ioc

Di

Testing

Ambient context

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru