Önbelleğe alabileceğiniz her şey
Toplamaları kullanırken bir işlemdeki bir baytın maliyeti, bir depolama yuvasının maliyetinden çok daha pahalıdır. Bu nedenle, mümkün olduğunca çok bilgiyi zincir üstünde önbelleğe almak mantıklıdır.
Bu makalede, birden çok kez kullanılması muhtemel herhangi bir parametre değerinin önbelleğe alınacağı ve (ilk seferden sonra) çok daha az sayıda bayt ile kullanıma sunulacağı şekilde bir önbelleğe alma sözleşmesinin nasıl oluşturulacağını ve kullanılacağını ve bu önbelleği kullanan zincir dışı kodun nasıl yazılacağını öğreneceksiniz.
Makaleyi atlayıp yalnızca kaynak kodunu görmek isterseniz, buradadır (opens in a new tab). Geliştirme yığını Foundry (opens in a new tab)'dir.
Genel tasarım
Basitlik adına, tüm işlem parametrelerinin uint256 olduğunu ve 32 bayt uzunluğunda olduğunu varsayacağız. Bir işlem aldığımızda, her bir parametreyi şu şekilde ayrıştıracağız:
-
İlk bayt
0xFFise sonraki 32 baytı bir parametre değeri olarak alın ve önbelleğe yazın. -
İlk bayt
0xFEise sonraki 32 baytı bir parametre değeri olarak alın ancak önbelleğe yazmayın. -
Diğer herhangi bir değer için, ilk dört biti ek bayt sayısı olarak ve son dört biti önbellek anahtarının en anlamlı bitleri olarak alın. İşte bazı örnekler:
Calldata'daki baytlar Önbellek anahtarı 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
Önbellek manipülasyonu
Önbellek, Cache.sol (opens in a new tab) içinde uygulanmıştır. Satır satır üzerinden geçelim.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;345contract Cache {67 bytes1 public constant INTO_CACHE = 0xFF;8 bytes1 public constant DONT_CACHE = 0xFE;Bu sabitler, tüm bilgileri sağladığımız ve önbelleğe yazılmasını isteyip istemediğimiz özel durumları yorumlamak için kullanılır. Önbelleğe yazma, daha önce kullanılmamış depolama yuvalarına her biri 22100 gaz maliyetinde iki SSTORE (opens in a new tab) işlemi gerektirir, bu yüzden bunu isteğe bağlı hâle getiriyoruz.
12 mapping(uint => uint) public val2key;Değerler ve anahtarları arasında bir eşleme (opens in a new tab). Bu bilgi, işlemi göndermeden önce değerleri kodlamak için gereklidir.
1 // Konum n, n+1 anahtarının değerine sahiptir, çünkü sıfırı "önbellekte değil" olarak2 // korumamız gerekir.3 uint[] public key2val;Anahtarları atadığımız için anahtarlardan değerlere eşleme için bir dizi kullanabiliriz ve basitlik adına bunu sıralı olarak yaparız.
1 function cacheRead(uint _key) public view returns (uint) {2 require(_key <= key2val.length, "Reading uninitialize cache entry");3 return key2val[_key-1];4 } // cacheReadÖnbellekten bir değer okuyun.
1 // Değer zaten mevcut değilse önbelleğe bir değer yazın2 // Testin çalışmasını sağlamak için yalnızca public3 function cacheWrite(uint _value) public returns (uint) {4 // Değer zaten önbellekteyse mevcut anahtarı döndürün5 if (val2key[_value] != 0) {6 return val2key[_value];7 }Aynı değeri önbelleğe birden fazla kez koymanın bir anlamı yoktur. Değer zaten oradaysa, sadece mevcut anahtarı döndürün.
1 // 0xFE özel bir durum olduğundan, önbelleğin tutabileceği en büyük anahtar2 // 0x0D ve ardından 15 adet 0xFF'tir. Önbellek uzunluğu zaten bu kadar3 // büyükse başarısız olur.4 // 1 2 3 4 5 6 7 8 9 A B C D E F5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,6 "önbellek taşması");Hiçbir zaman bu kadar büyük bir önbelleğe sahip olacağımızı sanmıyorum (yaklaşık 1,8*1037 giriş, bu da depolamak için yaklaşık 1027 TB gerektirir). Ancak, "640kB her zaman yeterli olacaktır" (opens in a new tab) sözünü hatırlayacak kadar yaşlıyım. Bu test çok ucuzdur.
1 // Değeri bir sonraki anahtarı kullanarak yazın2 val2key[_value] = key2val.length+1;Ters aramayı ekleyin (değerden anahtara).
1 key2val.push(_value);İleriye doğru aramayı ekleyin (anahtardan değere). Değerleri sıralı olarak atadığımız için onu son dizi değerinden sonra ekleyebiliriz.
1 return key2val.length;2 } // cacheWriteYeni değerin depolandığı hücre olan key2val öğesinin yeni uzunluğunu döndürün.
1 function _calldataVal(uint startByte, uint length)2 private pure returns (uint)Bu işlev, calldata'dan rastgele uzunlukta (en fazla 32 bayt, kelime boyutu) bir değer okur.
1 {2 uint _retVal;34 require(length < 0x21,5 "_calldataVal uzunluk sınırı 32 bayttır");6 require(length + startByte <= msg.data.length,7 "_calldataVal calldatasize'ın ötesini okumaya çalışıyor");Bu işlev dahili olduğu için kodun geri kalanı doğru yazılırsa bu testler gerekli değildir. Ancak, maliyetleri çok yüksek olmadığı için kullanabiliriz.
1 assembly {2 _retVal := calldataload(startByte)3 }Bu kod Yul (opens in a new tab) dilindedir. Calldata'dan 32 baytlık bir değer okur. Bu, calldata startByte+32'den önce dursa bile çalışır çünkü EVM'deki başlatılmamış alanın sıfır olduğu kabul edilir.
1 _retVal = _retVal >> (256-length*8);İlla ki 32 baytlık bir değer istemiyoruz. Bu, fazla baytları ortadan kaldırır.
1 return _retVal;2 } // _calldataVal345 // Calldata'dan _fromByte'tan başlayarak tek bir parametre okuyun6 function _readParam(uint _fromByte) internal7 returns (uint _nextByte, uint _parameterValue)8 {Calldata'dan tek bir parametre okuyun. Parametreler 1 bayt ile 33 bayt arasında değişebileceğinden, yalnızca okuduğumuz değeri değil, aynı zamanda sonraki baytın konumunu da döndürmemiz gerektiğini unutmayın.
1 // İlk bayt bize gerisini nasıl yorumlayacağımızı söyler2 uint8 _firstByte;34 _firstByte = uint8(_calldataVal(_fromByte, 1));Solidity, potansiyel olarak tehlikeli örtük tür dönüşümlerini (opens in a new tab) yasaklayarak hata sayısını azaltmaya çalışır. Örneğin 256 bitten 8 bite bir tür küçültme işleminin açık olması gerekir.
12 // Değeri okuyun, ancak önbelleğe yazmayın3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Değeri okuyun ve önbelleğe yazın7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // Buraya geldiysek, önbellekten okuma yapmamız gerektiği anlamına gelir1415 // Okunacak ek bayt sayısı16 uint8 _extraBytes = _firstByte / 16;Tümünü gösterAlt nibble (opens in a new tab)'ı alın ve değeri önbellekten okumak için diğer baytlarla birleştirin.
1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +2 _calldataVal(_fromByte+1, _extraBytes);34 return (_fromByte+_extraBytes+1, cacheRead(_key));56 } // _readParam789 // n parametrelerini okuyun (fonksiyonlar kaç parametre beklediklerini bilirler)10 function _readParams(uint _paramNum) internal returns (uint[] memory) {Tümünü gösterSahip olduğumuz parametre sayısını calldata'nın kendisinden alabiliriz, ancak bizi çağıran fonksiyonlar kaç parametre beklediklerini bilir. Bize söylemelerine izin vermek daha kolaydır.
1 // Okuduğumuz parametreler2 uint[] memory params = new uint[](_paramNum);34 // Parametreler 4. baytta başlar, ondan öncesi fonksiyon imzasıdır5 uint _atByte = 4;67 for(uint i=0; i<_paramNum; i++) {8 (_atByte, params[i]) = _readParam(_atByte);9 }Tümünü gösterİhtiyacınız olan sayıya ulaşana kadar parametreleri okuyun. Calldata'nın sonunu geçersek, _readParams çağrıyı geri alır.
12 return(params);3 } // readParams45 // _readParams'ı test etmek için, dört parametrenin okunmasını test edin6 function fourParam() public7 returns (uint256,uint256,uint256,uint256)8 {9 uint[] memory params;10 params = _readParams(4);11 return (params[0], params[1], params[2], params[3]);12 } // fourParamTümünü gösterFoundry'nin büyük bir avantajı, testlerin Solidity'de yazılmasına izin vermesidir (aşağıdaki Önbelleği test etme bölümüne bakın). Bu, birim testlerini çok daha kolaylaştırır. Bu, testin doğru olduklarını doğrulayabilmesi için dört parametreyi okuyan ve döndüren bir fonksiyondur.
1 // Bir değer alın, onu kodlayacak baytları döndürün (mümkünse önbelleği kullanarak)2 function encodeVal(uint _val) public view returns(bytes memory) {encodeVal, önbelleği kullanan calldata oluşturmaya yardımcı olmak için zincir dışı kodun çağırdığı bir fonksiyondur. Tek bir değer alır ve onu kodlayan baytları döndürür. Bu fonksiyon bir view fonksiyonudur, bu nedenle bir işlem gerektirmez ve harici olarak çağrıldığında herhangi bir gaz maliyeti yoktur.
1 uint _key = val2key[_val];23 // Değer henüz önbellekte değil, ekleyin4 if (_key == 0)5 return bytes.concat(INTO_CACHE, bytes32(_val));EVM'de tüm başlatılmamış depolamanın sıfır olduğu varsayılır. Yani, orada olmayan bir değerin anahtarını ararsak sıfır elde ederiz. Bu durumda, onu kodlayan baytlar INTO_CACHE (böylece bir sonraki sefere önbelleğe alınacaktır), ardından gerçek değer gelir.
1 // Anahtar <0x10 ise tek bir bayt olarak döndürün2 if (_key < 0x10)3 return bytes.concat(bytes1(uint8(_key)));Tek baytlar en kolay olanlardır. Herhangi bir uzunlukta olabilen bir bytes<n> türünü bir bayt dizisine dönüştürmek için bytes.concat (opens in a new tab) kullanırız. İsmine rağmen, yalnızca bir argümanla sağlandığında gayet iyi çalışır.
1 // İki baytlık değer, 0x1vvv olarak kodlanmış2 if (_key < 0x1000)3 return bytes.concat(bytes2(uint16(_key) | 0x1000));163'ten küçük bir anahtarımız olduğunda, bunu iki baytla ifade edebiliriz. Önce 256 bitlik bir değer olan _key'i 16 bitlik bir değere dönüştürürüz ve ilk bayta ek bayt sayısını eklemek için mantıksal veya kullanırız. Sonra onu, bytes'a dönüştürülebilen bir bytes2 değerine dönüştürürüz.
1 // Muhtemelen aşağıdaki satırları bir döngü olarak yapmanın zekice bir yolu vardır,2 // ancak bu bir view fonksiyonu olduğundan programcı zamanı ve3 // basitlik için optimize ediyorum.45 if (_key < 16*256**2)6 return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));7 if (_key < 16*256**3)8 return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));9 .10 .11 .12 if (_key < 16*256**14)13 return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));14 if (_key < 16*256**15)15 return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));Tümünü gösterDiğer değerler (3 bayt, 4 bayt vb.) farklı alan boyutlarıyla aynı şekilde ele alınır.
1 // Buraya gelirsek, bir şeyler yanlış demektir.2 revert("encodeVal'da hata, olmamalıydı");Buraya gelirsek, 16*25615'ten küçük olmayan bir anahtar aldığımız anlamına gelir. Ancak cacheWrite anahtarları sınırlar, bu yüzden 14*25616'ya bile ulaşamayız (bunun ilk baytı 0xFE olurdu, yani DONT_CACHE gibi görünürdü). Ancak gelecekteki bir programcının bir hata eklemesi durumunda bir test eklemek bize çok pahalıya mal olmaz.
1 } // encodeVal23} // CacheÖnbelleği test etme
Foundry'nin avantajlarından biri, testleri Solidity'de yazmanıza izin vermesidir (opens in a new tab), bu da birim testleri yazmayı kolaylaştırır. Cache sınıfının testleri buradadır (opens in a new tab). Test kodu, testlerin olma eğiliminde olduğu gibi tekrarlayıcı olduğundan, bu makale yalnızca ilginç kısımları açıklamaktadır.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;34import "forge-std/Test.sol";567// Konsol için `forge test -vv` komutunu çalıştırmanız gerekir.8import "forge-std/console.sol";Bu, sadece test paketini ve console.log'u kullanmak için gerekli olan standart bir koddur.
1import "src/Cache.sol";Test ettiğimiz sözleşmeyi bilmemiz gerekiyor.
1contract CacheTest is Test {2 Cache cache;34 function setUp() public {5 cache = new Cache();6 }setUp fonksiyonu her testten önce çağrılır. Bu durumda sadece yeni bir önbellek oluştururuz, böylece testlerimiz birbirini etkilemez.
1 function testCaching() public {Testler, adları test ile başlayan fonksiyonlardır. Bu fonksiyon, değerleri yazıp tekrar okuyarak temel önbellek işlevselliğini kontrol eder.
1 for(uint i=1; i<5000; i++) {2 cache.cacheWrite(i*i);3 }45 for(uint i=1; i<5000; i++) {6 assertEq(cache.cacheRead(i), i*i);Gerçek testi assert... fonksiyonlarını (opens in a new tab) kullanarak bu şekilde yaparsınız. Bu durumda, yazdığımız değerin okuduğumuz değer olduğunu kontrol ederiz. cache.cacheWrite sonucunu atabiliriz çünkü önbellek anahtarlarının doğrusal olarak atandığını biliyoruz.
1 }2 } // testCaching345 // Aynı değeri birden çok kez önbelleğe alın, anahtarın aynı6 // kaldığından emin olun7 function testRepeatCaching() public {8 for(uint i=1; i<100; i++) {9 uint _key1 = cache.cacheWrite(i);10 uint _key2 = cache.cacheWrite(i);11 assertEq(_key1, _key2);12 }Tümünü gösterÖnce her değeri önbelleğe iki kez yazarız ve anahtarların aynı olduğundan emin oluruz (yani ikinci yazma işlemi gerçekten gerçekleşmemiştir).
1 for(uint i=1; i<100; i+=3) {2 uint _key = cache.cacheWrite(i);3 assertEq(_key, i);4 }5 } // testRepeatCachingTeoride, ardışık önbellek yazımlarını etkilemeyen bir hata olabilir. Bu yüzden burada ardışık olmayan bazı yazımlar yapıyoruz ve değerlerin hala yeniden yazılmadığını görüyoruz.
1 // Bir bellek arabelleğinden bir uint okuyun (gönderdiğimiz parametreleri2 // geri aldığımızdan emin olmak için)3 function toUint256(bytes memory _bytes, uint256 _start) internal pure4 returns (uint256)bytes memory arabelleğinden 256 bitlik bir kelime okuyun. Bu yardımcı fonksiyon, önbelleği kullanan bir fonksiyon çağrısı çalıştırdığımızda doğru sonuçları aldığımızı doğrulamamızı sağlar.
1 {2 require(_bytes.length >= _start + 32, "toUint256_sınır_dışı");3 uint256 tempUint;45 assembly {6 tempUint := mload(add(add(_bytes, 0x20), _start))7 }Yul, uint256'nın ötesindeki veri yapılarını desteklemez, bu nedenle _bytes bellek arabelleği gibi daha karmaşık bir veri yapısına atıfta bulunduğunuzda, o yapının adresini alırsınız. Solidity, bytes memory değerlerini uzunluğu içeren 32 baytlık bir kelime olarak ve ardından gerçek baytları depolar, bu nedenle _start bayt numarasını almak için _bytes+32+_start'ı hesaplamamız gerekir.
12 return tempUint;3 } // toUint25645 // fourParams() için fonksiyon imzası,6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d izniyle7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Doğru değerleri geri aldığımızı görmek için sadece bazı sabit değerler10 uint256 constant VAL_A = 0xDEAD60A7;11 uint256 constant VAL_B = 0xBEEF;12 uint256 constant VAL_C = 0x600D;13 uint256 constant VAL_D = 0x600D60A7;Tümünü gösterTest için ihtiyacımız olan bazı sabitler.
1 function testReadParam() public {Parametreleri doğru bir şekilde okuyabildiğimizi test etmek için readParams kullanan bir fonksiyon olan fourParams()'ı çağırın.
1 address _cacheAddr = address(cache);2 bool _success;3 bytes memory _callInput;4 bytes memory _callOutput;Önbelleği kullanarak bir fonksiyon çağırmak için normal ABI mekanizmasını kullanamayız, bu yüzden düşük seviyeli <address>.call() (opens in a new tab) mekanizmasını kullanmamız gerekir. Bu mekanizma, girdi olarak bir bytes memory alır ve bunu (bir Boole değeri ile birlikte) çıktı olarak döndürür.
1 // İlk çağrı, önbellek boş2 _callInput = bytes.concat(3 FOUR_PARAMS,Aynı sözleşmenin hem önbelleğe alınmış fonksiyonları (doğrudan işlemlerden gelen çağrılar için) hem de önbelleğe alınmamış fonksiyonları (diğer akıllı sözleşmelerden gelen çağrılar için) desteklemesi kullanışlıdır. Bunu yapmak için, her şeyi bir fallback fonksiyonuna (opens in a new tab) koymak yerine doğru fonksiyonu çağırmak için Solidity mekanizmasına güvenmeye devam etmemiz gerekir. Bunu yapmak birleştirilebilirliği çok daha kolaylaştırır. Çoğu durumda fonksiyonu tanımlamak için tek bir bayt yeterli olacaktır, bu yüzden üç bayt (16*3=48 gaz) israf ediyoruz. Ancak, ben bunu yazarken bu 48 gazın maliyeti 0,07 sent, bu da daha basit, daha az hataya açık kod için makul bir maliyettir.
1 // İlk değer, önbelleğe ekleyin2 cache.INTO_CACHE(),3 bytes32(VAL_A),İlk değer: Önbelleğe yazılması gereken tam bir değer olduğunu söyleyen bir bayrak, ardından değerin 32 baytı. Diğer üç değer benzerdir, ancak VAL_B önbelleğe yazılmaz ve VAL_C hem üçüncü hem de dördüncü parametredir.
1 .2 .3 .4 );5 (_success, _callOutput) = _cacheAddr.call(_callInput);Burası Cache sözleşmesini gerçekten çağırdığımız yerdir.
1 assertEq(_success, true);Çağrının başarılı olmasını bekliyoruz.
1 assertEq(cache.cacheRead(1), VAL_A);2 assertEq(cache.cacheRead(2), VAL_C);Boş bir önbellekle başlarız ve ardından VAL_A'yı ve sonrasında VAL_C'yi ekleriz. Birincisinin anahtarının 1, ikincisinin ise 2 olmasını bekleriz.
1 assertEq(toUint256(_callOutput,0), VAL_A);2 assertEq(toUint256(_callOutput,32), VAL_B);3 assertEq(toUint256(_callOutput,64), VAL_C);4 assertEq(toUint256(_callOutput,96), VAL_C);Çıktı dört parametredir. Burada doğru olduğunu doğruluyoruz.
1 // İkinci çağrı, önbelleği kullanabiliriz2 _callInput = bytes.concat(3 FOUR_PARAMS,45 // Önbellekteki ilk değer6 bytes1(0x01),16'nın altındaki önbellek anahtarları yalnızca bir bayttır.
1 // İkinci değer, önbelleğe eklemeyin2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Üçüncü ve dördüncü değerler, aynı değer6 bytes1(0x02),7 bytes1(0x02)8 );9 .10 .11 .12 } // testReadParamTümünü gösterÇağrıdan sonraki testler, ilk çağrıdan sonrakilerle aynıdır.
1 function testEncodeVal() public {Bu fonksiyon, testReadParam'a benzer, ancak parametreleri açıkça yazmak yerine encodeVal() kullanırız.
1 .2 .3 .4 _callInput = bytes.concat(5 FOUR_PARAMS,6 cache.encodeVal(VAL_A),7 cache.encodeVal(VAL_B),8 cache.encodeVal(VAL_C),9 cache.encodeVal(VAL_D)10 );11 .12 .13 .14 assertEq(_callInput.length, 4+1*4);15 } // testEncodeValTümünü göstertestEncodeVal()'daki tek ek test, _callInput'un uzunluğunun doğru olduğunu doğrulamaktır. İlk arama için 4+33*4'tür. İkincisi için, her değerin zaten önbellekte olduğu durumda, 4+1*4'tür.
1 // Anahtarın tek bir bayttan fazla olduğu durumlarda encodeVal'ı test edin2 // Önbelleği dört bayta kadar doldurmak çok uzun sürdüğü için3 // en fazla üç bayt.4 function testEncodeValBig() public {5 // Önbelleğe bir dizi değer koyun.6 // İşleri basit tutmak için, n değeri için n anahtarını kullanın.7 for(uint i=1; i<0x1FFF; i++) {8 cache.cacheWrite(i);9 }Tümünü gösterYukarıdaki testEncodeVal fonksiyonu önbelleğe yalnızca dört değer yazar, bu nedenle fonksiyonun çok baytlı değerlerle ilgilenen kısmı (opens in a new tab) kontrol edilmez. Ancak bu kod karmaşık ve hataya açıktır.
Bu fonksiyonun ilk kısmı, 1'den 0x1FFF'ye kadar olan tüm değerleri sırayla önbelleğe yazan bir döngüdür, böylece bu değerleri kodlayabilir ve nereye gittiklerini bilebiliriz.
1 .2 .3 .45 _callInput = bytes.concat(6 FOUR_PARAMS,7 cache.encodeVal(0x000F), // Bir bayt 0x0F8 cache.encodeVal(0x0010), // İki bayt 0x10109 cache.encodeVal(0x0100), // İki bayt 0x110010 cache.encodeVal(0x1000) // Üç bayt 0x20100011 );Tümünü gösterBir bayt, iki bayt ve üç baytlık değerleri test edin. Yeterli yığın girdisi yazmak çok uzun süreceğinden (en az 0x10000000, yaklaşık olarak çeyrek milyar) bunun ötesinde test yapmıyoruz.
1 .2 .3 .4 .5 } // testEncodeValBig678 // Aşırı küçük bir arabellekle bir geri alma elde ettiğimizi test edin9 function testShortCalldata() public {Tümünü gösterYeterli parametre olmadığında anormal durumda ne olduğunu test edin.
1 .2 .3 .4 (_success, _callOutput) = _cacheAddr.call(_callInput);5 assertEq(_success, false);6 } // testShortCalldataGeri döndüğü için alacağımız sonuç false olmalıdır.
1 // Orada olmayan önbellek anahtarlarıyla çağrı yapın2 function testNoCacheKey() public {3 .4 .5 .6 _callInput = bytes.concat(7 FOUR_PARAMS,89 // İlk değer, önbelleğe ekleyin10 cache.INTO_CACHE(),11 bytes32(VAL_A),1213 // İkinci değer14 bytes1(0x0F),15 bytes2(0x1234),16 bytes11(0xA10102030405060708090A)17 );Tümünü gösterBu fonksiyon dört tamamen meşru parametre alır, ancak önbellek boştur, bu nedenle okunacak değer yoktur.
1 .2 .3 .4 // Aşırı uzun bir arabellekle her şeyin çalıştığını test edin5 function testLongCalldata() public {6 address _cacheAddr = address(cache);7 bool _success;8 bytes memory _callInput;9 bytes memory _callOutput;1011 // İlk çağrı, önbellek boş12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // İlk değer, önbelleğe ekleyin16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // İkinci değer, önbelleğe ekleyin19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Üçüncü değer, önbelleğe ekleyin22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Dördüncü değer, önbelleğe ekleyin25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // Ve "iyi şans" için başka bir değer28 bytes4(0x31112233)29 );Tümünü gösterBu fonksiyon beş değer gönderir. Beşinci değerin, geçerli bir önbellek girişi olmadığı için yoksayıldığını biliyoruz; dahil edilmeseydi geri dönmeye neden olurdu.
1 (_success, _callOutput) = _cacheAddr.call(_callInput);2 assertEq(_success, true);3 .4 .5 .6 } // testLongCalldata78} // CacheTest9Tümünü gösterÖrnek bir uygulama
Solidity'de test yazmak çok iyidir, ancak günün sonunda bir merkeziyetsiz uygulamanın kullanışlı olması için zincir dışından gelen istekleri işleyebilmesi gerekir. Bu makale, "Bir Kez Yaz, Çok Kez Oku" anlamına gelen WORM ile bir merkeziyetsiz uygulamada önbelleğe almanın nasıl kullanılacağını göstermektedir. Bir anahtar henüz yazılmamışsa, ona bir değer yazabilirsiniz. Anahtar zaten yazılmışsa, bir geri alma alırsınız.
Sözleşme
Bu sözleşmedir (opens in a new tab). Çoğunlukla Cache ve CacheTest ile zaten yaptıklarımızı tekrarlar, bu yüzden sadece ilginç olan kısımları ele alacağız.
1import "./Cache.sol";23contract WORM is Cache {Cache kullanmanın en kolay yolu, onu kendi sözleşmemize miras almaktır.
1 function writeEntryCached() external {2 uint[] memory params = _readParams(2);3 writeEntry(params[0], params[1]);4 } // writeEntryCachedBu fonksiyon, yukarıdaki CacheTest'teki fourParam'a benzer. ABI spesifikasyonlarını takip etmediğimiz için, fonksiyona herhangi bir parametre bildirmemek en iyisidir.
1 // Bizi çağırmayı kolaylaştırın2 // writeEntryCached() için Fonksiyon İmzası, 3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3 izniyle4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;writeEntryCached'i çağıran harici kodun, ABI spesifikasyonlarına uymadığımız için worm.writeEntryCached'i kullanmak yerine calldata'yı manuel olarak oluşturması gerekecektir. Bu sabit değere sahip olmak sadece yazmayı kolaylaştırır.
WRITE_ENTRY_CACHED'i bir durum değişkeni olarak tanımlasak da, harici olarak okumak için onun alıcı fonksiyonu olan worm.WRITE_ENTRY_CACHED()'i kullanmak gerektiğini unutmayın.
1 function readEntry(uint key) public view2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)Okuma fonksiyonu bir view fonksiyonudur, bu nedenle bir işlem gerektirmez ve gaz maliyeti yoktur. Sonuç olarak, parametre için önbelleği kullanmanın bir faydası yoktur. View fonksiyonlarında daha basit olan standart mekanizmayı kullanmak en iyisidir.
Test kodu
Bu, sözleşmenin test kodudur (opens in a new tab). Yine, sadece ilginç olanlara bakalım.
1 function testWReadWrite() public {2 worm.writeEntry(0xDEAD, 0x60A7);34 vm.expectRevert(bytes("giriş zaten yazılmış"));5 worm.writeEntry(0xDEAD, 0xBEEF);Bu (vm.expectRevert) (opens in a new tab), bir Foundry testinde bir sonraki çağrının başarısız olması gerektiğini ve başarısızlık için bildirilen nedeni bu şekilde belirtiriz. Bu, <sözleşme>.<fonksiyon adı>() sözdizimini, calldata'yı oluşturup sözleşmeyi düşük seviyeli arayüzü (<sözleşme>.call(), vb.) kullanarak çağırmak yerine kullandığımızda geçerlidir.
1 function testReadWriteCached() public {2 uint cacheGoat = worm.cacheWrite(0x60A7);Burada cacheWrite'ın önbellek anahtarını döndürmesi gerçeğini kullanıyoruz. Bu, üretimde kullanmayı bekleyeceğimiz bir şey değildir, çünkü cacheWrite durumu değiştirir ve bu nedenle yalnızca bir işlem sırasında çağrılabilir. İşlemlerin dönüş değerleri yoktur, eğer sonuçları varsa bu sonuçların olaylar olarak yayınlanması gerekir. Bu nedenle cacheWrite dönüş değerine yalnızca zincir üstü koddan erişilebilir ve zincir üstü kodun parametre önbelleğe almasına gerek yoktur.
1 (_success,) = address(worm).call(_callInput);Bu, Solidity'ye <sözleşme adresi>.call()'un iki dönüş değeri olmasına rağmen, yalnızca ilkiyle ilgilendiğimizi söyleme şeklimizdir.
1 (_success,) = address(worm).call(_callInput);2 assertEq(_success, false);Düşük seviyeli <adres>.call() fonksiyonunu kullandığımız için, vm.expectRevert()'i kullanamayız ve çağrıdan aldığımız boole başarı değerine bakmak zorundayız.
1 event EntryWritten(uint indexed key, uint indexed value);23 .4 .5 .67 _callInput = bytes.concat(8 worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));9 vm.expectEmit(true, true, false, false);10 emit EntryWritten(a, b);11 (_success,) = address(worm).call(_callInput);Tümünü gösterBu, Foundry'de kodun bir olayı doğru şekilde yaydığını (opens in a new tab) doğrulama şeklimizdir.
İstemci
Solidity testleriyle elde edemeyeceğiniz bir şey, kendi uygulamanıza kesip yapıştırabileceğiniz JavaScript kodudur. Bu kodu yazmak için WORM'u Optimism'in (opens in a new tab) yeni test ağı olan Optimism Goerli (opens in a new tab)'ye dağıttım. Adresi 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab)'dır.
İstemci için JavaScript kodunu burada görebilirsiniz (opens in a new tab). Kullanmak için:
-
Git deposunu klonlayın:
1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
Gerekli paketleri yükleyin:
1cd javascript2yarn -
Yapılandırma dosyasını kopyalayın:
1cp .env.example .env -
Yapılandırmanız için
.envdosyasını düzenleyin:Parametre Değer MNEMONIC Bir işlemi ödemek için yeterli ETH'ye sahip bir hesabın anımsatıcısı. Optimism Goerli ağı için ücretsiz ETH'yi buradan alabilirsiniz (opens in a new tab). OPTIMISM_GOERLI_URL Optimism Goerli'ye URL. Genel uç nokta, https://goerli.optimism.io, hız sınırlıdır ancak burada ihtiyacımız olan şey için yeterlidir -
index.js'i çalıştırın.1node index.jsBu örnek uygulama önce WORM'a bir giriş yazar, calldata'yı ve Etherscan'deki işleme bir bağlantıyı görüntüler. Sonra bu girişi geri okur ve kullandığı anahtarı ve girişteki değerleri (değer, blok numarası ve yazar) görüntüler.
İstemcinin çoğu normal merkeziyetsiz uygulama JavaScript'idir. Bu yüzden yine sadece ilginç kısımları ele alacağız.
1.2.3.4const main = async () => {5 const func = await worm.WRITE_ENTRY_CACHED()67 // Her seferinde yeni bir anahtar gerekir8 const key = await worm.encodeVal(Number(new Date()))Belirli bir yuvaya yalnızca bir kez yazılabilir, bu nedenle yuvaları yeniden kullanmadığımızdan emin olmak için zaman damgasını kullanırız.
1const val = await worm.encodeVal("0x600D")23// Bir giriş yazın4const calldata = func + key.slice(2) + val.slice(2)Ethers, çağrı verisinin onaltılık bir dize, yani 0x ve ardından çift sayıda onaltılık basamak olmasını bekler. Hem key hem de val 0x ile başladığı için bu başlıkları kaldırmamız gerekir.
1const tx = await worm.populateTransaction.writeEntryCached()2tx.data = calldata34sentTx = await wallet.sendTransaction(tx)Solidity test kodunda olduğu gibi, önbelleğe alınmış bir fonksiyonu normal şekilde çağıramayız. Bunun yerine daha düşük seviyeli bir mekanizma kullanmamız gerekiyor.
1 .2 .3 .4 // Az önce yazılan girişi okuyun5 const realKey = '0x' + key.slice(4) // FF bayrağını kaldırın6 const entryRead = await worm.readEntry(realKey)7 .8 .9 .Tümünü gösterGirişleri okumak için normal mekanizmayı kullanabiliriz. view fonksiyonlarıyla parametre önbelleğe alma kullanmaya gerek yoktur.
Sonuç
Bu makaledeki kod bir kavram kanıtıdır, amaç fikri anlaşılır kılmaktır. Üretime hazır bir sistem için bazı ek işlevler uygulamak isteyebilirsiniz:
-
uint256olmayan değerleri işleyin. Örneğin, dizeler. -
Genel bir önbellek yerine, belki kullanıcılar ve önbellekler arasında bir eşleme olabilir. Farklı kullanıcılar farklı değerler kullanır.
-
Adresler için kullanılan değerler, diğer amaçlar için kullanılanlardan farklıdır. Sadece adresler için ayrı bir önbelleğe sahip olmak mantıklı olabilir.
-
Şu anda, önbellek anahtarları "ilk gelen, en küçük anahtar" algoritmasına göredir. İlk on altı değer tek bir bayt olarak gönderilebilir. Sonraki 4080 değer iki bayt olarak gönderilebilir. Sonraki yaklaşık bir milyon değer üç bayttır, vb. Bir üretim sistemi, önbellek girişlerinde kullanım sayaçları tutmalı ve bunları, en yaygın on altı değerin bir bayt, sonraki 4080 en yaygın değerin iki bayt vb. olacak şekilde yeniden düzenlemelidir.
Ancak, bu potansiyel olarak tehlikeli bir işlemdir. Aşağıdaki olaylar dizisini hayal edin:
-
Noam Naive, jeton göndermek istediği adresi kodlamak için
encodeVal'ı çağırır. Bu adres, uygulamada kullanılan ilklerden biridir, bu nedenle kodlanmış değer 0x06'dır. Bu birviewfonksiyonudur, bir işlem değildir, bu yüzden Noam ve kullandığı düğüm arasındadır ve başka kimse bunu bilmez -
Owen Owner, önbellek yeniden sıralama işlemini çalıştırır. Çok az insan bu adresi gerçekten kullanıyor, bu yüzden şimdi 0x201122 olarak kodlanıyor. Farklı bir değer olan 1018, 0x06'ya atanır.
-
Noam Naive, jetonlarını 0x06'ya gönderir. Jetonlar
0x0000000000000000000000000de0b6b3a7640000adresine gider ve kimse bu adresin özel anahtarını bilmediği için orada takılıp kalırlar. Noam mutlu değil.
Bu sorunu ve önbellek yeniden sıralaması sırasında mempool'da bulunan işlemlerin ilgili sorununu çözmenin yolları vardır, ancak bunun farkında olmalısınız.
-
Burada Optimism ile önbelleğe almayı gösterdim, çünkü ben bir Optimism çalışanıyım ve bu en iyi bildiğim toplamadır. Ancak, dahili işleme için minimum bir maliyet talep eden herhangi bir toplamayla çalışmalıdır, böylece karşılaştırmalı olarak işlem verilerini L1'e yazmak ana masraf olur.
Çalışmalarımdan daha fazlası için buraya bakın (opens in a new tab).
Sayfanın son güncellenmesi: 25 Şubat 2026