Обработка событий в приложениях с многослойной архитектурой

в 10:03, , рубрики: .net, C#, event handling, Events

Довольно часто в приложениях с многослойной архитектурой простой механизм событий, предлагаемый .NET, оказывается неудобным в использовании. При глубине композиции объектов от трёх и более возникают сложности либо с подпиской верхних объектов на события, которые происходят на нижних уровнях, либо с передачей события вверх через все уровни, которые могут быть в нём заинтересованы. Рассмотрим подробнее эти проблемы, а также способ их избежать с помощью централизованного механизма регистрации и вызова обработчиков событий.

Вот пример из моего недавнего проекта. Это WPF-приложение, которое периодически отслеживает появление контента в определённом источнике и уведомляет пользователя об обновлениях.

image

В целом, с точки зрения архитектуры и дизайна не следует увлекаться и вводить слишком много слоёв в иерархии классов. Примечание: Иерархия здесь и далее в статье используется не в смысле наследования, а в смысле использования одних объектов другими. Однако на это иногда приходится идти, чтобы обеспечить универсальность классов и упростить имплементацию нового функционала в будущем.

В этом конкретном примере, иерархия выглядит разумной и сбалансированной. Каждое сообщение должно показываться в отдельном окне, есть критерии сортировки и фильтрации сообщений на основе данных. Поэтому было разумно ввести класс NotificationManager, который отвечает за всю эту логику. Класс Notification хранит все данные, связанные с сообщением. Класс NotificationWindow был введён для того, чтобы не привязываться к конкретному способу нотификации пользователя. Возможно, в будущем нужно будет изменить (или добавить) уведомление по e-mail, sms, etc. Класс Watcher отвечает за логику взаимодействия с сервером и с базой, в которой хранится информация о полученных сообщениях и действиях пользователей над ними.

Пользователь взаимодействует с окнами NotificationWindow, таким образом, объекты NotificationWindow являются основным источником событий в приложении. Причём некоторые из этих событий обрабатываются объектом NotificationManager и не идут дальше (например, закрытие окна), а некоторые должны дойти до объекта Watcher или даже до Application (например, изменение/сохранение данных, добавление данных в фильтры, и т.д.).

Теперь начинается самое интересное. Если бы мы использовали обычные события .NET, мы бы столкнулись со следующими вопросами:

1. Как обеспечить возможность для каждого заинтересованного объекта в иерархии подписаться на событие, которое находится на самом нижнем уровне?

Допустим, в классе NotificationWindow объявлено следующее событие:

class NotificationWindow
{
	//	...
	public event EventHandler<ContentChangedEventArgs> ContentChanged;
}

Объекты Notification могут при желании легко на него подписаться, но в этом событии больше всего заинтересован Watcher. Есть два возможных варианта, чтобы подписать Watcher на событие ContentChanged, и оба из них далеко не идеальны:

Первый (плохой) вариант: В каждом промежуточном классе объявить своё событие ContentChanged, на которое будет подписываться верхний уровень, и вызывать его в обработчике, который привязан к событию нижнего уровня. Что-то вроде этого:

class NotificationWindow : Window
{
	//	...
	public event EventHandler<ContentChangedEventArgs> ContentChanged;
}

class Notification
{
	private readonly NotificationWindow window;
	public event EventHandler<ContentChangedEventArgs> ContentChanged;
	//	...
	public Notification()
	{
		//	...
		window = new NotificationWindow();
		window.ContentChanged += NotificationContentChanged;
	}
	private void NotificationContentChanged(object sender, ContentChangedEventArgs e)
	{
		//	Self handling code
		//	...
		//	Passing event to upper layer
		ContentChanged?.Invoke(this, e);
	}
}

image

Минусы такого подхода очевидны: каждый промежуточный класс заводит своё событие и обработчик, чаще всего для того, чтобы просто передать событие на уровень выше. Отсюда лишний код, дополнительные затраты памяти и времени на цепочку вызовов.

Второй (плохой) вариант: Обеспечить возможность для Watcher подписаться на событие ContentChanged в NotificationWindow. Проблема тут в том, что Watcher должен пробраться через все промежуточные слои до нужного события, а это не всегда легко. Если все объекты уже существуют на момент подписки, то это просто:

watcher.notificationManager.notification.notificationWindow.ContentChanged += watcher.NotificationContentChanged;

Но в нашем случае объекты Notification и NotificationWindow создаются в процессе работы, поэтому NotificationManager должен как-то уведомить (через очередной event?) Watcher о создании нового объекта Notification, чтобы тот мог подписаться на его событие ContentChanged. Либо можно пойти простым путём: объявить событие статическим и напрямую подписать на него обработчик в Watcher:

class NotificationWindow : Window
{
	//	...
	public static event EventHandler<ContentChangedEventArgs> ContentChanged;
}
class Watcher
{
	//	...
	public void Init()
	{
		NotificationWindow.ContentChanged += NotificationContentChanged;
	}
	private void NotificationContentChanged(object sender, ContentChangedEventArgs e)
	{
		//	...
	}
}

image

Хотя такой подход выглядит простым, он плох с точки зрения архитектуры. Watcher не должен знать ничего о деталях реализации нижних слоёв. Мы должны попытаться обеспечить его взаимодействие с нижними уровнями только через ближайший нижний слой NotificationManager, как это делает предыдущий пример с цепочкой событий.

Продолжим список вопросов, которые возникают при использовании стандартных событий.Net в такой многослойной архитектуре:

2. Как обеспечить правильный порядок вызова обработчиков событий?

Обработчик сначала должен вызываться для объекта на нижнем уровне, затем для его “родителя”, и так выше и выше до вершины иерархии. Но даже если мы обеспечим подписку на событие в таком порядке, .NET не специфицирует, в каком порядке будут вызваны обработчики. Да, сейчас обработчики вызываются в порядке подписки, но это детали реализации и в будущих версиях .NET Framework всё может измениться.

3. Как обеспечить возможность прерывания обработки события?

Хотя это идёт вразрез с общей концепцией событий в .NET, согласно которой подписчики независимы и не влияют друг на друга, но на практике, особенно в таких многослойных приложениях, необходимость прервать обработку события всё-таки возникает.

Для того чтобы преодолеть все эти проблемы, я решил создать специальный механизм обработки событий в таких иерархических моделях. К нему предъявлялись следующие требования:

  1. Лёгкий способ подписки на событие и передачи события вверх по уровням.
    В идеале, подписка на событие любым заинтересованным уровнем должна сводиться к вызову одного метода, без необходимости вводить дополнительные ретрансляторы, как мы делали в примерах выше.
  2. Сохранение инкапсуляции уровней.
    Каждый уровень должен «знать» только о ближайшем нижнем уровне.
    Нижний уровень ничего не должен «знать» об уровнях, расположенных выше.
  3. Обеспечение порядка вызова обработчиков событий.
    Вызовы обработчиков должны осуществляться по цепочке от нижних уровней к верхним.
  4. Возможность прервать дальнейшую обработку события на любом из уровней.
    Каждый уровень может «решить», что он справился с обработкой события и что дальнейшая обработка события не имеет смысла.
  5. Отсутствие необходимости отписки от событий.
    Очень часто подписчики на стандартные события .NET не отписываются от них, что приводит к утечке памяти в управляемом коде. Поэтому я решил избавить клиентов от необходимости отписки (предоставив, конечно же, такую возможность).

Чтобы удовлетворить все эти требования я разработал класс MultilayerEventManager. Рассмотрим основные архитектурные особенности, а потом перейдём к деталям реализации:

  1. Вся информация об иерархии уровней и обо всех обработчиках событий хранится в одном центральном объекте. Собственно это и есть MultilayerEventManager, который является статическим классом.
  2. Иерархия уровней задаётся самими объектами. Каждый объект может «сказать» примерно следующее: нижним для меня уровнем является такой-то объект или такой-то класс (подробнее ниже).
  3. Иерархии уровней определяются отдельно для каждого типа события.
  4. Чтобы освободить клиентов от необходимости отписки и в то же время избежать утечек памяти, MultilayerEventManager хранит только слабые ссылки на объекты. Если объект был удалён, то информация о событиях, на которые он был подписан, автоматически удаляется.

image

Теперь рассмотрим основные моменты реализации. Вот поля и публичные методы MultilayerEventManager:

public static class MultilayerEventManager
{
	internal delegate void LayerEventHandler(object target, object sender, MultilayerEventArgs e);

	internal const int CallsBetweenDeadReferencesRemoving = 10;

	internal static readonly Dictionary<Type, WeakReferenceMap> Parents = new Dictionary<Type, WeakReferenceMap>();
	internal static readonly Dictionary<Type, WeakReferenceDictionary<LayerEventHandler>> Handlers = new Dictionary<Type, WeakReferenceDictionary<LayerEventHandler>>();
	internal static int CallsAfterLastDeadReferencesRemoving;

	public static void RegisterLowerLayer<TEventArgs>(object currentLayer, object lowerLayer);

	public static void RegisterLowerLayerForEvents(object currentLayer, object lowerLayer, params Type[] eventTypes);

	public static void RegisterHandler<TTarget, TEventArgs>(TTarget target, Action<TTarget, object, TEventArgs> handler) where TEventArgs : MultilayerEventArgs;

	public static void UnRegisterHandler<TEventArgs>(object target);

	public static void UnRegisterInstance(object target);
	
	public static void TriggerEvent<TEventArgs>(object sender, TEventArgs e) where TEventArgs : MultilayerEventArgs;

	public static void Clear();
}

Две основные структуры в классе – это Parents и Handlers. Так как для каждого типа события могут быть определены свои иерархии и обработчики, то ключом в каждом из этих словарей является тип события, а значением – информация об иерархиях и обработчиках для этого типа события.

WeakReferenceDictionary – это словарь, ключом для которого являются объекты WeakReference. Мы не можем использовать обычный Dictionary, т.к. ключами в нём могут быть только неизменяемые объекты. Объекты WeakReference изменяются (когда ссылаемый объект удаляется сборщиком мусора), поэтому пришлось реализовать простенький класс словаря с объектами WeakReference в качестве ключей.

WeakReferenceMap используемый в Parents это тот же самый WeakReferenceDictionary, в котором значениями также являются объекты WeakReference. Он имеет несколько дополнительных методов для очистки от «мёртвых» объектов.

Parents заполняется в методах RegisterLowerLayer() и RegisterLowerLayerForEvents() и для каждого уровня хранит его верхний уровень. RegisterLowerLayerForEvents() – это просто удобный способ задать одинаковую иерархию для нескольких типов событий. При вызовах этих методов в аргументе lowerLayer можно передать конкретный объект, а можно тип объекта, тогда события от всех объектов этого класса будут передаваться наверх к объекту currentLayer.

Handlers хранит информацию об обработчике события конкретного типа для конкретного объекта.

Тип события определяется типом передаваемого EventArgs, причём все аргументы событий наследуют от следующего класса MultilayerEventArgs, который обеспечивает возможность прервать обработку события:

public class MultilayerEventArgs : EventArgs
{
	public bool Handled { get; set; }
}

Наибольший интерес представляет метод TriggerEvent:

public static void TriggerEvent<TEventArgs>(object sender, TEventArgs e) where TEventArgs : MultilayerEventArgs
{
	var handlers = new List<Tuple<object, LayerEventHandler>>();

	lock (Parents)
	lock (Handlers)
	{
		CollectDeadReferences();

		WeakReferenceDictionary<LayerEventHandler> eventHandlers;
		if (Handlers.TryGetValue(e.GetType(), out eventHandlers))
		{
			WeakReferenceMap eventParents;
			Parents.TryGetValue(e.GetType(), out eventParents);

			object target = sender;
			while (target != null)
			{
				LayerEventHandler handler;
				if (eventHandlers.TryGetValue(target, out handler) && handler != null)
				{
					handlers.Add(new Tuple<object, LayerEventHandler>(target, handler));
				}

				if (eventParents == null)
				{
					break;
				}
				else
				{
					var targetType = target.GetType();
					if (!(eventParents.TryGetValue(target, out target) || eventParents.TryGetValue(targetType, out target)))
					{
						break;
					}
				}
			}
		}
	}

	foreach (var handler in handlers)
	{
		handler.Item2(handler.Item1, sender, e);
		if (e.Handled)
		{
			break;
		}
	}
}

Сначала мы строим полную очередь обработчиков, которые должны быть вызваны, и только потом последовательно их вызываем, не забывая проверить флаг Handled. Обработчики не должны вызываться под lock-ом для Parents или Handlers, т.к. некоторые из них могут вызывать другие события в других потоках, что приведёт к deadlock-у.

Теперь посмотрим, как клиенты подписываются и инициируют события:

public Watcher()
{
	// ...
	MultilayerEventManager.RegisterLowerLayer(this, notificationsManager, typeof(ContentChangedEventArgs));
	MultilayerEventManager.RegisterHandler<Watcher, ContentChangedEventArgs>(this, (t, s, e) => t.OnContentChanged(s, e));
	// ...
}

public NotificationsManager()
{
	// ...
	MultilayerEventManager.RegisterLowerLayer(this, typeof(Notification), typeof(ContentChangedEventArgs));
}

public Notification()
{
	// ...
	MultilayerEventManager.RegisterLowerLayer(this, typeof(NotificationWindow), typeof(ContentChangedEventArgs));
}

Вызов события из NotificationWindow очень прост:

MultilayerEventManager.TriggerEvent(this, new ContentChangedEventArgs(/* some data */));

Подробнее стоит остановиться на обработчиках событий. Когда нам нужно сохранить ссылку на обработчик события в словаре Handlers, перед нами встаёт дилемма. Какую ссылку на делегат сохранять, сильную или слабую? Делегат обработчика сам имеет сильную ссылку на ассоциированный объект, что часто и вызывает утечки памяти в .Net приложениях, если не отписываться от событий. Если мы сами сохраним сильную ссылку на делегат, то получим такую же утечку памяти, как и с обычными событиями .NET.

image

Мы можем сохранить слабую ссылку на делегат. Но так как на этот объект делегата не будет никаких сильных ссылок, он будет удален при следующей сборке мусора и обработчик станет недоступен.

image

Чтобы решить эту проблему, достаточно вспомнить, что нам нет необходимости привязывать делегат обработчика к объекту уровня, т.к. этот объект уже хранится в качестве ключа в словаре Handlers. Причём Handlers хранит именно слабую ссылку. Поэтому пока объект жив, мы имеем для него действительный обработчик. Когда объект удаляется, все его обработчики будут автоматически удалены. Таким образом, в Handlers хранятся обработчики следующего типа:

internal delegate void LayerEventHandler(object target, object sender, MultilayerEventArgs e);

Подписка на события осуществляется с помощью инструкции вида:

MultilayerEventManager.RegisterHandler<Watcher, ContentChangedEventArgs>(this, (t, s, e) => t.OnContentChanged(s, e));

С течением времени, в словарях Parents и Handlers накапливаются мёртвые ссылки. Для их автоматического удаления есть метод CollectDeadReferences(), который вызывается из всех методов по подписке/отписке и на каждый десятый вызов, пробегается по словарям и удаляет мёртвые ссылки.

Конечно, описанный класс MultilayerEventManager не заменит обычный механизм событий .NET. Его следует использовать, только если система связей между классами становится достаточно глубокой. В этом случае, как было показано выше, обычная модель событий оказывается неудобной, а описанный класс обеспечивает элегантный механизм для подписки на события и их обработки.

Ниже приводится полный исходный код для класса MultilayerEventManager и вспомогательных классов:

Исходный код MultilayerEventManager

class MultilayerEventManager

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;

namespace MultilayerEventManager
{
	/// <summary>
	/// Class for convenient subscribing and processing of events in multilayer applications
	/// </summary>
	public static class MultilayerEventManager
	{
		internal delegate void LayerEventHandler(object target, object sender, MultilayerEventArgs e);

		internal const int CallsBetweenDeadReferencesRemoving = 10;

		internal static readonly Dictionary<Type, WeakReferenceMap> Parents = new Dictionary<Type, WeakReferenceMap>();
		internal static readonly Dictionary<Type, WeakReferenceDictionary<LayerEventHandler>> Handlers = new Dictionary<Type, WeakReferenceDictionary<LayerEventHandler>>();
		internal static int CallsAfterLastDeadReferencesRemoving;

		/// <summary>
		/// Registers the relationship between upper and lower layer
		/// </summary>
		public static void RegisterLowerLayer<TEventArgs>(object currentLayer, object lowerLayer)
		{
			lock (Parents)
			{
				CollectDeadReferences();
				Parents.ProvideValue(typeof(TEventArgs))[lowerLayer] = new WeakReference(currentLayer);
			}
		}

		/// <summary>
		/// Serves for batch relationshiop registration between two layers for set of events
		/// </summary>
		public static void RegisterLowerLayerForEvents(object currentLayer, object lowerLayer, params Type[] eventTypes)
		{
			lock (Parents)
			{
				CollectDeadReferences();
				foreach (var eventType in eventTypes)
				{
					Parents.ProvideValue(eventType)[lowerLayer] = new WeakReference(currentLayer);
				}
			}
		}

		/// <summary>
		/// Registers the handler for the specific event
		/// </summary>
		public static void RegisterHandler<TTarget, TEventArgs>(TTarget target, Action<TTarget, object, TEventArgs> handler) where TEventArgs : MultilayerEventArgs
		{
			lock (Handlers)
			{
				CollectDeadReferences();
				Handlers.ProvideValue(typeof(TEventArgs))[target] = (t, s, e) => handler((TTarget)t, s, (TEventArgs)e);
			}
		}

		/// <summary>
		/// Unregisters the handler for the specific event
		/// </summary>
		public static void UnregisterHandler<TEventArgs>(object target)
		{
			lock (Handlers)
			{
				CollectDeadReferences();

				WeakReferenceDictionary<LayerEventHandler> eventHandlers;
				if (!Handlers.TryGetValue(typeof(TEventArgs), out eventHandlers))
				{
					return;
				}

				eventHandlers.Remove(target);
				if (eventHandlers.Count == 0)
				{
					Handlers.Remove(typeof(TEventArgs));
				}
			}
		}

		/// <summary>
		/// Unregisters all relationships and handlers for specific object
		/// </summary>
		public static void UnregisterInstance(object target)
		{
			lock (Parents)
			lock (Handlers)
			{
				CollectDeadReferences();
				
				foreach (var typeData in Handlers.ToList())
				{
					typeData.Value.Remove(target);
				}

				foreach (var typeData in Parents.ToList())
				{
					typeData.Value.RemoveInstance(target);
				}

				TrimDictionaries();
			}
		}

		/// <summary>
		/// Launches the chain of handlers calls for specific event, from lower to upper layer
		/// </summary>
		public static void TriggerEvent<TEventArgs>(object sender, TEventArgs e) where TEventArgs : MultilayerEventArgs
		{
			//	Handlers should not be called under lock
			//	Otherwise deadlock could happen because some handlers could switch to UI thread and trigger other events
			//	That's why handlers sequence is built under lock but handlers are called outside the lock
			var handlers = new List<Tuple<object, LayerEventHandler>>();

			lock (Parents)
			lock (Handlers)
			{
				CollectDeadReferences();

				WeakReferenceDictionary<LayerEventHandler> eventHandlers;
				if (Handlers.TryGetValue(e.GetType(), out eventHandlers))
				{
					WeakReferenceMap eventParents;
					Parents.TryGetValue(e.GetType(), out eventParents);

					object target = sender;
					while (target != null)
					{
						LayerEventHandler handler;
						if (eventHandlers.TryGetValue(target, out handler) && handler != null)
						{
							handlers.Add(new Tuple<object, LayerEventHandler>(target, handler));
						}

						if (eventParents == null)
						{
							break;
						}
						else
						{
							var targetType = target.GetType();
							if (!(eventParents.TryGetValue(target, out target) || eventParents.TryGetValue(targetType, out target)))
							{
								break;
							}
						}
					}
				}
			}

			foreach (var handler in handlers)
			{
				handler.Item2(handler.Item1, sender, e);
				if (e.Handled)
				{
					break;
				}
			}
		}

		/// <summary>
		/// Clears all relationship and handlers information
		/// </summary>
		public static void Clear()
		{
			lock (Parents)
			lock (Handlers)
			{
				Parents.Clear();
				Handlers.Clear();
			}
		}

		private static void CollectDeadReferences()
		{
			if (Interlocked.Increment(ref CallsAfterLastDeadReferencesRemoving) >= CallsBetweenDeadReferencesRemoving)
			{
				CallsAfterLastDeadReferencesRemoving = 0;
				RemoveDeadReferences();
			}
		}

		internal static void RemoveDeadReferences()
		{
			lock (Parents)
			lock (Handlers)
			{
				foreach (var typeData in Parents.ToList())
				{
					typeData.Value.RemoveDeadReferences();
				}

				foreach (var typeData in Handlers.ToList())
				{
					typeData.Value.RemoveDeadReferences();
				}

				TrimDictionaries();
			}
		}

		private static void TrimDictionaries()
		{
			foreach (var typeData in Parents.Where(dict => dict.Value.Count == 0).ToList())
			{
				Parents.Remove(typeData.Key);
			}

			foreach (var typeData in Handlers.Where(dict => dict.Value.Count == 0).ToList())
			{
				Handlers.Remove(typeData.Key);
			}
		}
	}
}

class WeakReferenceDictionary

using System;
using System.Collections.Generic;

namespace MultilayerEventManager
{
	internal class WeakReferenceDictionary<TValue>
	{
		protected readonly List<Tuple<WeakReference, TValue>> Items = new List<Tuple<WeakReference, TValue>>();

		public TValue this[object key]
		{
			get
			{
				TValue result;
				if (!TryGetValue(key, out result))
				{
					throw new KeyNotFoundException();
				}

				return result;
			}

			set
			{
				var itemIndex = FindItem(key);
				if (itemIndex == -1)
				{
					Items.Add(new Tuple<WeakReference, TValue>(new WeakReference(key), value));
				}
				else
				{
					Items[itemIndex] = new Tuple<WeakReference, TValue>(Items[itemIndex].Item1, value);
				}
			}
		}

		public int Count => Items.Count;

		public bool TryGetValue(object key, out TValue value)
		{
			var itemIndex = FindItem(key);
			if (itemIndex == -1)
			{
				value = default(TValue);
				return false;
			}
			else
			{
				value = Items[itemIndex].Item2;
				return true;
			}
		}

		public bool Remove(object key)
		{
			return Items.RemoveAll(it => EqualItems(it, key) || !it.Item1.IsAlive) > 0;
		}

		public virtual void RemoveDeadReferences()
		{
			Items.RemoveAll(it => !it.Item1.IsAlive);
		}

		private int FindItem(object obj)
		{
			return Items.FindIndex(it => EqualItems(it, obj));
		}

		private bool EqualItems(Tuple<WeakReference, TValue> item, object obj)
		{
			return ReferenceEquals(item.Item1.Target, obj);
		}
	}
}

class WeakReferenceMap

using System;

namespace MultilayerEventManager
{
	internal class WeakReferenceMap : WeakReferenceDictionary<WeakReference>
	{
		public bool TryGetValue(object key, out object value)
		{
			WeakReference objRef;
			if (TryGetValue(key, out objRef))
			{
				value = objRef.Target;
				return true;
			}
			else
			{
				value = null;
				return false;
			}
		}

		public void RemoveInstance(object key)
		{
			Items.RemoveAll(it => ReferenceEquals(it.Item1.Target, key) || ReferenceEquals(it.Item2.Target, key));
			RemoveDeadReferences();
		}

		public override void RemoveDeadReferences()
		{
			Items.RemoveAll(it => !it.Item1.IsAlive || !it.Item2.IsAlive);
		}
	}
}

class MultilayerEventArgs

using System;

namespace MultilayerEventManager
{
	/// <summary>
	/// Base class for passing event data when MultilayerEventManager is used
	/// </summary>
	public class MultilayerEventArgs : EventArgs
	{
		/// <summary>
		/// Indicates whether the event was marked as processed by some layer and should be skipped by all upper layers
		/// </summary>
		public bool Handled { get; set; }
	}
}

class CollectionExtensions

using System.Collections.Generic;

namespace MultilayerEventManager
{
	/// <summary>
	/// Holder for collection extension methods
	/// </summary>
	public static class CollectionExtensions
	{
		/// <summary>
		/// Returns value for specific key in the dictionary if it exists
		/// Otherwise adds and returns default value of the type specified
		/// </summary>
		public static TValue ProvideValue<TKey, TValue>(this Dictionary<TKey, TValue> dict, TKey key) where TValue : new()
		{
			lock (dict)
			{
				TValue value;
				if (!dict.TryGetValue(key, out value))
				{
					value = new TValue();
					dict.Add(key, value);
				}
				return value;
			}
		}
	}
}

Автор: CodeFuller

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js