65.9K
CodeProject 正在变化。 阅读更多。
Home

断开连接的客户端架构

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.76/5 (64投票s)

2007年2月14日

CPOL

22分钟阅读

viewsIcon

167639

downloadIcon

2692

本文将介绍我为客户应用程序实现的一种离线客户端架构。

引言

本文讨论了我最近添加到一个正在开发的商业产品中的断开连接的客户端架构。读者应该了解智能客户端离线应用模块。本文中介绍的架构与之有相似之处。由于我的产品已经拥有用于通信和事务管理的丰富架构,我选择了一个与该架构紧密结合的实现。您可以在以下文章中了解该架构的各个部分:

DataTable 事务记录器

DataTable 同步管理器

压缩加密网络流

作业队列

最简单的 Tcp 服务器

关于下载

本文的下载不是一个完整的演示应用程序。它更像是一个带有PC板和组件的电子套件,你需要提供烙铁、焊锡和劳动力来组装它。这里的主要目的是讨论架构而不是实现,因此下载内容包括你可能会觉得有用的组件,用于创建具有离线功能的客户端应用程序。

离线挑战

微软的离线应用模块 (OAB) 提出了一些关于离线挑战的问题,这些问题似乎是讨论我开发的架构的一个很好的切入点。

应用程序如何确定它是在线还是离线?

应用程序在以下三种情况下确定其处于离线状态:

  1. 尝试连接服务器时
  2. 发送或接收数据时抛出异常
  3. 在等待读取数据时抛出异常

尝试连接服务器时

Connect 方法演示了如何判断连接尝试失败的实现:

public override void Connect()
{
  // If we have offline transactions, reconnecting is going to have to be
  //  done in a completely different way.
  // If the connection hasn't been created...
  if (tcpClient == null)
  {
    tcpClient = new TcpClient(); // Create a TCP client.

    try
    {
      tcpClient.Connect(host, port); // Connect.
    }
    catch (Exception)
    {
      // Let API handle connection failure.
      RaiseConnectionFailed();
      tcpClient = null;
    }

    // Only continue if connection succeeded.
    if (tcpClient != null)
    {
      InitializeReader();
    }
  }
}

如果连接失败,ConnectionFailed 事件就会被触发。通常,事件处理程序会将客户端切换到断开连接状态。

protected void OnConnectionFailed(object sender, EventArgs e)
{
  // If disconnected operation is not allowed, throw an exception.
  if (!allowDisconnectedOperation)
  {
    throw new ConnectionException("Connection with the server failed.");
  }

  if (isConnected)
  {
    SetDisconnectedState();
    disconnectedServerComm.HandleConnectionFailure();
    connectedServerComm.StartReconnectThread();
  }
}

同时会启动一个尝试重新连接的线程。

protected void ReconnectThread()
{
  bool connected = false;

  while (!connected)
  {
    // try every second.
    Thread.Sleep(1000);

    try
    {
      tcpClient = new TcpClient(); // Create a TCP client.
      tcpClient.Connect(host, port); // Connect.
      connected = true;
      // Success!
      RaiseReconnectedToServer();
    }
    catch
    {
      // try again.
    }
  }
}

当客户端成功重新连接时,会触发 ConnectedToServer 事件。

线程问题

ConnectedToServer 事件在工作线程中触发。这是一个重要的问题,因为此事件将异步触发。事件处理程序及其调用的方法必须是线程安全的。我使用一个特定对象来阻塞通信和重新连接过程的执行,以确保从断开连接状态到连接状态的平稳过渡。

void OnReconnectedToServer(object sender, EventArgs e)
{
  // Block all command/responses until we're done here. Wait until a 
  // current command/response
  // is completed before entering here.
  lock (commLock)
  {
    ...

commLock 对象

protected object commLock = new Object();

public object CommLock
{
  get { return commLock; }
}

在与服务器的所有通信中都会使用。发送命令到服务器并接收响应只有一个方法入口点(顺便说一下,“命令”是一个同步过程——必须接收到响应才能继续处理)。

public static class IssueCommand<T> where T : IResponse, new()
{
  /// <summary>
  /// Issue the command and receive the response.
  /// </summary>
  /// <param name="api">The api is required in case the server goes down 
  /// and the ServerComm instance switches to the disconnected server comm 
  /// instance.</param>
  /// <param name="cmd">The command to issue.</param>
  /// <returns></returns>
  public static T Go(API api, ICommand cmd) 
  {
    T resp = new T();

    lock (api.CommLock)
    {
      api.ServerComm.Connect();
      api.ServerComm.WriteCommand(cmd);
      api.ServerComm.ReadResponse(cmd, resp);
    }

    return resp;
  }
}

上述方法为与服务器的所有通信提供了一个单一入口点,允许与异步重新连接事件进行同步。

Generics

泛型用于方便地反序列化正确的响应。如果没有泛型,调用者需要对返回的响应进行类型转换。这不是一个大问题,但我认为指定响应类可以提高代码的健壮性,例如:

ICommand cmd = new LoginCommand(username, password);
LoginResponse resp = IssueCommand<LoginResponse>.Go(this, cmd);

这确保了 `resp` 是相同的类型。在其他实现中,您可能例如在命令中放入有关响应类型的信息。这将更加健壮,因为不会意外指定错误的响应类型。

发送/接收数据时抛出异常

当写入数据时发生异常,通信服务会抛出 `TcpLibException`(这是我自己的异常)。`WriteCommand` 方法会抛出 `CommandFailed` 事件,以便客户端有机会在断开连接状态下处理该命令。

public override void WriteCommand(ICommand cmd)
{
  try
  {
    comm.BeginWrite();
    CommandHeader hdr = new CommandHeader(sessionID, cmd.CommandId);
    comm.WriteData(hdr); // Write the header.
    cmd.Serialize(comm); // Write the command data.
    comm.EndWrite();
  }
  catch (TcpLibException)
  {
    RaiseCommandFailed(cmd);
  }
}

等待数据时抛出异常

读取线程会阻塞直到数据可用。如果与服务器的连接丢失,通信服务会抛出 TcpLibException。读取线程会处理此异常并触发 ConnectionFailed 事件。

while (!stopReader)
{
  try
  {
    // Start the read.
    comm.BeginRead();
    ResponseHeader respHdr;
    // Read the response header. This blocks until an exception or the 
    // response header is read.
    respHdr = (ResponseHeader)comm.ReadData(typeof(ResponseHeader)); 
    // Get the appropriate response instance.
    IResponse resp = (IResponse)Activator.CreateInstance(
         responseTypes[respHdr.responseId]);
    // Read the actual response.
    resp.Deserialize(comm);
    // Done reading.
    comm.EndRead();

    // If this is actually a notification...
    if (resp is SyncViewResponse)
    {
      // Queue the notification job so the data gets sync'd separately
      // from this thread.
      SyncViewResponse svr = (SyncViewResponse)resp;
      syncQueue.QueueForWork(svr);
    }
    else
    {
      // Otherwise queue the response.
      lock (responseData)
      {
        responseData.Enqueue(resp);
      }
    }
  }
  catch (TcpLibException e)
  {
    // If this is not an exception resulting from a controlled close of 
    // the connection...
    if (!stopReader)
    {
      // Force a disconnect.
      Disconnect();
      // Terminate the reader.
      stopReader = true;

      // And enqueue a client error.
      lock (responseData)
      {
        responseData.Enqueue(new ConnectionErrorResponse(e.Message, 
          e.StackTrace));
        RaiseConnectionFailed();
      }
    }
  }...
线程问题

ConnectionFailed 事件可以由以下情况触发:

  • 建立连接时失败(通常是主线程)
  • 向服务器写入命令失败(通常是主线程,但也可能是工作线程)
  • 因连接丢失而无法读取响应(读取线程)

因此,`ConnectionFailed` 事件处理程序必须考虑到它可能从不同的线程上下文调用。

如果连接在不可预测的时间发生变化,依赖连接状态的应用程序组件应如何收到通知?

理想情况下,用户应该继续使用应用程序,甚至不知道服务器已宕机。这对我的一些客户来说是一个关键要求,因为客户端应用程序不是最终用户直接交互的(通过传统的UI、键盘和鼠标)。其他客户端应用程序可能是需要几天才能完成的运行进程,但会频繁地与服务器通信以获取额外的作业分配并报告当前作业状态。即使是基于UI的客户端应用程序,其理念也是透明地处理状态变化。

通过以下方式实现:

  • 客户端可以发送到服务器并接收回来的特定且少量命令和相关响应。
  • 使用通用接口实现命令和响应的读/写方法和序列化。
  • 向服务器发出命令并接收响应的单一入口点。

通常,唯一需要通知的应用程序组件是 API 层,它会从连接状态切换到断开连接状态。

protected void SetDisconnectedState()
{
  lock (commLock)
  {
    if (isConnected)
    {
      isConnected = false;
      serverComm = disconnectedServerComm;
    }
  }
}

应用程序应如何在本地存储数据,以便在离线时也能访问?

客户端应用程序将数据视图作为快照存储在一个独立的文件中。

数据视图快照

客户端专门使用服务器提供的离散 DataView 实例。这些实例通过我在文章原始序列化中描述的压缩和加密技术在本地缓存,并利用我的文章xxx中描述的原始序列化器。因此,例如,要写入 DataView,需要使用公共方法:

public static void Write(DataView dv, string name, string prefix)
{
  StreamInfo streamInfo=InitializeSerializer(key, iv);
  RawDataTable.Serialize(streamInfo.Serializer, dv.Table);
  EndWrite(streamInfo);
  WriteToFile(prefix + "-" + name + ".cache", streamInfo.WriteBuffer);
  // Do last so memory stream isn't closed.
  streamInfo.EncStream.Close();
}

初始化序列化流

protected static StreamInfo InitializeSerializer(byte[] key, byte[] iv)
{
  MemoryStream writeBuffer = new MemoryStream();
  EncryptTransformer et = new EncryptTransformer(EncryptionAlgorithm.Rijndael);
  et.IV = iv;
  ICryptoTransform ict = et.GetCryptoServiceProvider(key);
  CryptoStream encStream = new CryptoStream(writeBuffer, ict, 
      CryptoStreamMode.Write);
  GZipStream comp = new GZipStream(encStream, CompressionMode.Compress, true);
  RawSerializer serializer = new RawSerializer(comp);
  StreamInfo streamInfo = new StreamInfo(encStream, comp, 
      writeBuffer, serializer);
  streamInfo.Iv = et.IV;
  streamInfo.Key = et.Key;

  return streamInfo;
}

并将数据写入文件

protected static void WriteToFile(string fn, MemoryStream ms)
{
  FileStream fs = new FileStream(fn, FileMode.Create);
  BinaryWriter bw = new BinaryWriter(fs);
  int len = (int)ms.Length;
  bw.Write(len);
  bw.Write(ms.GetBuffer(), 0, len);
  bw.Close();
  fs.Close();
}

从技术上讲,我可能可以将 FileStream 附加到序列化器而不是 MemoryStream。

数据会过时吗?

如果数据比其他客户端进行的另一次更新更旧,则数据可能会过时。然而,存在一个隐含的假设,即较新的数据更准确。当与服务器同步时,服务器的问题是,我从客户端获得的数据是否过时,这意味着其他客户端是否已经更近地更新了记录?***

何时刷新?

当客户端重新连接到服务器时,持久存储会进行同步;在服务器同步后,客户端会同步一个新的视图快照。通常来说,这种方法效果很好,会立即更新用户的数据视图。这里的复杂之处在于,这可能需要客户端业务规则来处理新视图中发生的变化。例如,我使用通知服务来通知客户端警报记录中的状态变化。当管理员在他的工作站清除警报(实际上是更新数据库中的一行)时,这会自动向适当的客户端发送通知,以清除相应客户端硬件中的警报标志。如果客户端断开连接,则不会发出此通知。相反,当客户端重新同步时,必须触发一个业务规则,该规则比较旧数据和新数据,以确定是否需要清除任何警报标志。

当应用程序无法访问所有必需的数据或服务时,其行为是否应有所不同?

尽可能不应该有不同的行为。我一直致力于确保这一点。有几个方面会导致困难。

自定义 SQL 语句

我的产品支持自定义客户端 SQL 语句,可用于工作流或通过客户端 API 层直接调用。对于离线应用程序,我不支持自定义 SQL 语句。在某些时候,这些可能能够在客户端运行,但理想情况下,在任何离线情况下,都应避免自定义 SQL 语句。

报告

报告需要查询服务器以在服务器端生成报告或获取在客户端生成报告所需的数据集。离线时,报告不可用。

监控和实时通知

除了客户端行为不同之外,企业可能会监控客户端本身是否离线,即使服务器看起来在线。离线时,实时通知(如警报、收入、传感器和硬件状态)是不可能的。这对于企业来说可能是一个足够关键的问题,以至于在离线时可能需要其他通知机制。处理离线客户端不仅仅涉及客户端如何响应,还可能影响监控应用程序如何报告离线客户端。

当应用程序离线时,事务数据(消息数据)应该如何以及存储在哪里?

这个问题实际上有两个部分。服务器是否应该实现事务机制来在离线客户端上线时更新它们,以及客户端如何管理离线事务?

服务器事务——好还是坏?

嗯,没有好坏之分。在设计我的产品时,我决定服务器不维护事务队列。服务器端事务队列增加了很大的复杂性。在刷新它并要求重新加载视图之前,你允许事务队列变得多大?当不同的客户端连接到服务器时,你如何跟踪它们在事务队列中的位置?当离线客户端同步服务器时,你是否确保服务器不会最终重新同步该特定客户端?当事务队列在内存中维护时,重启服务器如何影响同步?如果一行被删除,你会遍历事务队列并删除与被删除行相关的事务吗?如果存在那些事务触发的客户端业务规则,可能仍然需要运行怎么办?类似地,如果一个字段被更新,你会删除以前的事务更新吗?当服务器维护事务队列时,服务器架构的可伸缩性如何?

是的,我可以一直说下去。这些问题都没有正确答案,有时答案是如此具体于应用程序,以至于在我看来,在服务器端维护事务队列实际上是糟糕的。另一方面,“好”的架构现在要求客户端在每次请求视图时都从服务器获取完整的快照。潜在地,客户端可以利用缓存视图并只获取同步事务。也许这样数据量更少,速度更快,对持久性服务器的负担也更小。同样,这些问题无法以通用方式回答,并期望实现能满足应用程序特定的需求。所以最终,是 KISS 方法(保持简单和愚蠢)赢得了胜利,而不是对某种实现方案的赞成或反对论点。

数据视图事务

讽刺的是,在审视了服务器端事务之后,您会发现 DataView 事务是在客户端管理的!为了支持离线客户端,客户端不仅要在离线时记录事务,而且要在在线时记录,直到数据视图重新加载。以下序列图说明了不同的模式以及客户端本地事务的管理方式。

当客户端在线时

客户端

  • 连接到服务器
  • 对于给定视图,获取与该视图相关的离线事务
  • 将它们发布到服务器
  • 从服务器加载当前视图,获取视图的当前快照
  • 将视图保存到本地缓存
  • 删除离线和在线事务。视图现在是当前的。
  • 当客户端向服务器发布事务时,它也会将它们保存为“在线”事务。
  • 服务器发送的同步事务也会保存为“在线”事务。

当客户端离线时

客户端

  • 加载缓存视图
  • 获取在线和离线事务
  • 使用事务同步视图
  • 在本地发布(离线)事务

离线事务使用 Sqlite 发布

public void SaveTransactions(PostTransactionsCommand ptc, bool isOffline)
{
  // Build the comma delimited PK list.
  StringBuilder csPkList = BuildCsPkList(ptc);

  // Write out the transactions...
  using (DbCommand cmd = sqliteConn.CreateCommand())
  {
    // Write out the view and container for which these transactions 
    // are associated.
    int id = WriteTransactionInfo(cmd, ptc, csPkList, isOffline ? 1 : 0);

    // For each transaction in the log associated with the view and container...
    foreach (DataRow row in ptc.Transactions.Rows)
    {
      // Write the transaction record.
      using (DbCommand cmd2=sqliteConn.CreateCommand())
      {
        int recId = WriteTransactionRecord(cmd2, row, id);
 
        // Each transaction record has one or more PK values that uniquely 
        // identify the row being operated on in the view's table.
        foreach (string pkcol in ptc.PKColumnNames)
        {
          using (DbCommand cmd3 = sqliteConn.CreateCommand())
          {
            WriteTransactionRecordPrimaryKeys(cmd3, recId, pkcol, row);
          }
        }
      }
    }
  }
}

这些事务直接对应于由DataTable 事务记录器管理的信息。对于每个事务集,这包括:

  • 视图名称
  • 主键列名

在代码中,您会注意到事务集不仅由视图名称限定,还由容器名称限定,因为容器概念用于管理可能以不同方式过滤的视图。

对于集合中的每个事务

  • 事务类型(更新、插入、删除)
  • 受影响的列名(不用于插入或删除)
  • 值类型(不用于插入或删除)
  • 新值(不用于插入或删除)
  • 唯一标识记录的主键值(用于所有事务)

当应用程序从离线状态变为在线状态时,事务数据应如何与服务器同步?

我觉得这个问题实际上有两个部分——如何何时。

如何做到?

如何解决已在上述加载 DataView 的过程中阐述——离线事务被发送到服务器,客户端获取更新的快照,然后本地事务被删除。

何时?

“何时”是一个更有趣的问题。例如,对于我的客户,应用程序是 24/7/365 运行的,并且计算机封装在一个外壳中。重新启动应用程序是不希望的,因此客户端应用程序需要自动重新连接和重新同步。当然,更简单的情况是,当客户端登录到服务器时再重新同步。这对于我的客户来说是不可行的场景。另一方面,如果这对您可行的,那么您可以忽略所有在运行时重新连接的问题。

当触发 `ReconnectedToServer` 事件时,客户端会执行以下操作:

  1. 将客户端设置为已连接状态
  2. 登录
  3. 重新加载活动视图

重新加载活动视图的行为会同步服务器并更新客户端的视图快照。以下代码说明了此过程:

void OnReconnectedToServer(object sender, EventArgs e)
{
  // Block all command/responses until we're done here. 
  // Wait until a current command/response
  // is completed before entering here.
  lock (commLock)
  {
    // Raise the reconnecting event.
    RaiseReconnecting();
    // Restart the reader thread.
    connectedServerComm.InitializeReader();
    // Set the client to connected state.
    SetConnectedState();
    // Login.
    Login(username, password);

    // Walk through the active containers and sync the views 
    // in those containers.
    foreach (Container container in containers.Values)
    {
      // Get each view...
      foreach (ViewInfo vi in container.Views)
      {
        DataView dvNew;

        if (vi.CreateOnly)
        {
          // If it's a create only view (no data is loaded), create the view,
          // which synchronizes the
          // server with any transactions that occurred offline.
          dvNew = CreateViewIntoContainer(vi.ViewName, vi.KeepSynchronized, 
              vi.Where, vi.ContainerId);
        }
        else
        {
          // If it's a create and load view, load the view, 
          // which synchronizes the server with any
          // transactions that occurred offline and updates the local 
          // cache with the new server snapshot.
          dvNew = LoadViewIntoContainer(vi.ViewName, vi.Where, vi.OrderBy, 
              vi.DefColValues, vi.Parms, vi.ContainerId, 
              vi.KeepSynchronized, vi.IsCached);
        }

        // Reload the view data. This updates the existing view records, 
        // causing any processes
        // that were updating view records to have invalid rows. 
        // Therefore, such processes need
        // to synchronize with the commLock object. 
        // TODO: See ReloadView.
        ReloadView(vi.View, dvNew);
      }
    }

    // Raise the ReconnectFinished event.
    RaiseReconnectFinished();
  }
}

`ReloadView` 方法是一种强制同步内存中的 DataView 与从服务器接收到的视图的方法。实际上,它非常糟糕,但对于某些要求来说,它确实可以完成任务。它使用 `DataTable` 类的 `ExtendedProperties` 功能来阻止事务记录器事件,然后逐行、逐字段地将新的 `DataView` 复制到现有的 `DataView` 中。

protected void ReloadView(DataView destView, DataView newView)
{
  // Stop events, etc.
  destView.Table.BeginLoadData();
  // Stop the transaction logger.
  destView.Table.ExtendedProperties["BlockEvents"]=true;
  // Clear the entire table of all records.
  destView.Table.Rows.Clear();

  // Get each new row.
  foreach (DataRow dr in newView.Table.Rows)
  {
    // Create a row in the new data view.
    DataRow newRow = destView.Table.NewRow();

    // Copy the column values.
    foreach (DataColumn dc in destView.Table.Columns)
    {
      newRow[dc] = dr[dc.ColumnName];
    }

    // Add the row.
    destView.Table.Rows.Add(newRow);
  }

  // Accept all changes.
  destView.Table.AcceptChanges();
  // Re-enable events, etc.
  destView.Table.EndLoadData();
  // Re-enable transaction logging.
  destView.Table.ExtendedProperties["BlockEvents"] = false;
}

重新连接过程中存在几个问题,我将在下面的“问题”部分讨论。然而,这里有一点——上述代码不是在生产环境中更新 `DataView` 的方式。相反,`DataView` 应该使用现有的 `DataRow` 实例进行同步。在处理当前正在编辑的行(例如,网格内编辑)以及已删除的编辑行时,必须小心。这些机制的实现本身就值得单独写一篇文章。

提问时间

为什么不使用 Sqlite 作为 DataView 缓存?

这是一个很好的问题,我为此挣扎了一段时间。当然,没有一个正确的答案。

我决定在数据视图快照和相应的事务之间保持清晰的分离。客户端不具备服务器在更新表方面的任何逻辑,我也不想走到需要考虑在客户端数据库中实现服务器端逻辑来更新客户端数据视图的地步。毕竟,我仍然需要单独维护事务,以便可以将它们发送到服务器。

另一个原因是它更简单。与在 Sqlite 中创建和管理必要的表相比,将数据视图序列化到独立文件更容易(根据我的经验也更快)。

最后,问题的症结在于模式。虽然模式可供客户端使用,但就客户端而言,模式的存在是为了帮助创建空视图和访问视图属性,例如正则表达式验证,这些都是在服务器的模式中定义的。然而,模式实际上是有些动态的。在许多情况下,我可以在不关闭服务器的情况下更新模式。这使得向企业添加新功能非常方便。如果我将视图快照存储在 Sqlite 中作为表,客户端也必须确定模式是否已更改,删除旧表并创建新表。目前,这似乎是不必要的复杂性。

为什么不使用 XML 存储事务,而使用 Sqlite?

又是一个好问题。同样,没有一个正确答案。我选择 Sqlite 是希望它比 XML 更简洁、更快。然而,问题的症结在于 Sqlite 提供了内置加密功能。如果我将事务存储在 XML 中,我就必须提供加密服务。与数据视图缓存不同,数据视图缓存是一个快照,因此是一个一次性加密/解密过程,而事务总是不断添加,涉及两个嵌套关系,并且需要在服务器同步时删除。数据库似乎比平面 XML 文件更自然的持久化机制,而且无需处理加密使得 Sqlite 成为更合乎逻辑的选择。

问题

离线客户端存在几个问题,任何具体实现都必须解决这些问题。本文不讨论这些问题。

用户认证

通常情况下,服务器对客户端进行身份验证。当客户端离线时,客户端本身需要执行身份验证。当然,用户身份验证与用户角色和权限是相互关联的。

角色和权限

一个简单的解决方案是只允许在离线时使用最少的角色和权限。更复杂的解决方案涉及缓存角色和权限表,并在客户端实现相同的服务器端逻辑。然而,再次强调,如果管理员撤销了某个角色或权限,但用户因为离线而继续拥有访问权限,会发生什么?由于角色/权限已更改,现在应该禁止的事务在服务器端如何处理?我觉得这些问题无法泛泛回答,并期望解决方案能满足每个人的需求。另一方面,应该有可能将问题充分抽象化,以允许应用程序指定其希望使用的特定范式,并提供一种机制来扩展该范式,以满足真正超出常规(或最初未考虑)的需求。

另一种机制可能涉及指定哪些视图可以缓存,哪些视图在任何情况下都不能缓存。程序功能可以根据该功能所需的视图的可用性来禁用。这是一种选择,但同样特定于单个应用程序需求,只能抽象地支持。

同步

除非客户端在启动期间明确实现,否则当前架构不会将服务器与数据视图的离线更改同步,直到该数据视图实际加载。这取决于客户端实现。

主从同步

同步详细视图需要先同步主视图。或者更一般地说,视图中的任何外键都表明可能需要先同步父视图。目前这是通过视图的加载顺序来处理的,这绝对不是理想的情况。这些问题不是本架构所解决的,而是交由客户端实现。

脏数据

在同步离线事务(甚至是在线操作期间)时,可能会出现更新已被删除的记录或更新已被另一个客户端更改的值的情况。这些问题不是本架构所解决的,而是交由特定的客户端/服务器实现。

服务器端限定符

为了减少从服务器发送到客户端的数据量,并提高查询效率,我们经常使用服务器端限定符(SQL 的 "where" 子句)来在服务器端过滤视图。在离线客户端中,当视图被缓存时,那些依赖于客户端动态数据进行过滤的服务器端限定符在处理缓存视图时会失败。例如:

  • 用于确定权限的用户名或用户 ID
  • 用于限定加载视图命令的 UI 数据

这些场景(以及其他)极大地增加了离线客户端的复杂性。应用程序需求必须权衡数据大小、性能和可用的离线功能,同时开发人员也必须非常清楚他们如何与服务器交互,以及这可能如何导致离线客户端无法实际工作(甚至更糟的是,赋予离线客户端通常不具备的权限)。

重新连接

如前所述,其中一些问题是由于需要在不重新启动客户端应用程序的情况下重新建立与服务器的连接。如果这对于您的应用程序来说不是一个要求,那么事情就会变得容易得多。话虽如此,这里有一些关于应用程序运行时自动重新连接的注意事项。

如果在重新连接过程中服务器宕机怎么办?

这里的关键问题是,服务器是否收到了事务,以及事务现在是否可以从客户端的事务缓存中删除?其次,如何平稳地将客户端切换回断开连接状态?确保本地 DataView 缓存未损坏也至关重要。

如果用户在重新连接发生时正在编辑记录,会发生什么?

使用行事务沙盒(RTS),用户可以很好地与视图更新过程隔离——当他们在沙盒中工作时,视图可以重新加载,而用户不会丢失他们的编辑内容。因为 RTS 本身使用事务记录器,所以提交更改不受以下事实的影响:在上述实现中,具体的 `DataRow` 现在是一个新实例。但是,如果该行现在被删除,或者用户正在进行网格内编辑,RTS 就无法提供帮助。同样,上面提出的用于同步 `DataView` 的代码是一个简单的“黑客”方法,旨在让原型正常工作。

不活动视图如何更新,何时更新?

除了更新当前已加载并可能正在显示的视图之外,还存在如何同步不活动视图的问题。这应该是一个后台任务吗?在视图需要之前是否应该不予理会?在尽可能保持客户端同步、处理带宽问题(如果客户端连接速度慢,是否要同步?)以及其他不可预测的特定应用程序问题和要求之间,平衡点在哪里?

代码

正如我所提到的,下载实际上是一个工具包,用于探索实现断开连接客户端的一种方式,并作为考虑更复杂问题的基础。您会注意到在下载中完全缺少服务器命令处理实现(尽管包含了 TcpServer 代码)。您基本上需要自行创建服务器命令处理。我觉得深入服务器实现会分散本文的重点,因为本文只关注客户端。我想有些人会对此以及我没有提供完整演示的事实感到不满。如果受到鼓励,我可能会写一篇关于最小服务器实现的后续文章。

代码包含以下文件夹,我将在这里进行描述:

Api

这包含应用程序与服务器通信所需的核心 Api 方法。API 架构强调应用程序使用单一接口与服务器通信,API 本身使用单一入口点来发出命令和获取响应。API 还处理连接/断开事件(以及其他事件)。

缓存

有一个简单的静态类,实现了基于文件的缓存。

客户端

提供应用程序客户端需要增强的基本和存根实现。提供了一个用于将客户端与服务器通知同步的简单模板,因为这可以说明事务记录器和同步管理器。

通讯

实现了模板连接和断开连接的通信类。这包括连接通信类的完整读取器线程和数据包读取器。断开连接的通信类实现了将事务记录到 Sqlite、用于尝试重新连接的线程,以及通过使用缓存视图数据模拟消息响应。

加密

实现了一个用于常见加密算法的包装器。这是一个用于构建不同加密/解密算法的薄包装器。

Logger

包含事务记录器和同步管理器的文件。请参阅本文开头列出的链接以获取进一步文档。然而,这里的代码是最新的代码。

杂项

杂项类——一个数据转换器(类似于 Convert.ChangeType)、KeyedList 类、Stephen Toub 的托管线程池类、我的 ProcessQueue 类和一个字符串帮助类。

数据包

这些是基本的命令和响应数据包——登录、加载视图、创建视图、发布事务和同步视图。

原始序列化器

原始序列化器代码,如本文所述。

TcpLib

通信服务类,详见此处此处

结论

本文并非典型的“这里有一个现成的解决方案”文章。相反,我试图讨论围绕离线客户端架构的问题,我在我撰写的其他文章背景下做出的设计决策,并且我试图识别我认为超出通用实现范围,必须根据您的具体需求处理的问题。正如我在引言中提到的,代码不是一个“交钥匙”解决方案,更像是一个工具包,希望它能给您一些有用的组件来尝试。

个人笔记

构建这个架构(即使存在问题)最让我享受的一点是,它汇集了我许多其他文章中的工作。我大约一年前完成的事务记录器事务沙盒工作已成为我客户系统坚实的主干,并持续良好地适应不同的用例。同样,原始序列化器加密流是可靠的“老黄牛”。

Sqlite

您可以在这里下载 Sqlite ADO.NET 提供程序:Sqlite ADO.NET 2.0 Provider,其中也包含了 Sqlite 引擎。

© . All rights reserved.