Added the concept of a StepRegistry that tracks the Step instance attached to a Job.
- Added a simple MapStepRegistry that keeps track of the step in a simple map per job - MapJobRegistry implements StepRegistry as well using the delegate pattern - DefaultJobLoader makes sure to initialize both registries now. Auto-detects that the JobRegistry implements StepRegistry - JobSupport now implements StepLocator so that such job can be loaded by the DefaultJobLoader - Also registering any "detached" step available for a particular job to that job if that wasn't done yet. Since a partition handler may link to any step by name, the step registry must be filled with any step that are available in the context of a job. If each job is located in its own application context, this is just fine. If more than one job is defined in the same context, the step registry may be able to return a step that is not related to that job but it shouldn't hurt as this happens only if a component is explicitly asking to execute a step by name for a particular job.
This commit is contained in:
committed by
Michael Minella
parent
b345e1c072
commit
00ee45854e
@@ -16,82 +16,234 @@
|
||||
package org.springframework.batch.core.configuration.support;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.JobExecution;
|
||||
import org.springframework.batch.core.JobParametersIncrementer;
|
||||
import org.springframework.batch.core.JobParametersValidator;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.configuration.DuplicateJobException;
|
||||
import org.springframework.batch.core.configuration.JobFactory;
|
||||
import org.springframework.batch.core.configuration.JobRegistry;
|
||||
import org.springframework.batch.core.configuration.StepRegistry;
|
||||
import org.springframework.batch.core.launch.NoSuchJobException;
|
||||
import org.springframework.batch.core.step.NoSuchStepException;
|
||||
import org.springframework.batch.core.step.StepLocator;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
/**
|
||||
* @author Dave Syer
|
||||
*
|
||||
* @author Stephane Nicoll
|
||||
*/
|
||||
public class DefaultJobLoaderTests {
|
||||
|
||||
private JobRegistry registry = new MapJobRegistry();
|
||||
/**
|
||||
* The name of the job as defined in the test context used in this test.
|
||||
*/
|
||||
private static final String TEST_JOB_NAME = "test-job";
|
||||
|
||||
private DefaultJobLoader jobLoader = new DefaultJobLoader(registry);
|
||||
/**
|
||||
* The name of the step as defined in the test context used in this test.
|
||||
*/
|
||||
private static final String TEST_STEP_NAME = "test-step";
|
||||
|
||||
private JobRegistry jobRegistry = new MapJobRegistry();
|
||||
private StepRegistry stepRegistry = new MapStepRegistry();
|
||||
|
||||
private DefaultJobLoader jobLoader = new DefaultJobLoader(jobRegistry, stepRegistry);
|
||||
|
||||
@Test
|
||||
public void testLoadWithExplicitName() throws Exception {
|
||||
GenericApplicationContextFactory factory = new GenericApplicationContextFactory(new ByteArrayResource(
|
||||
JOB_XML.getBytes()));
|
||||
jobLoader.load(factory);
|
||||
assertEquals(1, registry.getJobNames().size());
|
||||
assertEquals(1, jobRegistry.getJobNames().size());
|
||||
jobLoader.reload(factory);
|
||||
assertEquals(1, registry.getJobNames().size());
|
||||
assertEquals(1, jobRegistry.getJobNames().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReload() throws Exception {
|
||||
@Test
|
||||
public void createWithBothRegistries() {
|
||||
final DefaultJobLoader loader = new DefaultJobLoader();
|
||||
loader.setJobRegistry(jobRegistry);
|
||||
loader.setStepRegistry(stepRegistry);
|
||||
|
||||
loader.afterPropertiesSet();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createWithJobRegistryWhichIsAStepRegistry() {
|
||||
final DefaultJobLoader loader = new DefaultJobLoader();
|
||||
loader.setJobRegistry(jobRegistry);
|
||||
|
||||
loader.afterPropertiesSet();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createWithSimpleJobRegistry() {
|
||||
final DefaultJobLoader loader = new DefaultJobLoader();
|
||||
loader.setJobRegistry(new JobRegistryMock());
|
||||
|
||||
try {
|
||||
loader.afterPropertiesSet();
|
||||
fail("Should have failed to create job loader without a step registry (" +
|
||||
"and the job registry could not fulfill that role)");
|
||||
} catch (IllegalArgumentException e) {
|
||||
// OK
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRegistryUpdated() throws DuplicateJobException {
|
||||
GenericApplicationContextFactory factory = new GenericApplicationContextFactory(
|
||||
new ClassPathResource("trivial-context.xml", getClass()));
|
||||
jobLoader.load(factory);
|
||||
assertEquals(1, jobRegistry.getJobNames().size());
|
||||
assertStepExist(TEST_JOB_NAME, TEST_STEP_NAME);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMultipleJobsInTheSameContext() throws DuplicateJobException {
|
||||
GenericApplicationContextFactory factory = new GenericApplicationContextFactory(
|
||||
new ClassPathResource("job-context-with-steps.xml", getClass()));
|
||||
jobLoader.load(factory);
|
||||
assertEquals(2, jobRegistry.getJobNames().size());
|
||||
assertStepExist("job1", "step11", "step12");
|
||||
assertStepDoNotExist("job1", "step21", "step22");
|
||||
assertStepExist("job2", "step21", "step22");
|
||||
assertStepDoNotExist("job2", "step11", "step12");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMultipleJobsInTheSameContextWithSeparateSteps() throws DuplicateJobException {
|
||||
GenericApplicationContextFactory factory = new GenericApplicationContextFactory(
|
||||
new ClassPathResource("job-context-with-separate-steps.xml", getClass()));
|
||||
jobLoader.load(factory);
|
||||
assertEquals(2, jobRegistry.getJobNames().size());
|
||||
assertStepExist("job1", "step11", "step12", "genericStep1", "genericStep2");
|
||||
assertStepDoNotExist("job1", "step21", "step22");
|
||||
assertStepExist("job2", "step21", "step22", "genericStep1", "genericStep2");
|
||||
assertStepDoNotExist("job2", "step11", "step12");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReload() throws Exception {
|
||||
GenericApplicationContextFactory factory = new GenericApplicationContextFactory(new ClassPathResource(
|
||||
"trivial-context.xml", getClass()));
|
||||
jobLoader.load(factory);
|
||||
assertEquals(1, registry.getJobNames().size());
|
||||
jobLoader.reload(factory);
|
||||
assertEquals(1, registry.getJobNames().size());
|
||||
}
|
||||
jobLoader.load(factory);
|
||||
assertEquals(1, jobRegistry.getJobNames().size());
|
||||
assertStepExist(TEST_JOB_NAME, TEST_STEP_NAME);
|
||||
jobLoader.reload(factory);
|
||||
assertEquals(1, jobRegistry.getJobNames().size());
|
||||
assertStepExist(TEST_JOB_NAME, TEST_STEP_NAME);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReloadWithAutoRegister() throws Exception {
|
||||
@Test
|
||||
public void testReloadWithAutoRegister() throws Exception {
|
||||
GenericApplicationContextFactory factory = new GenericApplicationContextFactory(new ClassPathResource(
|
||||
"trivial-context-autoregister.xml", getClass()));
|
||||
jobLoader.load(factory);
|
||||
assertEquals(1, registry.getJobNames().size());
|
||||
jobLoader.reload(factory);
|
||||
assertEquals(1, registry.getJobNames().size());
|
||||
}
|
||||
jobLoader.load(factory);
|
||||
assertEquals(1, jobRegistry.getJobNames().size());
|
||||
assertStepExist(TEST_JOB_NAME, TEST_STEP_NAME);
|
||||
jobLoader.reload(factory);
|
||||
assertEquals(1, jobRegistry.getJobNames().size());
|
||||
assertStepExist(TEST_JOB_NAME, TEST_STEP_NAME);
|
||||
}
|
||||
|
||||
private static final String JOB_XML = String
|
||||
.format("<beans xmlns='http://www.springframework.org/schema/beans' xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance' "
|
||||
+ "xsi:schemaLocation='http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.1.xsd'><bean class='%s$StubJob'/></beans>",
|
||||
DefaultJobLoaderTests.class.getName());
|
||||
protected void assertStepExist(String jobName, String... stepNames) {
|
||||
for (String stepName : stepNames) {
|
||||
try {
|
||||
stepRegistry.getStep(jobName, stepName);
|
||||
} catch (NoSuchJobException e) {
|
||||
fail("Job with name [" + jobName + "] should have been found.");
|
||||
} catch (NoSuchStepException e) {
|
||||
fail("Step with name [" + stepName + "] for job [" + jobName + "] should have been found.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class StubJob implements Job {
|
||||
protected void assertStepDoNotExist(String jobName, String... stepNames) {
|
||||
for (String stepName : stepNames) {
|
||||
try {
|
||||
final Step step = stepRegistry.getStep(jobName, stepName);
|
||||
fail("Step with name [" + stepName + "] for job [" + jobName + "] should " +
|
||||
"not have been found but got [" + step + "]");
|
||||
} catch (NoSuchJobException e) {
|
||||
fail("Job with name [" + jobName + "] should have been found.");
|
||||
} catch (NoSuchStepException e) {
|
||||
// OK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final String JOB_XML = String
|
||||
.format(
|
||||
"<beans xmlns='http://www.springframework.org/schema/beans' xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance' "
|
||||
+ "xsi:schemaLocation='http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd'><bean class='%s$StubJob'/></beans>",
|
||||
DefaultJobLoaderTests.class.getName());
|
||||
|
||||
public static class StubJob implements Job, StepLocator {
|
||||
|
||||
@Override
|
||||
public void execute(JobExecution execution) {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JobParametersIncrementer getJobParametersIncrementer() {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "job";
|
||||
}
|
||||
return "job";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRestartable() {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JobParametersValidator getJobParametersValidator() {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@Override
|
||||
public Collection<String> getStepNames() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Step getStep(String stepName) throws NoSuchStepException {
|
||||
throw new NoSuchStepException("Step [" + stepName + "] does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
private static class JobRegistryMock implements JobRegistry {
|
||||
@Override
|
||||
public void register(JobFactory jobFactory) throws DuplicateJobException {
|
||||
// dummy
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregister(String jobName) {
|
||||
// dummy
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> getJobNames() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Job getJob(String name) throws NoSuchJobException {
|
||||
throw new NoSuchJobException("Mock implementation does not hold any job.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package org.springframework.batch.core.configuration.support;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.configuration.StepRegistry;
|
||||
import org.springframework.batch.core.launch.NoSuchJobException;
|
||||
import org.springframework.batch.core.step.NoSuchStepException;
|
||||
import org.springframework.batch.core.step.tasklet.TaskletStep;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
|
||||
/**
|
||||
* @author Sebastien Gerard
|
||||
*/
|
||||
public class MapStepRegistryTests {
|
||||
|
||||
private static final String EXCEPTION_NOT_THROWN_MSG = "An exception should have been thrown";
|
||||
|
||||
@Test
|
||||
public void registerStepEmptyCollection() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
launchRegisterGetRegistered(stepRegistry, "myJob", getStepCollection());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registerStepNullJobName() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
try {
|
||||
stepRegistry.register(null, new HashSet<Step>());
|
||||
Assert.fail(EXCEPTION_NOT_THROWN_MSG);
|
||||
} catch (IllegalArgumentException e) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registerStepNullSteps() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
try {
|
||||
stepRegistry.register("fdsfsd", null);
|
||||
Assert.fail(EXCEPTION_NOT_THROWN_MSG);
|
||||
} catch (IllegalArgumentException e) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registerStepGetStep() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
launchRegisterGetRegistered(stepRegistry, "myJob",
|
||||
getStepCollection(
|
||||
createStep("myStep"),
|
||||
createStep("myOtherStep"),
|
||||
createStep("myThirdStep")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getJobNotRegistered() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
final String aStepName = "myStep";
|
||||
launchRegisterGetRegistered(stepRegistry, "myJob",
|
||||
getStepCollection(
|
||||
createStep(aStepName),
|
||||
createStep("myOtherStep"),
|
||||
createStep("myThirdStep")
|
||||
));
|
||||
|
||||
assertJobNotRegistered(stepRegistry, "a ghost");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getJobNotRegisteredNoRegistration() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
assertJobNotRegistered(stepRegistry, "a ghost");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getStepNotRegistered() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
final String jobName = "myJob";
|
||||
launchRegisterGetRegistered(stepRegistry, jobName,
|
||||
getStepCollection(
|
||||
createStep("myStep"),
|
||||
createStep("myOtherStep"),
|
||||
createStep("myThirdStep")
|
||||
));
|
||||
|
||||
assertStepNameNotRegistered(stepRegistry, jobName, "fsdfsdfsdfsd");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registerRegisterAgainAndGet() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
final String jobName = "myJob";
|
||||
final Collection<Step> stepsFirstRegistration = getStepCollection(
|
||||
createStep("myStep"),
|
||||
createStep("myOtherStep"),
|
||||
createStep("myThirdStep")
|
||||
);
|
||||
|
||||
// first registration
|
||||
launchRegisterGetRegistered(stepRegistry, jobName, stepsFirstRegistration);
|
||||
|
||||
// register again the job
|
||||
launchRegisterGetRegistered(stepRegistry, jobName,
|
||||
getStepCollection(
|
||||
createStep("myFourthStep"),
|
||||
createStep("lastOne")
|
||||
));
|
||||
|
||||
assertStepsNotRegistered(stepRegistry, jobName, stepsFirstRegistration);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getStepNullJobName() throws NoSuchJobException {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
try {
|
||||
stepRegistry.getStep(null, "a step");
|
||||
Assert.fail(EXCEPTION_NOT_THROWN_MSG);
|
||||
} catch (IllegalArgumentException e) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getStepNullStepName() throws NoSuchJobException {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
final String stepName = "myStep";
|
||||
launchRegisterGetRegistered(stepRegistry, "myJob", getStepCollection(createStep(stepName)));
|
||||
|
||||
try {
|
||||
stepRegistry.getStep(null, stepName);
|
||||
Assert.fail(EXCEPTION_NOT_THROWN_MSG);
|
||||
} catch (IllegalArgumentException e) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registerStepUnregisterJob() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
final Collection<Step> steps = getStepCollection(
|
||||
createStep("myStep"),
|
||||
createStep("myOtherStep"),
|
||||
createStep("myThirdStep")
|
||||
);
|
||||
|
||||
final String jobName = "myJob";
|
||||
launchRegisterGetRegistered(stepRegistry, jobName, steps);
|
||||
|
||||
stepRegistry.unregisterStepsFromJob(jobName);
|
||||
assertJobNotRegistered(stepRegistry, jobName);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unregisterJobNameNull() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
try {
|
||||
stepRegistry.unregisterStepsFromJob(null);
|
||||
Assert.fail(EXCEPTION_NOT_THROWN_MSG);
|
||||
} catch (IllegalArgumentException e) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unregisterNoRegistration() {
|
||||
final StepRegistry stepRegistry = createRegistry();
|
||||
|
||||
assertJobNotRegistered(stepRegistry, "a job");
|
||||
}
|
||||
|
||||
protected StepRegistry createRegistry() {
|
||||
return new MapStepRegistry();
|
||||
}
|
||||
|
||||
protected Step createStep(String stepName) {
|
||||
return new TaskletStep(stepName);
|
||||
}
|
||||
|
||||
protected Collection<Step> getStepCollection(Step... steps) {
|
||||
return Arrays.asList(steps);
|
||||
}
|
||||
|
||||
protected void launchRegisterGetRegistered(StepRegistry stepRegistry, String jobName, Collection<Step> steps) {
|
||||
stepRegistry.register(jobName, steps);
|
||||
assertStepsRegistered(stepRegistry, jobName, steps);
|
||||
}
|
||||
|
||||
protected void assertJobNotRegistered(StepRegistry stepRegistry, String jobName) {
|
||||
try {
|
||||
stepRegistry.getStep(jobName, "a step");
|
||||
Assert.fail(EXCEPTION_NOT_THROWN_MSG);
|
||||
} catch (NoSuchJobException e) {
|
||||
}
|
||||
}
|
||||
|
||||
protected void assertStepsRegistered(StepRegistry stepRegistry, String jobName, Collection<Step> steps) {
|
||||
for (Step step : steps) {
|
||||
try {
|
||||
stepRegistry.getStep(jobName, step.getName());
|
||||
} catch (NoSuchJobException e) {
|
||||
Assert.fail("Unexpected exception " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void assertStepsNotRegistered(StepRegistry stepRegistry, String jobName, Collection<Step> steps) {
|
||||
for (Step step : steps) {
|
||||
assertStepNameNotRegistered(stepRegistry, jobName, step.getName());
|
||||
}
|
||||
}
|
||||
|
||||
protected void assertStepNameNotRegistered(StepRegistry stepRegistry, String jobName, String stepName) {
|
||||
try {
|
||||
stepRegistry.getStep(jobName, stepName);
|
||||
Assert.fail(EXCEPTION_NOT_THROWN_MSG);
|
||||
} catch (NoSuchJobException e) {
|
||||
Assert.fail("Unexpected exception");
|
||||
} catch (NoSuchStepException e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,10 @@
|
||||
|
||||
package org.springframework.batch.core.job;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.JobExecution;
|
||||
@@ -25,6 +27,8 @@ import org.springframework.batch.core.JobParametersIncrementer;
|
||||
import org.springframework.batch.core.JobParametersValidator;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.UnexpectedJobExecutionException;
|
||||
import org.springframework.batch.core.step.NoSuchStepException;
|
||||
import org.springframework.batch.core.step.StepLocator;
|
||||
import org.springframework.beans.factory.BeanNameAware;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
@@ -37,9 +41,9 @@ import org.springframework.util.ClassUtils;
|
||||
* @author Lucas Ward
|
||||
* @author Dave Syer
|
||||
*/
|
||||
public class JobSupport implements BeanNameAware, Job {
|
||||
public class JobSupport implements BeanNameAware, Job, StepLocator {
|
||||
|
||||
private List<Step> steps = new ArrayList<Step>();
|
||||
private Map<String, Step> steps = new HashMap<String, Step>();
|
||||
|
||||
private String name;
|
||||
|
||||
@@ -110,11 +114,13 @@ public class JobSupport implements BeanNameAware, Job {
|
||||
|
||||
public void setSteps(List<Step> steps) {
|
||||
this.steps.clear();
|
||||
this.steps.addAll(steps);
|
||||
for (Step step : steps) {
|
||||
this.steps.put(step.getName(), step);
|
||||
}
|
||||
}
|
||||
|
||||
public void addStep(Step step) {
|
||||
this.steps.add(step);
|
||||
this.steps.put(step.getName(), step);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -172,4 +178,15 @@ public class JobSupport implements BeanNameAware, Job {
|
||||
return jobParametersValidator;
|
||||
}
|
||||
|
||||
public Collection<String> getStepNames() {
|
||||
return steps.keySet();
|
||||
}
|
||||
|
||||
public Step getStep(String stepName) throws NoSuchStepException {
|
||||
final Step step = steps.get(stepName);
|
||||
if (step == null) {
|
||||
throw new NoSuchStepException("Step ["+stepName+"] does not exist for job with name ["+getName()+"]");
|
||||
}
|
||||
return step;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user