SiteMap-провайдер
На этой страничке я расскажу как написать СОБСТВЕННЫЙ SiteMap-провайдер. Особо умного я не нашел ничего на эту тему ни в MSDN, ни в букварях - все переписывают друг у друга один и тот же пример со StaticSiteMapProvider, который мне явно не подходит. Поэтому пришлось разбираться самому.
До начала этой работы у меня был примерно вот такой приготовленный мною файлик Web.sitemap. Именно его цепляет стандартный AspNetXmlSiteMapProvider (при отсутствии указаний на другое имя файла в Web-config). Этот файлик обрабатывается на страничке (обычно MasterPage) двумя контролами
- контролом SiteMapPath, который выглядит на страничке вот так:
<asp:SiteMapPath ID="SiteMapPath2" runat="server"> </asp:SiteMapPath>
- и контролом Menu, который в отличие от предыдущего контрола, работает НЕ НЕПОСРЕДСТВЕННО С ПРОВАЙДЕРОМ, а при посредничестве SiteMapDatasource, который выдает для контрола Menu HierarhicalDataGrid. Эта пирамидка на страничке обычно выглядит вот так:
<asp:Menu ID="Menu2" runat="server" DataSourceID="SiteMapDataSource2"> </asp:Menu> <asp:SiteMapDataSource ID="SiteMapDataSource2" runat="server" />
MS подготовила для конфигурирования этих контролов преотличный мастер, который можно вызвать и ровно в один клик получить и меню и путь по сайту - вообще не задумываясь что и как там работает за кулисами. Но мы задумаемся и сначала посмотрим на методы и свойства контрола SiteMapPath (и увидим на этом рисунке как это контрол напрямую обращается к стандартному XmlSiteMapProvider), посмотрим на методы и свойства контрола Menu (и увидим, как он биндится на SiteMapDataSource).
Теперь посмотрим на методы и свойства источника данных - SiteMapDatasource и увидим, как он фактически обращается к тому же провайдеру, к которому контрол SiteMapPath обращается непосоредственно. Далее обратимся к провайдеру и посмотрим на саму основную структуру данных SiteMapNode - которую провайдер отдает либо контролу SiteMapPath непосредственно, либо SiteMapDataSource, который формирует из нее иерархический DataView и отдает его контролу Menu. К этому моменту уже становится ясно что именно и должен будет совершить наш провайдер - скомпоновать дерево из узлов SiteMapNode и отдавать их либо контролу либо SiteMapDataSource.
Теперь попробуем понять КАК ИМЕННО он должен это делать. Первое, что очевидно - ОДИН его экземпляр будет работать со всеми Request'ами на сервер. Иначе говоря, это должен быть реентерабельный код, в необходимых местах залоченный по SyncLock (так же как работает собственно операционная система). Попробуем унаследоваться от SiteMapProvider и посмотреть какие именно методы нам обозначили как MustInherit. Это самый начальный момент моей разработки, когда я еще не понимал, какие методы и в какой момент сработают. Конечно, в мультизадачном варианте он некорректен, как минимум надо было лочить Trace1.AppendLine. После трассировки становится более ли менее понятно, что:
- GetChildNodes - Апендикс. Сюда выпадает при всех обращениях к ChildNodes (даже при загрузке в Initialize)
- GetParentNode - Тоже апендикс. Сюда выпадает код ParentNode у узла Nothing. Эти два метода нам будут не нужны. Непонятно вообще зачем они обозначены как MustInherit. Но, не будем обращать на это внимание и пойдем дальше.
- Initialize - это инифиализация класса провайдера - при старте Web-приложения.
- GetRootNodeCore - наш основной рабочий метод - должен вернуть вообще все, что удалось построить в Initialize.
- FindSiteMapNode - наш основной рабочий метод. На нем будет проводится собственно все тестирование провайдера.
- MySiteMapProvider_SiteMapResolve - это событие возникает как правило при каждом новом юзере при первичном обращении его к провайдеру. Его функциональность дублирует FindSiteMapNode.
Настало время подготовить некоторые тестовые входные данные для нашего провайдера. На самом деле, все конечно обстоит значительно сложнее и реальные данные УЖЕ загружены в базе Дигимейкера, но мы пока не будем зацикливаться на этом и добьемся корректной работы провайдера на тестовых данных. Итак: <
- Подготовим вот такую табличку.
- Заполним ее данными. Это пока самые простейшие данные, без наименования узлов и атрибутов. Вообще-то для использования атрибутов длина поля с ними должна быть не менее 4 тыс. Но насчет заполнения атрибутов я скажу как делать это чуть дальше.
- Создадим ДатаСет. В принципе вот его исходный текст.
- Я здесь создал и табличку привелигированных юзеров и прав привелигированных юзеров на защищенные странички. В это вся фишка моего провайдера и цель его создания. Он не только должен вычитать странички созданные ядром дигимейкера, но и создать для разных привелегированных юзеров разные представления о сайте. Ну а для непривилегированных - само собой - представление о сайте будет вообще другое. Те. речь идет НЕ ТОЛЬКО об аутентификации, которая не пустит на нужную страничку юзера, не имающего прав не нее (в этой сложной среде с аутентификацией DigemakerMemberShipProvider'ом мне пришлось реализовывать провайдер аутентификации самому), но и создаст у разных юзеров РАЗНЫЕ ПЛАНЫ САЙТА.
Теперь пора поговорить об атрибутах каждого узла. К этим атрибутам можно будет привязаться непосредственно со странички, ибо нам не просто надо создать такой провайдер, который бы создавал разные планы сайта для разных юзеров (да еще и принимая данные из базы дигимейкера), но и достаточно визуально накрученный. Те некоторые узлы синенькие, некоторые беленькие и так далее. Эти все фишечки мы заделаем с помощью атрибутов, которые будут записаны у нас базе.
Для этого возьмем вот такой мой библиотечный код:
00001: Public Class SoapFormatter 00002: Dim Buf() As Byte 00003: Dim Encoder As New System.Text.ASCIIEncoding 00004: Dim Soap As New System.Runtime.Serialization.Formatters.Soap.SoapFormatter 00005: 00006: Public Sub New(ByVal BufSize As Integer) 00007: Buf = Array.CreateInstance(GetType(System.Byte), BufSize) 00008: End Sub 00009: 00010: Public Function Serialize(ByVal Input As System.Collections.Specialized.NameValueCollection) As String 00011: Dim MS As New System.IO.MemoryStream(Buf) 00012: Soap.Serialize(MS, Input) 00013: Return Encoder.GetString(Buf) 00014: End Function 00015: 00016: Public Function Deserialize(ByVal Input As String) As System.Collections.Specialized.NameValueCollection 00017: Buf = Encoder.GetBytes(Input) 00018: Dim MS As New System.IO.MemoryStream(Buf) 00019: Return Soap.Deserialize(MS) 00020: End Function 00021: End Classсформируем им атрибуты нужных узлов И вот так запишем их в базу. Обратите внимание, как именно я прочитал этот атрибут на страничке. Так же легко я его прочитаю и декларативно в коде HTML-разметки, чтобы у меня узлы на плане сайта были еще и разноцветными и условно содержали либо не содержали ссылки - те были бы ЗАГОЛОВКАМИ подразделов ИЛИ ПРОПУСКАМИ на плане сайта.
Те, кто дочитал до этого момента, уже созрел, чтобы увидеть код простейшей заготовки моего провайдера:
00001: Imports Microsoft.VisualBasic 00002: 00003: Public Class MySiteMapProvider 00004: Inherits SiteMapProvider 00005: 00006: 'Dim Trace1 As New System.Text.StringBuilder 00007: Dim AdminRight As String 00008: Dim PrividerName As String 00009: Dim LocalPrefix as string 00010: Dim Map As dsSiteMap.MapDataTable 00011: Dim Right As dsSiteMap.RightDataTable 00012: Dim Tree As System.Web.SiteMapNode 'MySiteMapNode 00013: Dim SOAP as New siSMWeb.SoapFormatter(10000) 00014: Dim Attr as New Collections.Specialized.NameValueCollection 00015: 00016: 00017: #Region "Начальная загрузка дерева" 00018: 00019: Public Overrides Sub Initialize(ByVal name As String, ByVal attributes As System.Collections.Specialized.NameValueCollection) 00020: 'Trace1.AppendLine("Initialize") 00021: If Map Is Nothing Then 00022: SyncLock Me 00023: 'сначала читаем все из базы 00024: PrividerName = name 00025: AdminRight = attributes("AdminRight") 00026: LocalPrefix = attributes("LocalPrefix") 00027: Map = New dsSiteMap.MapDataTable 00028: Right = New dsSiteMap.RightDataTable 00029: Dim MapTA1 As New dsSiteMapTableAdapters.MapTA 00030: Dim RightTA1 As New dsSiteMapTableAdapters.RightTA 00031: MapTA1.Fill(Map) 00032: RightTA1.Fill(Right) 00033: For Each X As Data.DataRow In Map.Rows 00034: X.Item("Url") = LocalPrefix & X.Item("Url") 00035: X.Item("Key") = LocalPrefix & X.Item("Key") 00036: If IsDBNull(X.Item("Title")) Then X.Item("Title") = System.IO.Path.GetFileNameWithoutExtension(X.Item("Url")) 00037: If IsDBNull(X.Item("Description")) Then X.Item("Description") = System.IO.Path.GetFileNameWithoutExtension(X.Item("Url")) 00038: Next 00039: If Map.Rows.Count = 0 Then Throw New Exception("SiteMap Error - No records in SiteMap table") 00040: If Right.Rows.Count = 0 Then Throw New Exception("Right Error - No records in UserRight table") 00041: ' 00042: 'теперь строим дерево 00043: Tree = New System.Web.SiteMapNode(Me, Map.Rows(0).Item("Key").ToString, Map.Rows(0).Item("Url").ToString, Map.Rows(0).Item("Title").ToString, Map.Rows(0).Item("Description").ToString) 00044: Tree.ResourceKey = Map.Rows(0).Item("i") '"i" строки в Map для ссылки из дочернего узла 00045: AddChildren(Map.Rows(0).Item("i"), Tree) 00046: For I As Integer = 1 To Map.Rows.Count - 1 00047: Dim Z As System.Web.SiteMapNode = GetNodesForTag(Tree, Map.Rows(I).Item("i")) 00048: If Z IsNot Nothing and not IsDBNull(Map.Rows(I).Item("Attributes")) then 00049: 'добавим в узел десериализованные атрибуты 00050: attr.Clear 00051: try 00052: Attr=Soap.Deserialize(Map.Rows(I).Item("Attributes")) 00053: Catch ex As Exception 00054: Throw new Exception ("Error in Attributes fields in SiteMap table in row <" & Map.Rows(I).Item("i").ToString & ">", ex) 00055: End Try 00056: for each T as string in Attr 00057: z.Item(t)=attr(t) 00058: Next 00059: End If 00060: 'и дочерние узлы 00061: If Z IsNot Nothing Then AddChildren(Map.Rows(I).Item("i"), Z) 00062: Next 00063: 'дерево построено - служебные маркеры можно удалить 00064: For I As Integer = 0 To Map.Rows.Count - 1 00065: Dim Z As System.Web.SiteMapNode = GetNodesForTag(Tree, Map.Rows(I).Item("i")) 00066: If Z IsNot Nothing Then Z.ResourceKey = Nothing 00067: Next 00068: End SyncLock 00069: ' 00070: MyBase.Initialize(name, attributes) 00071: End If 00072: 00073: End Sub 00074: 00075: 'Добавление узла в дерево из таблы 00076: Private Sub AddChildren(ByVal TableIndex As Integer, ByRef ParentNode As System.Web.SiteMapNode) 00077: Dim Y As New System.Web.SiteMapNodeCollection 00078: For I As Integer = TableIndex To Map.Rows.Count - 1 00079: If Map.Rows(I).Item("ToParent") = TableIndex Then 00080: Dim X As New System.Web.SiteMapNode(Me, Map.Rows(I).Item("Key").ToString, Map.Rows(I).Item("Url").ToString, Map.Rows(I).Item("Title").ToString, Map.Rows(I).Item("Description").ToString) 00081: X.ResourceKey = Map.Rows(I).Item("i").ToString 00082: X.ParentNode = ParentNode 00083: Y.Add(X) 00084: End If 00085: Next 00086: If Y.Count > 0 Then ParentNode.ChildNodes = Y 00087: End Sub 00088: 00089: 'Рекурсивный обход дерева в поисках нужного тега 00090: Private Function GetNodesForTag(ByVal StartNode As System.Web.SiteMapNode, ByVal TableIndex As Integer) As System.Web.SiteMapNode 00091: If StartNode.ResourceKey = TableIndex Then Return StartNode 00092: If StartNode.ChildNodes IsNot Nothing Then 00093: For Each X As System.Web.SiteMapNode In StartNode.ChildNodes 00094: If X.ResourceKey = TableIndex Then 00095: Return X 00096: Else 00097: 'обход в глубину - сразу же проверяем его дочек 00098: Dim Y As System.Web.SiteMapNode = GetNodesForTag(X, TableIndex) 00099: If Y IsNot Nothing Then Return Y 00100: End If 00101: Next 00102: End If 00103: End Function 00104: 00105: 'Рекурсивный обход дерева в поисках нужного URL 00106: Private Function GetNodesForURL(ByVal StartNode As System.Web.SiteMapNode, ByVal URL As String) As System.Web.SiteMapNode 00107: If StartNode.URL = URL Then Return StartNode 00108: If StartNode.ChildNodes IsNot Nothing Then 00109: For Each X As System.Web.SiteMapNode In StartNode.ChildNodes 00110: If X.Url = URL Then 00111: Return X 00112: Else 00113: 'обход в глубину - сразу же проверяем его дочек 00114: Dim Y As System.Web.SiteMapNode = GetNodesForURL(X, URL) 00115: If Y IsNot Nothing Then Return Y 00116: End If 00117: Next 00118: End If 00119: End Function 00120: 00121: #End Region 00122: 00123: Public Overloads Overrides Function FindSiteMapNode(ByVal rawUrl As String) As System.Web.SiteMapNode 00124: 'Trace1.AppendLine("FindSiteMapNode:" & rawUrl & ",Context.Session('ContactID')=" & HttpContext.Current.Session("ContactID")) 00125: Return GetNodesForURL(Tree, rawUrl) 00126: End Function 00127: 00128: Protected Overrides Function GetRootNodeCore() As System.Web.SiteMapNode 00129: 'Trace1.AppendLine("GetRootNodeCore,Context.Session('ContactID')=" & HttpContext.Current.Session("ContactID")) 00130: Return Tree 00131: End Function 00132: 00133: 'Occurs when the CurrentNode property is called. 00134: Private Function MySiteMapProvider_SiteMapResolve(ByVal sender As Object, ByVal e As System.Web.SiteMapResolveEventArgs) As System.Web.SiteMapNode Handles Me.SiteMapResolve 00135: 'Trace1.AppendLine("SiteMapResolve. Context.Session('ContactID')=" & e.Context.Session("ContactID")) 00136: Return GetNodesForURL(Tree, e.Context.Current.Request.RawUrl) 00137: End Function 00138: 00139: #Region "Неактуально в моей схеме" 00140: 'Апендикс. Сюда выпадает при всех обращениях к ChildNodes (даже при загрузке в Initialize) 00141: Public Overrides Function GetChildNodes(ByVal node As System.Web.SiteMapNode) As System.Web.SiteMapNodeCollection 00142: 'Trace1.AppendLine("GetChildNodes: " & node.Url) 00143: End Function 00144: 00145: 'Апендикс. Сюда выпадает код ParentNode у узла Nothing 00146: Public Overrides Function GetParentNode(ByVal node As System.Web.SiteMapNode) As System.Web.SiteMapNode 00147: 'Trace1.AppendLine("GetParentNode: " & node.Url) 00148: End Function 00149: #End Region 00150: End Class
Разумеется расширять вышевыложенный шаблон проекта можно В ЛЮБОМ направлении. Ну тут приведен тот самый момент разработки, когда провайдер еще НЕ утратил своей универсальности и применимости для всех и вся. На описанный момент - это полностью работающий универсальный класс. Отсюда этот проект можно развивать в любую сторону. Но я его развил только в определенную, необходимую заказчику. Конечно, полностью коммерческий продукт я описывать тут не буду, ибо права на него принадлежат не мне, но я лишь НАМЕКНУ, как В ПРИНЦИПЕ МОЖНО развивать этот проект дальше. Я упоминал, что одна из целей этого провайдера - разграничить создать разный план сайта у разных юзеров.
Здесь я вижу два пути. В первом варианте докручивания к этот код прав юзеров - нам потребуется создать сам узел дерева с дополнительным свойством - минимальным уровнем прав юзера, чтобы этот узел был виден в контексте юзера. Разумеется тут можно докрутить и группы и прочее, но мой собственный провайдер аутентификации (надстройка над дигимейкером) не поддерживает групп. Поэтому их тут и нет:
00001: Public Class MySiteMapNode 00002: Inherits System.Web.SiteMapNode 00003: 00004: Dim Node_Right as Integer 00005: Public readonly property NodeRight as Integer 00006: Get 00007: return Node_Right 00008: End Get 00009: End Property 00010: Public Sub New(NodeRight as Integer, ByVal Provider As System.Web.SiteMapProvider, ByVal Key As String) 00011: MyBase.New(Provider, Key) 00012: Node_Right =NodeRight 00013: End Sub 00014: 00015: Public Sub New(NodeRight as Integer,ByVal Provider As System.Web.SiteMapProvider, ByVal Key As String, Url as string) 00016: MyBase.New(Provider,Key, Url) 00017: Node_Right =NodeRight 00018: End Sub 00019: 00020: Public Sub New(NodeRight as Integer,ByVal Provider As System.Web.SiteMapProvider, ByVal Key As String, Url as string, Title as string) 00021: MyBase.New(Provider,Key, Url, Title) 00022: Node_Right =NodeRight 00023: End Sub 00024: 00025: Public Sub New(NodeRight as Integer,ByVal Provider As System.Web.SiteMapProvider, ByVal Key As String, Url as string, Title as string, Description as string) 00026: MyBase.New(Provider,Key, Url, Title, Description) 00027: Node_Right =NodeRight 00028: End Sub 00029: 00030: Public Sub New(NodeRight as Integer,ByVal Provider As System.Web.SiteMapProvider, ByVal Key As String, Url as string, Title as string, Description as string, Roles as system.Collections.Ilist, Attributes as System.Collections.Specialized.NameValueCollection, ExplicitResourceKeys as System.Collections.Specialized.NameValueCollection, ImplocitResourceKey as string) 00031: MyBase.New(Provider,Key, Url, Title, Description, Roles, Attributes , ExplicitResourceKeys , ImplocitResourceKey ) 00032: Node_Right =NodeRight 00033: End Sub 00034: 00035: End Classв этом варианте View дерева сайта надо создавать на ходу для каждого юзера отдельно. Это весьма экономно по расходуемой памяти. Но требует перегрузки не только System.Web.SiteMapNode, но и System.Web.SiteMapNodeCollection.
Второй вариант более быстрый, не требует владения тонкостями обьектного программирования. Но плата за это - ощутимый расход памяти при значительном количестве юзеров с разными правами. В этом варианте можно создать например вот такой словарик
00001: Dim SiteMapList as New System.Collections.Generic.Dictionary(Of Integer, System.Web.SiteMapNode)в который укладывать все возможные представления о структуре сайта и далее возвращать предстваления юзерам с конкретными правами примерно вот так:
00002: Dim Right as Integer = GetCurrentUserRight 00003: If not SiteMapList.ContainsKey(Right) then 00004: Dim Tree as system.web.SiteMapNode 00005: Synclock ME 00006: 'дерева сайта для юзера с такими правами еще нету - надо строить 00007: Tree= BuildSiteTree(Right) 00008: SiteMapList.Add(Right,Tree) 00009: End Synclock 00010: return Tree 00011: else 00012: return SiteMapList(Right) 00013: End If
Еще один вектор развития дальнейшего этого проекта - это изменение меню без перезапуска приложения. Это можно сделать и через отдельный административный интерфейс. Впрочем для моего проекта с Дигимейкером это не подходит. Другой вариант - сделать применить встроенный в ASP2 сервис событий изменения рекордсетов. Третий вариант - только для SQL2005 - применить Notification Server. И наконец, четвертый - который мне наиболее близок по духу (и к тому же подходит для работы в среде Дигимейкера) - создать в домене приложения поток, который будет держать открытый коннект к базе. Когда триггер словит изменение в нужном мне рекордсете, он рвет коннект. Висящий поток перезапускает опять коннект на WAITFOR и выдает Refresh в провайдер.
В любом случае вам придется вытащить на форму адрес рефреша провайдера. Делается это с помощью делегатов (это вообще хороший пример их применения). Для этого в самом провайдере делается вот такое объявление делегата:
Public delegate sub SiteMapProvider_Refresh (ByVal name As String, ByVal attributes As System.Collections.Specialized.NameValueCollection)И затем адрес провайдера запоминается в обьекте Application вот так:
Сам по себе вызов этого делегата с формы выглядит так:
Теперь последний вопрос, который мы тут посмотрим - как привязаться на страничке к данным, сформированным моим провайдерм. Вообще, привязка - это непростая придумка. Особенно когда надо выражения привязки составить самостоятельно. Поэтому я тут покажу, как именно прицепиться на страничке к данным, сформированным моим провайдером. Для этого:
- Проверим как биндится контрол SiteMapPath уже непосредственно к моему провайдеру.
- Теперь становится ясно, как ИМЕННО надо биндится к этим данным непосредственно на страничке:
<asp:SiteMapPath ID="SiteMapPath1" runat="server" SiteMapProvider="MySiteMapProvider"> <NodeTemplate> <asp:HyperLink ID="HyperLink2" NavigateUrl='<%# DataBinder.Eval(Container, "SiteMapNode.URL") %>' runat="server" Text='<%# DataBinder.Eval(Container, "SiteMapNode.Title") %>' ></asp:HyperLink> </NodeTemplate> </asp:SiteMapPath>
- Проверим как биндится контрол Menu к SiteMapDataSource, обращающимся к моему провайдеру. Привязка происходит к узлу SiteMapNode. Если мы создадим шаблон, то дальше можно будет привязать свойства элемента в шаблоне уже в диалоге.
- В итоге на страничке привязка контрола MENU будет выглядеть так:
<asp:Menu ID="Menu1" runat="server" DataSourceID="SiteMapDataSource1" StaticDisplayLevels="3"> <DataBindings> <asp:MenuItemBinding DataMember="SiteMapNode" NavigateUrlField="Url" TextField="Title" /> </DataBindings> <StaticItemTemplate> <asp:HyperLink ID="HyperLink1" runat="server" NavigateUrl='<%# Eval("NavigateUrl", "{0}") %>' Text='<%# Eval("Text", "{0}") %>'></asp:HyperLink> </StaticItemTemplate> </asp:Menu> <asp:SiteMapDataSource ID="SiteMapDataSource1" runat="server" ShowStartingNode="False" SiteMapProvider="MySiteMapProvider" />
- А как прибиндится со странички к атрибутам, попробуйте догадаться сами...
И, наконец, самый последний вопрос, про который я чуть не забыл. Провайдер конфигурится через Web-конфиг, через заведомо предусмотренную секцию конфигурации. При этом имя класса провайдера в контролах можно даже не задавать (если провайдер один).
Разумеется, к этому провайдеру вам придется сделать административную форму, если только вы не захотите предоставить юзеру напрямую ковыряться в базе. Эта админка может, например, выглядеть вот так:
Текст этой админ-формы совершенно тривиален и представляет собой обычную ONLINE-редактируемую сетку и единственное, о чем тут может быть упомянуто - это то, что редактируемый шаблон как видите заполняется существенно по разному для каждой строки. В комбешнике можно установить ТОЛЬКО ДОПУСТИМЫЕ значения для номера узла вышележащего уровня. Жизненный цикл странички позволяет добиться такого эффекта ТОЛЬКО если вынести шаблон в отдельный контрол.
|