Proaktif Hata Yakalama: Bölüm II
ANTLR ile Veri Tabanı ve Kod Arasındaki Uyumsuzlukları Önceden Tespit Etme
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.Decompiler
kü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.
Ş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:
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.
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:
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.
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.