मुख्य सामग्री पर जाएं

वह सब कुछ जो आप कैश कर सकते हैं

परत 2
कैशिंग
स्टोरेज
माध्यमिक
ओरी पोमेरेंट्ज़
15 सितंबर 2022
26 मिनट का पठन

रोलअप का उपयोग करते समय, लेनदेन में एक बाइट की लागत भंडारण स्लॉट की लागत से बहुत अधिक महंगी होती है। इसलिए, ऑन-चेन में जितना संभव हो उतनी जानकारी कैश करना समझ में आता है।

इस लेख में आप सीखेंगे कि कैशिंग अनुबंध को इस तरह से कैसे बनाया और उपयोग किया जाए कि कोई भी पैरामीटर मान जिसका कई बार उपयोग होने की संभावना है, उसे कैश किया जाएगा और बहुत कम संख्या में बाइट्स के साथ उपयोग के लिए उपलब्ध होगा (पहली बार के बाद), और इस कैश का उपयोग करने वाले ऑफ़-चेन कोड को कैसे लिखना है।

यदि आप लेख को छोड़ना चाहते हैं और केवल स्रोत कोड देखना चाहते हैं, तो यह यहां है (opens in a new tab)। डेवलपमेंट स्टैक फाउंड्री (opens in a new tab) है।

समग्र डिजाइन

सरलता के लिए, हम यह मान लेंगे कि सभी लेनदेन पैरामीटर uint256, 32 बाइट्स लंबे हैं। जब हमें कोई लेनदेन प्राप्त होता है, तो हम प्रत्येक पैरामीटर को इस तरह पार्स करेंगे:

  1. यदि पहला बाइट 0xFF है, तो अगले 32 बाइट्स को पैरामीटर मान के रूप में लें और इसे कैश में लिखें।

  2. यदि पहला बाइट 0xFE है, तो अगले 32 बाइट्स को पैरामीटर मान के रूप में लें, लेकिन इसे कैश में लिखें।

  3. किसी भी अन्य मान के लिए, शीर्ष चार बिट्स को अतिरिक्त बाइट्स की संख्या के रूप में लें, और नीचे के चार बिट्स को कैश की के सबसे महत्वपूर्ण बिट्स के रूप में लें। यहां कुछ उदाहरण दिए गए हैं:

    कॉलडेटा में बाइट्सकैश की
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

कैश मैनिपुलेशन

कैश Cache.sol (opens in a new tab) में कार्यान्वित किया गया है। आइए इसे लाइन-दर-लाइन देखें।

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;


contract Cache {

    bytes1 public constant INTO_CACHE = 0xFF;
    bytes1 public constant DONT_CACHE = 0xFE;

इन स्थिरांकों का उपयोग उन विशेष मामलों की व्याख्या करने के लिए किया जाता है जहां हम सभी जानकारी प्रदान करते हैं और या तो इसे कैश में लिखा चाहते हैं या नहीं। कैश में लिखने के लिए पहले से अप्रयुक्त भंडारण स्लॉट्स में दो SSTORE (opens in a new tab) संचालन की आवश्यकता होती है, जिसमें प्रत्येक की लागत 22100 गैस होती है, इसलिए हम इसे वैकल्पिक बनाते हैं।


    mapping(uint => uint) public val2key;

मानों और उनकी कीज़ के बीच एक मैपिंग (opens in a new tab)। लेनदेन भेजने से पहले मानों को एन्कोड करने के लिए यह जानकारी आवश्यक है।

    // स्थान n में की n+1 के लिए मान है, क्योंकि हमें शून्य को
    // "कैश में नहीं" के रूप में संरक्षित करने की आवश्यकता है।
    uint[] public key2val;

हम कीज़ से मानों तक मैपिंग के लिए एक ऐरे का उपयोग कर सकते हैं क्योंकि हम कीज़ असाइन करते हैं, और सरलता के लिए हम इसे क्रमिक रूप से करते हैं।

    function cacheRead(uint _key) public view returns (uint) {
        require(_key <= key2val.length, "अप्रारंभीकृत कैश प्रविष्टि पढ़ना");
        return key2val[_key-1];
    }  // cacheRead

कैश से एक मान पढ़ें।

    // यदि कोई मान पहले से कैश में नहीं है, तो उसे लिखें
    // परीक्षण को काम करने में सक्षम करने के लिए केवल सार्वजनिक
    function cacheWrite(uint _value) public returns (uint) {
        // यदि मान पहले से ही कैश में है, तो वर्तमान की लौटाएं
        if (val2key[_value] != 0) {
            return val2key[_value];
        }

कैश में एक ही मान को एक से अधिक बार रखने का कोई मतलब नहीं है। यदि मान पहले से ही वहां है, तो बस मौजूदा की लौटाएं।

        // चूंकि 0xFE एक विशेष मामला है, कैश द्वारा रखी जा सकने वाली सबसे बड़ी की
        // 0x0D है जिसके बाद 15 0xFF हैं। यदि कैश की लंबाई पहले से ही इतनी
        // बड़ी है, तो विफल हो जाएं।
        //                              1 2 3 4 5 6 7 8 9 A B C D E F
        require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
            "कैश ओवरफ़्लो");

मुझे नहीं लगता कि हमें कभी इतना बड़ा कैश मिलेगा (लगभग 1.8*1037 प्रविष्टियाँ, जिसे स्टोर करने के लिए लगभग 1027 टीबी की आवश्यकता होगी)। हालाँकि, मैं इतना बूढ़ा हो गया हूँ कि मुझे "640kB हमेशा पर्याप्त होगा" (opens in a new tab) याद है। यह परीक्षण बहुत सस्ता है।

        // अगली की का उपयोग करके मान लिखें
        val2key[_value] = key2val.length+1;

रिवर्स लुकअप जोड़ें (मान से की तक)।

        key2val.push(_value);

फॉरवर्ड लुकअप जोड़ें (की से मान तक)। क्योंकि हम क्रमिक रूप से मान निर्दिष्ट करते हैं, हम इसे अंतिम ऐरे मान के बाद जोड़ सकते हैं।

        return key2val.length;
    }  // cacheWrite

key2val की नई लंबाई लौटाएं, जो वह सेल है जहां नया मान संग्रहीत है।

    function _calldataVal(uint startByte, uint length)
        private pure returns (uint)

यह फ़ंक्शन मनमानी लंबाई (32 बाइट्स तक, शब्द आकार) के कॉलडेटा से एक मान पढ़ता है।

    {
        uint _retVal;

        require(length < 0x21,
            "_calldataVal लंबाई सीमा 32 बाइट्स है");
        require(length + startByte <= msg.data.length,
            "_calldataVal कॉलडेटासाइज से परे पढ़ने की कोशिश कर रहा है");

यह फ़ंक्शन आंतरिक है, इसलिए यदि बाकी कोड सही ढंग से लिखा गया है, तो इन परीक्षणों की आवश्यकता नहीं है। हालाँकि, उनकी लागत बहुत अधिक नहीं है, इसलिए हम उन्हें भी रख सकते हैं।

        assembly {
            _retVal := calldataload(startByte)
        }

यह कोड युल (opens in a new tab) में है। यह कॉलडेटा से 32 बाइट मान पढ़ता है। यह तब भी काम करता है जब कॉलडेटा startByte+32 से पहले रुक जाता है क्योंकि EVM में अप्रारंभीकृत स्थान को शून्य माना जाता है।

        _retVal = _retVal >> (256-length*8);

हमें आवश्यक रूप से 32 बाइट का मान नहीं चाहिए। यह अतिरिक्त बाइट्स से छुटकारा दिलाता है।

        return _retVal;
    } // _calldataVal


    // _fromByte से शुरू होकर, कॉलडेटा से एक पैरामीटर पढ़ें
    function _readParam(uint _fromByte) internal
        returns (uint _nextByte, uint _parameterValue)
    {

कॉलडेटा से एक पैरामीटर पढ़ें। ध्यान दें कि हमें न केवल पढ़े गए मान को लौटाना होगा, बल्कि अगले बाइट के स्थान को भी लौटाना होगा क्योंकि पैरामीटर 1 बाइट से 33 बाइट्स तक हो सकते हैं।

        // पहला बाइट हमें बताता है कि बाकी की व्याख्या कैसे करें
        uint8 _firstByte;

        _firstByte = uint8(_calldataVal(_fromByte, 1));

सॉलिडिटी संभावित खतरनाक निहित प्रकार रूपांतरण (opens in a new tab) को प्रतिबंधित करके बग की संख्या को कम करने का प्रयास करता है। एक डाउनग्रेड, उदाहरण के लिए 256 बिट से 8 बिट तक, स्पष्ट होना चाहिए।

निचले निबल (opens in a new tab) को लें और इसे कैश से मान पढ़ने के लिए अन्य बाइट्स के साथ मिलाएं।

हम कॉलडेटा से ही पैरामीटर्स की संख्या प्राप्त कर सकते हैं, लेकिन हमें कॉल करने वाले फ़ंक्शन जानते हैं कि वे कितने पैरामीटर्स की अपेक्षा करते हैं। उन्हें हमें बताने देना आसान है।

पैरामीटर तब तक पढ़ें जब तक आपके पास आवश्यक संख्या न हो जाए। यदि हम कॉलडेटा के अंत से आगे जाते हैं, तो _readParams कॉल को रिवर्ट कर देगा।

फाउंड्री का एक बड़ा फायदा यह है कि यह सॉलिडिटी में परीक्षण लिखने की अनुमति देता है (नीचे कैश का परीक्षण देखें)। यह यूनिट परीक्षणों को बहुत आसान बनाता है। यह एक फ़ंक्शन है जो चार पैरामीटर पढ़ता है और उन्हें लौटाता है ताकि परीक्षण यह सत्यापित कर सके कि वे सही थे।

    // एक मान प्राप्त करें, बाइट्स लौटाएं जो इसे एन्कोड करेंगे (यदि संभव हो तो कैश का उपयोग करके)
    function encodeVal(uint _val) public view returns(bytes memory) {

encodeVal एक फ़ंक्शन है जिसे ऑफ़-चेन कोड कैश का उपयोग करने वाले कॉलडेटा बनाने में मदद के लिए कॉल करता है। यह एक मान प्राप्त करता है और इसे एन्कोड करने वाले बाइट्स लौटाता है। यह फ़ंक्शन एक view है, इसलिए इसे किसी लेनदेन की आवश्यकता नहीं है और जब इसे बाहरी रूप से कॉल किया जाता है तो कोई गैस खर्च नहीं होती है।

        uint _key = val2key[_val];

        // मान अभी तक कैश में नहीं है, इसे जोड़ें
        if (_key == 0)
            return bytes.concat(INTO_CACHE, bytes32(_val));

EVM में सभी अप्रारंभीकृत भंडारण को शून्य माना जाता है। इसलिए यदि हम किसी ऐसे मान के लिए की खोज करते हैं जो वहां नहीं है, तो हमें शून्य मिलता है। उस स्थिति में, इसे एन्कोड करने वाले बाइट्स INTO_CACHE हैं (ताकि यह अगली बार कैश हो जाए), उसके बाद वास्तविक मान आता है।

        // यदि की <0x10 है, तो इसे एकल बाइट के रूप में लौटाएं
        if (_key < 0x10)
            return bytes.concat(bytes1(uint8(_key)));

एकल बाइट्स सबसे आसान हैं। हम bytes<n> प्रकार को एक बाइट ऐरे में बदलने के लिए bytes.concat (opens in a new tab) का उपयोग करते हैं, जो किसी भी लंबाई का हो सकता है। नाम के बावजूद, यह केवल एक तर्क के साथ प्रदान किए जाने पर ठीक काम करता है।

        // दो बाइट मान, 0x1vvv के रूप में एन्कोड किया गया
        if (_key < 0x1000)
            return bytes.concat(bytes2(uint16(_key) | 0x1000));

जब हमारे पास 163 से कम की एक की होती है, तो हम इसे दो बाइट्स में व्यक्त कर सकते हैं। हम पहले _key, जो एक 256 बिट मान है, को 16 बिट मान में बदलते हैं और पहले बाइट में अतिरिक्त बाइट्स की संख्या जोड़ने के लिए लॉजिकल ऑर का उपयोग करते हैं। फिर हम इसे bytes2 मान में डालते हैं, जिसे bytes में बदला जा सकता है।

अन्य मान (3 बाइट्स, 4 बाइट्स, आदि) उसी तरह से संभाला जाता है, बस अलग-अलग फ़ील्ड आकार के साथ।

        // यदि हम यहाँ पहुँचते हैं, तो कुछ गलत है।
        revert("encodeVal में त्रुटि, नहीं होनी चाहिए");

अगर हम यहां पहुंचते हैं तो इसका मतलब है कि हमें एक की मिली है जो 16*25615 से कम नहीं है। लेकिन cacheWrite कीज़ को सीमित करता है ताकि हम 14*25616 तक भी नहीं पहुँच सकें (जिसका पहला बाइट 0xFE होगा, इसलिए यह DONT_CACHE जैसा दिखेगा)। लेकिन अगर भविष्य में कोई प्रोग्रामर बग पेश करता है तो परीक्षण जोड़ने में हमें बहुत अधिक लागत नहीं लगती है।

    } // encodeVal

}  // Cache

कैश का परीक्षण

फाउंड्री का एक फायदा यह है कि यह आपको सॉलिडिटी में परीक्षण लिखने देता है (opens in a new tab), जो यूनिट परीक्षण लिखना आसान बनाता है। Cache वर्ग के लिए परीक्षण यहां (opens in a new tab) हैं। क्योंकि परीक्षण कोड दोहराव वाला होता है, जैसा कि परीक्षण होते हैं, यह लेख केवल दिलचस्प भागों की व्याख्या करता है।

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";


// कंसोल के लिए `forge test -vv` चलाने की आवश्यकता है।
import "forge-std/console.sol";

यह केवल बॉयलरप्लेट है जो परीक्षण पैकेज और console.log का उपयोग करने के लिए आवश्यक है।

import "src/Cache.sol";

हमें उस अनुबंध को जानने की जरूरत है जिसका हम परीक्षण कर रहे हैं।

contract CacheTest is Test {
    Cache cache;

    function setUp() public {
        cache = new Cache();
    }

setUp फ़ंक्शन को प्रत्येक परीक्षण से पहले कॉल किया जाता है। इस मामले में हम बस एक नया कैश बनाते हैं, ताकि हमारे परीक्षण एक-दूसरे को प्रभावित न करें।

    function testCaching() public {

परीक्षण वे फ़ंक्शन हैं जिनके नाम test से शुरू होते हैं। यह फ़ंक्शन मूल कैश कार्यक्षमता की जाँच करता है, मानों को लिखता है और उन्हें फिर से पढ़ता है।

        for(uint i=1; i<5000; i++) {
            cache.cacheWrite(i*i);
        }

        for(uint i=1; i<5000; i++) {
            assertEq(cache.cacheRead(i), i*i);

यह है कि आप assert... फ़ंक्शंस (opens in a new tab) का उपयोग करके वास्तविक परीक्षण कैसे करते हैं। इस मामले में, हम यह जाँचते हैं कि हमने जो मान लिखा है वह वही है जिसे हमने पढ़ा है। हम cache.cacheWrite के परिणाम को छोड़ सकते हैं क्योंकि हम जानते हैं कि कैश कीज़ रैखिक रूप से निर्दिष्ट की जाती हैं।

पहले हम प्रत्येक मान को कैश में दो बार लिखते हैं और सुनिश्चित करते हैं कि कीज़ समान हैं (जिसका अर्थ है कि दूसरा लेखन वास्तव में नहीं हुआ)।

        for(uint i=1; i<100; i+=3) {
            uint _key = cache.cacheWrite(i);
            assertEq(_key, i);
        }
    }    // testRepeatCaching

सिद्धांत रूप में, एक बग हो सकता है जो लगातार कैश लिखने को प्रभावित नहीं करता है। तो यहाँ हम कुछ ऐसे लेख करते हैं जो लगातार नहीं होते हैं और देखते हैं कि मान अभी भी फिर से नहीं लिखे गए हैं।

    // मेमोरी बफर से एक uint पढ़ें (यह सुनिश्चित करने के लिए कि हम उन पैरामीटरों को वापस प्राप्त करें
    // जो हमने भेजे हैं)
    function toUint256(bytes memory _bytes, uint256 _start) internal pure
        returns (uint256)

bytes memory बफर से 256 बिट शब्द पढ़ें। यह यूटिलिटी फ़ंक्शन हमें यह सत्यापित करने देता है कि जब हम कैश का उपयोग करने वाले फ़ंक्शन कॉल चलाते हैं तो हमें सही परिणाम प्राप्त होते हैं।

    {
        require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
        uint256 tempUint;

        assembly {
            tempUint := mload(add(add(_bytes, 0x20), _start))
        }

युल uint256 से परे डेटा संरचनाओं का समर्थन नहीं करता है, इसलिए जब आप एक अधिक परिष्कृत डेटा संरचना, जैसे कि मेमोरी बफर _bytes का उल्लेख करते हैं, तो आपको उस संरचना का पता मिलता है। सॉलिडिटी bytes memory मानों को 32 बाइट शब्द के रूप में संग्रहीत करता है जिसमें लंबाई होती है, उसके बाद वास्तविक बाइट्स होते हैं, इसलिए बाइट नंबर _start प्राप्त करने के लिए हमें _bytes+32+_start की गणना करने की आवश्यकता है।

परीक्षण के लिए हमें कुछ स्थिरांक चाहिए।

    function testReadParam() public {

fourParams() को कॉल करें, एक फ़ंक्शन जो readParams का उपयोग करता है, यह परीक्षण करने के लिए कि हम पैरामीटर सही ढंग से पढ़ सकते हैं।

        address _cacheAddr = address(cache);
        bool _success;
        bytes memory _callInput;
        bytes memory _callOutput;

हम कैश का उपयोग करके किसी फ़ंक्शन को कॉल करने के लिए सामान्य ABI तंत्र का उपयोग नहीं कर सकते हैं, इसलिए हमें निम्न स्तर के <address>.call() (opens in a new tab) तंत्र का उपयोग करने की आवश्यकता है। वह तंत्र इनपुट के रूप में bytes memory लेता है, और उसे (साथ ही एक बूलियन मान) आउटपुट के रूप में लौटाता है।

        // पहली कॉल, कैश खाली है
        _callInput = bytes.concat(
            FOUR_PARAMS,

एक ही अनुबंध के लिए दोनों कैश्ड फ़ंक्शन (लेनदेन से सीधे कॉल के लिए) और गैर-कैश्ड फ़ंक्शन (अन्य स्मार्ट अनुबंधों से कॉल के लिए) का समर्थन करना उपयोगी है। ऐसा करने के लिए हमें सही फ़ंक्शन को कॉल करने के लिए सॉलिडिटी तंत्र पर भरोसा करना जारी रखना होगा, बजाय इसके कि सब कुछ एक fallback फ़ंक्शन (opens in a new tab) में डाल दिया जाए। ऐसा करने से कम्पोजिबिलिटी बहुत आसान हो जाती है। ज्यादातर मामलों में फ़ंक्शन की पहचान करने के लिए एक बाइट ही काफी होगी, इसलिए हम तीन बाइट्स (16*3=48 गैस) बर्बाद कर रहे हैं। हालाँकि, जैसा कि मैं यह लिख रहा हूँ, उन 48 गैस की लागत 0.07 सेंट है, जो सरल, कम बग प्रवण, कोड की एक उचित लागत है।

            // पहला मान, इसे कैश में जोड़ें
            cache.INTO_CACHE(),
            bytes32(VAL_A),

पहला मान: एक फ्लैग जो कहता है कि यह एक पूर्ण मान है जिसे कैश में लिखा जाना चाहिए, उसके बाद मान के 32 बाइट्स आते हैं। अन्य तीन मान समान हैं, सिवाय इसके कि VAL_B को कैश में नहीं लिखा जाता है और VAL_C तीसरा और चौथा दोनों पैरामीटर है।

             .
             .
             .
        );
        (_success, _callOutput) = _cacheAddr.call(_callInput);

यह वह जगह है जहां हम वास्तव में Cache अनुबंध को कॉल करते हैं।

        assertEq(_success, true);

हम उम्मीद करते हैं कि कॉल सफल होगी।

        assertEq(cache.cacheRead(1), VAL_A);
        assertEq(cache.cacheRead(2), VAL_C);

हम एक खाली कैश से शुरू करते हैं और फिर VAL_A और उसके बाद VAL_C जोड़ते हैं। हम उम्मीद करेंगे कि पहले वाले की की 1 होगी, और दूसरे की 2 होगी।

        assertEq(toUint256(_callOutput,0), VAL_A);
        assertEq(toUint256(_callOutput,32), VAL_B);
        assertEq(toUint256(_callOutput,64), VAL_C);
        assertEq(toUint256(_callOutput,96), VAL_C);

आउटपुट चार पैरामीटर हैं। यहां हम सत्यापित करते हैं कि यह सही है।

        // दूसरी कॉल, हम कैश का उपयोग कर सकते हैं
        _callInput = bytes.concat(
            FOUR_PARAMS,

            // कैश में पहला मान
            bytes1(0x01),

16 से नीचे की कैश कीज़ केवल एक बाइट की होती हैं।

कॉल के बाद के परीक्षण पहली कॉल के बाद के परीक्षणों के समान हैं।

    function testEncodeVal() public {

यह फ़ंक्शन testReadParam के समान है, सिवाय इसके कि पैरामीटर को स्पष्ट रूप से लिखने के बजाय हम encodeVal() का उपयोग करते हैं।

testEncodeVal() में एकमात्र अतिरिक्त परीक्षण यह सत्यापित करना है कि _callInput की लंबाई सही है। पहली कॉल के लिए यह 4+33*4 है। दूसरे के लिए, जहां हर मान पहले से ही कैश में है, यह 4+1*4 है।

ऊपर दिया गया testEncodeVal फ़ंक्शन कैश में केवल चार मान लिखता है, इसलिए फ़ंक्शन का वह हिस्सा जो बहु-बाइट मानों से संबंधित है (opens in a new tab) की जाँच नहीं की जाती है। लेकिन वह कोड जटिल और त्रुटि-प्रवण है।

इस फ़ंक्शन का पहला भाग एक लूप है जो 1 से 0x1FFF तक के सभी मानों को क्रम से कैश में लिखता है, ताकि हम उन मानों को एन्कोड कर सकें और जान सकें कि वे कहाँ जा रहे हैं।

एक बाइट, दो बाइट और तीन बाइट मानों का परीक्षण करें। हम इससे आगे का परीक्षण नहीं करते हैं क्योंकि पर्याप्त स्टैक प्रविष्टियाँ (कम से कम 0x10000000, लगभग एक चौथाई अरब) लिखने में बहुत समय लगेगा।

असामान्य मामले में क्या होता है, इसका परीक्षण करें जहां पर्याप्त पैरामीटर नहीं हैं।

        .
        .
        .
        (_success, _callOutput) = _cacheAddr.call(_callInput);
        assertEq(_success, false);
    }   // testShortCalldata

चूंकि यह रिवर्ट होता है, इसलिए हमें जो परिणाम मिलना चाहिए वह false है।

यह फ़ंक्शन चार पूरी तरह से वैध पैरामीटर प्राप्त करता है, सिवाय इसके कि कैश खाली है इसलिए पढ़ने के लिए कोई मान नहीं हैं।

यह फ़ंक्शन पांच मान भेजता है। हम जानते हैं कि पांचवें मान को अनदेखा कर दिया जाता है क्योंकि यह एक वैध कैश प्रविष्टि नहीं है, जो अगर शामिल नहीं किया गया होता तो एक रिवर्ट का कारण बनता।

एक नमूना एप्लिकेशन

सॉलिडिटी में परीक्षण लिखना बहुत अच्छी बात है, लेकिन दिन के अंत में एक डैप को उपयोगी होने के लिए श्रृंखला के बाहर से अनुरोधों को संसाधित करने में सक्षम होना चाहिए। यह लेख WORM के साथ एक डैप में कैशिंग का उपयोग करने का तरीका बताता है, जिसका अर्थ है "एक बार लिखें, कई बार पढ़ें"। यदि कोई की अभी तक नहीं लिखी गई है, तो आप उस पर एक मान लिख सकते हैं। यदि की पहले से लिखी हुई है, तो आपको एक रिवर्ट मिलता है।

अनुबंध

यह अनुबंध है (opens in a new tab)। यह ज्यादातर वही दोहराता है जो हम पहले ही Cache और CacheTest के साथ कर चुके हैं, इसलिए हम केवल उन हिस्सों को कवर करते हैं जो दिलचस्प हैं।

import "./Cache.sol";

contract WORM is Cache {

Cache का उपयोग करने का सबसे आसान तरीका इसे अपने अनुबंध में इनहेरिट करना है।

    function writeEntryCached() external {
        uint[] memory params = _readParams(2);
        writeEntry(params[0], params[1]);
    }    // writeEntryCached

यह फ़ंक्शन ऊपर CacheTest में fourParam के समान है। क्योंकि हम ABI विनिर्देशों का पालन नहीं करते हैं, इसलिए फ़ंक्शन में कोई पैरामीटर घोषित न करना सबसे अच्छा है।

    // हमें कॉल करना आसान बनाएं
    // writeEntryCached() के लिए फ़ंक्शन हस्ताक्षर, सौजन्य से
    // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
    bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

बाहरी कोड जो writeEntryCached को कॉल करता है, उसे मैन्युअल रूप से कॉलडेटा बनाना होगा, बजाय worm.writeEntryCached का उपयोग करने के, क्योंकि हम ABI विनिर्देशों का पालन नहीं करते हैं। इस स्थिर मान का होना इसे लिखना आसान बनाता है।

ध्यान दें कि यद्यपि हम WRITE_ENTRY_CACHED को एक स्टेट चर के रूप में परिभाषित करते हैं, इसे बाहरी रूप से पढ़ने के लिए इसके लिए गेटर फ़ंक्शन का उपयोग करना आवश्यक है, worm.WRITE_ENTRY_CACHED()

    function readEntry(uint key) public view
        returns (uint _value, address _writtenBy, uint _writtenAtBlock)

रीड फ़ंक्शन एक view है, इसलिए इसे किसी लेनदेन की आवश्यकता नहीं है और इसमें गैस खर्च नहीं होती है। परिणामस्वरूप, पैरामीटर के लिए कैश का उपयोग करने का कोई लाभ नहीं है। व्यू फ़ंक्शंस के साथ, सरल मानक तंत्र का उपयोग करना सबसे अच्छा है।

परीक्षण कोड

यह अनुबंध के लिए परीक्षण कोड है (opens in a new tab)। फिर से, आइए केवल दिलचस्प बातों पर ध्यान दें।

    function testWReadWrite() public {
        worm.writeEntry(0xDEAD, 0x60A7);

        vm.expectRevert(bytes("प्रविष्टि पहले से लिखी हुई है"));
        worm.writeEntry(0xDEAD, 0xBEEF);

यह (vm.expectRevert) (opens in a new tab) है कि हम एक फाउंड्री परीक्षण में कैसे निर्दिष्ट करते हैं कि अगली कॉल विफल होनी चाहिए, और विफलता का रिपोर्ट किया गया कारण। यह तब लागू होता है जब हम सिंटैक्स <contract>.<function name> का उपयोग करते हैं() बजाय इसके कि हम कॉलडेटा बनाएं और निम्न-स्तरीय इंटरफ़ेस (<contract>.call(), आदि) का उपयोग करके अनुबंध को कॉल करें।

    function testReadWriteCached() public {
        uint cacheGoat = worm.cacheWrite(0x60A7);

यहां हम इस तथ्य का उपयोग करते हैं कि cacheWrite कैश की लौटाता है। यह कुछ ऐसा नहीं है जिसे हम उत्पादन में उपयोग करने की उम्मीद करेंगे, क्योंकि cacheWrite स्टेट को बदलता है, और इसलिए इसे केवल एक लेनदेन के दौरान ही कॉल किया जा सकता है। लेनदेन के पास रिटर्न मान नहीं होते हैं, यदि उनके पास परिणाम होते हैं तो उन परिणामों को इवेंट्स के रूप में उत्सर्जित किया जाना चाहिए। तो cacheWrite रिटर्न मान केवल ऑन-चेन कोड से सुलभ है, और ऑन-चेन कोड को पैरामीटर कैशिंग की आवश्यकता नहीं है।

        (_success,) = address(worm).call(_callInput);

यह है कि हम सॉलिडिटी को कैसे बताते हैं कि जबकि <contract address>.call() के दो रिटर्न मान हैं, हम केवल पहले की परवाह करते हैं।

        (_success,) = address(worm).call(_callInput);
        assertEq(_success, false);

चूंकि हम निम्न स्तर <address>.call() फ़ंक्शन का उपयोग करते हैं, इसलिए हम vm.expectRevert() का उपयोग नहीं कर सकते हैं और हमें कॉल से प्राप्त बूलियन सफलता मान को देखना होगा।

यह वह तरीका है जिससे हम सत्यापित करते हैं कि कोड फाउंड्री में एक इवेंट सही ढंग से उत्सर्जित करता है (opens in a new tab)

क्लाइंट

एक चीज जो आपको सॉलिडिटी परीक्षणों के साथ नहीं मिलती है, वह है जावास्क्रिप्ट कोड जिसे आप अपने एप्लिकेशन में काट और चिपका सकते हैं। उस कोड को लिखने के लिए मैंने WORM को ऑप्टिमिज़्म गोअर्ली (opens in a new tab), ऑप्टिमिज़्म's (opens in a new tab) नए टेस्टनेट पर डिप्लॉय किया। यह 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab) पते पर है।

आप क्लाइंट के लिए जावास्क्रिप्ट कोड यहां देख सकते हैं (opens in a new tab)। इसका उपयोग करने के लिए:

  1. git रिपॉजिटरी को क्लोन करें:

    git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
    
  2. आवश्यक पैकेज स्थापित करें:

    cd javascript
    yarn
    
  3. कॉन्फ़िगरेशन फ़ाइल कॉपी करें:

    cp .env.example .env
    
  4. अपने कॉन्फ़िगरेशन के लिए .env संपादित करें:

    पैरामीटरमूल्य
    MNEMONICएक ऐसे खाते के लिए स्मरक जिसके पास लेनदेन के भुगतान के लिए पर्याप्त ETH है। आप ऑप्टिमिज़्म गोअर्ली नेटवर्क के लिए मुफ्त ETH यहाँ से प्राप्त कर सकते हैं (opens in a new tab)
    OPTIMISM_GOERLI_URLऑप्टिमिज़्म गोअर्ली का URL। सार्वजनिक एंडपॉइंट, https://goerli.optimism.io, दर सीमित है लेकिन यहां हमें जो चाहिए उसके लिए पर्याप्त है
  5. index.js चलाएँ।

    node index.js
    

    यह नमूना एप्लिकेशन पहले WORM में एक प्रविष्टि लिखता है, कॉलडेटा और ईथरस्कैन पर लेनदेन के लिए एक लिंक प्रदर्शित करता है। फिर यह उस प्रविष्टि को वापस पढ़ता है, और उस की को प्रदर्शित करता है जिसका वह उपयोग करता है और प्रविष्टि में मान (मान, ब्लॉक संख्या और लेखक) प्रदर्शित करता है।

अधिकांश क्लाइंट सामान्य Dapp जावास्क्रिप्ट है। तो फिर हम केवल दिलचस्प भागों पर जाएंगे।

.
.
.
const main = async () => {
    const func = await worm.WRITE_ENTRY_CACHED()

    // हर बार एक नई की की आवश्यकता होती है
    const key = await worm.encodeVal(Number(new Date()))

एक दिए गए स्लॉट में केवल एक बार लिखा जा सकता है, इसलिए हम यह सुनिश्चित करने के लिए टाइमस्टैम्प का उपयोग करते हैं कि हम स्लॉट का पुन: उपयोग न करें।

const val = await worm.encodeVal("0x600D")

// एक प्रविष्टि लिखें
const calldata = func + key.slice(2) + val.slice(2)

Ethers को उम्मीद है कि कॉल डेटा एक हेक्स स्ट्रिंग होगा, 0x जिसके बाद सम संख्या में हेक्साडेसिमल अंक होंगे। चूंकि key और val दोनों 0x से शुरू होते हैं, इसलिए हमें उन हेडर को हटाने की जरूरत है।

const tx = await worm.populateTransaction.writeEntryCached()
tx.data = calldata

sentTx = await wallet.sendTransaction(tx)

सॉलिडिटी परीक्षण कोड की तरह, हम सामान्य रूप से कैश्ड फ़ंक्शन को कॉल नहीं कर सकते हैं। इसके बजाय, हमें एक निचले स्तर के तंत्र का उपयोग करने की आवश्यकता है।

प्रविष्टियों को पढ़ने के लिए हम सामान्य तंत्र का उपयोग कर सकते हैं। view फ़ंक्शंस के साथ पैरामीटर कैशिंग का उपयोग करने की कोई आवश्यकता नहीं है।

निष्कर्ष

इस लेख में कोड एक अवधारणा का प्रमाण है, इसका उद्देश्य विचार को समझना आसान बनाना है। उत्पादन-तैयार प्रणाली के लिए आप कुछ अतिरिक्त कार्यक्षमता लागू करना चाह सकते हैं:

  • uint256 न होने वाले मानों को संभालें। उदाहरण के लिए, स्ट्रिंग्स।

  • एक वैश्विक कैश के बजाय, शायद उपयोगकर्ताओं और कैश के बीच एक मैपिंग हो। विभिन्न यूज़र अलग-अलग मानों का उपयोग करते हैं।

  • पतों के लिए उपयोग किए जाने वाले मान अन्य उद्देश्यों के लिए उपयोग किए जाने वाले मानों से भिन्न होते हैं। सिर्फ पतों के लिए एक अलग कैश रखना समझदारी हो सकती है।

  • वर्तमान में, कैश कीज़ "पहले आओ, सबसे छोटी की" एल्गोरिथम पर हैं। पहले सोलह मानों को एक बाइट के रूप में भेजा जा सकता है। अगले 4080 मान दो बाइट्स के रूप में भेजे जा सकते हैं। अगले लगभग दस लाख मान तीन बाइट्स हैं, आदि। एक उत्पादन प्रणाली को कैश प्रविष्टियों पर उपयोग काउंटरों को रखना चाहिए और उन्हें पुनर्गठित करना चाहिए ताकि सोलह सबसे आम मान एक बाइट के हों, अगले 4080 सबसे आम मान दो बाइट्स के हों, आदि।

    हालाँकि, यह एक संभावित खतरनाक ऑपरेशन है। इवेंट्स के निम्नलिखित क्रम की कल्पना करें:

    1. नोआम नेव encodeVal को उस पते को एन्कोड करने के लिए कॉल करता है जिस पर वह टोकन भेजना चाहता है। वह पता एप्लिकेशन पर उपयोग किए जाने वाले पहले में से एक है, इसलिए एन्कोडेड मान 0x06 है। यह एक view फ़ंक्शन है, लेनदेन नहीं, इसलिए यह नोआम और उसके द्वारा उपयोग किए जाने वाले नोड के बीच है, और इसके बारे में कोई और नहीं जानता है

    2. ओवेन ओनर कैश रीऑर्डरिंग ऑपरेशन चलाता है। बहुत कम लोग वास्तव में उस पते का उपयोग करते हैं, इसलिए अब इसे 0x201122 के रूप में एन्कोड किया गया है। एक अलग मान, 1018, को 0x06 असाइन किया गया है।

    3. नोआम नेव अपने टोकन 0x06 पर भेजता है। वे 0x0000000000000000000000000de0b6b3a7640000 पते पर जाते हैं, और चूंकि कोई भी उस पते के लिए निजी कुंजी नहीं जानता है, इसलिए वे बस वहीं फंसे रहते हैं। नोआम खुश नहीं है।

    इस समस्या को हल करने के तरीके हैं, और कैश रीऑर्डर के दौरान मेमपूल में होने वाले लेनदेन की संबंधित समस्या, लेकिन आपको इसके बारे में पता होना चाहिए।

मैंने यहाँ ऑप्टिमिज़्म के साथ कैशिंग का प्रदर्शन किया, क्योंकि मैं एक ऑप्टिमिज़्म कर्मचारी हूँ और यह वह रोलअप है जिसे मैं सबसे अच्छी तरह जानता हूँ। लेकिन इसे किसी भी रोलअप के साथ काम करना चाहिए जो आंतरिक प्रसंस्करण के लिए न्यूनतम लागत वसूलता है, ताकि तुलना में लेनदेन डेटा को L1 पर लिखना प्रमुख व्यय हो।

मेरे और काम के लिए यहाँ देखें (opens in a new tab)

पेज का अंतिम अपडेट: 3 मार्च 2026

क्या यह ट्यूटोरियल उपयोगी था?