Saya telah membaca Bekerja Efektif dengan Legacy Code dan Clean Code dengan tujuan mempelajari strategi tentang cara mulai membersihkan basis kode yang ada dari aplikasi webforms ASP.NET yang besar.
Sistem ini sudah ada sejak 2005 dan sejak itu telah mengalami sejumlah penyempurnaan. Awalnya kode ini disusun sebagai berikut (dan sebagian besar masih terstruktur dengan cara ini):
- ASP.NET (aspx / ascx)
- Kode di belakang (c #)
- Lapisan Logika Bisnis (c #)
- Lapisan Akses Data (c #)
- Database (Oracle)
Masalah utama adalah bahwa kode tersebut adalah prosedural yang menyamar sebagai berorientasi objek. Ini hampir melanggar semua pedoman yang dijelaskan dalam kedua buku.
Ini adalah contoh kelas tipikal dalam Lapisan Logika Bisnis:
public class AddressBO
{
public TransferObject GetAddress(string addressID)
{
if (StringUtils.IsNull(addressID))
{
throw new ValidationException("Address ID must be entered");
}
AddressDAO addressDAO = new AddressDAO();
return addressDAO.GetAddress(addressID);
}
public TransferObject Insert(TransferObject addressDetails)
{
if (StringUtils.IsNull(addressDetails.GetString("EVENT_ID")) ||
StringUtils.IsNull(addressDetails.GetString("LOCALITY")) ||
StringUtils.IsNull(addressDetails.GetString("ADDRESS_TARGET")) ||
StringUtils.IsNull(addressDetails.GetString("ADDRESS_TYPE_CODE")) ||
StringUtils.IsNull(addressDetails.GetString("CREATED_BY")))
{
throw new ValidationException(
"You must enter an Event ID, Locality, Address Target, Address Type Code and Created By.");
}
string addressID = Sequence.GetNextValue("ADDRESS_ID_SEQ");
addressDetails.SetValue("ADDRESS_ID", addressID);
string syncID = Sequence.GetNextValue("SYNC_ID_SEQ");
addressDetails.SetValue("SYNC_ADDRESS_ID", syncID);
TransferObject syncDetails = new TransferObject();
Transaction transaction = new Transaction();
try
{
AddressDAO addressDAO = new AddressDAO();
addressDAO.Insert(addressDetails, transaction);
// insert the record for the target
TransferObject addressTargetDetails = new TransferObject();
switch (addressDetails.GetString("ADDRESS_TARGET"))
{
case "PARTY_ADDRESSES":
{
addressTargetDetails.SetValue("ADDRESS_ID", addressID);
addressTargetDetails.SetValue("ADDRESS_TYPE_CODE",
addressDetails.GetString("ADDRESS_TYPE_CODE"));
addressTargetDetails.SetValue("PARTY_ID", addressDetails.GetString("PARTY_ID"));
addressTargetDetails.SetValue("EVENT_ID", addressDetails.GetString("EVENT_ID"));
addressTargetDetails.SetValue("CREATED_BY", addressDetails.GetString("CREATED_BY"));
addressDAO.InsertPartyAddress(addressTargetDetails, transaction);
break;
}
case "PARTY_CONTACT_ADDRESSES":
{
addressTargetDetails.SetValue("ADDRESS_ID", addressID);
addressTargetDetails.SetValue("ADDRESS_TYPE_CODE",
addressDetails.GetString("ADDRESS_TYPE_CODE"));
addressTargetDetails.SetValue("PUBLIC_RELEASE_FLAG",
addressDetails.GetString("PUBLIC_RELEASE_FLAG"));
addressTargetDetails.SetValue("CONTACT_ID", addressDetails.GetString("CONTACT_ID"));
addressTargetDetails.SetValue("EVENT_ID", addressDetails.GetString("EVENT_ID"));
addressTargetDetails.SetValue("CREATED_BY", addressDetails.GetString("CREATED_BY"));
addressDAO.InsertContactAddress(addressTargetDetails, transaction);
break;
}
<< many more cases here >>
default:
{
break;
}
}
// synchronise
SynchronisationBO synchronisationBO = new SynchronisationBO();
syncDetails = synchronisationBO.Synchronise("I", transaction,
"ADDRESSES", addressDetails.GetString("ADDRESS_TARGET"),
addressDetails, addressTargetDetails);
// commit
transaction.Commit();
}
catch (Exception)
{
transaction.Rollback();
throw;
}
return new TransferObject("ADDRESS_ID", addressID, "SYNC_DETAILS", syncDetails);
}
<< many more methods are here >>
}
Ini memiliki banyak duplikasi, kelas memiliki sejumlah tanggung jawab, dll, dll - itu hanya kode 'tidak bersih' yang umumnya.
Semua kode di seluruh sistem tergantung pada implementasi konkret.
Ini adalah contoh kelas khas di Lapisan Akses Data:
public class AddressDAO : GenericDAO
{
public static readonly string BASE_SQL_ADDRESSES =
"SELECT " +
" a.address_id, " +
" a.event_id, " +
" a.flat_unit_type_code, " +
" fut.description as flat_unit_description, " +
" a.flat_unit_num, " +
" a.floor_level_code, " +
" fl.description as floor_level_description, " +
" a.floor_level_num, " +
" a.building_name, " +
" a.lot_number, " +
" a.street_number, " +
" a.street_name, " +
" a.street_type_code, " +
" st.description as street_type_description, " +
" a.street_suffix_code, " +
" ss.description as street_suffix_description, " +
" a.postal_delivery_type_code, " +
" pdt.description as postal_delivery_description, " +
" a.postal_delivery_num, " +
" a.locality, " +
" a.state_code, " +
" s.description as state_description, " +
" a.postcode, " +
" a.country, " +
" a.lock_num, " +
" a.created_by, " +
" TO_CHAR(a.created_datetime, '" + SQL_DATETIME_FORMAT + "') as created_datetime, " +
" a.last_updated_by, " +
" TO_CHAR(a.last_updated_datetime, '" + SQL_DATETIME_FORMAT + "') as last_updated_datetime, " +
" a.sync_address_id, " +
" a.lat," +
" a.lon, " +
" a.validation_confidence, " +
" a.validation_quality, " +
" a.validation_status " +
"FROM ADDRESSES a, FLAT_UNIT_TYPES fut, FLOOR_LEVELS fl, STREET_TYPES st, " +
" STREET_SUFFIXES ss, POSTAL_DELIVERY_TYPES pdt, STATES s " +
"WHERE a.flat_unit_type_code = fut.flat_unit_type_code(+) " +
"AND a.floor_level_code = fl.floor_level_code(+) " +
"AND a.street_type_code = st.street_type_code(+) " +
"AND a.street_suffix_code = ss.street_suffix_code(+) " +
"AND a.postal_delivery_type_code = pdt.postal_delivery_type_code(+) " +
"AND a.state_code = s.state_code(+) ";
public TransferObject GetAddress(string addressID)
{
//Build the SELECT Statement
StringBuilder selectStatement = new StringBuilder(BASE_SQL_ADDRESSES);
//Add WHERE condition
selectStatement.Append(" AND a.address_id = :addressID");
ArrayList parameters = new ArrayList{DBUtils.CreateOracleParameter("addressID", OracleDbType.Decimal, addressID)};
// Execute the SELECT statement
Query query = new Query();
DataSet results = query.Execute(selectStatement.ToString(), parameters);
// Check if 0 or more than one rows returned
if (results.Tables[0].Rows.Count == 0)
{
throw new NoDataFoundException();
}
if (results.Tables[0].Rows.Count > 1)
{
throw new TooManyRowsException();
}
// Return a TransferObject containing the values
return new TransferObject(results);
}
public void Insert(TransferObject insertValues, Transaction transaction)
{
// Store Values
string addressID = insertValues.GetString("ADDRESS_ID");
string syncAddressID = insertValues.GetString("SYNC_ADDRESS_ID");
string eventID = insertValues.GetString("EVENT_ID");
string createdBy = insertValues.GetString("CREATED_BY");
// postal delivery
string postalDeliveryTypeCode = insertValues.GetString("POSTAL_DELIVERY_TYPE_CODE");
string postalDeliveryNum = insertValues.GetString("POSTAL_DELIVERY_NUM");
// unit/building
string flatUnitTypeCode = insertValues.GetString("FLAT_UNIT_TYPE_CODE");
string flatUnitNum = insertValues.GetString("FLAT_UNIT_NUM");
string floorLevelCode = insertValues.GetString("FLOOR_LEVEL_CODE");
string floorLevelNum = insertValues.GetString("FLOOR_LEVEL_NUM");
string buildingName = insertValues.GetString("BUILDING_NAME");
// street
string lotNumber = insertValues.GetString("LOT_NUMBER");
string streetNumber = insertValues.GetString("STREET_NUMBER");
string streetName = insertValues.GetString("STREET_NAME");
string streetTypeCode = insertValues.GetString("STREET_TYPE_CODE");
string streetSuffixCode = insertValues.GetString("STREET_SUFFIX_CODE");
// locality/state/postcode/country
string locality = insertValues.GetString("LOCALITY");
string stateCode = insertValues.GetString("STATE_CODE");
string postcode = insertValues.GetString("POSTCODE");
string country = insertValues.GetString("COUNTRY");
// esms address
string esmsAddress = insertValues.GetString("ESMS_ADDRESS");
//address/GPS
string lat = insertValues.GetString("LAT");
string lon = insertValues.GetString("LON");
string zoom = insertValues.GetString("ZOOM");
//string validateDate = insertValues.GetString("VALIDATED_DATE");
string validatedBy = insertValues.GetString("VALIDATED_BY");
string confidence = insertValues.GetString("VALIDATION_CONFIDENCE");
string status = insertValues.GetString("VALIDATION_STATUS");
string quality = insertValues.GetString("VALIDATION_QUALITY");
// the insert statement
StringBuilder insertStatement = new StringBuilder("INSERT INTO ADDRESSES (");
StringBuilder valuesStatement = new StringBuilder("VALUES (");
ArrayList parameters = new ArrayList();
// build the insert statement
insertStatement.Append("ADDRESS_ID, EVENT_ID, CREATED_BY, CREATED_DATETIME, LOCK_NUM ");
valuesStatement.Append(":addressID, :eventID, :createdBy, SYSDATE, 1 ");
parameters.Add(DBUtils.CreateOracleParameter("addressID", OracleDbType.Decimal, addressID));
parameters.Add(DBUtils.CreateOracleParameter("eventID", OracleDbType.Decimal, eventID));
parameters.Add(DBUtils.CreateOracleParameter("createdBy", OracleDbType.Varchar2, createdBy));
// build the insert statement
if (!StringUtils.IsNull(syncAddressID))
{
insertStatement.Append(", SYNC_ADDRESS_ID");
valuesStatement.Append(", :syncAddressID");
parameters.Add(DBUtils.CreateOracleParameter("syncAddressID", OracleDbType.Decimal, syncAddressID));
}
if (!StringUtils.IsNull(postalDeliveryTypeCode))
{
insertStatement.Append(", POSTAL_DELIVERY_TYPE_CODE");
valuesStatement.Append(", :postalDeliveryTypeCode ");
parameters.Add(DBUtils.CreateOracleParameter("postalDeliveryTypeCode", OracleDbType.Varchar2, postalDeliveryTypeCode));
}
if (!StringUtils.IsNull(postalDeliveryNum))
{
insertStatement.Append(", POSTAL_DELIVERY_NUM");
valuesStatement.Append(", :postalDeliveryNum ");
parameters.Add(DBUtils.CreateOracleParameter("postalDeliveryNum", OracleDbType.Varchar2, postalDeliveryNum));
}
if (!StringUtils.IsNull(flatUnitTypeCode))
{
insertStatement.Append(", FLAT_UNIT_TYPE_CODE");
valuesStatement.Append(", :flatUnitTypeCode ");
parameters.Add(DBUtils.CreateOracleParameter("flatUnitTypeCode", OracleDbType.Varchar2, flatUnitTypeCode));
}
if (!StringUtils.IsNull(lat))
{
insertStatement.Append(", LAT");
valuesStatement.Append(", :lat ");
parameters.Add(DBUtils.CreateOracleParameter("lat", OracleDbType.Decimal, lat));
}
if (!StringUtils.IsNull(lon))
{
insertStatement.Append(", LON");
valuesStatement.Append(", :lon ");
parameters.Add(DBUtils.CreateOracleParameter("lon", OracleDbType.Decimal, lon));
}
if (!StringUtils.IsNull(zoom))
{
insertStatement.Append(", ZOOM");
valuesStatement.Append(", :zoom ");
parameters.Add(DBUtils.CreateOracleParameter("zoom", OracleDbType.Decimal, zoom));
}
if (!StringUtils.IsNull(flatUnitNum))
{
insertStatement.Append(", FLAT_UNIT_NUM");
valuesStatement.Append(", :flatUnitNum ");
parameters.Add(DBUtils.CreateOracleParameter("flatUnitNum", OracleDbType.Varchar2, flatUnitNum));
}
if (!StringUtils.IsNull(floorLevelCode))
{
insertStatement.Append(", FLOOR_LEVEL_CODE");
valuesStatement.Append(", :floorLevelCode ");
parameters.Add(DBUtils.CreateOracleParameter("floorLevelCode", OracleDbType.Varchar2, floorLevelCode));
}
if (!StringUtils.IsNull(floorLevelNum))
{
insertStatement.Append(", FLOOR_LEVEL_NUM");
valuesStatement.Append(", :floorLevelNum ");
parameters.Add(DBUtils.CreateOracleParameter("floorLevelNum", OracleDbType.Varchar2, floorLevelNum));
}
if (!StringUtils.IsNull(buildingName))
{
insertStatement.Append(", BUILDING_NAME");
valuesStatement.Append(", :buildingName ");
parameters.Add(DBUtils.CreateOracleParameter("buildingName", OracleDbType.Varchar2, buildingName));
}
if (!StringUtils.IsNull(lotNumber))
{
insertStatement.Append(", LOT_NUMBER");
valuesStatement.Append(", :lotNumber ");
parameters.Add(DBUtils.CreateOracleParameter("lotNumber", OracleDbType.Varchar2, lotNumber));
}
if (!StringUtils.IsNull(streetNumber))
{
insertStatement.Append(", STREET_NUMBER");
valuesStatement.Append(", :streetNumber ");
parameters.Add(DBUtils.CreateOracleParameter("streetNumber", OracleDbType.Varchar2, streetNumber));
}
if (!StringUtils.IsNull(streetName))
{
insertStatement.Append(", STREET_NAME");
valuesStatement.Append(", :streetName ");
parameters.Add(DBUtils.CreateOracleParameter("streetName", OracleDbType.Varchar2, streetName));
}
if (!StringUtils.IsNull(streetTypeCode))
{
insertStatement.Append(", STREET_TYPE_CODE");
valuesStatement.Append(", :streetTypeCode ");
parameters.Add(DBUtils.CreateOracleParameter("streetTypeCode", OracleDbType.Varchar2, streetTypeCode));
}
if (!StringUtils.IsNull(streetSuffixCode))
{
insertStatement.Append(", STREET_SUFFIX_CODE");
valuesStatement.Append(", :streetSuffixCode ");
parameters.Add(DBUtils.CreateOracleParameter("streetSuffixCode", OracleDbType.Varchar2, streetSuffixCode));
}
if (!StringUtils.IsNull(locality))
{
insertStatement.Append(", LOCALITY");
valuesStatement.Append(", :locality");
parameters.Add(DBUtils.CreateOracleParameter("locality", OracleDbType.Varchar2, locality));
}
if (!StringUtils.IsNull(stateCode))
{
insertStatement.Append(", STATE_CODE");
valuesStatement.Append(", :stateCode");
parameters.Add(DBUtils.CreateOracleParameter("stateCode", OracleDbType.Varchar2, stateCode));
}
if (!StringUtils.IsNull(postcode))
{
insertStatement.Append(", POSTCODE");
valuesStatement.Append(", :postcode ");
parameters.Add(DBUtils.CreateOracleParameter("postcode", OracleDbType.Varchar2, postcode));
}
if (!StringUtils.IsNull(country))
{
insertStatement.Append(", COUNTRY");
valuesStatement.Append(", :country ");
parameters.Add(DBUtils.CreateOracleParameter("country", OracleDbType.Varchar2, country));
}
if (!StringUtils.IsNull(esmsAddress))
{
insertStatement.Append(", ESMS_ADDRESS");
valuesStatement.Append(", :esmsAddress ");
parameters.Add(DBUtils.CreateOracleParameter("esmsAddress", OracleDbType.Varchar2, esmsAddress));
}
if (!StringUtils.IsNull(validatedBy))
{
insertStatement.Append(", VALIDATED_DATE");
valuesStatement.Append(", SYSDATE ");
insertStatement.Append(", VALIDATED_BY");
valuesStatement.Append(", :validatedBy ");
parameters.Add(DBUtils.CreateOracleParameter("validatedBy", OracleDbType.Varchar2, validatedBy));
}
if (!StringUtils.IsNull(confidence))
{
insertStatement.Append(", VALIDATION_CONFIDENCE");
valuesStatement.Append(", :confidence ");
parameters.Add(DBUtils.CreateOracleParameter("confidence", OracleDbType.Decimal, confidence));
}
if (!StringUtils.IsNull(status))
{
insertStatement.Append(", VALIDATION_STATUS");
valuesStatement.Append(", :status ");
parameters.Add(DBUtils.CreateOracleParameter("status", OracleDbType.Varchar2, status));
}
if (!StringUtils.IsNull(quality))
{
insertStatement.Append(", VALIDATION_QUALITY");
valuesStatement.Append(", :quality ");
parameters.Add(DBUtils.CreateOracleParameter("quality", OracleDbType.Decimal, quality));
}
// finish off the statement
insertStatement.Append(") ");
valuesStatement.Append(")");
// build the insert statement
string sql = insertStatement + valuesStatement.ToString();
// Execute the INSERT Statement
Dml dmlDAO = new Dml();
int rowsAffected = dmlDAO.Execute(sql, transaction, parameters);
if (rowsAffected == 0)
{
throw new NoRowsAffectedException();
}
}
<< many more methods go here >>
}
Sistem ini dikembangkan oleh saya dan tim kecil pada tahun 2005 setelah 1 minggu kursus .NET. Sebelumnya, pengalaman saya ada di aplikasi client-server. Selama 5 tahun terakhir saya telah mengakui manfaat pengujian unit otomatis, pengujian integrasi otomatis, dan pengujian penerimaan otomatis (menggunakan Selenium atau yang setara) tetapi basis kode saat ini tampaknya tidak mungkin untuk memperkenalkan konsep-konsep ini.
Kami sekarang mulai bekerja pada proyek peningkatan besar dengan kerangka waktu yang ketat. Tim ini terdiri dari 5 pengembang .NET - 2 pengembang dengan beberapa tahun pengalaman .NET dan 3 lainnya dengan sedikit atau tanpa pengalaman .NET. Tak satu pun dari tim (termasuk saya) yang memiliki pengalaman dalam menggunakan .NET unit testing atau kerangka kerja mengejek.
Strategi apa yang akan Anda gunakan untuk membuat kode ini lebih bersih, lebih berorientasi objek, dapat diuji dan dipelihara?
sumber
Jawaban:
Anda menyebutkan dua buku yang salah satu pesan utamanya adalah "Aturan Pramuka" yaitu membersihkan kode saat Anda menyentuhnya. Jika Anda memiliki sistem kerja, penulisan ulang besar-besaran adalah kontra-produktif. Alih-alih, saat Anda menambahkan fungsionalitas baru, pastikan Anda meningkatkan kodenya.
Untuk menyelam lebih jauh, Feathers berbicara tentang pengujian aplikasi di jahitannya: titik logis di mana unit terhubung. Anda dapat memanfaatkan jahitan untuk membuat rintisan atau tiruan untuk dependensi sehingga Anda dapat menulis tes di sekitar objek dependen. Mari kita ambil AddressBO Anda sebagai contoh
Ada perbedaan yang jelas antara AddressBO dan AddressDAO. Mari kita membuat antarmuka untuk AddressDAO dan memungkinkan dependensi untuk disuntikkan ke AddressBO.
Sekarang Anda menyiapkan AddressBO untuk memungkinkan injeksi
Di sini kita menggunakan "injeksi ketergantungan orang miskin." Satu-satunya tujuan kami adalah untuk memecahkan jahitan dan memungkinkan kami untuk menguji AddressBO. Sekarang dalam unit test kami, kami dapat membuat tiruan IAddressDAO dan memvalidasi interaksi antara dua objek.
sumber
Jika saya ingat benar Bekerja Efektif dengan Legacy Code mengatakan penulisan ulang penuh tidak menjamin bahwa kode baru akan lebih baik daripada yang lama (dari sudut pandang fungsionalitas / cacat). Perbaikan dalam buku itu adalah untuk saat memperbaiki bug / menambahkan fitur baru.
Buku lain yang akan saya rekomendasikan adalah Pengembangan Aplikasi Brownfield di .NET yang pada dasarnya mengatakan untuk tidak menulis ulang secara penuh juga. Ini berbicara tentang membuat perubahan yang stabil dan berulang setiap kali Anda menambahkan fitur baru atau memperbaiki cacat. Ini membahas pertimbangan biaya vs manfaat dan memperingatkan tentang mencoba menggigit terlalu banyak pada satu waktu. Sementara Bekerja Efektif dengan Legacy Code sebagian besar berbicara tentang cara refactor pada tingkat mikro / kode, Pengembangan Aplikasi Brownfield di .NET , sebagian besar mencakup pertimbangan tingkat yang lebih tinggi ketika re-factoring (bersama dengan beberapa hal tingkat kode juga).
Buku Brownfield juga menyarankan mencari tahu area mana dari kode yang menyebabkan Anda paling banyak kesulitan dan fokus di sana. Area lain mana pun yang tidak membutuhkan banyak perawatan mungkin tidak layak diubah.
sumber
Untuk aplikasi lawas seperti itu, jauh lebih hemat biaya untuk memulai dengan menutupinya dengan tes integrasi tingkat tinggi (otomatis) daripada tes unit. Kemudian dengan tes integrasi sebagai jaring pengaman Anda, Anda dapat memulai refactoring dalam langkah-langkah kecil jika sesuai, yaitu jika biaya refactoring membayar sendiri kembali dalam jangka panjang. Seperti yang telah dicatat oleh orang lain, ini tidak terbukti dengan sendirinya.
Lihat juga jawaban saya sebelumnya untuk pertanyaan serupa; semoga bermanfaat.
sumber
Berhati-hatilah dengan membuang dan menulis ulang kode yang sedang berjalan ( Hal-hal yang tidak boleh Anda lakukan ). Tentu itu mungkin jelek, tetapi jika berhasil biarkan saja. Lihat posting blog Joel, tentu berumur 10+ tahun, tetapi masih tepat sasaran.
sumber
Seperti yang dinyatakan Mike 'aturan pramuka' mungkin yang terbaik di sini, jika kodenya berfungsi dan bukan sumber laporan bug yang konstan, saya lebih suka membiarkannya duduk di sana dan memperbaikinya perlahan seiring waktu.
Selama proyek peningkatan Anda memungkinkan cara-cara baru dalam melakukan sesuatu. Misalnya menggunakan ORM untuk fitur baru dan memotong pola lapisan data yang ada. Saat Anda mengalami peningkatan yang perlu menyentuh kode yang ada, Anda mungkin dapat memindahkan beberapa kode terkait ke cara baru dalam melakukan sesuatu. Menggunakan fasad atau beberapa adaptor di tempat dapat membantu Anda mengisolasi kode lama, bahkan mungkin per lapisan. Ini bisa membantu Anda membuat kode lama kelaparan.
Demikian pula ini dapat membantu Anda dengan menambahkan tes unit, Anda dapat mulai dengan kode baru yang Anda buat dan perlahan-lahan menambahkan beberapa tes untuk kode lama Anda harus menyentuh untuk perangkat tambahan baru.
sumber
Keduanya buku yang bagus. Jika Anda akan mulai menulis ulang kode dengan cara itu, saya pikir penting juga untuk mulai menutup kode dengan tes unit untuk membantu menjaga kestabilannya saat Anda menulis ulang.
Itu harus dilakukan dalam langkah-langkah kecil dan memodifikasi kode semacam itu dapat dengan mudah mengacaukan seluruh sistem.
Saya tidak akan mengubah kode apa pun yang sedang tidak Anda kerjakan. Hanya lakukan ini pada kode yang Anda secara aktif tingkatkan atau perbaiki. Jika sesuatu melayani tujuan itu tetapi belum dimodifikasi dalam beberapa tahun kemudian tinggalkan saja. Melakukan hal itu bahkan jika Anda tahu cara yang lebih baik.
Pada akhirnya perusahaan membutuhkan produktivitas. Sementara kode yang lebih baik meningkatkan produktivitas, menulis ulang kode hanya karena itu bisa ditulis lebih baik mungkin bukan cara terbaik untuk membawa nilai pada produk Anda.
sumber