Cómo lidiar elegantemente con zonas horarias

140

Tengo un sitio web alojado en una zona horaria diferente a la de los usuarios que usan la aplicación. Además de esto, los usuarios pueden tener una zona horaria específica. Me preguntaba cómo abordan esto otros usuarios y aplicaciones de SO. La parte más obvia es que dentro de la base de datos, la fecha / hora se almacena en UTC. Cuando está en el servidor, todas las fechas / horas deben tratarse en UTC. Sin embargo, veo tres problemas que estoy tratando de superar:

  1. Obtener la hora actual en UTC (resuelto fácilmente con DateTime.UtcNow).

  2. Extraer fechas / horas de la base de datos y mostrarlas al usuario. Potencialmente, hay muchas llamadas para imprimir fechas en diferentes vistas. Estaba pensando en alguna capa entre la vista y los controladores que podrían resolver este problema. O tener un método de extensión personalizado DateTime(ver más abajo). El principal inconveniente es que en cada ubicación de uso de una fecha y hora en una vista, ¡se debe llamar al método de extensión!

    Esto también agregaría dificultad para usar algo como el JsonResult. Ya no podría llamar fácilmente Json(myEnumerable), tendría que ser así Json(myEnumerable.Select(transformAllDates)). ¿Quizás AutoMapper podría ayudar en esta situación?

  3. Obteniendo información del usuario (Local a UTC). Por ejemplo, PUBLICAR un formulario con una fecha requeriría convertir la fecha a UTC anterior. Lo primero que viene a la mente es crear una costumbre ModelBinder.

Aquí están las extensiones que pensé usar en las vistas:

public static class DateTimeExtensions
{
    public static DateTime UtcToLocal(this DateTime source, 
        TimeZoneInfo localTimeZone)
    {
        return TimeZoneInfo.ConvertTimeFromUtc(source, localTimeZone);
    }

    public static DateTime LocalToUtc(this DateTime source, 
        TimeZoneInfo localTimeZone)
    {
        source = DateTime.SpecifyKind(source, DateTimeKind.Unspecified);
        return TimeZoneInfo.ConvertTimeToUtc(source, localTimeZone);
    }
}

Creo que tratar con zonas horarias sería algo tan común considerando que muchas aplicaciones ahora están basadas en la nube, donde la hora local del servidor podría ser muy diferente a la zona horaria esperada.

¿Se ha resuelto con elegancia esto antes? ¿Hay algo que me falta? Ideas y pensamientos son muy apreciados.

EDITAR: Para aclarar la confusión, pensé agregar algunos detalles más. El problema en este momento no es cómo almacenar las horas UTC en la base de datos, sino más bien el proceso de pasar de UTC-> Local y Local-> UTC. Como señala @Max Zerbini, obviamente es inteligente poner el código UTC-> Local en la vista, pero ¿está usando DateTimeExtensionsrealmente la respuesta? Al recibir información del usuario, ¿tiene sentido aceptar fechas como la hora local del usuario (ya que eso es lo que JS estaría usando) y luego usar a ModelBinderpara transformar a UTC? La zona horaria del usuario se almacena en la base de datos y se recupera fácilmente.

TheCloudlessSky
fuente
3
Es posible que primero tener una lectura a través de este excelente post ... El horario de verano y las mejores prácticas de zona horaria
dodgy_coder
@dodgy_coder: siempre ha sido un excelente enlace de recursos para zonas horarias. Sin embargo, realmente no resuelve ninguno de mis problemas (específicamente relacionado con MVC). Gracias sin embargo.
TheCloudlessSky
code.google.com/p/noda-time podría ser útil
Arnis Lapsa
Curioso en qué solución ha optado. Frente a una decisión similar yo mismo. Buena pregunta.
Sean
@Sean: la solución hasta ahora no ha sido elegante (por eso todavía no he aceptado una respuesta). Se han realizado muchos gastos generales manuales para imprimir fechas / horas y convertirlas manualmente de nuevo con a ModelBinder.
TheCloudlessSky

Respuestas:

106

No es que esto sea una recomendación, es más compartir un paradigma, pero la forma más agresiva que he visto de manejar la información de zona horaria en una aplicación web (que no es exclusiva de ASP.NET MVC) fue la siguiente:

  • Todas las horas en el servidor son UTC. Eso significa usar, como dijiste,DateTime.UtcNow .

  • Intente confiar en que el cliente pase las fechas al servidor lo menos posible. Por ejemplo, si necesita "ahora", no cree una fecha en el cliente y luego páselo al servidor. Cree una fecha en su GET y páselo al ViewModel o en POST doDateTime.UtcNow .

Hasta ahora, tarifa bastante estándar, pero aquí es donde las cosas se ponen "interesantes".

  • Si tiene que aceptar una fecha del cliente, use javascript para asegurarse de que los datos que está publicando en el servidor estén en UTC. El cliente sabe en qué zona horaria se encuentra, por lo que puede convertir con precisión razonable los tiempos en UTC.

  • Al representar vistas, usaban el <time>elemento HTML5 , nunca representaban las fechas y horas directamente en ViewModel. Fue implementado como una HtmlHelperextensión, algo así Html.Time(Model.when). Rendiría<time datetime='[utctime]' data-date-format='[datetimeformat]'></time> .

    Luego usarían javascript para traducir la hora UTC a la hora local del cliente. El script buscaría todos los <time>elementos y usaría la date-formatpropiedad de datos para formatear la fecha y llenar el contenido del elemento.

De esta manera, nunca tuvieron que hacer un seguimiento, almacenar o administrar la zona horaria de un cliente. Al servidor no le importaba en qué zona horaria estaba el cliente, ni tenía que hacer ninguna traducción de zona horaria. Simplemente escupió UTC y dejó que el cliente lo convirtiera en algo que fuera razonable. Lo cual es fácil desde el navegador, porque sabe en qué zona horaria se encuentra. Si el cliente cambia su zona horaria, la aplicación web se actualizará automáticamente. Lo único que almacenaron fue la cadena de formato de fecha y hora para la configuración regional del usuario.

No digo que haya sido el mejor enfoque, pero era diferente y no lo había visto antes. Tal vez obtendrá algunas ideas interesantes de él.

J. Holmes
fuente
Gracias por su respuesta. Al obtener la entrada de fecha del usuario (por ejemplo, programar una cita), ¿no se supone que esta entrada está en su zona horaria actual? ¿Qué opinas sobre la ModelBinder hacer esta transformación antes de entrar en la acción (ver mis comentarios a @casperOne. Además, la <time>idea es muy bueno. La única desventaja es que significa que necesito para consultar todo el DOM para estos elementos, los cuales no es exactamente agradable solo para transformar fechas (¿qué pasa con JS deshabilitado?). Gracias de nuevo!
TheCloudlessSky
Por lo general, sí, se supone que estaría en una zona horaria local. Pero hicieron un punto para asegurarse de que cada vez que recopilaban un tiempo de un usuario, se transformaba en UTC usando javascript antes de ser enviado al servidor. Su solución era muy pesada en JS, y no se degradaba muy bien cuando JS estaba apagado. No lo he pensado lo suficiente como para ver si hay algo inteligente al respecto. Pero como dije, tal vez te dé algunas ideas.
J. Holmes
2
Desde entonces he trabajado en otro proyecto que necesitaba fechas locales y este fue el método que utilicé. Estoy usando moment.js para formatear la fecha / hora ... es genial . ¡Gracias!
TheCloudlessSky
¿No hay una manera simple de definir en la aplicación.config / web.cofig de la aplicación una zona horaria de aplicación y hacer que convierta todos los valores de DateTime.Now en DateTime.UtcNow? (Quiero evitar situaciones en las que los programadores de la compañía todavía usen DateTime. Ahora por error)
Uri Abramson
3
Una desventaja de este enfoque que puedo ver es que cuando usa el tiempo para otras cosas, crea archivos PDF, correos electrónicos y demás, no hay un elemento de tiempo para esos, por lo que aún tendrá que convertirlos manualmente. De lo contrario, una solución muy ordenada
shenku
15

Después de varios comentarios, esta es mi solución final, que creo que es limpia y simple y cubre los problemas de horario de verano.

1 - Manejamos la conversión a nivel de modelo. Entonces, en la clase Modelo, escribimos:

    public class Quote
    {
        ...
        public DateTime DateCreated
        {
            get { return CRM.Global.ToLocalTime(_DateCreated); }
            set { _DateCreated = value.ToUniversalTime(); }
        }
        private DateTime _DateCreated { get; set; }
        ...
    }

2 - En un ayudante global hacemos nuestra función personalizada "ToLocalTime":

    public static DateTime ToLocalTime(DateTime utcDate)
    {
        var localTimeZoneId = "China Standard Time";
        var localTimeZone = TimeZoneInfo.FindSystemTimeZoneById(localTimeZoneId);
        var localTime = TimeZoneInfo.ConvertTimeFromUtc(utcDate, localTimeZone);
        return localTime;
    }

3 - Podemos mejorar esto aún más, guardando la identificación de la zona horaria en cada perfil de usuario para poder recuperarla de la clase de usuario en lugar de usar la "hora estándar de China" constante:

public class Contact
{
    ...
    public string TimeZone { get; set; }
    ...
}

4 - Aquí podemos obtener la lista de zonas horarias para mostrar al usuario para seleccionar desde un cuadro desplegable:

public class ListHelper
{
    public IEnumerable<SelectListItem> GetTimeZoneList()
    {
        var list = from tz in TimeZoneInfo.GetSystemTimeZones()
                   select new SelectListItem { Value = tz.Id, Text = tz.DisplayName };

        return list;
    }
}

Entonces, ahora a las 9:25 AM en China, sitio web alojado en EE. UU., Fecha guardada en UTC en la base de datos, aquí está el resultado final:

5/9/2013 6:25:58 PM (Server - in USA) 
5/10/2013 1:25:58 AM (Database - Converted UTC)
5/10/2013 9:25:58 AM (Local - in China)

EDITAR

Gracias a Matt Johnson por señalar las partes débiles de la solución original, y perdón por eliminar la publicación original, pero tuve problemas para obtener el formato de visualización de código correcto ... resultó que el editor tiene problemas para mezclar "viñetas" con "pre código", así que eliminado los bulles y estuvo bien.

Néstor
fuente
Parece ser viable si alguien no tiene una arquitectura de n niveles o si las bibliotecas web están compartidas. Cómo podemos compartir la identificación de la zona horaria con la capa de datos.
Vikash Kumar
9

En la sección de eventos en sf4answers , los usuarios ingresan una dirección para un evento, así como una fecha de inicio y una fecha de finalización opcional. Estos tiempos se traducen en undatetimeoffset servidor SQL que representa el desplazamiento desde UTC.

Este es el mismo problema al que se enfrenta (aunque está adoptando un enfoque diferente, ya que está utilizando DateTime.UtcNow ); tiene una ubicación y necesita traducir una hora de una zona horaria a otra.

Hay dos cosas principales que hice que funcionaron para mí. Primero, usa la DateTimeOffsetestructura , siempre. Representa la compensación de UTC y si puede obtener esa información de su cliente, le facilitará un poco la vida.

En segundo lugar, al realizar las traducciones, suponiendo que conozca la ubicación / zona horaria en la que se encuentra el cliente, puede usar la base de datos de zona horaria de información pública para traducir una hora de UTC a otra zona horaria (o triangular, si lo desea, entre dos zonas horarias). Lo mejor de la base de datos tz (a veces denominada base de datos Olson ) es que representa los cambios en las zonas horarias a lo largo de la historia; obtener una compensación es una función de la fecha en la que desea obtener la compensación (solo mire la Ley de Política Energética de 2005 que cambió las fechas cuando el horario de verano entra en vigencia en los EE. UU. . .).

Con la base de datos en la mano, puede usar la API .NET de ZoneInfo (base de datos tz / base de datos Olson) . Tenga en cuenta que no hay una distribución binaria, deberá descargar la última versión y compilarla usted mismo.

Al momento de escribir esto, actualmente analiza todos los archivos en la última distribución de datos (en realidad lo ejecuté contra el archivo ftp://elsie.nci.nih.gov/pub/tzdata2011k.tar.gz el 25 de septiembre, 2011; en marzo de 2017, lo obtendría a través de https://iana.org/time-zones o de ftp://fpt.iana.org/tz/releases/tzdata2017a.tar.gz ).

Entonces, en sf4answers, después de obtener la dirección, se geocodifica en una combinación de latitud / longitud y luego se envía a un servicio web de terceros para obtener una zona horaria que corresponde a una entrada en la base de datos tz. A partir de ahí, los tiempos de inicio y finalización se convierten enDateTimeOffset instancias con el desplazamiento UTC adecuado y luego se almacenan en la base de datos.

En cuanto a tratarlo en SO y sitios web, depende de la audiencia y de lo que intente mostrar. Si observa, la mayoría de los sitios web sociales (y SO, y la sección de eventos en sf4answers) muestran eventos en relación tiempo o, si se usa un valor absoluto, generalmente es UTC.

Sin embargo, si su audiencia espera horas locales, entonces usar DateTimeOffsetjunto con un método de extensión que tome la zona horaria para convertir estaría bien; el tipo de datos SQL datetimeoffsetse traduciría a .NET, DateTimeOffsetque luego puede obtener el tiempo universal para usar el GetUniversalTimemétodo . A partir de ahí, simplemente use los métodos en la ZoneInfoclase para convertir de UTC a hora local (tendrá que hacer un poco de trabajo para convertirlo en unDateTimeOffset , pero es lo suficientemente simple).

¿Dónde hacer la transformación? Es un costo que tendrá que pagar en alguna parte , y no hay una "mejor" forma. Sin embargo, optaría por la vista, con el desplazamiento de la zona horaria como parte del modelo de vista presentado a la vista. De esa manera, si los requisitos para la vista cambian, no tiene que cambiar su modelo de vista para acomodar el cambio. Su JsonResultsimplemente contener un modelo con el y el desplazamiento.IEnumerable<T>

¿En el lado de entrada, usando un modelo de carpeta? Yo diría absolutamente de ninguna manera. No puede garantizar que todas las fechas (ahora o en el futuro) tengan que ser transformadas de esta manera, debe ser una función explícita de su controlador realizar esta acción. Nuevamente, si los requisitos cambian, no tiene que ajustar una o varias ModelBinderinstancias para ajustar su lógica de negocios; y es lógica de negocios, lo que significa que debería estar en el controlador.

casperOne
fuente
Gracias por tu respuesta detallada. Espero no parecer grosero, pero esto realmente no respondió a ninguna de mis preocupaciones con ASP.NET MVC: ¿Qué pasa con la entrada del usuario (sería suficiente con un modelo de carpeta personalizada)? Y lo más importante, ¿qué pasa con mostrar estas fechas (en la zona horaria local del usuario)? Siento que el método de extensión va a agregar mucho peso a mis puntos de vista que parece innecesario.
TheCloudlessSky
1
@TheCloudlessSky: Vea los últimos dos párrafos de mi respuesta editada. Personalmente, creo que los detalles de dónde hacer las transformaciones son menores; El problema principal es la conversión y el almacenamiento reales de los datos de fecha y hora (por cierto, no puedo enfatizar lo suficiente el uso de datetimeoffsetSQL Server y DateTimeOffset.NET aquí, realmente simplifican enormemente las cosas) que creo que .NET no maneja adecuadamente en absoluto por los motivos descritos anteriormente. Si tiene una fecha en Nueva York que se ingresó en 2003, y luego desea que se traduzca a una fecha en LA en 2011, .NET falla en este caso.
casperOne
El problema con no usar un modelo de carpeta (o cualquier capa antes de la acción) es que cada controlador se vuelve muy difícil de probar cuando se trabaja con fechas (todos obtendrían una dependencia de la conversión de fecha). Esto permitiría escribir las fechas de las pruebas unitarias en UTC. Mi aplicación tiene usuarios asociados con un perfil (piense en médicos / secretarios asociados con su práctica). El perfil contiene la información de la zona horaria. Por lo tanto, es bastante fácil obtener la zona horaria del perfil del usuario actual y transformarla durante el enlace. ¿Tienes otros argumentos en contra de esto? ¡Gracias por tu contribución!
TheCloudlessSky
El caso en contra es que sus pruebas no reflejarían con precisión los casos de prueba. Su entrada no va a estar en UTC, por lo que sus casos de prueba no deberían estar diseñados para usarla. Debería usar fechas del mundo real con ubicaciones y todo (aunque si usa el DateTimeOffsetmitigará esto, IMO).
casperOne
Sí, pero esto significa que cada prueba que trata con fechas tiene que dar cuenta de esto. Cada acción que trate con fechas y horas siempre se convertirá a UTC. Para mí, este es un candidato principal para el modelo de carpeta y luego probar el modelo de carpeta .
TheCloudlessSky
5

Esta es solo mi opinión, creo que la aplicación MVC debería separar bien el problema de presentación de datos de la gestión del modelo de datos. Una base de datos puede almacenar datos en la hora del servidor local, pero es un deber de la capa de presentación representar la fecha y hora utilizando la zona horaria del usuario local. Este me parece el mismo problema que I18N y el formato de número para diferentes países. En su caso, su aplicación debería detectar la Culturezona horaria y del usuario y cambiar la vista que muestra diferentes presentaciones de texto, número y fecha de hora, pero los datos almacenados pueden tener el mismo formato.

Massimo Zerbini
fuente
Gracias por su respuesta. Sí, esto es lo que básicamente describí, solo me pregunto cómo podría lograrse esto de manera elegante con MVC. La aplicación almacena la zona horaria del usuario como parte de la creación de su cuenta (eligen su zona horaria). El problema nuevamente viene con cómo representar las fechas en cada vista sin (con suerte) tener que ensuciarlas con los métodos que creé DateTimeExtensions.
TheCloudlessSky
Hay muchas maneras de hacer esto, una es usar métodos auxiliares como usted sugirió, otra quizás más compleja pero elegante es el uso de un filtro que procesa las solicitudes y respuestas y convierte la fecha y hora. Otra forma es desarrollar un atributo de visualización personalizado para anotar los campos de tipo DateTime de su modelo de vista.
Massimo Zerbini
Ese es el tipo de cosas que estaba buscando. Si miras mi OP, también mencioné el uso de AutoMapper para hacer un procesamiento antes de ir al resultado view / json pero después de que la acción se haya ejecutado.
TheCloudlessSky
1

Para la salida, cree una plantilla de visualización / editor como esta

@inherits System.Web.Mvc.WebViewPage<System.DateTime>
@Html.Label(Model.ToLocalTime().ToLongTimeString()))

Puede vincularlos según los atributos de su modelo si solo desea que ciertos modelos usen esas plantillas.

Consulte aquí y aquí para obtener más detalles sobre la creación de plantillas de editor personalizadas.

Alternativamente, dado que desea que funcione tanto para la entrada como para la salida, sugeriría extender un control o incluso crear el suyo propio. De esa manera puede interceptar tanto la entrada como las salidas y convertir el texto / valor según sea necesario.

Con suerte, este enlace lo empujará en la dirección correcta si desea seguir ese camino.

De cualquier manera, si quieres una solución elegante, será un poco de trabajo. En el lado positivo, una vez que lo haya hecho, puede guardarlo en su biblioteca de códigos para usarlo en el futuro.

dnatoli
fuente
Creo que las plantillas de visualización / editor personalizadas y la carpeta son la solución más elegante, ya que "simplemente funcionará" una vez implementado. Ningún desarrollador necesitará saber nada especial para que las fechas funcionen correctamente, solo use DisplayFory EditorForfuncionará todo el tiempo. +1
Kevin Stricker
17
¿No representaría esto el tiempo del servidor de aplicaciones y no el tiempo del navegador?
Alex
0

Probablemente sea un mazo para romper una tuerca, pero podría inyectar una capa entre la UI y las capas de negocios que convierte de forma transparente las fechas y horas a la hora local en los gráficos de objetos devueltos, y a UTC en los parámetros de fecha y hora de entrada.

Me imagino que esto podría lograrse usando PostSharp o alguna inversión del contenedor de control.

Personalmente, simplemente convertiría explícitamente tus fechas en la interfaz de usuario ...

geoffreys
fuente