Jokenizer - JavaScript Expression'larını parse edelim
25 Oct 2018JavaScript ile Linq yazı serimizin beşincisine hoş geldiniz.
- Linq gerçekte nedir?
- Expression’lar
- ExpressionVisitor sınıfı
- IQueryable ve IQueryProvider
- Jokenizer - JavaScript Expression’larını parse edelim (You are here)
- Jokenizer.Net - C# Expression’larını parse edelim
- DynamicQueryable - Dinamik sorgu oluşturalım
- Jinqu - JavaScript ile Linq
- Linquest ve Linquest.AspNetCore - Asp.Net Core ile cevap verelim
Bu yazıda jokenizer projesi ile JavaScript Expression’ları parse edeceğiz. Artık yazılar teorik anlatımlardan sert bir dönüş ile geliştirmeye yönelecek, kolları sıvayalım.
Expression nedir öğrendik, peki JavaScript için durum nedir? Zor. JavaScript tip sistemsiz (arkadaşlarla tipsiz de deriz) bir dil olduğu için Expression gibi bir sınıfa sahip değil, Reflection gibi bir yapı da yok haliyle. Şöyle ki:
// sum isminde bir Lambda tanımlıyoruz
const sum = (a, b) => a + b;
console.log(typeof sum); // "function"
Gördüğünüz gibi, çıktımız Func<int, int> gibi bir tip değil. JavaScript tip bilgisi istediğimizde bize her zaman string değer döner. Bu durum, tip güvenlikli bir dil ile tecrübesi olanların JavaScript ile ilgili en büyük yanılgılarıdır. Ben mümkün olan her projemde TypeScript kullanarak en azından geliştirme aşamasında tip güvenliğini sağlamaya çalışıyorum.
TypeScript Microsoft tarafından geliştirilen, başında Turbo Pascal ve C# dillerindeki emeğinden tanıdığımız (adını kopyalamadan yazamadığım) Anders Hejlsberg var. TypeScript bir JavaScript süperset’i. Yani JavaScript’i kapsıyor gibi düşünebilirsiniz. Kendisi de TypeScript ile yazılmış Compiler’ı bizim için JavaScript üretiyor. En çok karşılaştığım yanılgı TypeScript ile geliştirdiğimiz interface‘ler ve type‘lara çalışma zamanı ulaşabileceğimizi zannetmek. Üretilen koda bakarsanız karşılık olarak bir JavaScript çıktısı üretmediklerini görebilirsiniz, çünkü JavaScript’te interface ve type kavramı yok!
“I love Typescript. It is the best thing. It is very pragmatic and well done and approachable.” - Ryan Dahl, creator of Node.js
Bu durumda iş başa düştü, Expression yapısını kendimizin geliştirmesi gerekiyor.
Proje Yapısı
Acelesi olanlar projenin bitmiş halini https://github.com/umutozel/jokenizer adresinden inceleyebilir.
- .github GitHub özel şablonlarımız burada. Issue Template, Pull Request Template gibi
- .vscode Visual Studio Code ayarlarımız. Ben sadece Mocha Sidebar ayarlarımı tutuyorum. Bu eklentiyi de şiddetle tavsiye ederim.
- lib Tüm geliştirmeleri burada yapıyoruz.
- test Unit testlerimiz burada. Araç seti olarak Mocha, Chai, istanbul.js ve nyc kullanıyorum. Alışkanlık oldu artık.
- .travis.yml CI/CD için Travis kullanıyorum, herkese tavsiye ederim.
- mocha.opts Test çağrılarında uzun uzun TypeScript ayarları yapmamak için ayarları dosyadan okutuyorum.
Proje Şablonu
Yeni bir JavaScript projesine başlamak biraz uğraş gerektiriyor, projelerime başlangıç şablonu olması amacıyla geliştirdiğim npm-typescript-starter isimli yeoman oluşturucusunu kullanabilirsiniz.
./lib/types.ts
Yukarıda da dediğimiz gibi JavaScript ile Expression tipleri olmadığından biz tanımlıyoruz.
// İlk tanımımız ExpressionType, hatırlarsınız C# için 80+ adet vardı, desteklediğimiz tüm Expression türleri aşağıdakiler:
export const enum ExpressionType {
Literal = 'Literal', // 42, "Marvin"
Variable = 'Variable', // a + 42, buradaki "a" bir değişken
Unary = 'Unary', // -42, buradaki "-" bir Unary işlem
Group = 'Group', // (a, b), direk kullanımı yasak olsa da Lambda için destek vermemiz gerekiyor
Assign = 'Assign', // a: 42, direk kullanımı yasak olsa da obje oluştururken destek vermemiz gerekiyor
Object = 'Object', // { a: 42 }
Array = 'Array', // [42]
Member = 'Member', // a.b, buradaki "b" bir Member, a ise Variable
Indexer = 'Indexer', // a[4]
Func = 'Func', // (a, b) => a + b
Call = 'Call', // func(1, 2)
Ternary = 'Ternary', // a ? 1 : 0
Binary = 'Binary' // a + b, a < b
}
// Sadece Expression tipini tutan bir yapı
export interface Expression {
readonly type: ExpressionType;
}
// Tüm Expression'ları listelemeyeceğim. Bir kaç örnek görmemiz yeterli olacaktır
export interface LiteralExpression extends Expression {
readonly value // tipi "any", yani her türlü veri atanabilir
}
export interface VariableExpression extends Expression {
readonly name: string;
}
export interface UnaryExpression extends Expression {
readonly operator: string; // yukarıdaki örnek için "-"
readonly target: Expression; // yukarıdaki örnek için LiteralExpression ancak her tür Expression olabilir
}
// son örnek için en güzel tercih
export interface BinaryExpression extends Expression {
readonly operator: string; // yaptığımız işlem, +, -, <, > gibi.
readonly left: Expression; // işleme dahil olan sol Expression
readonly right: Expression; // işleme dahil olan sağ Expression, yine her tür Expression olabilir
}
...
./lib/tokenizer.ts
Bu dosyamızda Expression parse işlemini yapıyoruz. Kullanıma açtığımız tek metodumuz aşağıdaki gibi:
export function tokenize<T extends Expression = Expression>(exp: string): T
Bir string parametre alıyor ve Expression tipinde obje dönüyor. Hemen bir örnek ile görelim:
const lambda = tokenize<FuncExpression>('(a, b) => a < b');
lambda değeri aşağıdaki gibi çok basit bir obje:
{
"type": "Func",
"parameters": ["a", "b"],
"body": {
"type": "Binary",
"operator": "<",
"left": {
"type": "Variable",
"name": "a"
},
"right": {
"type": "Variable",
"name": "b"
}
}
}
Peki C#’ta olduğu gibi Expression
const lambda = (a, b) => a < b;
console.log(lambda.toString()); // "(a, b) => a < b"
const func = function (a, b) { return a < b; }
console.log(func.toString()) // "function (a, b) { return a < b; }"
Gördüğünüz gibi, JavaScript bize bu konuda yardımcı oluyor, toString çağrısı fonksiyonun kodunu dönüyor.
Amacımız çok basit olduğundan Ejderhalı Kitap gibi kaynakların yöntemlerinden farklı kendimce bir yol izledim, ancak mantık aynı.
Bu tür ifadeleri parse edebilmek için Scanner denilen bir yapıya ihtiyaç duyarız. Scanner bizim parametre aldığımız string üzerinde gezmemizi sağlar. Ben Scan işini JavaScript Closure ile kapsadığım bir fonksiyon içinde hallettim.
Bazı önemli yardımcı fonksiyonları listeleyelim.
move(count: number = 1)
Bu fonksiyon cursor değerini verilen parametre kadar ileri taşıyor. Cursor dediğimiz parametre gelen string üzerinde gezen Scanner yapımız.
get(s: string)
Cursor bulunduğu noktada aranan değer var ise Cursor aranan değer kadar ilerler ve true döner, yok ise false döner.
skip()
Boşluk, tab gibi bir anlam ifade etmeyen karakterleri (Whitespace) atlar.
eq(idx: number, target: string)
Gönderilen indeks değerinden aranan değer olup olmadığını döner, Cursor ilerletmez.
to(c: string)
Boşluklar atlandığında gelinen karakter dizisi gönderilen değere eşit değil ise hata fırlatır, eşit ise Cursor’ı ilerletir. Örnek:
// parse etmek istediğimiz ifade
str = "function (a, b) { return a + b };"
// praser "function" ifadesi ile karşılaşınca fonksiyon parse etme moduna girer
// to('{') çağrısı ile "function" ifadesinden sonra parantez arar, bulamaz ise hata fırlatır
isSpace()
Cursor’ın gösterdiği karakterin Whitespace olup olmadığını döner
isNumber()
Cursor’ın gösterdiği karakterin bir sayı olup olmadığını döner
isVariableStart()
Cursor’ın gösterdiği karakterin geçerli bir JavaScript değişkeni ilk karakteri olup olmadığını döner. JavaScript değişkenleri sayılar ile başlayamaz ancak devam edebilir, a123 gibi.
stillVariable()
Cursor’ın gösterdiği karakterin geçerli bir JavaScript değişken karakteri olup olmadığını döner.
fixPrecedence(left: Expression, leftOp: string, right: BinaryExpression)
Tüm dillerde yapılan Binary işlemler için öncelik sırası vardır. Aşağıdaki gibi bir işlemi yorumlayalım:
4 + 2*5 - 1
eğer öncelik düzeltmesi yapmazsak bu ifade aşağıdaki gibi yorulanır:
(((4 + 2)*5) - 1) // 29
düzeltme sonucu ise aşağıdaki gibi olur:
(4 + (2*5) - 1) // 13 - olması gerektiği gibi
Yardımcı fonksiyonlarımız bu kadardı. Şimdi esas işi yapan metodumuza bir bakalım, basitliği sizi şaşırtacak diye umuyorum.
function getExp(): Expression {
skip(); // boşlukları atla
// sırayla tek başına kullanılabilen Expression'ları dene
let e: Expression = tryLiteral()
|| tryVariable()
|| tryUnary()
|| tryGroup()
|| tryObject()
|| tryArray();
// eğer bir Expression bulamazsan dön
if (!e) return e;
// Expression bilinen (true, false, null gibi) bir değer mi?
e = tryKnown(e) || e;
let r: Expression;
do {
// boşlukları atla
skip();
r = e;
// birleşik kullanılan Expression'ları dene
e = tryMember(e)
|| tryIndexer(e)
|| tryFunc(e)
|| tryCall(e)
|| tryTernary(e)
|| tryBinary(e);
} while (e)
return r;
}
Algoritmamız deneme üzerine kurulu. Önce tek başına kullanılabilen Expression’ları deniyoruz, sonra bunu birleşik Expression oluşturmada kullanıyoruz. Örneğin:
a + 42
İfadesini parse ederken ilk kısımda a değeri VariableExpression olarak parse edilir. Sonra sırayla Member, Indexer, Func, Call, Ternary olarak yorumlanmaya çalışılır ve bunların hepsi başarısız döner. En son Binary olarak yorumlamak istediğinde “+” operatorü ile karşılaştığı için recursive çağırılan getExp metodumuz sağ ifadeyi 42 LiteralExpression‘ı olarak parse eder.
Peki bu deneme metodları nasıl? Bir örnek görelim:
function tryObject() {
// "{" ile başlaması gerekiyor, yoksa başarısız
if (!get('{')) return null;
// Obje içinde yapılan tüm atamaları burada saklayacğız
const es: AssignExpression[] = [];
do {
skip();
// sıradaki Expression'ı oku
const ve = getExp() as VariableExpression;
// eğer bir değişken ya da üye erişimi değil ise hata fırlat
// { a: 42, b.c } desteklediğimiz yapı
if (ve.type !== ExpressionType.Variable && ve.type !== ExpressionType.Member)
throw new Error(`Invalid assignment at ${idx}`);
skip();
// atama karakteri var ise
if (get(':')) {
// artık değişken olmak zorunda
// { b.c: 42 } geçersiz bir ifade
if (ve.type !== ExpressionType.Variable)
throw new Error(`Invalid assignment at ${idx}`);
skip();
es.push(assignExp(ve.name, getExp()));
}
else {
es.push(assignExp(ve.name, ve));
}
// her atama arasında "," olmalı, eğer yok ise işlem tamamlandı demektir
} while (get(','));
// son karakterimiz "}" bulunamaz ise hata fırlatılır
to('}');
// objemiz hazır
return objectExp(es);
}
Diğer Expression’lar da buna benzer bir yapıda, kodu okuyarak çok rahat anlayabilirsiniz.
./lib/ExpressionVisitor.ts
Expression’larımız C#’ta olduğu gibi bir ağaç şeklinde. Dolayısıyla parse ettiğimiz bir Expression’ı çalıştırabilmek için bizim de bu ağacı recursive bir şekilde gezecek bir yapıya ihtiyacımız var, o da ExpressionVisitor. Nasıl çalıştığına yine ufak bir kod üzerinden bakalım:
protected visit(exp: Expression, scopes: any[]) {
switch (exp.type) {
// desteklenen her Expression'ı ziyaret ediyoruz
case ExpressionType.Array: return this.visitArray(<any>exp, scopes);
case ExpressionType.Binary: return this.visitBinary(<any>exp, scopes);
case ExpressionType.Call: return this.visitCall(<any>exp, scopes);
case ExpressionType.Indexer: return this.visitIndexer(<any>exp, scopes);
case ExpressionType.Literal: return this.visitLiteral(<any>exp, scopes);
case ExpressionType.Member: return this.visitMember(<any>exp, scopes);
case ExpressionType.Object: return this.visitObject(<any>exp, scopes);
case ExpressionType.Ternary: return this.visitTernary(<any>exp, scopes);
case ExpressionType.Unary: return this.visitUnary(<any>exp, scopes);
case ExpressionType.Variable: return this.visitVariable(<any>exp, scopes);
case ExpressionType.Group:
const gexp = exp as GroupExpression;
if (gexp.expressions.length == 1)
return this.visit(gexp.expressions[0], scopes);
// kendi başına Assign kullanımı desteklenmiyor. sadece Object Expression içinde olabilir
case ExpressionType.Assign:
// kendi başına Func kullanımı desteklenmiyor, sadece Call için parametre olabilir
case ExpressionType.Func:
throw new Error(`Invalid ${exp.type} expression usage`);
default: throw new Error(`Unsupported ExpressionType ${exp.type}`);
}
}
// örnek olarak Call expression'a bakalım
protected visitCall(exp: CallExpression, scopes: any[]) {
// fonksiyonu temsil eden Expression'ı ziyaret ediyor ve değerini buluyoruz
const func = this.visit(exp.callee, scopes);
// argümanları ziyaret ediyor ve değerlerini buluyoruz
// Func kullanımına sadece Call içinde izin veriyoruz
// örnek: items.sum(i => i) -> buradaki i => i bir Func ve sadece Call içinde parametre olabilir
const args = exp.args.map(a => a.type === ExpressionType.Func ? this.visitFunc(<any>a, scopes) : this.visit(a, scopes));
// foknsiyonumuzu çağırıyoruz
return func(...args);
}
...
Yazmadığımız diğer Expression tipleri için yazılması gereken kodu tahmin edebilirsiniz ya da kodları inceleyebilirsiniz. Burada önemli bir nokta scopes parametreleri, Expression parse ettiğimizde Variable değerlerini dışarıdan beslememiz gerekiyor.
a + 42
Yukarıdaki gibi bir Expression’ı parse ettiğimizde a değişkeninin değerini bilmiyoruz ve bu Expression’ı çalıştırmak istediğimizde bu değeri bizim sağlamamız gerekiyor. ExpressionVisitor Expression çalıştırma için ihtiyacımız olan altyapıyı sağladı, ancak çalıştırma işleminde bu sınıfı direk kullanmayacağız.
./lib/evaluator.ts
Tek bir fonksiyonu kullanıma sunduğumuz bu dosyanın içeriği çok basit.
export function evaluate(exp: Expression | string, ...scopes: any[]) {
return new ExpressionVisitor().process(typeof exp === 'string' ? tokenize(exp) : exp, scopes);
}
Çalıştırmak istediğimiz Expression’ı string ya da parse edilmiş Expression olarak gönderiyoruz ve gerekli scope‘ları sağlıyoruz ve bize sonucu dönyor. Hemen bir örnek yapalım:
// değerlendirilmesini istediğimiz karşılaştırma ifademiz
// ikinci parametrede verdiğimiz obje "v1" ve "v2" değişkenlerini aramak için kullanılacak
const value = evaluate('v1 >= v2', { v1: 5, v2: 3 });
console.log(value); // true
// fonksiyon parse ediyoruz, fonksiyon gerekli parametreleri aldığı için scope ihtiyacı duymuyoruz
const func = evaluate('function(a, b) { return a < b; }');
console.log(func(5, 3)); // false
// istersek fonksiyon yerine lambda yazımını da kullanabiliriz
const lambda = evaluate(tokenize('(a, b) => a < b'));
console.log(func(5, 3)); // false
Sonunda JavaScript’e Expression ve ExpressionVisitor desteği ekledik. Böylece string kodumuzu temsil eden Expression’ları elde edebileceğimiz gibi scope sağlayarak çalıştırabiliyoruz da.
Altıncı yazıda Jokenizer.Net projesi ile C# ifadelerini parse edeceğiz, görüşmek üzere.
“A good programmer is someone who always looks both ways before crossing a one-way street.” ― Doug Linder