Ana içeriğe geç

Çağrı Verisi Optimizasyonu için Kısa ABI'ler

katman 2
Orta düzey
Ori Pomerantz
1 Nisan 2022
13 dakikalık okuma minute read

Giriş

Bu makalede iyimser toplamalar, onların işlem ücretleri ve bu farklı maliyet yapısının Ethereum Ana Ağı'ndakilere göre farklı şeyler için optimizasyon yapmamızı nasıl şart koştuğu hakkında bilgi edineceksiniz. Aynı zamanda bu optimizasyon işlemini nasıl uygulayacağınızı da göreceksiniz.

Bilgilendirme

Ben tam zamanlı bir "Optimism"(opens in a new tab) çalışanıyım, bu yüzden bu makaledeki örnekler Optimism üzerinde çalışabilecek örnekler olacaktır. Ancak, burada anlatacağım teknik diğer toplamalarda da işe yarayacaktır.

Terminoloji

Toplamalar üzerinde konuşurken üretim Ethereum Ağı olan Ana Ağ için "katman 1 (L1)" terimi kullanılacaktır. "Katman 2 (L2)" terimi ise toplama veya güvenliği L1'e dayanan fakat işlemlerinin çoğunu zincir dışında yapan her türlü sistem için kullanılacaktır.

L2 işlemlerinin maliyetlerini nasıl daha da azaltabiliriz?

İyimser toplamalar, insanların sonradan gözden geçirip durumun doğru olup olmadığını kontrol edebilmesi için tüm geçmiş işlemlerin kayıtlarını tutmalıdır. Verileri Ethereum Ana Ağı'na sokabilmenin en uygun yolu, onları çağrı verisi olarak yazmaktır. Bu çözüm, hem Optimism(opens in a new tab) hem de Arbitrum(opens in a new tab) tarafından tercih edilmiştir.

L2 işlemlerinin maliyeti

L2 işlemlerinin maliyetleri iki bileşenden oluşur:

  1. L2 işlemi, genelde çok ucuzdur
  2. L1 depolaması, Ana Ağ'ın gaz ücretlerine bağlıdır

Bunu yazarken, Optimism'de L2 gazının maliyeti 0,001 Gwei idi. L1 gazının maliyeti ise şu an yaklaşık 40 Gwei'dir. Güncel fiyatları buradan inceleyebilirsiniz(opens in a new tab).

Çağrı verisinin bir baytı 4 gaz (eğer sıfırsa) veya 16 gazdır (eğer farklı bir değerse). EVM'deki en pahalı işlemlerden biri, depolamaya yazmaktır. 32 baytlık bir kelimeyi L2'deki bir depoya yazmanın maksimum maliyeti 22100 gazdır. Şu anda bu 22,1 Gwei'ye tekabül ediyor. Yani eğer sıfır baytlık bir çağrı verisi tasarruf etmemiz, depolamaya 200 bayt bile yazsak hala kârda olabileceğimizi gösteriyor.

ABI

İşlemlerin büyük bir çoğunluğu, bir sözleşmeye dıştan sahiplenilmiş bir hesaptan erişir. Çoğu sözleşme Solidity ile yazılmıştır ve veri alanlarını uygulama ikili arayüzü (ABI)(opens in a new tab) ile uyumlu olacak şekilde yorumlar.

Bununla birlikte ABI, bir çağrı verisi baytının maliyetinin yaklaşık olarak dört aritmetik işlemle aynı olduğu L1 için tasarlanmıştır; bir çağrı verisi baytının bin aritmetik işlemden daha pahalı olduğu L2 için değil. Örneğin, bir ERC-20 transfer işlemini burada bulabilirsiniz(opens in a new tab). Çağrı verisi şu şekilde bölünür:

BölümUzunlukBaytlarHarcanan baytHarcanan gazGereken baytGereken gaz
Fonksiyon seçici40-3348116
Sıfırlar124-15124800
Varış adresi2016-350020320
Miktar3236-67176415240
Toplam68160576

Açıklama:

L1 üzerinde harcanan 160 gaz normalde göz ardı edilebilir bir değerdir. Bir işlemin maliyeti en az 21.000 gazdır(opens in a new tab), yani ekstra %0,8'in bir önemi yoktur. Fakat L2'de işler biraz daha farklıdır. Buradaki işlem maliyetinin neredeyse tamamı işlemi L1'e yazmaktır. İşlem çağrı verisine ek olarak, 109 baytlık bir işlem başlığı vardır (varış adresi, imza vs.). Toplam maliyet 109*16+576+160=2480 kadardır ve bunun %65'ini boşa harcıyoruz.

Hedefi kontrol etmediğimiz durumlarda maliyetleri azaltma

Hedef sözleşme üzerinde kontrolünüz olmadığını varsayarsak, yine de buna(opens in a new tab) benzer bir çözüm yolu kullanabilirsiniz. Hadi ilgili dosyalara bir göz atalım.

Token.sol

Bu, hedef sözleşmedir(opens in a new tab). Bu, bir ek özellikle gelen standart bir ERC-20 sözleşmesidir. Bu faucet, her kullanıcının kullanabilmek için biraz jeton almasını sağlar. Bu, üretim ERC-20 sözleşmesini gereksiz kılabilecek olsa da, ERC-20 sadece test yapmayı kolaylaştırmak amaçlı var olduğunda işleri gerçekten kolaylaştırıyor.

1 /**
2 * @dev Gives the caller 1000 tokens to play with
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet
Kopyala

Burada bu sözleşmenin dağıtılmış olduğu bir örneği görebilirsiniz(opens in a new tab).

CalldataInterpreter.sol

Bu, işlemlerin daha küçük çağrı verileriyle çağırması gereken sözleşmedir(opens in a new tab). Hadi satır satır inceleyelim.

1//SPDX-License-Identifier: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";
Kopyala

Nasıl çağırabileceğimizi bilmek için jeton işlevine ihtiyacımız var.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;
Kopyala

Bizim vekil olduğumuz jetonun adresi.

1
2 /**
3 * @dev Specify the token address
4 * @param tokenAddr_ ERC-20 contract address
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
Tümünü göster
Kopyala

Jetonun adresi belirtmemiz gereken tek parametredir.

1 function calldataVal(uint startByte, uint length)
2 private pure returns (uint) {
Kopyala

Çağrı verisinden bir değer okuyalım.

1 uint _retVal;
2
3 require(length < 0x21,
4 "calldataVal length limit is 32 bytes");
5
6 require(length + startByte <= msg.data.length,
7 "calldataVal trying to read beyond calldatasize");
Kopyala

32 baytlık (256 bit) tek bir kelimeyi belleğe yükleyecek ve istediğimiz alanın bir parçası olmayan baytlardan kurtulacağız. Bu algoritma, 32 bayttan daha büyük değerler için işe yaramaz ve tabi ki çağrı verisini okuyup geçemeyiz. L1'de gaz tasarrufu için bu testleri atlamak gerekli olabilir fakat L2'de gaz oldukça ucuzdur ve düşünebileceğimiz her mantık kontrolünü yapabilmemize olanak sağlar.

1 assembly {
2 _retVal := calldataload(startByte)
3 }
Kopyala

fallback() (aşağıya bakın) çağrısından verileri kopyalayabilirdik, fakat EVM'nin derleme dili olan Yul(opens in a new tab)'u kullanmak daha kolaydır.

Burada, baytları okuyup yığına yerleştirmek için (startByte ve startByte+31) CALLDATALOAD işlem kodunu(opens in a new tab) kullanıyoruz. Genelde, Yul'daki bir işlem kodunun söz dizimi şu şekildedir: <opcode name>(<first stack value, if any>,<second stack value, if any>...).

1
2 _retVal = _retVal >> (256-length*8);
Kopyala

Sadece en önemli length baytları alanın bir parçasıdır, bu yüzden diğer verilerden kurtulmak için sağa kaydırma(opens in a new tab) kullanıyoruz. Bu işlem, değeri alanın sağına taşıma avantajını sağlıyor; yani değer çarpı 256something yerine değerin kendisini kullanmış oluyoruz.

1
2 return _retVal;
3 }
4
5
6 fallback() external {
Kopyala

Bir Solidity sözleşmesine yapılan çağrı hiçbir işlev imzasıyla eşleşmezse, fallback() fonksiyonunu(opens in a new tab) çağırır (bir tane olduğunu varsayarak). CalldataInterpreter söz konusu olduğunda, başka bir external veya public işlev olmadığından her çağrı buraya ulaşır.

1 uint _func;
2
3 _func = calldataVal(0, 1);
Kopyala

Çağrı verisinin ilk baytını okuyun, bu bize fonksiyonu anlatır. Burada bir fonksiyonun ulaşılabilir olmamasının iki sebebi vardır:

  1. pure veya view olan fonksiyonlar, durumu değiştirmezler ve gaz maliyetleri yoktur (zincir dışı olarak çağrıldıklarında). O yüzden gaz maliyetini düşürmeye çalışmanın da bir anlamı yoktur.
  2. msg.sender(opens in a new tab)'a bağımlı olan fonksiyonlar. msg.sender'ın değeri, çağıranın değil CalldataInterpreter'ın adresi olacaktır.

Malesef, ERC-20'nin özelliklerine bakıldığında(opens in a new tab) bu bize sadece bir fonksiyon bırakıyor: transfer. Bu da bize 2 fonsiyon bırakıyor: transfer (çünkü transferFrom çağrısı yapabiliyoruz) ve faucet (çünkü jetonları bizi kim çağırdıysa ona transfer edebiliyoruz).

1
2 // Call the state changing methods of token using
3 // information from the calldata
4
5 // faucet
6 if (_func == 1) {
Kopyala

faucet()'a yapılan parametresiz bir çağrı.

1 token.faucet();
2 token.transfer(msg.sender,
3 token.balanceOf(address(this)));
4 }
Kopyala

token.faucet()'i çağırdıktan sonra jetonlara sahip oluyoruz. Fakat vekil sözleşmesi olarak, jetonlara ihtiyaç duymuyoruz. Ama EOA (dışarıdan sahip olunan hesap) ya da bizi çağıran sözleşme duyuyor. Yani biz bizi kim çağırırsa ona tüm jetonlarımızı transfer ediyoruz.

1 // transfer (assume we have an allowance for it)
2 if (_func == 2) {
Kopyala

Jeton transferi iki parametreye ihtiyaç duyuyor: hedef adres ve miktar.

1 token.transferFrom(
2 msg.sender,
Kopyala

Kullanıcıların sadece kendi sahip oldukları jetonları transfer etmesine izin veriyoruz

1 address(uint160(calldataVal(1, 20))),
Kopyala

Hedef adres, 1 numaralı baytta başlıyor (0 numaralı bayt fonksiyonun kendisi). Bir adres olarak uzunluğu 20 bayttır.

1 calldataVal(21, 2)
Kopyala

Bu spesifik sözleşme için birinin isteyebileceği maksimum jeton sayısının 2 bayta sığacağını varsayıyoruz (65536'dan daha az).

1 );
2 }
Kopyala

Ortalama olarak bir transfer 35 bayt kadar çağrı verisi kaplar:

BölümUzunlukBayt
Fonksiyon seçici10
Varış adresi321-32
Miktar233-34
1 } // fallback
2
3} // contract CalldataInterpreter
Kopyala

test.js

Bu Javascript birim testi(opens in a new tab) bize bu mekanizmayı nasıl kullanacağımızı (ve nasıl doğru çalışacağını onaylayacağımızı) gösteriyor. chai(opens in a new tab) and ethers(opens in a new tab) kısımlarını anladığınızı varsayıp sadece sözleşme için geçerli olan kısımları anlatacağım.

1const { expect } = require("chai");
2
3describe("CalldataInterpreter", function () {
4 it("Should let us use tokens", async function () {
5 const Token = await ethers.getContractFactory("OrisUselessToken")
6 const token = await Token.deploy()
7 await token.deployed()
8 console.log("Token addr:", token.address)
9
10 const Cdi = await ethers.getContractFactory("CalldataInterpreter")
11 const cdi = await Cdi.deploy(token.address)
12 await cdi.deployed()
13 console.log("CalldataInterpreter addr:", cdi.address)
14
15 const signer = await ethers.getSigner()
Tümünü göster
Kopyala

Her iki sözleşmeyi dağıtarak başlıyoruz.

1 // Get tokens to play with
2 const faucetTx = {

Normalde işlem oluşturmak için kullandığımız yüksek seviyeli fonksiyonları (token.faucet() gibi) kullanamıyoruz, çünkü biz ABI'yi uygulamıyoruz. Bunun yerine, işlemi kendimiz oluşturmalı ve sonrasında göndermeliyiz.

1 to: cdi.address,
2 data: "0x01"

İşlem için temin etmemiz gereken 2 parametre var:

  1. to, hedef adres. Bu, çağrı verisi yorumlama sözleşmesidir.
  2. data, gönderilecek çağrı verisi. Bir musluk çağrısı durumunda veri tek bayttır, 0x01.
1
2 }
3 await (await signer.sendTransaction(faucetTx)).wait()

İmza sahibinin sendTransaction yöntemini(opens in a new tab) çağırıyoruz. Çünkü hedefi çoktan belirledik (faucetTx.to) ve artık imzalanacak olan işleme ihtiyacımız var.

1// Check the faucet provides the tokens correctly
2expect(await token.balanceOf(signer.address)).to.equal(1000)

Burada bakiyeyi onaylıyoruz. view fonsiyonlarında gaz tasarrufuna gerek yoktur, bu yüzden bunları sadece normal şekilde çalıştırıyoruz.

1// CDI'ye bir izin verin (onaylar vekalet edilemez)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Çağrı verisi yorumlayıcısına transferleri yapabilmesi için bir ödenek verin.

1// Transfer tokens
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}

Bir transfer işlemi oluşturun. İlk bayt "0x02"dir ve ardından hedef adres gelir; son olarak da miktar bulunur (0x0100, ondalık olarak 256).

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Check that we have 256 tokens less
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // And that our destination got them
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Tümünü göster

Örnek

Bu dosyaları kendiniz çalıştırmadan çalışırken görmek istiyorsanız, şu bağlantıları izleyin:

  1. OrisUselessToken(opens in a new tab)'ın 0x950c753c0edbde44a74d3793db738a318e9c8ce8(opens in a new tab) adresine dağıtılması.
  2. CalldataInterpreter(opens in a new tab)'ın 0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55(opens in a new tab) adresine dağıtılması.
  3. faucet()(opens in a new tab) çağrısı.
  4. OrisUselessToken.approve()(opens in a new tab) çağrısı. Bu çağrı doğrudan jeton sözleşmesine gider, çünkü işleme msg.sender'a dayanır.
  5. transfer()(opens in a new tab) çağrısı.

Hedef sözleşmeyi kontrol ederken maliyeti azaltma

Eğer hedef sözleşme üzerinde gerçekten kontrolünüz varsa msg.sender'i atlatabilen fonksiyonlar oluşturabilirsiniz. Çünkü bunlar çağrı verisi yorumlayıcısına güvenir. Burada bunun, control-contract bölümü(opens in a new tab) içerisinde nasıl çalıştığına dair bir örnek görebilirsiniz.

Eğer sözleşme sadece harici sözleşmelere cevap veriyorsa, bunu sadece tek bir sözleşmeye sahip olarak halledebiliriz. Fakat bu birleştirilebilirliiği bozardı. Normal ERC-20 çağrılarına yanıt veren bir sözleşmeye ve küçük çağrı verilerine cevap verebilen başka bir sözleşmeye sahip olmak çok daha iyidir.

Token.sol

Bu örnekte, Token.sol'u modifiye ediyoruz. Bu, bizim sadece vekilin çağırabileceği bir çok fonksiyona sahip olmamızı sağlıyor. İşte yeni bölümler:

1 // The only address allowed to specify the CalldataInterpreter address
2 address owner;
3
4 // The CalldataInterpreter address
5 address proxy = address(0);
Kopyala

ERC-2O sözleşmesi yetkili vekilin kimliğini bilmelidir. Fakat, oluşturucu içindeki bu değişkeni biz ayarlayamayız, çünkü değeri henüz bilmiyoruz. Bu sözleşme, vekil jetonun adresinin oluşturucusunda olmasını beklediğinden ilk somutlaştırılan sözleşmedir.

1 /**
2 * @dev Calls the ERC20 constructor.
3 */
4 constructor(
5 ) ERC20("Oris useless token-2", "OUT-2") {
6 owner = msg.sender;
7 }
Kopyala

Vekili belirlemesine izin verilen tek adres olduğundan yaratıcının adresi (owner) de burada depolanır.

1 /**
2 * @dev set the address for the proxy (the CalldataInterpreter).
3 * Can only be called once by the owner
4 */
5 function setProxy(address _proxy) external {
6 require(msg.sender == owner, "Can only be called by owner");
7 require(proxy == address(0), "Proxy is already set");
8
9 proxy = _proxy;
10 } // function setProxy
Tümünü göster
Kopyala

Güvenlik kontrollerini atlayabildiği için vekilin ayrıcalıklı erişimi vardır. Vekile güvenebileceğimizden emin olmak için bu fonksiyonu sadece 1 kereliğine owner'ın çağırmasına izin veriyoruz. proxy'nin gerçek bir değeri olduğunda (sıfır dışında), o değer değişemez; sözleşme sahibi kötü niyetli olarak bunu değiştirmeye kalksa veya anımsatıcısı açığa çıksa bile hala güvendeyiz demektir.

1 /**
2 * @dev Some functions may only be called by the proxy.
3 */
4 modifier onlyProxy {
Kopyala

Bu modifier fonksiyonudur(opens in a new tab) ve diğer fonksiyonların çalışma şeklini değiştirebilir.

1 require(msg.sender == proxy);
Kopyala

İlk olarak, başkası tarafından değil, vekil tarafından çağrıldığımızı doğrulayalım. Eğer değilse, revert kullanın.

1 _;
2 }
Kopyala

Doğrulayabiliyorsa, değiştirdiğimiz fonksiyonu çalıştıralım.

1 /* Proxy'nin hesaplar için gerçekten proxy yapmasına izin veren işlevler */
2
3 function transferProxy(address from, address to, uint256 amount)
4 public virtual onlyProxy() returns (bool)
5 {
6 _transfer(from, to, amount);
7 return true;
8 }
9
10 function approveProxy(address from, address spender, uint256 amount)
11 public virtual onlyProxy() returns (bool)
12 {
13 _approve(from, spender, amount);
14 return true;
15 }
16
17 function transferFromProxy(
18 address spender,
19 address from,
20 address to,
21 uint256 amount
22 ) public virtual onlyProxy() returns (bool)
23 {
24 _spendAllowance(from, spender, amount);
25 _transfer(from, to, amount);
26 return true;
27 }
Tümünü göster
Kopyala

Bunlar normalde mesajın doğrudan jeton aktaran veya bir ödeneği onaylayan kuruluştan gelmesini gerektiren üç işlemdir. Burada bu işlemlerin şu nitelikleri taşıyan vekil versiyonları mevcuttur:

  1. Başka hiç kimse kontrol sahibi olamasın diye onlyProxy() tarafından değiştirilmiş.
  2. Normalde msg.sender olan adresi ekstra parametre olarak alan.

CalldataInterpreter.sol

Çağrı verisi yorumlayıcısı neredeyse yukardakiyle aynı olmasına rağmen şu noktada ayrışır: vekil fonksiyonlar msg.sender parametresi alır ve transfer için herhangi bir ödeneğe ihtiyaç yoktur.

1 // transfer (no need for allowance)
2 if (_func == 2) {
3 token.transferProxy(
4 msg.sender,
5 address(uint160(calldataVal(1, 20))),
6 calldataVal(21, 2)
7 );
8 }
9
10 // approve
11 if (_func == 3) {
12 token.approveProxy(
13 msg.sender,
14 address(uint160(calldataVal(1, 20))),
15 calldataVal(21, 2)
16 );
17 }
18
19 // transferFrom
20 if (_func == 4) {
21 token.transferFromProxy(
22 msg.sender,
23 address(uint160(calldataVal( 1, 20))),
24 address(uint160(calldataVal(21, 20))),
25 calldataVal(41, 2)
26 );
27 }
Tümünü göster
Kopyala

Test.js

Az önceki test kodları ve aşağıdakinin arasında birkaç değişiklik vardır.

1const Cdi = await ethers.getContractFactory("CalldataInterpreter")
2const cdi = await Cdi.deploy(token.address)
3await cdi.deployed()
4await token.setProxy(cdi.address)
Kopyala

ERC-20 sözleşmesine hangi vekil sunucuya güveneceğini aktarmamız gerekir

1console.log("CalldataInterpreter addr:", cdi.address)
2
3// Need two signers to verify allowances
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]
Kopyala

approve() ve transferFrom()'u kontrol edebilmek için ikinci bir imza sahibine ihtiyacımız var. Buna poorSigner adını veriyoruz çünkü bizim jetonlarımızın hiçbirini almıyor (elbette ETH sahibi olmasına gerek yok).

1// Transfer tokens
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}
7await (await signer.sendTransaction(transferTx)).wait()
Kopyala

ERC-20 sözleşmesi (cdi) vekile güvendiğinden transferleri aktarmak için ödeneğe ihtiyaç duymayız.

1// approval and transferFrom
2const approveTx = {
3 to: cdi.address,
4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
5}
6await (await signer.sendTransaction(approveTx)).wait()
7
8const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
9
10const transferFromTx = {
11 to: cdi.address,
12 data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",
13}
14await (await poorSigner.sendTransaction(transferFromTx)).wait()
15
16// Check the approve / transferFrom combo was done correctly
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Tümünü göster
Kopyala

İki yeni fonksiyonu test edin. transferFromTx öğesinin iki adres parametresi gerektirdiğini unutmayın: ödeneği veren ve alıcı.

Örnek

Bu dosyaları kendiniz çalıştırmadan çalışırken görmek istiyorsanız, şu bağlantıları izleyin:

  1. OrisUselessToken-2(opens in a new tab)'ın 0xb47c1f550d8af70b339970c673bbdb2594011696(opens in a new tab) adresine dağıtılması.
  2. CalldataInterpreter'ın(opens in a new tab)0x0dccfd03e3aaba2f8c4ea4008487fd0380815892(opens in a new tab) adresine dağıtılması.
  3. setProxy()(opens in a new tab) çağrısı.
  4. faucet()(opens in a new tab) çağrısı.
  5. transferProxy()(opens in a new tab) çağrısı.
  6. approveProxy()(opens in a new tab) çağrısı.
  7. transferFromProxy()(opens in a new tab) çağrısı. Bu çağrının diğerlerinden farklı bir adresten geldiğini de unutmayın; poorSigner yerine signer.

Sonuç

Hem Optimism(opens in a new tab) hem de Arbitrum(opens in a new tab), L1'e yazılan çağrı verilerinin boyutunu ve dolayısıyla işlem maliyetlerini azaltmanın yollarını aramaktadır. Fakat altyapı sağlayıcıları genel çözümler arıyorken, bizim yapabileceklerimiz sınırlıdır. Merkeziyetsiz uygulama geliştiricisi olarak uygulamaya özel bilgilere sahipsiniz. Bu da sizin çağrı verilerinizi bizim genel bir çözümle yapabileceğimize göre çok daha iyi optimize edebilmenizi mümkün kılar. Umarım bu makale, ihtiyaçlarınız için ideal çözümler bulmanıza yardımcı olur.

Son düzenleme: @Shiva-Sai-ssb(opens in a new tab), 30 Haziran 2024

Bu rehber yararlı oldu mu?