Skip to content
Caution: You are browsing the legacy symfony 1.x part of this website.

Hari Ke-Empat - Symfony advent calendar

Language

efactoring

Diterjemahkan Oleh : Wildan Maulana

Sebelumnya di Symfony

Selama hari ketiga, semua layer dari arsitektur MVC telah diperlihatkan dan dimodifikasi agar dapat menampilkan daftar pertanyaan-pertanyaan dengan baik di homepage. Aplikasi ini sudah terlihat makin baik, tetapi masih kurang isi.

Tujuan dari hari keempat adalah untuk menunjukkan list jawaban-jawaban sebuah pertanyaan, memberikan URL yang lebih bagus untuk halaman detail question, menambahkan class tambahan, dan memindahkan beberapa baris code ke tempat yang lebih baik. Ini akan cukup menolong Anda dalam memahami konsep template, modwlm routing policy (aturan routing), dan refractoring. Anda mungkin berpikir masih terlalu dini untuk menulis ulang kembali suatu code yang baru berumur beberapa hari, tapi kami akan menunjukkanya kepada Anda, dan melihat bagaimana perasaan Anda pada akhir tutorial ini.

Untuk membaca tutorial ini, Anda harus familiar dengan konsep-konsep MVC yang diimplementasikan di symfony. Akan menolong juga jika Anda memiliki sedikit gambaran tentang apa agile development itu.

Memperlihatkan Jawaban-Jawaban sebuah Pertanyaan

Pertama, mari kita lanjutkan adaptasi dari template yang digenerate oleh CRUD 'Question' selama hari kedua

Action question/show digunakan untuk menampilkan detail dari sebuah pertanyaan, yaitu jika Anda melawatkan parameter id. Untuk mengetesnya, panggil saja :

http://askeet/frontend_dev.php/question/show/id/1

Anda mungkin sudah mengenal halaman show jika Anda telah bermain-main dengan aplikasi ini sebelumnya. Disinilah kita akan menambahkan jawaban-jawaban sebuah pertanyaan.

Sekilas mengenai action show

Pertama, mari kita lihat action show, yang ada di file askeet/apps/frontend/modules/question/actions/actions.class.php :

public function executeShow()
 {
   $this->question = QuestionPeer::retrieveByPk($this->getRequestParameter('id'));
 
   $this->forward404Unless($this->question);
 }

Jika Anda sudah familiar dengan Propel, Anda disini akan mengenal request sederhana ke tabel Question. Query ini bertujuan untuk mengambil record yang unik yang memiliki nilai parameter id sebagai primary key. Pada URL yang diberikan pada contoh diatas, parameter id akan memiliki nilai 1, sehingga methode retrieveByPk() dari class QuestionPerr akan me-return object dengan tipe class Question dengan `1`` sebagai primary key.

Jika Anda tidak familiar dengan Propel, kembalilah setelah Anda membaca beberapa dokumentasinya pada website mereka.

Hasil dari request ini akan dilewatkan ke template showSuccess.php melalui variabel $question.

Method ->getRequestParameter('id' milik object sfAction kaan mendapatkan... paremeter request yang bernama id, baik dilewatkan dengan mode GET ataupun POST. Misalnya, jika Anda menulis URL seperti ini :

http://askeet/frontend_dev.php/question/show/id/1/myparam/myvalue

... Maka action show akan dapat menerima myvalue dengan merequest $this->getRequestParameter('myparam').

Catatan: Method forward4040Unless() menirim ke browser halaman 404 jika pertanyaannya tidak ditemukan pada database. Merupakan kebiasaan yang bagus dalam mengahdapi kasus terfatal dan error yang dapat terjadi selama proses eksekusi dan symfony memberikan kepada Anda beberapa method sederhana untuk menolong Anda melakukan hal yang benar dengan mudah.

Memodifikasi template showSuccess.php

Template showSuccess.php yang digenerate belum sesuai yang kita inginkan, jadi kita akan menulis ulang total template ini. Bukalah file frontend/modules/question/templates/showSuccess.php dan gantilah contentnya dengan :

<?php use_helper('Date') ?>
 
<div class="interested_block">
  <div class="interested_mark" id="mark_<?php echo $question->getId() ?>">
    <?php echo count($question->getInterests()) ?>
  </div>
</div>
 
<h2><?php echo $question->getTitle() ?></h2>
 
<div class="question_body">
  <?php echo $question->getBody() ?>
</div>
 
<div id="answers">
<?php foreach($question->getAnswers() as $answer): ?>
  <div>
    <?php echo count($answer->getRelevancys()) ?> points
    posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?> 
    on <?php echo format_date($answer->getCreatedAt(), 'p') ?>
    <div>
      <?php echo $answer->getBody() ?>
    </div>
  </div>
<?php endforeach ?>
</div>

Anda disini telah mengenal div interested_block yang telah ditambahkan pada template interested_block kemarin. Template ini hanya menampilkan jumlah user yang tertarik pada sebuah pertanyaan. Selain itu, markup yang digunakanpun hampir sama dengan yang ada pada list, kecuali tidak ada link_to pada title nya. Ini hanya menulis ulang code asal untuk menampilkan informasi tentang sebuah pertanyaan saja.

Bagian yang baru adalah div answer. div ini menampilkan semua jawaban sebuah pertanyaan (dengan menggunakan method Propel yang sederhana $question->getAnswers()), dan untuk masing-masingnya, ditampilkan relevancynya, nama pengarang, dan tanggal pembuatan, dan body tentu saja.

format_date() adalah contoh lain dari template helpers dimana diperlukan deklarasi awal. Anda dapat membaca lebih jauh mengenai helper ini dan sintaksnya dan helper lainnya pada Bab Internasionalisasi dari buku symfony.(helper ini mempercepat kerjaan yang menyebalkan untuk menampilkan tanggal dalam format yang enak untuk dilihat)

Catatan: Propel membuat nama method untuk melink tabel-tabel dengan manambahkan akhirsan '2' secara otomatis pada akhir nama tabel. Tolong, maafkan nama method yang jelek ->getRelevenacys() karena method ini menghemat Anda beberapa baris code SQL.

Menambahkan beberapa data tes

Sekarang saatnya untuk menambahkan beberapa data untuk tabel answer dan relevancy pada file data/fixtures/test_data.yml (silahkan tambahkan data lagi sesuai keinginan Anda):

Answer:
  a1_q1:
    question_id: q1
    user_id:     francois
    body:        |
      You can try to read her poetry. Chicks love that kind of things.

  a2_q1:
    question_id: q1
    user_id:     fabien
    body:        |
      Don't bring her to a donuts shop. Ever. Girls don't like to be
      seen eating with their fingers - although it's nice. 

  a3_q1:
    question_id: q2
    user_id:     fabien
    body:        |
      The answer is in the question: buy her a step, so she can 
      get some exercise and be grateful for the weight she will
      lose.

  a4_q1:
    question_id: q3
    user_id:     fabien
    body:        |
      Build it with symfony - and people will love it.

Relevancy:
  rel1:
    answer_id: a1_q1
    user_id:   fabien
    score:     1

  rel2:
    answer_id: a1_q1
    user_id:   francois
    score:     -1

Reload data Anda dengan:

$ php batch/load_data.php

Bukalah action yang menunjukkan pertanyaan pertama untuk mengecek kalau modifikasi yang kita buat benar :

http://askeet/frontend_dev.php/question/show/id/XX

Catatan: Ganti XX dengan id dari pertanyaan pertama Anda.

Pertanyaan sekarang ditampilkan dengan tampilan yang lebih bagus, diikuti

Memodifikasi model, bagian I

Dapat dipastikan nama lengkap author akan diperlukan entah dimaan di aplikasi kita. Anda juga bisa mempertimbangkan nama lengkap ini menjadi attribute dari object User. Ini artinya ia harus menjadi sebuah method di model User yang dapat digunakan untuk mengambil nama lengkap, daripada menysunnya di sebuah action. Mari kita tulis. Bukalah askeet/lib/model/User.php dan tambahkanlah method berikut :

public function __toString()
{
  return $this->getFirstName().' '.$this->getLastName();
}

Mengapa method ini dinamakan __toString() daripada getFullName() atau yang lainnya ? Karena method __toSrting() adalah method default yang digunakan PHP5 untuk meng-echo sebuah object. Ini artinya Anda dapat mengganti baris

posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?> 

dari template askeet/apps/frontend/modules/question/templates/showSuccess.php dengan :

posted by <?php echo $answer->getUser() ?> 

untuk mencapai hasil yang sama. Mudah kan ?

Jangan melakukan hal yang sama berulang-ulang

Salah satu prinsip yang baik dari agile development adalah untuk menghindari duplikasi code. Dikatakan "Don't Repeat Yourself" (D.R.Y.). Ini dikarenakan adanya duplikasi code berarti dua kali lebih lama untuk mereview, memodifikasi, mengeset dan memvalidasi daripada sebuah code yang unik yang dienkapsulasi. Duplikasi code ini juga membuat proses maintaince sebuah aplikasi lebi kompleks. Jika Anda memperhatikan pada bagia akhir daru tutorial hari ini, Anda mungkin menemukan beberapa duplikasi code antara template listSuccess.php yanf ditulis kemarin dan showSuccess.php:

<div class="interested_block">
  <div class="interested_mark">
    <?php echo count($question->getInterests()) ?>
  </div>
</div>

Jadi sesio pertama Kita dari refactoring akan menindahkkan beberapa baris code dari dua buah template dan menyimpannya pada sebuah fragmeng, atau potongan code yang dapat digunakan kembali. Buatlah file _interseted_user.php pada direktori askeet/apps/frontend/modules/question/template/ dengan code berikut :

<div class="interested_mark">
  <?php echo count($question->getInterests()) ?>
</div>

Kemudian, ganti code awal pada kedua template tadi dengan:

<div class="interested_block">
  <?php echo include_partial('interested_user', array('question' => $question)) ?>
</div>

Sebuah fragment tidak memiliki akses langsung ke object-object yang ada. Fragment menggunakan variabel $question, sehingga ia harus didefinisikan ketika memanggil include_partial. Tambahan _ didepan nama file fragment menolong dalam membedakan fragmen denan template yang sebenarnya yang ada didirektori template. Jika Anda ingin belajar lebih jauh mengenai fragment, bacalah Bab view pada buku Symfony.

Memodifikasi model, bagian II

Panggilan $question->getInterest() dari frgment yang baru melalukan sebuah request ke database dan mereturn sebuah array object yang bertipe class Question. Ini adalah request yang cukup berat untuk sejumlah kecil orang yg tertarik, dan ini mungkin saha akan membuat beban database teralu berat. Ingat, panggilan ini juga dilakukan di template listSuccess.php, tapi kali ini dalam sebuah loop, untuk setiap pertanyaan pada list. Ide yang sangat bagus untuk mengoptimalisasi masalah ini.

Salah satu solusi yang baik adalah membuat sebuah kolom pada tabel Question yang bernama interested_users, dan mengupdate kolom ini setiap kali ketertarikan pada sebuah pertanyaan dibuat.

Peringatan: Kita akan mengubah model tanpa adanya cara yang jelas untuk mengetesnya, karena untuk saat ini belum ada cara untuk menambahkan record Interest melalui askeet. Jangan pernah sekali-kalli Anda memidifikasi sesuatu tanpa asa cara untuk mengetesnya.

Tapi untuknya, kita memiliki cara untuk mengetes modifikasi ini, dan Anda akan menemukannya nanti, pada bagian ini.

Menambahkan sebuah field ke model object User.

Sekarang, tanpa rasa takut, modifikasilaj model data askeet/config/schema.xml dengan menambahkan ke tabel ask_question :

<column name="interested_users" type="integer" default="0" />

Tambahkan kolom yang sama ke tabel MySQL:

$ mysql -u youruser -p askeet -e "alter table ask_question add interested_users int default '0'"

Kemudian buat kembali modelnya:

$ symfony propel-build-model

Ya benar, Kita sudah membuat kembali model tanpa takut adanya perubahan pada extension yang sudah kita buat pada model kita ! Hal ini karena, extension pada class User dibuat pada askeet/lib/model/User.php, yang mewarisi class-class yang digenerate oleh Propel di askeet/lib/model/om/BaseUser.php. Inilah alsannya mengapa Anda jangan pernag mengedit code pada direktori askeet/lib/model/om/ : file-file yang ada disini ditulis ulang kembali alias ditimpa setiap kali perintah build-model dipanggil. Symfony membantu memudahkan life cycle perubahan model pada tahap awal proyek web apapun juga.

Catatan: Ada lebih dari satu cara untuk melakukannya

Jika Anda tidak ingin menulis statemen SQL, Anda dapat juga membuat ulang schema SQL dan mereload kembali test data:

$ symfony propel-build-sql
$ mysql -u youruser -p askeet < data/sql/lib.model.schema.sql
$ php batch/load_data.php

Memodifikasi method save() pada object Interest()

Mengupdate nilai dari filed baru ini harus dilakukan setiap kali user menyatakan ketertarikannya pada sebuah pertanyaan, yaitu setiap kali sebuah record ditambahkan ke tabel Interest. Anda dapat mengimplementasikan hal ini dengan trigger di MySQL, tapi hal ini merupakan solusi yang bergantung pada sebuah database, dan Anda tidak akan dapat pindah ke database lain dengan mudah.

Solusi terbaiknya adalah untuk memodifikasi model dengan cara meng-override method save() dari class Interest(). Method ini dipanggil setiap kali object dari class Interest dibuat. Jadi bukalah file askeet/lib/model/Interest.php dan tulislah method berikut :

public function save($con = null)
{  
    $ret = parent::save($con);
 
    // update interested_users in question table
    $question = $this->getQuestion();
    $interested_users = $question->getInterestedUsers();
    $question->setInterestedUsers($interested_users + 1);
    $question->save();
 
    return $ret;
}

Method save() yang baru mengambil pertanyaan yang berhubungan dengan interest yang sekarang, dan menaikkan nilai field interested_users 1. Kemudian ia akan melakukan fungsi save() seperti biasanya, tapi karena $this->save(); akan mengakibatkkan infinite loop, method ini memilih menggunakan parent:::save().

Mengamankan proses request update dengan transaction

Apa yang terjadi jika database gagal dalam proses update antara object Question dengan object Interest ? Anda akan mendapatkan data yang corrupt. Masalah ini sama dengan yang ditemui di bank, ketika sejumlam uang ditransfer, maa request pertama adalah untuk mengurangi jumlah sebuah account, dan request kedua untuk menambahkan jumlah di account yang lain.

Jika dua buah request sangat saling berketergantungan, Anda harus mengamankan eksekusinya dengan menggunakan transaction. Sebuah transaction adalah jaminan yang akan mensukseskan kedua kedua request, atau tidak sama sekali. Jika sesuatu ada yang salah pada salah satu request transaction, semua request yang berhasil sebelumnya akan dibatalkan, dan database akan kembali ke kondisi sebelum dimulainya transaction.

Method save() kita ini adalah cara yang baik untuk mengilustrasikan implementasi transaction di symfony. Ganti code tadi dengan :

public function save($con = null)
{
  $con = sfContext::getInstance()->getDatabaseConnection('propel');
  try
  {
    $con->begin();
 
    $ret = parent::save($con);
 
    // update interested_users in question table
    $question = $this->getQuestion();
    $interested_users = $question->getInterestedUsers();
    $question->setInterestedUsers($interested_users + 1);
    $question->save();
 
    $con->commit();
 
    return $ret;
  }
  catch (Exception $e)
  {
    $con->rollback();
    throw $e;
  }
}

Pertama, method akan membuka koneksi langsung ke database melalui Creole. Antara deklarasi ->begin() dan ->commit(), transaction memastikan semua request akan dilakukan atau tidak sama sekali. Jika ada sesuatu yang gagal, sebuah exception akan terjadi, dan database akan mengeksekusi sebuah rollback untuk kembali ke kondisi sebelumnya.

Mengubah template.

Sekarang method ->getInterestedUsers() dari object Question telah bekerja dengan baik, sekarang saatnya untuk menyederhanakan fragment _interested_user.php dengan mengganti :

<?php echo count($question->getInterests()) ?>

dengan

<?php echo $question->getInterestedUsers() ?>

Dilihat dari segi jumlah request dan waktu eksekusi, cara ini seharusnya lebih baik.

Catatan: Terimakasi pada ide brilian kita untuk menggunakan fragment daripada menggunakan code yang duplikat pada template, modifikasi ini hanya perlu dilakukan sekali. Jika tidak, kita harus memodifikasi template showSuccess.php dan listSucces.php, dan untuk orang yang malas seperti kita, ini mungkin terlalu berlebihan.

Mengetes benar tidaknya modifikasi yang kita lakukan

Kita akan mengecek memang benar-benar tidak ada yang salah dengan cara merequest action show lagi, tetapi sebelum itu, jalankan kembai batc untuk import data yang telah Anda tulis kemarin :

$ cd /home/sfprojects/askeet/batch
$ php load_data.php

Ketika membuat record untuk tabel Interesr, object sfPropelData akan menggunakan method save() yang telah dioverride dan akan mengupdate record User yang berhubungan. Jadi ini adalah cara yang bagus untuk mengetes modifikasi dari model, meskipun belum ada interface CRUD dengan object Interest yang dibuat.

Perikasalah dengan merequest home page dan detail dari pertanyaan pertama:

http://askeet/frontend_dev.php/
http://askeet/frontend_dev.php/question/show/id/XX

Jumlah user yang tertaruk tidak berubah. Ini perubahan yang bagus!

Yang baru saja dilakukan untuk count($question->getInterests()) dapat juga dilakukan untuk count($answer->getRelevancys()). Sekarang Anda telah memahami bagaimana memodifikasi model, Anda dapat latihan dalam hal ini dan melihat bagaimana kami melakukannya pada repository SVN (lihatlah perubahan yang utama pada schema.xml), dan file test_data, class Relevancy dan Answer serta template showSuccess).

Routing

Sejak awal tutorial ini, kita menulis URL

http://askeet/frontend_dev.php/question/show/id/XX

Aturan routing default dari symfont memahami pola request seperti ini, seperti yang biasanya Anda request.

http://askeet/frontend_dev.php?module=question&action=show&id=XX

Tetapi dengan memiliki routing system telah membuka banyak kemungkinan. Kita dapat menggunakan judul pertanyaan sebagai URL, agar dapat membuka halaman yang sama dengan :

http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend

Cara ini dapat mengoptimalkan search engines mengindex halaman website, dan membuat URL lebih mudah dibaca.

Membuat versi alternatif dari judul

Pertama kita perlu mengkonversi judul menjadi versi yang dipisahkan dengan tanda strip "-" untuk dijadikan sebagai URL. Ada lebih dari satu cara untuk melakukan ini, dan kita akan memilih untuk menyimpan judul alternatif ini sebagi kolom baru dari tabel Question. Pada schema.xml, tambahkan baris berikut ke tabel Question:

<column name="stripped_title" type="longvarchar" />

...dan build kembali model

$ symfony propel-build-model

Anda juga perlu mengupdate database. Kali ini, kita akan menggenerate nya dan mengisi nya lagi dari awal:

$ symfony propel-build-sql
$ mysql -u youruser -p askeet < data/sql/lib.model.schema.sql

Kita segera akan mengoverride methos setTitle() dari object Question sehingga ia mengeset judul yang dipisah dengan tanda strip "-" sekaligus.

Custom class

Tetapi sebelum itu, Kita akan membuah sebuah custom class untuk mengubah sebuah judul menjadi judul yang dipisah dengan tanda strip "-", karena fungsi ini tidak benar-benar berhubungan dengan object Question (kita mungkin saja menggunakannya untuk object Answer).

Buatlah file myTools.class.php dibawah direktori askeet/lib/:

class myTools
{
  public static function stripText($text)
  {
    $text = strtolower($text);
 
    // strip all non word chars
    $text = preg_replace('/\W/', ' ', $text);
 
    // replace all white space sections with a dash
    $text = preg_replace('/\ +/', '-', $text);
 
    // trim dashes
    $text = preg_replace('/\-$/', '', $text);
    $text = preg_replace('/^\-/', '', $text);
 
    return $text;
  }
}

Sekarang buka class askeet/lib/model/Question.php dan tambahkan L

public function setTitle($v)
{
  parent::setTitle($v);
 
  $this->setStrippedTitle(myTools::stripText($v));
}

Perhatikan custom class myTools tidak perlu ddeklarasikan: symfony akan meloadnya secara otomatis jika diperlukan selama ia diletakkan di direktori lib/.

Kita dapat mereload kembali data kita:

$ php batch/load_data.php

Jika Anda ingin belajar lebih jauh mengenai custom class dan custom helpers, bacalah bab extension pada buku symfony.

Mengubah link ke action show

Pada template listSuccess.php, ubahlah baris:

<h2><?php echo link_to($question->getTitle(), 'question/show?id='.$question->getId()) ?></h2>

dengan

<h2><?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?></h2>

Sekarang buka action.class.php dari module question, dan ubahlah action show menjadi :

public function executeShow()
{
  $c = new Criteria();
  $c->add(QuestionPeer::STRIPPED_TITLE, $this->getRequestParameter('stripped_title'));
  $this->question = QuestionPeer::doSelectOne($c);
 
  $this->forward404Unless($this->question);
}

Cobalah untuk menampilkan lagi list pertanyaan-pertanyaan dan mengaksesnya dengan meng-klik judul pertanyaan-pertanyaan ini:

http://askeet/frontend_dev.php/

URL menampilkan dengan benar judul sebuah pertanyaan:

http://askeet/frontend_dev.php/question/show/stripped-title/what-shall-i-do-tonight-with-my-girlfriend

Mengubah aturan routing

Tetapi tampilan ini tidak sesuai dengan yang kita inginkan. Sekarang saatnya untuk mengesut aturan routing. Bukalah file konfigurasi routing.yml (yang terletak di direktori askeet/apps/frontend/config/) dan tambahkanlah aturan berikut pada bagian atas file:

question:
  url:   /question/:stripped_title
  param: { module: question, action: show }

Pada baris url, kata question adalah custom text yang akan muncul pada URL terakhir, sementara stripped_title adalah sebuah parameter (ia dimulai dengan :). Baris ini membentuk sebuah pattern yang oleh system routing symfony akan diterapkan pada semua link ke panggilan action question/show - karena semua link pada template kami menggunakan helper link_to().

Sekarang saatnya untuk melakukan tes terakhir: Menampilkan homepage kembali, klik pada judul pertanyan pertama. Bukan saja pertanyaan pertama muncul(membuktikan kalau tidak ada yang rusak), tetapi address bar dari browser Anda sekarang menampilkan:

http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend

Jika Anda ingin belajar lebih banyak mengenai fitur routing, bacalah [bab aturan routing] (http://www.symfony-project.com/book/1_0/09-Links-and-the-Routing-System) pada buku symfony.

Sampai ketemu Besok

Hari ini, websitenya sendiri, tidak mendapatkan banyak fitur baru. Tetapi, Anda melihat lebih banyak coding template, Anda tahu bagaimana memodifikasi model, dan keseluruhan code telah direfractor pada banyal tempat.

Hal ini sering terjadi pada siklus hidup sebuah project symfony: code yang dapat digunakan kembali direfractor menjadi fragment atau sebuah class custom, code yang nampak pada sebuah action atau template dan yang sebenarnya milik model dipindahkan ke mode. Meskipun hal ini akan membuat code menjadi tersebar pada file-file kecil yang banyak pada berbagai direktori, proses maintain dan pengembangan project menjadi lebih mudah. Selain itu, struktur file symfony project yang sudah tetap akan memudahkan kita untuk mencari dimana sebuah code sebenarnya berada (helper, model, template, action, custom class, dll).

Refractoring yang telah dilakukan hari ini akan mempercepat proses development pada hari-hari yang akan datang, Dan kita akan melakukan refractoing kembali secara periodik pada project ini, karena dengan cara develop kita - membuat sebuah fitur tanpa khawatir tentang fungsionalitas yang akan dibuat nantinya - memerlukan struktur code yang bagus jika tidak kita akan berakhir dengan code yang acak-acakan.

Apa saja untuk besok ? Kita akan mulai menulis sebuah form dan melihat bagaimana mendapatkan informasi dari form tersebut. Kita juga akan memisahkan daftar pertanyaan dari home page menjadi beberapa halaman. Untuk sekarang, silahkan mendownload code sakarang dari repository SVN (dengan tag release_day_4) di :

http://svn.askeet.com/tags/release_day_4/

dan kirimlah pada kami pertanyaan apapun dengan menggunakan mailing-list askeet atau forum-nya.

This work is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.