Monday, September 21, 2015

Knockout.js'de Bağlama (=Binding) Şekilleri

Zengin içerikli ve yüksek etkileşimli web uygulamaları için kullanıcı arayüz mantığını mutlaka tarayıcıda oluşturmak gerekir. Bu arayüz mantığını ise Javascript (JS) ile kodlamak gerekirse de dilin tarayıcılar arasındaki tutarsızlıkları, kullanıcı arayüzü oluşturmak için alt düzey bir dil olması gibi temel sebepler nedeniyle üst seviye çatılara ihtiyaç bulunur. jQuery, tarayıcılar arasındaki farklılıkları gidermiş ve DOM güncellemeyi kolaylaştırsa da basit karmaşıklıktaki bir arayüz mantığı için bile bir çözüm oluşturmaktan uzaktır. 
Son beş yılda, masaüstü uygulama geliştirirken yoğun olarak kullanılan MVC kalıbını, bu sefer Web tarayıcıda çalışan arayüzler oluşturmak için kullanan, çok sayıda JS çatısının doğduğuna tanık olduk. Bu çatılar arasında, basitliği ve sadeliği ile öne çıkan bir çatı bulunuyor: KnockoutJSKnockoutJS, Model-View-ViewModel (MVVM) kalıbını kullanıyor. Bu kalıpta Model sunucu tarafta, genellikle veri tabanında yer alır. View bileşenini HTML5 ve CSS3 kullanarak oluşturuyoruz. ViewModel ise arayüzde kullanıcıya sunmak istediğimiz veriyi modeller. View ile onu besleyen ViewModel arasındaki bağlantıyı ise HTML takılarına iliştirdiğimiz data-bind özniteliğini kullanarak bildirimsel (=declarative) olarak yapıyoruz. data-bind, HTML5 standardında tanımlı bir öznitelik (=attribute) değil. Ancak HTML5'in amacı Web tarayıcısını, uygulama geliştirebileceğimiz bir platforma dönüştürmektir. Bu nedenle KnockoutJS gibi çatıların ihtiyaçlarına uygun olarak yeni takı (=tag) ve öznitelik tanımlamalarına izin verir. KnockoutJS'de MVC'de deki Controller kodlamaya ihtiyaç bulunmaz. Controller, data-bind özniteliği ile tanımlanan bağın çift taraflı (2-way binding) çalışmasını sağlar ve göz önünde bulunmaz. 
Bu yazıda Knockout.js'deki çift taraflı bağlantı ele alınacaktır. Çift taraflı bağlantıyı çalışabilmek için örnek bir uygulama geliştireceğiz: Mastermind. Bu bir oyun. Bilgisayar n basamaklı, basamakları biri birinden farklı bir sayı seçer ve oyuncu bu sayıyı tahmin etmeye çalışır. n oyunun seviyesi. Başlangıç seviyesi ise 3. Oyunun başında bilgisayar 3 basamaklı, basamakları biri birinden bir sayı seçer. Bilgisayar kullanıcının tahminini değerlendirir ve bir sonraki tahmini daha iyi yapabilmesi için geri bildirimde bulunur. Her doğru basamakta bulduğu rakam için + ve yanlış basamakta bulduğu rakam için - kodlama yapar. Örneğin, bilgisayarın rastgele tuttuğu sayı 549 olsun. Kullanıcının 759 tahmini için bilgisayar 11, 579 tahmini için 2 ve 957 tahmini için ise 2 geri bildiriminde bulunur. Oyuncu, 549 tahmini ile bu seviyedeki sayıyı bildiğinde, oyunun seviyesi bir artar. Şimdi bilgisayar 4 basamaklı, basamakları biri birinden farklı rastgele bir sayı seçer. Oyuncunun amacı şimdi dört basamaklı olan bu sayıyı tahmin etmektir. Oyun en son sekizinci seviye için oynanır. Oyuncunun, bu seviyedeki sayıyı doğru tahmin etmesi ile oyun sona erer. Bu oyun için ViewModel aşağıdaki gibi olabilir:
var GameViewModel = function() {
 var self = this;

 self.guess = 123 ;
 self.gameLevel = 3;
 self.tries = 0;

 self.history = [];
 self.secret = createSecret(self.gameLevel);
}
View ise aşağıdaki gibi bir HTML olarak tasarlanabilir:
<!--  Game Board -->
<div class="panel panel-primary">
 <div class="panel-heading">
  <h3 class="panel-title">Mastermind</h3>
 </div>
 <div class="panel-body">
  <div class="form-group">
   <label for="gameLevel">Game Level:</label> <span></span>
  </div>
  <div class="form-group">
   <label for="tries">Tries:</label> <span></span>
  </div>
  <div class="form-group">
   <label for="tries">Time:</label> <span></span>
  </div>
  <div class="form-group">
   <label for="guess">Guess:</label> <span></span>
  </div>
  <div class="form-group">
   <button class="btn btn-success">Play</button>
  </div>
 </div>
</div>
<!--  Moves -->
<div class="panel panel-success">
 <div class="panel-heading">
  <h3 class="panel-title">Moves</h3>
 </div>
 <div class="panel-body">
  <table class="table table-striped">
   <thead>
    <tr>
     <th>Guess</th>
     <th>Evaluation</th>
    </tr>
   </thead>
   <tbody>
    <tr>
     <td></td>
     <td></td>
    </tr>
   </tbody>
  </table>
 </div>
</div>
Amacımız View değiştiğinde otomatik olarak ViewModel'i değiştirecek, ViewModel değiştiğinde ise View'i otomatik olarak değiştirecek bir çözüm oluşturmaktır. Knockout.js bunu, ViewModel ile View arasında bağlantı tanımlayarak gerçekleştirmemize yardımcı oluyor. Knockout.js'de View ile ViewModel arasında üç tür bağlantı kurulabilir:
1. ko.observable()
Eğer bağlanacak değişken skaler bir değişken ise bu değişken ko.observable() çağrısı ile yaratılır.
2. ko.observableArray()
Eğer bağlanacak değişken bir dizi ise bu değişken ko.observableArray() çağrısı ile yaratılır.
3. ko.computed()
Eğer bağlanacak değer, diğer "observable" değişkenlerden hesaplanarak oluşturulacak ise bu durumda ko.computed() çağrısı ile bir fonksiyon olarak tanımlanır.
Yukarıdaki örnek ViewModel'i şimdi bu "observable" yapıları kullanarak yeniden tanımlayalım:
var GameViewModel = function() {
   var self = this;

   self.guess = ko.observable();
   self.gameLevel = ko.observable(3);
   self.tries = ko.observable(0);
   self.history = ko.observableArray([]);
   self.secret = createSecret(self.gameLevel());
}
ViewModel ile View arasındaki bağlantı, HTML'de data-bind özniteliği kullanılarak sağlanır:
<!--  Game Board -->
<div class="panel panel-primary">
 <div class="panel-heading">
  <h3 class="panel-title">Mastermind</h3>
 </div>
 <div class="panel-body">
  <div class="form-group">
   <label for="gameLevel">Game Level:</label> 
   <span data-bind="text: gameLevel"></span>
  </div>
  <div class="form-group">
   <label for="tries">Tries:</label> 
   <span data-bind="text: tries"></span>
  </div>
  <div class="form-group">
   <label for="guess">Guess:</label> 
   <span data-bind="value: guess"></span>
  </div>
  <div class="form-group">
   <button>Play</button>
  </div>
 </div>
</div>
<!--  Moves -->
<div class="panel panel-success">
 <div class="panel-heading">
  <h3 class="panel-title">Moves</h3>
 </div>
 <div class="panel-body">
  <table class="table table-striped">
   <thead>
    <tr>
     <th>Guess</th>
     <th>Evaluation</th>
    </tr>
   </thead>
   <tbody>
    <tr>
     <td></td>
     <td></td>
    </tr>
   </tbody>
  </table>
 </div>
</div>
data-bind ile farklı amaçlarla bağlama yapılabilir: modeli sadece okumak, modeli hem okumak hem de değiştirmek. data-bind="text: tries" ile tries modelini sadece okumak için bağlıyoruz, data-bind="value: guess" ile guess modelini hem okumak hem de değiştirmek için bağlıyoruz. Bu durumda ön yüzden tahmin "guess" girildiğinde otomatik olarak ViewModel'deki guess değişecektir. Bağladığımız sadece öznitelik değil, bir fonksiyon da olabilir. Örneğin Play butonuna basıldığında ViewModel'deki bir fonksiyonun çalışmasını isteriz:
self.play = function() {
 var move = evaluateGuess(self.guess(), self.secret, self.gameLevel());
 self.tries(self.tries() + 1);
 if (move.eval.win) {
    self.gameLevel(self.gameLevel() + 1);
    self.init();
 }
 self.history.push(move);
}
Bu fonksiyonu View'daki Play butonuna aşağıdaki şekilde bağlıyoruz:
<div class="form-group">
   <button data-bind="click: play">Play</button>
</div>
Tüm bu bağlantıların işe yarayabilmesi için tüm sayfa yüklendikten sonra ko.applyBindings() çağrısının yapılması gerekir:
viewModel = new GameViewModel();
$(document).ready(function() {
   ko.applyBindings(viewModel);
});
Kullanıcının hamlelerini history dizisinde tutuyoruz. Oyuncunun hamlelerini bir tabloda göstermek için nasıl bir bağlama yapıldığına bakalım:
<!--  Moves -->
<div class="panel panel-success">
 <div class="panel-heading">
  <h3 class="panel-title">Moves</h3>
 </div>
 <div class="panel-body">
  <table class="table table-striped">
   <thead>
    <tr>
     <th>Guess</th>
     <th>Evaluation</th>
    </tr>
   </thead>
   <tbody data-bind="foreach: history">  
    <tr>
     <td data-bind="text: guess"></td>
     <td data-bind="visible: eval.win">
         <span class="label label-success" data-bind="visible: eval.win">You win!</span>
     </td>
     <td data-bind="visible: !eval.win"> 
          <span class="label label-success" 
              data-bind="visible: eval.perfectMatch>0, text: eval.perfectMatch"></span>
          <span class="label label-danger" 
              data-bind="visible: eval.partialMatch>0, text: eval.partialMatch"></span>
          <span class="label label-info" 
              data-bind="visible: eval.perfectMatch==0 && eval.partialMatch==0">No Match</span>
     </td>
    </tr>
   </tbody>
  </table>
 </div>
</div>
history dizisini data-bind="foreach: history" ile bağlıyoruz. Burada bir döngü oluşturulur. Bu döngü history dizisindeki her bir hamle için döner. Her hamle için <tr></tr> ile verilen tablo satırı dinamik olarak oluşturulur. <tr></tr> ile tablonun satırı için bir şablon verilir. Bu şablonun içinde kurulan bağlantılar ise GameViewModel ile değil history dizisindeki nesneler ile gerçekleştirilir. Knockout.js'de yukarıdaki verilen text, value, foreach gibi daha çok sayıda bağlantı etiketleri bulunur. Tüm bağlantı etiketlerine bu bağlantıdan erişebilirsiniz.
Burada dikkat edilmesi gereken önemli bir nokta bulunuyor. Değişkenleri observable olarak tanımladığımızda, onlar artık basit birer öznitelik olmaktan çıkıyorlar. Değerlerine ulaşmak ya da değerlerini değiştirmek için fonksiyon çağırır gibi çağırmamız gerekir. Örneğin tries'ın değerini bir arttırmak için aşağıdaki ifadeyi yazmamız gerekir:
self.tries( self.tries() + 1 );
Bazen observable alanları View'a bağlarken geçerli bir JS ifade içinde kullanmak gerekir. Bu durumda, yine observable alanın değerine parantez çifti ile fonksiyon çağırısı olarak erişmek gerekir:
<div class="alert alert-danger" role="alert" 
      data-bind="visible: validationMessage().length>0">
Burada validationMessage, ViewModel içinde ko.observable() olarak tanımlı bir alandır.
Şimdi oyundaki süreler ile ilgili bazı istatistikler toplamak isteyelim: kronometre, her bir seviyede hamle sayısı, tamamlama süresi, ortalama hamle süresi gibi. Bu kez bağlanacak değerlerin hesaplanarak getirilmesi gerekiyor. Bu durumda üçüncü bağlantı şeklini kullanacağız, ko.computed():
self.average = ko.computed(function(){
 if (self.tries() == 0) return self.duration();
 return self.duration()/ self.tries();
});

self.totalTime = ko.computed(function() {
 if (self.statistics().length == 0)
  return "No game played yet!";
 tt = 0.0;
 for (i in self.statistics()) {
  stat = self.statistics()[i];
  tt += Number(stat.time);
 }
 return tt;
});

self.averageTime = ko.computed(function() {
 if (self.statistics().length == 0)
  return "No game played yet!";
 return self.totalTime() / Number(self.statistics().length);

});
computed() fonksiyonuna parametre olarak bir fonksiyon veriyoruz. Javascript, fonksiyonel programlama dilidir. Dolayısı ile bunu yapmak yasal. computed() bağlantısını ilginç kılan ise, fonksiyon içinde kullanılan "observable" değerlerin herhangi biri değiştiğinde, otomatik olarak fonksiyonun yeniden çalıştırılmasıdır. Hesaplanan yeni değer, önyüzde (View) bağlandığı elemanın da değişmesini sağlayacaktır. Örneğin, self.average, self.tries ya da self.duration observable değişkenlerden herhangi biri değiştiğinde yeniden hesaplanacaktır. 
Şimdi, View ve ViewModel'in tamamına bakalım:

index.html:


<!doctype html>
<html>
<head>
   <meta charset="utf-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1"> 
   <style type="text/css">
 @import url('css/bootstrap.css');
 @import url('css/bootstrap-theme.css');
   </style>
   <script type="text/javascript" src="js/lib/jquery.js"></script>
   <script type="text/javascript" src="js/lib/knockout.js"></script>
   <script type="text/javascript" src="js/lib/bootstrap.js"></script>
   <script type="text/javascript" src="js/mastermind.js"></script>
   <title>MasterMind</title>
</head>
<body>

<div class="container" role="main">
   <!--  Game Board -->
   <div class="panel panel-primary">
 <div class="panel-heading">
  <h3 class="panel-title">Mastermind</h3>
 </div>
 <div class="panel-body">
  <div class="form-group">
   <label for="gameLevel">Game Level:</label> 
   <span data-bind="text: gameLevel"></span>
  </div>
  <div class="form-group">
   <label for="tries">Tries:</label> 
   <span data-bind="text: tries"></span>
  </div>
  <div class="form-group">
   <label for="tries">Time:</label> 
   <span class="label label-danger" data-bind="text: duration"></span>
  </div>
  <div class="form-group">
   <label for="tries">Average time:</label> 
   <span class="label label-danger" data-bind="text: average"></span>
  </div>
  <div class="form-group">
   <label for="guess">Guess:</label> 
   <input type="text" data-bind="value: guess" class="form-control" id="guess" />
  </div>
  <div class="form-group">
   <button class="btn btn-success" data-bind="click: play">Play</button>
  </div>    
 </div>
   </div>
   <!--  Moves -->
   <div class="panel panel-success">
 <div class="panel-heading">
  <h3 class="panel-title">Moves</h3>
 </div>
 <div class="panel-body">
  <table class="table table-striped">
   <thead>
      <tr>
    <th>Guess</th>
    <th>Evaluation</th>
      </tr>
   </thead>
   <tbody data-bind="foreach: history">  
      <tr>
    <td data-bind="text: guess"></td>
    <td data-bind="visible: eval.win">
                <span class="label label-success" 
                      data-bind="visible: eval.win">You win!</span>
    </td>
    <td data-bind="visible: !eval.win"> 
          <span class="label label-success" 
              data-bind="visible: eval.perfectMatch>0, text: eval.perfectMatch"></span>
          <span class="label label-danger" 
              data-bind="visible: eval.partialMatch>0, text: eval.partialMatch"></span>
          <span class="label label-info" 
              data-bind="visible: eval.perfectMatch==0 && eval.partialMatch==0">No Match</span>
    </td>
      </tr>
   </tbody>
  </table>
 </div>
 <div class="alert alert-danger" role="alert" 
      data-bind="visible: validationMessage().length>0">
  <strong data-bind="text: validationMessage"></strong>
 </div>    
    </div>
    <!--  Statistics -->
    <div class="panel panel-info">
 <div class="panel-heading">
  <h3 class="panel-title">Statistics</h3>
 </div>
 <div class="panel-body">
  <table class="table table-striped">
   <thead>
      <tr>
    <th>Level</th>
    <th>Tries</th>
    <th>Time (seconds)</th>
    <th>Average Move Time (seconds)</th>
      </tr>
   </thead>
   <tbody data-bind="foreach: statistics">  
      <tr>
    <td data-bind="text: level"></td>
    <td data-bind="text: tries"></td>
    <td data-bind="text: time"></td>
    <td data-bind="text: average"></td>
      </tr>
   </tbody>
  </table>
 </div>
    </div>
    <div class="alert alert-danger" role="alert">
      <strong>Total game time:</strong> 
      <span data-bind="text: totalTime"></span>
    </div>  
    <div class="alert alert-info" role="alert">
       <strong>Average game level time:</strong> 
       <span data-bind="text: averageTime"></span>
    </div>  
</div>  
</body>
</html>

mastermind.js:


function countDistinct(value){
   val= new String(value);
   hash = {};
   for (i = 0; i < val.length; ++i) {
       c= val.charAt(i);
       hash[c]= true;
   }
   return Object.keys(hash).length;
}

function getTimeDifference(startTime,endTime){
   return Math.round((endTime.getTime() - startTime.getTime()) / 1000);
}

function getTimeDifference(startTime){
   return Math.round((new Date().getTime() - startTime.getTime()) / 1000);
}

function getRandomDigit(min, max) {
   return Math.floor((Math.random() * (max - min + 1))) + min;
}

function createSecret(numberOfDigits) {
   var numbers = new Array();
   var candidate = new Number();
   numbers[0] = getRandomDigit(1, 9);
   for (i = 1; i < numberOfDigits; ++i) {
     do {
       candidate = getRandomDigit(0, 9);
     } while (numbers.indexOf(candidate) > -1);
     numbers[i] = candidate;
   }
   number = 0;
   for (i = 0; i < numberOfDigits; ++i)
       number = 10 * number + numbers[i];
   return number;
}

var GameLevelStat = function(level, tries, time, average) {
   var self = this;

   self.level = level;
   self.tries = tries;
   self.time = time;
   self.average = average;
}

var Move = function(guess, eval) {
   var self = this;

   self.guess = guess;
   self.eval = eval;
};

function Evaluation(perfectMatch, partialMatch, win) {
   var self = this;

   self.perfectMatch = Number(perfectMatch);
   self.partialMatch = Number(partialMatch);
   self.win = win;
};

function evaluateGuess(guess, secret, level) {
   if (guess == secret) {
      return new Move(guess, new Evaluation(level, 0, true));
   }
   secret = new String(secret);
   guess = new String(guess);
   var perfectMatch = 0;
   var partialMatch = 0;
   for (i = 0; i < secret.length; ++i) {
       for (j = 0; j < guess.length; ++j) {
          if (secret.charAt(i) == guess.charAt(j)) {
            if (i == j)
               ++perfectMatch;
            else
               ++partialMatch;
          }
       }
   }
   return new Move(guess, new Evaluation(perfectMatch, partialMatch, false));
};

var GameViewModel = function() {
   var self = this;

   self.guess = ko.observable();
   self.gameLevel = ko.observable(3);
   self.start = new Date();
   self.duration = ko.observable(0);
   self.tries = ko.observable(0);
   
   self.history = ko.observableArray([]);
   self.statistics = ko.observableArray([]);
   self.secret = createSecret(self.gameLevel());
   self.validationMessage = ko.observable("");
 
   self.average = ko.computed(function(){
     if (self.tries() == 0) return self.duration();
     return self.duration()/ self.tries();
   });
 
   self.totalTime = ko.computed(function() {
      if (self.statistics().length == 0)
         return "No game played yet!";
      tt = 0.0;
      for (i in self.statistics()) {
         stat = self.statistics()[i];
         tt += Number(stat.time);
      }
      return tt;
   });

   self.averageTime = ko.computed(function() {
     if (self.statistics().length == 0)
        return "No game played yet!";
     return self.totalTime() / Number(self.statistics().length);
   });
   
   self.validateGuess= function(){
     self.validationMessage("");
     guess = new String(self.guess());
     pattern= new RegExp('\^\\d{'+self.gameLevel()+'\}\$');
     if (!pattern.test(guess)){
        self.validationMessage("You must enter "+self.gameLevel()+"-digit integer!");
        return false;
     }
     if (countDistinct(guess) != self.gameLevel()){
        self.validationMessage("You have entered repeated digits!");
        return false;
     }
     for (i in self.history()){
         move= self.history()[i];
         if (self.guess()==move.guess){
            self.validationMessage("You have already entered "+self.guess()+"!");
            return false;
         }
     }
     return true;
   }
 
   self.init = function() {
     self.history([]);
     self.tries(0);
     self.start = new Date();
     self.secret = createSecret(self.gameLevel());
     setInterval(function() {
        now = new Date();
        self.duration( getTimeDifference(self.start) );
      }, 1000);
   };

   self.play = function() {
      if (self.validateGuess()){
      var move = evaluateGuess(self.guess(), self.secret, self.gameLevel());
      self.tries(self.tries() + 1);
      if (move.eval.win) {
         self.statistics.push(new GameLevelStat(self.gameLevel(), 
             self.tries(), self.duration(), self.duration()/self.tries()));
         self.gameLevel(self.gameLevel() + 1);
         self.init();
      }
      self.history.push(move);
    }
  }
};

viewModel = new GameViewModel();
$(document).ready(function() {
   ko.applyBindings(viewModel);
   viewModel.init();
});
Son olarak oyunun ekran görüntülerine bir göz atalım:
4. seviyede oyun ekranı
Tekrar eden basamaklar için sistemin uyarı verdiği ekran
Sistem, doğru basamakta bilinen rakam sayısını yeşil arka planda, yanlış basamakta bilinen rakam sayısını ise kırmızı arka planda gösteriyor:
Kullanıcının hamleleri ve bilgisayarın geri bildirim ekranı
Projenin tamamına bu bağlantıdan ulaşabilirsiniz. Knockout.js ve diğer MV* JS çatıları ile ilgili daha detaylı bilgi almak isterseniz Client-side and Server-side Javascript eğitimini öneririm.

No comments:

Post a Comment