动态生成的 XML 数据编辑器






4.88/5 (72投票s)
使用 XML 架构定义 (XSD) 文档,此实用工具可以动态生成一个数据录入表单,用于创建和编辑 XML 数据。
目录
引言
我 以前的实用工具 [^] 帮助您创建 XSD 文件。完成此操作后,我寻找了一个允许我管理关联 XML 文档数据的实用工具。具体来说,我想要两个功能:
- 一个通用的解决方案,允许我根据任何 XSD 架构创建 XML 数据
- 一个能够根据架构信息定制输入控件的东西
我没有找到任何接近满足这两个标准的东西。我找到的所有编辑器都侧重于编辑整个 XML 文档(包括标签),而不仅仅是数据。对我来说,标签是快速组织数据集的一个主要障碍。此外,.NET 解决方案(创建 CS 文件)是一种复杂、多步骤且非通用的方法。
因此,我决定编写自己的 XML 数据编辑器,并在过程中学习更多关于 XSD、XML 和 XPath 的知识。利用 XmlDataDocument、XmlSchema 和 DataTable 类,我创建了一个通用的编辑器,该编辑器根据架构定义和表关系动态创建用于数据录入的专用控件。
结果的示例是:(图片太长,抱歉!)

...这是从架构派生的(我的 XML 架构编辑器的截图)

示例文件
下载包含一个示例 XSD 和 XML 文件。首先使用“文件/加载 XSD”菜单项加载 sampleSchema2.XSD 文件。然后使用“文件/加载 XML”菜单项加载 sale5.xml 文件。
一些背景基础:导入架构和 XML 文档
鉴于
private XmlSchema schema=null;
private XmlDataDocument doc=null;
XSD 文件已加载
// get a stream reader for the XSD file
StreamReader tr=new StreamReader(dlgOpen.FileName);
// read the file into the XmlSchema object
schema=XmlSchema.Read(tr, 
  new ValidationEventHandler(SchemaValidationHandler));
tr.Close();
// report any problems with the schema by compiling it
CompileSchema();
// create the document
doc=new XmlDataDocument();
// open the schema again
tr=new StreamReader(dlgOpen.FileName);
// read it into the DataSet member
doc.DataSet.ReadXmlSchema(tr);
XML 文件已加载
XmlTextReader tr=new XmlTextReader(dlgOpen.FileName);
doc.Load(tr);
tr.Close();
动态 GUI 生成
一旦架构被加载到 DataSet 中,.NET 框架会自动创建相关的 DataTable 对象及其关系。此信息用于生成 GUI 的框架,该框架是一系列递归的 GroupBox 对象,封装了 Control 对象,所有这些都包含在一个面板中。面板为我们提供了自动滚动功能,从而减轻了我们自己管理滚动功能的负担。
获取根表
GUI 的生成始于检查所有根(非父)表,并确定定义它们的 XmlSchemaComplexType。这里的假设是,任何根表将在架构中表示为一个引用全局复杂类型的元素。
private void ConstructGUI(DataSet dataSet)
{
  Point pos=new Point(10, 10);
  // get all tables in the dataset
  foreach (DataTable dt in dataSet.Tables)
  {
    // but we're only interested in the toplevel tables
    if (dt.ParentRelations.Count==0)
    {
      /*
       * Rule 1:
       * A top level table will be a top level element in the schema that
       * is of a complex type.  The element name will be the table name.
       * What we want to identify is the complex 
       * type that the table references,
       * so that we can determine the data types of the columns.
       * 
       * Any other rules???
       */
       XmlSchemaElement el=GetGlobalElement(dt.TableName);
       XmlSchemaComplexType ct=GetGlobalComplexType(el.SchemaTypeName.Name);
       Point p2=ConstructGUI(pos.X, pos.Y, dt, pnlDynForm, ct);
       pos.Y+=p2.Y;
       }
    }
}
有两个非常简单的辅助函数用于获取适当的对象,此处显示是因为获取它们需要不同的机制。
GetGlobalElement()
private XmlSchemaElement GetGlobalElement(string name)
{
  XmlQualifiedName qname=new XmlQualifiedName(name, schema.TargetNamespace);
  XmlSchemaObject obj=schema.Elements[qname];
  return obj;
}
GetGlobalComplexType()
private XmlSchemaComplexType GetGlobalComplexType(string name)
{
  for (int i=0; i < schema.Items.Count; i++)
  {
    XmlSchemaComplexType obj=schema.Items[i] as XmlSchemaComplexType;
    if (obj != null)
    {
      if (obj.Name==name)
      {
        return obj;
      }
    }
  }
  return null;
}
创建 GroupBox:记录集合
GroupBox 以一种直接的方式创建,并附带一个名为 tableInfo 的辅助哈希表。最初,组框没有设置大小,因为大小是在放置完所有控件后确定的。此外,还会创建一个导航栏并将其与组框关联,提供第一条、上一条、下一条、最后一条、新建和删除记录按钮,以及 n of m 记录信息文本。
private Point ConstructGUI(int absx, int absy, DataTable dt, 
  Control gbParent, XmlSchemaComplexType ct)
{
  GroupBox gb1=new GroupBox();
  gb1.Font=new Font(gb1.Font, FontStyle.Bold);
  gb1.Text=dt.TableName;
  gb1.Location=new Point(absx, absy);
  gb1.Parent=gbParent;
  gb1.Visible=true;
  tableInfo[dt]=new TableInfo();
  CreateRecordNavigationBar(10, 15, gb1, dt);
  ...
一旦放置了组框,就会对 DataTable "dt" 中的所有列执行以下步骤:
// For each column in the table...
foreach (DataColumn col in dt.Columns)
  {...
1. 放置列名和适合非隐藏列的控件
// if it's not an internal ID...
if (col.ColumnMapping != MappingType.Hidden)
{
  // display its name
  CreateLabel(relx, rely, col.ColumnName, gb1);
  TypeInfo ti=GetTypeInfo(ct, col.ColumnName);
  if (ti != null)
  {
    Control editCtrl=CreateEditControl(relx+120, rely, ti, gb1, dt, col);
  }
}
2. 放置列名和只读文本控件以用于隐藏字段。这可以省略,但我将其保留下来,以便我能够检查 DataSet 的隐藏功能。与子表有关系的列显示为蓝色,与父表有关系的列显示为红色(此处未显示设置颜色)。由于架构的性质,这些关系总是 1:1(据我所见!)。
if (col.ColumnMapping==MappingType.Hidden)
{
  Label lbl=CreateLabel(relx, rely, col.ColumnName, gb1);
  Control editCtrl=CreateEditControl(relx+120, 
    rely, new TypeInfo("string"), gb1, dt, col);
  editCtrl.Size=new Size(50, 20);
  ((TextBox)editCtrl).ReadOnly=true;
3. 如果发现任何子关系,将递归调用相同的函数。
// Get child relationships, which are displayed as indented groupboxes
foreach (DataRelation childRelation in dt.ChildRelations)
{
  DataTable dt2=childRelation.ChildTable;
  XmlSchemaElement el2=GetLocalElement(ct, dt2.TableName);
  XmlSchemaComplexType ct2=GetGlobalComplexType(el2.SchemaTypeName.Name);
  if (ct2==null)
  {
    ct2=GetLocalComplexType(el2);
  }
  Point p=ConstructGUI(relx+20, rely+20, dt2, gb1, ct2);
还有一些关于缩进和计算外部组框大小的简单计算,我将不详细讨论。
控件创建
组框内的控件是根据从架构收集的信息中的一些简化规则创建的。这个区域可以大大扩展,因为我不想实现所有基本类型。规则如下:
- 如果元素包含枚举,则使用枚举数据创建一个 ComboBox。
- 如果元素包含 minInclusive和maxInclusive分面,则创建一个NumericUpDown控件。
- 如果元素是布尔类型,则创建一个 CheckBox控件。
- 如果元素是十进制或正整数类型,则创建一个右对齐的 TextBox控件。
- 其他所有都是 TextBox控件。
目前,我忽略了长度和模式等其他分面。
记录导航
记录导航需要我们手动选择由父记录 ID 索引的子记录。
基础知识
这些行存储在 tableInfo 中作为数组,以便于查找。有两个辅助函数被广泛使用。第一个函数用于调整指定导航器的 TextBox 的“记录 m of n”显示。如果表是根表,这里有一个测试来设置位置跟踪器。这可能不是这段代码的最佳位置!
private void UpdateRecordCountInfo(DataTable dt)
{
  // update the text box to reflect the record #
  TableInfo ti=tableInfo[dt] as TableInfo;
  if (dt.ParentRelations.Count==0)
  {
    ti.pos=BindingContext[dt].Position+1;
  }
  ti.tb.Text="   record "+ti.pos.ToString()+" of "+ti.rows.ToString();
}
GetMatchingRows 辅助函数在确定与父 ID 匹配的子行方面非常有用。给定子表,代码获取父表和建立关系的列。可以(而且我认为正确地)假定,总是只有一个列定义关系。由此,BindingContext 用于定位当前的父行。给定父行,我们可以获取 ID 的值并对与关系 ID 匹配的子行执行“select”查询。
private int GetMatchingRows(DataTable dt)
{
  TableInfo ti=tableInfo[dt] as TableInfo;
  // get parent relationship
  DataRelation parentRelation=dt.ParentRelations[0];
  // get the parent table
  DataTable dt2=parentRelation.ParentTable;
  // get the parent column (1:1 relationship always)
  DataColumn dcParent=parentRelation.ParentColumns[0];
  // get the current record # of the parent
  int n=BindingContext[dt2].Position;
  if (n != -1)
  {
    // get the ID
    string val=dt2.Rows[n][dcParent].ToString();
    // search the child for all records where child.parentID=parent.ID
    string expr=dcParent.ColumnName+"="+val;
    // save the rows, as we'll use them later on when navigating the child
    ti.dataRows=dt.Select(expr);
  }
  // return the length
  return ti.dataRows.Length;
}
移动
每个处理程序 - 第一条、上一条、下一条和最后一条 - 的形式基本相同。我将演示“下一条”处理程序。任何给定导航栏中的每个按钮都用它操作的 DataTable 进行“标记”。利用此信息,可以访问 tableInfo 对象以更新内部记录位置。请注意,对于子记录,此位置是相对于由父 ID 选择的行,而不是相对于子表中的所有记录。
private void NextRecord(object sender, EventArgs e)
{
  Button btn=sender as Button;
  DataTable dt=btn.Tag as DataTable;
  TableInfo ti=tableInfo[dt] as TableInfo;
  if (ti.pos < ti.rows)
  {
    ((TableInfo)tableInfo[dt]).pos++;
    NextRecord(dt, ti);
    UpdateRecordCountInfo(dt);
  }
}
出于这个原因,需要进行一些操作才能获取所需的记录。如果表是根表,则我们的内部位置与 DataTable 中的行之间存在 1:1 的对应关系。但是,如果它是子表,那么我们必须在 DataTable 中找到与我们在**已选择**行中的行相匹配的行。
private void NextRecord(DataTable dt, TableInfo ti)
{
    if (dt.ParentRelations.Count==0)
    {
        BindingContext[dt].Position++;
    }
    else
    {
        // get the next row that matches the parent ID
        SetPositionToRow(dt, ti.dataRows[ti.pos-1]);
    }
    ResetAllChildren(dt);
}
这是通过相当粗暴的方式完成的,可以优化为从当前位置向前或向后查找。好吧,下一个版本……
private void SetPositionToRow(DataTable dt, DataRow row)
{
  for (int i=0; i < dt.Rows.Count; i++)
  {
  if (dt.Rows[i]==row)
    {
      BindingContext[dt].Position=i;
      break;
    }
  }
}
更新子记录
现在,当我们切换到父表中的另一条记录时,所有子记录都需要更新以显示与父记录相关的行集。这是通过递归完成的。获取所有子记录的匹配行,并将子记录设置为第一条记录(如果存在)。然后对其子记录执行相同的操作。
private void ResetAllChildren(DataTable dt)
{
  // update all children of the table to match our new ID
  foreach (DataRelation dr in dt.ChildRelations)
  {
    DataTable dtChild=dr.ChildTable;
    ResetChildRecords(dtChild);
  }
}
private void ResetChildRecords(DataTable dt)
{
  int n=GetMatchingRows(dt);
  TableInfo ti=tableInfo[dt] as TableInfo;
  ti.pos=1;
  ti.rows=n;
  UpdateRecordCountInfo(dt);
  if (n != 0)
  {
    SetPositionToRow(dt, ti.dataRows[0]);
  }
  foreach (DataRelation dr in dt.ChildRelations)
  {
    DataTable dtChild=dr.ChildTable;
    ResetChildRecords(dtChild);
  }
}
添加记录
添加记录相当麻烦。记录被添加到集合的末尾,这有点帮助。任何与父相关的字段都必须设置为父 ID,并且所有子关系都必须创建新记录……当然是递归地。
private void NewRecord(object sender, EventArgs e)
{
  Button btn=sender as Button;
  DataTable dt=btn.Tag as DataTable;
  dt.Rows.Add(dt.NewRow());
  int newRow=dt.Rows.Count-1;
  BindingContext[dt].Position=newRow;
  TableInfo ti=tableInfo[dt] as TableInfo;
  // Set the child relationship ID's to the parent!
  // There will be only one parent relationship except
  // for the root table.
  if (dt.ParentRelations.Count != 0)
  {
    DataRelation parentRelation=dt.ParentRelations[0];
    DataTable dt2=parentRelation.ParentTable;
    int n=BindingContext[dt2].Position;
    // this is always a 1:1 relationship
    DataColumn dcParent=parentRelation.ParentColumns[0];
    DataColumn dcChild=parentRelation.ChildColumns[0];
    string val=dt2.Rows[n][dcParent].ToString();
    dt.Rows[newRow][dcChild]=val;
    n=GetMatchingRows(dt);
    ti.pos=n;
    ti.rows=n;
  }
  else
  {
    ti.pos=newRow+1;
    ti.rows=newRow+1;
  }
  UpdateRecordCountInfo(dt);
  // for each child, also create a new row in the child's table
  foreach (DataRelation childRelation in dt.ChildRelations)
  {
    DataTable dtChild=childRelation.ChildTable;
    NewRecord(dt, dtChild, childRelation);
  }
}
...以及针对每个子表...
private void NewRecord(DataTable parent, DataTable child, DataRelation dr)
{
  // add the child record
  child.Rows.Add(child.NewRow());
  // get the last row of the parent (this is the new row)
  // and the new row in the child (also the last row)
  int newParentRow=parent.Rows.Count-1;
  int newChildRow=child.Rows.Count-1;
  // go to this record
  BindingContext[child].Position=newChildRow;
  // get the parent and child columns
  // copy the parent ID (auto sequencing) to the child to establish
  // the relationship.  This is always a 1:1 relationship
  DataColumn dcParent=dr.ParentColumns[0];
  DataColumn dcChild=dr.ChildColumns[0];
  string val=parent.Rows[newParentRow][dcParent].ToString();
  child.Rows[newChildRow][dcChild]=val;
  ((TableInfo)tableInfo[child]).pos=1;
  ((TableInfo)tableInfo[child]).rows=1;
  UpdateRecordCountInfo(child);
  // recurse into children of this child
  foreach (DataRelation childRelation in child.ChildRelations)
  {
    DataTable dt2=childRelation.ChildTable;
    NewRecord(child, dt2, childRelation);
  }
}
删除记录
有趣的是,删除记录非常简单。这要归功于级联删除,DataSet 会自动启用该功能。
private void DeleteRecord(object sender, EventArgs e)
{
  Button btn=sender as Button;
  DataTable dt=btn.Tag as DataTable;
  int n=BindingContext[dt].Position;
  dt.Rows.RemoveAt(n);
  ...
...然后是一堆代码来显示有效的记录并重置子记录。
数据绑定
数据绑定使将控件与 DataTable 中的列关联变得异常简单,只需一行代码即可完成。
ctrl.DataBindings.Add("Text", dt, dc.ColumnName);
这会将控件的“Text”字段绑定到指定的表和列。反射不是很棒吗?BindingContext 也是一个救星。它允许我们指定用于绑定控件的每个 DataTable 中的行。太棒了!例如,给定表,我们可以用一行代码指定绑定行。
BindingContext[dt].Position=dt.Rows.Count;
XPath 查询

我实现了 XPath 功能,以便能够尝试如何从 XML 数据中提取信息。我来自数据库背景,我想要一些东西可以展平层次结构,这样我就可以一次看到所有数据,而无需通过层级进行鼠标点击等操作。使用 XmlDataDocument 的 SelectNodes 方法
XmlNodeList nodeList=null;
try
{
  nodeList=doc.SelectNodes(edXPath.Text);
  DlgXPathResult dlg=new DlgXPathResult(nodeList);
  dlg.ShowDialog(this);
}
我提取一个节点列表,如果成功,则将其传递给对话框。数据在 ListView 控件中显示,其标题从 XmlNode 中提取。显示节点数据有三个基本步骤:
- 获取标题。
- 用数据填充行。
- 清理空列。
获取头部信息
标题信息从节点列表的属性和元素中提取。此函数对元素进行递归操作,因此可能会生成不必要的列。此外,还会忽略重复的元素名称,如果您的架构在两个不同的区域使用相同的元素名称,而您又恰好查询这些元素,则可能导致覆盖问题。
private void ProcessChildHeaders(XmlNode node, XmlNode child)
{
  while (child != null)
  {
    // process attributes
    if (child.Attributes != null)
   {
      foreach(XmlAttribute attr in child.Attributes)
      {
        if (!cols.Contains(attr.Name))
        {
        cols.Add(attr.Name, colIdx);
        colHasData[colIdx]=false;
        lvResults.Columns.Add(attr.Name, -2, HorizontalAlignment.Left);
        ++colIdx;
        }
      }
    }
    // if this child is an element, get its children
    if (child.FirstChild is XmlElement)
    {
      ProcessChildHeaders(node, child.FirstChild);
    }
    else
    {
      // if not, then either it or its child is a text element
      string name=child.Name=="#text" ? node.Name : child.Name;
      if (!cols.Contains(name))
      {
        // add the column header
       cols.Add(name, colIdx);
       colHasData[colIdx]=false;
       lvResults.Columns.Add(name, -2, HorizontalAlignment.Left);
       ++colIdx;
      }
    }
    child=child.NextSibling;
  }
}
用数据填充行
此函数检查每个子节点的属性值和文本值,并递归进入子元素。使用了一些逻辑来防止空行。
private void ProcessChildData(XmlNode node, XmlNode child)
{
  while (child != null)
  {
    ProcessAttributes(child);
    // if this child is an element, get its children
    if (child.FirstChild is XmlElement)
    {
      ProcessChildData(child, child.FirstChild);
      if (hasAttributes | hasData)
      {
        lvi=CreateLVI(cols.Count);
        lvResults.Items.Add(lvi);
        hasData=false;
        hasAttributes=false;
      }
    }
    else
    {
      // if not, then either it or its child is a text element.
      string name=child.Name=="#text" ? node.Name : child.Name;
      int n=(int)cols[name];
      // set the data for the column
      lvi.SubItems[n].Text=child.InnerText;
      hasData=true;
      colHasData[n]=true;
    }
  child=child.NextSibling;
  }
}
结论
写完所有这些之后,我终于觉得我拥有了一套可以用来生成架构和操作 XML 数据的工具。虽然没有解决架构、分面等的所有问题(“choice”架构元素看起来特别棘手),但我认为我创建了一套很好的工具,可以完成我需要架构和 XML 文件的 90% 的工作。如果您觉得有任何特定的功能是“必备”的,请告诉我,我会尝试将其纳入。
历史
2003 年 10 月 15 日 - 添加了对更复杂架构的支持,修复了一些小错误


