列表的 MVC 模型绑定动态列表

MVC Model Binding Dynamic List of Lists

我有一个动态列表的动态列表,其中有 <input /> 需要发布到 MVC controller/action 并绑定为类型化对象。我的问题的症结在于我不知道如何在 custom model binder 中手动挑选任意的 POSTed 表单值。详情如下。

我有一个美国各州列表,每个州都有一个城市列表。州和城市都可以动态添加、删除和重新排序。所以像:

public class ConfigureStatesModel
{
    public List<State> States { get; set; }
}

public class State
{
    public string Name { get; set; }
    public List<City> Cities { get; set; }
}

public class City
{
    public string Name { get; set; }
    public int Population { get; set; }
}

获取:

public ActionResult Index()
{
    var csm = new ConfigureStatesModel(); //... populate model ...
    return View("~/Views/ConfigureStates.cshtml", csm);
}

ConfigureStates.cshtml:

@model Models.ConfigureStatesModel
@foreach (var state in Model.States)
{
    <input name="stateName" type="text" value="@state.Name" />
    foreach (var city in state.Cities)
    {
        <input name="cityName" type="text" value="@city.Name" />
        <input name="cityPopulation" type="text" value="@city.Population" />
    }
}

(还有更多标记和 javascript,但我将其留给 brevity/simplicity。)

然后所有表单输入都将发布到服务器,如此(由 Chrome 开发工具解析):

stateName: California
cityName: Sacramento
cityPopulation: 1000000
cityName: San Francisco
cityPopulation: 2000000
stateName: Florida
cityName: Miami
cityPopulation: 3000000
cityName: Orlando
cityPopulation: 4000000

我需要捕获表单值,理想情况下绑定为 List<State>(或者等效地,作为 ConfigureStatesModel),如下所示:

[HttpPost]
public ActionResult Save(List<State> states)
{
    //do some stuff
}

自定义模型活页夹似乎是完成这项工作的合适工具。但是我不知道如何知道哪些城市名称和城市人口属于哪些州名称。也就是说,我可以看到所有已发布的表单键和值,但我看不到了解它们关系的方法:

public class StatesBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        //California, Florida
        List<string> stateNames = controllerContext.HttpContext.Request.Form.GetValues("stateName").ToList();

        //Sacramento, San Francisco, Miami, Orlando
        List<string> cityNames = controllerContext.HttpContext.Request.Form.GetValues("cityName").ToList();

        //1000000, 2000000, 3000000, 4000000
        List<int> cityPopulations = controllerContext.HttpContext.Request.Form.GetValues("cityPopulation")
            .Select(p => int.Parse(p)).ToList();

        // ... build List<State> ...
    }
}

如果我能知道所有值相对于所有其他表单值的顺序,就足够了。我看到的唯一方法是查看原始请求流,如下所示:

Request.InputStream.Seek(0, SeekOrigin.Begin);
string urlEncodedFormData = new StreamReader(Request.InputStream).ReadToEnd();

但我不想手动解析它。

另请注意,州列表的顺序和每个州的城市列表的顺序很重要,因为我坚持对它们显示顺序的概念。所以这也需要从表单值中保留下来。

我试过动态列表绑定的变体,例如 and this。但是为了让绑定正常工作而丢弃 html 并添加大量(容易出错的)javascript 感觉不对。表单值已经存在;应该只是在服务器上捕获它们的问题。

我认为构建一个实际表示哪些城市属于哪个州的表单的唯一明显方法需要您使用 strongly-typed 助手。

所以,我会使用类似于:

@model Models.ConfigureStatesModel

@for (int outer = 0; outer < Model.States.Count; outer++)
{
    <div class="states">
        @Html.TextBoxFor(m => m.States[outer].Name, new { @class="state" })
        for (int inner = 0; inner < Model.States[outer].Cities.Count; inner++)
        {
            <div class="cities">
                @Html.TextBoxFor(m => m.States[outer].Cities[inner].Name)
                @Html.TextBoxFor(m => m.States[outer].Cities[inner].Population)
            </div>
        }
    </div>
}

这将创建具有默认模型绑定器可以处理的表单名称的输入。

需要一些额外工作的部分是处理 re-ordering。我会使用这样的东西,假设你已经在使用 jQuery:

// Iterate through each state
$('.states').each(function (i, el) {
    var state = $(this);
    var input = state.find('input.state');

    var nameState = input.attr('name');
    if (nameState != null) {
        input.attr('name', nameState.replace(new RegExp("States\[.*\]", 'gi'), '[' + i + ']'));
    }

    var idState = input.attr('id');
    if (idState != null) {
        input.attr('id', idState.replace(new RegExp("States_\d+"), i));
    }

    // Iterate through the cities associated with each state
    state.find('.cities').each(function (index, elem) {
        var inputs = $(this).find('input');

        inputs.each(function(){
            var cityInput = (this);

            var nameCity = cityInput.attr('name');
            if (nameCity != null) {
                cityInput.attr('name', nameCity.replace(new RegExp("Cities\[.*\]", 'gi'), '[' + index + ']'));
            }

            var idCity = cityInput.attr('id');
            if (idCity != null) {
                cityInput.attr('id', idCity.replace(new RegExp("Cities_\d+"), index));
            }
        });
    });
});

这最后一点可能需要一些调整,因为它未经测试,但它与我之前所做的类似。只要您视图中的项目是 added/edited/removed/moved.

,您就会调用它

我想出了自己的解决方案。这有点像 hack,但我觉得它比其他选择要好。其他解决方案和建议都涉及更改标记并添加 javascript 以同步添加的标记——我明确表示我不想在 OP 中这样做。如果说 <input /> 已经按照您希望的方式在 DOM 中排序,我觉得向 <input /> 名称添加索引是多余的。添加 javascript 只是需要维护的另一件事,并且通过线路发送了不必要的位。

无论如何 .. 我的解决方案涉及遍历原始请求正文。我之前没有意识到这基本上只是一个 url-encoded 查询字符串,并且在简单的 url-decode:

之后很容易使用
public class StatesBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        controllerContext.HttpContext.Request.InputStream.Seek(0, SeekOrigin.Begin);
        string urlEncodedFormData = new StreamReader(controllerContext.HttpContext.Request.InputStream).ReadToEnd();
        var decodedFormKeyValuePairs = urlEncodedFormData
            .Split('&')
            .Select(s => s.Split('='))
            .Where(kv => kv.Length == 2 && !string.IsNullOrEmpty(kv[0]) && !string.IsNullOrEmpty(kv[1]))
            .Select(kv => new { key = HttpUtility.UrlDecode(kv[0]), value = HttpUtility.UrlDecode(kv[1]) });
        var states = new List<State>();
        foreach (var kv in decodedFormKeyValuePairs)
        {
            if (kv.key == "stateName")
            {
                states.Add(new State { Name = kv.value, Cities = new List<City>() });
            }
            else if (kv.key == "cityName")
            {
                states.Last().Cities.Add(new City { Name = kv.value });
            }
            else if (kv.key == "cityPopulation")
            {
                states.Last().Cities.Last().Population = int.Parse(kv.value);
            }
            else
            {
                //key-value form field that can be ignored
            }
        }
        return states;
    }
}

这假设 (1) html 元素在 DOM 上正确排序,(2) 在 POST 请求正文中以相同顺序设置,并且 ( 3)以相同的顺序在服务器上的请求流中接收。据我了解,就我而言,这些都是有效的假设。

同样,这感觉像是一个 hack,而且看起来不太 MVC-y。但这对我有用。如果这恰好可以帮助其他人,那就太好了。