简体   繁体   中英

ASP.NET Core custom validation creates new instance of model

I'm playing with ASP.NET Core and trying to come up with a UI for a simple word game. You receive a randomly generated long word, and you're expected to submit shorter words from letters provided by the long word.

The application doesn't use a repository of any kind yet, and it simply stores a model instance as a static field in the controller for now.

I'm currently facing a problem where every time a new submitted word is validated, a new game instance is created, which naturally guarantees to render a validation error, because each game provides a new long word.

I must be misunderstanding something about the way model validation works but debugging doesn't give me any better clues than just showing a validation context that comes in with a new long word every time.

I'm stuck, please help.

Here's the controller:

public class HomeController : Controller
{
    private static WordGameModel _model;

    public IActionResult Index()
    {
        if (_model == null)
        {
            _model = new WordGameModel();
        }
        return View(_model);
    }

    [HttpPost]
    public IActionResult Index(WordGameModel incomingModel)
    {
        if (ModelState.IsValid)
        {
            _model.Words.Add(incomingModel.ContainedWordCandidate);
            return RedirectToAction(nameof(Index), _model);
        }
        return View(_model);
    }
}

Game model:

public class WordGameModel
{
    public WordGameModel()
    {
        if (DictionaryModel.Dictionary == null) DictionaryModel.LoadDictionary();
        LongWord = DictionaryModel.GetRandomLongWord();
        Words = new List<string>();
    }

    public string LongWord { get; set; }
    public List<string> Words { get; set; }

    [Required(ErrorMessage = "Empty word is not allowed")]
    [MinLength(5, ErrorMessage = "A word shouldn't be shorter than 5 characters")]
    [MatchesLettersInLongWord]
    [NotSubmittedPreviously]
    public string ContainedWordCandidate { get; set; }

    public bool WordWasNotSubmittedPreviously() => !Words.Contains(ContainedWordCandidate);
    public bool WordMatchesLettersInLongWord()
    {
        if (string.IsNullOrWhiteSpace(ContainedWordCandidate)) return false;
        return ContainedWordCandidate.All(letter => LongWord.Contains(letter));
    }
}

A custom validation attribute where validation fails:

internal class MatchesLettersInLongWord : ValidationAttribute
{
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        WordGameModel model = (WordGameModel) validationContext.ObjectInstance;

        if (model.WordMatchesLettersInLongWord()) return ValidationResult.Success;

        return new ValidationResult("The submitted word contains characters that the long word doesn't contain");
    }
}

View:

@model WordGameModel

<div class="row">
    <div class="col-md-12">
        <h2>@Model.LongWord</h2>
    </div>
</div>

<div class="row">
    <div class="col-md-6">
        <form id="wordForm" method="post">
            <div>
                <input id="wordInput" asp-for="ContainedWordCandidate"/>
                <input type="submit" name="Add" value="Add"/>
                <span asp-validation-for="ContainedWordCandidate"></span>
            </div>

        </form>
    </div>
</div>

<div class="row">
    <div class="col-md-6">
        <ul>
            @foreach (var word in @Model.Words)
            {
                <li>@word</li>
            }
        </ul>
    </div>
</div>

Thanks.

Your view would need to include a hidden input for LongWord , so that in the POST method so that after your constructor is called by the ModelBinder, the LongWord is set based on the form value (ie the value you sent to the view)

<form id="wordForm" method="post">
    <div>
        <input type="hidden" asp-for="LongWord" /> // add hidden input
        <input id="wordInput" asp-for="ContainedWordCandidate"/>
        <input type="submit" name="Add" value="Add"/>
        <span asp-validation-for="ContainedWordCandidate"></span>
    </div>
</form>

As a side note, in your post method it should be just return RedirectToAction(nameof(Index)); - the GET method does not (and should not) have a parameter for the model, so there is no point passing it (and it would just create an ugly query string anyway)

Don't use a static field in the controller to store your words. It's not a good idea to keep state in the controller because, as stated in another answer, the controller is transient and a new one is created for every request. So, even though your static variable should still be available, it's not a good to keep it with the controller. Also you want to keep your model clean ie don't put any business/game logic into it. Use a different class for that. Only use the model to ensure the values are valid ie minimum length, required etc.

A better solution for your problem would be to create a singleton service to store the data. As a singleton, only one service would be created for the lifetime of your application. You can use Dependency Injection to inject it into your controller and use it for every request, knowing that it will be the same instance of the service for every request.

For example:

public interface IWordService
{
    IEnumerable<String> Words { get; }

    bool WordWasNotSubmittedPreviously(string word);

    bool WordMatchesLettersInLongWord(string longWord, string containedWordCandidate);

    void AddWordToList(string word);
}

public class WordService : IWordService
{
    private List<string> _words;

    public IEnumerable<string> Words => _words;

    public WordService()
    {
        _words = new List<string>();
    }

    public bool WordWasNotSubmittedPreviously(string containedWordCandidate) => !_words.Contains(containedWordCandidate);

    public bool WordMatchesLettersInLongWord(string longWord, string containedWordCandidate)
    {
        if (string.IsNullOrWhiteSpace(containedWordCandidate)) return false;
        return containedWordCandidate.All(letter => longWord.Contains(letter));
    }

    public void AddWordToList(string word)
    {
        _words.Add(word);
    }
}

This service does all the work your ValidationAttribute did but we can use Dependency Injection to make sure we only create one for the whole application.

In you Startup.cs add this to the ConfigureServices method:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IWordService, WordService>();

    ....
}

Now we can inject this into our controller and, because we have registered it as a singleton we will get the same instance every time, even though we get a different instance of the controller:

public class HomeController : Controller
{
    private readonly IWordService _wordService;

    public HomeController(IWordService wordService)
    {
        _wordService = wordService;
    }

    [HttpPost]
    public IActionResult Index(WordGameModel incomingModel)
    {
        if (ModelState.IsValid)
        {
            // Use the `_wordService instance to perform your checks and validation
            ...
        }

        ...
    }
}

I've left the actual use of the _wordService for you to implement :-) but it should be fairly simple.

You can read more about Dependency Injection (DI) here

And also the ConfigureServices method here

With every request to your action in the HomeController the mvc framework creates a new instance of the controller for that. After returning the response it will dispose the controller.

Controller fields and objects can not be shared among the requests. In your case with every action called, your WordGameModel will be instantiated again and it's constructor creates a new Long word.
You can save your object in some database for every user to provide the functionality.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM