Optimistic concurrency control în ASP.NET WebAPI

[warning: loong post 🙂 ]
Prima întrebare, și oarecum de așteptat ar fi: ce treabă are ASP.NET Web API cu concurrency control? Până acuma, în majoritatea aplicațiilor, problema asta se trata la nivelul layerului de persistență sau chiar la nivelul database server-ului: în cazul optimistic concurrency control, dacă la update se detecta că datele au fost modificate între timp (comparativ cu momentul citirii lor), cel mai probabil de către alt user sau sistem, cel mai adesea se arunca o exceptie specifică (sau se returna un cod de eroare specific) și aplicația client trebuia sa o trateze într-un fel sau altul – de obicei afișand un dialog utilizatorului și permițăndu-i sa aleagă o metodă de rezolvare a conflictului, dupa caz: cancel la modificările proprii, suprascrierea modificarilor facute de celălalt user sau merge la modificări, dacă e posibil și în funție de cerințele aplicației.
Nu voi intra în detalii legate de “teoria” optimistic concurrency control, cănd se folosește și când nu, cum se implementează etc. – e o poveste cunoscută.

Ceea ce e mai puțin cunoscut e că HTTP-ul (ca application-level protocol), printre multe alte features, vine cu un feature built-in ce poate fi folosit, (și) pentru a face explicit mecanismul de optimistic concurrency control: ETags.Folosit de obicei pentru caching in HTTP, ETag-urile mai au o posibilă utilizare, sugerată chiar de definiția din standard: “Entity tags are used for comparing two or more entities from the same requested resource. … An entity tag MUST be unique across all versions of all entities associated with a particular resource.”

Chiar dacă terminologia e puțin diferită, ne dăm sema că unei resurse (sau document) îi corespunde un record (sau mai multe) din database, ‘entities’ le putem privi ca fiind instanțe de obiecte în memorie (reprezentări concrete în memorie ale unei entități din database), asta ca o echivalență aproximativă.

Tot ca analogie, eTag-ul poate fi echivalat (sau implementat) ca un “version”, “timestamp” asociat unui database record, în general o valoare “opacă”, fară o semnificație anume pentru user.Ne dăm astfel seama ca, ETag-ul, pe langă folosirea lui pentru caching, e un candidat ideal pentru a asigura o metodă standard de a transporta valorile folosite pentru concurrency control. Metoda nu e ceva nou și nemaivăzut, fiind sugerată chiar de cei ce au lucrat la standard cu multă vreme în urmă (http://www.w3.org/1999/04/Editing/) și folosită de diverse sisteme de document/content management (unde apare sub numele de “unreserved checkout”).Ce legătură au toate astea cu ASP.NET WebAPI? Au, deoarece Web API încearcă să ofere suport pentru servicii bazate pe HTTP ca application protocol și care fac HTTP-ul explicit și îi expun avantajele, în loc să îl ascundă/abstractizeze.

Dar destul cu teoria. Cum poate fi folosit la modul concret un ETag pentru a asigura suport pentru optimistic concurrency control, la nivel de HTTP?
Pseudocod:

  • o aplicație client face un request pentru o anumită resursă: GET /products/23
  • dacă resursa există, server-ul va trimite un răspuns ce va conține, pe lânga resursa propriu-zisă, valoarea ETag-ului asociat resursei, ca și field în header-ul HTTP (ex.: ETag: “s2hk707Mvk+GqzxNe+lbOQ==”)
  • aplicația client afișează resursa, user-ul (1) o modifică (pe client)
  • în acest timp un alt user (2) (sau sistem) editează și modifică aceeași resursă fără probleme. Pe server, ETag-ul este updatat în momentul cand resursa e persistată
  • primul user (1) termină de facut modificările și apasă butonul “Save”
  • aplicația client trimite un request de tip PUT la server, cu resursa modificată și cu ETag-ul original, sub forma unui field in header-ul HTTP, de forma:
    If-Match: “<original ETag value>”
  • pe server, dacă se verifică (intr-un fel sau altul) ca resursa există dar a fost modificată între timp (cel mai adesea comparând etag-ul original cu cel curent), update-ul nu va fi efectuat și se va intoarce un response cu codul 412 (Precondition Failed) unde “precondition” e chiar condiția exprimată de “If-Match” în requestul de modificare
  • rămâne la latitudinea aplicației client ce face cu acest răspuns: afișează un mesaj la user prin care îl lasă să aleagă intre diverse variante de soluționare a conflictului (“owerwrite the changes done by other users”, “cancel my changes” etc.), eventual afișând și o listă cu modificările facute de celălalt user

Cum ne ajută ASP.NET WebApi să implementăm așa ceva?
Simplu: actiunile dintr-un ApiController oferă un access facil la headerele HTTP – restul ramane în responsabilitatea noastră. Las codul să vorbească 🙂

    public class ProductsController : ApiController
    {
        private static IList _products = new List(); // dummy product repository

        static ProductsController()
        {
            // dummy data
            _products.Add(new Product() { Id = 10, Code = "P1", Description = "Product 1", Price = 123.45m,
                                          Version = Guid.NewGuid().ToByteArray() });
            _products.Add(new Product() { Id = 11, Code = "P2", Description = "Product 2", Price = 567.47m,
                                          Version = Guid.NewGuid().ToByteArray() });
            _products.Add(new Product() { Id = 12, Code = "P3", Description = "Product 3", Price = 100.22m,
                                          Version = Guid.NewGuid().ToByteArray() });
        }

        // GET /api/products
        public IEnumerable Get()
        {
            return _products;
        }

        // GET /api/products/11
        public HttpResponseMessage Get(int id)
        {
            Product prod = (from p in _products
                         where p.Id == id
                         select p).FirstOrDefault();
            if (prod == null)
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }
            else
            {
                var response = new HttpResponseMessage(prod, HttpStatusCode.OK);
                response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\""
                    + Convert.ToBase64String(prod.Version, Base64FormattingOptions.None) + "\"");
                return response;
            }
        }

        // PUT /api/values/5
        public void Put(int id, Product product)
        {
            // retrive the existing product from persitence
            Product existingProduct = (from p in _products
                            where p.Id == id
                            select p).FirstOrDefault();

            if (existingProduct != null)
            {
                // perform concurrency conflict check
                CheckIfProductWasModified(this.Request.Headers, existingProduct);

                //update the product
                existingProduct.Code = product.Code;
                existingProduct.Description = product.Description;
                existingProduct.Price = product.Price;
                // this should be done by the persistence layer (DB, etc..)
                existingProduct.Version = Guid.NewGuid().ToByteArray();

            }
            else
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }
        }

        private void CheckIfProductWasModified(HttpRequestHeaders requestHeaders, Product existingProduct)
        {
            if (requestHeaders.IfMatch != null)
            {
                // if the request contains an If-Match haeder with a non-empty ETag
                EntityTagHeaderValue firstHeaderVal = requestHeaders.IfMatch.FirstOrDefault();
                if ((firstHeaderVal != null) && (!string.IsNullOrEmpty(firstHeaderVal.Tag))
                    && (firstHeaderVal.Tag != "*")
                    )
                {
                    // compare the old and new ETag value (this can be done at DB-level using a WHERE clause)
                    string encodedNewTagValue = firstHeaderVal.Tag.Trim("\"".ToCharArray());
                    string encodeExistingTagValue = Convert.ToBase64String(existingProduct.Version, Base64FormattingOptions.None);
                    if (!encodedNewTagValue.Equals(encodeExistingTagValue, StringComparison.Ordinal))
                    {
                        // concurrency conflict if the resource was modified
                        throw new HttpResponseException(HttpStatusCode.PreconditionFailed);
                    }
                }
            }
        }

        // POST /api/products
        public HttpResponseMessage Post(Product product)
        {
            _products.Add(product);
            var response = new HttpResponseMessage(product, HttpStatusCode.Created);
            response.Headers.Location = new Uri(Request.RequestUri,
                "/api/products/" + product.Id.ToString(CultureInfo.InvariantCulture));
            return response;
        }

    }

Liniile evidențiate arată părțile relevante:
– la GET, setăm ETag-ul resursei returnate folosind HttpRespnseMessage.Headers.ETag (valoarea trebuie să fie cuprinsă în ghilimele (“)
GET with ETag
– la PUT (modify), dacă this.Request.Headers.IfMatch are o valoare (și nu e “*”), o comparăm cu ETag-ul resursei persistate (în DB sau cache)
– dacă nu concid, returnăm un response cu HttpStatusCode.PreconditionFailed
PUT with ETag

Și câteva teste care ilustreză ceea ce ne așteptăm să se întâmple:

    [TestClass]
    public class ProductsControllerIntegrationTests
    {
        private HttpClient _client;
        private string _productUri = "http://ipv4.fiddler/TestETag/api/products/11";
        private readonly string jsonMediaType = "application/json";

        [TestInitialize]
        public void Init()
        {
            _client = new HttpClient();
        }

        [TestMethod]
        public async Task Get_ReturnsTheETagInTheHeaders()
        {
            HttpResponseMessage httpResp = await _client.GetAsync(_productUri);

            Assert.IsNotNull(httpResp.Headers.ETag, "No ETag was included in the response headers!");
            Assert.IsTrue(!string.IsNullOrEmpty(httpResp.Headers.ETag.Tag), "An empty ETag was received!");

        }

        [TestMethod]
        public async Task Put_IfTheProductWasModified_ReturnsPreconditionFailed()
        {
            // first user reads the product 11
            HttpResponseMessage getProdUser1 = await _client.GetAsync(_productUri);
            getProdUser1.EnsureSuccessStatusCode();
            Product prodUser1 = await getProdUser1.Content.ReadAsAsync();

            ///////////////
            // second user reads the product 11
            HttpResponseMessage getProdUser2 = await _client.GetAsync(_productUri);
            getProdUser2.EnsureSuccessStatusCode();
            Product prodUser2 = await getProdUser2.Content.ReadAsAsync();

            //second users modifies (PUT) and persist the product
            prodUser2.Description = prodUser2.Description + " modif by user 2";

            var putRequest2 = GetPutRequestMessage(prodUser2);
            // we have to use SendAsync in order to be able to set any header, including If-Match
            HttpResponseMessage putUser2Response = await _client.SendAsync(putRequest2);
            putUser2Response.EnsureSuccessStatusCode();

            ///////////////
            // first user modifies (PUT) and tries to persist the same product
            prodUser1.Description = prodUser2.Description + " modif by user 1";

            var putRequest1 = GetPutRequestMessage(prodUser1);
            HttpResponseMessage putUser1Response = await _client.SendAsync(putRequest1);

            /////////////////////
            Assert.AreEqual(HttpStatusCode.PreconditionFailed, putUser1Response.StatusCode,
                            "The response was not precondition failed!");

        }

        private HttpRequestMessage GetPutRequestMessage(Product product)
        {
            var requestMessage = new HttpRequestMessage(HttpMethod.Put, _productUri);
            // add an 'If-Match' header
            requestMessage.Headers.IfMatch.ParseAdd("\"" + Convert.ToBase64String(product.Version) + "\"");
            // add the modified object serialized as JSON
            ObjectContent prodObjContent =
                requestMessage.CreateContent(
                product,
                MediaTypeHeaderValue.Parse(jsonMediaType),
                new MediaTypeFormatter[] { new JsonMediaTypeFormatter() },
                new FormatterSelector());

            return requestMessage;
        }

        [TestCleanup]
        public void Cleanup()
        {
            if (_client != null)
            {
                _client.Dispose();
            }
        }

    }

Normal, nu contează limbajul sau frameworkul folosit pe client – am folosit HttpClient din ASP.NET Web API doar fiindcă era la îndemână, puteam folosi JavaScript, C++ sau altceva.

Pentru o aplicație reală, desigur ar mai fi câțiva pași: refactoring la greu pentru a obține pe cât posibil un cod reutilizabil, separarea codului ce face concurrency checks într-o clasă separată – separation of concerns (probabil într-un DelegatingChanell, precum e descris la http://javiercrespoalvez.com/2011/06/etags-and-optimistic-concurrency.html ), folosirea unei soluții de persitență reale (DB de ex.). De asemenea, evident testele de mai sus sunt doar niște integration tests, nu ar strica niște unit teste adevărate.

Ce nu e relevant in codul de mai sus:
– formatul folosit pentru ETag – am folosit byte[] doar fiindcă se mapează mai ușor la rowversion din SQL Server, când se folosește EF, dar la fel de bine putea fi un int sau Guid cât timp ne asigurăm ca e unic
– modul în care e encodat ETag-ul in header-ul HTTP: am folosit Convert.ToBase64String doar fiindcă e o modalitate comodă și safe de a encoda un array de bytes (în ambele direcții)
– formatul în care e serializată resursa (entitatea) – poate fi JSON ca mai sus, dar si XML sau altceva

Ce nu e abordat in acest post: rolul jucat de ETag in HTTP caching, care dacă e folosit devine relevant.

This entry was posted in .NET, Web and tagged , , , . Bookmark the permalink.

3 Responses to Optimistic concurrency control în ASP.NET WebAPI

  1. ignatandrei says:

    extraordinar

  2. ignatandrei says:

    Articol de prima pagina!

Leave a comment