javascript - Dekoratör ara katman desenleri için Typescript tipleme


Node ara yazılımları (farklı çerçevelerde) hakkında düşünüyorum. Çoğu zaman ara yazılımlar bir istek veya yanıt nesnesine bir özellik ekler ve bu özellik daha sonra onu takip eden herhangi bir ara yazılım tarafından kullanılabilir hale gelir.

Bu modelle ilgili bir sorun, etkili yazma elde edememenizdir.

Bunu göstermek için, işte böyle bir çerçevenin oldukça basitleştirilmiş bir modeli. Bu durumda her şey eşzamanlıdır ve her ara katman bir İstek alır ve ayrıca bir İstek (potansiyel olarak değiştirilmiş) döndürmek zorundadır.

interface Req {
    path: string;
    method: string;
}

type Middleware = (request: Req) => Req;

class App {

    mws: Middleware[] = [];

    use(mw: Middleware) {
        this.mws.push(mw);
    }

    run(): Req {

        let request = {
            path: '/foo',
            method: 'POST'
        }
        for(const mw of this.mws) {
           request = mw(request);
        }

        return request;

    }

}

Uygulamayı göstermek için 2 ara katman tanımlıyorum. Birincisi Req'e yeni bir özellik ekliyor ve ikincisi bu özelliği kullanıyor:

const app = new App();

app.use( req => {
  req.foo = 'bar';
});

app.use( req => {
  console.log(req.foo);
}

Typescript oyun alanı bağlantısı

Bu, sunucu tarafı Javascript frameworklerinde çok yaygın bir kalıptır. Değiştirilmeden bırakılırsa, bu bir hata verir:

Property 'foo' does not exist on type 'Req'.

Buna standart çözüm, arayüz bildirimi birleştirmesini kullanmaktır:

interface Req {
    path: string;
    method: string;
}
interface Req {
    foo: string;
}

Bununla birlikte bunun dezavantajları var. Bu, türlerde küresel olarak değişiklik yapar ve aslında TypeScript'e yalan söylüyoruz. Req türünün .foo özelliği, ilk ara katman çağrılana kadar olmayacak.

Yani, bence bu Express, Koa, Curveball'da sadece 'işin böyle olduğu' durumu, ama yeni bir framework yazarsak farklı bir desenin mümkün olup olmadığını merak ediyorum.

Bu sahte kod örneğini uygulamanın mümkün olduğunu sanmıyorum:

app.use(mw1); // Uses default signature
app.use(mw2); // Type of `req` is now the return type of `mw1`.

Bunun zincirleme ile mümkün olabileceğini düşünüyorum:

app
  .use(mw1)
  .use(mw2);

Ama bunu tamamen nasıl kavradığımı anlamakta gerçekten zorlanıyorum. Üstelik bir next fonksiyonu varsa işler daha da karmaşık hale geliyor, ama bu sorunun amacı uğruna onu şimdilik dışarıda bırakalım.

Daha geniş bir çerçevede, benim sorum ... Typescript gözetilerek özel olarak yazıldığında mükemmel bir sunucu tarafı ara yazılım tabanlı çerçevenin nasıl görüneceği. Alternatif olarak, belki de daha etkili olan başka desenler vardır ve ben bunu görmeyecek kadar körüm.

Cevap #1

İlginç bir soru, tam bir cevabım yok ama buna yaklaşmanın bazı yolları olduğunu düşünüyorum.

İlk yaklaşım

Eğer middleware'i biraz daha genel tanımlarsanız, sadece bunun basit bir fonksiyon olduğunu söyleyebilirsiniz:

type Middleware<In, Out> = (input: In) => Out

Bir HTTP ortamında, giriş olarak istek verilerini içeren bir tür bağlam kullanırsınız:

interface InitialContext {
  method: 'GET' | 'POST'
  path: string
  query?: Record<string, string>
  headers?: Record<string, string>
}

İlk ara katman öğesinin InitialContext'i başka bir şeye dönüştürmesi gerektiğini söyleyebilirim. Esnek olmak istiyorsanız, bu herhangi bir şey olabilir. Daha pratik bir şekilde, muhtemelen InitialContext'i genişletmek istersiniz. Şimdilik, ara katmanın InitialContext'i herhangi bir şeye dönüştürebileceği ve bir sonraki ara katmanın bununla ilgilenmesi gerektiği fikrinde kalacağım.

Bir yaklaşım, bir dizi middleware fonksiyonunu alan ve son middleware parçasının sonucunu döndüren bir fonksiyon yaratmak olabilir. İhtiyacınız olduğu kadar middleware parçasını sadece ekleyebilirsiniz:

function requestHandler< 
  M1, 
  M2,
  M3,
  M4,
>(
  context: InitialContext,
  middleware1?: Middleware<InitialContext, M1>,
  middleware2?: Middleware<M1, M2>,
  middleware3?: Middleware<M2, M3>,
  middleware4?: Middleware<M3, M4>,
) {
  if (!middleware1) return context
  if (!middleware2) return middleware1(context)
  if (!middleware3) return middleware2(middleware1(context))
  if (!middleware4) return middleware3(middleware2(middleware1(context)))
  return middleware4(middleware3(middleware2(middleware1(context))))
}

Bu 4 ile sınırlıdır, ancak elbette bazı kod üretimi kullanılarak herhangi bir sonlu sayıya genişletilebilir. Şöyle çalışır:

const m1 = (s: InitialContext) => s.path
const m2 = (s: string) => Number(s)
const m3 = (s: number) => `${s}${s}`

const response = requestHandler({
  method: 'GET',
  path: '/blabla'
}, m1, m2, m3)

Bu yaklaşımın güzel tarafı, eğer ikinci middleware parçanız ilk parçanın çıktısıyla çalışmazsa size bunu bildirecek olmasıdır. Ve bu hata oldukça açık olacaktır.

Dezavantajı, her bir middleware argümanını ayrı ayrı yazmak zorunda olmanın yanı sıra, çıktı türünün unknown olmasıdır. Kaç adet middleware’in enjekte edileceğini önceden bilmiyorsanız, bu çözülmesi zor bir durumdur.


İkinci yaklaşım, 'azaltıcı' ara yazılım

Yani, bir sonraki soru şu olabilir: N parça ara yazılımın birleşik olarak 'toplam' türünü belirlemek mümkün mü? Önceki ara yazılımlarımıza geri bakacak olursak:

const m1 = (s: InitialContext) => s.path
const m2 = (s: string) => Number(s)
const m3 = (s: number) => `${s}${s}`

Bu 3 parçanın birleşik türünün Middleware<InitialContext, string> olduğunu söyleyebilirsiniz çünkü ilk fonksiyonun girişi InitialContext ve son fonksiyonun çıktısı string.

Onları birleştirmek, bu şekilde görünen bir fonksiyonla mümkün olurdu:

function reduceMiddleware<T extends Middleware<any, any>[]>(...middleware: T) {
  return middleware.reduce((a, b) => {
    return ((input: any) => b(a(input)))
  }) as any
}

Bu fonksiyona bir çıktı türü verebilir miyiz?

İki ara katman türünü infer ve koşullu bir tür kullanarak birleştirebiliriz:

type TwoIntoOne<T> = T extends [Middleware<infer A, any>, Middleware<any, infer B>] ? Middleware<A, B> : unknown

Bunu aşağıdakilerle test etme:

type Combined = TwoIntoOne<[Middleware<string, number>, Middleware<number, Date>]>

Bize çıktı olarak (input: string) => Date veya Middleware<string, Date> verecek.

Ancak, sadece 2 değil, N adet orta katman yazılımı birleştirmemiz gerekiyor. Bu, ya bir tür döngüye ihtiyacımız olduğu ya da türleri birlikte indirgememiz gerektiği anlamına gelir. İndirme, özyineleme kullanılarak gerçekleştirilebilir ve özyineleme, Typescript türlerinin desteklediği bir şeydir!

Yani, N tipi tek bir tipte birleştiren bir tür oluşturmak için özyinelemeyi kullanabiliriz:

type Combine<T> = T extends [Middleware<infer Ain, infer Aout>, Middleware<infer Aout, infer Bout>, ... infer Rest] ? 
  Combine<[Middleware<Ain, Bout>, ...Rest]> : T

Bu son tür bir diziye sarılacak, onu açmak için basit bir ayarlama yapabiliriz:

type Unwrap<T> = T extends [infer A] ? A : unknown

type Combine<T> = T extends [Middleware<infer Ain, infer Aout>, Middleware<infer Aout, infer Bout>, ... infer Rest] ? 
  Combine<[Middleware<Ain, Bout>, ...Rest]> : 
  Unwrap<T>

Bunu daha önce yaptığımız fonksiyonumuzda bu genel türü kullanabiliriz:

function reduceMiddleware<T extends Middleware<any, any>[]>(...middleware: T): Combine<T> {
  return middleware.reduce((a, b) => {
    return ((input: any) => b(a(input)))
  }) as any
}

Ancak, unutmayın ki bu işlev dahili olarak tür güvenli değildir. Bunu derleyici hatası olmadan çalıştırabilmek için sonucu herhangi bir tür ile geçersiz kılmamız gerekir. Ancak, bunu yapmak geri kalan kodumuzda tür güvenliği elde etmemizi sağlar:

const m1 = (s: InitialContext) => s.path
const m2 = (s: string) => Number(s)
const m3 = (s: number) => `${s}${s}`

const combined = reduceMiddleware(
  m1, m2, m3
) 

// Calculated type:
// const combined: Middleware<InitialContext, string>

Bu ara yazılımı, bazı bağlamları girdi olarak kullanarak doğrudan kullanabiliriz:

const output = combined({
  method: 'GET',
  path: '/blabla'
})

Ya da bu tek parça ara yazılımı bir istek/yanıt döngüsünü yönetmek için kullanan daha büyük bir uygulama kurabiliriz.

Dikkat edin, bu hesaplanmış tür, orta katmanın giriş/çıkışının eşleşip eşleşmediğini kontrol etse de, size gerçekten bir hata mesajı vermeyecektir. Size bilinmeyen türü verecek ve bu muhtemelen birleşik fonksiyonu kullanırken size bazı tür hataları verecektir. Bunu çözmenize yardımcı olacak gerçekten herhangi bir tür ipucu yok, bu yüzden kesinlikle geliştirilebilecek yollar vardır.


Yani sorunuza cevap vermek gerekirse: evet, ara yazılımlarla daha tip-güvenli bir şekilde çalışmak için seçenekler var. Bunu OOP yaklaşımıyla birleştirmek zor olabilir, ama N yerine tek bir ara yazılımla çalışmak bunu biraz daha yapılabilir kılıyor.

Çok basit bir yaklaşım, yapıcıda tek bir ara yazılım kabul etmek ve kullanıcının ara yazılım azaltıcısını ayrı bir adım olarak kullanmasını beklemek olabilir:

const combined = reduceMiddleware(
  m1, m2, m3
) 

class App<In, Out> {
  middleware: Middleware<In, Out>

  constructor(middleware: Middleware<In, Out>) {
    this.middleware = middleware
  }
}

const app = new App(combined)
// Calculated type:
// const app: App<InitialContext, string>

Cevap #2

Harika bir soru. Senin yerinde olsaydım, Request arayüzünü genişleten yeni bir arayüz oluştururdum.

interface MyReq extends Request {
  foo: string;
}

getMovies(req: MyReq, res: Response): void {
  req.foo = "Hi there setting foo";
  res.json(this.movieService.getMovies());
}

Bu, senin projen gibi büyük bir proje için daha sürdürülebilir. Açık olan küçük bir projede hızlıca test ettim ve gayet iyi çalışıyor, hem de derleyiciyi kandırmadan :). Şuna bir göz at:

Ne düşündüğünü bana bildir. Sağ ol.

İlgili makaleler