Proaktif Hata Yakalama: Bölüm II

ANTLR ile Veri Tabanı ve Kod Arasındaki Uyumsuzlukları Önceden Tespit Etme

Rifat Aricanli
VakıfBank Teknoloji
6 min readOct 22, 2021

--

Photo by Markus Spiske on Unsplash

Bir önceki yazıda birbirinden bağımsız derlenen ve üretim ortamına taşınan modüller arasındaki olası uyumsuzluklardan kaynaklanan hataların önceden nasıl tespit edilebileceğine değinmiştik. Bu bölümde de önceki konunun devamı olarak modüller ve veri tabanı nesneleri arasındaki uyumsuzlukların sebep olacağı üretim ortamı hatalarının ANTLR’dan faydalanarak nasıl taşıma öncesinde tespit edilebileceği üzerinde duracağız. Bilindiği gibi, veri tabanındaki güncel olmayan bir stored procedure (SP) çağrıldığında “parameter count mismatch” ya da “column does not belong to datatable” gibi hatalar alınabilir.

Modüller arasındaki uyumsuzlukları ararken yapıldığı gibi, modül ve veri tabanı arasındaki uyumsuzluklar aranırken de üretim ortamına taşınması planlanan modül kodları ve veri tabanı nesneleri arasında karşılaştırma yapılmalıdır. Bu sebeple, modüllerin veri tabanı erişim metotları önceki yazıda izlediğimiz yöntemle decompile edilmeli ve kodun içindeki veri tabanı erişiminden sorumlu kısımlar bulunmalıdır.

Veri tabanı nesneleri ise (bizim durumumuzda stored procedure kodları) ayrıca parse edilmeli ve erişim tipine göre (CRUD)) içindeki parametre ve select sütunlarının listesi çıkartılmalıdır. Bu listeye sahip olduktan sonra veri tabanına erişen tüm metodların kod bloğundaki veri tabanı parametreleri ve DataTable sütunları, ilgili SP içindeki bilgilerle karşılaştırılır ve uyumsuzluk bulunduğunda bu durum hata ya da uyarı olarak raporlanır.

Modüllerin decompile edilmesi için önceki bölümdeki gibi ICSharpCode.Decompilerkütüphanesinden, SP’lerin parse edilmesi için ise ANTLR’dan faydalandım. Öncelikle ANTLR ile SQL (bizim durumumuzda PL/SQL) parse etmek için nasıl bir yol izlememiz gerektiğinden bahsedelim.

ANTLR ile SQL okuma

PL/SQL cümlelerini okuyabilen yapıyı kurmak için öncelikle buradaki github adresinden aşağıdaki dosyaları indirmemiz gerekiyor:

https://github.com/antlr/grammars-v4/tree/master/sql/plsql/PlSqlLexer.g4
https://github.com/antlr/grammars-v4/tree/master/sql/plsql/PlSqlParser.g4
https://github.com/antlr/grammars-v4/tree/master/sql/plsql/CSharp/PlSqlLexerBase.cs
https://github.com/antlr/grammars-v4/tree/master/sql/plsql/CSharp/PlSqlParserBase.cs

Ardından da aşağıdaki adresten ANTLR parser ve lexer dosyalarını oluşturan aracı indiriyoruz. Bu jar dosyasını kullanmak için Java 1.7 ya da üstüne ihtiyacımız olduğunu da not düşelim.

https://www.antlr.org/download/antlr-4.9.2-complete.jar

Şimdi SQL metinlerini okumak için kullanacağımız sınıfları oluşturabiliriz. Bunun için aşağıdaki komutu çalıştırmamız yeterli:

java -jar antlr-4.9.2-complete.jar -Dlanguage=CSharp -visitor -listener

Bu komut bize çıktı olarak aşağıdaki dosyaları verecek:

Lexer, Parser, Visitor ve Listener sınıfları

Bu sınıflar az önce github’dan indirdiğimiz PlSqlParserBase ve PlSqlLexerBase sınıflarından türemektedir. Bu nedenle bu iki sınıfı da projemize ekliyoruz. Parser projemizin son hali aşağıdakine benzer olmalıdır.

Parser projesindeki tüm dosyalar

Not: Parser ve lexer cs dosyalarının toplam boyutu 10MB’ı geçtiğinden derlenmeleri 2–3 dakikaya kadar sürebilmektedir. Bu sebepten bu dosyaları ileride nadiren değiştirilecek ayrı bir proje (ve kitaplık) altında toplamak, esas projemizi geliştirirken zamandan tasarruf etmemizi sağlayacaktır.

Parser mekanizmamız bu noktadan sonra kullanıma hazır. Şimdi modüllerdeki veri tabanı erişim metodlarını nasıl keşfettiğimize bakalım.

Veri Tabanı metotlarını decompile etme

İlgilendiğimiz metotların kodlarına erişmek için aşağıdaki kod bloğundan faydalanıyoruz:

Veri tabanı metotlarının decompile edilmesi

Buradaki adımları satırlara göre açıklayacak olursak:

  • 64: Kullanacağımız CSharpDecompiler nesnesini hazırlıyoruz.
  • 66: Modül dll’imizi ReflectionOnly amacıyla yüklüyoruz. Bu yöntem uygulamanın performansına bir miktar katkı sağlıyor.
  • 68: Sistemimizdeki tüm veri tabanın nesnelerinin türediğini bildiğimiz BaseEntity sınıfı, assembly’de ilgilendiğimiz sınıfların türediği tip olmalıdır. Buna göre filtreleme yapıyoruz.
  • 69–73: System.Type’ı decompiler kitaplığına aitITypeDefinition tipine dönüştürüyoruz.
  • 74: Başarılı dönüştürülemeyen tipleri filtreliyoruz
  • 75: Tipin içindeki metodları bulmaya başlıyoruz.
  • 76–77: İlgilendiğimiz metot tipini belirtiyoruz. Public olmalı, constructor olmamalı vb.
  • 78: Son çıktı listemizi hazırlıyoruz.
  • 80: Hataları raporlarken kullanmak üzere metodu tanımlayan benzersiz bir isim üretiyoruz.
  • 81: Metoda ait MetadataToken bilgisini kullanarak metodu decompile ediyoruz. Bu işlem sonunda elimizde sadece o metoda ait kod bloğu kalıyor.

Bir sonraki aşamada yapmamız gereken, kod bloğu içinden SP çağrımına ait bilgileri çıkarmak. Standart kullanımı olan bir veri tabanı katmanı kullandığımız için kod içinde ilgilendiğimiz kısımları Regular Expressions kullanarak bulmak mümkün.

Öncelikle SP çağrım bloğunu buluyoruz:

var reExecute =
new Regex(@"Database[\.]Execute(?<executiontype>.+)\([""](?<spname>[\w]{3}_[SLIUD]_[\w]+)""");
var execStatement = reExecute.Match(method.Code);var spName = execStatement.Groups["spname"].Value;var isSelect = reSelectSp.IsMatch(spName)
&& execStatement.Groups["executiontype"].Value switch
{
"DataSet" or "DataSetParameterized" => true,
_ => false
};

Çağrımların tümü Database.ExecuteNonQuery("APP_I_MESSAGE", parameters) formatında. Regex’deki executiontype adlı grup bize çağrımın select ya da insert işlemi olduğu hakkında fikir veriyor. Aynı şekilde iki “_ karakteri arasındaki harften de bunun bir insert sp’si olduğunu anlıyoruz. Böylece SP’nin adını ve select SP’si olup olmadığını öğreniyoruz.

Ardından SP’ye gönderilen parametreleri belirlememiz gerekiyor:

var reParameters =
new Regex(
@"[\.]Add(?<direction>In|Out|InOut)Parameter\(""(?<parametername>\w+)""[ ]*,[ ]*.+[ ]*,[ ]*DatabaseTypes\.(?<parametertype>[\w\d]+)");
var parameters = reParameters.Matches(method.Code)
.Cast<Match>()
.Select(rm => new Parameter
{
Name = rm.Groups["parametername"].Value,
Direction = rm.Groups["direction"].Value.ToUpperInvariant(),
Type = rm.Groups["parametertype"].Value
})
.ToList();

Parametre ekleme işlemleri de param.AddInParameter("p_Name","myName", DatabaseTypes.String) formatında. Bu Regex ile parametrenin adını, yönünü ve hatta tipini elde edebiliyoruz.

Son olarak, söz konusu kod bir select SP çağrımına ait ise, burada SP yanıtından okunmaya çalışılan sütun adlarını da tespit etmemiz gerekiyor. Bunun için kullandığımız yöntem ise aşağıdaki gibi:

var reColumns = new Regex(@"([\w][\d]*|\[\d+\])\[""(?<columnname>[\w]+)""\]");var columns = reColumns.Matches(method.Code)
.Cast<Match>()
.Select(rm => rm.Groups["columnname"].Value)
.Where(c => isSelect && !parameters.Any(p =>
p.Type.Contains("OUT" && p.Name== c)))
.Distinct()
.ToList();

Sütunları okurken basit şekilde ["FIRSTNAME"] tarzındaki blokları yakalıyoruz. Burada sorun olabilecek bir durum, okunan OUT parametre adlarının da sütun adlarının arasına karışması. Bunun önüne geçmek için, bulduğumuz sütun adlarının bir önceki adımda listesini aldığımız parametre listesinde bulunup bulunmadığını kontrol ediyoruz.

Bu implementasyonda hem parametre listesinde hem de sütun listesinde aynı adla yer alan bir değerin sütun listesinde gözden kaçma riski var. Regex’de iyileştirme yaparak ve bulunan adların listesinde Distinct() çağrımı yapılmadan ayrı bir filtreleme uygulanarak bu olası durum da önlenebilir.

Artık sıra SP kodunu okumaya geldi. Bizim durumumuzda, üretim ortamına taşınacak olan SP’lerin içeriği Azure DevOps üzerinde bulunduğundan buradaki ve buradaki nuget paketlerini kullanarak kodlara eriştik. Burada önemli olan, üretim ortamına taşınması planlanan SP kodlarının bir şekilde elimizde olması.

Birden fazla noktada SQL parse işlemi yapmamız gerektiğinden öncelikle parser üretiminden sorumlu bir Utility metodumuzun olması işimizi kolaylaştıracaktır. Bizim kullandığımız metot aşağıdaki gibi:

SQL dili büyük/küçük harfe duyarlı değil, fakat ANTLR duyarlı. SP içeriğinde karşılaşabileceğimiz tutarsız büyük/küçük harf kullanımlarının sorun oluşturmaması için burada bulunanCaseChangingCharStream sınıfını kullanıyoruz. Konuyla ilgili daha fazla detay için buraya göz atabilirsiniz.

SQL metnini okurken uygulamak istediğimiz kurallar, hangi dil yapısıyla karşılaştığımızda ne yapmamız gerektiği, bizim uygulamamızın amacına göre değişen bir durum. Bu sebeple SP içeriğini okumak için kendi Visitor sınıfımızı yazmak durumundayız. Bu sınıfı yazdıktan sonra SP’yi okumak ve içinden ilgimizi çeken bilgileri çıkartmak için yapmamız gerekenler aşağıdaki kod bloğundan ibaret olacaktır.

Yazdığımız Visitor’ın kullanımı

Parser’ımızı oluşturduktan sonra create_procedure_body() metodunu çağırarak SQL metnini CREATE OR REPLACE PROCEDURE... cümlesi olarak okuyoruz. Ardından yapmamız gereken, okuduğumuz context’i yazdığımız visitor’a ileterek Visit() metodunu çağırmak. Eğer beklenmedik bir hata almazsak, sonuç olarak sarı işaretli sütun ve parametre listelerinin doldurulmasını bekliyoruz. Bunun ardından tek yapmamız gereken, önceki adımlarda koddan doldurduğumuz listelerle buradakiler arasındaki tutarsızlıkları raporlamak olacak. Şimdi PlSqlSpVisitor implementasyonuna göz atalım.

Visitor İmplementasyonu

Visitor sınıfları, içinde dilin muhtelif yapılarıyla karşılaşıldığında ANTLR tarafından çağrılan metotların olduğu sınıflardır. Özellikle odaklanmak istediğimiz bir yapı (örneğin SP içindeki parametre tanımları) ile karşılaştığımızda yapmak istediğimiz bir işlem olduğunda, ilgili metodu override ederek kendi kodumuzu yazabiliriz. SP’leri okurken ele almamız gereken yapılar aşağıdaki gibidir:

  • Parametre tanımları
  • Atamalar (dinamik SQL’leri okumak için)
  • OPEN FOR cümleleri (Oracle RefCursor)
  • Select cümleleri

Şimdi method override’lardan birkaçına göz atalım:

VisitParameter

SQL metni içinde her parametre tanımıyla karşılaşıldığında çağrılan metottur. Context’e ait parameter_name() ve type_spec() metotlarıyla parametre adını ve tipini doğrudan alabiliyoruz. Tanımdaki ilk iki TerminalNodeImpl değeri ise bize parametrenin yönünü veriyor. Buradan IN ve varsa OUT değerlerini buluyoruz. Eğer hiç birini bulamazsak parametre varsayılan olarak IN yönündedir.

VisitAssignment_statement

SP içindeki dinamik SQL ifadelerini bulmak için atama ifadelerini gözlemlememiz gerekiyor. SELECT ile bağlayan cümlelerle ya da WITH ile başlayan common table expression’larla karşılaşabileceğimiz için metni buna göre okumamız gerekiyor. Dinamik SQL’lerde sıklıkla karşılaşılan WHERE’den sonra gelebilecek 1=1 AND gibi ifadeleri ve escape edilmiş yanyana tek tırnakları, cümlenin bütünlüğünü bozabileceğinden siliyoruz. FROM tbl ifadesinin eksikliği de cümle bütünlüğünü bozacağından, eğer yoksa dummy bir FROM ifadesi ekliyoruz. En son elimizde kalan SQL metnini olası dinamik SQL’leri tuttuğumuz dictionary’ye koyuyoruz. Bu bilgiyi ileride VisitOpen_for_statement overload’u içinde kullanacağız.

VisitOpen_for_statement

Bir cursor ifadesi ile karşılaştığımızda önümüzde iki olasılık vardır. Ya altında doğrudan bir select ifadesi vardır, ya da dinamik SQL için bir cursor açılıyordur. Duruma göre, GetColumns metoduna elimizdeki select_statement’ı göndererek buradan dönen listeyi kendi sütun listemize ekliyoruz.

Sütunları okuma

Select ifadelerinden sütun adlarını düzgün şekilde çıkarmak için kullandığımız GetColumns metodu ise kabaca aşağıdaki gibi:

Öncelikle select cümlesinden sütun listesini bulmamız gerekiyor. Eğer SELECT * tarzında bir kullanım varsa metottan direkt bunu dönüyoruz. Aksi durumda ise ilk önce column alias olup olmadığına bakıyoruz. Varsa bu bizim için yeterlidir, bir sonraki liste elemanına ilerleyebiliriz. Eğer yoksa sütun elemanını yorumlarda belirtilmiş olan fonksiyon çağrımı, DISTINCT, CASE WHEN gibi yapılar için kontrol edip bu ifadelerin sütun adını oluşturan bileşenlerini almaya çalışıyoruz.

--

--