Scripts - one of the most common ways to make the application more flexible, with the ability to fix something right on the go. Of course, this approach has its drawbacks, you should always remember about the balance between flexibility and controllability. But in this article we will not talk “in general” about the pros and cons of using scripts, we will look at practical ways to implement this approach, and also present a library that provides a convenient infrastructure for adding scripts to applications written in the Spring Framework.
A few introductory words
When you want to add the ability to change the business logic in the application without recompiling and subsequent deployment, the scripts are one of the ways that comes to mind first. Often, the scripts appear not because it was intended, but because it happened. For example, in the specification there is a part of logic that is not completely clear right now, but in order not to spend an extra couple of days (and sometimes longer) on analysis, you can make an extension point and call the script stub. And then, of course, this script will be rewritten when the requirements become clear.
The method is not new, and its advantages and disadvantages are well-known: flexibility - you can change the logic on a running application and save time for redeployed, but, on the other hand, scripts are harder to test, hence, possible problems with security, performance, etc.
')
Those techniques, which will be discussed later, can be useful both to developers who already use scripts in their application, and to those who only think about it.
Nothing personal, only scripting
With JSR-233, scripting in Java has become very simple. There are enough scripting engines based on this API (Nashorn, JRuby, Jython, and some more), so adding a little scripting magic to the code is not a problem:
Map<String, Object> parameters = createParametersMap(); ScriptEngineManager manager = new ScriptEngineManager(); ScriptEngine scriptEngine = manager.getEngineByName("groovy"); Object result = scriptEngine.eval(script.getScriptAsString("discount.groovy"), new SimpleBindings(parameters));
Obviously, if such a code is scattered throughout the application, it will become incomprehensible into what. And, of course, if you have more than one script call in your application, then you need to make a separate class for working with them. Sometimes you can go even further and make special classes that will wrap the
evaluateGroovy()
calls into regular, typed Java methods. In these methods there will be a rather uniform service code, as in the example:
public BigDecimal applyCustomerDiscount(Customer customer, BigDecimal orderAmount) { Map<String, Object> params = new HashMap<>(); params.put("cust", customer); params.put("amount", orderAmount); return (BigDecimal)scripting.evalGroovy(getScriptSrc("discount.groovy"), params); }
This approach greatly increases the transparency when calling scripts from the application code - you can immediately see which parameters the script accepts, what type they are and what is returned. The main thing - do not forget to add to the standards of writing code a ban on calling scripts from non-typed methods!
We pump scripts
Despite the fact that scripts are easy, if you have a lot of them and you use them intensively, then there is a real chance to face performance problems. For example, if a bunch of groovy templates are used to generate reports and you run them at the same time, sooner or later this will become one of the bottlenecks in the application's performance.
Therefore, many frameworks make various add-ons over the standard API for improving performance, caching, monitoring execution, using different scripting languages ​​in one application, etc.
For example, in CUBA a rather clever
scripting engine was made that supports additional features, such as:
- Ability to write scripts in Java and Groovy
- Class cache to not re-compile scripts
- JMX bean to control the engine
All this, of course, improves performance and usability, but still the low-level engine remains low-level, and you still need to read the script text, pass parameters, and call the API to execute the script. So you need to still do some wrappers in each project to make development more efficient.
And it would be unfair not to mention GraalVM - an experimental engine that can execute programs in different languages ​​(JVM and non-JVM) and allows inserting
modules into these languages in
Java applications . I hope that Nashorn will go down in history sooner or later, and we will have the opportunity to write parts of the code in different languages ​​in one source. But this is only a dream.
Spring Framework: hard to refuse offer?
Spring has built-in support for script execution, built on the basis of the JDK API. In the
org.springframework.scripting.*
Package, you can find many useful classes — all so that you can conveniently use a low-level API for scripting in your application.
In addition, there is a higher level support, it is described in detail in the
documentation . In short, you need to make a class in a scripting language (for example, Groovy) and publish it as a bin via XML description:
<lang:groovy id="messenger" script-source="classpath:Messenger.groovy"> <lang:property name="message" value="I Can Do The Frug" /> </lang:groovy>
After a bin is published, it can be added to its classes using IoC. Spring provides automatic script update when changing text in a file, you can hang aspects on methods, etc.
It looks good, but you need to make “real” classes in order to publish them, you can’t write the usual function in the script. In addition, the scripts can be stored only in the file system, to use the database will have to climb inside Spring. Yes, and XML configuration, many consider obsolete, especially if the application is all on the annotations. This, of course, tastes, but it often has to be considered.
Scripts: difficulties and ideas
So, each solution has its price, and, if we talk about scripts in Java applications, then with the implementation of this technology, you may encounter some difficulties:
- Controllability. Often, script calls are scattered throughout the application, and with changes in the code, it is quite difficult to track calls to the necessary scripts.
- Ability to find call points. If something goes wrong in a particular script, then finding all its call points will be a problem, unless you use the search by file name or method calls
evaluateGroovy()
- Transparency. Writing a script is not an easy task in itself, and even more difficult is for those who call this script. It is necessary to remember how the input parameters are called, what data type they have and what is the result of the execution. Or every time to look at the source code of the script.
- Testing and updating - it is not always possible to test the script in the environment of the application code, and after uploading it to the “combat” server, you need to somehow be able to quickly roll back everything if something goes wrong.
It seems that wrapping script calls in Java methods will help to solve most of the above tasks. It is quite good if such classes can be published in the IoC container and call methods with normal, meaningful names in their services, instead of calling
eval(“disc_10_cl.groovy”)
from some utility class. Another plus is that the code becomes self-documenting, the developer doesn’t have to worry what algorithm lies behind the file name.
On top of that, if each script is associated with only one method, you can quickly find all call points in the application using the “Find Usages” menu from the IDE and understand the place of the script in each specific business logic algorithm.
Testing is simplified - it turns into “ordinary” testing of classes, using familiar frameworks, mocks and so on.
All of the above is very consonant with the idea mentioned at the beginning of the article - “special” classes for methods that are implemented by scripts. But what if you take one more step and hide the entire service code of the same type for invoking the script engines from the developer so that he doesn't even think about it (well, almost)?
Script Repositories - Concept
The idea is quite simple and should be familiar to those who have ever worked with Spring, especially with Spring JPA. What you need is to make a Java interface and call the script when calling its methods. In JPA, by the way, an identical approach is used - the call to CrudRepository is intercepted, a query is created based on the method name and parameters, which is then executed by the database engine.
What you need to implement the concept?
For starters, class level annotation, so that you can find the repository interface and make a bin based on it.
Also, probably, annotations on the methods of this interface will be useful in order to store the metadata needed to call the method. For example - where to get the script text and which engine to use.
A useful addition will be the ability to use methods with the implementation in the interface (aka default) - this code will work until the business analyst does not deduce a more complete version of the algorithm, and the developer does not make a script based on
this information. Or let the analyst script write, and the developer then simply copy it to the server. There are many options :-)
So, suppose that for an online store you need to make a service for calculating discounts based on a user profile. Right now it is not clear how to do this, but the business analyst swears that all registered users are entitled to a 10% discount, he will find out the rest within a week at the customer. Service is needed right tomorrow - the season after all. What might the code look like for such a case?
@ScriptRepository public interface PricingRepository { @ScriptMethod default BigDecimal applyCustomerDiscount(Customer customer, BigDecimal orderAmount) { return orderAmount.multiply(new BigDecimal("0.9")); } }
And then the algorithm itself, written, for example, on groovy, arrives, and there the discounts will be slightly different:
def age = 50 if ((Calendar.YEAR - customer.birthday.year) >= age) { return orderAmount.multiply(0.75) } else { return orderAmount.multiply(0.9) }
The purpose of all this is to give the developer the opportunity to write only the interface code and the script code, and not to bother with all these calls
getEngine
,
eval
and others. The library for working with scripts should do all the magic - intercept the method call of the interface, get the script text, substitute parameter values, get the necessary script engine, execute the script (or call the default method if there is no script text) and return the value. Ideally, in addition to code that has already been written, the program should have something like this:
@Service public class CustomerServiceBean implements CustomerService { @Inject private PricingRepository pricingRepository;
The challenge is readable, understandable, and to make it, you do not need to have any special skills.
These were ideas on the basis of which a small library for working with scripts was made. It is designed for Spring applications, this framework was used to create a library. It provides an extensible API for downloading scripts from various sources and executing them, which hides the chore of scripting engines.
How it works
For all interfaces marked with
@ScriptRepository
, when the Spring context is initialized, proxy objects are created using the
newProxyInstance
class's
newProxyInstance
method. These proxies are published in the Spring context as singleton bins, so you can declare a class field with an interface type and annotate it with
@Autowired
or
@Inject
. Exactly as planned.
Scanning and processing of script interfaces is activated using the
@EnableSriptRepositories
annotation, just as in Spring JPA or repositories for MongoDB are activated (
@EnableJpaRepositories
and
@EnableMongoRepositories
respectively). As parameters of the annotation, you need to specify an array with the names of the packages that you want to scan.
@Configuration @EnableScriptRepositories(basePackages = {"com.example", "com.sample"}) public class CoreConfig {
Methods need to be annotated with
@ScriptMethod
(there is also
@GroovyScript
and
@JavaScript
, with the appropriate specialization) to add metadata to invoke the script. Of course, default methods are supported in interfaces.
The general library device is shown in the diagram. Blue highlights the components that need to be developed, whites - which already exist in the library. The Spring icon indicates components that are available in the Spring context.
When the interface method is invoked (in fact, a proxy object), the call handler is launched, which in the context of the application searches for two beans: the provider that will search for the script text, and the executor who will actually execute the found text. Then the handler returns the result to the calling method.
The names of the provider and the executor
@ScriptMethod
specified in the
@ScriptMethod
annotation, and there you can also put a limit on the execution time of the method. Below is an example of library use code:
@ScriptRepository public interface PricingRepository { @ScriptMethod (providerBeanName = "resourceProvider", evaluatorBeanName = "groovyEvaluator", timeout = 100) default BigDecimal applyCustomerDiscount( @ScriptParam("cust") Customer customer, @ScriptParam("amount") BigDecimal orderAmount) { return orderAmount.multiply(new BigDecimal("0.9")); } }
You can see the
@ScriptParam
annotations - they are needed to specify the names of the parameters when transferring them to the script, since the Java compiler erases the original names from the source (there are ways to force it not to do it, but it’s better not to rely on it). You can not specify the names of the parameters, but in this case, the script will need to use “arg0”, “arg1”, which does not greatly improve readability.
By default, the library has providers for reading .groovy and .js files from disk and the corresponding artists, which are wrappers over the standard JSR-233 API. You can create your own beans for different sources of scripts and for different engines, for this you need to implement the appropriate interfaces:
ScriptProvider
and
SpringEvaluator
. The first interface uses
org.springframework.scripting.ScriptSource
and the second is
org.springframework.scripting.ScriptEvaluator
. The Spring API was used so that you could use ready-made classes if they already exist in the application.
The search for the provider and the contractor is done by name for more flexibility — you can replace the standard bins from the library in your application, calling your components with the same names.
Testing and Versioning
Since the scripts change frequently and easily, you need to have a way to somehow make sure that the changes do not break anything. The library is compatible with JUnit, the repository can simply be tested as a normal class as part of a unit or integration test. Mock libraries are also supported, in the tests for the library you can find an example of how to make a mock for the script repository method.
If you need versioning, you can create a provider that will read different versions of scripts from the file system, from the database or from Git, for example. So you can easily organize a rollback to the previous version of the script in case of problems on the main server.
Total
The presented library will help to organize scripts in the Spring application:
- The developer will always have information about what parameters the scripts need and what is returned. And if the interface methods are named meaningfully, then what the script does.
- Providers and executors will help to keep the code for getting scripts and interaction with the script engine in one place and these calls will not be scattered throughout the application code.
- All script calls can be easily found using Find Usages.
Spring Boot auto configuration, unit testing, mock'i ​​is supported. You can get information about the “script” methods and their parameters through the API. You can also wrap the result of the execution with a special ScriptResult object, in which there will be a result or an exception instance, if you do not want to bother with try ... catch when invoking scripts. XML configuration is supported if it is required for one reason or another. And finally - you can specify a timeout for the execution of the script method, if the need arises.
Library sources are here.