Лучший способ использовать таймеры в .NET C#

Лучший способ использовать таймеры в .NET C#

31 марта 2023 г.

Как получить полный контроль над таймером и добиться стопроцентного покрытия с помощью модульных тестов

При использовании System.Timers.Timer в приложении .NET C# могут возникнуть проблемы с его абстрагированием и возможностью охвата ваши модули с модульными тестами.

В этой статье мы обсудим рекомендации о том, как решить эти проблемы, и к концу вы сможете достичь 100% охвата ваших модулей.


Photo by Lina Trochez on Unsplash

Подход

Вот как мы собираемся подойти к нашему решению:

  1. Придумайте очень простой пример для работы.

2. Начните с простого плохого решения.

3. Продолжайте улучшать его, пока мы не достигнем окончательного формата.

4. Обобщение уроков, извлеченных в ходе нашего путешествия.


Photo by James Harrison on Unsplash

Пример

В нашем примере мы будем создавать простое консольное приложение, которое будет делать только одну простую вещь: использовать System.Timers.Timer писать в консоль дату и время каждую секунду.

В итоге у вас должно получиться вот это:

Image by Ahmed Tarek

Как видите, требования просты, ничего вычурного.


Photo by Mikael Seegen on Unsplash, adjusted by Ahmed Tarek

Отказ от ответственности

  1. Некоторые рекомендации будут проигнорированы/отброшены, чтобы сосредоточить основное внимание на других рекомендациях, рассматриваемых в этой статье.

2. В этой статье мы сосредоточимся на рассмотрении модуля, использующего System.Timers.Timer с модульными тестами. Однако остальная часть решения не будет покрыта модульными тестами. Если вы хотите узнать об этом больше, вы можете прочитать статью Как полностью покрыть консольное приложение .NET C# модульными тестами.

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


Photo by Maria Teneva on Unsplash, adjusted by Ahmed Tarek

Плохое решение

В этом решении мы будем использовать System.Timers.Timer напрямую, не создавая уровень абстракции.

Структура решения должна выглядеть следующим образом:

Image by Ahmed Tarek

Это решение UsingTimer с одним проектом Console TimerApp.

Я намеренно потратил некоторое время и усилия на абстрагирование System.Console в IConsole, чтобы доказать, что это не решит нашу проблему с таймером.

namespace TimerApp.Abstractions
{
    public interface IConsole
    {
        void WriteLine(object? value);
    }
}

В нашем примере нам нужно будет использовать только System.Console.WriteLine; вот почему это единственный абстрактный метод.

namespace TimerApp.Abstractions
{
    public interface IPublisher
    {
        void StartPublishing();
        void StopPublishing();
    }
}

У нас есть только два метода в интерфейсе IPublisher: StartPublishing и StopPublishing.

Теперь о реализации:

using TimerApp.Abstractions;

namespace TimerApp.Implementations
{
    public class Console : IConsole
    {
        public void WriteLine(object? value)
        {
            System.Console.WriteLine(value);
        }
    }
}

Console — это всего лишь тонкая оболочка для System.Console.

using System.Timers;
using TimerApp.Abstractions;

namespace TimerApp.Implementations
{
    public class Publisher : IPublisher
    {
        private readonly Timer m_Timer;
        private readonly IConsole m_Console;

        public Publisher(IConsole console)
        {
            m_Timer = new Timer();
            m_Timer.Enabled = true;
            m_Timer.Interval = 1000;
            m_Timer.Elapsed += Handler;

            m_Console = console;
        }

        public void StartPublishing()
        {
            m_Timer.Start();
        }

        public void StopPublishing()
        {
            m_Timer.Stop();
        }

        private void Handler(object sender, ElapsedEventArgs args)
        {
            m_Console.WriteLine(args.SignalTime);
        }
    }
}

Publisher — это простая реализация IPublisher. Он использует System.Timers.Timer и просто настраивает его.

Он имеет IConsole, определенный как зависимость. Это не лучшая практика с моей точки зрения. Если вы хотите понять, что я имею в виду, вы можете проверить статью Когда не следует использовать DI, IoC и контейнеры IoC в .NET C#.

Однако только для простоты мы просто внедрили бы его как зависимость в конструкторе.

Мы также устанавливаем интервал таймера на 1000 миллисекунд (1 секунду) и настраиваем обработчик для записи таймера SignalTime в консоль.

using TimerApp.Abstractions;
using TimerApp.Implementations;

namespace TimerApp
{
    public class Program
    {
        static void Main(string[] args)
        {
            IPublisher publisher = new Publisher(new Implementations.Console());
            publisher.StartPublishing();
            System.Console.ReadLine();
            publisher.StopPublishing();
        }
    }
}

Здесь, в классе Program, мы мало что делаем. Мы просто создаем экземпляр класса Publisher и начинаем публикацию.

Запуск этого должен закончиться чем-то вроде этого:

Image by Ahmed Tarek

Теперь вопрос: если вы собираетесь написать модульный тест для класса Publisher, что вы можете сделать?

К сожалению, ответ будет таким: не слишком много.

Во-первых, вы не вводите сам таймер как зависимость. Это означает, что вы прячете зависимость внутри класса Publisher. Поэтому мы не можем имитировать или заглушать таймер.

Во-вторых, предположим, что мы изменили код таким образом, что Timer теперь внедряется в конструктор; Тем не менее, вопрос будет заключаться в том, как написать модульный тест и заменить Timer макетом или заглушкой?

Я слышу, как кто-то кричит: «Давайте завернем Таймер в абстракцию и внедрим его вместо Таймера».

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


Photo by Carson Masterson on Unsplash, adjusted by Ahmed Tarek

Хорошее решение

Настало время найти хорошее решение. Давайте посмотрим, что мы можем с этим сделать.

Структура решения должна выглядеть следующим образом:

Image by Ahmed Tarek

Это то же самое решение UsingTimer с новым проектом Console BetterTimerApp.

IConsole, IPublisher и Console будут одинаковыми.

ИТаймер

using System;

namespace BetterTimerApp.Abstractions
{
    public delegate void TimerIntervalElapsedEventHandler(object sender, DateTime dateTime);

    public interface ITimer : IDisposable
    {
        event TimerIntervalElapsedEventHandler TimerIntervalElapsed;

        bool Enabled { get; set; }
        double Interval { get; set; }

        void Start();
        void Stop();
    }
}

Что мы можем здесь заметить:

  1. Мы определили новый делегат TimerIntervalElapsedEventHandler. Этот делегат представляет событие, которое должно быть вызвано нашим ITimer.

2. Вы можете возразить, что нам не нужен этот новый делегат, поскольку у нас уже есть собственный ElapsedEventHandler, который уже используется System.Timers.Timer.

3. Да, это правда. Однако вы заметите, что событие ElapsedEventHandler предоставляет ElapsedEventArgs в качестве аргументов события. Этот ElapsedEventArgs имеет частный конструктор, и вы не сможете создать свой собственный экземпляр. Кроме того, свойство SignalTime, определенное в классе ElapsedEventArgs, доступно только для чтения. Поэтому вы не сможете переопределить его в дочернем классе.

4. Для Microsoft открыт запрос на изменение для обновления этого класса, но до момента написания этой статьи никакие изменения не применялись.

5. Также обратите внимание, что ITimer расширяет IDisposable.

Издатель

using System;
using BetterTimerApp.Abstractions;

namespace BetterTimerApp.Implementations
{
    public class Publisher : IPublisher
    {
        private readonly ITimer m_Timer;
        private readonly IConsole m_Console;

        public Publisher(ITimer timer, IConsole console)
        {
            m_Timer = timer;
            m_Timer.Enabled = true;
            m_Timer.Interval = 1000;
            m_Timer.TimerIntervalElapsed += Handler;

            m_Console = console;
        }

        public void StartPublishing()
        {
            m_Timer.Start();
        }

        public void StopPublishing()
        {
            m_Timer.Stop();
        }

        private void Handler(object sender, DateTime dateTime)
        {
            m_Console.WriteLine(dateTime);
        }
    }
}

Он почти такой же, как старый Publisher, за исключением небольших изменений. Теперь у нас есть ITimer, определенный как зависимость, которая внедряется через конструктор. Остальной код будет таким же.

Таймер

using System;
using System.Collections.Generic;
using System.Linq;
using System.Timers;
using BetterTimerApp.Abstractions;

namespace BetterTimerApp.Implementations
{
    public class Timer : ITimer
    {
        private Dictionary<TimerIntervalElapsedEventHandler, List<ElapsedEventHandler>> m_Handlers = new();
        private bool m_IsDisposed;
        private System.Timers.Timer m_Timer;

        public Timer()
        {
            m_Timer = new System.Timers.Timer();
        }

        public event TimerIntervalElapsedEventHandler TimerIntervalElapsed
        {
            add
            {
                var internalHandler =
                    (ElapsedEventHandler)((sender, args) => { value.Invoke(sender, args.SignalTime); });

                if (!m_Handlers.ContainsKey(value))
                {
                    m_Handlers.Add(value, new List<ElapsedEventHandler>());
                }

                m_Handlers[value].Add(internalHandler);

                m_Timer.Elapsed += internalHandler;
            }

            remove
            {
                m_Timer.Elapsed -= m_Handlers[value].Last();

                m_Handlers[value].RemoveAt(m_Handlers[value].Count - 1);

                if (!m_Handlers[value].Any())
                {
                    m_Handlers.Remove(value);
                }
            }
        }

        public bool Enabled
        {
            get => m_Timer.Enabled;
            set => m_Timer.Enabled = value;
        }

        public double Interval
        {
            get => m_Timer.Interval;
            set => m_Timer.Interval = value;
        }

        public void Start()
        {
            m_Timer.Start();
        }

        public void Stop()
        {
            m_Timer.Stop();
        }

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (m_IsDisposed) return;

            if (disposing && m_Handlers.Any())
            {
                foreach (var internalHandlers in m_Handlers.Values)
                {
                    if (internalHandlers?.Any() ?? false)
                    {
                        internalHandlers.ForEach(handler => m_Timer.Elapsed -= handler);
                    }
                }

                m_Timer.Dispose();
                m_Timer = null;
                m_Handlers.Clear();
                m_Handlers = null;
            }

            m_IsDisposed = true;
        }

        ~Timer()
        {
            Dispose(false);
        }
    }
}

Именно здесь происходит почти все волшебство.

Что мы можем здесь заметить:

  1. Внутри мы используем System.Timers.Timer.

    2. Мы применили шаблон проектирования IDisposable. Вот почему вы можете увидеть private bool m_IsDisposed, public void Dispose(), защищенный виртуальный void Dispose(bool dispose) и ~Таймер().

    3. В конструкторе мы инициализируем новый экземпляр System.Timers.Timer. В остальных шагах мы будем называть его внутренним таймером.

    4. Для public bool Enabled, public double Interval, public void Start() и public void Stop(), мы просто делегируем реализацию внутреннему таймеру.

    5. Для открытого события TimerIntervalElapsedEventHandler TimerIntervalElapsed это самая важная часть; так что давайте проанализируем это шаг за шагом.

    6. Что нам нужно сделать с этим событием, так это обработать, когда кто-то подпишется/отпишется на него извне. В данном случае мы хотим отразить это во внутреннем таймере.

    7. Другими словами, если у кого-то извне есть экземпляр нашего ITimer, он должен иметь возможность сделать что-то вроде этого t.TimerIntervalElapsed += (sender, dateTime) => { // сделать что-нибудь .

    8. На данный момент нам следует сделать что-то вроде m_Timer.Elapsed += (sender, elapsedEventArgs) => { // сделать что-нибудь .

    9. Однако нам нужно помнить, что два обработчика не одинаковы, поскольку они на самом деле разных типов; TimerIntervalElapsedEventHandler и ElapsedEventHandler.

    10. Поэтому нам нужно обернуть поступающий TimerIntervalElapsedEventHandler в новый внутренний ElapsedEventHandler. Это то, что мы можем сделать.

    11. Однако мы также должны помнить, что в какой-то момент кому-то может понадобиться отменить подписку на обработчик события TimerIntervalElapsedEventHandler.

    12. Это означает, что в данный момент нам нужно знать, какой обработчик ElapsedEventHandler соответствует этому обработчику TimerIntervalElapsedEventHandler, чтобы мы могли отменить его подписку на внутреннем таймере.

    13. Единственный способ добиться этого — отслеживать каждый обработчик TimerIntervalElapsedEventHandler и вновь созданный обработчик ElapsedEventHandler в словаре. Таким образом, зная переданный обработчик TimerIntervalElapsedEventHandler, мы можем узнать соответствующий обработчик ElapsedEventHandler.

    14. Однако мы также должны помнить, что кто-то извне может подписаться на один и тот же обработчик TimerIntervalElapsedEventHandler более одного раза.

    15. Да, это не логично, но все же выполнимо. Поэтому для полноты картины для каждого обработчика TimerIntervalElapsedEventHandler мы будем вести список обработчиков ElapsedEventHandler.

    16. В большинстве случаев в этом списке будет только одна запись, за исключением случаев дублирования подписки.

    17. Вот почему вы можете видеть этот private Dictionary<TimerIntervalElapsedEventHandler, List<ElapsedEventHandler>> m_Handlers = новый();.

public event TimerIntervalElapsedEventHandler TimerIntervalElapsed
{
    add
    {
        var internalHandler =
            (ElapsedEventHandler)((sender, args) => { value.Invoke(sender, args.SignalTime); });

        if (!m_Handlers.ContainsKey(value))
        {
            m_Handlers.Add(value, new List<ElapsedEventHandler>());
        }

        m_Handlers[value].Add(internalHandler);

        m_Timer.Elapsed += internalHandler;
    }

    remove
    {
        m_Timer.Elapsed -= m_Handlers[value].Last();

        m_Handlers[value].RemoveAt(m_Handlers[value].Count - 1);

        if (!m_Handlers[value].Any())
        {
            m_Handlers.Remove(value);
        }
    }
}

В add мы создаем новый ElapsedEventHandler, добавляя запись в m_Handlers словарь, сопоставляющий это с TimerIntervalElapsedEventHandler и, наконец, подписка на внутренний таймер.

В remove мы получаем соответствующий список обработчиков ElapsedEventHandler, выбираем последний обработчик, отписываем его от внутреннего таймера, удаляем из списка и удаляем весь запись, если список пуст.

Также стоит упомянуть реализацию Dispose.

protected virtual void Dispose(bool disposing)
{
    if (m_IsDisposed) return;

    if (disposing && m_Handlers.Any())
    {
        foreach (var internalHandlers in m_Handlers.Values)
        {
            if (internalHandlers?.Any() ?? false)
            {
                internalHandlers.ForEach(handler => m_Timer.Elapsed -= handler);
            }
        }

        m_Timer.Dispose();
        m_Timer = null;
        m_Handlers.Clear();
        m_Handlers = null;
    }

    m_IsDisposed = true;
}

Мы отписываем все оставшиеся обработчики от внутреннего таймера, избавляемся от внутреннего таймера и очищаем словарь m_Handlers.

Программа

using BetterTimerApp.Abstractions;
using BetterTimerApp.Implementations;

namespace BetterTimerApp
{
    public class Program
    {
        static void Main(string[] args)
        {
            var timer = new Timer();
            IPublisher publisher = new Publisher(timer, new Implementations.Console());
            publisher.StartPublishing();
            System.Console.ReadLine();
            publisher.StopPublishing();
            timer.Dispose();
        }
    }
}

Здесь мы еще мало что делаем. Это почти то же самое, что и старое решение.

Запуск этого должен закончиться чем-то вроде этого:

Image by Ahmed Tarek


Photo by Testalize.me on Unsplash, adjusted by Ahmed Tarek

Время испытаний, момент истины

Теперь у нас есть окончательный дизайн. Однако нам нужно посмотреть, действительно ли этот дизайн поможет нам покрыть наш модуль Publisher модульными тестами.

Структура решения должна выглядеть следующим образом:

Image by Ahmed Tarek

Я использую NUnit и Moq для тестирования. Вы наверняка сможете работать с предпочитаемыми библиотеками.

Заготовка таймера

using System;
using System.Collections.Generic;
using BetterTimerApp.Abstractions;

namespace BetterTimerApp.Tests.Stubs
{
    public enum Action
    {
        Start = 1,
        Stop = 2,
        Triggered = 3,
        Enabled = 4,
        Disabled = 5,
        IntervalSet = 6
    }

    public class ActionLog
    {
        public Action Action { get; }
        public string Message { get; }

        public ActionLog(Action action, string message)
        {
            Action = action;
            Message = message;
        }
    }

    public class TimerStub : ITimer
    {
        private bool m_Enabled;
        private double m_Interval;

        public event TimerIntervalElapsedEventHandler TimerIntervalElapsed;

        public Dictionary<int, ActionLog> Log = new();

        public bool Enabled
        {
            get => m_Enabled;
            set
            {
                m_Enabled = value;

                Log.Add(Log.Count + 1,
                    new ActionLog(value ? Action.Enabled : Action.Disabled, value ? "Enabled" : "Disabled"));
            }
        }

        public double Interval
        {
            get => m_Interval;
            set
            {
                m_Interval = value;
                Log.Add(Log.Count + 1, new ActionLog(Action.IntervalSet, m_Interval.ToString("G17")));
            }
        }

        public void Start()
        {
            Log.Add(Log.Count + 1, new ActionLog(Action.Start, "Started"));
        }

        public void Stop()
        {
            Log.Add(Log.Count + 1, new ActionLog(Action.Stop, "Stopped"));
        }

        public void TriggerTimerIntervalElapsed(DateTime dateTime)
        {
            OnTimerIntervalElapsed(dateTime);
            Log.Add(Log.Count + 1, new ActionLog(Action.Triggered, "Triggered"));
        }

        protected void OnTimerIntervalElapsed(DateTime dateTime)
        {
            TimerIntervalElapsed?.Invoke(this, dateTime);
        }

        public void Dispose()
        {
            Log.Clear();
            Log = null;
        }
    }
}

Что мы можем здесь заметить:

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

2. Кроме того, мы определили класс ActionLog, который будет использоваться для ведения журнала.

3. Мы определили класс TimerStub как заглушку класса ITimer. Мы будем использовать эту заглушку позже при тестировании модуля Publisher.

4. Реализация проста. Стоит отметить, что мы добавили дополнительный метод public void TriggerTimerIntervalElapsed(DateTime dateTime), чтобы мы могли запускать заглушку вручную в модульном тесте.

5. Мы также можем передать ожидаемое значение dateTime, чтобы у нас было известное значение для утверждения.

Тесты издателя

using System;
using BetterTimerApp.Abstractions;
using BetterTimerApp.Implementations;
using BetterTimerApp.Tests.Stubs;
using Moq;
using NUnit.Framework;
using Action = BetterTimerApp.Tests.Stubs.Action;

namespace BetterTimerApp.Tests.Tests
{
    [TestFixture]
    public class PublisherTests
    {
        private TimerStub m_TimerStub;
        private Mock<IConsole> m_ConsoleMock;
        private Publisher m_Sut;

        [SetUp]
        public void SetUp()
        {
            m_TimerStub = new TimerStub();
            m_ConsoleMock = new Mock<IConsole>();
            m_Sut = new Publisher(m_TimerStub, m_ConsoleMock.Object);
        }

        [TearDown]
        public void TearDown()
        {
            m_Sut = null;
            m_ConsoleMock = null;
            m_TimerStub = null;
        }

        [Test]
        public void ConstructorTest()
        {
            Assert.AreEqual(Action.Enabled, m_TimerStub.Log[1].Action);
            Assert.AreEqual(Action.Enabled.ToString(), m_TimerStub.Log[1].Message);
            Assert.AreEqual(Action.IntervalSet, m_TimerStub.Log[2].Action);
            Assert.AreEqual(1000.ToString("G17"), m_TimerStub.Log[2].Message);
        }

        [Test]
        public void StartPublishingTest()
        {
            // Arrange
            var expectedDateTime = DateTime.Now;

            m_ConsoleMock
                .Setup
                (
                    m => m.WriteLine
                    (
                        It.Is<DateTime>(p => p == expectedDateTime)
                    )
                )
                .Verifiable();


            // Act
            m_Sut.StartPublishing();
            m_TimerStub.TriggerTimerIntervalElapsed(expectedDateTime);


            // Assert
            ConstructorTest();

            m_ConsoleMock
                .Verify
                (
                    m => m.WriteLine(expectedDateTime)
                );

            Assert.AreEqual(Action.Start, m_TimerStub.Log[3].Action);
            Assert.AreEqual("Started", m_TimerStub.Log[3].Message);
            Assert.AreEqual(Action.Triggered, m_TimerStub.Log[4].Action);
            Assert.AreEqual(Action.Triggered.ToString(), m_TimerStub.Log[4].Message);
        }

        [Test]
        public void StopPublishingTest()
        {
            // Act
            m_Sut.StopPublishing();


            // Assert
            ConstructorTest();

            Assert.AreEqual(Action.Stop, m_TimerStub.Log[3].Action);
            Assert.AreEqual("Stopped", m_TimerStub.Log[3].Message);
        }

        [Test]
        public void FullProcessTest()
        {
            // Arrange
            var expectedDateTime1 = DateTime.Now;
            var expectedDateTime2 = expectedDateTime1 + TimeSpan.FromSeconds(1);
            var expectedDateTime3 = expectedDateTime2 + TimeSpan.FromSeconds(1);

            var sequence = new MockSequence();

            m_ConsoleMock
                .InSequence(sequence)
                .Setup
                (
                    m => m.WriteLine
                    (
                        It.Is<DateTime>(p => p == expectedDateTime1)
                    )
                )
                .Verifiable();

            m_ConsoleMock
                .InSequence(sequence)
                .Setup
                (
                    m => m.WriteLine
                    (
                        It.Is<DateTime>(p => p == expectedDateTime2)
                    )
                )
                .Verifiable();

            m_ConsoleMock
                .InSequence(sequence)
                .Setup
                (
                    m => m.WriteLine
                    (
                        It.Is<DateTime>(p => p == expectedDateTime3)
                    )
                )
                .Verifiable();


            // Act
            m_Sut.StartPublishing();
            m_TimerStub.TriggerTimerIntervalElapsed(expectedDateTime1);


            // Assert
            ConstructorTest();

            m_ConsoleMock
                .Verify
                (
                    m => m.WriteLine(expectedDateTime1)
                );

            Assert.AreEqual(Action.Start, m_TimerStub.Log[3].Action);
            Assert.AreEqual("Started", m_TimerStub.Log[3].Message);
            Assert.AreEqual(Action.Triggered, m_TimerStub.Log[4].Action);
            Assert.AreEqual(Action.Triggered.ToString(), m_TimerStub.Log[4].Message);


            // Act
            m_TimerStub.TriggerTimerIntervalElapsed(expectedDateTime2);


            // Assert
            m_ConsoleMock
                .Verify
                (
                    m => m.WriteLine(expectedDateTime2)
                );

            Assert.AreEqual(Action.Triggered, m_TimerStub.Log[5].Action);
            Assert.AreEqual(Action.Triggered.ToString(), m_TimerStub.Log[5].Message);


            // Act
            m_Sut.StopPublishing();


            // Assert
            Assert.AreEqual(Action.Stop, m_TimerStub.Log[6].Action);
            Assert.AreEqual("Stopped", m_TimerStub.Log[6].Message);
        }
    }
}

Теперь, как видите, у нас есть полный контроль, и мы можем легко покрыть наш модуль Publisher модульными тестами.

Если мы посчитаем охват, то должны получить вот это:

Image by Ahmed Tarek

Как видите, модуль Publisher реализован на 100 %. В остальном это выходит за рамки этой статьи, но вы можете просто осветить это, если будете следовать подходу, описанному в статье Как полностью покрыть консольное приложение .NET C# модульными тестами.


Photo by Jingda Chen on Unsplash, adjusted by Ahmed Tarek

Заключительные слова

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

Если вы хотите больше тренироваться, ознакомьтесь с другими моими статьями о некоторых передовых методах.

Вот и все, надеюсь, вам было так же интересно читать эту статью, как мне было ее писать.


Также опубликовано здесь


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE