Jakieś kilka tygodni temu zadałem pytanie na dP dotyczące sposobu pobrania dwóch wartości za pomocą API ESRI. I tak jak je zadałem tak też o nim zapomniałem czekając jak ktoś na nie odpowie a jednocześnie przekładając napisanie fragmentu kodu do ostatnich chwili.

Ta chwila nadeszła w zeszłym tygodniu, gdzie już nie miałem innego wyjścia i musiałem zająć się problemem. Wtedy też miałem pierwszy raz dostęp do API z poziomu VS, wcześniej pisałem to na „wyczucie” jak w starych dobrych kartach (perforowanych???).

Zacznijmy jednak od początku. W jednym projekcie, w którym operujemy na mapie zaszła potrzeba efektywnego zarządzania warstwami, które są wyświetlane na mapie. Pod słowem efektywne rozumieliśmy sposób pobrania informacji o warstwach taki by:

  1. Nie pobierał 1MB danych na starcie – taki rozmiar ma wynik zapytania REST dot. wszystkich warstw i ich wartości;
  2. Nie odpytywał N razy serwera o dane – normalnie zabawa z 3 warstwami danych mogła w ciągu 5 min generować 20-30 requestów;
  3. Umożliwił dalszą zabawę warstwami w aplikacji w tym wyświetlenie raportów jak zarządzanie kolejnością wyświetlanych warstw.

Problem jaki istnieje, jest taki iż ESRI udostępnia domyślnie 3 informacje dotyczące warstwy:

  1. Ogólny opis wszystkich warstw, dość ograniczony (listing 1), w naszym przypadku to 20KB;
  2. Szczegółowy opis wszystkich warstw (ze wszystkimi pojedynczymi wystąpieniami obiektów), zawierający masę zbędnych danych (przykład 1 warstwy, listing 2), w naszym przypadku tekst ten miał wielkość 1MB;
  3. Szczegółowy opis pojedynczej warstwy (na podstawie tego 2 jest tworzona) – przykładowy rozmiar to od 3KB do 100KB.

Listing 1:

{
  "serviceDescription" : "", 
  "mapName" : "Layers", 
  "description" : "", 
  "copyrightText" : "", 
  "layers" : [
    {
      "id" : 0, 
      "name" : "General Gazetteer", 
      "parentLayerId" : -1, 
      "defaultVisibility" : false, 
      "subLayerIds" : [1, 2]
    }, 
    {
      "id" : 1, 
      "name" : "XYZ Offices", 
      "parentLayerId" : 0, 
      "defaultVisibility" : false, 
      "subLayerIds" : null
    }, 
    // ....
   ], 
  "spatialReference" : {
    "wkid" : 29902
  }
}

Listing 2:

{
  "layers" : [
  {
  "id" : 3, 
  "name" : "Large Towns", 
  "type" : "Feature Layer", 
  "description" : "ADMIN_TownsScaled,0,0,0", 
  "definitionExpression" : "", 
  "geometryType" : "esriGeometryPoint", 
  "copyrightText" : "", 
  "parentLayer" : {"id" : 2, "name" : "Towns"}, 
  "subLayers" : [], 
  "minScale" : 0, 
  "maxScale" : 300000, 
  "defaultVisibility" : false, 
  "extent" : {
    "xmin" : 32212, 
    "ymin" : 25248, 
    "xmax" : 365715.0005, 
    "ymax" : 450038, 
    "spatialReference" : {
      "wkid" : 29902
    }
  }, 
  "hasAttachments" : false, 
  "htmlPopupType" : "esriServerHTMLPopupTypeNone", 
  "drawingInfo" : {"renderer" : 
    {
      "type" : "uniqueValue", 
      "field1" : "size", 
      "field2" : null, 
      "field3" : null, 
      "fieldDelimiter" : ", ", 
      "defaultSymbol" : null, 
      "defaultLabel" : "u003call other valuesu003e", 
      "uniqueValueInfos" : [
        {
          "value" : "Large", 
          "label" : "Large Town", 
          "description" : "", 
          "symbol" : 
          {
            "type" : "esriPMS", 
            "url" : "280F5312", 
            "imageData" : "iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAYAAACprHcm
AAAAAXNSR0IB2cksfwAAAAlwSFlzAAAOxAAADsQBlSsOG
wAAACVJREFUGJVj8fX1vcHExKTOQBhEshChCA5GFdNJ8ebNmz
WIVQwA3sMEs20eFZsAAAAASUVORK5CYII=", 
            "contentType" : "image/png", 
            "color" : null, 
            "width" : 8, 
            "height" : 8, 
            "angle" : 0, 
            "xoffset" : 0, 
            "yoffset" : 0
          }
        }
      ]
    }, 
    "transparency" : 0, 
    "labelingInfo" : null}, 
  "displayField" : "NAME", 
  "fields" : [
    {
      "name" : "OBJECTID", 
      "type" : "esriFieldTypeOID", 
      "alias" : "OBJECTID"}, 
    {
      "name" : "NAME", 
      "type" : "esriFieldTypeString", 
      "alias" : "NAME", 
      "length" : 254}, 
    {
      "name" : "COUNTY", 
      "type" : "esriFieldTypeString", 
      "alias" : "COUNTY", 
      "length" : 254}, 
    {
      "name" : "EASTING", 
      "type" : "esriFieldTypeDouble", 
      "alias" : "EASTING"}, 
    {
      "name" : "NORTHING", 
      "type" : "esriFieldTypeDouble", 
      "alias" : "NORTHING"}, 
    {
      "name" : "Display", 
      "type" : "esriFieldTypeDouble", 
      "alias" : "Display"}, 
    {
      "name" : "ScaleDep", 
      "type" : "esriFieldTypeDouble", 
      "alias" : "ScaleDep"}, 
    {
      "name" : "size", 
      "type" : "esriFieldTypeString", 
      "alias" : "size", 
      "length" : 15}, 
    {
      "name" : "Shape", 
      "type" : "esriFieldTypeGeometry", 
      "alias" : "Shape"}
  ], 
  "typeIdField" : null, 
  "types" : null, 
  "relationships" : [], 
  "capabilities" : "Map,Query,Data"
}
  // ...
   ]
  // ...
}

My zaś chcieliśmy mieć coś pośredniego albo dokładniej mówiąc rozszerzony Listing 1 o kilka informacji z Listing 2 jak i dodatkowe informacje niezbędne do naszego procesowania warstwy w aplikacji, takich jak czy posiada raport czy, jeżeli jest typu Point to zwróć informacje o atrybutach. Ogólnie chodziło nam by pod koniec Json zwrócony wyglądał tak:

{
  "mapName" : "Layers",
  "description" : "",
  "copyrightText" : "",
  "layers" : [
    {
      "id" : 0,
      "name" : "General Gazetteer",
      "description" : "",
      "parentLayerId" : -1,
      "geometryType" : null,
      "scaleLower" : 0,
      "scaleUpper" : 0,
      "defaultVisibility" : false,
      "containsReport" : false,
      "displayField" : "",
      "fields" : null,
      "subLayerIds" : [
        1,
        2
      ]
    },
    {
      "id" : 1,
      "name" : "XYZ Offices",
      "description" : "ADMIN_XYZOffices,0,1,0",
      "parentLayerId" : 0,
      "geometryType" : "esriGeometryPoint",
      "scaleLower" : 0,
      "scaleUpper" : 0,
      "defaultVisibility" : false,
      "containsReport" : false,
      "displayField" : "XYZ_OFFICE",
      "fields" : [
        {
          "name" : "XYZ_OFFICE",
          "aliasName" : "XYZ_OFFICE"
        },
        {
          "name" : "OFFICE_TYP",
          "aliasName" : "OFFICE_TYP"
        }
      ],
      "subLayerIds" : null
    },
   // ...
   ]
}

Zanim się do tego dobrałem, istniało pośrednie rozwiązanie za pomocą usługi Proxy. Celem jej było zwrócenie nam danych tak by wyglądały jak chcemy, zaś usługa Proxy odpytywała serwer o 1MB danych, które następnie przetwarzała. Rozwiązanie nie było złe, działało i ograniczało odpytywanie serwera ESRI, jednak miało wadę taką, iż wciąż pobierało ten 1MB danych.

Postanowiłem więc, że trzeba to naprawić i ograniczyć komunikację do minimum. Jednak nie wiedziałem jak a dokumentacja ESRI jest trochę… nie ważne. Jakimś cudem jednak odnalazłem w niej informacje o rozszerzeniu usługi REST o własny serwis za pomocą Server Object Extensions (SOE) – ogólnie tworzymy obiekt COM, i korzystamy z COMów udostępnionych przez ESRI w celu pobrania i zwrócenia danych. Tutaj właśnie natknąłem się na interfejs IMapServer3, który przysporzył mi wiele problemów. Po pierwsze IMapServer3 to kolejna wersja interfejsu, wcześniejsze to IMapServer i IMapServer2. Co lepsze dokumentacja chyba w pełni jest stworzona do IMapServer zaś 2-3 ma ją ograniczoną – linków do dok nie podaje bo… się nie da lub wymaga to wycinanie ich z iframe (sorki).

Kolejnym problemem z IMapServer3 jest to iż w zależności od tego gdzie z niego korzystamy możemy go castować na inny typ intefejsu. Na przykład w jednym z przykładów pokazany jest kod:

IMapServer mapServer = serverContext.ServerObject as IMapServer;
// mapServer is IMapServerObejcts == false in SOE REST Ext
IMapServerObjects mapServerObjects = mapServer as IMapServerObjects;
IMap map = mapServerObjects.get_Map(mapServer.DefaultMapName);

IEnumLayer layers = map.get_Layers(null, true);
layers.Reset();
ILayer layer;
while((layer = layers.Next()) != null)
{
    IFeatureLayer featureLayer = layer as IFeatureLayer;
    if(featureLayer != null
        && featureLayer.FeatureClass.ShapeType == esriGeometryType.esriGeometryPoint
    )
    {
        // do...
    }
}

Który w moim przypadku nie działał, a szkoda, bo prowadził on do dostępu do ILayer (ILayer2), który dawał mi wszystkie własności, które chciałem pobrać. Oczywiście na ma w dok. na ten temat żadnych informacji, bo i po co :) ale ok, tyle jeżeli chodzi o jechanie po ESRI API. Tak czy siak jest to chyba jedyna firma, która ma tak dobrze rozbudowane API i SDK dla programistów. Szkoda :)

Mając już dostęp do środowiska dev, gdzie mogłem pisać było mi łatwiej testować i dowiadywać się, że coś nie działa tak jak chciałbym (testowanie: odrejestrowanie rozszerzenia z ESRI API, odrejestrowanie COM, restart usług ESRI, rejestracja COM, rejestracja rozszerzenia w ESRI, restart usług ESRI, włączenie extension na stronie zarządzania serwerem, otwarcie strony, przeczytanie błędu, powrót do VS i tak w kółko). Dzięki kilku próbom dowiedziałem się na co mogę a na co nie mogę castować IMapServer3, przez to też wiedziałem jakie API mogę użyć by pobrać informacje o warstwach. I tu spotkałem się ze ścianą. Mimo iż miałem dostęp do API wciąż nie byłem wstanie pobrać dwóch informacji, które mnie interesowały (na ten sam problem natknąłem się kiedy pisałem kod na sucho):

  1. geometryType – typ warstwy;
  2. defaultVisibility – informacja, czy warstwa jest domyślnie widoczna czy też nie.

I tu chyba bym się już poddał gdyby nie mały cud. Podczas pobierania informacji o atrybutach do wyświetlenia natrafiłem na pole typu esriFieldTypeGeometry, co mnie trochę zaciekawiło. Po pierwsze, czemu znajduje się ono w atrybutach kiedy zarówno Json jak i strona REST pokazuje iż ono się tam nie znajduje (albo ja źle rozumiem pole Shape, które dla mnie określa kształ nie warstwy a pojedynczego elementu/obiektu w warstwie):

geometryType 

fields

Po drugie, czy ono da mi tą informację której potrzebuje?

Na szczęście, po kolejnym castowaniu na interfejs okazało się iż jest to możliwe:

IFields fields = mapLayerInfo.Fields;
if(fields == null) return;

for(int j = 0; j < fields.FieldCount; j++)
{
    IField field = fields.Field[j];

    if(field.Type != esriFieldType.esriFieldTypeGeometry)
    {
        continue;
    }

    IGeometryDef geometryDef = field.GeometryDef;

    layerInfo.GeometryType = Enum.GetName(typeof(esriGeometryType), geometryDef.GeometryType);
}

Pozostałą więc sprawa z default visibility. Tutaj już tak prosto nie było, po przejrzeniu wszystkich atrybutów stwierdziłem, iż tej informacji tam nie ma. Nie ma jej także w IMapLayerInfo, jednak gdzieś musi być ona przechowywana.

Przypadkowo przeglądając IntelliSense od VS natrafiłem na własność DefaultMapDescription, od IMapServerInfo3 (nie mylić IMapServer3), która zawierała pod własność zwracającą ILayerDescriptions. Które po z castowaniu na ILayerDescription3 zwracało informacje o domyślnej widoczności elementu:

public static bool IsLayerVisible(IMapServerInfo3 mapServerInfo, int layerId)
{
    ILayerDescriptions layerDescs = mapServerInfo.DefaultMapDescription.LayerDescriptions;
    long c = layerDescs.Count;

    for (long i = 0; i < c; i++)
    {
        var layerDesc = (ILayerDescription3)layerDescs.Element[i];

        if (layerDesc.ID == layerId)
        {
            return layerDesc.Visible;
        }
    }

    return false;
}

I tak o to po męczarniach udało mi się rozwiązać problem pobrania geometryType i default visibility dla warstwy w SOE ArcObjects dla REST.

Cały kod wygląda tak:

// get map info
public static MapInfo ConstructFrom(IMapServer3 mapServer3)
{
    // IMapServerInfo3 contains CopyrightText prop, 1 & 2 does not 
    var mapServerInfo = (IMapServerInfo3)mapServer3.GetServerInfo(mapServer3.DefaultMapName);
            
    var mapInfo = new MapInfo
                    {
                        Description = mapServerInfo.Description, 
                        MapName = mapServerInfo.Name, 
                        CopyrightText = mapServerInfo.CopyrightText
                    };
            
    IMapLayerInfos layerInfos = mapServerInfo.MapLayerInfos;
    for (int i = 0; i < layerInfos.Count; i++)
    {
        var layerInfo = layerInfos.Element[i];
                
        mapInfo.Layers.Add(
            LayerInfo.ConstructFrom(
                layerInfo, 
                LayerInfo.IsLayerVisible(mapServerInfo, layerInfo.ID)
        ));
    }

    return mapInfo;
}

// get layer info
private static readonly int[] AvaliableReports = new[] {17,18,19,20};
public static LayerInfo ConstructFrom(IMapLayerInfo mapLayerInfo, bool visible)
{
    var layerInfo = new LayerInfo
    {
        Id = mapLayerInfo.ID,
        DisplayField = mapLayerInfo.DisplayField,
        ScaleUpper = (int)mapLayerInfo.MaxScale,
        ScaleLower = (int)mapLayerInfo.MinScale,
        Name = mapLayerInfo.Name,
        Description = mapLayerInfo.Description,
        DefaultVisibility = visible,
        ParentLayerId = mapLayerInfo.ParentLayerID,
    };

    IFields fields = mapLayerInfo.Fields;
    bool addFields = false;
    if (fields != null)
    {
        for (int j = 0; j < fields.FieldCount; j++)
        {
            IField field = fields.Field[j];

            if (field.Type == esriFieldType.esriFieldTypeString)
            {
                layerInfo.DictFields[field.Name] = field.AliasName;
                continue;
            }

            if(field.Type != esriFieldType.esriFieldTypeGeometry)
            {
                continue;
            }
                    
            IGeometryDef geometryDef = field.GeometryDef;

            layerInfo.GeometryType = Enum.GetName(typeof(esriGeometryType), geometryDef.GeometryType);

            switch (geometryDef.GeometryType)
            {
                case esriGeometryType.esriGeometryNull:
                    layerInfo.GeometryType = null;
                    break;
                case esriGeometryType.esriGeometryPoint:
                    addFields = true;
                    break;
            }
        }
    }

    // hack: this should be done in a proper way... but for now is enough
    layerInfo.ContainsReport = AvaliableReports.Contains(layerInfo.Id);

    if(!addFields)
    {
        layerInfo.DictFields = new Dictionary<string, string>();
    }

    if (mapLayerInfo.SubLayers == null)
    {
        return layerInfo;
    }

    for (int i = 0; i < mapLayerInfo.SubLayers.Count; i++)
    {
        layerInfo.SubLayerIds.Add(mapLayerInfo.SubLayers.Element[i]);
    }

    return layerInfo;
}

// get default visibility
public static bool IsLayerVisible(IMapServerInfo3 mapServerInfo, int layerId)
{
    ILayerDescriptions layerDescs = mapServerInfo.DefaultMapDescription.LayerDescriptions;
    long c = layerDescs.Count;

    for (long i = 0; i < c; i++)
    {
        var layerDesc = (ILayerDescription3)layerDescs.Element[i];

        if (layerDesc.ID == layerId)
        {
            return layerDesc.Visible;
        }
    }

    return false;
}

Jedynym problemem jest to, iż cały czas operujemy na różnych tablicach przez co IMO niepotrzebnie robimy pętle for, jednak nie ma nigdzie napisane, że kolejność w każdej z tablic jest zachowana i ich wielkość taka sama. A skoro nie ma tego napisanego to można założyć, iż są przypadki kiedy indeks odpowiadający za IMapLayerInfo będzie odnosił się do innej warstwy w ILayerDescription3. Może ktoś z was coś na ten temat wie? Z chęcią ograniczyłbym te fory, jednak nie mam zbyt dużo serwerów map na tyle zróżnicowanych by móc powiedzieć, iż problem z tablicami nie istnieje.